Amazon Bedrockのバッチ推論「でも」Structured Outputsを活用、AIの出力を安定化してみた
当社の技術ブログ「DevelopersIO」では、AIを利用して約60,000件超の記事の要約作成を試みています。今回、プロンプトの改善結果を反映するため、大量の要約を再処理する必要が生じました。全量を一度に処理する前に、まずパイロットとして直近の約7,000件を対象に先行実施した結果を紹介します。
これまで、Amazon Bedrockのバッチ推論とStructured Outputsについてそれぞれ紹介してきました。
今回はこの2つを組み合わせた実践記録となります。
オンデマンドで1件ずつ処理すると約3秒/件 x 60,000件で約50時間かかりますが、Batch Inferenceなら処理時間の大幅短縮とコスト50%削減が見込めます。Batch Inferenceの基本的なセットアップについては1本目の記事を参照してください。
課題: バッチ推論のJSON出力が不安定だった
1本目のバッチ推論では、systemプロンプトに「JSON形式で出力してください」と指示し、結果を正規表現でパースしていました。この方法では、バッチ用にJSON出力指示を追加することで本番プロンプトと乖離する上、日本語テキスト中のダブルクォートや改行でJSONが壊れる、複数フィールドの後半が欠落する、長文でJSON閉じ括弧が欠けるといった問題が発生していました。
今回のプロンプト改善に合わせて、オンデマンド側はStructured Outputsに移行済みです。バッチ側でも同じスキーマで処理できれば、プロンプトの一元管理と型安全なJSON出力を両立できます。
解決: バッチ推論でもStructured Outputsが使えた
結論から言うと、Batch InferenceでもStructured Outputsはそのまま使えました。当初は「バッチでは使えないだろう」と思い込み従来方式で実装を進めていましたが、公式ドキュメントに "Batch inference - Use structured outputs within batch inference without any additional setup." と明記されていました。
以降のプロンプトとスキーマは動作再現用のミニマムサンプルです。本番プロンプトの詳細は非公開としています。
サンプルスキーマ
日本語と英語の要約・詳細、計4フィールドを1回のリクエストで生成します。
SCHEMA = {
"type": "object",
"properties": {
"summary_ja": {"type": "string"},
"summary_en": {"type": "string"},
"detail_ja": {"type": "string"},
"detail_en": {"type": "string"}
},
"required": ["summary_ja", "summary_en", "detail_ja", "detail_en"],
"additionalProperties": False
}
required に指定した全フィールドの出力が保証されるため、フィールドの欠落やJSON構文エラーが原理的に発生しません。
JSONLの変更は3箇所だけ
既存のバッチ推論JSONLに対して、systemプロンプトからJSON出力指示を削除し、output_config を追加するだけです。結果パースも json.loads() のみになります。
{
"recordId": "article_id",
"modelInput": {
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 4096,
- "system": "... Generate a JSON with summary_ja, summary_en ... Output ONLY valid JSON.",
+ "system": "... Generate summaries in both Japanese and English.",
"messages": [{"role": "user", "content": "..."}],
+ "output_config": {
+ "format": {
+ "type": "json_schema",
+ "schema": { ... }
+ }
+ }
}
}
オンデマンドとバッチで同一プロンプト・同一スキーマになり、品質の一貫性が保証されます。
API間のスキーマ形式の違い
Structured Outputsを複数のAPIで使う場合、スキーマの渡し方がAPIごとに異なります。
| API | パラメータ | schema の型 | name 必須 |
|---|---|---|---|
| Converse API | outputConfig.textFormat.structure.jsonSchema |
JSON文字列 | Yes |
| InvokeModel API | modelInput.output_config.format |
dict オブジェクト | No |
| Batch Inference (JSONL) | modelInput.output_config.format |
dict オブジェクト | No |
スキーマをdictで一元管理し、Converse API側でのみ json.dumps() するのが実用的です。
# Converse API -- schemaを文字列化、name必須
outputConfig={"textFormat": {"type": "json_schema", "structure": {
"jsonSchema": {"name": "my_schema", "schema": json.dumps(SCHEMA)}}}}
# Batch Inference(JSONL内)-- dictのまま、nameなし
"output_config": {"format": {"type": "json_schema", "schema": SCHEMA}}
バッチが向かないケース: プロンプトキャッシュとの比較
systemプロンプトが大きく同一プロンプトを繰り返す場合は、オンデマンド + プロンプトキャッシュのほうがコスト効率が良くなります。
当システムではタグ分類処理のsystemプロンプトにタグマスタCSV(約3,000件、約15,000トークン)を含みます。この処理ではプロンプトキャッシュで2件目以降の入力コストを90%削減しています。
system=[
{"text": tag_prompt},
{"cachePoint": {"type": "default"}}
]
タグ分類1,000件の場合のコスト比較です(systemプロンプト約15,000トークン、記事入力約500トークン、出力約100トークン)。
| 方式 | 入力コスト | 出力コスト | 合計 |
|---|---|---|---|
| バッチ(50%OFF) | $6.20 | $0.20 | $6.40 |
| オンデマンド(キャッシュなし) | $12.40 | $0.40 | $12.80 |
| オンデマンド(キャッシュあり) | $1.60 | $0.40 | $2.00 |
※料金は Claude 3.5 Haiku で計算(入力 $0.80 / 1M, 出力 $4.00 / 1M)
systemプロンプトが大きく繰り返し利用する場合、プロンプトキャッシュの90%OFFがバッチの50%OFFを上回ります。ただし、プロンプトキャッシュにはTTL(5分間)の制約があり、リクエスト間隔が5分を超えるとキャッシュが失効して通常料金に戻ります。連続して処理し続けられるワークロードであることが前提です。バッチ推論にはこのような時間的制約がないため、「いつでも投入して放置できる」という運用上の手軽さがあります。
判断フローチャート
Batch Inferenceの最小要件は1,000件です。それ未満では待機時間だけで15分以上かかるため、少数処理には向きません。1,000件以上であれば並列処理により件数が増えても所要時間はほぼ一定です(実測: 1,000件17分、7,950件21分)。
当システムでの使い分けは以下の通りです。
| 処理 | systemプロンプト | 方式 | 理由 |
|---|---|---|---|
| AI要約生成 | 約800トークン | バッチ推論 | プロンプト小、50%OFF有効 |
| 記事評価 | 約600トークン | バッチ推論 | プロンプト小、50%OFF有効 |
| タグ分類 | 約15,000トークン | オンデマンド+キャッシュ | プロンプト大、キャッシュ90%OFFが有利 |
実装のポイント
モデルIDに us. prefixを付けてCross-Region Inference Profileを有効にすると、リージョン間の負荷分散で特定リージョンのキャパシティ不足を回避できます。また、投入前にrecordIdの重複チェック、ファイルサイズ200MB以下、レコード数1,000-50,000の範囲内であることを確認しておくと、Validating段階での失敗を防げます。
実行結果
1回目: パイロット(1,000件)
| 項目 | 値 |
|---|---|
| 処理時間 | 17分 |
| 成功率 | 973/1,000 (97.3%) |
| JSONパース成功率 | 100%(成功レコード) |
| エラー内訳 | Grammar compilation timed out: 26件, Content filtering: 1件 |
成功したレコードのJSON構文エラーは0件でした。
26件のエラーに再現性は確認されず、事象から推察すると、基盤側の一時的な負荷やコールドスタートに起因するタイムアウトの可能性が考えられます。
2回目: スケールアップ(6,284件)
| 項目 | 値 |
|---|---|
| 処理時間 | 18分 |
| 成功率 | 6,281/6,284 (99.95%) |
| エラー | 3件 |
件数が6倍になっても処理時間はほぼ変わりませんでした。エラー率も2.7%から0.05%に改善しており、基盤側で何らかのウォームアップが効いた可能性があります。
失敗分のリトライ
1回目と2回目の失敗分計30件は、オンデマンドのConverse APIで個別にリトライし全件解消しました。バッチの最小要件は1,000件のため、少数の失敗分はオンデマンドで即リトライするほうが合理的でした。
効果まとめ
| 観点 | オンデマンド1件ずつ | バッチ + Structured Outputs |
|---|---|---|
| 処理時間(1,000件) | 約50分(3秒/件) | 17分(並列処理) |
| 処理時間(7,000件) | 約6時間 | 約20分(2バッチ) |
| コスト | オンデマンド料金100% | 50%削減 |
| JSONパース | 正規表現 + バリデーション | json.loads() のみ |
| パース失敗率 | 数%発生しうる | 0%(スキーマ保証) |
| プロンプト管理 | バッチ用に別途管理 | 本番と完全同一 |
処理時間、コスト、品質のすべてで改善が得られました。特にプロンプト管理を一元化できたことは、今後の保守性向上における大きな収穫でした。
まとめ
Batch InferenceとStructured Outputsを組み合わせることで、型安全なJSON出力と50%のコスト削減を同時に実現できました。オンデマンドとバッチでプロンプトとスキーマを完全に共通化できるため、「バッチ用に別のプロンプトを管理する」という運用負荷を回避できました。
今後、新しいモデルのリリースやプロンプトチューニングなどで既存データの一括更新メンテナンスが必要になった際には、Batch InferenceとStructured Outputsを活用していく予定です。








