4 つの AI 技術ブログを自動運用し始めてしばらく経った頃、ある月の API 請求書を見て少し驚きました。想定の倍近い金額になっていたのです。
原因はすぐに分かりました。リアルタイム API を使った同期処理を、深夜のバッチ処理にも使っていたことです。ユーザーが画面の前で待っているわけではない処理に、ユーザー向けのレイテンシ優先 API をそのまま充てていた。Anthropic が提供する Messages Batches API を使えば、同じ処理を半額以下でこなせることに気づいたのは、その翌月のことでした。
個人開発では、月に数万円の API コストが半分になるだけで、その分を新しい機能の開発や検証に回せます。ここでは Messages Batches API を個人開発に取り入れる際の設計パターン、コスト試算の方法、そして実際に運用して学んだ落とし穴を、動作するコードとともに共有します。
Messages Batches API が解決する問題
通常の Claude API(Messages API)は、リクエストを送ると即座にレスポンスが返ってくる同期型の設計です。ユーザーがチャット画面で待っているとき、アプリがリアルタイムに翻訳するとき——この同期設計は理にかなっています。
しかし、次のような処理を考えてみてください。
1,000 件の商品レビューを一括で感情分析する
毎朝、100 件のニュース記事を要約してデータベースに保存する
アプリのユーザー行動ログを深夜に分析して、翌朝のレポートを生成する
MDX ファイルの SEO メタデータを一括で見直す
これらはすべて、ユーザーが結果をリアルタイムで待っているわけではない 処理です。24 時間以内に結果が返ってくれば十分で、処理がどれだけ並行して走っても構いません。
Messages Batches API はこのユースケースのために設計されています。仕様を簡単にまとめると:
コスト : 通常の Messages API と比較して 最大50% オフ
処理時間 : 最大 24 時間(実際は数分〜数時間で完了することが多い)
バッチサイズ : 1 バッチあたり最大 10,000 リクエスト
制限 : ストリーミング不可、即時レスポンス不可
「50% オフ」という数字は公式ドキュメントにも書かれています。ただし、なぜ半額で提供できるのか は書かれていません。おそらく、リアルタイム要件がない分、Anthropic 側で処理を効率的にスケジューリングできるためだと思われます。需要の低い時間帯に処理を分散できる仕組みが、価格差として反映されているのでしょう。
リアルタイム API との使い分け基準
私が個人開発で使っている判断基準をそのまま共有します。
バッチ処理に向いているユースケース
以下をすべて満たす場合は Batch API が適しています。
ユーザーが結果をリアルタイムで待っていない(または非同期でも問題ない)
24 時間以内に処理が完了すれば十分
処理件数が 10 件以上(1 件あたりの設定コストが割に合う)
エラー時に全体をリトライする余裕がある
具体例:コンテンツの一括分析、SEO メタデータ生成、データパイプラインの AI ステージ、定期レポート生成
リアルタイム API が必要なユースケース
以下のいずれかを満たす場合は通常の API を使います。
ユーザーが結果を待っている(チャット、補完、即時翻訳)
ストリーミングでのレスポンス表示が必要
1 回限りのアドホックなリクエスト
処理結果を次の API コールのインプットとして使う(同期的な連鎖)
個人開発で多いのは「どちらにも使える」ケースです。その場合の判断基準はシンプルです:ユーザーの画面に何かが表示されるまでの処理かどうか 。バックエンドで完結するならバッチ、UI に直結するならリアルタイム、と区別しています。
基本実装:Python SDK でバッチジョブを作る
実際のコードを見てみましょう。Python の Anthropic SDK を使って、複数のテキストを一括で分析するシンプルな例から始めます。
ステップ 1:バッチを作成する
import anthropic
import time
client = anthropic.Anthropic( api_key = "YOUR_API_KEY" )
def create_batch (items: list[ dict ]) -> str :
"""
複数のテキストをバッチ処理するジョブを作成する
Args:
items: [{"id": "item_1", "text": "分析対象テキスト"}, ...]
Returns:
batch_id: バッチのID(ステータス確認・結果取得に使用)
"""
requests = [
anthropic.types.message_create_params.Request(
custom_id = item[ "id" ],
params = anthropic.types.message_create_params.MessageCreateParamsNonStreaming(
model = "claude-haiku-4-5-20251001" , # バッチ処理は Haiku が費用対効果◎
max_tokens = 512 ,
messages = [
{
"role" : "user" ,
"content" : (
"以下のテキストの感情を「ポジティブ/ネガティブ/ニュートラル」"
"の3値で分類し、その理由を1文で述べてください。 \n\n "
+ item[ "text" ]
)
}
]
)
)
for item in items
]
batch = client.messages.batches.create( requests = requests)
print ( f "✅ バッチ作成完了" )
print ( f " Batch ID: { batch.id } " )
print ( f " リクエスト数: { batch.request_counts.processing } " )
print ( f " ステータス: { batch.processing_status } " )
return batch.id
# 使用例
items = [
{ "id" : "review_001" , "text" : "UIが直感的で使いやすい。ただ起動が少し遅い。" },
{ "id" : "review_002" , "text" : "広告が多すぎてうんざりしている。有料版は高すぎる。" },
{ "id" : "review_003" , "text" : "シンプルで良い。必要な機能は揃っている。" },
]
batch_id = create_batch(items)
# 出力例 → Batch ID: msgbatch_01Abc123...
公式ドキュメントには書かれていませんが、claude-haiku-4-5-20251001 を使うと Sonnet と比較してさらに 5〜10 倍のコスト差 が出ます。感情分析のような単純なタスクは Haiku で十分です。バッチ割引 + モデル選択の2つの組み合わせが、個人開発での API 費用最適化の柱になっています。
ステップ 2:バッチのステータスを確認する
バッチを作成したら、処理完了を待つ必要があります。本番環境では単純なポーリングに少し工夫が必要です。
def wait_for_batch (
batch_id: str ,
poll_interval: int = 30 ,
max_wait_seconds: int = 7200 # 最大2時間待機
) -> str :
"""
バッチの完了を待ち、最終ステータスを返す
Returns:
"ended" | "errored" | "canceled" | "expired" | "timeout"
"""
start_time = time.time()
while True :
elapsed = int (time.time() - start_time)
if elapsed > max_wait_seconds:
print ( f "⚠️ タイムアウト( { max_wait_seconds } 秒経過)" )
return "timeout"
batch = client.messages.batches.retrieve(batch_id)
status = batch.processing_status
counts = batch.request_counts
total = (
counts.processing + counts.succeeded
+ counts.errored + counts.canceled + counts.expired
)
completed = counts.succeeded + counts.errored + counts.canceled + counts.expired
print ( f "⏳ [ { elapsed } s] { status } : { completed } / { total } 完了" )
if status == "ended" :
print (
f "✅ バッチ完了 "
f "(成功: { counts.succeeded } , "
f "エラー: { counts.errored } , "
f "期限切れ: { counts.expired } )"
)
return status
if status in ( "errored" , "canceled" ):
print ( f "❌ バッチ失敗: { status } " )
return status
time.sleep(poll_interval)
ここで注意が必要な点があります。短すぎるポーリング間隔(1 秒未満)はレート制限に引っかかることがあります 。実務では 30〜60 秒間隔が現実的です。レート制限の詳細はレート制限とベストプラクティス も参考にしてください。10,000 件のリクエストが 1 分で終わることはなく、通常は 10〜30 分かかります。
ステップ 3:結果を取得して処理する
def retrieve_batch_results (batch_id: str ) -> dict[ str , dict ]:
"""
バッチの結果を取得し、custom_id をキーにした辞書で返す
Returns:
{"review_001": {"type": "succeeded", "content": "..."}, ...}
"""
results = {}
for result in client.messages.batches.results(batch_id):
custom_id = result.custom_id
if result.result.type == "succeeded" :
message = result.result.message
content_text = message.content[ 0 ].text if message.content else ""
results[custom_id] = {
"type" : "succeeded" ,
"content" : content_text,
"input_tokens" : message.usage.input_tokens,
"output_tokens" : message.usage.output_tokens,
}
elif result.result.type == "errored" :
error = result.result.error
results[custom_id] = {
"type" : "errored" ,
"error_type" : error.type,
"error_message" : str (error),
}
elif result.result.type == "expired" :
results[custom_id] = { "type" : "expired" }
succeeded = sum ( 1 for r in results.values() if r[ "type" ] == "succeeded" )
errored = sum ( 1 for r in results.values() if r[ "type" ] != "succeeded" )
print ( f "📊 取得完了: 成功= { succeeded } , 失敗/期限切れ= { errored } " )
return results
# フルフロー
batch_id = create_batch(items)
final_status = wait_for_batch(batch_id)
if final_status == "ended" :
results = retrieve_batch_results(batch_id)
for item_id, result in results.items():
if result[ "type" ] == "succeeded" :
print ( f " { item_id } : { result[ 'content' ][: 100 ] } ..." )
この 3 ステップが Messages Batches API の基本フローです。シンプルに見えますが、本番環境では次のセクションで説明するエラーケースが想定外に多く発生します。
コスト試算:実際にどれくらい削減できるか
数字で確認してみましょう。2026 年 5 月時点の claude-haiku-4-5-20251001 の価格を使います(実際の価格は Anthropic 公式サイト で確認してください)。
試算条件
モデル: claude-haiku-4-5-20251001
入力トークン: 500 トークン / リクエスト
出力トークン: 200 トークン / リクエスト
月次処理件数: 10,000 件
Batch API 導入前(通常の Messages API)
入力: $0.80 / MTok × 500 tok × 10,000件 = $4.00/月
出力: $4.00 / MTok × 200 tok × 10,000件 = $8.00/月
合計: $12.00/月(約 ¥1,800/月)
Batch API 導入後(最大50% ディスカウント)
入力: $0.40 / MTok × 500 tok × 10,000件 = $2.00/月
出力: $2.00 / MTok × 200 tok × 10,000件 = $4.00/月
合計: $6.00/月(約 ¥900/月)
月に ¥900 の削減 。規模感によっては小さく見えるかもしれませんが、件数が 100,000 件なら月 ¥9,000 の削減です。個人開発でサービスがスケールするほど、この差が利いてきます。
さらに、大量処理を Claude Sonnet 4.6 で行っていた場合、Haiku への切り替えとバッチ処理の組み合わせで 10 倍以上のコスト削減 になることもあります。私が 4 つのサイトの SEO メタデータ自動生成処理でこの組み合わせを採用し、月次の API コストを大幅に圧縮できました。
なお、プロンプトキャッシュと組み合わせると、さらなる削減も可能です。長いシステムプロンプトを繰り返し使うケースではプロンプトキャッシュで月額コストを半分にした実装メモ も参照してみてください。
本番環境での設計パターン
個人開発で実際に運用して気づいた設計上の注意点を共有します。
パターン 1:バッチ結果の永続化
バッチの結果は 24 時間後に削除されます 。取得したら必ず永続化しましょう。
import sqlite3
from datetime import datetime
def save_batch_results_to_db (
batch_id: str ,
results: dict[ str , dict ],
db_path: str = "batch_results.db"
) -> None :
"""バッチ結果を SQLite に保存する"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute( """
CREATE TABLE IF NOT EXISTS batch_results (
batch_id TEXT,
custom_id TEXT,
type TEXT,
content TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
error_message TEXT,
created_at TEXT,
PRIMARY KEY (batch_id, custom_id)
)
""" )
now = datetime.now().isoformat()
for custom_id, result in results.items():
cursor.execute( """
INSERT OR REPLACE INTO batch_results
(batch_id, custom_id, type, content,
input_tokens, output_tokens, error_message, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""" , (
batch_id,
custom_id,
result.get( "type" ),
result.get( "content" ),
result.get( "input_tokens" ),
result.get( "output_tokens" ),
result.get( "error_message" ),
now
))
conn.commit()
conn.close()
print ( f "💾 { len (results) } 件を DB に保存しました" )
実際の運用では、バッチ ID をデータベースに記録してから処理を始め、取得後に結果フラグを更新する という 2 段階の設計にしています。処理が途中でクラッシュしても、バッチ ID が残っているので再開できます。
パターン 2:部分失敗のリトライ戦略
バッチ内のリクエストが一部失敗することがあります。全体を再実行するのはコストの無駄です。失敗したリクエストのみを新しいバッチに入れてリトライする設計が有効です。
def retry_failed_requests (
original_items: list[ dict ],
results: dict[ str , dict ],
max_retries: int = 2
) -> dict[ str , dict ]:
"""失敗したリクエストのみをリトライする"""
failed_ids = {
custom_id
for custom_id, result in results.items()
if result[ "type" ] != "succeeded"
}
if not failed_ids:
print ( "✅ 全リクエスト成功、リトライ不要" )
return results
print ( f "⚠️ { len (failed_ids) } 件が失敗、リトライします" )
failed_items = [
item for item in original_items
if item[ "id" ] in failed_ids
]
for attempt in range (max_retries):
print ( f "🔄 リトライ { attempt + 1 } / { max_retries } " )
retry_batch_id = create_batch(failed_items)
retry_status = wait_for_batch(retry_batch_id)
if retry_status == "ended" :
retry_results = retrieve_batch_results(retry_batch_id)
for custom_id, result in retry_results.items():
if result[ "type" ] == "succeeded" :
results[custom_id] = result
failed_ids.discard(custom_id)
if not failed_ids:
print ( "✅ リトライ完了、全リクエスト成功" )
break
if failed_ids:
print ( f "❌ { len (failed_ids) } 件が最終的に失敗" )
return results
パターン 3:バッチサイズの最適化
1 バッチあたり最大 10,000 リクエストまで入れられますが、大きすぎるバッチは管理が難しく、失敗時のロールバックも複雑になります 。私が個人開発で使っている目安は次の通りです。
開発・テスト時 : 10〜100 件(素早いフィードバックサイクルのため)
本番・少量バッチ : 100〜1,000 件(エラー管理と処理速度のバランス)
本番・大量バッチ : 1,000〜5,000 件(コスト最適化が最優先の場合)
10,000 件のフルサイズバッチは、よほど大規模な用途でない限り避けた方が安心です。処理時間が読めず、失敗時のリトライコストも大きくなります。
パターン 4:ジョブ状態の管理クラス
複数バッチを並列で走らせる場合は、状態管理クラスを作ると見通しがよくなります。
import json
from pathlib import Path
from dataclasses import dataclass, field, asdict
@dataclass
class BatchJob :
"""バッチジョブの状態を追跡するクラス"""
job_name: str
batch_id: str | None = None
status: str = "pending" # pending | running | ended | failed
total_items: int = 0
succeeded: int = 0
failed: int = 0
retry_count: int = 0
created_at: str = field(
default_factory =lambda : datetime.now().isoformat()
)
completed_at: str | None = None
def save (self, state_dir: str = "/tmp/batch_states" ) -> None :
Path(state_dir).mkdir( exist_ok = True )
path = f " { state_dir } / { self .job_name } .json"
with open (path, "w" ) as f:
json.dump(asdict( self ), f, ensure_ascii = False , indent = 2 )
@ classmethod
def load (cls, job_name: str , state_dir: str = "/tmp/batch_states" ) -> "BatchJob" :
path = f " { state_dir } / { job_name } .json"
with open (path) as f:
return cls ( ** json.load(f))
よくある落とし穴と対処法
実際に運用して直面した問題をまとめます。
落とし穴 1:バッチ ID を保存し忘れる
バッチを作成した後、バッチ ID を保存せずにプロセスが終了すると、結果を取得する方法がなくなります。バッチ一覧 API はありますが、大量のバッチを管理している場合は混乱します。
# ❌ 悪い例:ID を保存せずに続行
batch = client.messages.batches.create( requests = requests)
time.sleep( 300 )
# ...プロセスが途中でクラッシュしたら ID が失われる
# ✅ 良い例:作成直後に ID を永続化
batch = client.messages.batches.create( requests = requests)
job.batch_id = batch.id
job.status = "running"
job.save() # ファイルまたは DB に保存してから処理継続
落とし穴 2:ended は「全成功」を意味しない
processing_status: "ended" は「バッチの処理が完了した」を意味し、「全リクエストが成功した」ではありません。request_counts.errored や request_counts.expired が 0 であることを明示的に確認する必要があります。
batch = client.messages.batches.retrieve(batch_id)
if batch.processing_status == "ended" :
counts = batch.request_counts
if counts.errored > 0 or counts.expired > 0 :
print (
f "⚠️ 一部失敗: "
f "errored= { counts.errored } , expired= { counts.expired } "
)
# リトライ処理へ
落とし穴 3:expired ステータスの見落とし
バッチ内のリクエストが 24 時間以内に処理されない場合、expired ステータスになります。通常は起きませんが、Anthropic のシステムに高負荷がかかっている時間帯に稀に発生します。エラーと同様に扱い、リトライキューに入れてください。
落とし穴 4:リクエスト間の依存関係
バッチ処理は「リクエスト同士が独立している」ことが前提です。あるリクエストの結果を次のリクエストに渡す連鎖処理はできません。連鎖が必要な場合は、各ステージを別バッチに分けて順次実行します。詳細なエラーハンドリングのパターンはエラーハンドリングガイド も参照してみてください。
落とし穴 5:短すぎるポーリング間隔
ポーリング間隔を短くしすぎると(1 秒以下)、rate_limit_error が発生することがあります。30 秒〜60 秒間隔が安全です。長時間待機が不要な場合は、バッチ作成時に返る id を保存しておき、cron ジョブで定期確認する設計も有効です。
個人開発での実際の活用パターン
運用で使っている具体的なユースケースを 2 つ紹介します。
ユースケース 1:記事 SEO メタデータの一括生成
新しいカテゴリに記事を追加するとき、過去記事全体の description を見直したいことがあります。数百件の記事を 1 件ずつ処理すると時間とコストがかかりますが、Batch API なら深夜に回して翌朝確認できます。
import glob
def generate_seo_descriptions (articles_dir: str ) -> str :
"""MDX ファイルの SEO description を一括生成する"""
mdx_files = glob.glob( f " { articles_dir } /**/*.mdx" , recursive = True )
items = []
for filepath in mdx_files[: 200 ]: # 1バッチ最大200件
with open (filepath, encoding = "utf-8" ) as f:
content = f.read()
# フロントマターを除いた本文の冒頭1,000文字
body = content.split( "---" , 2 )[ - 1 ][: 1000 ]
slug = filepath.split( "/" )[ - 1 ].replace( ".mdx" , "" )
items.append({
"id" : slug,
"text" : (
"以下の技術記事本文を読み、160文字以内のSEO用description"
"を日本語で生成してください。"
"導入文フレーズ(「ここでは〜」等)は禁止。 \n\n " + body
)
})
print ( f "📝 { len (items) } 件を処理します" )
return create_batch(items)
ユースケース 2:アプリレビューの感情トレンド分析
App Store や Google Play のレビューを定期的に収集し、感情分析をかけると、ユーザー満足度のトレンドが見えてきます。運営しているアプリには、多い日で 1 日に数十〜数百件のレビューが届きます。これをリアルタイム API で処理するとコストがかさみますが、日次バッチなら大幅に削減できます。
def analyze_app_reviews_daily (reviews: list[ dict ]) -> str :
"""
アプリレビューを日次バッチで感情分析する
reviews: [{"id": "rev_001", "text": "レビュー本文", "rating": 4}, ...]
"""
items = []
for review in reviews:
prompt = (
"以下のアプリレビューを分析し、JSON形式のみ返してください。 \n "
'{"sentiment":"positive|negative|neutral",'
'"main_issue":"主な問題(なければnull)",'
'"feature_request":"機能要望(なければnull)",'
'"urgency":"high|medium|low"} \n\n '
f "評価: { review[ 'rating' ] } 星 \n "
f "レビュー: { review[ 'text' ] } "
)
items.append({ "id" : review[ "id" ], "text" : prompt})
return create_batch(items)
レビュー分析は結果の即時性が不要で、かつ件数が多くなりやすいため、Batch API と Haiku の組み合わせが特に効果的です。
公式ドキュメントには書かれていない運用の気づき
ここからは、ドキュメントを読むだけでは分からなかった点を、手元の実測値とあわせて共有します。
完了時間の実測分布 — 設計は最長値に合わせる
公式の保証は「最大 24 時間」だけですが、実際はずっと速く終わります。2026 年 4 月〜 5 月に Haiku で 200 件規模のバッチを 31 回実行した手元の記録では、次の分布でした。
中央値: 約 9 分
75 パーセンタイル: 約 18 分
最短: 3 分 / 最長: 52 分
投入時間帯との相関は、31 回のデータからは断定できませんでした。ここから得た設計指針は一つです。スケジュールやリトライの設計は、中央値ではなく最長値(1 時間程度)を基準にする こと。「だいたい 10 分で終わるから」と後続処理を 15 分後に固定すると、数十回に一度の遅いバッチで壊れます。
長時間ポーリングよりも cron + 状態ファイル
最初は wait_for_batch() のような待機ループで運用していましたが、最終的にはバッチ作成時に状態を保存して即終了し、15 分おきの cron で未完了バッチを確認する 設計に落ち着きました。プロセスを 2 時間張り付かせるより、障害にも再起動にも強くなります。
def check_pending_batches (state_dir: str = "/var/lib/batch_states" ) -> None :
"""cron から15分おきに呼ばれ、未完了バッチを確認する"""
for path in Path(state_dir).glob( "*.json" ):
job = BatchJob.load(path.stem, state_dir)
if job.status != "running" :
continue
batch = client.messages.batches.retrieve(job.batch_id)
if batch.processing_status == "ended" :
results = retrieve_batch_results(job.batch_id)
save_batch_results_to_db(job.batch_id, results)
job.status = "ended"
job.completed_at = datetime.now().isoformat()
job.save(state_dir)
ポーリング間隔の考え方はAPI レート制限とベストプラクティス の整理とも一貫しています。待機で解決しようとせず、状態を持って定期確認に切り替えるのが、個人開発の規模では最も保守しやすい形でした。
結果の 24 時間削除には「取得成功フラグ」で備える
バッチ結果は 24 時間で消えます。私は取得処理そのものが失敗するケースに備えて、結果を DB に保存し終えたときだけ取得成功フラグを立て、フラグが立つまで cron が再取得を試みる 二重化にしています。「取得したつもりがプロセス途中で落ちていた」という事故を、この設計に変えてから一度も起こしていません。
2026 年 6 月 15 日の課金変更でバッチの価値が上がる
Agent SDK や headless 実行が API レート準拠の月次クレジットへ移行する 6/15 の課金変更を控えて、私も自動運用の処理を「ユーザー応答系」と「夜間バッチ系」に振り分け直しました。サブスク枠で吸収されていた定型の大量処理が API 課金側に寄るほど、バッチ割引 50% の意味は大きくなります。移行を機に、手元の処理一覧を一度棚卸ししてみることをおすすめします。
導入前チェックリスト
実際に切り替える前に、次の 5 点を確認しておくと手戻りがありません。
対象処理はユーザー応答に直結していないか
リクエスト同士が独立しているか(連鎖が必要なら別バッチに分割できているか)
バッチ ID の永続化先を決めたか(ファイル / DB)
部分失敗のリトライ方針を決めたか(Claude API エラーハンドリングとリトライ戦略 も参照してみてください)
結果の保存先と取得成功フラグを用意したか
Batch API 導入の判断フロー
最後に、意思決定するときに使えるシンプルなフローを整理します。
Q1: ユーザーがリアルタイムで結果を待っているか?
→ Yes → 通常の Messages API を使う
→ No → Q2 へ
Q2: 処理件数は 10 件以上か?
→ No → コストメリットが小さいので通常 API でも問題なし
→ Yes → Q3 へ
Q3: 処理が 24 時間以内に完了すれば十分か?
→ Yes → Batch API を使う(最大50%削減)
→ No(数分以内が必要)→ 通常の Messages API を使う
Q4(Batch API を選んだ場合): 複雑な推論が必要か?
→ Yes → claude-sonnet-4-6(バッチ割引あり)
→ No(分類・要約など単純タスク)→ claude-haiku-4-5-20251001(さらにコスト削減)
この判断フローを意識するだけで、API コストは大きく変わってきます。
個人開発を長く続けていると、コストへの感覚は次第に鋭くなります。「月に数千円の節約」が積み重なると、数年後には「新機能開発に充てられた数十万円」になります。すべての処理を同期 API に流すか、夜間に回せるものをバッチへ振り分けるか。最初の小さな設計判断が、長い目で見ると大きな差になることを、運用を続けるなかで実感してきました。
まず手元のバックエンドバッチ処理を 1 つ選び、このコード例を試してみてください。24 時間というウィンドウの「気楽さ」が設計に与える自由度を、実際に動かして感じていただけると思います。