Strands Agents + Lambda + API Gateway で、認証・ストリーミング・セッション管理付きのAPI を作ってみた

Strands Agents + Lambda + API Gateway で、認証・ストリーミング・セッション管理付きのAPI を作ってみた

Strands AgentsをLambda + API Gatewayでデプロイし、S3セッション管理とAgentCore Memory連携を試してみました!
2026.07.25

はじめに

こんにちは、スーパーマーケットが好きなコンサル部の神野です。

みなさん Strands Agents で AI エージェント作っていますか?
Strands Agents で作ったエージェントのデプロイ先といえば AgentCore が定番ですが、公式ドキュメントを見ると Lambda も選択肢として挙げられています。

https://strandsagents.com/docs/user-guide/deploy/deploy_to_aws_lambda/

API Gateway も 2025 年 11 月にレスポンスストリーミングに対応しました。であれば Lambda + API Gateway でストリーミング対応のエージェント API もサクッと作れるのでは?と気になり、試してみました!

今回のコードは GitHub で公開しています。

https://github.com/yuu551/lambda-api-strands

リポジトリの v1 ブランチに記事本文のコードがあります。clone 後は pnpm install && pnpm deploy で一括デプロイできます。

実行コマンド
git clone -b v1 https://github.com/yuu551/lambda-api-strands.git
cd lambda-api-strands
pnpm install
pnpm deploy

前提

執筆時に動作確認したバージョンは下記です。正確な依存関係はリポジトリの lockfile を参照してください。

項目 バージョン・設定
リージョン us-east-1
Node.js 24.16.0
Python 3.13(AgentCore Memory 版のみ)
パッケージ管理 pnpm 11.11.0
aws-cdk-lib 2.261.0
@strands-agents/sdk 1.10.0
hono 4.12.31
zod 4.4.3
@aws-cdk/aws-lambda-python-alpha 2.262.0-alpha.0(AgentCore Memory 版のみ)
モデル Claude Haiku 4.5

アーキテクチャ

この記事で最終的に作るものは、全体としてはこうなります。

バックエンドのアーキテクチャ図。クライアントは Cognito で ID トークンを取得して API Gateway を呼び、POST /api/invoke は Node.js Lambda と S3 セッションバケット、POST /api/invoke-memory は Python Lambda と AgentCore Memory へつながる。どちらの Lambda も Amazon Bedrock を呼び出す

前半では ① の S3 SessionManager 版だけを作ります。構成はシンプルで、登場するリソースは Lambda 1 個と API Gateway、Cognito、S3 バケットだけです。後半で AgentCore Memory を使う 2 本目の Lambda を足しますが、まずはこの最小構成から始めます。

クライアントは事前に Cognito で認証して ID トークンを取得し、Authorization ヘッダーに付けて API を呼び出します。
API Gateway の Cognito オーソライザーがトークンを検証するので、Lambda 側に認証ロジックを書く必要はありません。

Hono を挟む理由

Lambda でストリーミングするだけなら awslambda.streamifyResponse を直接使っても書けます。
Hono を挟むとストリーミング処理を簡潔に書け、ルーティングも扱えます。個人的にも好きなフレームワークなので、今回は Hono を使いました!

Hono を使った類似の実装は、弊社ブログの次の記事でも紹介しています。必要に応じてご参照ください。

https://dev.classmethod.jp/articles/shuntaka-cdk-hono-lambda-web-adapter-apigateway-15/

実装

今回は下記のような構成で実装しました!思ったよりもシンプルになりました。

lambda-api-strands/
├── bin/
│   └── app.ts              # CDK エントリーポイント
├── lib/
│   └── agent-api-stack.ts   # スタック定義
├── lambda/
│   └── handler.ts           # Hono + Strands Agents(エージェント本体)
├── cdk.json
├── package.json
└── tsconfig.json

プロジェクトのセットアップ

まず pnpm で依存関係をインストールします。

実行コマンド
pnpm init
pnpm add aws-cdk-lib constructs @strands-agents/sdk @aws-sdk/client-s3 hono zod @aws-cdk/aws-lambda-python-alpha
pnpm add -D aws-cdk typescript tsx esbuild @types/node

Lambda ハンドラー

Hono + Strands Agents + S3 SessionManager を組み合わせたハンドラーを実装していきます。

lambda/handler.ts
import { Hono } from 'hono'
import { streamHandle } from 'hono/aws-lambda'
import { streamText } from 'hono/streaming'
import { Agent, BedrockModel, SessionManager, tool } from '@strands-agents/sdk'
import { S3Storage } from '@strands-agents/sdk/storage'
import z from 'zod'

type Bindings = {
  event: {
    requestContext: {
      authorizer: {
        claims: { sub: string; email?: string }
      }
    }
  }
}

const SYSTEM_PROMPT = `あなたは日本語で応答する親切なアシスタントです。
現在時刻が必要な質問には get_current_time ツールを使って正確に回答してください。
回答は簡潔にまとめてください。`

const getCurrentTime = tool({
  name: 'get_current_time',
  description: '日本時間(JST)の現在時刻を返します。',
  inputSchema: z.object({}),
  callback: () =>
    new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }),
})

const app = new Hono<{ Bindings: Bindings }>()

app.post('/invoke', async (c) => {
  const { prompt, sessionId } = await c.req.json<{ prompt: string; sessionId?: string }>()
  if (!prompt) {
    return c.json({ error: 'prompt is required' }, 400)
  }

  // Cognito の JWT claims からユーザー固有の sub を取得
  const userSub = c.env.event.requestContext.authorizer.claims.sub

  const sessionManager = new SessionManager({
    sessionId: sessionId ?? 'default',
    // ユーザーごとに S3 プレフィックスを分離
    storage: new S3Storage(process.env.SESSION_BUCKET!, {
      prefix: `sessions/${userSub}/`,
    }),
  })

  const agent = new Agent({
    model: new BedrockModel({ modelId: process.env.MODEL_ID }),
    systemPrompt: SYSTEM_PROMPT,
    tools: [getCurrentTime],
    sessionManager,
    printer: false,
  })

  return streamText(c, async (stream) => {
    for await (const chunk of agent.stream(prompt)) {
      if (
        chunk.type === 'modelStreamUpdateEvent' &&
        chunk.event.type === 'modelContentBlockDeltaEvent' &&
        chunk.event.delta.type === 'textDelta'
      ) {
        await stream.write(chunk.event.delta.text)
      }
    }
  })
})

export const handler = streamHandle(app)

Cognito の ID トークンには、そのユーザーを一意に表す sub という値が入っています。API Gateway のオーソライザーはトークンを検証したあと、中身をデコードして Lambda のイベントに載せてくれます。つまり Lambda 側はトークンを自分で解析する必要がなく、c.env.event.requestContext.authorizer.claims.sub を読むだけでログイン中のユーザー情報を取得できます。

この sub を S3 のキーの先頭に埋め込むことで、保存先が sessions/{sub}/{sessionId}/... に分割し、誰かが他人と同じ sessionId を指定してきても、sub が違えば別のパスになるので、会話履歴が混ざらないようにしています。

ストリーミングは Hono の streamText() の中で 処理しています。
イベントは二重構造で、外側の modelStreamUpdateEvent の中に modelContentBlockDeltaEvent が入っており、delta.typetextDelta のものがテキストの増分です。これを stream.write() に渡せばクライアントへ逐次返却します。

セッション管理は SessionManager に S3Storage を設定して実現できます。リクエストボディの sessionId で会話を識別し、同じ sessionId なら前回の会話履歴を自動復元してくれます。DynamoDB のテーブル設計も自前のセッション管理コードも不要です。簡単に実装できて良いですね。

CDK スタック

本体のスタック定義はこうしました。

lib/agent-api-stack.ts
import * as cdk from 'aws-cdk-lib'
import * as apigateway from 'aws-cdk-lib/aws-apigateway'
import * as cognito from 'aws-cdk-lib/aws-cognito'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as lambda from 'aws-cdk-lib/aws-lambda'
import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs'
import * as s3 from 'aws-cdk-lib/aws-s3'
import { Construct } from 'constructs'

const MODEL_ID = 'global.anthropic.claude-haiku-4-5-20251001-v1:0'

export class AgentApiStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    // Cognito User Pool
    const userPool = new cognito.UserPool(this, 'AgentUserPool', {
      selfSignUpEnabled: false,
      signInAliases: { email: true },
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    })
    const userPoolClient = userPool.addClient('AgentApiClient', {
      authFlows: { userPassword: true },
    })

    // セッション保存用 S3 バケット
    const sessionBucket = new s3.Bucket(this, 'SessionBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    })

    // Lambda(Hono + Strands Agents TypeScript SDK)
    const agentFunction = new NodejsFunction(this, 'AgentFunction', {
      entry: 'lambda/handler.ts',
      handler: 'handler',
      runtime: lambda.Runtime.NODEJS_24_X,
      architecture: lambda.Architecture.ARM_64,
      timeout: cdk.Duration.minutes(5),
      memorySize: 512,
      environment: {
        MODEL_ID,
        SESSION_BUCKET: sessionBucket.bucketName,
      },
      bundling: {
        format: OutputFormat.ESM,
        banner:
          "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
      },
    })
    sessionBucket.grantReadWrite(agentFunction)
    agentFunction.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'],
        resources: ['*'],
      }),
    )

    // API Gateway (REST API) + Cognito オーソライザー + ストリーミング
    const api = new apigateway.RestApi(this, 'AgentApi', {
      restApiName: 'strands-agent-api',
      deployOptions: { stageName: 'v1' },
    })
    const authorizer = new apigateway.CognitoUserPoolsAuthorizer(this, 'AgentApiAuthorizer', {
      cognitoUserPools: [userPool],
    })
    const methodOptions: apigateway.MethodOptions = {
      authorizer,
      authorizationType: apigateway.AuthorizationType.COGNITO,
    }

    // POST /invoke: ストリーミングで応答
    api.root.addResource('invoke').addMethod(
      'POST',
      new apigateway.LambdaIntegration(agentFunction, {
        responseTransferMode: apigateway.ResponseTransferMode.STREAM,
        timeout: cdk.Duration.minutes(5),
      }),
      methodOptions,
    )

    new cdk.CfnOutput(this, 'ApiEndpoint', { value: api.url })
    new cdk.CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId })
    new cdk.CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId })
  }
}

NodejsFunction を使っているので、handler.ts を esbuild で自動バンドルしてくれます。Strands SDK も Hono も zod も全部バンドルに含まれるため、Lambda レイヤーは不要です。

ストリーミングの有効化は LambdaIntegrationresponseTransferModeSTREAM を設定します。

主な設定値は下記のようになります。

設定項目 設定値 説明
runtime Node.js 24 Lambda ストリーミングに対応したマネージドランタイム
architecture ARM64
responseTransferMode STREAM API Gateway のレスポンスストリーミングを有効化
timeout 5 分 Lambda 関数のタイムアウト。長い応答に備えて長めに設定
bundling.format ESM NodejsFunction のバンドル形式

デプロイ

実装できたらデプロイしてみます。

実行コマンド
pnpm install
npx cdk deploy
実行結果
  AgentApiStack

  Deployment time: 61.86s

Outputs:
AgentApiStack.ApiEndpoint = https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/v1/
AgentApiStack.UserPoolClientId = xxxxxxxxxxxxxxxxxxxxxxxxxx
AgentApiStack.UserPoolId = us-east-1_xxxxxxxxx

デプロイできました!動作確認してみます。

動作確認

実際に試してみます!

テストユーザーの作成

Cognito にテストユーザーを作成します。ここでは、デプロイ時に出力されたユーザープール ID を使用します。

実行コマンド
USER_POOL_ID=us-east-1_xxxxxxxxx

aws cognito-idp admin-create-user \
  --user-pool-id $USER_POOL_ID \
  --username test@example.com \
  --message-action SUPPRESS

aws cognito-idp admin-set-user-password \
  --user-pool-id $USER_POOL_ID \
  --username test@example.com \
  --password 'YourPassword123!' \
  --permanent

認証の確認

まずトークンなしで叩いてみます。

実行コマンド
curl -X POST "$API_ENDPOINT/invoke" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "こんにちは"}'
実行結果
{"message":"Unauthorized"}

ちゃんと 401 で弾かれていますね!

ストリーミングの確認

Cognito からトークンを取得して、ストリーミングで呼んでみます。

実行コマンド
CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx

ID_TOKEN=$(aws cognito-idp initiate-auth \
  --auth-flow USER_PASSWORD_AUTH \
  --client-id $CLIENT_ID \
  --auth-parameters USERNAME=test@example.com,PASSWORD='YourPassword123!' \
  --query 'AuthenticationResult.IdToken' \
  --output text)

curl -N -X POST "$API_ENDPOINT/invoke" \
  -H "Content-Type: application/json" \
  -H "Authorization: $ID_TOKEN" \
  -d '{"prompt":"AWSの好きなサービスを3つ挙げて","sessionId":"test-1"}'

ターミナル上で文字が逐次表示されました!本当にストリーミングされているか、チャンクの到着タイミングを計測してみると下記のようになっていました。

実行結果(チャンク到着時刻の抜粋)
[+  1.51s] chunk 1: 'AWSの好きなサービスを3つ'
[+  1.54s] chunk 2: '挙げる'
[+  1.55s] chunk 3: 'とす'
(中略)
[+  2.95s] chunk 63: '的なサービ'
[+  2.98s] chunk 66: 'スです。'
--- first: +1.51s, chunks: 74, done: +3.26s

リクエストから約 1.5 秒で最初のチャンクが届き、74 個のチャンクが逐次到着していますね!

マルチターン会話の確認

S3 SessionManager が機能しているか確認します。同じ sessionId で 2 回呼んでみます。

1回目(自己紹介)
curl -N -X POST "$API_ENDPOINT/invoke" \
  -H "Content-Type: application/json" \
  -H "Authorization: $ID_TOKEN" \
  -d '{"prompt":"こんにちは、私は神野です。好きな食べ物はカレーです。覚えておいてね。","sessionId":"blog-test-1"}'
1回目の応答
こんにちは、神野さん!はじめまして。

好きな食べ物がカレーだということですね。カレーはおいしいですね!
ご紹介いただきありがとうございます。この情報は今後のお話の中で参考にさせていただきます。
2回目(記憶の確認)
curl -N -X POST "$API_ENDPOINT/invoke" \
  -H "Content-Type: application/json" \
  -H "Authorization: $ID_TOKEN" \
  -d '{"prompt":"私の名前と好きな食べ物を覚えてる?","sessionId":"blog-test-1"}'
2回目の応答
はい、覚えていますよ!

あなたのお名前:神野さん
好きな食べ物:カレー

ですね。今後のご質問やお話の中で、この情報を活用させていただきます!

お、ちゃんと前の会話を踏まえて回答してくれていますね!

S3 の中身を見てみると、Cognito の sub でユーザーごとにパスが分離されていました。

実行結果
sessions/94085448-7071-7023-fd3e-d4866412213a/session/blog-test-1/scopes/agent/agent/snapshots/snapshot_latest.json

sessions/{sub}/{sessionId}/... という構造になっていますね!別のユーザーが同じ sessionId で呼んでもパスが変わるため、アプリケーション上はユーザーごとに履歴が分かれます。

Lambda はステートレスですが、S3 SessionManager のおかげでマルチターン会話が実現できています。
S3 版はこれで一通り動きました!!

AgentCore Memory 版も試してみる

AgentCore Memory の SessionManager を使うと、会話からユーザーの好みや事実情報を自動抽出して、別セッションでも参照できる長期記憶も簡単に連携できます。

ただし、Strands 向けの AgentCoreMemorySessionManager は執筆時点では Python 向けに提供されています。そのため Hono(TypeScript)とは別に、FastAPI + Lambda Web Adapter の Python Lambda を追加する構成にしました。ここだけ言語が変わりますが、Lambda でストリーミングしつつ AgentCore Memory も使いたいとなると、現状は Python を起点にする形になります。

AgentCore Memory リソースの作成

AgentCore Memory は CDK から作れるので、スタックに足してしまいます。3 つの長期記憶戦略を有効化しています。

lib/agent-api-stack.ts(追加分)
import * as agentcore from 'aws-cdk-lib/aws-bedrockagentcore'

// AgentCore Memory(Python 側の RetrievalConfig のパスと合わせる)
const memory = new agentcore.Memory(this, 'AgentMemory', {
  memoryName: 'lambda_api_strands_memory',
  expirationDuration: cdk.Duration.days(7),
  memoryStrategies: [
    agentcore.MemoryStrategy.usingUserPreference({
      strategyName: 'UserPreference',
      namespaces: ['/preferences/{actorId}/'],
    }),
    agentcore.MemoryStrategy.usingSemantic({
      strategyName: 'SemanticFacts',
      namespaces: ['/facts/{actorId}/'],
    }),
    agentcore.MemoryStrategy.usingSummarization({
      strategyName: 'SessionSummary',
      namespaces: ['/summaries/{actorId}/{sessionId}/'],
    }),
  ],
})
const cfnMemory = memory.node.findChild('Memory') as agentcore.CfnMemory
cfnMemory.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY)

namespace の {actorId} はリクエスト時に自動置換されるプレースホルダーです。この後 Python 側で actor_id に Cognito の sub を渡すので、ユーザーごとに記憶が分かれます。

Python Lambda ハンドラー

FastAPI + Strands Python SDK + AgentCore Memory SessionManager のハンドラーです。

lambda/memory/main.py
import json
import os
from datetime import datetime, timedelta, timezone

from bedrock_agentcore.memory.integrations.strands.config import (
    AgentCoreMemoryConfig,
    RetrievalConfig,
)
from bedrock_agentcore.memory.integrations.strands.session_manager import (
    AgentCoreMemorySessionManager,
)
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from strands import Agent, tool

SYSTEM_PROMPT = """あなたは日本語で応答する親切なアシスタントです。
現在時刻が必要な質問には get_current_time ツールを使って正確に回答してください。
回答は簡潔にまとめてください。
"""

app = FastAPI()

@tool
def get_current_time() -> str:
    """日本時間(JST)の現在時刻を返します。"""
    jst = timezone(timedelta(hours=9))
    return datetime.now(jst).strftime("%Y-%m-%d %H:%M:%S")

class InvokeRequest(BaseModel):
    prompt: str
    session_id: str | None = None

def _get_user_sub(request: Request) -> str | None:
    """Cognito の sub を返す。取得できない場合は None(呼び出し側で 401)。"""
    # LWA (response streaming mode) は x-amzn-request-context に requestContext を渡す
    raw = request.headers.get("x-amzn-request-context")
    if not raw:
        return None
    try:
        ctx = json.loads(raw)
        sub = ctx.get("authorizer", {}).get("claims", {}).get("sub")
        return sub if isinstance(sub, str) and sub else None
    except (json.JSONDecodeError, TypeError, AttributeError):
        return None

@app.post("/invoke-memory")
async def invoke_memory(req: InvokeRequest, request: Request):
    # actor_id は Memory のユーザー分離境界なので、共有 ID にフォールバックしない
    user_sub = _get_user_sub(request)
    if user_sub is None:
        return JSONResponse({"message": "Unauthorized"}, status_code=401)

    session_id = req.session_id or "default"

    config = AgentCoreMemoryConfig(
        memory_id=os.environ["MEMORY_ID"],
        session_id=session_id,
        actor_id=user_sub,
        retrieval_config={
            "/preferences/{actorId}/": RetrievalConfig(top_k=5, relevance_score=0.5),
            "/facts/{actorId}/": RetrievalConfig(top_k=10, relevance_score=0.3),
            "/summaries/{actorId}/{sessionId}/": RetrievalConfig(
                top_k=3, relevance_score=0.5
            ),
        },
    )
    session_manager = AgentCoreMemorySessionManager(
        config, region_name=os.environ.get("MEMORY_REGION", "us-east-1")
    )

    agent = Agent(
        model=os.environ["MODEL_ID"],
        system_prompt=SYSTEM_PROMPT,
        tools=[get_current_time],
        session_manager=session_manager,
        callback_handler=None,
    )

    async def generate():
        # 途中で例外やクライアント切断が起きても確実にフラッシュする
        try:
            async for event in agent.stream_async(req.prompt):
                if "data" in event:
                    yield event["data"]
        finally:
            session_manager.close()

    return StreamingResponse(generate(), media_type="text/plain; charset=utf-8")

Hono 版と構造は似ていますが、いくつか違いがあります。

ストリーミングは agent.stream_async() を使います。TypeScript 版の agent.stream() に相当する非同期ジェネレーターで、イベントの data キーにテキストの増分が入ってきます。

ユーザー分離は Cognito の sub を AgentCoreMemoryConfig の actor_id に渡すだけです。Hono 版では自分で S3 のプレフィックスに sub を埋め込みましたが、AgentCore Memory は namespace の {actorId} を自動置換してくれます。

JWT claims の取得方法は Hono 版と違うので注意してください。
Lambda Web Adapter のストリーミングモードでは、API Gateway の requestContext が x-amzn-request-context ヘッダーに JSON として渡されます。Hono だと c.env.event でイベントを取り出せましたが、LWA 経由の FastAPI ではリクエストヘッダーから取り出します。

CDK への追加

CDK スタックに Python Lambda を追加します。@aws-cdk/aws-lambda-python-alphaPythonFunction を使うと、requirements.txt から Docker 経由で依存関係を自動バンドルしてくれます。TypeScript 側の NodejsFunction と同じ感覚です。

import に @aws-cdk/aws-lambda-python-alpha を追加しておきます。

import * as python from '@aws-cdk/aws-lambda-python-alpha'
CDK 追加分(agent-api-stack.ts に追記)
lib/agent-api-stack.ts(追加分)
    // Python Lambda(FastAPI + Strands Python SDK + AgentCore Memory)
    const lwaLayer = lambda.LayerVersion.fromLayerVersionArn(
      this,
      'LwaLayer',
      `arn:aws:lambda:${this.region}:753240598075:layer:LambdaAdapterLayerArm64:25`,
    )
    const memoryFunction = new python.PythonFunction(this, 'MemoryFunction', {
      entry: 'lambda/memory',
      index: 'main.py',
      runtime: lambda.Runtime.PYTHON_3_13,
      architecture: lambda.Architecture.ARM_64,
      timeout: cdk.Duration.minutes(5),
      memorySize: 512,
      layers: [lwaLayer],
      environment: {
        MODEL_ID,
        MEMORY_ID: memory.memoryId,
        MEMORY_REGION: this.region,
        AWS_LAMBDA_EXEC_WRAPPER: '/opt/bootstrap',
        AWS_LWA_INVOKE_MODE: 'response_stream',
        PORT: '8000',
      },
    })
    // PythonFunction は handler を Python モジュール形式で生成するが、
    // LWA は run.sh をエントリポイントとして使うので L1 でオーバーライドする
    const cfnMemoryFunction = memoryFunction.node.defaultChild as lambda.CfnFunction
    cfnMemoryFunction.addPropertyOverride('Handler', 'run.sh')

    memoryFunction.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'],
        resources: ['*'],
      }),
    )
    memory.grantFullAccess(memoryFunction)

    // POST /invoke-memory エンドポイント追加
    api.root.addResource('invoke-memory').addMethod(
      'POST',
      new apigateway.LambdaIntegration(memoryFunction, {
        responseTransferMode: apigateway.ResponseTransferMode.STREAM,
        timeout: cdk.Duration.minutes(5),
      }),
      methodOptions,
    )

環境変数の MEMORY_ID には、CDK で作った Memory のリソース ID を設定します。

PythonFunction は entry で指定したディレクトリの requirements.txt を検出し、cdk synth 時に Docker コンテナ内で pip install を実行します。ARM64 向けのネイティブホイールも Docker が解決してくれます。

AgentCore Memory 版の動作確認

デプロイ後、/invoke-memory エンドポイントを叩いてみます。

1回目(自己紹介)
curl -N -X POST "$API_ENDPOINT/invoke-memory" \
  -H "Content-Type: application/json" \
  -H "Authorization: $ID_TOKEN" \
  -d '{"prompt":"こんにちは、私は神野です。TypeScriptが好きで、趣味はスーパーマーケット巡りです。覚えてね。","session_id":"ltm-test-1"}'
1回目の応答
こんにちは、神野さん!よろしくお願いします。

あなたについて覚えさせていただきます:
- 名前: 神野さん
- 好きなプログラミング言語: TypeScript
- 趣味: スーパーマーケット巡り

素晴らしい趣味ですね!今後の会話でお役に立てるよう、この情報を念頭に置いておきます。

同一セッションでの記憶確認は S3 版と同じように動きます。
長期記憶の抽出が終わるのを少し待ってから、別の sessionId で呼んでみます。

別セッションから記憶確認
curl -N -X POST "$API_ENDPOINT/invoke-memory" \
  -H "Content-Type: application/json" \
  -H "Authorization: $ID_TOKEN" \
  -d '{"prompt":"私について何か知ってることある?","session_id":"ltm-cross-session"}'
別セッションの応答
はい、神野さんについて知っていることがあります!

- 名前:神野さん
- 好きな言語:TypeScript
- 趣味:スーパーマーケット巡り

TypeScriptは確かに素晴らしい言語ですね。型安全性があって開発効率も良いです。
また、スーパーマーケット巡りは、各地の特色ある商品や地域の食文化を発見できる素敵な趣味だと思います!

初めて使うセッション ID ですが、前の会話で伝えた情報を覚えていますね!!

AgentCore Memory が裏側で会話からユーザーの好みを自動抽出していて、下記のようなレコードが保存されていました!

AgentCore Memory に自動抽出された記録
{"context":"ユーザーが自己紹介の中で、TypeScriptが好きだと明示的に述べた。",
 "preference":"TypeScriptが好き",
 "categories":["プログラミング","技術"]}

{"context":"ユーザーが自己紹介の中で、趣味はスーパーマーケット巡りだと明示的に述べた。",
 "preference":"趣味はスーパーマーケット巡り",
 "categories":["趣味","ショッピング"]}

AgentCore との使い分け

Strands の Lambda デプロイガイドでは、ガイド内の Lambda 例はストリーミングを実装していないと説明されていて、必要な場合の選択肢として Fargate が挙げられています。ただ今回試したように、API Gateway のストリーミング対応と Hono を組み合わせれば、Lambda でもストリーミング API は作れます。

既存の API 基盤に乗せたい、Cognito や WAF、使用量プランなど API Gateway の機能をフルに使いたい、リクエスト課金でコストを抑えたいという場合は Lambda + API Gateway が有利な気がしました。S3 セッションを使う最小版ならわりと簡単に実装できる気がします。API Gateway と AgentCoreを組み合わせることも可能ですが、ネイティブに結合はできず、プロキシするLambdaが必要になるのでそこが少しデメリットかもなと感じました。

一方、実行時間が 15 分を超える長時間タスクや、microVM によるセッション分離が必要になったら、AgentCore を検討したほうがよいです。AgentCore CLI を使えばエージェントの雛形からデプロイまで一気に通せるので、まず動くものを作りたいケースでもとっつきやすいと思います。ケースバイケースな気がしましたが、とにかくAIエージェントを作りたいんだ!試したい!と言ったケースはAgentCore RuntimeやHarnessで試すのも良い気がします。

おまけとしてチャット UI も作ってみた

せっかくなので比較用のチャット UI も作ってみました!
フルスタックで使える方が嬉しいですもんね、チャット UI は main ブランチに入っています。

Strands Lambda Chat のチャット画面。AgentCore Memory エンドポイントで「私のことを覚えていますか?」と聞くと、過去の会話から抽出された関心事を答えてくれている

React + Vite 製の SPA を S3 + CloudFront で配信する構成です。

SPA を含めたアーキテクチャ図。ブラウザ上の React SPA は Cognito で直接サインインし、CloudFront 経由で S3 の静的アセットを取得する。/api/* は API Gateway へ転送され、2 つの Lambda がそれぞれ S3 セッションバケットと AgentCore Memory を使う

画面左上のエンドポイントセレクターで、同じ画面のまま S3 SessionManager 版と AgentCore Memory 版を切り替えられます。スクリーンショットは AgentCore Memory 版で「私のことを覚えていますか?」と聞いたら、過去の会話から自動抽出された関心事を答えてくれていますね!

「新しい会話」で sessionId を切り替えてから同じ質問をすると、S3 版は忘れて AgentCore Memory 版は覚えている、という本編の比較を UI 上でポチポチ試せます。

本編のハンドラーはテキストをそのまま返却しましたが、UI でツール呼び出しの過程も見せたかったので、レスポンスを SSE に変更しています。agent.stream() が返すのは SDK 内部のイベントオブジェクトなので、テキストだけ流すなら本編のままで十分です。

最終的なディレクトリ構成はこうなりました。

lambda-api-strands/
├── bin/app.ts
├── lib/
│   ├── agent-api-stack.ts       # API GW + Lambda + Cognito + CloudFront
│   └── model-catalog.ts         # Haiku / Sonnet の切り替え定義
├── lambda/
│   ├── handler.ts               # Hono + S3 SessionManager(SSE)
│   ├── validate-request.ts
│   └── memory/main.py           # FastAPI + AgentCore Memory(SSE)
├── frontend/                    # React + Vite のチャット UI
│   ├── public/app-config.json   # Cognito / エンドポイント / モデル定義
│   └── src/
│       ├── components/          # Header, ChatMessage, EndpointSelector ...
│       ├── hooks/useChat.ts     # 送信とストリーム状態の管理
│       └── lib/sse.ts           # SSE パーサー(TS 版・Python 版で共通)
└── package.json

セットアップ手順は README にまとめてあります。pnpm deploy でフロントエンドのビルドから CDK デプロイまで一気に流れるので、動かしてみたい方はそちらをどうぞ!
フィードバックがあれば、Issue で教えてください!

おわりに

Lambda でもデプロイしてみたいと思い、試してみました。AgentCore だけでなく、こういった構成を選ぶケースもありそうです。

本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!

補足

API Gateway レスポンスストリーミングの注意点

ストリーミング有効時はいくつか挙動が変わります。API Gateway は Lambda を InvokeWithResponseStream API で呼び出すため、Lambda 側は streamifyResponse 相当の形式で返す必要があります(Hono が内部で処理してくれる)。統合タイムアウトは上限が 15 分まで延ばせるようになり(デフォルトは 29 秒のまま)、レスポンスの最初の 10 MB は帯域制限なし、以降は 2 MB/s に制限されます。課金はレスポンスペイロード 10 MB 単位でカウントされます。

https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html

S3 SessionManager のセッション構造

セッションデータはこの構造で S3 に保存されます。JWT の sub をプレフィックスに入れているため、アプリケーション上はユーザーごとに履歴が分かれます。

sessions/<userSub>/session/<sessionId>/
└── scopes/
    └── agent/
        └── <agentId>/
            └── snapshots/
                └── snapshot_latest.json

SessionManager は invocation が完了するたびに snapshot_latest.json を上書き保存します。次回同じ sessionId でリクエストが来ると、snapshot を自動で復元して会話を継続してくれます。

削除方法

検証が終わったらスタックごと消してください。S3 バケットには autoDeleteObjects、AgentCore Memory には RemovalPolicy.DESTROY を設定しているため、いずれもスタック削除と同時に消えます。

実行コマンド
npx cdk destroy

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事