Messages Batches API の制約と適用条件
導入前に制約を把握しておく点が肝心です。闇雲に使用しても効果がなく、場合によっては逆効果になることもあります。
主な制約事項
リクエスト数とサイズの上限
1 バッチにつき 100,000 リクエスト、または 256 MB のいずれか先に到達した方まで
サイズ超過時は 413 request_too_large
同時処理中バッチ数の上限は、公開ドキュメントでは数値が示されていません(以前この節には「通常 20 バッチ」と書いておりましたが、根拠を確認できなかったため取り下げます)
処理時間と保持期間
24 時間で処理が終わらないリクエストは expired へ
結果を取り出せる期間はバッチの作成から 29 日 (後述します)
リアルタイム応答の用途には不向き
利用可能モデル
「アクティブなモデルはすべて Messages Batches API に対応」と明記されています。モデルごとの可否を心配する必要はありません
退役したモデル ID を書いたコードだけが残る、という形の事故のほうが起こりやすいので、モデル ID はひとつの定数にまとめておくと後が楽です
使えないパラメータは 3 つだけ
stream: true(結果は 1 つのファイルで返るため)
speed(Fast mode は同期のレイテンシを調整する機能のため)
max_tokens: 0(キャッシュの事前温めは、バッチの処理中に 5 分の有効期限が切れてしまうため)
いずれかを含めると検証エラーになります。逆に言えば、Vision・ツール使用(サーバーツールを含む)・システムメッセージ・複数ターン・拡張思考、そしてほとんどのベータ機能はそのまま通ります。「β機能との組み合わせ制限」を漠然と警戒していた以前の記述は、実態より広く見積もっておりました。
バッチ処理が向いているユースケース
向いているケース:
大量ドキュメントの要約・分類 : 数百〜数万件のレポートや記事の一括処理
データ拡充 : ECサイトの商品説明生成、カテゴリ付与
コンテンツモデレーション : UGCのバッチスクリーニング
翻訳パイプライン : 多言語コンテンツの一括翻訳
定期レポート生成 : 夜間バッチでのBI・アナリティクスレポート作成
向いていないケース:
チャットボット(リアルタイム応答が必要)
ユーザーが結果を待っているインタラクティブなアプリケーション
処理結果をもとに次のリクエストを決定するシーケンシャルな処理
実装ステップ1:バッチの作成
Python SDK を使った基本実装
まず Anthropic Python SDK(anthropic パッケージ)での最小構成です。custom_id と params を持つ Request を並べて渡すのが、公式ドキュメントに載っている形です。
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
client = anthropic.Anthropic( api_key = "YOUR_API_KEY" )
documents = [
{ "id" : "doc_001" , "content" : "AIの倫理に関する最新の議論について述べる文書..." },
{ "id" : "doc_002" , "content" : "機械学習の基礎と応用に関する技術論文..." },
{ "id" : "doc_003" , "content" : "スタートアップの資金調達戦略についての解説..." },
]
MODEL = "claude-sonnet-4-6" # モデル ID は 1 か所にまとめる
batch_requests = [
Request(
# custom_id: 結果と元データを紐付けるための ID(必須)
# 1〜64 文字の英数字・ハイフン・アンダースコアのみ
custom_id = doc[ "id" ],
params = MessageCreateParamsNonStreaming(
model = MODEL ,
max_tokens = 500 , # バッチでは 1 以上が必須(0 は使えない)
messages = [
{
"role" : "user" ,
"content" : f "以下の文書を3行で要約してください。 \n\n{ doc[ 'content' ] } " ,
}
],
),
)
for doc in documents
]
batch = client.messages.batches.create( requests = batch_requests)
print ( f "バッチID: { batch.id } " )
print ( f "ステータス: { batch.processing_status } " )
print ( f "処理待ち: { batch.request_counts.processing } " )
# 出力例:
# バッチID: msgbatch_01HxxxxxxxxxxxxxxxxxxXXXX
# ステータス: in_progress
# 処理待ち: 3
呼び出し面は client.messages.batches です。client.beta.messages.batches を使うのは、output-300k-2026-03-24 のようなベータヘッダを付けて max_tokens の上限を上げるときだけで、通常のバッチには要りません。公開当初の本稿はここを beta 面で書いており、SDK の更新に合わせて直しました。
このコードで最も大事なのは custom_id です。公式ドキュメントには「バッチの結果は投入順と一致せず、任意の順で返ることがある」とはっきり書かれています。結果と元データの対応づけは、配列の添字ではなく必ず custom_id で行ってください。データベースのレコード ID や処理対象ファイル名など、元データに紐付く値を入れておくと後の突合が楽になります。
実装ステップ2:ポーリングとステータス管理
バッチを送信したら、処理完了を待つためのポーリング実装が必要です。ここが実務での品質を左右する重要なポイントです。
基本的なポーリング実装
import time
import anthropic
def wait_for_batch_completion (
client: anthropic.Anthropic,
batch_id: str ,
poll_interval_seconds: int = 60 ,
max_wait_minutes: int = 120 ,
) -> anthropic.types.messages.MessageBatch:
"""
バッチ処理の完了を待機する関数。
Args:
client: Anthropic クライアント
batch_id: 待機するバッチのID
poll_interval_seconds: ポーリング間隔(秒)
max_wait_minutes: 最大待機時間(分)
Returns:
完了したバッチオブジェクト
Raises:
TimeoutError: 最大待機時間を超過した場合
RuntimeError: バッチが失敗した場合
"""
max_attempts = (max_wait_minutes * 60 ) // poll_interval_seconds
for attempt in range (max_attempts):
batch = client.messages.batches.retrieve(batch_id)
status = batch.processing_status
if status == "ended" :
# 処理完了(成功・失敗含む)
print ( f "バッチ処理完了: { batch_id } " )
print ( f " 成功: { batch.request_counts.succeeded } " )
print ( f " 失敗: { batch.request_counts.errored } " )
print ( f " 期限切れ: { batch.request_counts.expired } " )
return batch
elif status == "canceling" :
raise RuntimeError ( f "バッチがキャンセルされています: { batch_id } " )
else :
# in_progress の場合は継続待機
processed = batch.request_counts.processing
total = (
batch.request_counts.processing +
batch.request_counts.succeeded +
batch.request_counts.errored
)
print (
f "[ { attempt + 1 } / { max_attempts } ] "
f "処理中... ( { processed } / { total } 件残り)"
)
time.sleep(poll_interval_seconds)
raise TimeoutError (
f "バッチ処理が { max_wait_minutes } 分以内に完了しませんでした: { batch_id } "
)
エクスポネンシャルバックオフの導入
大量のバッチを処理する本番システムでは、定間隔のポーリングではなく指数バックオフ を採用することを強く推奨します。
import random
def wait_for_batch_with_backoff (
client: anthropic.Anthropic,
batch_id: str ,
initial_interval: float = 30.0 ,
max_interval: float = 300.0 , # 最大5分
multiplier: float = 1.5 ,
jitter: bool = True ,
) -> anthropic.types.beta.BetaMessageBatch:
"""
指数バックオフによるポーリング実装。
APIレート制限への配慮とリソース効率化に有効。
"""
interval = initial_interval
while True :
batch = client.messages.batches.retrieve(batch_id)
if batch.processing_status == "ended" :
return batch
# ジッターを加えてサンダリングハード問題を防ぐ
wait_time = interval
if jitter:
wait_time = interval * ( 0.5 + random.random())
print ( f "次のポーリングまで { wait_time :.1f } 秒待機..." )
time.sleep(wait_time)
# 次回の待機時間を計算(上限を設ける)
interval = min (interval * multiplier, max_interval)
実装ステップ3:結果の取得と処理
バッチ処理が完了したら、結果を取得して元データと紐付ける処理が必要です。
結果の取得とパース
def process_batch_results (
client: anthropic.Anthropic,
batch_id: str ,
original_documents: dict[ str , dict ],
) -> list[ dict ]:
"""
バッチ処理結果を取得し、元データと紐付けて返す。
Args:
client: Anthropic クライアント
batch_id: 処理済みバッチのID
original_documents: custom_id -> ドキュメントデータ の辞書
Returns:
処理結果のリスト(成功・失敗情報を含む)
"""
results = []
# 結果をストリーミングで取得(大量データでもメモリ効率よく処理)
for result in client.messages.batches.results(batch_id):
custom_id = result.custom_id
original_doc = original_documents.get(custom_id, {})
if result.result.type == "succeeded" :
# 成功したリクエスト
message = result.result.message
content_text = ""
for block in message.content:
if block.type == "text" :
content_text = block.text
break
results.append({
"id" : custom_id,
"status" : "success" ,
"original" : original_doc,
"summary" : content_text,
"input_tokens" : message.usage.input_tokens,
"output_tokens" : message.usage.output_tokens,
})
elif result.result.type == "errored" :
# エラーが発生したリクエスト(課金されません)
# Python SDK では error はエラーレスポンスなので、
# 実際の種別は error.error.type にあります(.error が二段)
err = result.result.error.error
results.append({
"id" : custom_id,
"status" : "error" ,
"original" : original_doc,
"error_type" : err.type, # 例: invalid_request_error
"error_message" : err.message,
})
elif result.result.type == "expired" :
# 24 時間の処理枠に入らなかったリクエスト(課金されません)
results.append({
"id" : custom_id,
"status" : "expired" ,
"original" : original_doc,
})
elif result.result.type == "canceled" :
# キャンセルしたバッチには部分結果が残るため、
# canceled を取りこぼすと「消えたリクエスト」になります
results.append({
"id" : custom_id,
"status" : "canceled" ,
"original" : original_doc,
})
return results
# 実際の使用例
documents_map = {doc[ "id" ]: doc for doc in documents}
completed_batch = wait_for_batch_completion(client, batch.id)
results = process_batch_results(client, batch.id, documents_map)
# 結果をJSONファイルに保存
with open ( "batch_results.json" , "w" , encoding = "utf-8" ) as f:
json.dump(results, f, ensure_ascii = False , indent = 2 )
# 統計レポート
success_count = sum ( 1 for r in results if r[ "status" ] == "success" )
error_count = sum ( 1 for r in results if r[ "status" ] == "error" )
print ( f "処理完了: 成功 { success_count } 件 / 失敗 { error_count } 件" )
実装ステップ4:本番対応のフルパイプライン
小規模なスクリプトと本番システムの最大の違いは、エラー回復・ログ管理・スケーラビリティへの対応 です。ここでは実務で使えるフルパイプラインを構築します。
バッチジョブマネージャークラス
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable
import anthropic
logger = logging.getLogger( __name__ )
@dataclass
class BatchJob :
"""バッチジョブの管理クラス"""
job_id: str
batch_id: str | None = None
status: str = "pending" # pending / submitted / completed / failed
submitted_at: datetime | None = None
completed_at: datetime | None = None
request_count: int = 0
success_count: int = 0
error_count: int = 0
metadata: dict = field( default_factory = dict )
class BatchJobManager :
"""
本番環境向けバッチジョブ管理クラス。
特徴:
- ジョブの永続化(DB/ファイル対応可能)
- 自動リトライ(失敗リクエストの再送)
- 進捗トラッキング
- コスト計算
"""
def __init__ (
self,
client: anthropic.Anthropic,
max_requests_per_batch: int = 5000 , # 上限は 100,000 だが 256 MB 側で先に詰まる
):
self .client = client
self .max_requests_per_batch = max_requests_per_batch
self .jobs: dict[ str , BatchJob] = {}
def create_batches_from_requests (
self,
all_requests: list[ dict ],
job_id_prefix: str = "job" ,
) -> list[BatchJob]:
"""
大量のリクエストを複数バッチに分割して送信。
件数の上限は 100,000 だが、実務で先に当たるのは 256 MB のサイズ上限。
長いシステムプロンプトを全リクエストに複製すると、
数千件でサイズ側に触れることがあるため保守的に分割する。
"""
jobs = []
# リクエストをバッチサイズで分割
for i in range ( 0 , len (all_requests), self .max_requests_per_batch):
chunk = all_requests[i:i + self .max_requests_per_batch]
job_id = f " { job_id_prefix } _ { i // self .max_requests_per_batch :04d } "
try :
logger.info( f "バッチ送信中: { job_id } ( { len (chunk) } 件)" )
batch = self .client.messages.batches.create(
requests = chunk
)
job = BatchJob(
job_id = job_id,
batch_id = batch.id,
status = "submitted" ,
submitted_at = datetime.now(),
request_count = len (chunk),
)
self .jobs[job_id] = job
jobs.append(job)
logger.info( f "バッチ作成成功: { batch.id } " )
except anthropic.APIError as e:
logger.error( f "バッチ作成失敗: { job_id } - { e } " )
job = BatchJob(
job_id = job_id,
status = "failed" ,
metadata = { "error" : str (e)},
)
self .jobs[job_id] = job
jobs.append(job)
return jobs
def calculate_cost_estimate (
self,
results: list[ dict ],
model: str = "claude-sonnet-4-6" ,
) -> dict :
"""
処理結果からコストを計算する。
Batches API の50%割引を適用。
"""
# モデルごとの料金設定($/MTok・いずれも Batches 割引後 = 公開価格の半額)
# 退役したモデルの価格を残すと、移行後に実費と合わなくなる
pricing = {
"claude-sonnet-4-6" : { "input" : 1.50 , "output" : 7.50 },
"claude-sonnet-5" : { "input" : 1.00 , "output" : 5.00 },
"claude-haiku-4-5-20251001" : { "input" : 0.50 , "output" : 2.50 },
"claude-opus-5" : { "input" : 2.50 , "output" : 12.50 },
}
model_pricing = pricing.get(model, pricing[ "claude-sonnet-4-6" ])
total_input_tokens = sum (
r.get( "input_tokens" , 0 )
for r in results
if r[ "status" ] == "success"
)
total_output_tokens = sum (
r.get( "output_tokens" , 0 )
for r in results
if r[ "status" ] == "success"
)
input_cost = (total_input_tokens / 1_000_000 ) * model_pricing[ "input" ]
output_cost = (total_output_tokens / 1_000_000 ) * model_pricing[ "output" ]
return {
"total_input_tokens" : total_input_tokens,
"total_output_tokens" : total_output_tokens,
"input_cost_usd" : round (input_cost, 4 ),
"output_cost_usd" : round (output_cost, 4 ),
"total_cost_usd" : round (input_cost + output_cost, 4 ),
"model" : model,
}
本番ユースケース詳解
ユースケース1:EC サイトの商品説明一括生成
ECサイト運営者が数万件の商品データから自動で魅力的な説明文を生成するシナリオです。
def create_product_description_requests (products: list[ dict ]) -> list[ dict ]:
"""商品データからバッチリクエストを生成"""
requests = []
for product in products:
prompt = f """以下の商品情報から、SEOに最適化された魅力的な商品説明文を生成してください。
商品名: { product[ 'name' ] }
カテゴリ: { product[ 'category' ] }
スペック: { json.dumps(product[ 'specs' ], ensure_ascii = False ) }
価格: ¥ { product[ 'price' ] :, }
要件:
- 150〜200文字
- 特徴・メリットを自然な言葉で表現
- ターゲット顧客が共感できる表現
- 購買意欲を高めるCTA的な締め"""
requests.append({
"custom_id" : f "product_ { product[ 'id' ] } " ,
"params" : {
"model" : "claude-haiku-4-5-20251001" , # 大量処理にはHaikuが経済的
"max_tokens" : 300 ,
"messages" : [{ "role" : "user" , "content" : prompt}]
}
})
return requests
このユースケースでは claude-haiku-4-5-20251001 を使うことで、さらにコストを抑えつつ十分な品質の説明文が生成できます。Batches API の50%割引と組み合わせると、通常のsonnet同期APIと比較して最大88%のコスト削減 も実現可能です。
ユースケース2:コンテンツのセンチメント分析・分類
大量のカスタマーレビューや SNS投稿を一括で感情分析するパイプラインです。
def create_sentiment_analysis_requests (reviews: list[ dict ]) -> list[ dict ]:
"""レビューデータからセンチメント分析リクエストを生成"""
ANALYSIS_SCHEMA = """
{
"sentiment": "positive | neutral | negative",
"score": 1-5の整数,
"categories": ["品質", "価格", "配送", "サポート"] の中から該当するもの,
"summary": "20文字以内の要約",
"requires_action": true | false // 返金・対応が必要な場合はtrue
}"""
requests = []
for review in reviews:
requests.append({
"custom_id" : f "review_ { review[ 'id' ] } " ,
"params" : {
"model" : "claude-haiku-4-5-20251001" ,
"max_tokens" : 150 ,
"messages" : [
{
"role" : "user" ,
"content" : f """以下のレビューを分析し、JSON形式で回答してください。
レビュー: { review[ 'text' ] }
出力形式:
{ ANALYSIS_SCHEMA }
JSONのみを出力し、説明は不要です。"""
}
]
}
})
return requests
ユースケース3:多言語コンテンツの並列翻訳
ブログ記事・ドキュメント・マーケティングコピーの多言語展開を一括で行います。
TARGET_LANGUAGES = [
( "en" , "English" ),
( "zh" , "Simplified Chinese" ),
( "ko" , "Korean" ),
( "es" , "Spanish" ),
]
def create_translation_requests (articles: list[ dict ]) -> list[ dict ]:
"""記事を複数言語に翻訳するリクエストを生成"""
requests = []
for article in articles:
for lang_code, lang_name in TARGET_LANGUAGES :
requests.append({
"custom_id" : f "article_ { article[ 'id' ] } _ { lang_code } " ,
"params" : {
"model" : "claude-sonnet-4-6" , # 翻訳品質のためsonnetを使用
"max_tokens" : len (article[ 'content' ]) * 2 , # 余裕を持たせる
"messages" : [
{
"role" : "user" ,
"content" : f """Translate the following Japanese article to { lang_name } .
Maintain the original tone, formatting, and technical accuracy.
Return only the translated text.
---
{ article[ 'content' ] } """
}
]
}
})
return requests
4言語×100記事 = 400リクエストを一括送信することで、通常の同期処理では1〜2時間かかる作業が1回の APIコールで非同期に処理 されます。
エラーハンドリングと失敗への対応
エラーの種類と対処法
Batches API のエラーは主に3種類あります。
1. overloaded_error(過負荷エラー)
Anthropic のサーバーが高負荷の際に発生。対処法は時間をおいて再送信するか、バッチサイズを分割することです。
2. invalid_request_error(不正リクエスト)
リクエストのフォーマットが誤っている場合。custom_id の重複、パラメータの型エラーなどが原因になります。バリデーションを事前に実施することで予防できます。
3. expired(期限切れ)
24 時間の処理枠に入らなかったリクエストです。需要と自分の投入量によって処理が遅くなると増えると公式にも書かれています。自動で再送する仕組みを組み込んでおくとよいでしょう。
ここで安心材料が一つあります。errored・canceled・expired の 3 つは、いずれも課金されません (公式ドキュメントに明記されています)。リトライ設計を考えるとき、私は最初「失敗した分も二重に払うのか」と身構えておりましたが、払うのは成功した分だけです。もう一つ、バッチ内の 1 件が失敗しても他のリクエストの処理は止まりません。
失敗リクエストの自動再送信
def retry_failed_requests (
client: anthropic.Anthropic,
original_requests: list[ dict ],
results: list[ dict ],
documents_map: dict[ str , dict ],
max_retries: int = 3 ,
) -> list[ dict ]:
"""
失敗・期限切れのリクエストを再送信する。
リトライ回数の上限を設けてループを防ぐ。
"""
# 失敗・期限切れのIDを取得
failed_ids = {
r[ "id" ] for r in results
if r[ "status" ] in ( "error" , "expired" )
}
if not failed_ids:
logger.info( "再送信が必要なリクエストはありません" )
return results
# 元のリクエストから失敗分を抽出
retry_requests = [
req for req in original_requests
if req[ "custom_id" ] in failed_ids
]
logger.warning(
f " { len (retry_requests) } 件のリクエストを再送信します "
f "(試行回数: { max_retries } )"
)
# 再送信(上限回数まで)
all_results = [r for r in results if r[ "status" ] == "success" ]
for attempt in range (max_retries):
if not retry_requests:
break
batch = client.messages.batches.create( requests = retry_requests)
wait_for_batch_completion(client, batch.id)
# 元データの辞書を渡さないと original が空になり、
# 再送で成功した分だけ突合できない結果が混ざります
retry_results = process_batch_results(client, batch.id, documents_map)
# 成功したものを結果に追加
success_results = [r for r in retry_results if r[ "status" ] == "success" ]
all_results.extend(success_results)
# まだ失敗しているリクエストを次の試行対象に
still_failed_ids = {
r[ "id" ] for r in retry_results
if r[ "status" ] in ( "error" , "expired" )
}
retry_requests = [
req for req in retry_requests
if req[ "custom_id" ] in still_failed_ids
]
logger.info( f "リトライ { attempt + 1 } / { max_retries } : 残り { len (retry_requests) } 件" )
# 最終的に失敗したリクエストも結果に含める
if retry_requests:
logger.error( f " { len (retry_requests) } 件が最終的に失敗しました" )
all_results.extend([
{ "id" : req[ "custom_id" ], "status" : "permanent_failure" }
for req in retry_requests
])
return all_results
コスト最適化のベストプラクティス
モデル選択の戦略
Batches API を最大限活用するには、タスクの複雑さに応じたモデル選択が重要です。
大量処理かつシンプルなタスク(分類・ラベリング・短い要約)には claude-haiku-4-5-20251001 を使います。Batches 割引後は入力 $0.50 / 出力 $2.50 per MTok で、数万件を流しても請求書が静かなままです。
中程度の複雑さのタスク(詳細な要約・構造化データ抽出・翻訳)には claude-sonnet-5 か claude-sonnet-4-6 を割り当てます。Sonnet 5 のほうが単価は低い(Batches で入力 $1 / 出力 $5)ものの、ここに一つ落とし穴があります。Claude 4.7 以降の世代は新しいトークナイザを使っており、同じ文章で約 30% 多くトークンが出ます。 単価が 3 分の 1 下がっても、同じ原稿の請求はその幅ほど下がりません。移行の判断は単価表ではなく、自分の usage を突き合わせてからにしています。
高度な推論・分析が必要なタスク(複雑な文書分析・コード生成・専門的な判断)には claude-opus-5 を使います。Batches 割引後で入力 $2.50 / 出力 $12.50 per MTok です。なお退役したモデル ID をコードに残したままにすると、ある日から 404 が返るようになります。私は上のコード例のように、モデル ID をモジュール先頭の定数に集めておく形に変えました。
システムプロンプトのキャッシュ活用
同じシステムプロンプトを使うバッチリクエストでは、プロンプトキャッシュ(cache_control)と Batches API を組み合わせられます。両方の割引は積み上がります。ただしここは、当初の記述がいちばん楽観的でした。
# プロンプトキャッシュ + Batches API の組み合わせ
request_with_cache = {
"custom_id" : "doc_001" ,
"params" : {
"model" : "claude-sonnet-4-6" ,
"max_tokens" : 500 ,
"system" : [
{
"type" : "text" ,
"text" : "あなたは文書分析の専門家です。以下のルールに従って...(長い共通指示)" ,
# 既定の ephemeral は有効期限 5 分。バッチは 5 分を超えて
# 処理されることが普通なので、1 時間の TTL を明示する
"cache_control" : { "type" : "ephemeral" , "ttl" : "1h" }
}
],
"messages" : [
{ "role" : "user" , "content" : "文書の内容..." }
]
}
}
ここで数字を丁寧に見ておきます。キャッシュヒットは基本入力単価の 0.1 倍、5 分の書き込みは 1.25 倍、1 時間の書き込みは 2 倍です。つまり「入力コストが最大 90% 削減」という言い方はキャッシュから読めた部分に限った話 で、書き込みの割増を引くと 5 分なら 1 回読めば元が取れ、1 時間なら 2 回読んでようやく元が取れます。さらにバッチは並列・非同期で処理されるため、ヒットはベストエフォートです。公式は利用者の実測として 30〜98% という幅を示しています。
そして最大の制約は、キャッシュが効くのが入力の共通部分だけ という点です。出力が主コストのパイプラインでは、キャッシュで動く金額はそもそも小さいのです。私のパイプラインで実際に効いた内訳は後の節に置きましたが、結論を先に書いておきます。割引は入力の一部にしか効きません。請求書の形を決めるのは、いつでも出力トークンです。
詳しいプロンプトキャッシュの実装方法は、Claude API コスト最適化パターン の記事で解説していますので合わせてご覧ください。
公式ドキュメントには書かれていない Batches API の運用知見
API リファレンスは制約と型をきれいに書いてくれますが、本番で 1 か月以上動かしていると、そこには書かれていない「現場の癖」が少しずつ見えてきます。手元のパイプラインで気づいたものを共有します。
processing_status の遷移はステップ単位で滑らかではない
公式ドキュメントには in_progress → ended の単純な遷移として記載されていますが、実際には「内部的にバッチを複数シャードに分割して処理 → 一部のシャードだけが先に終わる」という挙動が見られます。request_counts.succeeded が 5,000 件中 4,800 件まで一気に進んだあと、残り 200 件が 40 分以上残り続ける、というケースが、翻訳工程のバッチでは月 2〜3 回観測されています。
対処としては、ポーリング側で「進捗率 95% 超えたら閾値時間を短縮 → タイムアウト超過時に再送」のロジックを必ず入れること。具体的には次のように二段階の閾値を持たせます。
def adaptive_polling (client, batch_id, base_interval = 60 , fast_interval = 15 ,
deadline_minutes = 180 ):
"""進捗 95% 超で短間隔に切り替える適応ポーリング"""
deadline = time.monotonic() + deadline_minutes * 60
while time.monotonic() < deadline:
batch = client.messages.batches.retrieve(batch_id)
if batch.processing_status == "ended" :
return batch
rc = batch.request_counts
total = rc.processing + rc.succeeded + rc.errored + rc.expired
done = rc.succeeded + rc.errored + rc.expired
progress = done / total if total else 0
# 95% 超えたら短間隔モード
interval = fast_interval if progress >= 0.95 else base_interval
time.sleep(interval)
# 24 時間枠の中でも、後続処理の締切は自分で持つ
raise TimeoutError ( f " { deadline_minutes } 分を超えても ended になりませんでした: { batch_id } " )
長時間滞留したリクエストは expired に落ちることがあるため、リトライキューに自動で逃がせる設計にしておくと事故が減ります。
custom_id は「業務 ID + バッチセッション ID」の複合キーが安全
custom_id は最大 64 文字の英数字・ハイフン・アンダースコアまで、と仕様に書かれていますが、もう一つ実装で大事な制約があります。同一バッチ内で重複してはならない 点です。これだけなら自明ですが、本番では「日次バッチが失敗 → 翌日同じ業務 ID でリトライ → 結果ファイルに同じ ID が混在」という事故が起こりがちです。
私は doc_<業務スラッグ>__<YYYYMMDD>__<シーケンス> の複合キーを採用しています。例: doc_catalog_item_42__20260521__001。これで「同じ業務 ID を別日でリトライしても、結果ストア上は別レコードとして残る」「監査ログから日付逆引きが可能」という 2 つを両立できます。
結果が消える 29 日は、完了時刻ではなく created_at から数える
公式ドキュメントには 1 行で書かれているだけですが、これが運用で最も事故を生む箇所です。そして本稿はここを長いあいだ書き間違えておりました。正しくは「結果はバッチの作成から 29 日間ダウンロードできる」であり、公式のトラブルシューティングにも「created_at(処理の ended_at ではない)から 29 日未満であることを確認」と明記されています。
この差は、実務では丸ごと 1 日ぶんの余裕として現れます。作成から 23 時間かけて処理が終わったバッチは、残りが 28 日と 1 時間しかありません。「完了から 29 日」と信じて月末にまとめて取りに行く設計だと、境界の数本だけが静かに取れなくなります。期限を過ぎたあともバッチ自体は参照できるので、processing_status は ended のまま、結果だけがダウンロードできない状態になります。ステータスを見て安心していると気づけません。
私の運用では「バッチ ended → 即時に R2 / KV へ結果を JSON 化して保存 → その日のうちに件数の整合性チェック」の 3 段階を必須にしています。保存の締切は created_at + 29 日ではなく、created_at の当日 に置いておくのが安全です。取得の起点は完了通知(cron の再確認でも十分です)に任せ、人間の介入待ちにしないことが大切です。
SDK の更新で、型の置き場所と名前が変わる
私が実際に踏んだのは、型の中身よりも型の住所 の変更でした。ベータ面で書いていたコードは anthropic.types.beta.BetaMessageBatch を import しており、通常面へ移したときにこれが存在しない名前になりました。processing_status は "in_progress" | "ended" | "canceling" のリテラル型なので、mypy を strict で回している場所では、SDK を上げた日にビルドだけが落ちます。
pyproject.toml の依存固定例:
[ project ]
dependencies = [
"anthropic>=0.40.0,<1.0.0" , # 型の置き場所が動くため minor もピン留め
]
SDK アップデートは月 1 回、dependabot の PR レビュー時に手動でマージしており、Batches 関連の型変更は CHANGELOG を必ず確認してから取り込むフローにしています。ベータヘッダ前提のコード(client.beta.messages.batches と anthropic.types.beta.*)を通常面のコードと混ぜて置かないことも、後から効きました。
投入前のプリフライト検証を必ず挟む
上の 3 つはいずれも「投入したあとで気づく」種類の事故です。投入前に機械的に弾けるものは弾いておくほうが、夜間バッチの安心感が違います。私が create() の直前に必ず通している検証は次の 4 つです。
import re
import json
MAX_REQUESTS_PER_BATCH = 100_000 # 公式上限
MAX_BATCH_BYTES = 256 * 1024 * 1024 # 256 MB。実務ではこちらに先に当たる
CUSTOM_ID_RE = re.compile( r " ^[ a-zA-Z0-9_- ] {1,64} $ " )
UNSUPPORTED_PARAMS = ( "stream" , "speed" ) # バッチでは検証エラーになる
def preflight (requests: list[ dict ], budget_usd: float | None = None ) -> None :
"""バッチ投入前の静的検証。1つでも落ちたら投入しない"""
if not requests:
raise ValueError ( "空のバッチは投入しない(空振りのポーリングが残る)" )
if len (requests) > MAX_REQUESTS_PER_BATCH :
raise ValueError ( f "1バッチ上限超過: { len (requests) } 件" )
size = len (json.dumps(requests, ensure_ascii = False ).encode( "utf-8" ))
if size > MAX_BATCH_BYTES :
raise ValueError ( f "サイズ上限超過: { size / 1024 / 1024 :.1f } MB(413 になる)" )
seen: set[ str ] = set ()
for r in requests:
cid = r[ "custom_id" ]
if not CUSTOM_ID_RE .match(cid):
raise ValueError ( f "custom_id が仕様外: { cid } " )
if cid in seen:
raise ValueError ( f "custom_id 重複: { cid } " )
seen.add(cid)
params = r[ "params" ]
for key in UNSUPPORTED_PARAMS :
if key in params:
raise ValueError ( f "バッチでは使えないパラメータ: { key } ( { cid } )" )
if params.get( "max_tokens" , 0 ) < 1 :
raise ValueError ( f "max_tokens は 1 以上が必須: { cid } " )
# max_tokens の積み上げから上限コストを概算し、想定外の膨張を止める
if budget_usd is not None :
est = sum (estimate_max_cost(r) for r in requests)
if est > budget_usd:
raise ValueError ( f "想定コスト超過: $ { est :.2f } > $ { budget_usd :.2f } " )
地味ですが、custom_id の重複を投入前に落とせるようになってから、結果ファイルの取り違えがゼロになりました。budget_usd の上限は、プロンプト生成側のバグで max_tokens が跳ね上がったときの保険です。一度だけ、テンプレートの変数展開を誤って本文全体を入力に二重で積んだことがあり、そのときはこの一行で止まりました。
バッチに載せた瞬間に落ちる、5 つの段差
同期 API で動いていたコードをそのまま params に移すと、次のどれかで止まります。どれも原因が「バッチだから」とは書かれていないエラーで返ってくるため、先に表で持っておくと調べる時間が減ります。
最後の 1 行が最もたちが悪いところです。エラーも警告も出ないまま、要約と原稿が 1 件ずつずれた状態で保存されます。私は結果の保存前に「custom_id の集合が投入時と一致するか」を必ず数えるようにしました。
50% 割引が効かない場所
割引の話は「どこに効くか」より「どこに効かないか」で覚えたほうが、見積りを外しません。
Fast mode : バッチと併用できません
Claude Managed Agents のセッション : 対話的で状態を持つため、Batch API の割引は適用されません
サーバーツールの従量課金 : 例えばウェブ検索の 1,000 回あたりの料金はトークンとは別建てです(バッチのワーカーは組織単位で検索をスロットリングし、絞られた分は自動でリトライされます)
逆に、1M トークンのコンテキストは標準価格のままで、バッチとキャッシュの割引はその全域に効きます。
3 か月分の請求書で見たコスト推移と、圧縮できた内訳
「最大 50% 削減」と表現される Batches API の効果が、実際の運用でどの程度になるか — 手元のパイプラインの形をそのまま置いて、現在の公開価格で積算し直した数字を共有します。工程・件数・平均トークン数は 2026 年 2 月から 4 月に動かしていた実物、単価は現行の公開価格です。公開当初はここに、掛け算の合わない数字と、退役したモデルの単価が混ざっておりました。
パイプラインの構成
処理単位 : 長文 1 件につき「長文生成 → 日英翻訳 → タグ要約 → 関連候補抽出」の 4 リクエスト
日次量 : 16 単位/日(4 つの出力先へ均等に配分)
総リクエスト : 64 リクエスト/日 × 30 日 = 約 1,920 リクエスト/月(バッチ移行前)
移行前後のコスト内訳
claude-sonnet-4-6 を主モデルとし、日本語生成のみ Opus 系を使っています。
Batches API への切り替えだけで、月 $45.05 の削減(−50.0% )です。ここまでは単価が半分になるだけなので、驚きはありません。
問題はこの先でした。4 系統で共通のシステムプロンプト(1,500 トークン前後)にプロンプトキャッシュを当てれば、入力側がさらに縮みます。1 時間 TTL で書き込み 2 倍・読み出し 0.1 倍、ヒット率を強気に 90% と置いて 4 工程ぶんを足し上げると、縮むのは月 $2.56 です。
最終的な月コストは $42.49 、同期 API 比で −52.8% になります。公開当初、私はここに「−69.6%」と書いておりました。キャッシュが入力の共通部分だけに効くことを忘れて、入力全体が 8 割引かれる前提で計算していたのです。このパイプラインは出力トークンが費用の 8 割を占めており、入力側をどれだけ削っても、下がる幅はこの程度にしかなりません。
割引の議論より先に、自分の請求の何割が出力トークンなのかの確認を。 遠回りに見えて、これがいちばん早い順序でした。
レイテンシ分布
ポーリング間隔を 60 秒で運用したときの、バッチ完了時間の実測値(バッチサイズ別・30 日間の集計)。
バッチサイズ 64 件 : p50 = 4 分 12 秒、p95 = 11 分 38 秒、p99 = 19 分 02 秒
バッチサイズ 320 件 : p50 = 8 分 47 秒、p95 = 22 分 14 秒、p99 = 38 分 51 秒
バッチサイズ 1,920 件 : p50 = 18 分 24 秒、p95 = 47 分 09 秒、p99 = 1 時間 21 分
バッチサイズが大きいほど 1 リクエストあたりのオーバーヘッドは下がりますが、p99 が伸びる傾向があります。私は「夜間に 1 日分まとめる」ではなく「6 時間ごとに 16 単位ずつ流す」運用に変えました。p99 に引っかかった 1 バッチが翌朝の後続処理を丸ごと止める、という詰まり方を避けるためです。
スケジューリングとの相性
Batches を組むと「投入時刻」と「結果取得時刻」がずれるため、後続タスクの依存関係を chained job として扱う必要があります。私はスケジュール実行を次のように分離しました。
02:00 — 長文生成バッチを投入(Webhook で完了通知 → 03:00 頃に結果取得+翻訳バッチ投入)
04:00 — 翻訳結果を取得+タグ要約バッチ投入
06:00 — 全結果を集約してストレージへ確定保存し、後続の配信処理へ引き渡し
「同期 API なら 1 つのタスクで完結する処理を 3 つに分割する」運用負担はありますが、コスト削減幅が大きく、夜間帯のレート制限にも引っかからないため総合的にはメリットの方が上回っています。
Batches に寄せるか、同期のまま残すか — 5 つの判断軸
Batches API を導入すべきかどうかは、コスト削減の大小だけでは決まりません。個人開発で API の請求書と向き合ってきた中で、実装前に必ず確認するようになった 5 つの判断軸があります。
判断軸 1: ユーザーが「待たされていることを知っている」か
同期 API は「ユーザーが待っている」処理に必須です。チャット応答・コード補完・検索結果の生成など、明示的に待たせると体験が破綻する処理は同期 API 一択です。一方で、夜間レポート・文章の一括生成・データ拡充など「ユーザーが結果を受け取るタイミングが朝起きた後でよい」処理は、Batches に寄せられます。
私自身がいちばん迷うのは、「準リアルタイム」と呼ばれる中間領域です。例えば「ユーザーが投稿したコメントを 1 分以内にモデレーション」のような処理。これは同期 API を使うのが一般的ですが、私は「初回投稿時のみ厳密なリアルタイムモデレーション、その後の編集は 15 分のバッチで再評価」というハイブリッド設計に落ち着きました。
判断軸 2: 1 回のリクエストが他のリクエストの結果に依存するか
リクエスト A の結果を踏まえて B を実行するシーケンシャル処理は、Batches に向きません。Batches は「同時に投入したリクエストはすべて独立に処理される」前提のため、A → B の依存があるなら 2 つのバッチに分けて 2 回ポーリングするか、同期 API でループする方が運用負荷が低いです。
判断軸 3: 月間リクエスト数が損益分岐を超えるか
私のラフな目安として、月 500 リクエスト未満は同期 API のほうがトータルで安価 です。Batches に移行すると、Webhook 受信用のエンドポイント・結果保存ストレージ・タイムアウト監視など、運用面の追加コストが発生します。これらの「人手の値段」を考えると、月数千件以下の処理では同期 API の方がシンプルです。
逆に月 5,000 件を超えるあたりから、Batches の経済的メリットが運用負荷を明確に上回り始めます。手元の 1,920 リクエスト/月はちょうど境界線の上ですが、処理対象を増やしたときに組み直したくなかったので Batches に統一しました。
判断軸 4: 同期 API のレート制限に張り付いていないか
Anthropic のレート制限は Tier ごとに RPM(リクエスト毎分)と TPM(トークン毎分)で定義されます。同期 API でレート制限に張り付いてしまうと、ユーザー向けのインタラクティブ処理まで巻き込まれてエラーが返るリスクがあります。Batches API は「優先度の低いキュー」で処理されるため、同期 API のレート制限とは別枠でリソースを使えます。
私はアプリのプッシュ通知のバルク生成で、同期 API でレート制限に張り付き、ユーザーがチャット応答を待たされるトラブルを経験しました。それ以来、「ユーザー応答に直結しない処理はすべて Batches に逃がす」というルールを徹底しています。
判断軸 5: 結果保存・整合性のコストを払えるか
Batches API の最大の運用上のリスクは「バッチの作成から 29 日で結果が取り出せなくなる」点です。これは同期 API にはない概念で、結果を確実に取得・保存・整合性チェックする責任が呼び出し側に発生します。
これを払えるチームと払えないチームがあります。1 人開発・少人数チームの場合、Webhook を受信して結果を保存するインフラを別途維持する余裕がないことが多いです。私は Cloudflare Workers + R2 で軽量に組みましたが、AWS や GCP で同じことをやろうとすると最低でも数時間の構築コストがかかります。
5 つの判断軸を Yes/No で評価して 3 つ以上当てはまるなら Batches、それ以下なら同期 API、というのが私の運用ルールです。
全体を振り返ってと、次に検討してほしいこと
Claude API Messages Batches API は、運用まで含めて設計できれば「コスト 50% 削減」を超えて、レート制限・スケジューリング・将来のスケール余地まで含めた効きかたをします。手元の運用では、プロンプトキャッシュとの併用まで含めて月コストが 52.8% 下がりました。50% は単価の値引きが素直に効いた分で、残りの 2.8 ポイントがキャッシュ分です。
この記事を書き直しながらいちばん強く感じたのは、間違いの多くが「当時は正しかったこと」の側にあった、ということでした。ベータだった呼び出し面、生きていたモデル ID、切りのいい削減率。どれも公開した日には合っていて、そのまま置いておいたぶんだけ、読んでくださる方に古い地図を渡していたことになります。
次に検討していただきたいことは 1 つだけです。いま動いている自分のバッチについて、created_at から数えた 29 日の期限と、結果を保存し終えた時刻を並べてみてください 。この 2 つの差が「安心して眠れる時間」で、私の場合はここが思っていたより短いことに気づけたのが、いちばんの収穫でした。
実装の足がかりには、本記事の BatchJobManager をそのまま写経して、まずポーリングと結果保存だけを動かしてみるのが早いはずです。同期 API の本番設計の話は Claude API プロダクション設計パターン で、ストリーミングとの使い分けは リアルタイムチャット実装 で深掘りしていますので、合わせて読んでいただけると Batches を組み込む際の全体像が見えるはずです。
最後までお付き合いいただきありがとうございました。単価表とにらみ合う夜が、少しだけ短くなれば嬉しいです。