AIエージェント開発で押さえておきたい6つのこと|サーバーレスSSEで作るAIエージェント

こんにちは、マネックス証券で AI 関連の開発を担当している倉田です。

World Summit AI 2025 に参加してから少し時間が経ちましたが、その後も AI 関連の開発に携わるなかで、サミットで聞いた話と現場で感じていたことが重なり、エージェント開発において重要だと感じるポイントがいくつか見えてきました。この記事では、それらを自分なりに整理してまとめてみます。

 
     
 

この記事は 2 部構成です。

  • Part 1: 実際にエージェントを作ってみて見えてきた、重要な 6 つのこと
  • Part 2: その土台になる最小の実行基盤 — サーバーレス SSE × Tool use でストリーミング対応のエージェントをゼロから作ってみるハンズオン(CDK でそのままデプロイできる形で書いています)

なお、データ面の重要性は他の記事でも多く語られているので、Part 1 では実装・運用の側面に絞って書きます。

Part 2 は、最近の開発で Lambda Response Streaming を使ってチャットエージェントを実装してみたら、相性が良く良いユースケースだと感じたので、その最小構成を共有します。Lambda Response Streaming は 2023 年 4 月に登場した機能 で、まとまった実装情報も少なかったので、動いているコードをそのまま載せる形にしました。


Part 1 — 作ってみて分かった、エージェント開発で重要な 6 つのこと

1. プロンプトとツールの「民主化」

最初に挙げたいのは、プロンプトとツール定義を 開発者だけのもの にしない、ということです。

 
     
 

具体的には、業務・顧客・商品を一番よく知っている現場の担当者(以下、ドメインエキスパート)がプロンプトを管理画面などの UI から直接編集・調整し、その結果を eval で検証しながら改善していける仕組みを作る、ということです。こうすることで、プロンプト改善の多くをドメインエキスパート自身で担えるようになり、開発・改善のスピードが大きく上がります。

なぜこれが重要かというと、AI エージェントの品質はプロンプトやツール定義(特に description)の作り込みに大きく依存する一方で、業務や顧客を最もよく理解しているのは、開発者ではなく現場のドメインエキスパートであることが多いからです。

役割分担としては、次のようなイメージです。

ドメインエキスパート エンジニア
ユーザーの期待を理解する プロンプトを最適化する
エージェントの出力品質を評価する コストを最適化する
業務知識をプロンプトに反映する ツール側のビジネスロジックを構築する
ペインポイントを発見する セキュリティを担保する

実際、あるプロジェクトでは、この仕組みを導入してから、上がってきたフィードバックの約半数が、エンジニアを介さずにドメインエキスパート(マーケティング)自身で解決されるようになっています。エンジニア側はその分、新機能や改善に時間を使えるようになりました。

2. エージェントの評価(Evaluation)

個人的には、6 つの中でここがいちばん重要だと思っています。

というのも、モデルは頻繁に変わります。Sonnet 4.5 → Opus 4.5 → Opus 4.6 と矢継ぎ早に出る、というペースです。このときに「切り替えて大丈夫か」を判断する材料がなければ、怖くて最新モデルに乗り換えられません。プロンプトを一部調整したときに別の部分が悪化する、いわゆるリグレッションも、評価がないと検知できません。

評価は 3 層のピラミッドで表されています。

 
     

数としていちばん多いのが底辺の Deterministic(コードによる判定)です。「文字数が規定内に収まっているか」「禁止ワードが含まれていないか」「フォーマットが崩れていないか」といった、白黒がはっきりつくチェックをここで処理します。

真ん中の LLM as a Judge は、より賢いモデルに「回答が意図に沿っているか」「トーンが適切か」といったニュアンスを含む判定を任せます。頂点の Human Review はコストも高く時間もかかるので、LLM でも判定が揺れるケースに絞って使います。

これらを組み合わせて Golden Dataset(理想的な入出力を固定したテストセット)に対して継続的に回し、プロンプトやモデルを変えたときのリグレッションを検知します。

Golden Dataset も民主化してしまい、ドメインエキスパートが「こういう質問には、こう答えてほしい」というケースを管理画面から直接追加できるようにしています。実業務で遭遇した失敗ケースがそのままテストケースになるので、現場のペインがそのまま改善に繋がります。

それとセットで効くのが ハルシネーション対策 です。方針はシンプルで、「分からないことは分からないと答えさせる」「答える場合は情報源を示す」の 2 つ。これを守らせた上で、「根拠なく断定していないか」を eval の判定基準にも入れておくと、評価とハルシネーション対策が一枚のループで回ります。

3. Human in the Loop(HITL)

民主化と表裏一体なのが Human in the Loop(HITL)です。「全自動にしない」とひと言で言えますが、どこに人の判断を残すか で効き方がまったく変わります。

実務では、最低でも次の 3 か所に HITL ポイントを置いています。

  • プロンプト・eval の反映時:ドメインエキスパートが編集した内容を、指標の差分を見た上で反映する
  • エージェントの実行時:外部に影響する操作(メール送信・発注・顧客宛の回答など)には、必ず人の確認を挟む
  • 評価の Human Review:LLM でも判定が揺れるケースは、最終的に人間がラベルを付ける

ただし、HITL は置くだけだと 鵜呑みレビュー(とりあえず承認ボタンを押すだけ)に陥ります。「差分が見やすい UI にする」「変更理由を書かせる」「承認率をモニタリングする」など、レビューが形骸化しない仕掛けをセットにしておくのがコツです。

AI による編集支援や提案は便利ですが、最終判断は人間がやる。この形が機能すれば、業務に組み込む心理的なハードルもぐっと下がります。

4. Dogfooding(社内検証)

顧客にリリースする前に自分たちで一通り使ってみて、期待通りに動くか・本当に役に立つかを見極める、というやり方です。World Summit AI 2025 のセッションでも、メルカリやアトラシアンといった企業が「社内利用で成熟させてから外に出す」取り組みを紹介していました。

どんなサービスでも重要ですが、AI エージェントは特にこれが効きます。AI は扱う入力と出力のパターンが事実上無限なので、事前にすべてのエッジケースを潰すことができません。さらに、ユーザー側も新しい体験なので「何をどう聞くか」「何を期待するか」が事前には読めず、実際に使ってみて初めて課題が見えることが多いのです。リリース前にひたすら自分たちで使い倒さないと、本番で初めて問題が顕在化します。

5. Feedback Loop

World Summit AI 2025 のセッションを通して感じたのは、フィードバックループの質と速さが、最終的にエージェントの差を生むということでした。

AI エージェントは、リリース時点で完璧に作り込むことはほぼ不可能です。完璧を目指せば目指すほどリリースは遠のき、その分だけ競争で後手に回ります。だからこそ、「早くリリースして、早く直す」ループを仕組みとして持っている側が、長期的に優位に立てます。

  リリース → フィードバック
     ↑              ↓
    評価   ←    改善

重要なのは、「誰がどう気づき、どれだけ速く改善につなげられるか」をあらかじめ設計しておくことです。このループを速く、継続的に回せる仕組みが、長期的な競争力を決めます。

6. 見える化(Observability)

最後が見える化です。地味に見えて、なければ何も改善できません。

実務的に重要なのは 3 つです。

  • トークン消費量の可視化:エージェントごとのコストを把握し、異常消費を早期に検知する
  • 推論プロセスの可視化:エージェントがどんな思考・ツール呼び出しを経てその回答に至ったかを追えるようにする。代表例は LangSmith / Langfuse などのトレーシングプラットフォーム
  • 失敗パターンの可視化:エージェントが答えられなかった、ツール呼び出しが失敗した、想定外の返答やループが発生した、などの異常パターンを検知する

シンプルに言えば 「見えなければ改善できない、測れなければ評価できない」。これが残り 5 つを下から支える土台になります。

民主化 / 評価 / Human in the Loop / Dogfooding / Feedback Loop / 見える化 — どれも派手な論点ではありませんが、エージェントを継続的に改善していくうえで見過ごせないものばかりだと感じました。次は、この 6 つを回すための土台になる最小の実行基盤の話に移ります。


Part 2 — サーバーレス SSE × Tool use でエージェントを作る

なぜこの記事を書いたか

サービスを作るにあたって、多くのプロジェクトが MCP サーバーを立てる方向に進む中で、今回の Lambda Response Streaming × Bedrock ConverseStream の組み合わせが、コスト・スケーラビリティ・実装のシンプルさのバランスで一番しっくりきました。

 
     

ただ、この構成は比較的新しく、AWS と Anthropic のドキュメントも散らばっています。特に API Gateway 経由でレスポンスストリーミングを有効化する部分(Integration.ResponseTransferMode: STREAM を使う CFN オーバーライド)や、ツール呼び出しループまで含めた完成形は、自分でもコーディングエージェントに関連ドキュメントを読み込ませながら組み上げていった、というのが正直なところです。同じ構成を検討している人の参考になればと思い、動いているコードそのままの形でブログにまとめることにしました。

なぜ Lambda + SSE なのか(EKS との比較)

MCP サーバーを別途立てる構成に比べると、アプリケーション側で完結する Tool use は構成がシンプルで、サーバーレスとの相性も良くなります。そこで今回は Lambda + SSE を選びました。キーワードは cheap / scalable / simple の 3 つです。

EKS Lambda SSE
待機コスト コントロールプレーンだけで月 $73〜、ノード込みで $100 超 アイドル時ほぼゼロ
スケール ノード追加・HPA 設定が必要 自動(同時実行数の制御のみ)
運用 クラスタ管理・パッチ・監視 大幅に軽減
デプロイ Helm・マニフェスト管理 cdk deploy

AI の応答は通常 5〜60 秒で完結し、Lambda の 15 分上限に対して十分余裕があります。アイドル状態のコンテナにコストを払い続ける必要はありません。Reserved Concurrency を 5 に設定しておけば、暴走してもコストが跳ね上がりません。

向かないケースもあります。常時接続型の通知基盤 や、1000 人以上の同時接続が恒常的に発生する規模 であれば、EKS や Fargate のほうが総コストで有利になることがあります。また、1 セッションが 15 分を超えるような長大なエージェントループ(深いツール呼び出しや長時間の Deep Research など)は Lambda の実行時間上限に引っかかるため、そもそもサーバーレスでは成立しません。今回のサンプルは「小〜中規模のチャットエージェント」に最適化された構成です。

アーキテクチャ

ブラウザ
  ↓ POST(fetch + ReadableStream で SSE を受信)
Lambda Function URL(InvokeMode: RESPONSE_STREAM)
  ↓ awslambda.streamifyResponse で SSE フレームを書き出す
Bedrock ConverseStreamCommand(Tool use 対応)

このサンプルでは最小構成として Function URL 直結 で書いています。CORS もヘッダも Function URL 側で完結するので、ピースを減らせます。

実際の本番運用では API Gateway(REST)を挟むこともできます。最近の CDK なら LambdaIntegrationresponseTransferMode: apigateway.ResponseTransferMode.STREAM を渡すだけで L2 のまま設定できます(古い CDK バージョンでは L1 エスケープハッチで Integration ノード下の ResponseTransferMode プロパティを指定する必要あり)。なお、ストリーミング時のアイドルタイムアウトRegional / Private で 5 分、Edge-optimized で 30 秒 なので、長時間のツール呼び出しを想定するなら Regional エンドポイントを選ぶのが無難です。

実装

ファイル構成は次の 4 ファイルです(cdk.jsontsconfig.jsoncdk init の生成そのまま)。 ファイル構成は次の通りです。手で書くのは下 4 つだけで、package.json / cdk.json / tsconfig.jsonnpm init / cdk init の生成物をそのまま使います(以降に出てくる <app> は、お好きなアプリ名に読み替えてください)。

<app>/
├── package.json        # npm init 生成
├── lambda/handler.ts   # Lambda本体(ツール定義 + ストリーミング)
├── lib/stack.ts        # CDK 最小スタック
├── bin/app.ts          # CDK エントリ
└── index.html          # ブラウザから触れる最小フロント

1. プロジェクトを作る

CDK のテンプレートを生成して、Bedrock SDK を入れます。

mkdir <app> && cd <app>
npm init -y
npm install aws-cdk-lib constructs @aws-sdk/client-bedrock-runtime
npm install -D aws-cdk typescript @types/node esbuild ts-node
npx cdk init app --language typescript --generate-only

前提条件:

  • AWS CLI にクレデンシャルが設定済み(~/.aws/config
  • Bedrock で Claude Sonnet 4.5(または任意のモデル)のアクセスが有効化済み
  • Node.js v20 以上

2. lambda/handler.ts

ここがこの記事の核です。ツール定義・ストリーミング・ツール呼び出しループを 1 ファイルに収めます。

// Lambda Response Streaming のランタイムグローバル型
// `@types/aws-lambda` は入れず、この最小宣言だけで型を通す
declare const awslambda: {
  streamifyResponse: (
    handler: (event: unknown, stream: NodeJS.WritableStream) => Promise<void>
  ) => (event: unknown) => Promise<void>;
  HttpResponseStream: {
    from: (
      stream: NodeJS.WritableStream,
      metadata: { statusCode: number; headers: Record<string, string> }
    ) => NodeJS.WritableStream;
  };
};

import {
  BedrockRuntimeClient,
  ConverseStreamCommand,
  type Message,
  type ToolConfiguration,
} from '@aws-sdk/client-bedrock-runtime';

const bedrock = new BedrockRuntimeClient({ region: 'ap-northeast-1' });

// 推奨: クロスリージョン推論プロファイル(レートリミット緩和)
// 東京リージョンで使う場合は `jp.` プレフィックス。APAC 広域の場合は `apac.`。
const MODEL_ID = 'jp.anthropic.claude-sonnet-4-5-20250929-v1:0';

// ── ツール定義(本 + 購入履歴の2つ)──
const toolConfig: ToolConfiguration = {
  tools: [
    {
      toolSpec: {
        name: 'buy_book',
        description: '指定された本を購入する',
        inputSchema: {
          json: {
            type: 'object',
            properties: {
              book_title: { type: 'string', description: '購入したい本のタイトル' },
              quantity: { type: 'integer', description: '購入する本の数量' },
            },
            required: ['book_title'],
          },
        },
      },
    },
    {
      toolSpec: {
        name: 'get_purchase_history',
        description: '購入履歴を取得する',
        inputSchema: {
          json: {
            type: 'object',
            properties: {
              year: { type: 'integer', description: '購入履歴を取得したい年' },
            },
          },
        },
      },
    },
  ],
};

// ── ツール実行(ここにビジネスロジックを書く)──
function executeTool(name: string, input: Record<string, unknown>): string {
  if (name === 'buy_book') {
    return `購入完了: ${input.book_title}${input.quantity ?? 1} 冊`;
  }
  if (name === 'get_purchase_history') {
    return `${input.year} 年の購入履歴: ハリーポッター / 料理本`;
  }
  return '未対応のツールです';
}

// ── SSE パディング(プロキシ対策)──
// 16KB の初期パディングで企業プロキシのバッファ閾値を超える。
// token イベントごとに 256B を付加して再バッファを抑制する。
const INITIAL_PADDING = `:${' '.repeat(16384)}\n\n`;
const TOKEN_PADDING = `:${' '.repeat(256)}\n\n`;

function sseFrame(event: string, data: unknown): string {
  const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
  return event === 'token' ? frame + TOKEN_PADDING : frame;
}

async function streamHandler(
  event: unknown,
  responseStream: NodeJS.WritableStream
): Promise<void> {
  const body = JSON.parse((event as { body: string }).body ?? '{}');
  const userMessage: string = body.message ?? 'こんにちは';

  const httpStream = awslambda.HttpResponseStream.from(responseStream, {
    statusCode: 200,
    headers: {
      'Content-Type': 'text/event-stream; charset=utf-8',
      'Cache-Control': 'no-cache, no-store, must-revalidate, no-transform',
      'Connection': 'keep-alive',
      'X-Accel-Buffering': 'no',            // 下流に nginx がある場合のバッファ無効化(CloudFront / ALB は未対応だが無害)
      'Content-Encoding': 'identity',       // 圧縮を無効化(これがないと詰まる)
      'Access-Control-Allow-Origin': '*',
    },
  });

  // 初期パディング + 開始イベント
  httpStream.write(INITIAL_PADDING);
  httpStream.write(sseFrame('stream_start', {}));

  // 15 秒ハートビート(プロキシのアイドルタイムアウト対策)
  const heartbeat = setInterval(() => {
    httpStream.write(sseFrame('heartbeat', {}));
  }, 15_000);

  try {
    const messages: Message[] = [{ role: 'user', content: [{ text: userMessage }] }];

    // ツール呼び出しループ(安全のため最大 3 周で打ち切り)
    for (let turn = 0; turn < 3; turn++) {
      const response = await bedrock.send(
        new ConverseStreamCommand({ modelId: MODEL_ID, messages, toolConfig })
      );

      let assistantText = '';
      const toolUses: Array<{ toolUseId: string; name: string; input: Record<string, unknown> }> = [];
      let currentTool: { toolUseId: string; name: string } | null = null;
      let currentToolInput = '';

      for await (const chunk of response.stream ?? []) {
        // ツール呼び出し開始
        if (chunk.contentBlockStart?.start?.toolUse) {
          const t = chunk.contentBlockStart.start.toolUse;
          currentTool = { toolUseId: t.toolUseId!, name: t.name! };
          currentToolInput = '';
          httpStream.write(sseFrame('tool_start', { name: t.name }));
        }
        // 通常テキストのデルタ
        else if (chunk.contentBlockDelta?.delta?.text) {
          const text = chunk.contentBlockDelta.delta.text;
          assistantText += text;
          httpStream.write(sseFrame('token', { text }));
        }
        // ツールの input は JSON 文字列が分割で届く → 連結
        else if (chunk.contentBlockDelta?.delta?.toolUse?.input) {
          currentToolInput += chunk.contentBlockDelta.delta.toolUse.input;
        }
        // ブロック終了 → ツール呼び出しを確定
        else if (chunk.contentBlockStop && currentTool) {
          const input = currentToolInput ? JSON.parse(currentToolInput) : {};
          toolUses.push({ ...currentTool, input });
          httpStream.write(sseFrame('tool_end', { name: currentTool.name }));
          currentTool = null;
        }
      }

      // ツール呼び出しがなければ通常回答で終了
      if (toolUses.length === 0) {
        httpStream.write(sseFrame('done', { fullText: assistantText }));
        break;
      }

      // ツール結果を次ターンに渡す
      messages.push({
        role: 'assistant',
        content: [
          ...(assistantText ? [{ text: assistantText }] : []),
          ...toolUses.map((t) => ({
            toolUse: { toolUseId: t.toolUseId, name: t.name, input: t.input },
          })),
        ],
      });
      messages.push({
        role: 'user',
        content: toolUses.map((t) => ({
          toolResult: {
            toolUseId: t.toolUseId,
            content: [{ text: executeTool(t.name, t.input) }],
          },
        })),
      });
    }
  } catch (err) {
    httpStream.write(sseFrame('error', { message: (err as Error).message }));
  } finally {
    clearInterval(heartbeat);
    httpStream.end();
  }
}

// Lambda Response Streaming のエントリポイント
export const handler = awslambda.streamifyResponse(streamHandler);

押さえておきたい細かいポイントは 3 つです。

  • 16KB 初期 + 256B トークンのパディング:プロキシや CloudFront が小さなチャンクをバッファして「Lambda は流しているのにブラウザには最後にまとめて届く」症状を防ぐ
  • Content-Encoding: identity:圧縮を明示的にオフ(X-Accel-Buffering: no は nginx 向け。CloudFront / ALB には効かないが無害なので残している)
  • 15 秒ハートビート:アイドルタイムアウトでコネクションを切られないため

3. lib/stack.ts

CDK 側は最小限です。Function URL に InvokeMode.RESPONSE_STREAM を指定するのがキモです。

import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as iam from 'aws-cdk-lib/aws-iam';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from 'constructs';
import * as path from 'path';

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

    const fn = new NodejsFunction(this, 'StreamHandler', {
      entry: path.join(__dirname, '../lambda/handler.ts'),
      handler: 'handler',
      runtime: lambda.Runtime.NODEJS_22_X,
      architecture: lambda.Architecture.ARM_64,
      timeout: cdk.Duration.minutes(10),
      memorySize: 512,
      reservedConcurrentExecutions: 5, // コスト暴走防止
    });

    // Bedrock 呼び出し権限(Foundation Model + クロスリージョン推論プロファイル)
    fn.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'],
        resources: [
          'arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-5-*',
          `arn:aws:bedrock:*:${cdk.Stack.of(this).account}:inference-profile/jp.anthropic.claude-sonnet-4-5-*`,
        ],
      })
    );

    // Function URL(Response Streaming モード)
    const fnUrl = fn.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
      invokeMode: lambda.InvokeMode.RESPONSE_STREAM,
      cors: {
        allowedOrigins: ['*'],
        allowedMethods: [lambda.HttpMethod.POST],
        allowedHeaders: ['Content-Type', 'Accept'],
      },
    });

    new cdk.CfnOutput(this, 'FunctionUrl', { value: fnUrl.url });
  }
}

4. bin/app.ts

#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { AppStack } from '../lib/stack';

const app = new cdk.App();
new AppStack(app, 'app-stack', {
  env: { region: 'ap-northeast-1' },
});

5. index.html

ブラウザ側は単一の HTML で完結します。EventSource ではなく fetch + ReadableStream を使うのは、EventSource は POST 不可・カスタムヘッダー付与不可だからです。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title><app> streaming</title>
  <style>
    body { font-family: sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; }
    #input { width: 100%; padding: 0.5em; box-sizing: border-box; }
    #status { color: #888; font-size: 0.9em; min-height: 1.2em; }
    #output { padding: 1em; background: #f5f5f5; white-space: pre-wrap; min-height: 4em; }
  </style>
</head>
<body>
  <h1>&lt;app&gt; — ストリーミングチャット</h1>
  <input id="input" placeholder="例: 2023年の購入履歴を教えて" />
  <button id="send">送信</button>
  <div id="status"></div>
  <div id="output"></div>

  <script>
    // ← cdk deploy の出力 FunctionUrl をここに貼る
    const FUNCTION_URL = 'https://xxxxxxxx.lambda-url.ap-northeast-1.on.aws/';

    const input = document.getElementById('input');
    const output = document.getElementById('output');
    const status = document.getElementById('status');

    document.getElementById('send').addEventListener('click', async () => {
      output.textContent = '';
      status.textContent = '接続中...';

      // EventSource は POST 不可のため fetch + ReadableStream で受ける
      const res = await fetch(FUNCTION_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
        body: JSON.stringify({ message: input.value }),
      });

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });

        // SSE イベントは空行区切り(CRLF / LF どちらも許容)
        const events = buffer.split(/\r?\n\r?\n/);
        buffer = events.pop() || '';

        for (const raw of events) {
          if (!raw.trim() || raw.startsWith(':')) continue; // コメント(パディング)はスキップ
          let eventType = 'message';
          let dataStr = '';
          for (const line of raw.split(/\r?\n/)) {
            if (line.startsWith('event: ')) eventType = line.slice(7).trim();
            else if (line.startsWith('data: ')) dataStr = line.slice(6);
          }
          if (!dataStr) continue;
          const data = JSON.parse(dataStr);

          if (eventType === 'token') output.textContent += data.text;
          else if (eventType === 'tool_start') status.textContent = `ツール実行中: ${data.name}`;
          else if (eventType === 'tool_end') status.textContent = '';
          else if (eventType === 'done') status.textContent = '完了';
          else if (eventType === 'error') status.textContent = `エラー: ${data.message}`;
        }
      }
    });
  </script>
</body>
</html>

デプロイ

# 初回のみ
npx cdk bootstrap

# デプロイ
npx cdk deploy

デプロイ完了時にスタックの Outputs に FunctionUrl が表示されるので、これを index.htmlFUNCTION_URL に貼り付けます。

open index.html   # ブラウザで直接開いてもOK
# または
npx serve .       # ローカルサーバー越しでもOK
 
     

これで、入力欄にメッセージを打って「送信」すると、Bedrock から返ってくるトークンがリアルタイムで表示され、必要に応じてツール呼び出しの状態も見えるはずです。

本番に持っていくときに足すもの

今回は「最小で動く」ことに振り切ったので、本番運用では次を追加する必要があります。

  • 認証: Cognito ID トークンを Authorization ヘッダで受け取り、Lambda 内で JWT 検証
  • CORS 制限: allowedOrigins: ['*'] を自ドメイン限定に
  • 会話履歴: DynamoDB にセッション単位で保存
  • Application Inference Profile: モデルごとのコスト/レイテンシを分離可視化するなら有効
  • CloudFront 経由の配信: Cache-Control: no-store, no-transformCachingDisabled マネージドキャッシュポリシーを Origin 側に設定(バッファ抑制のため)
  • エラーハンドリング強化 / ログ構造化 / メトリクス記録

それぞれ普通のサーバーレスアプリの話なので、まずはこの最小構成を手元で動かして、ストリーミングの気持ちよさを体感してもらうのがおすすめです。


最後に

Part 2 で紹介した構成を出発点にすれば、ストリーミング対応の AI エージェントは比較的シンプルに立ち上げられます。ただ、本当に重要なのは土台そのものより、その上で民主化 / 評価 / Human in the Loop / Dogfooding / Feedback Loop / 見える化といった 6 つの観点を継続的に回していくことです。

一見地味に見えるポイントばかりですが、こうした部分に向き合い続けることこそがエージェント開発の核心であり、企業の競争力にもつながっていくのではないかと考えています。

本記事が、これから AI エージェントを作る方、あるいは改善していく方にとって、少しでも参考になれば幸いです。


参考リンク: - Anthropic — Tool use 公式ドキュメント - AWS — Introducing AWS Lambda response streaming (発表ブログ) - AWS — Bedrock ConverseStream API Reference

システム開発二部 第二プロダクトグループ 倉田 賢