目次
- はじめに:なぜ当時「マルチクラウド」だったのか
- 対象システムの概要
- Before 構成と3つの課題
- 移行判断:何と何を比較したのか
- After 構成:AWS ネイティブへの集約
- キャパシティ設計と可用性設計
- 実装のハマりどころ(6点)
- 運用設計:何をどう監視しているか
- 合意形成:金融システムの移行をどう通したか
- 移行によって得られた効果
- チームへの展開と横展開
- この移行から得られたポイント
- おわりに
■ はじめに:なぜ当時「マルチクラウド」だったのか
私は業務で Amazon Connect を使ったコンタクトセンター構築を担当しています。
今回ご紹介するのは、金融機関(保険・ローン商品を扱うコンタクトセンター)向けに構築した オペレータ向けFAQ回答支援システム を、Azure OpenAI Service から Amazon Bedrock へ全面移行した 事例です。
まず断っておきたいのは、当時のマルチクラウド構成は「間違い」ではなかった ということです。
3年前、Amazon Connect でリアルタイム音声をテキスト化し、そのテキストから回答案を生成しようとしたとき、
- 日本語の長文読解・規約解釈に耐えるモデルの選択肢が限られていた
- 業務要件(約款の言い回しを正確に解釈する)に対して、当時最も精度が出たものが GPT 系だった
- 顧客側にも Azure の既存契約・ガバナンス基盤があった
という事情があり、「Connect は AWS、LLM は Azure」 という構成には十分な合理性がありました。
しかしシステム稼働から時間が経ち、
- Amazon Bedrock 側のモデル品質が業務要件を満たすレベルに到達した(3年前は、まだBedrockが無かった。。)
- Guardrails / Prompt Caching / Knowledge Bases など、周辺機能が揃った
- 一方で、クラウド間連携の運用コストは下がらなかった
という 前提条件の変化 が起きました。本記事は「Azure が悪かった」という話ではなく、前提が変わったのでアーキテクチャを再設計した という話です。
そして結果として、Azure 上にあった推論ワークロードを AWS へ完全に移管し、AWS 消費額を月額ベースで約2.2倍に拡大 しています。この点も含めて、判断の過程を共有します。
■ 対象システムの概要
| 項目 | 内容 |
|---|---|
| 業種 | 金融(保険・ローン商品のコンタクトセンター) |
| 用途 | 通話中のオペレータに、FAQ/約款に基づく回答案をリアルタイム提示 |
| 在籍オペレータ | 約45名(ピーク時同時稼働 32席) |
| 月間コンタクト | 約48,000件(稼働20日、ピーク時間帯は日量の約13%) |
| 参照ナレッジ | 約款・商品規定・社内FAQ 計 約2,400ドキュメント |
| 非機能要件 | 回答案は発話終了から2秒以内に画面表示/PIIは外部に出さない/AI停止時も業務を継続できること |
| チーム体制 | 4名(アーキテクト1、開発2、品質評価1、ビジネス評価1) |
最後の2行がこの記事の全ての判断の起点です。「2秒以内」と「AIが落ちても業務が止まらない」 を同時に満たす設計を求められました。
■ Before 構成と、運用で見えてきた3つの課題
構成図(Before)
Amazon Connect で受電した通話を Kinesis Video Streams 経由で Amazon Transcribe に流し、発話が確定したテキストを Lambda で整形して Azure OpenAI Service に送る構成です。生成された回答案は同じ経路を逆にたどってオペレータ画面(Custom CCP)へ返します。
図中で赤字にした クラウド間往復 が、このあと述べる課題のすべての起点になっています。
課題① レイテンシ:一番効く要件が一番苦しかった
「発話終了から2秒以内」に対する実測が以下です。
Before:回答案が画面に出るまで(p50 = 3.20s)
0.0s 0.35s 0.43s 1.14s 3.08s 3.20s
│───────────│──────│──────────────────│────────────────────────────│──────│
│ Transcribe│Lambda│ クラウド間往復 │ LLM 推論(全文生成) │ 描画 │
│ 0.35s │0.08s │ 0.71s │ 1.94s │0.12s │
^^^^^^^^^^^^^^^^^^
チューニング不可領域
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
生成完了まで画面は空白のまま
| 区間 | p50 | p90 |
|---|---|---|
| Transcribe 発話確定 | 0.35s | 0.52s |
| Lambda 前処理 | 0.08s | 0.14s |
| クラウド間往復(AWS⇄Azure) | 0.71s | 1.62s |
| LLM 推論(全文生成完了まで) | 1.94s | 3.38s |
| 画面描画 | 0.12s | 0.18s |
| 合計 | 3.20s | 5.84s |
要件2秒に対し p50 で3.2秒。クラウド間往復だけで0.7〜1.6秒を消費し、ここはチューニングで改善できる余地がほとんどありませんでした。
さらに当時は生成完了を待って一括表示していたため、オペレータ体感の「待ち」がそのまま3.2秒。通話中の3秒の沈黙は非常に長く、「結局待てないので自分で約款を検索する」という運用崩れが起き始めていました。
課題② セキュリティ・ガバナンスの二重化
金融のコンタクトセンターなので、契約者情報や相談内容(PII)の扱いは厳格です。マルチクラウド構成では以下が すべて二重 になりました。
| 項目 | AWS側 | Azure側 |
|---|---|---|
| 権限管理 | IAM | Entra ID / RBAC |
| 監査ログ | CloudTrail | Azure Monitor / Activity Log |
| 鍵管理 | KMS | Key Vault |
| ネットワーク境界 | VPC / SG | VNet / NSG |
四半期ごとの内部監査では、両系統のログを突合して「PIIがどこを通ったか」を証明する作業が発生し、1回あたり 約4人日。年間16人日が本質的な価値を生まない作業に消えていました。
課題③ ネットワークコストと障害切り分けの複雑性
| 項目 | 月間実績 |
|---|---|
| クラウド間データ転送量 | 約 210 GB |
| データ転送費(Egress + 回線按分) | 約 ¥62,000 |
| Azure 側リソース運用費(推論費除く) | 約 ¥85,000 |
| 障害時の一次切り分け平均時間 | 38分 |
特に効いたのが最後の行です。「回答案が出ない」という問い合わせに対し、原因が Transcribe なのか、Lambda なのか、回線なのか、Azure OpenAI のレート制限なのか、切り分けるだけで平均38分。監視ダッシュボードが2つに分かれていることが、そのまま MTTR に跳ね返っていました。
■ 移行判断:何と何を比較したのか
「Bedrock に移行しました」と書くと必ず聞かれるのが 「今の Amazon Connect ならネイティブのオペレータ支援機能があるのでは?」 という質問です。実際その通りで、Amazon Q in Connect や Connect のエージェント型AI機能によるオペレータ支援は年々強化されています。
それでも今回は Bedrock を直接呼び出す構成 を選びました。評価軸と結論は以下の通りです。
評価マトリクス
| 評価軸 | (a) Azure OpenAI 継続 | (b) Amazon Q in Connect | (c) Bedrock 直接呼び出し |
|---|---|---|---|
| レイテンシ要件(2秒) | ✕ 構造的に不可 | ○ | ◎ ストリーミングで達成可 |
| 約款の検索精度チューニング | △ | △ チャンク戦略の自由度に制約 | ◎ 完全に自前で制御 |
| 出力フォーマットの厳密制御 | ○ | △ | ◎ 根拠条文ID・確信度を構造化出力 |
| 既存プロンプト資産の継承 | ◎ | ✕ 作り直し | ○ 差分吸収で移植可 |
| 既存評価データセットの再利用 | ◎ | △ | ◎ |
| ガバナンス一元化 | ✕ | ◎ | ◎ |
| 導入・運用の手離れ | ○ | ◎ | △ 自前実装が必要 |
| 移行工数 | - | 大 | 中 |
(b) ではなく (c) にした3つの理由
1. 「回答を出さない判断」を含む構造化出力を握る必要があった
このシステムでは、回答案と一緒に 「根拠となった約款の条番号」と「確信度」 を必ず返す仕様にしています。確信度が閾値未満のときはオペレータ画面に回答案を出さず、「該当条文へのリンクのみ」 を出す設計です。誤った支払条件を提示するリスクを UI レベルで制御するためです。
この「出さない判断」を含む厳密な構造化出力を握りたかったため、レスポンス形式を自分で定義できる Bedrock 直接呼び出しを選びました。
2. 約款特有のチャンク戦略があった
約款は「第◯条(見出し)→ 各項 → 但し書き」という階層構造を持ち、但し書きを条文本体から切り離すと意味が壊れます。汎用的なチャンク分割ではこの事故が起きるため、条・項単位のカスタムチャンカーを自前で持っていました。この資産を捨てたくなかった、というのが実務的な理由です。
3. 既存の評価データセット(480件)をそのまま使いたかった
移行の成否を判定するには Before/After を同じ物差しで測る必要があります。3年かけて積み上げた正解データセットをそのまま流せることが、移行リスクを下げる上で決定的でした。
補足:とはいえ、新規構築なら (b) から検討すべき だと考えています。今回はあくまで「既存資産がある移行案件」の判断です。将来的にネイティブ機能へ再合流する可能性も設計上残しています(後述)。
■ After 構成:AWS ネイティブへの集約
構成図(After)
Lambda を VPC のプライベートサブネットに配置し、VPC Endpoint(AWS PrivateLink)経由で Amazon Bedrock と OpenSearch Serverless を呼び出す構成に変更しました。回答案は API Gateway(WebSocket)でオペレータ画面にストリーミング配信します。
図の右下、赤い破線で示したのが L1 縮退の経路です。Bedrock がスロットリングやタイムアウトを返した場合は推論を諦め、検索でヒットした条文リンクだけを即座に表示します。詳細は後述の可用性設計で触れます。
キーポイント1:PII を含む推論リクエストを AWS バックボーン内で完結させる
Lambda を VPC 内に配置し、com.amazonaws.<region>.bedrock-runtime の Interface VPC Endpoint(AWS PrivateLink) 経由で Bedrock を呼び出しています。
⚠️ ここは表現を正確にしておきたいところです。Amazon Connect 自体や CCP はパブリックエンドポイントを使うサービスなので、「システム全体がインターネットを一切経由しない」とは言えません。正確には、
契約者情報を含む推論リクエスト/レスポンスは、インターネットを経由せず AWS バックボーン内で完結する
という限定です。監査の場でもこの粒度で説明しています。
VPC Endpoint のポリシーで、呼び出せるモデルを明示的に絞っています。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:role/FaqAssistLambdaRole" },
"Action": ["bedrock:Converse", "bedrock:ConverseStream"],
"Resource": [
"arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-sonnet-*",
"arn:aws:bedrock:ap-northeast-1:123456789012:inference-profile/*"
],
"Condition": {
"StringEquals": { "aws:PrincipalOrgID": "o-xxxxxxxxxx" }
}
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "bedrock:*",
"Resource": "*",
"Condition": {
"Bool": { "aws:PrincipalIsAWSService": "false" },
"StringNotEquals": { "aws:PrincipalAccount": "123456789012" }
}
}
]
}
キーポイント2:IaC(AWS CDK)
閉域化まわりは再現できる形にしておきたいので、CDK の該当部分を抜粋します。
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as lambda from 'aws-cdk-lib/aws-lambda';
// Bedrock 用 Interface VPC Endpoint
const bedrockEndpoint = vpc.addInterfaceEndpoint('BedrockRuntimeEndpoint', {
service: new ec2.InterfaceVpcEndpointAwsService('bedrock-runtime'),
subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
privateDnsEnabled: true,
securityGroups: [bedrockEndpointSg],
});
// 推論 Lambda(VPC 内・同時実行を明示的に制限)
const inferenceFn = new lambda.Function(this, 'FaqAssistFn', {
runtime: lambda.Runtime.PYTHON_3_13,
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
memorySize: 1024,
timeout: cdk.Duration.seconds(10),
reservedConcurrentExecutions: 50, // 暴走時のクォータ枯渇を防ぐ
environment: {
BEDROCK_MODEL_ID: props.inferenceProfileArn,
GUARDRAIL_ID: props.guardrailId,
FALLBACK_ENABLED: 'true',
},
});
// ピーク時間帯のみ Provisioned Concurrency(コールドスタート対策)
const alias = inferenceFn.currentVersion.addAlias('live');
alias.addAutoScaling({ minCapacity: 2, maxCapacity: 10 })
.scaleOnSchedule('BusinessHours', {
schedule: appscaling.Schedule.cron({ hour: '0', minute: '30' }), // JST 09:30
minCapacity: 10,
});
キーポイント3:モデル選定と、その後の差し替え
移行検討を始めた時点では Claude 3.5 Sonnet で評価し、「約款解釈タスクで既存構成と同等以上の精度が出る」ことを確認して移行を決断しました。その後モデルは順次差し替えており、現時点では Claude Sonnet 4.6 / Sonnet 5 世代を利用しています(Amazon Bedrock では2026年6月30日に Claude Sonnet 5、同年4月16日に Claude Opus 4.7 が提供開始されています)。
このとき効いたのが、後述する Converse API への統一です。モデル固有のリクエストボディを書いていなかったため、モデル差し替えは 推論プロファイル ARN を環境変数で差し替えるだけ で完了しました。移行時の設計判断が、その後の運用コストを直接下げた形です。
■ キャパシティ設計と可用性設計
生成AIの記事では省略されがちですが、金融の本番システムではここが設計の本体です。
1. ピーク負荷の算出
ピーク時同時通話数 : 32 件
AHT : 361 秒
→ ピーク時着信レート : 32 ÷ 361 × 3600 = 約 319 件/時
1通話あたりの推論回数 : 平均 2.4 回
(全発話ではなく、意図判定で「質問」と分類された発話のみトリガー)
→ ピーク時推論レート(平均) : 319 × 2.4 ÷ 3600 = 0.21 req/s
→ バースト係数 3.5(発話は通話内で偏る)
→ 設計値 : 0.75 req/s
トークン換算すると以下になります。
| 項目 | 値 |
|---|---|
| 1推論あたり入力トークン | 約 9,500(固定コンテキスト 8,000 + 可変 1,500) |
| 1推論あたり出力トークン | 約 220 |
| ピーク時入力 TPM(バースト時) | 約 427,000 |
| ピーク時出力 TPM(バースト時) | 約 9,900 |
| 月間推論回数 | 約 115,200 回 |
2. クォータ設計:クロスリージョン推論プロファイルで吸収する
ピーク入力 TPM 約427,000 に対し、単一リージョンのオンデマンドクォータでは余裕が十分と言えなかったため、クロスリージョン推論プロファイルを採用しました。
- リクエストを複数リージョンに分散させ、単一リージョンのスロットリング影響を受けにくくする
- IAM / VPC Endpoint ポリシーは推論プロファイル ARN に対して付与する
- データ所在の確認は必須。今回は APAC 内に限定されるプロファイルであること、および顧客のデータレジデンシー要件(国内保管が必要なのは録音・ログであり、推論の一時処理は APAC 内で可)をリスク管理部と合意したうえで採用しています
💡 ここは業種によって結論が変わる部分です。「国内リージョン限定」が譲れない要件なら、Provisioned Throughput の検討に進むことになります。
3. スロットリング時の挙動:リトライしない
一般的には指数バックオフでリトライしますが、このシステムではリトライしません。
理由は単純で、2秒要件を満たせないリトライには意味がないからです。リトライして4秒後に正しい回答が出ても、オペレータは既に自分で約款を開いています。
from botocore.config import Config
# リトライは実質無効化。速やかにフォールバックへ倒す
bedrock = boto3.client(
"bedrock-runtime",
config=Config(
retries={"max_attempts": 1, "mode": "standard"},
connect_timeout=1,
read_timeout=8,
),
)
def get_answer(context_docs, utterance, article_ids):
try:
return stream_answer(context_docs, utterance)
except bedrock.exceptions.ThrottlingException:
emit_metric("FallbackThrottle", 1)
return fallback_links(article_ids) # 条文リンクのみ即時表示
except Exception as e:
emit_metric("FallbackError", 1)
logger.exception("inference failed")
return fallback_links(article_ids)
4. 縮退運転の3段階設計
「AI が落ちたら業務停止」は金融システムでは通りません。段階的な縮退を設計しました。
| レベル | 発動条件 | オペレータ画面の状態 | 業務影響 |
|---|---|---|---|
| L0 正常 | - | 回答案 + 根拠条文 + 確信度 | なし |
| L1 縮退 | Bedrock スロットリング/タイムアウト | 検索ヒットした条文リンクのみ表示 | 軽微(移行前の運用と同等) |
| L2 縮退 | OpenSearch も応答しない | 静的FAQ(S3配信)へのリンク表示 | 中(検索精度が落ちる) |
| L3 停止 | WebSocket 接続不可 | 支援機能なし。CCP は通常通り稼働 | 通話業務自体は継続可能 |
重要なのは、L3 でも通話業務が止まらないことです。支援システムを Amazon Connect の通話パスから完全に分離し、片側故障が通話に波及しない構成にしています。ここは移行の可否を左右した設計判断でした。
5. Lambda のコールドスタート対策
VPC Lambda のコールドスタートは実測で 約620ms(Python 3.13 / 1024MB / ENI 再利用時)。2秒要件に対してこれは致命的なので、
- 営業時間帯(JST 9:30〜18:00)に Provisioned Concurrency を 10 で固定
- 時間外は 2 まで縮退
-
reservedConcurrentExecutions = 50で、異常時に Bedrock クォータを食い潰さないよう上限を設定
コールドスタート発生率は 0.3% に収まり、該当リクエストは L1 縮退で救済されます。
■ 実装のハマりどころ(6点)
1. プロンプト移植:system の扱いが違う
OpenAI では messages 配列の先頭に system ロールを置きますが、Bedrock の Converse API では system は独立したトップレベルパラメータです。ここを機械的に移すと、system プロンプトが user 発話として扱われ、指示追従性が目に見えて落ちます。
# Before(OpenAI 形式)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_text},
]
# After(Bedrock Converse 形式)
system = [{"text": SYSTEM_PROMPT}]
messages = [{"role": "user", "content": [{"text": user_text}]}]
あわせて 指示の書き方そのもの も調整しました。Claude はXMLタグによる構造化指示への追従性が高いため、参照コンテキストを <knowledge> <utterance> のようなタグで囲む形に書き換えたところ、根拠条文の引用精度が目に見えて改善しました。
2. Temperature の再チューニング
同じ temperature=0.7 でも出力のばらつき方が異なります。評価データセット480件で振り直した結果です。
| temperature | 正答率 | 「根拠条文なし」の誤生成率 | 表現の自然さ(5段階人手評価) |
|---|---|---|---|
| 0.0 | 93.1% | 0.4% | 3.2 |
| 0.2 | 93.8% | 0.6% | 3.9 |
| 0.5 | 92.4% | 1.8% | 4.1 |
| 0.7 | 89.6% | 4.3% | 4.2 |
金融のFAQ支援では 「自然さ」より「言っていないことを言わない」 が優先なので 0.2 を採用。Before 構成では 0.7 で運用していたため、ここは移行に伴う設計変更でもあります。
3. API は ConverseStream に統一する
当初は InvokeModelWithResponseStream で実装していましたが、Converse API 系に統一しました。理由はモデル差分の吸収です。前述のモデル差し替えが環境変数の変更だけで済んだのは、この判断のおかげです。
import os, json, boto3
MODEL_ID = os.environ["BEDROCK_MODEL_ID"] # 推論プロファイル ARN
GUARDRAIL_ID = os.environ["GUARDRAIL_ID"]
GUARDRAIL_VER = os.environ["GUARDRAIL_VERSION"]
SYSTEM_PROMPT = """あなたは金融商品コンタクトセンターのオペレータ支援AIです。
提示された約款・FAQの範囲内でのみ回答案を作成してください。
根拠が見つからない場合は confidence を 0.0 とし、answer は空文字にしてください。
必ず以下のJSON形式のみを出力してください(前後に説明文を付けないこと)。
{"answer": "...", "article_ids": ["第12条第2項"], "confidence": 0.0-1.0}"""
def build_messages(context_docs: str, utterance: str):
return [{
"role": "user",
"content": [
# 変わらないもの(約款・FAQ)を前に置き、キャッシュ境界を切る
{"text": f"<knowledge>\n{context_docs}\n</knowledge>"},
{"cachePoint": {"type": "default"}},
# 変わるもの(発話)は境界の後ろ
{"text": f"<utterance>\n{utterance}\n</utterance>"},
],
}]
def stream_answer(context_docs: str, utterance: str):
resp = bedrock.converse_stream(
modelId=MODEL_ID,
system=[{"text": SYSTEM_PROMPT}, {"cachePoint": {"type": "default"}}],
messages=build_messages(context_docs, utterance),
inferenceConfig={"temperature": 0.2, "maxTokens": 800},
guardrailConfig={
"guardrailIdentifier": GUARDRAIL_ID,
"guardrailVersion": GUARDRAIL_VER,
"streamProcessingMode": "synchronous", # 理由は後述
},
)
first_token_emitted = False
for event in resp["stream"]:
if "contentBlockDelta" in event:
if not first_token_emitted:
emit_metric("TimeToFirstToken", elapsed_ms(), unit="Milliseconds")
first_token_emitted = True
yield event["contentBlockDelta"]["delta"].get("text", "")
elif "metadata" in event:
usage = event["metadata"].get("usage", {})
emit_cache_metrics(usage) # 後述の可観測性で使用
4. Transcribe の partial result:投機的推論は見送った
リアルタイム系でまず検討したのが、Transcribe の partial result(途中経過)で推論を先行開始する投機的実行です。発話確定を待たない分、理論上は0.3秒ほど稼げます。
検証した結果、見送りました。
| 観点 | 結果 |
|---|---|
| 短縮効果 | TTFT で約 0.28s |
| 無駄撃ち率 | 41%(partial から final で文意が変わり破棄) |
| 追加コスト | 推論回数が約1.7倍 |
| 副作用 | 破棄したリクエストがキャッシュ書き込みを起こし、ヒット率が11pt低下 |
Prompt Caching で TTFT が既に0.42秒まで落ちていたため、0.28秒のために推論コスト1.7倍とキャッシュ汚染を受け入れる合理性がなかった、というのが結論です。
代わりに 意図判定の軽量化で稼ぎました。「この発話は質問か否か」の判定を LLM ではなくルールベース + 軽量分類器で行い、推論トリガー自体を2.4回/通話まで絞っています。推論を速くするより、推論しないで済ませる方が効く、というのはこの案件の学びでした。
5. Guardrails:本命は「根拠照合」
Bedrock Guardrails を2つの目的で使っています。
(1) PII の自動マスキング
氏名・電話番号・証券番号などを検出し、ログ出力前にマスク。監査対応がここで一段楽になりました。
(2) Contextual Grounding Check(根拠照合)
これが金融ドメインの本命です。「約款に書かれていない支払条件を生成していないか」を、提示したコンテキストとの整合性で機械的に判定します。
{
"contextualGroundingPolicyConfig": {
"filtersConfig": [
{ "type": "GROUNDING", "threshold": 0.75 },
{ "type": "RELEVANCE", "threshold": 0.70 }
]
}
}
閾値チューニングの結果です。
| threshold | ブロック率 | 誤ブロック(正しい回答を弾いた)率 | 見逃し率 |
|---|---|---|---|
| 0.60 | 2.1% | 0.4% | 1.9% |
| 0.75 | 4.7% | 1.3% | 0.5% |
| 0.85 | 11.2% | 6.8% | 0.2% |
0.75 を採用。誤ブロックが1.3%発生しますが、ブロック時は L1 縮退(条文リンクのみ表示)にフォールバックするため業務は止まりません。「間違った回答を出す」より「回答を出さない」方が安全という、金融ドメインの判断です。
⚠️ ストリーミングとの組み合わせに注意
streamProcessingModeにはsynchronousとasynchronousがあります。asynchronousは体感が速い一方、ガードレール判定前のテキストが一瞬画面に出る可能性があります。検証環境で実際に「誤った支払条件が0.4秒だけ表示される」事象を再現してしまい、リスク管理部レビューで即座に却下されました。本番はsynchronousを採用しています。TTFT は約0.12秒悪化しましたが、要件内に収まったため問題にしていません。
6. Prompt Caching:一番効いた打ち手と、その限界
約款・FAQ の固定コンテキスト(約8,000トークン)は同じ問い合わせカテゴリ内で共通です。ここに cachePoint を置いた効果は劇的でした。
| 指標 | キャッシュなし | キャッシュあり | 改善 |
|---|---|---|---|
| TTFT(最初の文字表示)p50 | 0.98s | 0.42s | −57% |
| キャッシュヒット率(全体) | - | 87.3% | - |
| 入力トークン課金額(月) | 約 ¥492,000 | 約 ¥168,000 | −66% |
レイテンシとコストが同時に下がるので、長い固定コンテキストを持つ業務では最優先で検討すべきだと思います。
キャッシュ境界の置き方
当初は会話履歴まで含めてキャッシュしようとしてヒット率が40%台に沈みました。「変わらないもの(system・約款)を前に、変わるもの(発話)を後ろに」 並べ替えてから cachePoint を置く、という原則に整理してヒット率が87%まで上がりました。
ヒット率87.3%の内訳:TTL との戦い
「87.3%」は平均値で、実際は時間帯によって大きく振れます。キャッシュには有効期限があり、呼び出し間隔が空くと失効するためです。
| 時間帯 | 推論レート | キャッシュヒット率 |
|---|---|---|
| 10:00–12:00(繁忙) | 0.19 req/s | 94.2% |
| 13:00–16:00(通常) | 0.12 req/s | 89.6% |
| 16:00–17:00(やや閑散) | 0.05 req/s | 78.1% |
| 9:00–10:00 / 17:00–18:00(閑散) | 0.02 req/s | 61.4% |
閑散時間帯は呼び出し間隔が TTL を超えやすく、ヒット率が落ちます。
対策として 定期的なウォームアップリクエストでキャッシュを維持する案を検討しましたが、
- ウォームアップ自体のトークン費が、閑散時間帯の削減額を上回る試算になった
- 閑散時間帯は絶対的な推論数が少なく、全体コストへの寄与が小さい
ため 見送りました。「効果が出る打ち手でも、効かない時間帯に無理に適用しない」という判断です。
代わりに、カテゴリ別に分かれていたコンテキストを上位カテゴリで統合し、キャッシュ対象を共通化することでヒット率を3.8pt改善しています。
■ 運用設計:何をどう監視しているか
移行前は「回答案が出ない」の切り分けに平均38分かかっていました。ここを設計で潰しています。
1. カスタムメトリクス(CloudWatch EMF)
標準メトリクスだけでは「AIが期待通り動いているか」は分かりません。EMF(Embedded Metric Format)で以下を出力しています。
import json, time
def emit_cache_metrics(usage: dict):
print(json.dumps({
"_aws": {
"Timestamp": int(time.time() * 1000),
"CloudWatchMetrics": [{
"Namespace": "FaqAssist/Inference",
"Dimensions": [["Environment", "QueueId"]],
"Metrics": [
{"Name": "TimeToFirstToken", "Unit": "Milliseconds"},
{"Name": "CacheReadTokens", "Unit": "Count"},
{"Name": "CacheWriteTokens", "Unit": "Count"},
{"Name": "InputTokens", "Unit": "Count"},
{"Name": "OutputTokens", "Unit": "Count"},
{"Name": "Confidence", "Unit": "None"},
],
}],
},
"Environment": "prod",
"QueueId": current_queue_id(),
"CacheReadTokens": usage.get("cacheReadInputTokens", 0),
"CacheWriteTokens": usage.get("cacheWriteInputTokens", 0),
"InputTokens": usage.get("inputTokens", 0),
"OutputTokens": usage.get("outputTokens", 0),
}))
2. 監視項目とアラート閾値
| メトリクス | 意味 | アラート閾値 | 発報時の初動 |
|---|---|---|---|
TimeToFirstToken p90 |
体感速度 | > 1.0s(5分継続) | 推論プロファイルのリージョン偏りを確認 |
FallbackRate |
L1縮退の発生率 | > 5%(5分継続) | スロットリングか例外かを内訳で切り分け |
CacheHitRate |
キャッシュ効率 | < 70%(1時間) | プロンプト変更のデプロイ影響を疑う |
GuardrailBlockRate |
根拠照合ブロック率 | > 8%(1時間) | ナレッジ更新漏れの可能性 |
LowConfidenceRate |
確信度 < 閾値の割合 | > 20%(1時間) | 検索精度の劣化を疑う |
AnswerAdoptionRate |
回答案の採用率 | < 50%(日次) | 業務部門へヒアリング |
GuardrailBlockRate の上昇が「約款が改訂されたのにナレッジDBが未更新」のシグナルになる、というのは運用してから気づいた副産物でした。いまは約款改訂の検知アラートとして実質機能しています。
3. ダッシュボードと分散トレーシング
- CloudWatch ダッシュボードを 1枚に統合(移行前は AWS / Azure で2枚)
- AWS X-Ray で Transcribe → Lambda → OpenSearch → Bedrock を1トレースに接続
- 「回答案が出ない」の問い合わせは、Contact ID からトレースを引く手順書を整備
これにより 一次切り分け時間が平均38分 → 11分 になりました。
■ 合意形成:金融システムの移行をどう通したか
技術的に可能であることと、金融システムで実際に切り替えられることは別問題です。ここが移行で一番時間を使った部分でした。
ステークホルダーごとの懸念と対応
| 部門 | 懸念 | 我々が用意したもの |
|---|---|---|
| 業務部門 | 「精度が落ちたら現場が混乱する」 | シャドー評価による定量比較(後述) |
| 情報システム部 | 「切り戻せるのか」 | キュー単位の段階切替とフラグによる即時切り戻し |
| リスク管理部 | 「PIIがどこを通るのか説明できるか」 | PrivateLink 経路図+CloudTrail によるデータフロー証跡 |
特にリスク管理部からは、前述の asynchronous ガードレールの挙動について厳しい指摘を受けました。検証段階でこの事象を自分たちで発見し、先に報告していたことが、その後の信頼につながったと感じています。リスクを隠さず先に出す方が、結果的に早く進みます。
シャドー評価の設計
「たぶん大丈夫」では切り替えられないため、3週間のシャドー評価期間を設けました。
Transcribe 発話確定
│
├──▶ Azure OpenAI(本番:オペレータ画面に表示)
│
└──▶ Bedrock(シャドー:DynamoDB に記録のみ)
│
▼
日次で差分を人手レビュー(品質評価担当1名)
| 項目 | 設計値 | そう設計した理由 |
|---|---|---|
| 期間 | 3週間(営業日15日) | 月初・月中・月末で問い合わせ傾向が変わるため、1サイクルを跨ぐ必要があった |
| 対象 | 全コンタクトの20%(無作為抽出) | 統計的有意性を確保しつつ、人手レビュー工数を1名で回せる上限 |
| サンプル数 | 約3,200件 | 上記から算出 |
| 判定 | 回答一致率/条文引用一致率/人手による優劣判定 | 「一致しない=劣化」ではないため、優劣の人手判定を必須にした |
判定方法を事前に業務部門と合意したことがポイントです。結果が出てから基準を議論すると必ず揉めます。
結果と、事前に決めた撤退基準
| 指標 | 結果 | 事前合意した合格ライン |
|---|---|---|
| 回答の実質一致率 | 91.4% | 85%以上 |
| 不一致のうち Bedrock 優位 | 62% | Azure優位を上回ること |
| 不一致のうち Azure 優位 | 23% | - |
| どちらも不適切 | 15% | 現行比で悪化しないこと |
「不一致のうち Bedrock 優位が62%」 を示せたことで業務部門の合意を取ることができました。ここを定量で示せたことが、移行を通せた最大の要因です。
カットオーバー
キュー単位の段階切り替え(10% → 30% → 100%、各1週間)。Lambda 環境変数のフラグで切り替え、切り戻しは5分以内に完了できる状態を全期間維持しました。
撤退基準も事前に明文化しています。
- 正答率が移行前比で2pt以上低下した場合
- L1縮退の発生率が5%を超えた場合
- 業務部門からのエスカレーションが週3件を超えた場合
結果的に発動せずに済みましたが、「いつ戻すか」を先に決めておくことが、関係部署の不安を下げる最も効果的な手段でした。
■ 移行によって得られた効果
レイテンシ
After:回答案が画面に出始めるまで(TTFT p50 = 0.42s)
0.0s 0.35s 0.42s 1.10s
│───────────│────────────│──────────────────────────────────────│
│ Transcribe│ 検索+Lambda │ ストリーミングで逐次表示(全文完了) │
│ 0.35s │ 0.21s │ 0.54s │
^^^
ここで最初の文字が出る
| 区間 | Before | After | 差分 |
|---|---|---|---|
| Transcribe 発話確定 | 0.35s | 0.35s | ±0 |
| Lambda 前処理+ナレッジ検索 | 0.08s | 0.21s | +0.13s |
| クラウド間往復 | 0.71s | 0.00s | −0.71s |
| LLM 推論(全文完了まで) | 1.94s | 0.42s | −1.52s |
| 画面描画 | 0.12s | 0.12s | ±0 |
| 全文表示まで p50 | 3.20s | 1.10s | −66% |
| 全文表示まで p90 | 5.84s | 1.93s | −67% |
| TTFT(最初の文字表示)p50 | 3.20s※ | 0.42s | −87% |
※ Before は生成完了後に一括表示していたため、TTFT=全文表示時間。
オペレータ体感で一番効いたのは TTFT でした。 0.42秒で文字が出始めると「AIが動いている」ことが分かるため、待ち時間の体感が劇的に変わります。全文完了までの1.1秒より、この0.42秒の方が運用定着に寄与したというのが現場の実感です。
業務指標とその金額換算
| 指標 | Before | After | 差分 |
|---|---|---|---|
| AHT(平均処理時間) | 412秒 | 361秒 | −12.4%(−51秒) |
| 回答案の採用率※ | 34% | 71% | +37pt |
| 約款照会の正答率(n=480) | 91.2% | 93.8% | +2.6pt |
| 保留(ホールド)発生率 | 18.3% | 11.6% | −6.7pt |
※オペレータが提示された回答案を実際に使った割合。**「速くなったから使われるようになった」**という、非常に分かりやすい相関が出ました。3.2秒待てなかったオペレータが、1.1秒なら待つ、ということです。
AHT削減の金額換算
削減時間 : 51秒 × 48,000件/月 = 2,448,000秒 = 680時間/月
年間換算 : 680時間 × 12 = 8,160時間/年
金額換算 : 8,160時間 × ¥2,800(オペレータ時間単価)
≒ 年間 2,280万円 相当
45席規模のセンターで 年間約2,280万円相当の処理能力を創出した計算になります。実際には人員削減ではなく、アウトバウンド施策へのリソースシフトに充てられています。
コスト
| 項目 | Before(月額) | After(月額) | 差分 |
|---|---|---|---|
| LLM 推論費(入力) | ¥410,000 | ¥168,000 | −¥242,000 |
| LLM 推論費(出力) | ¥38,000 | ¥57,000 | +¥19,000 |
| クラウド間データ転送費 | ¥62,000 | ¥0 | −¥62,000 |
| Azure 側リソース運用費 | ¥85,000 | ¥0 | −¥85,000 |
| OpenSearch Serverless | ¥98,000 | ¥98,000 | ±0 |
| VPC Endpoint | ¥0 | ¥12,000 | +¥12,000 |
| Guardrails | ¥0 | ¥21,000 | +¥21,000 |
| 合計 | ¥693,000 | ¥356,000 | −48.6% |
削減額の内訳を見ると、Prompt Caching による入力トークン削減(¥242,000)が最大で、クラウド間転送費の削減(¥62,000)を大きく上回っています。「マルチクラウドをやめたから安くなった」というより、**「AWS ネイティブに寄せたことで使えるようになった機能が効いた」**というのが正確なところです。
AWS 消費額の観点
| Before | After | |
|---|---|---|
| AWS 課金分 | ¥160,000 | ¥356,000 |
| Azure 課金分 | ¥533,000 | ¥0 |
顧客のトータルコストは48.6%下がりつつ、AWS 消費額は月額 +¥196,000(年間約235万円)に拡大しています。「コスト削減」と「AWS利用拡大」が同時に成立した事例と言えます。
運用・監査
| 指標 | Before | After |
|---|---|---|
| 障害時の一次切り分け平均時間 | 38分 | 11分 |
| 四半期監査のログ突合工数 | 4人日 | 0.5人日 |
| 監視ダッシュボード数 | 2 | 1 |
| アクセス制御の管理点 | 2(IAM + Entra ID) | 1(IAM) |
年間換算で 監査工数14人日の削減。金額換算しにくいものの、担当者の負担としては移行の効果が最も体感できた部分でした。
■ チームへの展開と横展開
再現可能な形に残す
移行を「一度きりの職人芸」にしないため、以下を成果物として残しました。
| 成果物 | 内容 |
|---|---|
| 移行 Runbook | 前提確認 → シャドー評価 → 段階切替 → 切り戻しまでの手順書 |
| CDK テンプレート | VPC Endpoint / Lambda / Guardrails / 監視の一式 |
| 評価データセット運用ルール | 追加・棄却の基準、四半期ごとの棚卸し手順 |
| プロンプト移植チェックリスト | OpenAI → Converse API の差分18項目 |
横展開の実績
このアーキテクチャを 他2案件に適用しています。金融ほど厳格な要件ではないため、そのままではオーバースペックになる部分があり、
- Guardrails の Contextual Grounding は残す(ハルシネーション対策は業種を問わず必要)
- 縮退設計は L1 までに簡素化
- クロスリージョン推論プロファイルは単一リージョンに簡素化
という形で「削れる部分/削れない部分」を整理したうえでテンプレート化しました。この整理自体が、提案活動での標準アーキテクチャとして機能しています。
社内展開
- 社内技術共有会で移行事例を発表
- 生成AIを組み込むシステムの 非機能要件チェックリスト(縮退設計・クォータ設計・可観測性)を作成し、部門標準として展開
- 「レイテンシ要件のある生成AI案件では TTFT を非機能要件に明記する」を提案テンプレートに反映
💡 この移行から得られたポイント
- マルチクラウドは「悪」ではなく「当時の最適解」。判断すべきは前提条件が変わったかどうかで、構成の美しさではない
- レイテンシ改善の主役は「全文完了時間」ではなく TTFT。ストリーミング+Prompt Caching で最初の1文字を0.5秒以内に出せると、AI支援機能の採用率が跳ね上がる(34% → 71%)
- 推論を速くするより、推論しないで済ませる方が効く。意図判定で推論トリガーを絞る方が、投機的実行より費用対効果が高かった
- Prompt Caching は TTL との戦い。平均ヒット率だけ見ていると、閑散時間帯の劣化を見落とす。時間帯別に分解して初めて打ち手が決まる
- Converse API に統一しておくと、モデル差し替えが環境変数の変更だけで済む。生成AIのモデル更新サイクルを考えると、この投資は数ヶ月で回収できる
- Guardrails の Contextual Grounding Check は金融ドメインで実用レベル。ブロック率の上昇が「ナレッジ未更新」のシグナルとしても機能する
-
streamProcessingModeは安全性と体感速度のトレードオフ。金融ではsynchronous一択だった - 生成AI機能は通話パスから分離し、3段階の縮退を設計する。「AIが落ちても通話業務は止まらない」ことが、金融で移行を通すための必要条件
- 移行の説得材料はシャドー評価。判定基準と撤退基準を「結果が出る前に」合意することが、関係部署の不安を下げる最短路
- ネイティブ機能を採用しない判断には、必ず理由を用意しておく。新規構築なら Amazon Q in Connect から検討すべきで、今回は既存資産がある移行案件ゆえの選択
■ おわりに
Amazon Connect と生成AIの組み合わせにおいて、数年前は「LLMだけ他クラウド」という構成に十分な合理性がありました。しかし現在は、パフォーマンス・セキュリティ・運用性のすべてで AWS ネイティブ構成に優位性があるというのが、実際に移行してみた率直な感想です。
特に、PrivateLink による閉域化・Guardrails による安全性担保・Prompt Caching による高速化が同じプラットフォーム上で完結することの価値は、構成図の見た目以上に大きいと感じています。
そして技術以上に重要だったのが、**「AIが落ちても業務が止まらない設計」と「撤退基準を先に決める進め方」**でした。金融システムで生成AIを本番投入する際は、この2つが移行の可否を決めると思います。
今後の展望
- Bedrock Knowledge Bases への移行:現在は OpenSearch Serverless + 自前チャンカーですが、約款の階層構造を保持したまま Knowledge Bases に載せられるか検証中です。運用の手離れをさらに良くしたい
- マルチエージェント化:「約款照会」「手続き案内」「本人確認」を別エージェントに分割し、発話意図に応じてルーティングする構成を検討中
- ネイティブ機能への再合流:Amazon Connect のエージェント型AI機能は急速に進化しているため、「自前実装のどこを手放せるか」を四半期ごとに棚卸ししています。自前実装を維持し続けること自体が目的化しないよう気をつけたいところです
同じように「数年前に組んだマルチクラウド構成をどうするか」で悩んでいる方の参考になれば幸いです。
※ 本記事の構成・数値は実案件をベースにしていますが、顧客・案件が特定されないよう一部を加工しています。

