はじめに
JAWS DAYS 2026 の会場デモ用に、AWS の各種サービスを組み合わせた AI チャットアシスタントを開発しました。Three.js のサメ 3D アバターが音声に合わせて口を動かし、Amazon Polly で返答を音声合成し、Amazon Transcribe でリアルタイム音声入力を受け付けるというシステムです。
一番苦労したところは、JAWSのキャラを3Dで動かすところですが、それはまた別の機会でお話しするとして、今日はサーバレス構成でどう実現したのかについてお話ししたいと思います。
本記事では以下について解説します。
- フルサーバーレスで AI アシスタントを構成したアーキテクチャ全体像
- Amazon Bedrock AgentCore の短期メモリ・RAG・モデルルーティングの使い方
- Step Functions を使った Knowledge Base の自動同期パイプライン
- Transcribe Streaming と Polly を組み合わせた音声 I/O の実装
- 「RAG 検索 → 要約 → 読み上げ」の遅延を 1 リクエストに圧縮した工夫
アーキテクチャ
全体構成
使用している AWS サービス一覧
| サービス | 用途 |
|---|---|
| Amazon Bedrock AgentCore | AI エージェント実行 (Claude Haiku 4.5 / Sonnet 4.5 自動ルーティング) |
| Amazon Bedrock AgentCore Memory | セッション中の短期記憶 |
| Amazon Bedrock Knowledge Bases | RAG(イベント情報検索) |
| Amazon S3 Vectors | ベクトルデータベース |
| Amazon Titan Embed Text v2 | テキスト埋め込みモデル |
| Amazon Polly | 音声合成(Mizuki) |
| Amazon Transcribe Streaming | リアルタイム音声認識 |
| Amazon Cognito | 未認証ユーザー認証 |
| Amazon DynamoDB | 会話履歴の永続化 |
| AWS Step Functions | KB 同期ワークフロー |
| Amazon EventBridge | 定期実行スケジュール |
| Amazon S3 + CloudFront | 静的ホスティング / CDN |
| AWS CDK | Infrastructure as Code |
Amazon Bedrock AgentCore:短期メモリ・RAG・モデルルーティング
AgentCore Memory で会話をまたぐ記憶を実現
AgentCore Memory を使うことで、セッション中の会話の内容を記憶できます。最初に「AIのセッションを教えて?」と聞いた質問を覚えることで掘り下げて聞きたいときに「AIのセッション」という言葉を再度言わなくてもいいようにしました。
CDK での Memory リソース定義はシンプルで、イベント有効期間を指定するだけです。
const agentMemory = new bedrockagencore.CfnAgentMemory(this, 'AgentMemory', {
memoryName: 'jaws_days_bandai_memory',
eventExpiryDuration: 1, // 1日間有効
});
AgentCore Runtime(Python)側では AgentCoreMemorySessionManager を渡すだけで Memory が有効になります。
agent = Agent(
model=model,
tools=[retrieve],
system_prompt=SYSTEM_PROMPT,
session_manager=session_manager, # Memory 統合
)
RAG でイベント情報を検索
JAWS DAYS 2026 の公式サイトから取得したセッション・会場情報を Amazon Bedrock Knowledge Bases に蓄積し、質問に応じて関連情報を検索して回答に使います。ベクトルストアには Amazon S3 Vectors(Amazon の新しいベクトルデータベースサービス)を使いました。Amazon S3 Vectorsは非常に費用が安く今回のイベントなどの場合は最適なベクトルデータベースだと思います。
Amazon S3 Vectors バケット + インデックス
埋め込みモデル: amazon.titan-embed-text-v2:0
次元数: 1024 / 距離メトリック: cosine
モデルルーティングでコストと品質を両立
簡単な質問は安価な Haiku 4.5 で、複雑な質問は Sonnet 4.5 で答えるよう Agent 内でルーティングしています。これは安価な部分もありますが、のちに出てくる応答速度をなるべく早くするためでもあります。会話は待ち時間が短い方がリアルに感じるのをテストしていて感じました。
def requires_sonnet(message: str) -> bool:
if len(message) > 100:
return True
COMPLEX_KEYWORDS = [
"仕組み", "アーキテクチャ", "設計", "比較", "違い",
"メリット", "デメリット", "なぜ", "どうやって", "実装",
]
return any(kw in message for kw in COMPLEX_KEYWORDS)
model = get_sonnet_model() if requires_sonnet(user_message) else get_haiku_model()
Step Functions:Knowledge Base の自動同期パイプライン
毎日 JST 23:00 に EventBridge が Step Functions を起動し、公式サイトのスクレイピングから KB 更新まで一連のワークフローを自動実行します。(ここはスクレイピングが禁止されているようなサイトもあると思うので確認しながら使ってください。)
EventBridge (毎日 JST 23:00)
│
▼
Step Functions State Machine(タイムアウト: 15分)
│
├─ 1. ScrapeAndUpload Lambda(5分、512MB)
│ jawsdays2026.jaws-ug.jp + fortee.jp をスクレイピング
│ → jawsdays2026.jsonl を S3 にアップロード
│
├─ 2. StartIngestion Lambda(30秒、128MB)
│ Bedrock KB の IngestJob を開始
│
├─ 3. Wait(15秒)
│
├─ 4. CheckIngestion Lambda(30秒、128MB)
│ IngestJob の完了を確認
│
└─ 5. Choice State
├─ COMPLETE → 成功終了
├─ FAILED → 失敗終了
└─ その他 → 3. Wait に戻る(ポーリングループ)
KB のインジェスションは非同期で完了まで時間がかかるため、Step Functions の待機 → ポーリングループパターンを使いました。Lambda を連鎖させてポーリングを実装しようとすると複雑になりますが、Step Functions の Wait State を使えばシンプルに記述できます。
Transcribe:リアルタイム音声認識
Amazon Transcribe Streaming を使い、マイクの音声を WebSocket 経由でリアルタイムにテキスト変換しています。
ブラウザのマイクは通常 44.1kHz または 48kHz でキャプチャしますが、Transcribe は 16kHz・PCM 16bit を要求するため、AudioWorklet でリアルタイムにダウンサンプリングしています。
// ScriptProcessor でダウンサンプリング
const ratio = audioContext.sampleRate / 16000;
const downsampled = new Int16Array(Math.floor(input.length / ratio));
for (let i = 0; i < downsampled.length; i++) {
const sample = input[Math.floor(i * ratio)];
downsampled[i] = Math.max(-32768, Math.min(32767, sample * 32768));
}
なお、「JAWS」が「上手(ジョウズ)」に誤認識されるという問題が発生していました。Transcribe には Custom Vocabulary(カスタム語彙)という機能があり、専門用語や固有名詞の読みと重みを登録しておくことで認識精度を上げられます。今回は実装が間に合いませんでしたが、これを設定していれば認識精度がさらに改善できたかもしれません。
# カスタム語彙の例(未実装)
Phrase SoundsLike DisplayAs Weight
JAWS ジョーズ JAWS 3
JAWS-UG ジョーズユージー JAWS-UG 3
AWS エーダブリューエス AWS 2
ちなみに AWS::Transcribe::Vocabulary は CloudFormation 未対応のリソースのため、CDK で管理する場合は AwsCustomResource で Transcribe API を直接呼び出す実装が必要になります。
Polly:サメアバターとリップシンクする音声合成
AgentCore の回答テキストを Amazon Polly(Mizuki ボイス)で音声合成し、Three.js のサメ 3D アバターとリップシンクさせています。
Polly が誤読しやすい固有名詞は、SSML の <phoneme> タグを使ったインライン発音辞書で補正しています。たとえば「彩の国」はそのままだと「アヤノクニ」と読まれてしまうため、カタカナ読みを直接指定しています。
// 発音辞書(Polly が誤読しやすい語を SSML phoneme タグで補正)
const PRONUNCIATION_MAP: Record<string, string> = {
"彩の国": "サイノクニ",
};
// SSML に phoneme タグを埋め込んで読み上げ速度 120% で合成
const ssml = `<speak><prosody rate="120%">
${applyPronunciationMap(escapeSSML(text))}
</prosody></speak>`;
// 出力例
// <phoneme alphabet="x-amazon-pron-kana" ph="サイノクニ">彩の国</phoneme>
Polly には外部 Lexicon ファイルを登録する方法もありますが、SSML への直接埋め込みであればデプロイ不要かつリアルタイムで変更できるため、イベント固有の語が増えても柔軟に対応できます。
日本語のひらがな変換には Kuroshiro を使い、母音に対応した口の開き具合をサメのモーフターゲットに反映しています。
あ: 1.0 / い: 0.5 / う: 0.4 / え: 0.6 / お: 0.7 / ん: 0.2
工夫した点:「RAG → 要約 → 読み上げ」を 1 リクエストに圧縮
最初の設計(遅かった)
当初の設計では、AgentCore が回答を返した後に別途 Bedrock API を呼んで「音声読み上げ用の要約」を生成していました。
ユーザー入力
│
▼
AgentCore(RAG 検索 → 詳細な回答生成) ← 1回目のリクエスト
│
▼
summarizeText()(Bedrock で関西弁要約を別途生成) ← 2回目のリクエスト
│
▼
Polly(音声合成)
これでは AgentCore のレスポンス完了を待ち、さらに要約リクエストが終わるまで Polly の再生を開始できず、ユーザーが返答を聞けるまでに大きな遅延が生じていました。
改善後:||| デリミタプロトコル
解決策は、Agent 自身に「音声用の要約」と「表示用の詳細回答」を 1 回のリクエストの中で同時に生成させることです。System Prompt で以下のフォーマットを指示しました。
[関西弁サマリ(100文字程度、音声読み上げ用)]|||[詳細な回答(チャット表示用)]
実際のレスポンスイメージ:
そうやなー!JAWS DAYS 2026 は新潟で開催やで!ぜひ来てや〜!|||
JAWS DAYS 2026 は 2026 年 3 月 21 日(土)に新潟県で開催されます。
会場は...(詳細な説明が続く)
フロントエンドでは AgentCore のストリームをリアルタイムに監視し、||| が流れてきた瞬間に前半のテキストを Polly に送信します。
// ストリーミング中に ||| を検出したら即座に TTS を開始
onDelta: (delta) => {
buffer += delta;
const delimiterIndex = buffer.indexOf("|||");
if (delimiterIndex !== -1 && !earlyTtsTriggered) {
const ttsText = buffer.slice(0, delimiterIndex);
triggerPolly(ttsText); // 要約部分が揃った瞬間に再生開始
earlyTtsTriggered = true;
}
},
この改善で 追加のリクエストがゼロになり、しかも AgentCore のストリーミング中にサメが喋り始める UX を実現しました。
Before:
AgentCore 完了(数秒)→ 要約リクエスト(数秒)→ Polly 再生開始
After:
AgentCore ストリーム中 → ||| 検出した瞬間 → Polly 再生開始(数秒短縮)
フォールバックとして ||| が返ってこなかった場合は従来通り summarizeText() で別途要約します。このため既存の動作は保ちつつ、Agent が対応している場合は高速化する設計になっています。
まとめ
- フルサーバーレス:ブラウザ → AgentCore の直接呼び出しで遅延を最小化
- AgentCore Memory:セッションをまたいだ短期記憶を 3 行のコードで実現
- Step Functions:KB の自動同期をポーリングループで堅牢に実装
- Transcribe + カスタム語彙:「JAWS」誤認識問題を専用語彙で撃退
-
|||デリミタプロトコル:1 回のリクエストで要約と詳細回答を同時生成し、Polly 再生の遅延を大幅短縮
JAWS DAYS 2026 の会場で使っていただいた方々ありがとうございました!


