🗣️

Strands Agents で AIエージェントを備えた Webサイトを実現する

に公開
2026/01/06

0. この記事で伝えたいこと

私がこの記事で伝えたいことは、AIエージェントは「後から付ける機能」ではなく最初から組み込むべきものになりつつあるということです。

1年半前、同じ「まひとつ屋」を構築したとき、私はAIを「外部サービス」として扱いました。
https://zenn.dev/akring/articles/55fe4c6b9b1614

しかし今回、あらためて新しい技術を使いながら同じテーマで設計と実装を進める中で、AIエージェントは最初から組むことを前提に考える時代が既に来ているのではないかと感じ、この記事を書くことにしました。Strands Agentsによる実装を例に、AIエージェントをアプリケーションに組み込むための設計がどんなものだったのか、私の経験を共有しようと思います。

1. はじめに

システム概要

「まひとつ屋」は架空の和菓子店のWebサイトです。このサイトには、商品情報の提供、カート管理、注文処理に加えて、AIによる接客機能が統合されています。

ユーザーは自然言語で質問し、AIエージェント("番頭さん")が適切な商品を提案し、カートへの追加まで実行します。このエージェントは単なるFAQボットではなく、店舗の業務システムと深く統合された実用的なエージェントです。


「まひとつ屋」スクリーンショット

会話例:AIエージェントとの対話

以下は、実際にシステムで行われた会話の要約です。このエージェントは自然言語で質問を受け付け、適切なツールを選択・実行し、エラーが発生した場合は自律的に代替案を提示します。

AIエージェントとの対話(要約版)
お客様:「お勧めの商品は何ですか。」
番頭さん:(list_featured_products ツールを実行)
          「どら焼き(220円)、抹茶大福(280円)、羊羹(350円)、カステラ(420円)がお勧めです。」

お客様:「そこから適当に2000円以内で選んでカートに入れてください。」
番頭さん:(add_to_cart 実行 → エラー:抹茶大福は店舗限定)
          (自律的に抹茶大福を除外して再実行 → 成功)
          「どら焼き×2、羊羹×2、カステラ×1で合計1,560円をカートに追加しました。」

お客様:「カードで決済してください」
番頭さん:「決済はお客様ご自身でお願いします。カート画面から手続きにお進みください。」

この会話で注目すべき点:

  • 文脈理解:「そこから」という代名詞を、直前の推薦商品リストとして正しく解釈
  • 自律的エラー処理:1回目のカート追加失敗後、ユーザーに再指示を求めず自動で代替案を実行
  • 権限の理解:決済は番頭の権限外であることを認識し、適切に説明

実際のシステムで行われた完全な会話ログは以下に収めてあります:

完全な対話ログ
お客様:「お勧めの商品は何ですか。」

番頭さん:「かしこまりました。当店のお勧めの商品をご案内させていただきます。
少々お待ちくださいませ。

(システム内部:list_featured_products ツールを実行)

お待たせいたしました。当店が特にお勧めしております商品は、こちらの四品でございます。

【どら焼き】220円
ふっくらと焼き上げた生地に、自家製餡の粒あんをたっぷりと挟んだ逸品でございます。

【抹茶大福】280円
香り高い抹茶を練り込んだ餅生地で、白あんを包んだ人気商品でございます。

【羊羹】350円
小豆の風味豊かな本練り羊羹でございます。上品な甘さが特徴の定番商品です。

【カステラ】420円
しっとりとした食感と優しい甘さが人気のカステラでございます。」

お客様:「そこから適当に2000円以内で選んでカートに入れてください。」

番頭さん:「かしこまりました。それでは2000円以内でバランスよくお選びさせていただきます。

(システム内部:add_to_cart ツールを実行 → エラー発生)

申し訳ございません。抹茶大福がオンラインでのご購入対象外となっておりました。
それでは、別の組み合わせでご用意させていただきます。

(システム内部:抹茶大福を除いて add_to_cart を再実行)

カートへのお入れが完了いたしました。

【カートの内容】
- どら焼き × 2個(220円 × 2 = 440円)
- 羊羹 × 2個(350円 × 2 = 700円)
- カステラ × 1個(420円)

合計金額:1,560円

当店の人気商品を詰め合わせでお選びいたしました。どら焼き、羊羹、カステラと、
それぞれ異なる味わいをお楽しみいただけます。

カートの内容はこちらでよろしゅうございますでしょうか。」

お客様:「カードで決済してください」

番頭さん:「申し訳ございません。私どもはオンラインショップの番頭として、
商品のご案内やカートへの追加までをお手伝いさせていただいておりますが、
実際の決済手続きにつきましては、お客様ご自身で決済画面にて
お手続きいただく形となっております。

【お支払い方法について】
当店では以下のお支払い方法をご用意しております:
- クレジットカード決済
- 代金引換
- 銀行振込
- コンビニ決済

クレジットカードでのお支払いをご希望とのことでございますので、
カート画面から「ご購入手続きへ進む」ボタンを押していただき、
決済画面にてクレジットカード情報をご入力いただければと存じます。

ご不明な点やご心配な点がございましたら、何なりとお申し付けくださいませ。
お買い上げいただき、誠にありがとうございます。」

最終的にはカートに商品が入った状態でUIも更新されています。

チャットでリクエストしたとおりにカートが更新されている

裏側で何が起きているのか

会話の流れ:

  1. 初回リクエスト(08:18:22)

    • プロンプト:「お勧めの商品は何ですか」
    • ツール実行:list_featured_products
    • Backend APIへSigV4署名付きリクエスト送信
    • 4商品の情報を取得して提案
  2. カート追加リクエスト(08:19:28)

    • プロンプト:「そこから適当に2000円以内で選んでカートに入れてください。」
    • ツール実行:add_to_cart(抹茶大福を含む) → エラー
    • Backend応答:ProductNotAvailableOnline エラー
    • LLMがエラーを理解し、代替案を提示
    • ツール再実行:add_to_cart(どら焼き、羊羹、カステラ)
    • 最終カート:どら焼き×2、羊羹×2、カステラ×1(合計1,560円)
  3. 決済リクエスト(08:21:58)

    • プロンプト:「カードで決済してください」
    • 現在のカート内容を取得:get_cart_contents
    • 決済は権限外であることを説明

最終的なカート状態:

最終的なカートの状態
{
  "items": [
    { "productId": "prod_002", "quantity": 2, "addedAt": "2025-12-30T08:19:32.301Z" },
    { "productId": "prod_009", "quantity": 2, "addedAt": "2025-12-30T08:19:32.500Z" },
    { "productId": "prod_010", "quantity": 1, "addedAt": "2025-12-30T08:19:32.601Z" }
  ]
}

会話履歴の保存:

  • 全6ターン(ユーザー3回 + アシスタント3回)がDynamoDBに記録済み
  • 次回のリクエスト時にコンテキストとして利用可能な状態

ポイント:

  1. セッション管理:会話履歴とカートがDynamoDBに保存され、クライアント毎にコンテキストが保持される
  2. ツール呼び出し:LLMが自律的にlist_featured_productsなどのツールを選択・実行
  3. IAM認証:エージェントからバックエンドAPIへの呼び出しはSigV4署名で保護
  4. ストリーミング:応答はServer-Sent Events (SSE)でリアルタイムに配信
実際のAI AgentのCloudWatch Logs
AgentのCloudWatch Logs
[agent][handleChat] sessionId=c2b21899-7fa6-4fd0-a97d-bf0b9a923e00 prompt=お勧めの商品は何ですか...
[sigv4] start { method: 'GET', path: '/prod/api/v1/sessions/c2b21899-7fa6-4fd0-a97d-bf0b9a923e00' }
[sigv4] done { elapsedMs: 3850 }
[sigv4] start { method: 'GET', path: '/prod/api/v1/featured-products' }
[sigv4] done { elapsedMs: 4200 }
[sigv4] start { method: 'GET', path: '/prod/api/v1/products/prod_002' }
[sigv4] done { elapsedMs: 4100 }
[sigv4] start { method: 'GET', path: '/prod/api/v1/products/prod_009' }
[sigv4] done { elapsedMs: 3800 }
[sigv4] start { method: 'GET', path: '/prod/api/v1/products/prod_010' }
[sigv4] done { elapsedMs: 4200 }
[agent][summary] sessionId=c2b21899-7fa6-4fd0-a97d-bf0b9a923e00 events=312 totalChars=490
[updateConversationHistory] Successfully updated conversation history

[agent][handleChat] sessionId=c2b21899-7fa6-4fd0-a97d-bf0b9a923e00 prompt=そこから適当に2000円以内で...
[agent][handleChat] Loaded 2 messages from history
[sigv4] start { method: 'PATCH', path: '/prod/api/v1/sessions/.../cart' }
[sigv4] done { elapsedMs: 7300 }
[addToCartTool] Error: The following products are not available for online purchase
[sigv4] start { method: 'PATCH', path: '/prod/api/v1/sessions/.../cart' }
[sigv4] done { elapsedMs: 4300 }
[agent][summary] sessionId=c2b21899-7fa6-4fd0-a97d-bf0b9a923e00 events=296 totalChars=387

[agent][handleChat] sessionId=c2b21899-7fa6-4fd0-a97d-bf0b9a923e00 prompt=カードで決済してください...
[agent][handleChat] Loaded 4 messages from history
[sigv4] start { method: 'GET', path: '/prod/api/v1/sessions/.../cart' }
[sigv4] done { elapsedMs: 1380 }
[agent][summary] sessionId=c2b21899-7fa6-4fd0-a97d-bf0b9a923e00 events=233 totalChars=420

ログから読み取れる動作:

  • SigV4署名:各API呼び出しに1〜8ミリ秒の署名処理時間
  • 会話履歴のロード:「Loaded 2 messages」→「Loaded 4 messages」と累積
  • エラーハンドリング:addToCartToolでエラー発生後、自律的に代替案を実行
  • 非同期保存:会話履歴の保存がSuccessfully updatedと確認可能
  • イベント数:1回の応答で233〜312イベント(ストリーミングチャンク数)
  1. 代名詞による参照:
    「そこから適当に2000円以内で選んでカートに入れてください」
    → 「そこから」が何を指すかは、直前の番頭さんが提案した4つの推薦商品(どら焼き、抹茶大福、羊羹、カステラ)のリストを覚えているからこそ理解できる

  2. 暗黙の文脈理解:
    「カードで決済してください」
    → 「何を」決済するのか明示していないが、エージェントは以下を理解している:

    • 直前のやりとりでカートに商品を追加した
    • 現在のカート内容(どら焼き×2、羊羹×2、カステラ×1、合計1,560円)
    • お客様が購入手続きに進みたいという意図

    このやりとりは、会話履歴がなければ「何の決済なのか」「カートに何が入っているのか」が全く不明で成立しません。

  3. エラーからの文脈継承と自律的な問題解決:
    最初のカート追加で「抹茶大福は店舗限定」というエラーが発生しましたが、エージェントはそのエラー情報を記憶し、同じリクエスト内で自動的に抹茶大福を除外した代替案(どら焼き、羊羹、カステラ)を選択して再試行しています。お客様に再度指示を求めることなく、自律的に問題を解決しました。

Strands Agentは会話履歴をconversationHistory配列で管理し、各リクエストで過去のやりとりをLLMに渡します(ログの「Loaded 2 messages」→「Loaded 4 messages」はこの動作)。これにより、人間と同じように文脈を保持した自然な会話が可能になっています。

2. アーキテクチャ全体像

私が構築したシステムの特徴を、まずお話しします。このアーキテクチャには、以下の5つの軸があります:

  1. 完全サーバーレス:CloudFront + Lambda + API Gateway + DynamoDB + API Gateway
  2. ゼロトラスト認証:すべてのAPI呼び出しにIAM認証を使用(APIキー不要)
  3. 構造化データ駆動:RAG/ベクトルDBではなくJSON + DynamoDBで正確な情報提供
  4. 関心の分離:Frontend、Backend、Agentが厳密に分離された設計
  5. 型安全な実装:TypeScript + Zodによるツール定義とスキーマバリデーション

これらがどのように実現されているか、順に見ていきましょう。

4層サーバーレスアーキテクチャ

このシステムは、Frontend(Nuxt.js)→ Backend(Hono)→ Agent(Strands + Bedrock)→ Data(DynamoDB) の4層で構成されています。

各層の責務

層 技術スタック 責務 公開方法
Frontend Nuxt.js 3 (SSR) on Lambda UI/UX、BFFパターンでAPI集約、セッション管理 CloudFront + Function URL (OAC)
Backend Hono on Lambda ビジネスロジック、データバリデーション、CRUD操作 API Gateway (IAM認証)
Agent Strands Agents + Bedrock on Lambda 自然言語処理、ツール選択・実行、会話制御 Function URL (IAM認証)
Data DynamoDB + S3 データ永続化、静的アセット配信 IAMポリシーで保護

なぜサーバーレスを選んだのか

私がサーバーレスアーキテクチャを選んだ理由は、コスト効率と運用負荷のバランスです。

コストの観点

  • 従量課金:検証プロジェクトなので、アクセスがなければコストゼロになる点が魅力的でした
  • ARM64:すべてのLambdaでARM64を使用し、x86比で最大34%コスト削減されるはずです
  • DynamoDB On-Demand:トラフィック予測が不要で、自動スケールしてくれるのが助かりました

運用の観点

  • サーバー管理不要:パッチ適用やOS更新を気にせず、アプリケーションコードに集中できました
  • 自動ログ収集:CloudWatch Logsに自動で集約されるので、ログ基盤の構築が不要でした
  • Infrastructure as Code:CDKですべてをコード管理し、再現性を確保しました

セキュリティの観点

  • ゼロトラスト:API呼び出しに全てIAM認証を使い、APIキー漏洩のリスクを避けました
  • データ主権:すべてのリソースをap-northeast-1(東京リージョン)に配置しました
  • HTTPS対応:CloudFront、API Gateway、Lambda関数URLのすべてがHTTPSをデフォルトでサポートしています

https://aws.amazon.com/jp/blogs/news/amazon-bedrock-now-supports-japan-cross-region-inference/?utm_source=chatgpt.com

CDKによるインフラ管理

すべてのAWSリソースはCDKで定義されています。

infrastructure/lib/mahitotsu-ya-stack.ts
// (抜粋)
export class MahitotsuYaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 事前ビルド(Dockerイメージ化前にbun buildを実行)
    preBuildBackend();
    preBuildFrontend();
    preBuildAgent();

    // DynamoDBテーブル作成
    const tables = createDynamoDBTables(this);

    // Backend Lambda + API Gateway
    const { api } = createBackendResources(this, tables, staticAssetsBucket);

    // Agent Lambda + Function URL
    const { agentFunctionUrl } = createAgentResources(this, api, this.region, this.account);

    // Frontend Lambda + CloudFront
    const { distribution } = createCloudFrontDistribution(/* ... */);
  }
}

これにより、以下のようなメリットが得られています。

  • 再現性:cdk deployで同じ環境を何度でも構築可能
  • 変更追跡:Gitで管理、差分がコードレビュー可能
  • ロールバック:デプロイ失敗時の自動ロールバック
  • 依存関係の明示:リソース間の依存関係がコードで明確

3. Strands Agentによる実装

3-1. なぜStrands Agentsを選んだのか

今回、私がStrands Agentsを選んだ理由は、制御可能性とシンプルさのバランスです。

冒頭にも引用した過去の記事で「Agents for Amazon Bedrock」を使用した経験がありますが、プロンプトとAction Groupの記述だけでAgentの挙動を制御するのは困難でした。デバッグはCloudWatch Logsから推測するしかなく、ローカルテストも難しい状況でした。

一方で、Strands Agentsでは、すべてがTypeScriptコードです。ツールの定義から実装まで、通常のNode.jsアプリケーションとして開発できるのは魅力的でした。なにより、AIによるコーディング支援や自動生成の対象とできる点は非常に大きいです。

導入の容易さ:npm installだけで始められる

Strands Agentの最大の魅力の一つは、導入の手軽さです。複雑なセットアップやAWSコンソールでの設定は不要で、通常のNode.jsプロジェクトとして開発を始められます。

必要な依存関係(package.jsonから抜粋):
Strands Agentに必要なパッケージは、通常のNode.jsプロジェクトと変わりません。

agent/package.json
{
  "dependencies": {
    "@strands-agents/sdk": "^0.1.3",
    "@aws-sdk/client-bedrock-runtime": "^3.958.0",
    "hono": "^4.11.1", // HTTPでリクエストを受け付けるために利用
    "zod": "^4.2.1"
  }
}

たったこれだけです。npm install(またはbun install)を実行すれば、すぐにエージェント開発を開始できます。

導入の3ステップ:

ステップ1: 依存関係のインストール(通常のnpmパッケージとして追加)

# 1. 依存関係のインストール
npm install @strands-agents/sdk @aws-sdk/client-bedrock-runtime zod

ステップ2: Agentインスタンスの作成(最小構成はわずか10行)

// 2. 最小限のエージェント作成(10行で動作)
import { Agent } from "@strands-agents/sdk";

const agent = new Agent({
  model: { provider: "bedrock", modelId: "anthropic.claude-3-5-sonnet-20241022-v2:0" },
  systemPrompt: "あなたは親切なアシスタントです",
  tools: [],
});

// 3. 会話の実行
const result = await agent.executeConversation({
  userMessage: "こんにちは",
  conversationHistory: [],
});

console.log(result.response);  // → 「こんにちは!何かお手伝いできることはありますか?」

追加の設定不要:

  • ✅ AWSコンソール不要:Lambda関数に直接デプロイ可能
  • ✅ 専用インフラ不要:既存のNode.js環境で動作
  • ✅ エージェント定義ファイル不要:すべてTypeScriptコードで完結
  • ✅ ローカル開発可能:bun run devで即座に動作確認

3-2. 実装の詳細

実践的な実装例:複数ツールを持つエージェント

最小限のエージェントから一歩進んで、実際のプロジェクト(まひとつ屋)での実装を見てみましょう。ここでは9つのツールを持つエージェントを構築しています。

エージェントの初期化コード(Lambda起動時に1回だけ実行):

agent/src/index.ts
// (抜粋)
import { Agent } from "@strands-agents/sdk";
import { model, SYSTEM_PROMPT } from "./config";
import { requestContext } from "./context";
import {
  getShopInfoTool,
  listProductsTool,
  listFeaturedProductsTool,
  listAnnouncementsTool,
  getProductDetailTool,
} from "./tools/shopInfo";
import {
  getCartContentsTool,
  addToCartTool,
  removeFromCartTool,
  clearCartTool,
} from "./tools/cart";

// Lambdaコンテナ起動時に1回だけ初期化
const agent = new Agent({
  model,                    // Bedrock Claude モデル
  systemPrompt: SYSTEM_PROMPT,  // 番頭としての振る舞い定義
  tools: [
    getShopInfoTool,
    listProductsTool,
    listFeaturedProductsTool,
    listAnnouncementsTool,
    getProductDetailTool,
    getCartContentsTool,
    addToCartTool,
    removeFromCartTool,
    clearCartTool,
  ],
});

ポイント:

  • ステートレス設計:Agentインスタンス自体は状態を持たないように実装
  • インスタンス再利用:そのため、Lambda関数のライフサイクル内でAgentインスタンスを安全に再利用可能
  • セッションコンテキスト:リクエストごとに固有の値はAsyncLocalStorageで注入(後述)

ツールの実装

Strands Agentsのツールは、Zodスキーマ + コールバック関数で定義します。以下は商品一覧取得ツールの例です:

agent/src/tools/shopInfo.ts
import { tool } from "@strands-agents/sdk";
import { z } from "zod";
import { fetchProducts } from "../api/backendClient";

export const listProductsTool = tool({
  name: "list_products",
  description: "Get a list of all products available in the shop.",
  inputSchema: z.object({}),  // パラメータなし
  callback: async () => {
    // AsyncLocalStorageからsessionIdを自動取得
    // backendClientが内部でSigV4署名してBackend APIを呼び出す
    const products = await fetchProducts(50);  // 最大50件
    return { products };
  },
});

これはAsyncLocalStorageに格納されたsessionIdに基づいて特定のクライアントのカートの内容について取得するツールの例です:

agent/src/tools/cart.ts
export const addToCartTool = tool({
  name: "add_to_cart",
  description: "Add a product to the cart. You must use the exact 'id' field from list_products results.",
  inputSchema: z.object({
    productId: z.string().describe("Product ID (e.g., prod_002). Must be exact match."),
    quantity: z.number().int().positive().default(1).describe("Quantity to add"),
  }),
  callback: async (input) => {
    try {
      // AsyncLocalStorageからsessionIdを取得
      const sessionId = getSessionId();
      
      // Backend APIを呼び出す(内部でカート取得→更新→PATCH送信)
      const result = await updateCart(sessionId, input.productId, input.quantity);
      
      return {
        success: true,
        itemCount: result.cart.items.reduce((sum, item) => sum + item.quantity, 0),
        addedProduct: input.productId,
        addedQuantity: input.quantity,
      };
    } catch (err) {
      // Backendエラーをそのまま返す(LLMが自然言語に翻訳)
      const error = err as Error & { recoveryHintForAgent?: string };
      return {
        success: false,
        error: error.recoveryHintForAgent || error.message,
      };
    }
  },
});

実装のポイント:

  • ツールはビジネスロジックを持たず、Backend APIを呼び出すだけ
  • 商品の存在確認、在庫チェック、バリデーションはBackendが実施
  • Backendのシステム的なエラーメッセージに、ToolがAgentのふるまいを助けるためのrecoveryHintForAgentを付与

AsyncLocalStorageによるコンテキスト注入

Agentインスタンスはステートレスですが、各ツールは「現在のリクエストのセッションID」にアクセスする必要があります。この課題をAsyncLocalStorageで解決しています。

コンテキストストアの定義:

agent/src/context.ts
import { AsyncLocalStorage } from 'node:async_hooks';

type RequestContext = {
  sessionId: string;
};

export const requestContext = new AsyncLocalStorage<RequestContext>();

export function getSessionId(): string {
  const context = requestContext.getStore();
  if (!context?.sessionId) {
    throw new Error('Session ID not found in request context');
  }
  return context.sessionId;
}

リクエストハンドラでAsyncLocalStorageにコンテキストを設定:

agent/src/index.ts
// リクエストハンドラ
const handleChat = async (c: Context) => {
  const { prompt, sessionId } = await c.req.json();

  // AsyncLocalStorageにコンテキストを設定
  return requestContext.run({ sessionId }, async () => {
    // この中でAgent.stream()を呼ぶと、すべてのツールがsessionIdにアクセス可能
    for await (const event of agent.stream(prompt)) {
      // ストリーミング処理
    }
  });
};

コンテキストの利用:

なぜこの設計なのか:
適切な文脈に基づいた情報の取得をLLMに任せるのは危険だと感じているためです。LLMの外側でsessionIdに基づいた適切な絞り込みを行った情報のみをLLMに引き渡すようにすることが出来ます。

システムプロンプトの役割

システムプロンプトは、振る舞いとツールの使い方に特化させました。業務知識は含めず、ツールから取得するデータに委譲します。

agent/src/config.ts
// (抜粋)
export const SYSTEM_PROMPT = `あなたは「まひとつ屋」という老舗和菓子店の番頭です。
お客様の質問に対して、丁寧で親しみやすい口調で応答してください。

【応答範囲の制約】
以下の内容についてのみ回答してください:
- 当店の商品(和菓子)に関すること
- 当店の営業時間、アクセス、店舗情報
- 当店の歴史、モットー、理念
...

【口調の特徴】
- 基本形: 「〜です」「〜ます」「〜いたします」
- 丁寧な語尾: 「〜でございます」「〜ございます」
...

【重要】商品情報、お知らせ、店舗情報、カート操作に関する質問には、
必ず提供されているツールを使用して最新の情報を取得・操作してください。
推測や一般的な知識ではなく、ツールから取得した実際のデータに基づいて回答してください。

【ツール使用ガイドライン】
店舗情報(get_shop_infoツール):
- 営業時間、定休日、所在地 → sections: ["basic"]
- 店舗紹介、店の特徴 → sections: ["about"]
- お店の歴史、創業の経緯 → sections: ["history"]
...

【重要】商品をカートに追加する際の手順:
1. 必ずlist_productsまたはlist_featured_productsを呼び出して商品一覧を取得
2. 取得した商品の「id」フィールド(例: prod_002)を正確にコピー
3. その正確なIDをadd_to_cartツールのproductIdパラメータに指定
4. 商品名から推測したIDは使用しない
...`;

システムプロンプトに書くべきこと/書かないこと:

書くべき 書かないべき
口調、応対スタイル 商品の具体的な価格や在庫
ツールの使い方、使用タイミング 営業時間などの店舗情報
応答範囲の制約 業務ルール(「上生菓子はオンライン販売不可」など)
エラー時の振る舞い 商品カテゴリの一覧

理由:業務知識や業務プロセスは変化します。それらをシステムプロンプトにハードコードすると、変更のたびにAgentの再デプロイが必要になります。代わりに、ツールから取得する構造化データに委譲することで、データ更新だけで対応できます。

ストリーミング応答の実装

Agentの応答はServer-Sent Events (SSE)でストリーミング配信されます。これにより、ユーザーは応答の最初のトークンをすぐに確認でき、体感速度が向上します。

agent/src/index.ts
// (抜粋)
import { streamSSE } from "hono/streaming";

const handleChat = async (c: Context) => {
  const { prompt, sessionId } = await c.req.json();

  return requestContext.run({ sessionId }, async () => {
    const chunks: string[] = [];

    return streamSSE(c, async (stream) => {
      for await (const ev of agent.stream(prompt)) {
        const text = ev.text ?? ev.delta ?? ev.response;
        
        if (typeof text === "string" && text.length > 0) {
          chunks.push(text);
          await stream.writeSSE({ data: JSON.stringify({ event: text }) });
        }
      }

      // ストリーム終了マーカー
      await stream.writeSSE({ data: JSON.stringify({ done: true }) });

      // 会話履歴を非同期で保存(応答を遅らせない)
      updateConversationHistory(sessionId, prompt, chunks.join('')).catch(console.error);
    });
  });
};

ストリーミングのメリット:

  • 体感速度向上:ユーザーは最初の数トークンですぐ応答を確認できる
  • リアルタイム感:「考えている」感覚をユーザーに提供
  • Function URLでの配信:AgentはFunction URL経由で公開されており、レスポンスストリーミングが有効化されているため、長い応答でもタイムアウトを気にせず配信できます

会話履歴の管理

セッションごとの会話履歴はDynamoDBに保存され、次回のリクエストで取得されます。これにより文脈を保持した会話が可能になります。

会話履歴の永続化: 各ターンの対話内容をBackendのセッションAPIに保存することで、Lambda関数が破棄されても会話の文脈を維持できます。以下のクライアント関数を使ってセッションデータを取得・更新します。実際の永続化は呼び出されているBackend APIが行っています。

agent/src/api/backendClient.ts
// (抜粋)
export async function getSessionData(sessionId: string) {
  const response = await callBackendAPI(`/api/v1/sessions/${sessionId}`, 'GET');
  return response.data;  // { sessionId, cart, conversationHistory }
}

export async function updateConversationHistory(
  sessionId: string,
  userMessage: string,
  assistantMessage: string
) {
  await callBackendAPI(`/api/v1/sessions/${sessionId}/conversation`, 'POST', {
    userMessage,
    assistantMessage,
  });
}

リクエストハンドラでの会話履歴の利用:

agent/src/index.ts
// (抜粋)
const handleChat = async (c: Context) => {
  const { prompt, sessionId } = await c.req.json();

  // セッションから会話履歴を取得
  const sessionData = await getSessionData(sessionId);
  const conversationHistory = sessionData?.conversationHistory || [];

  // 会話履歴をプロンプトに注入
  let enrichedPrompt = prompt;
  if (conversationHistory.length > 0) {
    const historyText = conversationHistory
        .map((msg) => `${msg.role === 'user' ? 'お客様' : '番頭さん'}: ${msg.content}`)
  // Agent.stream()に渡す
  for await (const ev of agent.stream(enrichedPrompt)) {
    // ...
  }
};

設計のポイント:

  • 会話履歴はBackendが管理:Agentは取得するだけ
  • プロンプト拡充:過去の会話をプロンプトに直接注入(シンプルで効果的)
  • 非同期保存:応答ストリーミング後に履歴を保存(ユーザー体験を優先)

4. 構造化データが正確性をもたらす理由

RAGではなく構造化データを選んだ理由

AIエージェントに情報を提供する方法として、RAG(Retrieval-Augmented Generation) が注目されていますが、私は最終的に構造化データ(JSON or DynamoDB) を選びました。

この判断は、データの正確性、応答速度、運用コストを総合的に考えた結果です。

私がRAGを避けた理由

今回は、定性的な情報であっても以下の理由でRAGよりも構造化データの利用が適切だと判断しました:

  1. 正確性の要求に対する不足

    • 商品価格、営業時間、来歴などは100%正確でなければならない
    • RAGの検索結果には「似た情報」が混ざる可能性がある
    • ベクトル検索のスコアリングで「たぶん合っている」では困る
  2. 二重メンテナンスの発生

    • 情報源の更新後、EmbeddingとベクトルDBへのロードによる同期が必要
    • 情報源とベクトルDBが同期しているかどうかの判定が容易ではない

構造化データのメリット

一方、構造化データ(DynamoDB + JSON)をBacked APIを介して照会する方式には以下のメリットがありました:

  1. 正確性:情報のカテゴリ毎に構造化され、Agentが読み取る必要がある範囲が明確
  2. 単一の情報源:Backend APIは、AgentもFrontendも同一のものを利用可能であり、常に同期することが可能
  3. コスト効率:Embedding処理やベクトルDBのランニングコストが不要
構造化データ(「まひとつ屋」のデータの抜粋)
public/data/shop-info.json
{
  "basic": {
    "name": "まひとつ屋",
    "tagline": "一つ一つの和菓子に、季節の移ろいと職人の心を込めて",
    "hours": "9:00 - 18:00",
    "closedOn": "水曜日",
    "phone": "03-0000-0000",
    "address": "東京都○○区○○ 1-2-3",
    "founded": "明治15年",
    "foundedYear": 1882,
    "currentGeneration": 5
  },
  "about": {
    "features": [
      {
        "icon": "🌾",
        "title": "厳選された素材",
        "description": "北海道産の小豆、国産のもち米、丹波栗など..."
      },
      {
        "icon": "👨‍🍳",
        "title": "職人の技",
        "description": "代々受け継がれてきた伝統の技法を守りながら..."
      }
    ]
  },
  "history": {
    "timeline": [
      {
        "year": "明治15年",
        "era": "1882年",
        "title": "創業",
        "description": "初代・松平茂三郎が東京・浅草にて...",
        "highlight": true
      },
      {
        "year": "昭和57年",
        "era": "1982年",
        "title": "創業100周年",
        "description": "創業100周年を迎える...",
        "highlight": true
      }
    ],
    "craftsmen": [
      {
        "generation": 5,
        "name": "松平 健太郎",
        "period": "令和3年 -",
        "description": "現店主。先代から受け継いだ技と心を...",
        "current": true
      }
    ]
  },
  "payment": {
    "methods": [
      {
        "id": "credit",
        "name": "クレジットカード",
        "icon": "💳",
        "fee": 0,
        "details": {
          "brands": ["Visa", "Mastercard", "JCB", "American Express"]
        }
      },
      {
        "id": "cod",
        "name": "代金引換",
        "icon": "📦",
        "fee": 330,
        "notes": ["お支払いは現金のみとなります"]
      }
    ]
  }
}

構造化データとRAGのハイブリッド戦略

ただし、RAGが全く不要というわけではありません。私は将来的に以下のような使い分けを考えています:

構造化データで管理すべき情報:

  • 商品マスター(ID、名前、価格、カテゴリ、オンライン販売可否)
  • 営業時間、定休日、アクセス情報
  • 注文履歴、カート状態

RAGで扱うべき情報:

  • 和菓子の歴史や製法の詳細説明
  • 季節ごとのおすすめ商品コラム
  • お客様からのよくある質問(FAQ)

つまり、厳密性が求められる業務データは構造化し、探索的な情報提供にはRAGを使うという戦略です。

5. 関心の分離とアーキテクチャ設計

次はアーキテクチャ全体の保守性について書きます。私がFrontend、Backend、Agentをどのように分離し、それぞれが独立して進化できるようにしたのか、お話しします。

私が重視したAPIコントラクト

私の設計では、各層はAPIコントラクトのみに依存するようにしました。

Backend API Contract(例:商品一覧取得)

Backendが保証するAPIレスポンス形式:

GET /api/v1/products のレスポンス
{
  "success": true,
  "data": [
    {
      "id": "prod_001",
      "name": "抹茶大福",
      "category": "大福",
      "price": 250,
      "availableOnline": true,
      "stock": 50
    },
    // ...
  ],
  "timestamp": "2025-12-29T12:00:00Z"
}

Frontendの視点
FrontendはBackendの内部実装を知らず、APIコントラクトのみに依存します:

frontend/server/api/products.get.ts
export default defineEventHandler(async (event) => {
  const response = await callBackendAPI('/api/v1/products');
  
  // Backendの内部実装は知らない(DynamoDBかRDSか、どうバリデーションしているか)
  // APIコントラクトで定義されたレスポンス形式だけを信頼
  return response;
});

Agentの視点
Agentも同様に、APIコントラクトのみに依存します:

agent/src/tools/shopInfo.ts
export const listProductsTool = tool({
  // ...
  callback: async () => {
    const products = await callBackendAPI('/api/v1/products');
    
    // Backendの内部は知らない(商品がどこに保存されているか、どう取得しているか)
    // APIから返ってきたデータをそのまま返す
    return products;
  },
});

この設計の結果:

  • Backendの内部実装(DynamoDB→RDSなど)を変更しても、FrontendとAgentは影響を受けません
  • APIコントラクトさえ維持されていれば、各層は独立して進化できます

分離の具体例1:データとロジックの実装場所

悪い例:AgentがBackendの責務を侵害
Agentがビジネスロジックを持つことで、Backendとの二重管理が発生します:

❌ 悪い例(Agentが責務を侵害)
// Agent側で商品の存在確認とバリデーション
export const addToCartTool = tool({
  // ...
  callback: async (input) => {
    // Agentが商品マスターデータを保持
    const validProducts = ['prod_001', 'prod_002', 'prod_003'];
    
    if (!validProducts.includes(input.productId)) {
      return { success: false, error: '商品が見つかりません' };
    }
    
    // Agentがオンライン販売可否を判定
    if (input.productId === 'prod_005') {
      return { success: false, error: 'この商品はオンラインでは購入できません' };
    }
    
    // BackendAPIを呼ぶ(すでに検証済み)
    await callBackendAPI('/api/v1/sessions/cart', sessionId, { /* ... */ });
  },
});

問題点:

  • Agentが商品マスターの知識を持っている(Backendと二重管理)
  • 商品追加時、AgentとBackendの両方でバリデーションコードを修正する必要がある
  • Backendのビジネスルールが変わったときに整合性が崩れる

良い例:Backendに完全委譲
Agentは薄いクライアントとして、すべての判断をBackendに委譲します:

✅ 良い例(Backendに完全委譲)
// Agentは何も判断せず、Backendに丸投げ
export const addToCartTool = tool({
  // ...
  callback: async (input) => {
    const sessionId = getSessionId();
    
    // BackendAPIを呼ぶだけ(検証はBackendが実施)
    // 注:実際にはカート全体を取得→更新→PATCH送信する実装
    const result = await updateCart(sessionId, input.productId, input.quantity);
    
    // Backendの結果をそのまま返す
    return result;
  },
});

Backend側では包括的なバリデーションを実施します:

backend/src/routes/sessions.ts
// (抜粋)
app.patch('/api/v1/sessions/:id/cart', async (c) => {
  const sessionId = c.req.param('id');
  const body = await c.req.json();
  
  // カート構造のバリデーション
  const cartSchema = z.object({
    items: z.array(
      z.object({
        productId: z.string(),
        quantity: z.number().int().positive(),
        addedAt: z.string().optional(),
      })
    ),
  });
  
  const parsed = cartSchema.safeParse(body);
  if (!parsed.success) {
    return c.json(errorResponse('BadRequest', 'Invalid cart data'), 400);
  }
  
  // ① 全商品の存在確認とオンライン販売可能性チェック
  const missingProductIds: string[] = [];
  const offlineProducts: Array<{ id: string; name: string }> = [];
  
  for (const item of parsed.data.items) {
    const product = await getProductById(item.productId);
    if (!product) {
      missingProductIds.push(item.productId);
    } else if (!product.availableOnline) {
      offlineProducts.push({ id: product.id, name: product.name });
    }
  }
  
  // ② エラーチェック
  if (missingProductIds.length > 0) {
    return c.json(
      errorResponse(
        'InvalidProductId',
        `The following productIds do not exist: ${missingProductIds.join(', ')}`,
        { missingProductIds }
      ),
      400
    );
  }
  
  if (offlineProducts.length > 0) {
    const productList = offlineProducts.map(p => `${p.name} (${p.id})`).join(', ');
    return c.json(
      errorResponse(
        'ProductNotAvailableOnline',
        `The following products are not available for online purchase: ${productList}`,
        { offlineProducts }
      ),
      400
    );
  }
  
  // ③ カート更新(完全置換)
  const updatedSession = await updateSessionCart(sessionId, parsed.data);
  return c.json(successResponse(updatedSession));
});

メリット:

  • Single Source of Truth:商品マスターとビジネスルールはBackendのみが管理
  • 変更の局所化:ビジネスルール変更時はBackendだけを修正すればよい
  • 一貫性:AgentとFrontendが常に同じルールで動作することを保証

分離の具体例2:エラーメッセージの設計

Backendはシステム向けエラーを返す
Backendは事実ベースのエラーメッセージを返します:

backend/src/routes/sessions.ts
if (offlineProducts.length > 0) {
  const productList = offlineProducts.map(p => `${p.name} (${p.id})`).join(', ');
  return c.json(
    errorResponse(
      'ProductNotAvailableOnline',
      `The following products are not available for online purchase: ${productList}`,
      { offlineProducts }
    ),
    400
  );
}

Agentはエラーを中継し、LLMが翻訳
AgentはBackendエラーをそのまま返し、LLMに自然言語化を任せます:

agent/src/tools/cart.ts
export const addToCartTool = tool({
  // ...
  callback: async (input) => {
    try {
      const result = await callBackendAPI(/* ... */);
      return result;
    } catch (err) {
      // Backendエラーをそのまま返す(LLMが解釈)
      const error = err as Error & { recoveryHintForAgent?: string };
      return {
        success: false,
        // recoveryHintForAgentはBackendではなく、Toolが付与する
        error: error.recoveryHintForAgent || error.message,
      };
    }
  },
});

recoveryHintForAgent属性には、Backendからのエラー種別ごとにAgentが次にどのように振舞うべきかのヒントを設定しています。

src/api/backendClient.ts
  if (res.statusCode >= 400) {
    const errorBody = res.json as { success: false; error: string; message: string; details?: Record<string, unknown> } | undefined;
    
    let recoveryHintForAgent = 'An error occurred while updating the cart.';
    
    if (errorBody?.error === 'InvalidProductId') {
      const invalidIds = (errorBody.details?.invalidProductIds as string[]) || [];
      recoveryHintForAgent = `The following product IDs do not exist in the system: ${invalidIds.join(', ')}. Please call list_products to get valid product IDs.`;
    } else if (errorBody?.error === 'ProductNotAvailableOnline') {
      const notAvailableProducts = (errorBody.details?.notAvailableProducts as Array<{ id: string; name: string }>) || [];
      const productNames = notAvailableProducts.map(p => `${p.name} (${p.id})`).join(', ');
      recoveryHintForAgent = `The following products are not available for online purchase: ${productNames}. These products can only be purchased in-store.`;
    } else if (errorBody?.error === 'SessionExpired') {
      recoveryHintForAgent = 'The session has expired. Please ask the user to refresh the page and try again.';
    } else if (errorBody?.error === 'SessionNotFound') {
      recoveryHintForAgent = 'Session not found. Please ask the user to refresh the page and try again.';
    }
    
    const error = new Error(errorBody?.message || 'Cart update failed');
    (error as Error & { recoveryHintForAgent?: string }).recoveryHintForAgent = recoveryHintForAgent;
    throw error;
  }

LLMが顧客向けメッセージに変換
最終的にLLMが顧客向けのメッセージを生成します。

LLM(Claude)の応答
LLM(Claude)の応答:
「申し訳ございません。こちらの商品は店舗でのみ販売しており、
オンラインではお求めいただけません。店頭でのご来店を心よりお待ちしております。」

私がこの設計を選んだ理由:

  1. Backendは事実のみ報告:「この商品はオンライン販売不可」という事実だけを返せば、Backendは顧客対応を意識しなくて済みます
  2. Agentは翻訳に専念:技術的エラーを顧客に優しい言葉に変えるという、LLMが得意な仕事を任せます
  3. Toolはヒントを提供:Backendからのエラーに対してAgentがどう振舞うのが良いのか、そのヒントを与える役割はBackendとAgentの橋渡し役であるToolが最適です
  4. エラーメッセージの柔軟性:BackendのAPIは変えずに、応答の口調だけをシステムプロンプトで調整できます

この分離を徹底することで、以下のメリットが得られます:

  1. 独立した進化:例えば、Frontendの技術スタックをNext.jsに変えてもBackendとAgentは影響を受けません
  2. 並行開発:私が一人で開発しましたが、チームであればFrontend、Backend、Agentを独立して進められます
  3. 責務の明確化:バグが発生したとき、どの層に問題があるか特定しやすくなりました
  4. テスト容易性:各層を独立してテストできるので、問題の切り分けが楽でした

6. 失敗から学んだこと:私が通った回り道

ここまで説明した「私が採用した設計」の裏には、それらの設計を採用しなかったことで失敗した体験が隠れています。それをいくつかお話ししようと思います。

失敗1:Agentにビジネスロジックを持たせた

最初にやったこと:

// ❌ 最初の実装(失敗例)
export const addToCartTool = tool({
  callback: async (input) => {
    // Agentが商品のオンライン販売可否を判定
    const product = await getProduct(input.productId);
    if (!product.availableOnline) {
      return { success: false, error: "この商品はオンライン販売不可" };
    }
    // ...
  },
});

何が問題だったか:

  • Backend側でも同じ判定を実装 → ロジックが二重化
  • availableOnlineのルールが変わったとき、AgentとBackend両方を変更する必要
  • テストが二重に必要(AgentとBackend両方で同じケースをテスト)

修正後(現在の設計):

// ✅ 修正後:Backendに丸投げ
export const addToCartTool = tool({
  callback: async (input) => {
    try {
      // Backend APIを呼ぶだけ(判定はBackendが行う)
      const result = await callBackendAPI('/api/v1/cart/add', 'POST', input);
      return result;
    } catch (err) {
      // Backendのエラーをそのまま返す
      return { success: false, error: err.message };
    }
  },
});

教訓:AgentはBackend APIのクライアントに徹する。判断はBackendに任せる。

失敗2:システムプロンプトに業務知識を書いた

最初にやったこと:

const SYSTEM_PROMPT = `
あなたは和菓子店「まひとつ屋」の接客係です。

【取扱商品】
- どら焼き:220円(オンライン販売可)
- 抹茶大福:280円(店舗限定)
- 羊羹:350円(オンライン販売可)
...

【営業時間】
平日:9:00-18:00
土日:10:00-17:00
`;

何が問題だったか:

  • 商品価格が変わるたびに、Agentの再デプロイが必要
  • 新商品追加のたびに、システムプロンプトを編集 → Lambda再デプロイ
  • 営業時間の変更すらデプロイが必要 → 緊急対応ができない

修正後(現在の設計):

const SYSTEM_PROMPT = `
あなたは和菓子店「まひとつ屋」の接客係です。

【利用可能なツール】
- get_shop_info: 営業時間、店舗情報を取得
- list_products: 商品一覧と価格を取得
...

【応答スタイル】
- 丁寧な言葉遣い
- 和菓子の魅力を伝える
`;

教訓:システムプロンプトには「振る舞い」だけを書く。業務知識は全てツール経由で取得する。

失敗3:セッション設計を後回しにした

最初にやったこと:

  • セッション管理の実装を後回しにしたため、会話履歴とカート状態をブラウザ側で保持する暫定実装を採用。Frontendから会話履歴とカート状態を常にエージェントへ送信
  • その後、会話履歴とカート状態の管理を本来担うべきBackendへ移行するため、セッションの概念を導入

何が問題だったか:

  • Agentインスタンスを複数リクエストにまたがって再利用していたが、受信したリクエストごとに異なるsessionIdを扱う必要が生じ、同一Agentインスタンスではリクエストを処理できなくなった

修正後(現在の設計):

  • Agentインスタンスのライフサイクルは変えず、リクエスト固有の情報はAsyncLocalStorageで注入する
  • 今回は無事に解決できたが、実装内容によっては大幅なアーキテクチャ変更につながるリスクがあった

教訓:ユーザーやクライアント毎に分離すべき情報の管理方法として、セッション管理は最初から考慮しておく。

実装しながら気づいたこと

私はこれらの失敗を通じて、設計フェーズで方針を決めておくことの重要性を実感しました。AgentとBackendの責務をどう分けるか、業務知識をどこに持つか、セッション管理をどう設計するか——これらは実装しながらでも決められますが、やはり事例やベストプラクティスを知っていれば迷う時間は減らせるはずです。

この感覚が、この記事をかくモチベーションになりました。

7. 最後に

この記事では、「まひとつ屋」の実装を例に、AIエージェントをアプリケーションの一部として扱った場合に、どのような設計判断が必要になるかを書きました。私は、すでにAIエージェントは「あとから付け足す存在」ではなく、初期設計の段階でその存在を前提に考えるべきものになったと感じ始めています。

この記事が、AIエージェントをアプリケーションに取り込むための設計方針について考え始めるきっかけになってくれればうれしく思います。

Discussion