LLM Frontline
開発・エージェント

LLMの大量処理を同期APIでやらない|バッチ推論・非同期処理の判断基準と失敗設計

ミナト・開発・API担当
・ 約30分で読めます
LLMの大量処理を同期APIでやらない|バッチ推論・非同期処理の判断基準と失敗設計

過去3年分の問い合わせ12万件にタグを付ける、社内文書5万本を要約してインデックスを作り直す、既存データをモデル移行後の構成で再処理する。こうした仕事が来たとき、最初に書かれるコードはたいてい同じ形をしています。CSVを読み込んで、for文でAPIを呼んで、結果を配列に足していく。件数が100件なら問題ありません。ところがこれが5万件になると、実行に丸一日かかり、途中で429に当たって止まり、どこまで終わったのか分からないまま再実行して二重に課金される、という展開になります。

この記事は、その手前で持つべき判断の話です。どの処理を同期のままにし、どれをバッチAPIへ寄せ、どれを自前のジョブキューで回すか。そして非同期に寄せると決めたあとに必ず出てくる、冪等性・突合・部分失敗・品質検査をどう設計するか。所要時間や割引の目安は各社の公式ドキュメントで確認できた範囲だけを書き、価格そのものは改定が続くため記載しません。

実行モードを、まず言葉として分ける

「非同期にする」という言葉は、実務では少なくとも4つの別のものを指します。混ざったまま議論すると噛み合いません。

モード実体待ち時間の性質主な使いどころ
同期APIリクエストを投げてその場で応答を待つ秒単位。人か上流の処理が待っている対話UI、画面から起動する単発処理
低優先ティア同期の呼び出し方のまま、優先度を下げて安く使う秒から分単位。遅くなることを受け入れる人が待っていないが、コードは同期のままにしたい処理
バッチAPI大量のリクエストをまとめて投入し、後から結果ファイルを回収する時間単位。完了ウィンドウが定義されている数千件以上の一括処理、再処理、評価
自前ジョブキューキューにジョブを積み、ワーカーが同期APIを叩く自分で決める。数分から数時間進捗を見せたい、順序や依存がある、複数ステップがある

低優先ティアの例として、OpenAIはservice_tierにflexを指定する方式を公開しています。公式ドキュメントによれば、トークンはBatch APIのレートで課金され、プロンプトキャッシュによる追加の割引も効きます。応答が遅くなることが前提で、リソースが確保できないときには429が返り、その場合は課金されないとされています。既定のタイムアウトは10分ですが、ドキュメントのコード例では15分へ引き上げる形が示されています。なお同ドキュメントはFlex processingがベータであり対応モデルも限られる旨を明記しているため、本番採用の可否は最新の記載と対応モデル一覧を確認したうえで判断してください。

この4つのうち、コードの書き換え量が最小なのは低優先ティアです。バッチAPIは投入と回収という別のフローになるため、いちばん書き換えが大きくなります。自前ジョブキューはその中間で、同期APIの呼び出しはそのまま流用できます。

判断軸は4つ:締切・件数・失敗許容度・コスト

どこへ寄せるかは、次の4つで決まります。順番に重要度が高いわけではなく、締切だけが性質の違う軸です。

締切(いつまでに全件終わっていればよいか)

これだけが足切りとして働きます。バッチAPIの完了ウィンドウは各社とも24時間の水準にあり、しかもそれは保証ではなく目安です。したがって「明日の朝までに終わっていればよい」なら候補に入りますが、「30分後の会議で使う」なら候補から外れます。ここで重要なのは、締切を人の都合ではなく処理の下流で決めることです。バッチの結果を受けて別のジョブが動く構成なら、そのジョブの開始時刻が実質の締切になります。

件数(1回のジョブで何件処理するか)

数十件なら同期でループさせても数分で終わり、失敗しても最初からやり直せます。数千件を超えたあたりから、途中失敗のコストが「やり直せばいい」で済まなくなり、進捗の保存が必要になります。数万件を超えると、レート制限に当たらないように送出を絞ること自体が仕事になり、バッチAPIの守備範囲に入ってきます。

失敗許容度(一部が落ちたときに何が起きるか)

全件そろわないと次に進めない処理と、9割そろえば十分な処理では設計が変わります。前者は再投入と全件突合の仕組みが必須で、後者は落ちた分を記録して次回に回す運用ができます。この見極めを先にやらないと、必要のない厳密さを作り込んだり、逆に静かに欠落したまま下流へ流したりします。

コスト(削減幅が設計変更の手間に見合うか)

各社ともバッチ処理は同期比で半分の水準としています。月に数千円しか使っていない処理なら、半額になっても設計変更の手間に見合いません。逆に、数万件の再処理を毎月回すような処理では、この差がそのまま予算の話になります。

ヒント

4軸のうち、締切以外は「今の値」ではなく「1年後の値」で考えるほうが安全です。件数は増える方向にしか動きません。同期のfor文は、件数が10倍になった瞬間に破綻します。最初から非同期の形にしておけば、増えたときに投入回数が増えるだけで済みます。

各社のバッチAPIに共通する形と、違いが出るところ

公式ドキュメントで確認できる範囲を整理すると、共通点と相違点がはっきりします。

項目Anthropic Message Batches APIOpenAI Batch APIGemini Batch API
所要時間多くのバッチが1時間未満で完了。結果は全件完了時か24時間経過時の早いほう完了ウィンドウは24時間(指定値は24hのみ。多くはより早く完了)目標所要時間は24時間。多くの場合はより早い
コスト50%削減同期API比50%割引標準の50%
1バッチの上限100,000リクエストまたは256MBの、先に達したほう50,000リクエスト、入力ファイル200MBインラインは合計20MB、ファイルは最大2GB
期限切れ24時間で完了しなければバッチが期限切れ24時間で完了しなければexpired実行中または保留のまま48時間を超えると期限切れ
結果の保持作成から29日間ダウンロード可能出力ファイルは完了から30日後に自動削除6週間ダウンロード可能
突合custom_id(1〜64文字、英数字とハイフン・アンダースコア)custom_id(リクエストごとに一意)ユーザー定義のキー

共通しているのは、24時間という時間軸、半額という価格帯、そして結果が投入順に返らないことです。Anthropicのドキュメントは「バッチ結果は任意の順序で返ることがあり、バッチ作成時のリクエスト順と一致しないことがある。結果を正しく対応付けるには常にcustom_idフィールドを使うこと」と明記しています。OpenAIも同様に、出力の行順が入力の行順と一致しないことがあるため順序に頼らずcustom_idを使うよう書いています。

注意

「投入した順に返ってくるはず」という前提でインデックス番号を使って突き合わせる実装は、テスト時の小さなバッチでは通ってしまい、本番の大きなバッチで結果が入れ替わります。しかも入れ替わりは例外を出さず、間違った紐付けのままデータベースに書き込まれます。最初から順序に依存しない実装にしてください。

期限切れと部分失敗:課金の考え方は共通、結果の取り方が違う

バッチが24時間で終わらなかったときの課金は、各社とも同じ考え方です。モデルへ送られる前に期限切れになった個々のリクエストは課金されず、期限切れまでに完了した分は課金されます。設計に効いてくる差分は金額ではなく、「未処理分をどうやって知るか」という結果の受け取り方のほうにあります。

Anthropicのドキュメントは、個々のリクエストの結果をsucceeded・errored・canceled・expiredの4種類に分け、errored(無効なリクエストや内部サーバエラー)・canceled(ユーザーがモデルへ送られる前にキャンセル)・expired(24時間の期限に到達し、モデルへ送られなかった)については課金されないとしています。またキャンセルしたバッチはendedという状態で終わり、キャンセル前に処理された分の部分結果を含むことがあるとされています。

OpenAIのドキュメントは、バッチが期限切れになると未完了のリクエストはキャンセルされ、完了済みリクエストの応答は出力ファイルから取得でき、完了したリクエストで消費されたトークンについては課金される、としています。つまり期限切れは「全損」ではなく「途中まで取れる、その分は払う」という扱いです。

Geminiは、ジョブ状態としてJOB_STATE_PENDING・JOB_STATE_RUNNING・JOB_STATE_SUCCEEDED・JOB_STATE_FAILED・JOB_STATE_CANCELLED・JOB_STATE_EXPIREDを定義しており、実行中または保留のまま48時間を超えたジョブは期限切れになるとしています。

つまり、未処理分の知り方が三者三様です。Anthropicはexpiredが結果JSONLの1行としてcustom_idつきで返り、OpenAIはbatch_expiredのエラーとしてエラーファイルへ書き出され、Geminiはジョブ全体がJOB_STATE_EXPIREDになり結果を取得できません。台帳側の未処理判定を、どの経路から埋めるかがここで決まります。

実務上の結論はシンプルです。期限切れは異常系ではなく、起こりうる正常な終了状態のひとつとして扱ってください。そのうえで、「期限切れになったとき、何件が未処理で残り、それを次にどう投入するか」を先に決めておきます。Anthropicのドキュメントには、需要と自分のリクエスト量によっては処理が遅くなり、24時間後に期限切れになるリクエストが増えることがある、という趣旨の記載もあります。混雑は自分では制御できない変数です。

あわせて読みたい

LLM APIのレート制限とリトライ設計|429・タイムアウト・二重実行を本番で捌く

ジョブ設計:主役はプロンプトではなく入力ID

非同期に寄せると決めた瞬間から、設計の中心はプロンプトの書き方ではなく、入力1件を一意に指す識別子になります。これが決まっていないと、突合も再投入も冪等性も成立しません。

入力IDの決め方

業務側にすでに一意キーがあるなら、それを使うのがいちばん安全です(問い合わせID、文書ID、注文番号など)。無い場合は、入力内容のハッシュを使う手もありますが、同じ内容が別の意味を持つ場合は衝突するため注意が必要です。IDには文字種の制約が付くことがあります。Anthropicのcustom_idは1〜64文字で、英数字・ハイフン・アンダースコアのみ(^[a-zA-Z0-9_-]{1,64}$)と定められています。日本語の文書名やスラッシュを含むパスをそのまま入れることはできません。

さらに実務では、IDに処理のバージョンを含めておくと後で助かります。doc-10482__v3のように、プロンプトのバージョンやモデル世代を含めておけば、同じ文書を条件を変えて再処理した結果を並べて保持できます。

あわせて読みたい

プロンプトのバージョン管理と本番運用|コードに置くか、レジストリに置くかの判断

台帳を先に作る

バッチジョブの中心は、投入したい入力の一覧と、その状態を持つテーブルです。これを先に作ってからAPIを呼びます。最低限、次の列があれば回ります。

ジョブ台帳に持たせる列

  • 入力ID(業務側の一意キー。突合とAPIのcustom_idの両方に使う)
  • 状態(pending / submitting / submitted / succeeded / failed / expired / skipped)
  • バッチID(どのバッチに投入したか。回収時にたどる)
  • 試行回数と、最後に投入した時刻
  • エラー種別(バリデーション/サーバ/期限切れ)とエラー本文の要約
  • 入力のハッシュとプロンプトのバージョン(条件が変わったかの判定に使う)
  • 出力の保存先(本文を別テーブルやオブジェクトストレージに置くならその参照)
  • 使用トークン数(入力・出力。コスト集計と見積もり更新に使う)

この台帳があると、「まだ処理していないものだけを投入する」という操作が単なるSQLになります。冪等性の土台はこの一文ですが、これだけでは足りません。APIを呼んだ直後、状態を書き戻す前にプロセスが落ちると、行は未処理のまま残り、次回の投入で同じ入力をもう一度投げてしまうからです。そこで投入は二段構えにします。

# 投入と回収を分けた最小構成の擬似コード(エラー処理は簡略化)
# 前提: 台帳テーブル jobs(input_id, status, batch_id, attempts, ...)

def submit_pending(limit=50_000):
    # 1. まだ終わっていないものだけを取り出す(冪等性の本体はこのクエリ)
    rows = db.query(
        "SELECT input_id, payload FROM jobs "
        "WHERE status IN ('pending', 'retryable') AND attempts < 3 "
        "LIMIT ?", limit)
    if not rows:
        return None

    requests = [
        {"custom_id": r.input_id, "params": build_params(r.payload)}
        for r in rows
    ]

    # 2. 投入する前に「投入しようとしている」ことを書く(二段書きの前半)
    #    これがないと、create() の直後に落ちたとき行が pending のまま残り、
    #    次回の submit_pending が同じ入力を再投入して二重課金になる
    token = new_uuid()   # この試行を識別する値(回収時のグループキー)
    db.execute(
        "UPDATE jobs SET status='submitting', submit_token=?, "
        "attempts=attempts+1, submitted_at=now() WHERE input_id IN (?)",
        token, [r.input_id for r in rows])

    batch = client.messages.batches.create(requests=requests)

    # 3. 成功したらバッチIDを埋めて submitted に進める(二段書きの後半)
    db.execute(
        "UPDATE jobs SET status='submitted', batch_id=? "
        "WHERE submit_token=?", batch.id, token)
    return batch.id


def recover_submitting(grace="30min"):
    # 4. submitting のまま宙に浮いた行を回収する。バッチ一覧APIで
    #    submitted_at 以降に作られたバッチを列挙し、その custom_id に
    #    自分の入力IDが含まれていれば submitted へ、
    #    どこにも無ければ create() 前に落ちたので pending へ戻す
    for token, since, ids in db.stale_submit_batches(older_than=grace):
        batch = find_batch_containing(client, since, ids)
        if batch:
            db.execute("UPDATE jobs SET status='submitted', batch_id=? "
                       "WHERE submit_token=?", batch.id, token)
        else:
            db.execute("UPDATE jobs SET status='pending', "
                       "attempts=attempts-1 WHERE submit_token=?", token)


def collect(batch_id):
    # 5. 結果は投入順に返らない。必ず custom_id で突き合わせる
    for result in client.messages.batches.results(batch_id):
        input_id = result.custom_id
        kind = result.result.type  # succeeded / errored / expired / canceled

        if kind == "succeeded":
            body = extract_text(result)
            if not passes_quality_check(body):        # 6. 検査もここで
                mark(input_id, "failed", reason="quality")
                continue
            save_output(input_id, body)
            mark(input_id, "succeeded")
        elif kind == "errored" and is_validation_error(result):
            mark(input_id, "failed", reason="validation")   # 再投入しない
        else:
            mark(input_id, "retryable", reason=kind)        # 再投入する

ポイントは3つです。投入する対象を台帳から決めること、API呼び出しの前後で状態を二段に書くこと、そして回収時にcustom_idだけで紐付けることです。二段書きにしておけば、どのタイミングで落ちても行はpendingかsubmittingかsubmittedのいずれかに残ります。再実行の前にrecover_submittingを通し、宙に浮いたsubmittingの行をバッチ一覧APIとcustom_idで突き合わせてから投入する、という順番にしてください(バッチ一覧の取得と、投入済みバッチの結果に含まれるcustom_idの確認は各社とも用意されています)。この復旧処理を挟まずに同じコマンドをもう一度叩くと、二重投入の穴がそのまま残ります。

部分失敗は例外ではなく通常状態

数万件を投げれば、必ず何件かは失敗します。ここで「失敗したので全部やり直す」を選ぶと、成功した分の課金が丸ごと無駄になります。再投入の可否は、エラーの性質で分けてください。

  • 再投入してはいけないもの: リクエスト自体が無効なケースです。入力が長すぎてコンテキスト上限を超えている、必須パラメータが欠けている、といったバリデーションエラーは、何度投げても同じ結果になります。Anthropicの公式サンプルコードも、erroredのうちinvalid_requestは「再送する前にリクエスト本文を修正する必要がある」、それ以外のサーバエラーは「そのまま再試行できる」とコメントで書き分けています。
  • 再投入してよいもの: 一時的なサーバエラーと、期限切れです。期限切れになった個々のリクエストは各社とも課金されず、期限切れまでに完了した分は課金される、というのが共通の形です。いずれにせよ未処理として次のバッチに載せ直すのが素直です。
  • 投げ方を変えるべきもの: 同じ入力で毎回タイムアウトや上限超過を起こす少数の巨大な入力です。これは全体のバッチから外し、分割してから別枠で処理します。1件の外れ値のために全体を細切れにするのは無駄が大きくなります。
「9割成功、1割失敗」で止まったときにいちばん困るのは、失敗した1割の一覧がどこにも残っていないことです。結果ファイルには成功分しか入っていない、という実装にしてしまうと、差集合を出すために元データとの突合からやり直すことになります。台帳の状態列は、そのためにあります。
バッチ処理を運用するエンジニア

再投入には上限を持たせてください。試行回数を台帳に持ち、3回失敗したものはfailedに固定して人の目に回します。無限に再投入するループは、失敗の原因が入力側にある場合に永久に回り続け、しかも毎回課金されます。

レート制限との付き合い方

バッチAPIの枠が同期の枠とは別に管理される点は、リトライ設計の記事で触れたとおりです(OpenAIのドキュメントも、Batch APIのレート制限は標準のモデル別レート制限とは別だと明記しています)。ここで押さえたいのは、その先にあるバッチ固有の制限です。AnthropicのMessage Batches APIには、全モデル共通のRPMに加えて「処理キューに同時に入れられるバッチリクエスト数」という軸があり、公式のレート制限ページではStart/Build/Scaleの各tierに対して200,000/300,000/500,000という値が、1バッチあたりの上限100,000件とあわせて示されています。ここでいうバッチリクエストとは、Message Batchの中の1件を指します。つまり「1バッチに何件入れられるか」と「同時に何件を待ち行列に置けるか」は別の制限です。

したがって、100万件を処理したいときの分割は、1バッチの上限だけでなく、キューに置ける総数も見て決めることになります。実務的には、1バッチを上限いっぱいまで詰めるより、回収と再投入の粒度として扱いやすいサイズ(数千から数万件)に区切り、前のバッチが終わってから次を投げる形のほうが運用が楽です。1つのバッチが期限切れになったときの被害も、そのぶん小さくなります。

ヒント

共通の長い指示文や参照文書を全件で使い回すなら、プロンプトキャッシュを併用する余地があります。Anthropicのドキュメントは、バッチの処理には5分を超える時間がかかりうるため、共有コンテキストを含むバッチではキャッシュヒット率を高める目的で1時間のキャッシュ期間の利用を検討するよう案内しています。ただしバッチ内ではmax_tokens: 0によるキャッシュの事前ウォームがサポートされていない点に注意してください。

あわせて読みたい

プロンプトキャッシュ(Prompt Caching)とは|LLM APIのコストとレイテンシを下げる仕組みと実務

コスト見積もりは、単価ではなくパイロットから立てる

見積もりを「単価×件数」で立てると、たいてい外れます。出力トークン数が入力によって大きくばらつくためです。順番としては次のようにします。

  1. 1

    母集団から無作為に100件抜く

    先頭100件ではなく無作為に選びます。データは並び順に偏りがあることが多く、先頭だけ短い、といったことが普通に起こります。
  2. 2

    同期APIで実行し、入力・出力トークン数を全件記録する

    この段階では速度もコストも気にしません。分布を知るための計測です。
  3. 3

    平均ではなくp50とp95で見る

    平均だけで全体を掛け算すると、長い入力が固まっている場合に大きく外れます。p95側で上振れの幅を把握します。
  4. 4

    全体件数へ引き伸ばし、バッチの割引率を掛ける

    各社が示している水準は同期比50%です。ここで初めて金額の話になります。
  5. 5

    失敗と再投入の分を上乗せする

    再投入率を5〜10%程度見込んでおくと、実績との乖離が小さくなります。品質検査で落ちた分の再処理もここに入ります。

そのうえで、実行中は台帳に記録したトークン数から実績を積み上げ、見積もりとの乖離を監視します。乖離が大きいときは、想定より長い出力が出ているか、リトライが想定より多いかのどちらかで、原因が違えば打ち手も違います。

なお、Anthropicのドキュメントには、高いスループットと並行処理のため、バッチがワークスペースに設定された支出上限をわずかに超えることがある、という記載があります。上限を安全弁として使っている場合は、この挙動を前提に少し余裕を持たせてください。

あわせて読みたい

LLMのコスト管理|トークン課金の考え方と削減の定石

品質検査をバッチの中に組み込む

同期処理なら、おかしな出力はその場で人の目に触れます。バッチではそれが起きません。5万件の結果が静かにデータベースへ書き込まれ、3週間後に「そういえばあのタグ、全部おかしくないですか」と言われるのが典型的な失敗です。検査は後工程ではなく、回収処理の中に入れてください。

まず、機械的に判定できるものは全件チェックします。JSONとしてパースできるか、想定したスキーマに合っているか、分類ラベルが定義した集合に入っているか、出力が空でないか、停止理由が上限到達になっていないか。前掲の擬似コードでpasses_quality_checkと書いた部分です。ここで落ちたものはfailedにして再投入対象に回します。

次に、内容の妥当性は全件では見られないので、無作為抽出で人が見ます。件数は母集団の規模と失敗許容度で決めますが、重要なのは「抽出をランダムにすること」と「見た結果を記録して次回と比較できるようにすること」です。バッチは繰り返し実行されるので、初回に基準を作っておけば2回目以降は差分だけを見ればよくなります。

3つ目の手として、LLM自身に検査させる方法があります。生成用のバッチとは別に、検査用のバッチをもう1本流す構成です。バッチは安いので、この二段構えのコストは同期で1回やるより安く収まることがあります。ただし検査側の判定が信用できるかどうかは別途評価が必要で、検査そのものが当てにならなければ意味がありません。

あわせて読みたい

LLM-as-a-Judge(LLMによる評価)とは|出力の良し悪しをLLMに採点させる仕組みとバイアスへの備え

バッチや非同期に向かない場面

寄せてはいけないものを先に決めておくと、判断が速くなります。

対話UIは論外です。ユーザーが画面の前で待っている処理を24時間ウィンドウの仕組みに載せることはできません。ここは同期とストリーミングの領域で、指標もコストではなくTTFTになります。

締切が分単位の処理も向きません。「入稿から10分以内にチェック結果を返す」といった約束がある処理は、目安として1時間未満で終わることが多いとされていても、混雑時に24時間側へ振れる可能性を許容できません。どうしても安く済ませたいなら、バッチに投げつつ一定時間で結果が揃わなければ同期へフォールバックする二重構成が考えられますが、実装と課金が複雑になるため、締切が本当に固いのかを先に確認するほうが建設的です。

入力が到着順や前の結果に依存する処理も向きません。バッチAPIは各リクエストを独立に処理する前提で、Anthropicのドキュメントも「各リクエストは独立して処理される」ことを明記しています。前の出力を次の入力に使う多段の処理は、1つのバッチには収まりません。この場合は、段ごとにバッチを分けて順に流すか、自前のジョブキューでワークフローとして組むことになります。

対話の途中経過を保持する処理も同様です。マルチターンの会話そのものはバッチに入れられますが(会話履歴を含めた1リクエストとして投げる形)、ユーザーの次の発話を待つタイプの処理は非同期化できません。

あわせて読みたい

LLMの会話履歴をどう設計するか|全量・直近N・要約・検索の7方式と壊れやすい境界

自前ジョブキューを選ぶべきとき

バッチAPIがあるのに自前で組む理由は、主に次の3つです。

1つ目は、進捗を見せる必要がある場合です。管理画面に「1,240/5,000件完了」と出したいなら、キューとワーカーの構成のほうが素直です。バッチAPIでも件数の内訳は取れますが、粒度と更新頻度は自分では決められません。

2つ目は、複数ステップのワークフローになっている場合です。抽出してから分類し、分類結果によって要約の仕方を変える、といった処理は、LLM呼び出し以外の処理が間に挟まります。ジョブキューなら1つのジョブの中で完結させられます。

3つ目は、バッチAPIが対応していない機能を使う場合です。たとえばAnthropicのバッチではstream: trueやspeedパラメータ、max_tokens: 0が使えず、指定するとバリデーションエラーになるとされています。同期でしか使えない機能に依存しているなら、キュー+同期呼び出しの構成になります。

自前で組む場合、レート制限との付き合いはすべて自分の責任になります。ワーカー数で送出レートを握り、429のときは指数バックオフとジッタで待つ。この部分は別記事で詳しく扱っているので、そちらを参照してください。

よくある質問

バッチAPIに投げれば必ず24時間以内に終わりますか
保証ではありません。各社とも「目標」や「完了ウィンドウ」という表現で、多くは早く終わるとしています。AnthropicのMessage Batches APIは多くのバッチが1時間未満で完了するとしつつ、結果を取得できるのは全件完了時か24時間経過時の早いほうで、24時間で完了しなければバッチは期限切れになるとしています。同ドキュメントには、需要と自分のリクエスト量によって処理が遅くなり、24時間後に期限切れになるリクエストが増えうるという記載もあります。GeminiのBatch APIは目標所要時間を24時間としたうえで、実行中または保留のまま48時間を超えたジョブは期限切れになると定義しています。締切のある処理に使うなら、期限切れ時の代替経路を先に決めておいてください。
バッチが期限切れになったら、それまでの処理は無駄になりますか
いいえ、完了した分は取得できます。課金の考え方も各社で共通で、モデルへ送られる前に期限切れになったリクエストは課金されず、完了した分のトークンは課金されます。OpenAIのドキュメントは、期限切れになると未完了のリクエストはキャンセルされ、完了済みリクエストの応答は出力ファイルから取得でき、完了したリクエストで消費されたトークンは課金される、としています。Anthropicは個々のリクエスト結果を4種類に分け、errored・canceled・expiredについては課金されないとしています。差が出るのは未処理分の知り方で、Anthropicはexpiredが結果JSONLの1行として返り、OpenAIはbatch_expiredのエラーとしてエラーファイルに書き出され、GeminiはJOB_STATE_EXPIREDになったジョブから結果を取得できません。取得できた分は取り込み、残りを未処理として次のバッチへ回すのが基本形です。そのために、入力IDごとの状態を持つ台帳を先に用意してください。
結果が投入した順に返ってこないのですが、バグですか
仕様です。Anthropicのドキュメントは、バッチ結果は任意の順序で返ることがあり作成時の順序と一致しないことがあるため、結果を正しく対応付けるには常にcustom_idを使うよう明記しています。OpenAIも出力行の順序が入力行の順序と一致しないことがあるとして、順序に依存せずcustom_idを使うよう案内しています。インデックス番号での突合は、小さなテストでは偶然通ってしまい、本番で静かに壊れます。
同じバッチを間違って2回投げてしまいました。二重課金になりますか
バッチAPI側に汎用の重複排除の仕組みがあるわけではないため、同じ内容を2回投入すれば2回処理されます。防ぐのはアプリ側の役割で、入力IDごとの状態を持つ台帳から未処理のものだけを選んで投入する形にしておけば、二重投入はそもそも起こりません。投入した事実を記録する処理を、APIを呼んだ直後に置くことも重要です。ここが抜けていると、記録前に落ちたときに同じ対象を再投入してしまいます。
低優先ティアとバッチAPIは、どちらを選べばよいですか
コードの書き換え量と、待てる時間で決めてください。OpenAIのFlex processingはservice_tierにflexを指定するだけで、呼び出し方は同期のままです。トークンはBatch APIのレートで課金され、応答が遅くなることとリソース不足時の429(この場合は課金されない)を受け入れる設計です。一方バッチAPIは投入と回収という別のフローになるため実装コストが上がりますが、数万件規模ではこちらのほうが管理しやすくなります。目安として、既存の同期処理を安くしたいだけなら低優先ティア、大量の一括処理を新しく作るならバッチAPIです。なおFlexはベータであり対応モデルも限られる旨がドキュメントに明記されているため、本番採用の前に必ず確認してください。
1バッチにできるだけ多く詰めたほうが効率的ですか
上限まで詰めることが最適とは限りません。1バッチあたりの上限はAnthropicが100,000リクエストまたは256MB、OpenAIが50,000リクエストで入力ファイル200MBとされていますが、大きなバッチほど期限切れになったときの被害も、再投入の単位も大きくなります。また、Anthropicには1バッチの上限とは別に、処理キューに同時に置けるバッチリクエスト数の制限がtierごとに定義されています。運用上は、回収と再投入の粒度として扱いやすいサイズに区切り、順に流すほうが安全です。
バッチの結果はいつまで取得できますか
公式に示されている期間は各社で異なります。Anthropicはバッチ結果が作成から29日間ダウンロード可能で、その後もバッチ自体は閲覧できるが結果はダウンロードできなくなるとしています。OpenAIは出力ファイルがバッチ完了から30日後に自動削除されるとしています。GeminiのBatch APIは結果が6週間ダウンロード可能とされています。いずれもプロバイダ側の保持期間に依存するため、必要な結果は回収したら自分側のストレージへ保存してください。なお、入出力の本文をどこにどれだけ保存してよいかは社内の情報管理ルールと契約条件によって変わります。個人情報や機密情報を含むデータを扱う場合は、各サービスのデータ保持に関する公式ドキュメントと社内規程を必ず確認してください。

まとめ

大量処理を同期APIでやらない、という判断そのものは難しくありません。難しいのは、非同期に寄せると決めたあとに何を作るかです。

判断は4軸で足ります。締切が時間単位まで許容できるか、件数が数千を超えるか、一部失敗を許容できるか、コスト差が手間に見合うか。締切だけが足切りで、残りは程度の問題です。そして寄せると決めたら、プロンプトより先に入力IDと台帳を設計します。結果は順不同で返り、一部は必ず失敗し、バッチは期限切れになりうる。この3つを前提にすれば、実装は「未処理を選ぶ・投入する・突き合わせて記録する」という単純な繰り返しに落ち着きます。

各社の仕様は共通点が多く、期限切れ時の課金の考え方も共通です。差が出るのは、未処理分をどの経路で知るかという部分結果の取り方と状態の表し方で、ここが台帳の設計に効いてきます。数値や挙動は改定されるため、採用時には必ず公式ドキュメントで現況を確認してください。この記事に書いた各社の記述も、確認できた範囲にとどめています。

あわせて読みたい

長文ドキュメントのLLM要約設計パターン|stuff・map-reduce・refine・抽出ハイブリッドの使い分け

出典・参考

この記事をシェア

関連する記事

開発・エージェント

LLM APIのレート制限とリトライ設計|429・タイムアウト・二重実行を本番で捌く

本番でLLM APIを叩き続けるための流量制御を整理します。RPM/TPMなど制限がかかる軸と単位、429・5xx・タイムアウトの切り分け、指数バックオフとジッタ、retry-afterの尊重とデッドライン設計、ストリーミングの3層タイムアウト、冪等性による二重実行対策、バッチAPIとキューでの平準化、監視指標と縮退運転までをまとめます。

開発・エージェント

LLMアプリの「遅い」を設計で潰す|TTFT・生成速度・総所要時間を分けて考えるレイテンシ設計

LLMアプリの体感速度をどう設計判断に落とすかを整理します。TTFT(最初のトークンまでの時間)・出力トークンの生成速度・総所要時間の切り分け、入力と出力のどちらがどの指標に効くか、推論トークンの扱い、効く順に並べた打ち手、逆効果になる打ち手、p50/p95での計測とSLOの決め方までを公式ドキュメントを確認しながらまとめます。