はじめに
こんにちは!
カスタマーサクセス本部 CS4課 櫻庭です。
今回は、予約情報の収集+質問回答を行える予約ボットという切り口で構成例をご紹介します。本ブログ上でのサンプルは飲食店予約ボットです。Amazon Lex V2とKnowledge BaseをLambdaを介して連携させる構成としています。
マネジメントコンソールでのLex設定とLambda実装を中心に、実際に動作するボットを作成していきます。
構築するボットの機能
- 予約情報のヒアリング
- ヒアリングした予約情報の確認
- 予約情報の登録
- 予約中に発生した質問への回答
想定読者
- AWSの基本的なサービス(Lambda、DynamoDB)に触れたことがある方
- Amazon LexやBedrockの連携に興味がある開発者
- 実用的なチャットボットの構築を学びたい方
前提知識
必要なAWSサービスの理解
ざっくり、以下のような構成とします。

あまり詳しくない方のためにサービス概要的なところ含めつつ、文章でも改めて簡単に記載します。(把握されてる方は読み飛ばして構いません)
Amazon Lex V2
対話型AI(チャットボット)サービス
- 音声・テキストでの自然言語処理を提供
- インテント: ユーザーの意図を定義(例:予約したい)
- スロット: 収集が必要な情報を定義(例:日時、人数)
- 確認プロンプト: 収集した情報の確認機能
- Lambda統合: カスタムビジネスロジックの実行
今回の構成での役割: ユーザーとの対話制御、予約情報の収集
Amazon Bedrock Knowledge Base
RAG(検索拡張生成)システム
- 独自ドキュメントのベクトル化・検索機能
- ベクトル検索: 質問に関連する情報を高精度で取得
- LLM統合: Claude等のモデルで自然な回答を生成
- 検索結果の活用: 取得した情報を基にした回答生成
今回の構成での役割: 予約中の質問(営業時間等)への自動回答
AWS Lambda
サーバーレス実行環境
- イベント駆動でのコード実行
- 自動スケーリング: 負荷に応じた処理能力の調整
今回の構成での役割: ナレッジベース検索やDynamoDBの制御
Amazon DynamoDB
NoSQLデータベース
- 高速な読み書き性能を提供
- キー・バリュー型: 柔軟なデータ構造に対応
- 自動スケーリング: アクセス量に応じた性能調整
今回の構成での役割: 確定した予約データの永続化・管理
前提環境
Knowledge Baseの準備
本記事では既存のKnowledge Baseを使用します。(Knowledge Baseの作成方法は本記事内では触れません)
その他の準備
- AWS CLI設定(CLIテスト用)
想定ユースケースと会話例
以下のようなユースケースを想定しています。
ユースケース例
- 予約ボット(質問回答機能付き)
- 飲食店の予約
- ホテルの予約
- 会議室の予約
- 質問回答ボット
- 商品に関する質問への回答ボット
- 社内規約に関する質問への回答ボット
具体的な会話例
通常の予約フロー:
ユーザー: 「予約したい」 ボット: 「いつの予約でしょうか?」 ユーザー: 「明日」 ボット: 「何時からの予約でしょうか?」 ユーザー: 「19時」 ボット: 「何名様でしょうか?数字だけお答えください。」 ...
質問が挟まるフロー(★の部分で営業時間の質問に対応している):
ユーザー: 「予約したい」 ボット: 「いつの予約でしょうか?」 ユーザー: 「明日」 ボット: 「何時からの予約でしょうか?」 ★ユーザー: 「営業時間を教えて」 ★ボット: 「営業時間は17:00-23:00です。 予約を継続します。いつの予約でしょうか?」 ユーザー: 「19時」 ボット: 「何名様でしょうか?数字だけお答えください。」 ...
作成の流れ(アウトライン)
本記事では以下の順序で飲食店予約ボットを構築します。
構築手順
- DynamoDBテーブル作成
- Lambda関数作成・実装
- Lex V2ボット設定
- エイリアス作成
- テスト実行
完成後の機能
- 予約情報の段階的収集(日時・人数・コース・連絡先)
- 予約内容の確認と登録
- 予約中の質問への自動回答(Knowledge Base連携)
- DynamoDBへの予約データ永続化
構築手順
ステップ1: DynamoDBテーブルの作成
まずは予約データを格納するテーブルを作成します。
1-1: テーブル設計
保存する予約データの構造:
| フィールド名 | データ型 | 役割 | 例 |
|---|---|---|---|
reservationId |
文字列 | パーティションキー、予約ID | RES20250830190000 |
reservationDate |
文字列 | 予約日 | 2025-08-30 |
reservationTime |
文字列 | 予約時間 | 19:00 |
partySize |
文字列 | 人数 | 2 |
courseType |
文字列 | コース種別 | お得な海鮮バリューコース |
customerName |
文字列 | 顧客名 | 田中 |
phoneNumber |
文字列 | 電話番号 | 0123-456-789 |
createdAt |
文字列 | 作成日時 (ISO形式) | 2025-08-28T18:00:00 |
1-2: 作成手順
- DynamoDB コンソールでテーブルを作成
- テーブル名:
eatery-reservations - パーティションキー:
reservationId(文字列)
- テーブル名:
ステップ2: Lambda関数の作成・実装
Lexで使用するLambda関数を作成し、実装します。
2-1: 実装の考え方
さて、ここが今回のポイントです!
「予約」と「質問回答」は別々のユーザー意図とも言えますが、別々のインテントとして構成すると、質問回答を挟んだ際に収集中の予約情報を維持しつつ質問回答し、予約を適切に継続する制御が煩雑になります(スロット情報が失われるため、セッション格納&リストアが必要、等)。そのため本記事では、質問回答はあくまで予約のためのオプション操作として捉え、一つのインテントで予約と質問回答を両方とも処理してしまう構成としています。
2-2: 基本アーキテクチャ
- 単一インテント設計:
ReservationIntent一つで予約とKB検索を統合処理 - スロット進捗管理: セッション属性でスロット収集状況を追跡
- 動的KBトリガー: スロット進捗が停滞した場合のみKB検索を実行
アーキテクチャパターンの選択について
本記事では、以下を踏まえてLambda直接統合パターンを採用しています。
- 高速レスポンス: エージェント経由よりも低レイテンシで応答
- 予測可能な動作: 明確な条件分岐による制御フロー
- コスト効率: 必要時のみKB検索を実行
代替パターン: Bedrockエージェント統合
より複雑な対話制御が必要な場合は、Bedrockエージェントの利用も検討できます。
- メリット: 自然言語による柔軟な判断、複数ツールの自動選択
- デメリット: レスポンス時間の増加、コストの上昇
- 適用場面: 複雑な業務フロー、多段階の意思決定が必要な場合
2-3: 処理フロー
-
スロット収集フェーズ
- Lexにスロット収集を委任(
DELEGATE) - スロット数の変化を監視
- Lexにスロット収集を委任(
-
KB検索判定
- スロット進捗が無く、予約確認フェーズでもない場合、KB検索が必要と見なす
-
KB検索処理
- Bedrock Knowledge Baseで情報検索
- Claude 3 Haikuで回答生成
- 回答後に予約継続(
ELICIT_SLOT)
-
予約確認フェーズ
- 全スロット収集後に確認プロンプト表示
- 承認時にDynamoDB保存 + 確認メッセージ
2-4: エラーハンドリング
- DynamoDB保存失敗時はエラーメッセージで電話予約を案内
- KB検索エラー時はシステムエラーメッセージで継続
2-5: データ構造
reservation_data = {
'reservationId': 'RES20250830180000',
'reservationDate': '2025-08-30',
'reservationTime': '19:00',
'partySize': '2',
'courseType': 'お得な海鮮バリューコース',
'customerName': '田中',
'phoneNumber': '0123-456-789',
'createdAt': '2025-08-28T18:00:00'
}
実装手順:
- AWS Lambda コンソールで新しい関数を作成
- 関数名:
eatery-bot-handler- ランタイム: Python 3.13
- 関数名:
-
実装コードをアップロード(詳細は「付録: Lambda実装詳細」参照)
ファイル構成:
- メインファイル:
eatery-bot-handler.py - 設定ファイル:
config.py
アップロード手順:
- 上記2ファイルを同じフォルダに配置
- フォルダ全体をZIPファイルに圧縮 (例:
lambda-function.zip) - Lambdaコンソールで「アップロード元」→「.zipファイル」を選択してアップロード
- メインファイル:
- タイムアウト設定
- 設定タブからタイムアウトを30秒に設定
- ハンドラー設定:
- ランタイム設定セクションで「編集」をクリック
- ハンドラ:
eatery-bot-handler.handler
- 環境変数を設定:
KNOWLEDGE_BASE_ID =RESERVATION_TABLE_NAME = eatery-reservations
- IAM権限を追加:
以下のJSONポリシーをカスタムポリシーとして追加作成
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"bedrock:Retrieve",
"bedrock:InvokeModel"
],
"Resource": [
"arn:aws:bedrock:us-east-1:*:knowledge-base/*",
"arn:aws:bedrock:*::foundation-model/*"
]
},
{
"Effect": "Allow",
"Action": [
"dynamodb:PutItem"
],
"Resource": "arn:aws:dynamodb:*:*:table/eatery-reservations"
}
]
}
ステップ3: Lex V2ボットの設定
3-1: ボットの作成
- Amazon Lex コンソールにアクセス
- 「ボットを作成」をクリック
- 「空のボットから開始」を選択
- ボット設定:
- ボット名:
eatery-bot - 説明: 飲食店予約ボット
- IAMロール: 新しいロールを作成
- 子供向けオンライン プライバシー保護法 (COPPA): いいえ
- アイドルセッションタイムアウト: 5分
- ボット名:
3-2: 言語の追加
- 言語: 日本語 (ja_JP)
- 音声による対話: 任意の音声を選択(本記事ではMizuki)
3-3: カスタムスロットタイプの作成
- 左ペインから 「スロットタイプ」を選択(ボット作成後、インテント設定の画面に自動遷移しているが中断してOK)
- 「スロットタイプを追加」「空のスロットタイプを追加」をクリック
- スロットタイプ名:
CourseType - スロット値の解決:
スロット値に制限 - スロット値を追加
- 生うに盛りだくさんコース (同義語: 盛りだくさんコース, 8000円のコース, 一番高いコース)
- 生うに付き海鮮スペシャルコース (同義語: スペシャルコース, 6000円のコース, 海鮮スペシャル)
- お得な海鮮バリューコース (同義語: バリューコース, 4000円のコース, お得なコース)
- コース無し (同義語: 席のみ, なし)

- 「スロットタイプを保存」をクリック
3-4: インテントの作成
- 左ペインから「インテント」を選択
- 自動作成されている 「NewIntent」 をクリック
- インテント名:
ReservationIntent
3-5: サンプル発話の追加
「サンプル発話」セクションで以下を追加
予約したい 予約をお願いします 席を予約したい コースを予約したい 今度の土曜日に予約したい

3-6: スロットの設定
「スロット」セクションで以下のスロットを追加
| 名前 | スロットタイプ | このインテントには必須 | プロンプト |
|---|---|---|---|
reservationDate |
AMAZON.Date |
✓ | いつの予約でしょうか? |
reservationTime |
AMAZON.Time |
✓ | 何時からの予約でしょうか? |
partySize |
AMAZON.Number |
✓ | 何名様でしょうか?数字だけお答えください。 |
courseType |
CourseType |
✓ | どちらのコースをご希望でしょうか? |
customerName |
AMAZON.FreeFormInput |
✓ | お名前をお聞かせください。 |
phoneNumber |
AMAZON.PhoneNumber |
✓ | お電話番号をお聞かせください。 |

3-7: 確認プロンプトの設定
「確認」セクション
-
アクティブ: ✓
-
確認プロンプト:
{reservationDate}の{reservationTime}に{partySize}名様、{courseType}コースで、{customerName}様({phoneNumber})でご予約でよろしいでしょうか?
- 応答を拒否する: かしこまりました。また何かご用がございましたらお声かけください。
3-8: コードフックとフルフィルメントの設定
「フルフィルメント」セクション
- アクティブ: ✓
- Lambda関数:
eatery-bot-handler(同じ関数) TODO - フルフィルメントに Lambda 関数を使用: ✓
「コードフック」セクション
- 初期化と検証に Lambda 関数を使用: ✓
- Lambda関数:
eatery-bot-handler(事前作成済み) TODO
インテントを保存
3-9: FallbackIntentの設定
- 自動作成されている 「FallbackIntent」** をクリック
「応答を閉じる」セクション
- アクティブ:: ✓
- メッセージ: すみません、理解できませんでした。もう一度お聞かせください。
インテントを保存
3-10: ボットのビルドとテスト
- 「構築」をクリック
ステップ4: バージョン、エイリアスの作成
4-1: バージョンの作成
- 左ペインから「ボットのバージョン」を選択
- 「バージョンを作成」をクリック
- 「作成」をクリック
4-2: エイリアスの作成
- 左ペインから「エイリアス」を選択
- 「エイリアスを作成」をクリック
- エイリアス名: 任意の名称(本記事では
Production) - バージョン: 作成済みの最新バージョン(初回はバージョン: 1)
- 「作成」をクリック

4-3: Lambdaの関連付け
- 作成したエイリアスを選択し詳細画面に遷移
- 言語 セクションの
Japanese (Japan)をクリック eatery-bot-handlerを選択し保存
ステップ5: テスト方法
それでは動かしてみます。
5-1: Lexコンソールでのテスト
- 左ペインからボットのバージョンをクリック
- 作成済みの最新バージョン(初回はバージョン: 1)をクリック
- 言語セクションの「Japanese (Japan)」をクリック
- 「テスト」 をクリック
- **作成したエイリアスを選択して「確認」
- 以下のメッセージでテストします。(ちなみにマイク入力すると、応答もテキストだけでなく音声で返されます)
- “予約したい”
- “明日”
- “営業時間を教えて”
- “19時”
- “2”
- “何のコースがあるか教えて”
- “6000円のコース”
- “鯖和太郎”
- “03-5579-8029”




意図通りの挙動をしていそうです。

DynamoDBにも無事格納されています。
5-2: CLIでのテスト方法(オプション)
# AWS CLIでのテスト例 aws lexv2-runtime recognize-text \ --bot-id\ --bot-alias-id \ --locale-id ja_JP \ --session-id test-session-1 \ --text "予約したい" \ --region ap-northeast-1
Bot IDとAlias IDの確認方法:
- Lex コンソールでボットを開く
- Bot ID: ボット詳細画面の上部に表示
- Alias ID: 「エイリアス」タブで作成したエイリアスをクリックして確認
5-3: Lex Web UIでのテスト方法(オプション)
以下から簡易なWeb UIをデプロイ可能です。
以下のパラメータを環境に合わせて変更してデプロイすればOKです。
| パラメータ名 | 設定値 |
|---|---|
| LexV2BotAliasId | |
| LexV2BotId | |
| LexV2BotLocaleId | ja_JP |
| WebAppConfBotInitialSpeech | 予約のお手伝いをいたします。予約したいとお話しください。 |
| WebAppConfBotInitialText | 予約のお手伝いをいたします。「予約したい」とお話しください。 |
| WebAppConfToolbarTitle | 飲食店予約ボット |
デプロイするとこのような画面が利用できるようになります。

まとめ
本記事では、Amazon Lex V2とKnowledge Baseを連携した飲食店予約ボットの構築方法を解説しました。
今回ご紹介した設定内容では、予約途中での予約内容の訂正等に対応していなかったり、エラーハンドリングが不十分な部分があったりするため、本番利用を想定するとより柔軟なフローに対応できるようにしていく必要があると思いますが、是非参考にしてみてください!
付録: Lambda実装詳細
1. メインロジック
import json import boto3 import os import logging from datetime import datetime import uuid from config import ( SLOT_CONFIG, SLOT_NAMES, KB_PROMPT_TEMPLATE, BEDROCK_REGION, DEFAULT_MODEL_ID, BEDROCK_CONFIG, MESSAGES, RESERVATION_ID_PREFIX, RESERVATION_ID_FORMAT, RESERVATION_CONFIRMATION_TEMPLATE, KB_CONTINUATION_TEMPLATE, CONFIRMATION_STATES, LEX_ACTIONS, LEX_INTENT_STATES ) logger = logging.getLogger() logger.setLevel(logging.INFO) if 'KNOWLEDGE_BASE_ID' not in os.environ: raise ValueError("KNOWLEDGE_BASE_ID environment variable is required") def handler(event, _): """エントリーポイント""" return handle_reservation(event) def handle_reservation(event): """予約処理""" session_attrs = event['sessionState'].get('sessionAttributes', {}) slots = event['sessionState']['intent'].get('slots', {}) prev_slot_count = int(session_attrs.get('slot_count', 0)) slot_values = [get_slot_value(slots, slot) for slot in SLOT_NAMES] reservation_date, reservation_time, party_size, course_type, customer_name, phone_number = slot_values current_slot_count = sum(1 for slot in slot_values if slot) confirmation_state = event['sessionState']['intent'].get('confirmationState', 'None') if should_trigger_kb_search(confirmation_state, current_slot_count, prev_slot_count): return handle_knowledge_base(event) session_attrs['slot_count'] = str(current_slot_count) next_slot = next((name for name, value in zip(SLOT_NAMES, slot_values) if not value), None) if next_slot: session_attrs['next_slot'] = next_slot return { 'sessionState': { 'dialogAction': {'type': LEX_ACTIONS['DELEGATE']}, 'intent': event['sessionState']['intent'], 'sessionAttributes': session_attrs } } if confirmation_state == CONFIRMATION_STATES['NONE']: return { 'sessionState': { 'dialogAction': {'type': LEX_ACTIONS['CONFIRM_INTENT']}, 'intent': event['sessionState']['intent'], 'sessionAttributes': session_attrs } } elif confirmation_state == CONFIRMATION_STATES['DENIED']: content = MESSAGES['confirmation_denied'] state = LEX_INTENT_STATES['FAILED'] else: reservation_id = f"{RESERVATION_ID_PREFIX}{datetime.now().strftime(RESERVATION_ID_FORMAT)}" save_success = save_reservation({ 'reservationId': reservation_id, 'reservationDate': reservation_date, 'reservationTime': reservation_time, 'partySize': party_size, 'courseType': course_type, 'customerName': customer_name, 'phoneNumber': phone_number, 'createdAt': datetime.now().isoformat() }) if save_success: content = RESERVATION_CONFIRMATION_TEMPLATE.format( reservation_date=reservation_date, reservation_time=reservation_time, party_size=party_size, course_type=course_type, customer_name=customer_name, phone_number=phone_number, reservation_id=reservation_id ) else: content = "申し訳ございません。システムエラーのため予約を完了できませんでした。お手数ですが、お電話でご予約をお取りください。" state = LEX_INTENT_STATES['FULFILLED'] return create_response(LEX_ACTIONS['CLOSE'], event['sessionState']['intent']['name'], state, content.strip(), {}) def handle_knowledge_base(event): """Knowledge Base検索処理""" session_attrs = event['sessionState'].get('sessionAttributes', {}) query = event['inputTranscript'] try: bedrock = boto3.client('bedrock-agent-runtime', region_name=BEDROCK_REGION) bedrock_runtime = boto3.client('bedrock-runtime', region_name=BEDROCK_REGION) retrieve_response = bedrock.retrieve( knowledgeBaseId=os.environ['KNOWLEDGE_BASE_ID'], retrievalQuery={'text': query} ) results = retrieve_response.get('retrievalResults', []) if not results: answer = MESSAGES['no_results'] else: prompt = KB_PROMPT_TEMPLATE.format(context_text="\n".join(result['content']['text'] for result in results), query=query) response = bedrock_runtime.invoke_model( modelId=os.environ.get('MODEL_ID', DEFAULT_MODEL_ID), body=json.dumps({ **BEDROCK_CONFIG, 'messages': [{'role': 'user', 'content': prompt}] }) ) response_body = json.loads(response['body'].read()) answer = response_body['content'][0]['text'] except Exception as e: logger.error(f"Knowledge Base error: {str(e)}") answer = MESSAGES['system_error'] return create_reservation_continuation(event, session_attrs, answer) def should_trigger_kb_search(confirmation_state, current_slot_count, prev_slot_count): """KB検索をトリガーすべきかを判定""" return (confirmation_state == CONFIRMATION_STATES['NONE'] and current_slot_count <= prev_slot_count and prev_slot_count > 0 and current_slot_count < len(SLOT_NAMES)) def get_slot_value(slots, slot_name): """スロット値を安全に取得""" slot = slots.get(slot_name) if not slot or not slot.get('value'): return None return slot['value'].get('interpretedValue') def create_response(action_type, intent_name, state, content, session_attrs): """レスポンスを作成""" return { 'sessionState': { 'dialogAction': {'type': action_type}, 'intent': {'name': intent_name, 'state': state}, 'sessionAttributes': session_attrs }, 'messages': [{'contentType': 'PlainText', 'content': content}] } def save_reservation(reservation_data): """予約をDynamoDBに保存""" try: dynamodb = boto3.resource('dynamodb') table = dynamodb.Table(os.environ['RESERVATION_TABLE_NAME']) table.put_item(Item=reservation_data) logger.info(f"Reservation saved: {reservation_data['reservationId']}") return True except Exception as e: logger.error(f"Failed to save reservation: {str(e)}") return False def create_reservation_continuation(event, session_attrs, answer): """予約継続レスポンスを作成""" next_slot = session_attrs.get('next_slot', 'reservationDate') content = KB_CONTINUATION_TEMPLATE.format(answer=answer, slot_message=SLOT_CONFIG[next_slot]) return { 'sessionState': { 'dialogAction': {'type': LEX_ACTIONS['ELICIT_SLOT'], 'slotToElicit': next_slot}, 'intent': event['sessionState']['intent'], 'sessionAttributes': session_attrs }, 'messages': [{'contentType': 'PlainText', 'content': content}] }
2. 設定ファイル(config.py)
BEDROCK_REGION = 'us-east-1' DEFAULT_MODEL_ID = 'anthropic.claude-3-haiku-20240307-v1:0' CONFIRMATION_STATES = { 'NONE': 'None', 'CONFIRMED': 'Confirmed', 'DENIED': 'Denied' } LEX_ACTIONS = { 'DELEGATE': 'Delegate', 'ELICIT_SLOT': 'ElicitSlot', 'CONFIRM_INTENT': 'ConfirmIntent', 'CLOSE': 'Close' } LEX_INTENT_STATES = { 'IN_PROGRESS': 'InProgress', 'FULFILLED': 'Fulfilled', 'FAILED': 'Failed' } SLOT_CONFIG = { 'reservationDate': 'いつの予約でしょうか?', 'reservationTime': '何時からの予約でしょうか?', 'partySize': '何名様でしょうか?数字だけお答えください。', 'courseType': 'どちらのコースをご希望でしょうか?', 'customerName': 'お名前をお聞かせください。', 'phoneNumber': 'お電話番号をお聞かせください。' } SLOT_NAMES = list(SLOT_CONFIG.keys()) BEDROCK_CONFIG = { 'anthropic_version': 'bedrock-2023-05-31', 'max_tokens': 300, 'temperature': 0.1 } KB_PROMPT_TEMPLATE = """{context_text} - あなたは飲食店の予約アシスタントです - 上記のcontextタグ内の情報のみを使用して回答してください - context外の知識や推測は一切使用しないでください - 不明な場合は「提供された情報では回答できません」と明記してください - 事実のみを簡潔に述べ、推測や解釈は避けてください - 質問ではなく、予約情報(日付、時間、人数、コース、名前、電話番号)の指定がされている場合は、以下のmessageタグ内の情報のみを回答してください - 申し訳ございませんが、予約情報を正しく理解できませんでした。 質問: {query} 回答:""" MESSAGES = { 'no_results': '申し訳ございませんが、関連する情報が見つかりませんでした。', 'system_error': 'システムエラーが発生しました。しばらく後にお試しください。', 'confirmation_denied': 'かしこまりました。また何かご用がございましたらお声かけください。' } KB_CONTINUATION_TEMPLATE = "{answer}\n\n予約を継続します。{slot_message}" RESERVATION_CONFIRMATION_TEMPLATE = """ご予約を承りました。{reservation_date}の{reservation_time}に{party_size}名様、{course_type}で、{customer_name}様のご予約です。お電話番号は{phone_number}、予約番号は{reservation_id}です。当日はお気をつけてお越しください。""" RESERVATION_ID_PREFIX = 'RES' RESERVATION_ID_FORMAT = '%Y%m%d%H%M%S'
Yushi.Sakuraba(記事一覧)
アプリ出身クラウドエンジニア(絶賛奮闘中)
コメント