Amazon Connect × Lex × Bedrock で「電話を切らない」AI 電話受付を作る ― 食品メーカー向け PoC を CDK で構築した話
はじめに
食品を製造・販売するメーカーのお客様窓口では、施設の担当者から「納品数が足りない」「商品を交換してほしい」といった電話が日々寄せられます。
内容自体は定型的ですが、電話で聞き取り、社内システムに転記する作業が担当者の負荷になっていました。
この課題に対して、電話の一次受付を生成 AI に任せ、聞き取った内容を構造化データとして保存し、翌朝には CSV で基幹側へ渡せる状態にする PoC を AWS 上に構築しました。
本記事では、その全体設計と実装のポイント、そして実際に構築して分かったハマりどころを共有します。
想定読者は、Amazon Connect や Amazon Bedrock を使ったコンタクトセンターのAI化を検討している方、AWS CDK で複数サービスをまたぐ構成をIaC化したい方です。
何を作ったか
一言でいうと、次の流れをすべてサーバーレスで実現する仕組みです。
電話 → Amazon Connect → Amazon Lex V2 (ja_JP)
→ Lambda (ai-conversation) → Amazon Bedrock (Claude) で対話 + 構造化
→ 復唱・確認 → Lambda (save-inquiry) → DynamoDB
→ EventBridge (毎日 00:05 JST) → Lambda (export-csv) → S3 に CSV
AIが聞き取る項目は、問い合わせ種別(不足・返品・交換・クレーム)、
顧客名(施設名)、
商品名、
対象数量、
希望対応、
電話者氏名、
折り返し先電話番号などです。
必須項目が揃ったらAIが内容を復唱し、お客様が肯定した時点で「受付確定」として保存します。
実際の対話は次のようなイメージです。
AI: お電話ありがとうございます。ご用件をお聞かせください。
お客様: 株式会社○○の山田です。昨日届いた鯛焼きが10箱注文したのに8箱しか入っていなくて。
AI: 株式会社○○の山田様、鯛焼きが10箱のご注文に対し8箱の納品で、2箱不足しているとのことですね。不足分の追送をご希望でしょうか。
お客様: はい、お願いします。
AI: では確認いたします。株式会社○○様、鯛焼き、発注10箱、受領8箱、不足2箱、不足分の追送をご希望、ご担当は山田様、折り返し先は今おかけいただいている番号。以上の内容でお間違いないでしょうか。
お客様: 間違いありません。
AI: 受付が完了いたしました。担当者より改めてご連絡いたします。
このやり取りの裏側では、次のようなJSONがDynamoDBに保存されています。
{
"status": "RECEIVED",
"requestType": "shortage",
"customerName": "株式会社○○",
"productName": "鯛焼き",
"productCode": "MF-A-001",
"deliveryDate": "2026-09-02",
"orderedQuantity": 10,
"receivedQuantity": 8,
"affectedQuantity": 2,
"requestedAction": "replacement",
"callerName": "山田太郎",
"confirmed": true
}
全体アーキテクチャ
flowchart LR Caller((電話)) --> Connect[Amazon Connect] Connect -->|Contact Flow| Lex[Amazon Lex V2 bot<br/>ja_JP] Lex -->|code hook 全発話| AI[Lambda<br/>ai-conversation] AI --> Bedrock[Amazon Bedrock<br/>Claude Sonnet 4.6<br/>jp. 推論プロファイル] AI --> Conv[(DynamoDB<br/>会話状態 TTL 24h)] AI -->|Port 経由| Mock[基幹 Mock Adapter] AI -->|確定 / 折り返し / エラー| Save[Lambda<br/>save-inquiry] Connect -->|フェイルセーフ分岐| Save Save --> Inq[(DynamoDB<br/>受付データ)] Sched[EventBridge<br/>00:05 JST] --> Exp[Lambda<br/>export-csv] Exp --> Inq Exp --> S3[(S3<br/>exports/YYYY/MM/DD/)]
IaCはAWS CDK(TypeScript)で、次の4スタックに分けています。
| スタック | 主なリソース |
|---|---|
| storage | DynamoDB ×2(受付データ、会話状態)、S3 エクスポートバケット |
| application | Lambda ×3、Lambda ごとの IAM Role、LogGroup、EventBridge Rule、SSM Parameter、Secrets Manager |
| connect | Lex Bot / Alias、Connect インスタンス、録音用 S3 + KMS、Queue、Contact Flow |
| monitoring | CloudWatch Alarm ×4、Dashboard、SNS |
VPC、NAT Gateway、RDS、EC2は一切作成していません。
待機時の固定費はKMSキーとSecrets Manager、アラームで月 2 USD 程度に収まります。
AI レイヤーの選定: なぜLex + Lambda + Bedrockなのか
Amazon Connectには近年、Connect自体がLLMと会話する「Agentic self-service」やAmazon Q in Connectといった選択肢も登場しています。
今回はあえて Lex V2 の code hook からすべての発話をLambdaに流し、LambdaがBedrockと会話する構成を選びました。
| 候補 | 判断 |
|---|---|
| Connect Agentic self-service / Q in Connect | 設計時点で東京リージョンでの提供状況と料金体系を確定できなかった。ただし Contact Flow の Lex ブロックを差し替えるだけで移行できる構造にしている |
| Lex V2 + Lambda + Bedrock(採用) | Connect とネイティブ連携(音声認識と TTS は Connect / Lex 側)、東京リージョンで利用可、会話ロジックを自前の Lambda に閉じ込められるため LLM の差し替えや業務ロジックの追加が自由 |
Lexは本来インテントとスロットで対話を組み立てるサービスですが、今回は **FallbackIntent を含むすべてのインテントを1つのLambdaに向け、
Lexを「音声認識とTTSのゲートウェイ」としてだけ使っています**。
対話の制御はすべてLambda内の LLM が担います。
モデルは jp.anthropic.claude-sonnet-4-6 を使いました。
これは 日本国内のリージョンだけで推論が完結するクロスリージョン推論プロファイルで、
機微な情報を扱う可能性のあった本件では「データが国外に出ない」ことが選定理由になっています。
実装のポイント
1. Forced tool use で「発話」と「構造化データ」を毎ターン同時に得る
LLMに電話対応をさせるうえで最大の課題は、自然な発話を生成させつつ、同時に構造化データも確実に取り出すことです。
プロンプトで「JSON で返してください」と頼む方法は、音声向けの短い発話と JSON が混ざったり、フォーマットが崩れたりして安定しません。
そこでBedrock Converse API の tool use を「強制」する方式を採りました。
update_inquiry というツールを 1 つ定義し、toolChoice でそのツールを必ず呼ばせます。
ツールの引数スキーマに「次に話す発話」「会話フェーズ」「今回判明した項目の差分」を含めることで、モデルの出力は必ずスキーマに沿った JSON になります。
const toolConfig: ToolConfiguration = {
tools: [updateInquiryTool],
toolChoice: { tool: { name: 'update_inquiry' } }, // 毎ターン必ずこのツールを呼ばせる
};
const res = await client.send(new ConverseCommand({
modelId: 'jp.anthropic.claude-sonnet-4-6',
system: [{ text: buildSystemPrompt(state, todayJst) }],
messages: buildMessages(state),
toolConfig,
inferenceConfig: { maxTokens: 1024, temperature: 0.2 },
}));
const toolUse = res.output?.message?.content
?.find((b) => b.toolUse?.name === 'update_inquiry')?.toolUse;
const parsed = ToolOutputSchema.parse(toolUse.input); // zod で二重に検証
ツールの入力スキーマは次のような形です(抜粋)。
{
"utterance": "次にお客様へ話す日本語の発話。電話向けに1〜2文、敬体、記号を使わない",
"phase": "collecting | confirming | completed | handoff",
"inquiry": {
"requestType": "shortage | return | replacement | complaint | other | null",
"customerName": "string | null",
"orderedQuantity": "integer | null",
"receivedQuantity": "integer | null",
"affectedQuantity": "integer | null",
"requestedAction": "replacement | return | refund | investigation | callback | other | null",
"confirmed": "boolean"
}
}
ここで工夫した点が2つあります。
inquiryは差分パッチとして扱い、非 null の値だけマージする。 モデルが「分からない」と判断した項目は null で返ってきますが、それで既知の情報を消してしまわないようにしています。- システムプロンプトに「今日の日付(JST)」「判明済み項目」「未確定の必須項目」を毎ターン埋め込む。 「昨日届いた」を正しい日付に換算させ、まだ聞いていない項目だけを聞き返すよう明示的に誘導しています。
2. LLMを信用しすぎない: サービス側の状態遷移ガード
会話フェーズは collecting → confirming → completed と進みますが、モデルが返した phase をそのまま信じると、必須項目が欠けたまま「確認に進んだ」ことになったり、復唱をスキップして「完了」になったりします。
そこでLLMの外側にある ConversationService が次のガードをかけています。
collecting ──必須項目充足→ confirming ──お客様が肯定→ completed (confirmed=true → RECEIVED で保存)
│ ↑訂正があれば confirming に戻る
├─ turnCount ≥ 14 ──────────────────→ handoff (NEEDS_FOLLOWUP で保存、担当者折り返し)
└─ エンジンエラー 2 回連続 ───────────→ error (NEEDS_FOLLOWUP で保存)
- 必須項目が欠けたまま
confirmingやcompletedが返ってきたらcollectingに戻す completedはconfirmed=trueのときだけ受け入れる- 発注数と受領数から不足数を自動導出し、モデルの計算ミスを打ち消す
- 終端状態は sticky にし、完了後に再発話があっても締めの文言だけ返す
「モデルに判断させるが、業務ルールはコードで担保する」という切り分けが、PoC を安定して動かすうえで効果的でした。
3. 「電話を強制終了しない」フェイルセーフ
電話はWebと違い、エラー画面を出して終わりにはできません。
Lex、Lambda、Bedrock、DynamoDB のどこで失敗しても、必ず「内容を受け付け、担当者から連絡する」という着地点を通るように Contact Flow を設計しました。
ConnectParticipantWithLexBot
│ 正常終了 → SessionAttributes.outcome で分岐
│ completed → 締めのメッセージ → Disconnect
│ handoff → 有人転送が有効なら Queue へ / 無効なら折り返しメッセージ
│ error / その他 → Lambda save-inquiry (source=connect-fallback) → お詫び + 折り返しメッセージ
└ Lex エラー / 入力タイムアウト → 同じフェイルセーフ経路
Lambda側でも例外をLexにそのまま返さず、outcome=error をセッション属性に載せてフローのフォールバック分岐に繋ぎます。
フォールバック時はConnectのコンタクトIDと発信者番号だけで NEEDS_FOLLOWUP レコードを作り、翌朝のCSVに「AI では受付できなかった着信」として載せます。
このフェイルセーフは、後述するBedrockの有効化が終わっていない状態でE2Eを回したときに実際に機能し、着信が取りこぼされずにCSVまで出力されることが確認できました。
4. ポート & アダプタで「基幹連携」と「LLM」を差し替え可能に
PoCでは基幹システム(顧客・商品・配送・在庫)はモックですが、本番では API、DB、あるいは既存画面のRPA操作など接続先が変わります。
そこで業務データへのアクセスはすべて Port(インターフェース)越しにし、LLM には直接触らせない構造にしました。
interface ProductService { searchProduct(input: SearchProductInput): Promise<ProductResult[]> }
interface DeliveryService { searchDelivery(input: SearchDeliveryInput): Promise<DeliveryResult> }
interface InventoryService { checkInventory(input: CheckInventoryInput): Promise<InventoryResult> }
interface ReturnService { registerReturn(...); registerReplacement(...) }
interface ConversationEngine { step(state: ConversationState, utterance: string): Promise<DialogStepResult> }
- LLM 自体も
ConversationEngineという Port の 1 実装なので、Bedrock 以外のモデルや将来 Connect のネイティブ AI に置き換える場合もConversationServiceや保存・CSV のロジックは変わりません。 - Lex に依存するコードは Lambda ハンドラの入出力変換だけに閉じ込めています。
- 会話ロジックはローカルで台本テストできるため、AWS に繋がずに「○○病院・栄養剤A・10 箱中 8 箱・追送希望」というシナリオを vitest で回しています。
5. ログとデータの扱い
機微な情報を扱う可能性があったため、ログは許可リスト方式にしました。
contactId、inquiryId、requestType、処理時刻、結果、エラーといったキーだけを構造化JSONで出し、問い合わせ本文・氏名・電話番号はDynamoDBにのみ保存します。
Lexのセッション属性にも本文は載せず、outcome / phase / inquiryId / requestType / turnCount だけを渡してConnect側のログへ漏れないようにしています。
IAMはLambdaごとに個別Roleを作り、マネージドポリシーは使わず、Bedrock の権限も設定したモデルの推論プロファイルARNと基盤モデルARNだけに絞っています。
CDK テンプレートにAction: "*"が存在しないことはvitestのテンプレート検査で担保をしています。
実際にハマったポイント
ここからはドキュメントを読んでも分かりづらく、実際に手を動かして初めて分かったことをまとめます。
BedrockのAnthropicモデルは「2 段階」で有効化が必要
新しいAWSアカウントでClaudeを使うには、次の 2 ステップが必要でした。
- Anthropic の利用目的フォームの提出(コンソールの Model access から。会社名・用途などを申告する法的手続きで、アカウント単位・初回のみ)
- モデルごとの AWS Marketplace 契約の作成(コンソールまたは CLI)
aws bedrock create-foundation-model-agreement \
--model-id anthropic.claude-sonnet-4-6 \
--offer-token $(aws bedrock list-foundation-model-agreement-offers \
--model-id anthropic.claude-sonnet-4-6 \
--query 'offers[0].offerToken' --output text)
# 確認: すべて AVAILABLE になれば OK
aws bedrock get-foundation-model-availability --model-id anthropic.claude-sonnet-4-6
どちらかが終わっていないと、管理者権限があってもConverse APIは ResourceNotFound や AccessDenied を返します。
エラーメッセージからは原因が読み取りにくく、はじめはIAMを疑って時間を使いました。
2 の実行には aws-marketplace:Subscribe 権限が必要ですが、これは Lambda の Role には付けず、セットアップする人間のプリンシパルだけに付与しています。
日本語LexボットをConnectから呼ぶには「言語コード」の明示設定が必要
管理画面のテストチャットでLexブロックが ResourceNotFoundException を返し、原因の特定に時間がかかりました。
結論は、en-US以外のLex V2ボットを使う場合、Contact Flow でコンタクトの言語コードを明示的に設定する必要があるというものです。
音声チャネルでは「音声の設定」ブロックで ja-JP を指定すれば動いていたので気づきにくいのですが、チャットではこのブロックでは言語が決まりません。
フローにUpdateContactDataアクションを追加して解決しました。
{
Type: 'UpdateContactData',
Parameters: { LanguageCode: 'ja-JP', TargetContact: 'Current' },
...
}
コンソールでいうと「コンタクト属性の設定 → システム → 言語コード」に相当します。
API 経由のチャットはクライアントがWebSocketを購読するまでフローが動かない
チャットのE2EをStartChatContactとParticipant APIで自動化したところ、フローが一向に始まらず悩みました。
これは仕様で、API で開始したチャットは、クライアントが WebSocket に接続してaws/subscribeを送るまでContact Flowが実行されません。
またCreateParticipantConnectionにはType: ['WEBSOCKET', 'CONNECTION_CREDENTIALS']の両方を指定する必要があります。
管理画面のテストチャットが裏でやっていることを、自前のスクリプトでも再現する必要がありました。
「全リソースにタグを付ける」はCDKだけでは完結しない
PoCではコスト集計と削除漏れ防止のため、「案件に属する全リソースにProjectタグを付ける」ことを要件にしました。
CDK のTags.of(app).add()で大半は片付きますが、次の例外がありました。
AWS::Events::Ruleは CloudFormation でタグ付けできない。 デプロイ後にaws events tag-resourceで付与しています。- CDK 内部の
Custom::S3AutoDeleteObjectsプロバイダ Lambda / Role はTags.of()を無視する。 これは CDK の Aspect でaddPropertyOverride('Tags', ...)を直接注入して解決しました。
class TagPlainCfnResourcesAspect implements IAspect {
visit(node: IConstruct): void {
if (!cdk.CfnResource.isCfnResource(node)) return;
if (!['AWS::Lambda::Function', 'AWS::IAM::Role'].includes(node.cfnResourceType)) return;
if (cdk.TagManager.isTaggable(node)) return; // 通常リソースは Tags.of() が処理する
node.addPropertyOverride('Tags', toCfnTags(this.tags));
}
}
- Connect がインスタンス作成時に自動生成する既定キュー・フロー・ルーティングプロファイル等は IaC の管轄外。 デプロイ後スクリプトで Connect API 経由にタグを付け、インベントリに記録しています。
- Lambda Permission、Lex BotVersion、Bucket Policy などそもそもタグの概念がないリソースは、理由と親リソースを明記した「タグ付与不可リソース一覧」を自動生成することで、監査上の説明責任を果たすようにしました。
最終的に、実リソースから生成したインベントリで「タグ付与可能な全リソースに必須タグあり、付与不可リソースは理由付きで一覧化」という状態を機械的に検証できるようにしています。
細かいハマりどころ
- クロススタックの
fn.addPermissionで循環参照。 connect スタックから application スタックの Lambda に Lex 呼び出し許可を付けると、CDK が両方向の依存を作ってしまいます。呼び出す側のスタックでlambda.CfnPermissionを直接定義して回避しました。 - 録音用 S3 と KMS は connect スタックに置く。 バケットポリシーとキーポリシーが Connect インスタンスのサービスリンクロールを参照するため、storage スタックに置くとこれも循環参照になります。
- Connect コンソールの「緊急アクセス」は IAM ユーザーでは使えない(フェデレーションロール専用)。Connect 管理ユーザーを API で作成するスクリプトを用意しました。
- 日本の電話番号は書類審査が必要。 050 / 03 / 0120 などの番号は、登記書類・代表者本人確認・住所証明を AWS Support 経由で提出し審査完了後でないと取得できません。番号取得は IaC に含めず、手動ステップとして切り出しています。
- Connect インスタンスの作成・削除回数には 30 日あたりの上限がある。 connect スタックだけを何度も作り直す運用は避ける必要があります。
コスト感
100通話/月、平均8ターン、平均4分というPoC規模での概算です。
| 項目 | 概算 |
|---|---|
| Connect サービス料 + 日本着信料 | 20〜50 USD |
| Lex 音声リクエスト | 3〜4 USD |
| Bedrock(Claude Sonnet 4.6、jp. 推論プロファイル) | 10〜15 USD |
| Lambda / DynamoDB / S3 / Logs | 1〜3 USD |
| KMS + Secrets Manager + アラーム | 約 2 USD |
| 合計 | 40〜90 USD / 月(通話量に比例。待機時の固定費は電話番号 + 約 2 USD) |
なおjp.の国内リージョナル推論はglobal推論に比べて約 10% 高い単価になります。
データ所在地の要件と引き換えのコストとして把握しておく必要があります。
まとめ
- Amazon Connect + Lex V2 + Lambda + Bedrock で、電話受付から構造化保存、日次 CSV 出力までをサーバーレスで構築しました。
- Forced tool use で発話と構造化データを毎ターン同時に取得し、業務ルールはコード側のガードで担保する切り分けが安定運用の鍵になりました。
- 電話を強制終了しないフェイルセーフは、実際に AI 側の障害時に機能することを確認できました。
- Bedrock の 2 段階有効化、日本語 Lex ボットの言語コード設定、CDK の外にあるリソースのタグ付けなど、日本リージョンで日本語の音声 AI を動かすときならではの落とし穴が複数ありました。
今後は、実電話番号での音声E2E、基幹システムとの実接続(Port の実装追加)、有人転送の有効化、ConnectネイティブのAI機能への置き換え評価を進める予定です。
コンタクトセンターのAI化や、Amazon ConnectとBedrockを組み合わせた構成のPoCをご検討中の方は、ぜひお気軽にご相談ください。



