映画「プロジェクト・ヘイル・メアリー」の公開が待ち遠しい畑野です。
先日、記述式AWSクイズアプリの記事を公開したところ、少し反響をいただいたので、以前から改善ポイントとして考えていた「クイズ生成速度の改善」にチャレンジしてみました。
Amazon Bedrock(以降、Bedrock)に渡すプロンプト処理などに改善の余地があったため、どのように原因を切り分けて改善したか、そのプロセスをまとめてみました。
本記事をご覧になる前にこちらの記事をご覧いただくと、経緯が分かりやすくなります。
Bedrockのログ確認
Bedrockでモデル呼び出しのログ記録を有効化し、CloudWatch Logsを記録先に指定します。
その後、アプリを実行すると下記のようなログが出力されます。
"latencyMs": 21989 とあるため、このクイズ生成処理では、Bedrockがプロンプトを受け取って応答を返すまでに約22秒かかっていました。
これではクイズ生成に時間がかかるわけです。
"metrics": { "latencyMs": 21989 }, "usage": { "inputTokens": 1173, "cacheReadInputTokens": 0, "cacheWriteInputTokens": 0, "outputTokens": 1599, "totalTokens": 2772 } }
CloudWatch Logs Insightsでまとめて計測する場合は以下のクエリをご利用ください。
期間を30分程度にすると分析しやすいです。
fields @timestamp,
output.outputBodyJson.metrics.latencyMs as latency_ms,
output.outputBodyJson.usage.inputTokens as in_tokens,
output.outputBodyJson.usage.outputTokens as out_tokens,
output.outputBodyJson.usage.totalTokens as total_tokens
| filter ispresent(output.outputBodyJson.metrics.latencyMs)
| sort @timestamp desc

モデル出力の内訳を調査する
Inputで渡されるプロンプトはtemplate.yaml の System: と Messages: で定義していました。MCP Serverから得られた検索結果を元にこのプロンプトでクイズ生成を行います。
次にクイズ生成に利用されるOutputで出力されるログを確認します。
output.outputBodyJson.output.message.content の0番目の要素の text プロパティです。
$ jq '.text | fromjson' response.json で見やすく成型すると、以下のようなモデル出力(JSON文字列)が返ってきていることが分かります。
JSON文字列の内容を表示する
{ "title": "Lambda冪等性障害!「同じリクエストが2回実行された」原因と対策を答えよ", "body": "本番Lambda関数で「注文が2重に処理された」とユーザーから苦情が来た。調査するとSQSトリガーで同一メッセージが2回Lambdaに届いていた。Powertools for AWS Lambdaの冪等性ユーティリティを使って対策しようとしているが、設定が不完全だ。①この問題の根本原因、②冪等性の仕組み(INPROGRESS/COMPLETEステータスの役割)、③設定時の注意点を説明せよ。", "category": "serverless", "level": 300, "rubric": { "expectedAnswer": "①根本原因:SQSはat-least-once配信のため同一メッセージが複数回Lambdaに渡ることがある(重複実行)。②冪等性の仕組み:Powertoolsは初回実行時にリクエストIDをINPROGRESSとして永続化ストア(DynamoDB等)に記録し、処理完了後COMPLETEに更新する。2回目以降の同一IDリクエストはINPROGRESSまたはCOMPLETEを検出して処理をスキップし、COMPLETEの場合は保存済みレスポンスを返す。③注意点:冪等性キーの選定(リクエストIDやメッセージIDを使う)、TTL設定(期限切れ後は再実行される)、INPROGRESSタイムアウト設定(Lambda実行時間より長く設定する)。", "mustHavePoints": [ { "id": "p1", "label": "SQSのat-least-once配信が原因", "keywords_any": [ "at-least-once", "少なくとも1回", "重複配信", "SQS重複" ], "notes": "SQSの配信保証モデルを正確に述べていること" }, { "id": "p2", "label": "INPROGRESSステータスで処理中の重複をブロック", "keywords_any": [ "INPROGRESS", "処理中", "重複ブロック", "スキップ" ], "notes": "初回記録時のINPROGRESSの役割を説明していること" }, { "id": "p3", "label": "COMPLETEステータスで保存済みレスポンスを返す", "keywords_any": [ "COMPLETE", "保存済み", "キャッシュ", "レスポンス返却" ], "notes": "完了後の2回目リクエストに対してキャッシュ応答することを述べていること" }, { "id": "p4", "label": "冪等性キーの適切な選定(メッセージIDなど)", "keywords_any": [ "冪等性キー", "メッセージID", "リクエストID", "idempotency_key" ], "notes": "一意なキーを選ぶ重要性に言及していること" }, { "id": "p5", "label": "TTLまたはINPROGRESSタイムアウトの設定", "keywords_any": [ "TTL", "タイムアウト", "有効期限", "expires_after_seconds" ], "notes": "TTLやタイムアウト設定の必要性に触れていること" } ], "niceToHavePoints": [ { "id": "n1", "label": "永続化ストアにDynamoDBを使用する旨", "keywords_any": [ "DynamoDB", "永続化", "ストア" ], "notes": "デフォルトのバックエンドストアとしてDynamoDBに言及すると加点" } ], "commonWrongClaims": [ { "id": "w1", "label": "SQSはexactly-onceなので重複は起きないと主張", "keywords_any": [ "exactly-once", "重複しない", "1回だけ" ], "notes": "標準SQSはat-least-once。FIFO+重複排除は別設定が必要" }, { "id": "w2", "label": "冪等性はLambdaが自動で保証すると主張", "keywords_any": [ "Lambda自動", "自動保証", "デフォルト冪等" ], "notes": "冪等性はアプリ側で実装する必要がある" } ], "scoringPolicy": { "correct_threshold": 1.0, "close_threshold": 0.8, "must_points_total": 5, "close_if_must_points_met_at_least": 4, "correct_if_must_points_met_at_least": 5 } }, "sourceSummary": "Powertools for AWS Lambdaの冪等性ユーティリティはINPROGRESS/COMPLETEステータスをDynamoDB等に記録し重複実行を防ぐ。冪等性キー・TTL・INPROGRESSタイムアウトの適切な設定が必要。SQSのat-least-once配信による重複実行対策として有効。", "tags": [ "Lambda", "冪等性", "Powertools", "SQS", "DynamoDB", "運用", "serverless" ] }
ユーザーのクイズ回答画面には、 "title" と "body" が実際の問題文として表示されればいいのですが、それ以外に rubric (採点観点のチェックリスト) も生成されていることが判明しました。 rubricはユーザーの回答を評価するための情報であり、クイズ回答時には必要です。一方で、クイズ生成時点では不要な処理です。
次に "title", "body", "rubric"フィールドに含まれる文字列をPython の len() で文字数をカウントしてみます。
文字数カウントの結果
| フィールド名 | 文字数 |
|---|---|
| 全体 | 2332 |
| title | 40 |
| body | 198 |
| category | 12 |
| level | 3 |
| rubric | 1,767 |
| sourceSummary | 155 |
| tags | 70 |
rubricはレスポンス全体の約75%を占めていました。文字数とトークン数は単純には紐づけられませんが、ログ上でもoutputTokensが多かったため、出力量の多いrubricがレイテンシ増加の一因になっている可能性が高いと考えました。なお、レイテンシは文字数やトークン数に単純比例するとは限らないため、ここではあくまで粗い推測です。
また、outputTokensに対するrubricの影響を確認する方法としてCountTokens APIの利用も検討しました。ただし、CountTokens APIで見積もれるのは入力トークン数であり、outputTokensは生成処理後でなければ確定しません。さらに、改善前のプロンプトは、rubric以外のプロンプト要素と組み合わさって出力に影響するため、その影響だけを事前に切り分けることが難しく、今回はこの観点での調査は実施していません。*1
各フィールドの文字数カウント方法
文字数のカウント方法は以下の通りです。
textプロパティの内容をinput.jsonファイルに格納し実行しています。
$ python -c "import json; d=json.load(open('input.json', encoding='utf-8')); full=json.dumps(d, ensure_ascii=False); print(f'全体: {len(full)}\ntitle: {len(json.dumps(d.get(\"title\",\"\"), ensure_ascii=False))}\nbody: {len(json.dumps(d.get(\"body\",\"\"), ensure_ascii=False))}\ncategory: {len(json.dumps(d.get(\"category\",\"\"), ensure_ascii=False))}\nlevel: {len(json.dumps(d.get(\"level\",0), ensure_ascii=False))}\nrubric: {len(json.dumps(d.get(\"rubric\",{}), ensure_ascii=False))}\nsourceSummary: {len(json.dumps(d.get(\"sourceSummary\",\"\"), ensure_ascii=False))}\ntags: {len(json.dumps(d.get(\"tags\",[]), ensure_ascii=False))}')"
コードの改善
修正概要は下記の通りです。
大きな変更点はクイズとrubric生成を、1つのプロンプトでまとめていたものを、2つに分離し、rubric生成をバックグラウンドで並列実行する構成にした点です。
クイズ生成用プロンプトは、rubricを分離したことにより、問題の品質を落とさないように、良い例、悪い例、出力零をFew-Shotプロンプト化し、生成精度の向上を狙いました。 *2
コード差分はこちらです。
| ファイル名 | 修正概要 |
|---|---|
backend/template.yaml |
・QuizQuestionPrompt 追加(title+body生成用)・ QuizRubricPrompt 追加(rubric生成用)・ GenerateRubricFunction 追加(rubric生成専用Lambda)・環境変数とIAM権限追加 |
backend/src/generate_rubric/app.py |
(新規) ・Rubric生成専用関数 ・Bedrock呼び出し → DDB更新 |
backend/src/get_next_quiz/app.py |
・title+bodyのみ生成に変更 ・並列で GenerateRubricFunction呼び出し |
backend/src/judge_answer/app.py |
・Rubric完成待機ロジック追加 |
backend/layers/common/python/common/ddb.py |
・update_rubric() メソッド追加・ get_by_hash_with_retry() メソッド追加 |
backend/layers/common/python/common/validate.py |
・validate_rubric() 関数追加 |
改善後の処理時間
30回ほど生成を繰り返し、Bedrockのログを分析すると、以下のような結果となりました。
| 項目 | 改善前 (クイズ・rubric同時生成) |
改善後 (クイズ生成) |
改善後 (rubric生成) |
|---|---|---|---|
| latencyMs | 21,989 | 8,157 | 14,576 |
| inputTokens | 1,173 | 1,332 | 1,314 |
| outputTokens | 1,599 | 363 | 1,012 |
| totalTokens | 2,772 | 1,696 | 2,326 |
クイズ生成は平均約8秒で完了するようになり、クイズ初回表示までの待ち時間が改善前の約22秒から14秒短縮できました。
rubric生成には平均約14秒ほどかかっていますが、ユーザーがその短時間で解答を終えることは考えにくいため、バックエンドで並列して生成する構成でも問題ないと判断しています。
1回の呼び出しでクイズとrubricをまとめて生成していた処理を、2回に分けたことで、合算のinputTokensは増加しました。ただし、ユーザーが待つのは先に返すクイズ生成側だけになるため、体感速度は大きく改善しています。
また、outputTokensはクイズ生成側を "title" と "body" のみに絞ったことで、合計でも大きな増加はなく、想定通りの結果になりました。
よって、バックエンド全体の処理量は増えたものの、ユーザーが最初の問題文を受け取るまでの待ち時間を大きく短縮できた点が、今回の改善で最も大きな効果です。
クイズサンプル

まとめ
今回は、Bedrockのログを確認しながら生成結果の遅延要因を切り分け、生成処理の見直しを行いました。
その結果、クイズ本文と採点用rubricを一度に生成するのではなく、分離して並列化することで、ユーザーが最初の問題文を受け取るまでの待ち時間を大きく短縮できました。
生成AIを使ったアプリでは、つい「必要なものをまとめて1回で生成したくなる」ことがありますが、今回のように処理の目的と表示タイミングを分けて考えることで、ユーザー体験を改善できるケースがあることが分かりました。 Bedrockのログを活用してボトルネックを可視化すると、根拠を持って改善ポイントを見つけられるのが良い取り組みとなりました。
今回はBedrockのログが改善の方向性を考える上で非常に役立ったので、今後もまずは観測し、根拠を持って改善していきたいと思います。
AWSクイズアプリの詳細はこちらの記事をご参照ください。