はじめに
「10万件のドキュメントを要約したい」「月次レポートを LLM で一括生成したい」
こういうユースケースで、1件ずつ API を叩いていませんか?
Amazon Bedrock には Batch Inference という機能があります。JSONL ファイルに全リクエストをまとめて S3 に置き、非同期で一括処理する方式です。On-Demand の 50% OFF で処理できます。
本記事では、1005件のダミー議事録を生成して実際に Batch Inference を回し、コスト・実行時間・エラーハンドリング・ハマりポイントを検証した結果を共有します。
Batch Inference とは
On-Demand で大量処理するとき、何が辛いか
「1000件くらい for ループで回せばいいじゃん」と思うかもしれませんが、結構しんどいです。
まず Bedrock には TPM(Tokens Per Minute)と RPM(Requests Per Minute)のレート制限があります。
さらに厄介なのが「途中で落ちたらどうするか」問題です。
500件目で何かが壊れた場合、どこから再開するか。処理済みの recordId を追跡して、未処理分だけ再投入するロジックを書くことになります。
結果の集約も、非同期で返ってくるレスポンスを ID で紐付けて保存する仕組みが必要です。
要するに、「1件ずつ API を叩く」は一見シンプルに見えて、本番で真面目にやると制御コードの方が本体より長くなります。
Batch Inference ならどうなるか
Batch Inference はこの全部を AWS に丸投げできます。
やることは「JSONL を S3 に1本置いて、API を1回叩く」だけ。
リトライもレート制限も並行制御も結果の集約も、全部 Bedrock の内部で吸収されます。
| 比較軸 | On-Demand | Batch |
|---|---|---|
| レイテンシ | 即時(数秒) | 数十分〜数時間 |
| コスト | 100% | 50% |
| 最小単位 | 1リクエスト | JSONL(複数レコード) |
| レート制限対応 | 自前で実装 | 不要 |
| リトライ | 自前で実装 | 不要 |
| ユースケース | チャット・リアルタイム | バッチ処理・評価・レポート |
Batch が安い理由は EC2 Spot Instance と同じ発想です。「いつ処理してもいい」ワークロードを AWS が空きキャパシティで捌くため、レイテンシ SLA がない代わりに半額になります。
検証構成
| 項目 | 値 |
|---|---|
| モデル | Amazon Nova Lite (amazon.nova-lite-v1:0) |
| リージョン | us-east-1 |
| 入力形式 | Converse API format |
| タスク | 日本語議事録の3行要約 |
| レコード数 | 1,005件(通常1,000 + エラー検証5) |
| 実行時間 | 約12分(投入から結果出力まで) |
入力データの準備
1. テストデータ生成
Python で1000件のダミー議事録を生成しました。各議事録は以下の構成です。
(平均 1,321 文字/件、推定 660 tokens/件)
- 会議タイトル・日時・場所・参加者
- アジェンダ(2〜4項目)
- 議論内容(6〜12発言)
- 決定事項・次回アクション
2. JSONL 変換(Converse 形式)
Batch Inference の入力は JSONL です。1行1リクエスト。
{
"recordId": "DOC-0001",
"modelInput": {
"system": [{"text": "あなたは優秀な秘書です。与えられた議事録を読み、以下の形式で3行以内に要約してください:\n1. 会議の目的\n2. 主な決定事項\n3. 次のアクション"}],
"messages": [
{
"role": "user",
"content": [{"text": "以下の議事録を要約してください:\n\n会議タイトル: ...(以下省略)"}]
}
],
"inferenceConfig": {"maxTokens": 512, "temperature": 0.3, "topP": 0.9}
}
}
modelInvocationType: Converse を指定すると、この形式で統一できます。モデル固有の形式(InvokeModel)と違い、将来 Nova → Claude に変えても JSONL を書き直す必要がありません。
IAM の設計
Bedrock がジョブ実行時に Assume するサービスロールが必要です。
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "bedrock.amazonaws.com"},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {"aws:SourceAccount": "<account-id>"},
"ArnLike": {"aws:SourceArn": "arn:aws:bedrock:us-east-1:<account-id>:model-invocation-job/*"}
}
}]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/input/*"]
},
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": ["arn:aws:s3:::my-bucket/output/*"]
}
]
}
Condition を付けないと Confused Deputy 問題が発生します。他のアカウントの Batch ジョブがこのロールを Assume できてしまうため、必ず SourceAccount + SourceArn で絞りましょう。
ジョブ投入〜結果取得
投入
import boto3
session = boto3.Session(profile_name="your-profile", region_name="us-east-1")
bedrock = session.client("bedrock")
response = bedrock.create_model_invocation_job(
jobName="batch-summarize-20260826",
modelId="amazon.nova-lite-v1:0",
roleArn="arn:aws:iam::<account-id>:role/BedrockBatchInferenceRole",
modelInvocationType="Converse",
inputDataConfig={
"s3InputDataConfig": {
"s3Uri": "s3://my-bucket/input/batch-input.jsonl"
}
},
outputDataConfig={
"s3OutputDataConfig": {
"s3Uri": "s3://my-bucket/output/"
}
},
timeoutDurationInHours=24,
)
job_arn = response["jobArn"]
print(f"Job ARN: {job_arn}")
ステータス確認(ポーリング)
import time
TERMINAL = {"Completed", "PartiallyCompleted", "Failed", "Stopped", "Expired"}
while True:
resp = bedrock.get_model_invocation_job(jobIdentifier=job_arn)
status = resp["status"]
print(f"Status: {status}")
if status in TERMINAL:
break
time.sleep(30)
# 完了時の情報
print(f"投入: {resp['submitTime']}")
print(f"完了: {resp['endTime']}")
ジョブのステータスは Submitted / Validating / Scheduled / InProgress / Completed / PartiallyCompleted / Failed / Stopping / Stopped / Expired の10種類あります。Completed と Failed だけでループを抜けると、タイムアウト時の Expired や大量件数時の PartiallyCompleted で永久ループします。
結果の取得と突き合わせ
import json
s3 = session.client("s3")
# 出力 JSONL をダウンロード
s3.download_file("my-bucket", "output/<job-id>/batch-input.jsonl.out", "output.jsonl")
# recordId で突き合わせ
results = {}
with open("output.jsonl") as f:
for line in f:
record = json.loads(line)
record_id = record["recordId"]
if "error" in record:
results[record_id] = {"status": "error", "detail": record["error"]}
else:
# Converse 形式の出力から要約テキストを抽出
content = record["modelOutput"]["output"]["message"]["content"]
results[record_id] = {"status": "success", "text": content[0]["text"]}
print(f"成功: {sum(1 for v in results.values() if v['status'] == 'success')}")
print(f"失敗: {sum(1 for v in results.values() if v['status'] == 'error')}")
実行結果
manifest.json.out
{
"totalRecordCount": 1005,
"processedRecordCount": 1005,
"successRecordCount": 1004,
"errorRecordCount": 1,
"inputTokenCount": 635517,
"outputTokenCount": 90029
}
実行時間
| 指標 | 値 |
|---|---|
| 投入時刻 | 2026-08-24 00:50:07 UTC |
| 完了時刻 | 2026-08-24 01:02:01 UTC |
| 所要時間 | 11分54秒 |
1005件を約12分。1件あたり約0.7秒。On-Demand で並行度を制御しながら叩くより速い場合もある。
要約サンプル
入力(議事録の一部)
【営業部】プロジェクト進捗会議 - 次期基幹システム刷新 ...(約1300文字)
出力
- 会議の目的:次期基幹システム刷新の進捗確認とスケジュール調整
- 主な決定事項:スケジュールを8日延長、外部ベンダーに負荷テストを依頼
- 次のアクション:予算の再配分、UI改善スプリント追加、専任メンバーの追加アサイン検討
しっかり3行でまとめてくれています。
コスト比較(実測)
| 項目 | On-Demand | Batch | 差額 |
|---|---|---|---|
| 入力 (635K tokens) | $0.0381 | $0.0191 | $0.0191 |
| 出力 (90K tokens) | $0.0216 | $0.0108 | $0.0108 |
| 合計 | $0.0597 | $0.0299 | $0.0299 |
| 割引率 | 50.0% |
正直な話:Nova Lite ではコスト差は誤差
4円 vs 8円。差額4円。Nova Lite は単価が安すぎて、100万件回しても月 $30 の差にしかなりません。
Nova Lite で Batch を選ぶ理由はコストではなく「運用の単純化」です。
On-Demand で1000件を処理しようとすると以下を考慮する必要があります。
- API レート制限(TPM / RPM)に引っかからないよう並行数を制御する
- 429 エラー時のリトライ・エクスポネンシャルバックオフを実装する
- 途中で失敗した場合の再開ロジックを書く
- 1000件の非同期処理をオーケストレーションする
Batch なら JSONL を1本 S3 に置いて API を1回叩くだけ。リトライもレート制限も並行制御も全部 Bedrock がやってくれます。
上位モデルならコスト差が本格化する
Nova Lite ではコスト差が誤差ですが、上位モデルでは話が変わります。
| モデル | On-Demand Input | On-Demand Output | Batch 1005件コスト | On-Demand との月間差額(100万件) |
|---|---|---|---|---|
| Nova Lite | $0.06/1M | $0.24/1M | $0.03 | $30/月 |
| Claude Sonnet 4.6 | $3.00/1M | $15.00/1M | $1.63 | 約 $1,620/月 |
| Claude Opus 4.8 | $5.00/1M | $25.00/1M | $2.71 | 約 $2,700/月 |
※ Batch 単価 = On-Demand の 50%。1005件の Batch コストは(635K input tokens × Batch input 単価)+(90K output tokens × Batch output 単価)で算出。
料金は 2026年8月時点の値です。
最新の料金は 公式ページ で確認してください。
判断基準:
- 安いモデル(Nova Lite/Micro) → コスト理由ではなく「運用単純化」で Batch を選ぶ
- 高いモデル(Claude Sonnet/Opus) → コスト理由だけでも Batch を選ぶ価値がある(月100万件で月 \$1,600〜\$2,700 の差)
エラーハンドリング
意図的に5種類のエラーレコードを仕込みました。
| recordId | 内容 | 予想 | 実際 |
|---|---|---|---|
| ERR-0001 | 空テキスト | 400エラー | ✅ 正常応答 |
| ERR-0002 | 50万文字の繰り返し | 400エラー | ❌ 400 (Input Tokens Exceeded) |
| ERR-0003 | NULLバイト含む | 400エラー | ✅ 正常応答 |
| ERR-0004 | 「要約して」のみ | 正常 | ✅ 正常応答 |
| ERR-0005 | 数値のみ | 正常 | ✅ 正常応答 |
発見: Nova Lite は空テキストや NULLバイトでもエラーにならず、ちゃんと応答を返します。エラーになるのは 物理的にトークン数を超えたときだけ です。
つまり、入力バリデーションは 「コンテキスト長を超えないか」の1点チェックだけで十分です。
部分失敗時の挙動
- 一部レコードが失敗してもジョブ全体は Completed になる(Failed にはならない)
- 時間内に全レコードを処理しきれなかった場合は PartiallyCompleted になる
- 失敗レコードは出力 JSONL に
"error": {"errorCode": 400, "errorMessage": "..."}として記録される - manifest.json.out の
errorRecordCountで一発確認
ハマりポイント 6 選
1. timeoutDurationInHours の最小値は24時間
ParamValidationError: Invalid value for parameter timeoutDurationInHours,
value: 2, valid min value: 24
「1000件なら2時間で十分でしょ」→ 即死。実際は12分で終わりましたが、枠として最低24時間指定が必要です。
2. 最小レコード数は100件(us-east-1 / 2026年8月時点)
Batch ジョブには最小100レコードの制約があります(Service Quotas で確認)。99件以下だと ValidationException で弾かれます。
「最小24時間」+「最小100件」の制約から、Batch は小口処理に向きません。100件未満なら素直に On-Demand を使いましょう。なお、この値はモデル・リージョンごとに設定されており変更される可能性があるため、利用前に Service Quotas コンソールで確認してください。
3. 東京リージョン(ap-northeast-1)では直接使えない
Nova Lite の Batch は東京で Single-region model support が「N/A」です。Cross-region inference profile(apac.amazon.nova-lite-v1:0)経由での動作は未検証。確実に動かすなら us-east-1 を使ってください。
なお、Cross-region inference を使う場合、エンドポイント種別によっては 10% 程度の割増が適用される可能性があります(Batch との併用可否・追加コストは公式料金ページで確認してください)。
4. 出力の順序は入力と一致しない
並列処理のため順序保証なし。recordId を必ず付けて、結果と入力を紐付けましょう。
5. Converse 形式の system プロンプトの位置
system フィールドは messages の外(modelInput 直下)に配置します。InvokeModel 形式とは構造が異なるので注意。
{
"modelInput": {
"system": [{"text": "..."}],
"messages": [...]
}
}
6. Python 3.9 では str | None が使えない
型ヒントの str | None は Python 3.10+ の構文です。EC2 の Amazon Linux 2023 は Python 3.9 なので Optional[str] を使うか省略しましょう。
Batch Inference が有効なユースケース
| ユースケース | Batch向き | 理由 |
|---|---|---|
| リアルタイムチャット | ❌ | 即時応答必須 |
| 日次バッチ要約 | ✅ | 翌朝までに完了すればOK |
| 月次レポート生成 | ✅ | 大量×レイテンシ不問 |
| モデル評価(ベンチマーク) | ✅ | 大量プロンプトの一括処理 |
| ドキュメント一括分類 | ✅ | コスト重視 |
| A/B テスト用データ生成 | ✅ | 同一プロンプトを複数モデルで |
まとめ
- 50% OFF は本物。実測で確認した
- Nova Lite ではどの規模でもコスト差は誤差。安いモデルではコスト理由で Batch を選ぶな
- 高いモデル(Claude Sonnet/Opus)ではコスト理由だけでも選ぶ価値がある(月100万件で月 $1,600〜$2,700 の差)
- 真の価値は「運用の単純化」。レート制限・リトライ・並行制御が全部不要になる
- 入力バリデーションは「コンテキスト長超過チェック」だけで十分
- 「夜間バッチで翌朝レポート」が最も自然なユースケース
- 1005件が12分で完了。最小100件の制約があるため小口には向かないが、100件以上のバッチ処理なら検討の価値あり