前の記事では、1万件のような大量のAI処理を、単純なforループ、Queue + Responses API、Batch APIのどれで実行するかを扱いました。
1万件をAIで処理したい。forループでAPIを呼び続ける設計でいいのか | kuromica
数千・数万件の独立したAI処理をどう実行するか。単純な逐次処理、Queue + Responses API、OpenAI Batch APIを比較し、rate limit、retry、途中失敗、サーバー再起動、料金まで含めて選び方を考えます。
https://kuromica.com/blog/openai-batch-api-large-scale-processing/
今回は件数ではなく、1件の処理が長い場合を考えます。
たとえば、Responses APIへ送った処理が5分かかるとします。
Application
↓ request
OpenAI API
↓ 5分間処理
Applicationへresponse
この5分の間にApplicationが再起動したら、どうなるでしょうか。
OpenAI側では処理が続いているのか。結果は消えるのか。もう一度同じrequestを送ったら二重実行にならないのか。Reverse Proxyのtimeoutやnetwork interruptionでも同じ問題が起きます。
長い処理を安定して扱うには、HTTP connectionを5分間どう維持するかだけを考えても足りません。
AIの処理とHTTP connectionを別のlifecycleとして扱い、後から状態と結果を取りに行ける形にする必要があります。
OpenAIのResponses APIには、そのためのBackground Modeがあります。ただし、background: trueを付ければ障害対策が完了するわけではありません。response IDをどこへ保存するか、Webhookを取りこぼしたらどうするか、同じ完了通知が2回来たらどうするかまでApplication側で決める必要があります。
この記事では、その部分を中心に見ていきます。
同期処理ではHTTP connectionとAI処理が同じ時間だけ続く
通常のrequestでは、Applicationはresponseが返るまでHTTP connectionを待ちます。
request開始
↓
AI処理
↓
AI処理
↓
AI処理
↓
response
短い処理なら特に問題ありません。
しかし処理時間が数分になると、AIモデル以外の事情で接続が切れる可能性が増えます。
- Application processの再起動
- deploy
- reverse proxyやload balancerのtimeout
- network interruption
- client disconnect
- serverless functionの実行時間制限
ここで厄介なのは、HTTP connectionが切れたことと、OpenAI側の処理が止まったことは同じではないという点です。
Applicationから見るとrequestが失敗したように見えても、OpenAI側ではすでにrequestを受理して処理している可能性があります。
その状態で単純に同じrequestを送り直すと、同じAI処理を2回実行することもあり得ます。
Background Modeでは先にresponse IDを受け取れる
Responses APIではbackground: trueを指定すると、長時間処理をBackground Modeで開始できます。
import OpenAI from "openai";
const openai = new OpenAI();
const response = await openai.responses.create({
model: "gpt-5.6",
input: "長い調査レポートを作成してください",
background: true,
store: true,
});
console.log(response.id);
console.log(response.status);
概念的には次の流れです。
Application
↓ background=true
OpenAI API
↓
response IDを返す
↓
HTTP connection終了
OpenAI側
↓
処理継続
↓
queued
↓
in_progress
↓
completed
Applicationはresponse IDを使って、後から状態を取得できます。
const response = await openai.responses.retrieve("resp_...");
console.log(response.status);
OpenAIの現在のAPI Referenceでは、Responseのstatusとして次の6種類が定義されています。
| status | 意味 |
|---|---|
queued | 実行待ち |
in_progress | 処理中 |
completed | 完了 |
failed | 失敗 |
cancelled | キャンセル済み |
incomplete | 処理は終了したが完全なResponseにならなかった |
pollingではqueuedまたはin_progressの間だけ待ち、それ以外へ変わったら最終状態として扱う形が分かりやすいです。
OpenAI公式のBackground Modeガイドでも、queued / in_progressの間はretrieveを繰り返し、終了後のstatusを確認する例が掲載されています。
Background mode | OpenAI API
Run long running tasks asynchronously in the background.
https://developers.openai.com/api/docs/guides/background
response IDはApplication memoryだけに置かない
Background Modeを使っても、response IDを変数に持っているだけでは再起動に耐えられません。
request送信
↓
resp_abc123 を取得
↓
Application memoryに保持
↓
process crash
↓
response IDを失う
そのため、response IDを受け取ったら早い段階で永続化します。
たとえば、次のようなrecordを持てます。
ai_jobs
id
openai_response_id
status
created_at
updated_at
completed_at
result_saved_at
Application側のjob IDとOpenAI側のresponse IDを分けて持っておくと、後から追いやすくなります。
ai_jobs.id
job_20260830_001
openai_response_id
resp_abc123
処理開始時の流れは、次のようになります。
自分のDBにjobを作成
↓
OpenAIへbackground request
↓
response ID取得
↓
jobへresponse ID保存
↓
request元へjob IDを返す
ここで保存するstatusはOpenAIのstatusをそのままコピーしてもよいですし、自分のApplication用にsubmitted、running、completedなどへ変換しても構いません。
大切なのは、再起動後に「未完了のjobはどれか」をDBから見つけられることです。
再起動後はreconciliationで状態を合わせ直す
Applicationが落ちても、DBにresponse IDが残っていれば復旧できます。
たとえば起動時やcronで、未完了jobを探します。
Application再起動
↓
DBからunfinished jobを取得
↓
openai_response_idを読む
↓
Responses APIへretrieve
↓
最新statusをDBへ反映
このように、自分のDBと外部サービスの状態を後から照合して合わせ直す処理をreconciliationと呼びます。
簡略化すると、次のような実装です。
以下は構成を示す例で、DB部分はApplicationごとに置き換える必要があります。
async function reconcileAiJob(jobId: string) {
const job = await db.aiJobs.findById(jobId);
if (!job?.openaiResponseId) {
return;
}
const response = await openai.responses.retrieve(
job.openaiResponseId,
);
await db.aiJobs.update(job.id, {
status: response.status,
updatedAt: new Date(),
});
if (
response.status === "completed" &&
job.resultSavedAt === null
) {
await saveResponseResult(job.id, response);
await db.aiJobs.update(job.id, {
resultSavedAt: new Date(),
completedAt: new Date(),
});
}
}
実際にはfailed、cancelled、incompleteも分けて扱います。
このreconciliationがあると、Webhookを取りこぼしても、Applicationが数分止まっても、DBに残したresponse IDから状態を追い直せます。
Webhookは「完了したら呼んでもらう」仕組み
pollingだけでもBackground Modeは使えます。
every 1 minute
↓
unfinished jobsを取得
↓
Responses APIへretrieve
↓
DB更新
job数が少なければ、この方法はかなり単純です。
一方、jobが増えると、まだ終わっていないResponseへ何度も問い合わせることになります。
そこでWebhookを使うと、OpenAI側で状態が変わったタイミングにApplicationへ通知できます。
OpenAIのWebhookではBackground Responseに対して、response.completed、response.failed、response.incomplete、response.cancelledなどのeventを受け取れます。
OpenAI
↓ response.completed
Webhook endpoint
↓
response ID取得
↓
Responses APIからretrieve
↓
DB更新
Webhookのpayloadにはresponse IDが含まれます。完了通知を受け取ったら、そのIDを使って最新のResponseをretrieveできます。
OpenAIの公式例も、response.completedを受けた後にresponses.retrieve()でResponseを取得する形です。
Webhooks | OpenAI API
https://developers.openai.com/api/docs/guides/webhooks
Webhookがあってもpollingを完全には捨てない
Webhookは便利ですが、Webhookだけを唯一の復旧手段にすると別の弱点ができます。
たとえばApplicationが停止している間にWebhookが届けば、その場では受け取れません。
OpenAIの現在の仕様では、Webhook endpointが数秒以内に成功の2xxを返さなかった場合、指数バックオフで最大72時間まで再送されます。
それでも、次のようなケースは残ります。
Application停止が72時間を超えた
endpoint URLの設定を間違えた
署名検証の設定が壊れていた
Application bugでeventを保存できなかった
そのため、Webhookとreconciliationを組み合わせる方が復旧しやすくなります。
通常時
OpenAI
↓ Webhook
Application
↓
DB更新
取りこぼし時
cron / startup
↓
unfinished jobs
↓
Responses APIへretrieve
↓
DB更新
Webhookは「状態が変わったので確認してください」というsignalとして使い、DBに残っている未完了jobは別経路でも確認できるようにします。
少量ならpollingだけでも十分
Webhookを使わないと古い設計になるわけではありません。
たとえば同時に数件から数十件しかBackground Responseが動かないサービスなら、1分ごとのpollingだけでも十分なことがあります。
pollingなら、Webhook用に必要になる次の処理がありません。
- 公開endpointの用意
- signature verification
- duplicate deliveryの処理
- Webhookのlocal development環境
- event受信用のQueueやtable
一方、job数が増えると、未完了Responseへ問い合わせ続けるAPI callも増えます。
その場合は、
Webhook
+
低頻度のreconciliation
にすると、普段は通知を使いながら、取りこぼしだけ後から直せます。
Webhookは同じeventが複数回来る可能性がある
OpenAIのWebhookガイドには、まれに同じWebhook eventが重複して配信される可能性があると明記されています。
そのため、次のような実装は危険です。
response.completed
↓
メール送信
同じeventが2回来ると、
Webhook #1
↓
メール送信
Webhook #2
↓
メール送信
となる可能性があります。
OpenAIは重複排除にwebhook-id headerをidempotency keyとして使えると案内しています。
たとえば、受信済みWebhookを次のように保存します。
webhook_events
webhook_id
event_type
received_at
processed_at
同じwebhook_idがすでに存在すれば、後から届いた同一eventは処理しません。
Webhook受信
↓
webhook-id確認
↓
未処理なら保存
↓
処理開始
すでに存在
↓
何もしない
DB側でwebhook_idへUNIQUE constraintを付けておくと、Application instanceが複数ある場合にも重複を止めやすくなります。
Webhookのbusiness logicは2xxを返す前に全部実行しない
Webhook endpointで次の処理を全部行う実装も考えられます。
Webhook受信
↓
署名検証
↓
Responses APIへretrieve
↓
DB更新
↓
メール送信
↓
2xx
しかし処理が長くなるほど、OpenAI側のWebhook timeoutに近づきます。
公式ガイドでは、Webhook endpointは速やかに2xxを返し、時間のかかる処理はbackground workerへ渡すことが推奨されています。
実装するなら、次のような形が扱いやすいです。
Webhook受信
↓
署名検証
↓
webhook eventをDB / Queueへ永続化
↓
2xx
↓
別workerがevent処理
ここで順番も重要です。
もし、
Webhook受信
↓
2xx
↓
DB保存
とすると、2xxを返した直後にprocessが落ちた場合、OpenAI側では配信成功になりますが、自分のApplicationにはeventが残らない可能性があります。
そのため、少なくともeventを再処理できる場所へ保存してから2xxを返します。
Webhook署名は検証する
Webhook endpointはInternetから到達できるURLです。
受け取ったJSONをそのまま信頼すると、第三者がOpenAIのWebhookを装ったrequestを送る可能性があります。
OpenAI SDKにはWebhook署名を検証するためのwebhooks.unwrap()が用意されています。
以下は公式ガイドの流れに沿った最小例です。
import OpenAI from "openai";
import express from "express";
const openai = new OpenAI({
webhookSecret: process.env.OPENAI_WEBHOOK_SECRET,
});
const app = express();
app.use(
"/webhook",
express.text({ type: "application/json" }),
);
app.post("/webhook", async (req, res) => {
try {
const event = openai.webhooks.unwrap(
req.body,
req.headers,
);
const webhookId = req.header("webhook-id");
if (!webhookId) {
return res.sendStatus(400);
}
await persistWebhookEvent({
webhookId,
event,
});
return res.sendStatus(200);
} catch (error) {
if (
error instanceof OpenAI.InvalidWebhookSignatureError
) {
return res.sendStatus(400);
}
console.error(error);
return res.sendStatus(500);
}
});
persistWebhookEvent()はApplication固有の処理です。DB側ではwebhook_idをUNIQUEにし、同じeventを二重登録しないようにします。署名不正は400、DB障害などでeventを保存できなかった場合は500を返せば、OpenAI側のretry対象になります。
AI jobの完了と、その後の処理を分ける
AI処理の結果をDBへ保存するだけなら、同じResponseをもう一度retrieveしても大きな問題にならないことがあります。
しかしAI結果をきっかけに別の操作をすると、重複実行が問題になります。
たとえば、
AI処理完了
↓
請求書を作成
や、
AI処理完了
↓
ユーザーへメール送信
です。
Webhookのduplicate deliveryだけでなく、reconciliationも同じcompleted jobを見つける可能性があります。
そこで、AI jobの完了とbusiness side effectを分けます。
OpenAI response.completed
↓
AI resultを保存
↓
job status = completed
↓
side effect用jobを作成
↓
メール送信 / 外部API更新
side effect側には、AI job IDなどを使ったidempotency keyを持たせます。
たとえばメール通知なら、
notification_jobs
id
ai_job_id
type
status
sent_at
UNIQUE(ai_job_id, type)
のようにして、同じAI jobから同じ種類の通知を1回しか作れないようにできます。
Webhook自体が1回だけ届くことを期待するより、同じ処理が再度呼ばれても結果が壊れない形にする方が安全です。
「OpenAIは受理したがresponse IDを受け取れなかった」が最も難しい
Background Modeでも完全には消えない問題があります。
次のケースです。
Application
↓ request送信
OpenAI
↓ request受理
↓ response ID生成
network interruption
↓
Applicationはresponse IDを受け取れない
Applicationから見るとrequestは失敗しています。
しかしOpenAI側では処理が始まっている可能性があります。
ここで同じrequestを再送すると、最初の処理と再送した処理の両方が走る可能性があります。
2026年8月30日時点で確認できるResponses APIの公式ドキュメントでは、この曖昧な状態を一般的なIdempotency-Keyで完全に解消する方法は確認できませんでした。
そのため、少なくともApplication側では次の前提を持つ方がよいです。
- request送信結果が不明な場合、AI inferenceが二重実行される可能性を残す
- Application側のjob IDを
metadataなどへ入れて追跡しやすくする - AI結果を使ったside effectは必ず重複排除する
- 「同じAI処理が2回走ること」と「請求書やメールが2回作られること」を分ける
metadataへApplication側のjob IDを入れる例は次のようになります。
const response = await openai.responses.create({
model: "gpt-5.6",
input: "長い処理",
background: true,
store: true,
metadata: {
job_id: "job_20260830_001",
},
});
ただし、metadataを入れれば失ったresponse IDを必ず検索して復旧できる、という意味ではありません。
response IDを受け取れなかったrequestは「結果が分からないrequest」として扱い、後段のside effectを安全にする方が現実的です。
retentionを知らないと「後から取れる」が成立しない
Background Responseは永久に残るわけではありません。
OpenAIのData Controlsでは、Responses APIのApplication Stateは原則として30日間保持されます。
ただしBackground Modeには注意点があります。
2026年8月30日時点の公式ドキュメントでは、Background requestでstoreを省略するかfalseにすると、非同期実行とpollingのため一時的にdiskへ保存されますが、その期間はおよそ10分です。temporary polling periodの後はResponseが削除されます。
そのため、長時間後の復旧を想定するなら、保持要件を確認した上でstore: trueを明示する必要があります。
const response = await openai.responses.create({
model: "gpt-5.6",
input: "長い処理",
background: true,
store: true,
});
Zero Data Retentionが有効な組織ではstoreは常にfalseとして扱われます。Background Modeでも非同期処理とpollingのため約10分は一時保存されますが、通常の30日保存を前提にはできません。
保存期間はデータ要件にも関わるため、実装前に現在のData Controlsを確認した方が安全です。
Data controls in the OpenAI platform
Your data is your data. An overview of how OpenAI uses your data, including retention and usage policies.
https://developers.openai.com/api/docs/guides/your-data
Background ModeとStreamingは二者択一ではない
Streamingは、tokenやeventを処理途中から受け取るための仕組みです。
Background Modeは、処理そのものをHTTP connectionから切り離して継続できるようにする仕組みです。
役割が違います。
Streaming
処理中のoutputを順次受け取る
Background Mode
接続が切れてもResponseの処理を継続する
現在のResponses APIでは、background: trueとstream: trueを同時に指定できます。
さらに、Background Responseを最初からstream: trueで作成していれば、streamが途中で切れた後にsequence_numberをcursorとして再開できます。
const stream = await openai.responses.create({
model: "gpt-5.6",
input: "長い処理",
background: true,
stream: true,
});
ただし、単に完成した結果だけ欲しいのであれば、Background Mode + Webhookまたはpollingの方が実装は単純です。
リアルタイム表示も必要な場合に、Background + Streamingを検討する形で十分でしょう。
Background Modeと自前Queueは役割が違う
Background Modeを使うと、自前Queueが不要になるようにも見えます。
しかしQueueが扱う範囲はもっと広いです。
Application Queue
↓
どのjobをいつ実行するか
priority
rate control
retry schedule
concurrency
↓
Worker
↓
Responses API background=true
Background Modeは、1つのResponses API requestの処理をHTTP connectionから切り離します。
一方、Queueは「どのjobをいつOpenAIへ送るか」を管理します。
そのため、次の構成も普通に成立します。
Producer
↓
Application Queue
↓
Worker
↓
Responses API
background=true
↓
response ID保存
大量のrequestを制御したい場合はQueueが必要になることがありますし、1件の長い処理を接続切断から守りたいだけならBackground Modeだけで足りることもあります。
Batch APIとは対象にしている問題が違う
前の記事で扱ったBatch APIも非同期処理ですが、Background Modeとは目的が違います。
| 方式 | 向いている処理 |
|---|---|
| Background Mode | 1件の長時間Response |
| Batch API | 大量の独立requestをoffline処理 |
| Streaming | 生成途中のoutputをすぐ表示 |
| Queue | jobの実行順、priority、retry、concurrency管理 |
たとえばDeep Researchのように1件が数分かかり、完成した結果だけ欲しいならBackground Modeが候補になります。
10万件の文章分類ならBatch APIの方が自然です。
チャットUIでtokenが生成された瞬間から表示したいならStreamingを使います。
処理の優先順位やretry timingまで自分で管理したいならQueueを組み合わせます。
サーバーレス環境では「processが生き続ける前提」を外せる
Cloudflare Workers、AWS Lambda、Vercel Functionsなどでは、1つのprocessを長時間動かし続ける設計は環境ごとの制約を受けやすくなります。
Background Modeなら、Application側はresponse IDを保存した後に処理を終えられます。
HTTP request受信
↓
OpenAI background job作成
↓
response ID保存
↓
Application側のrequest終了
OpenAI側で処理継続
↓
Webhook / polling
↓
結果保存
この構成では、AI処理が5分かかるからApplication processも5分生き続ける、という前提がなくなります。
サーバーレス固有のtimeout値を覚えることより、process lifecycleとAI processing lifecycleを分けられることの方が設計上は大きな利点です。
最小構成から始めるならどうするか
数分かかるAI処理を初めて非同期化するなら、最初から複雑なイベント基盤を作る必要はありません。
job数が少ないなら、次の構成でも十分です。
1. DBにai_jobを作る
2. background=trueでResponses APIを呼ぶ
3. response IDをDBへ保存する
4. 1分ごとにunfinished jobをpollingする
5. completedなら結果を保存する
jobが増えてpolling量が気になってきたらWebhookを追加します。
Webhook
↓
普段の完了検知
reconciliation
↓
取りこぼしの復旧
外部通知や請求処理などが増えたら、AI jobの完了処理とside effectを分けます。
AI job
↓ completed
result保存
↓
side effect job
↓
メール / 外部API / 請求処理
この順で足していけば、最初から必要以上に複雑な構成にしなくて済みます。
まとめ
AI処理中にApplicationが落ちても、Background Modeで開始したResponseまで必ず消えるわけではありません。
response IDを永続化していれば、Application再起動後にResponses APIへretrieveして状態と結果を回収できます。
ただし、安定した非同期処理にするにはBackground Modeだけでは足りません。
Background Mode
↓
AI処理をHTTP connectionから切り離す
response ID + DB
↓
再起動後に追跡できる
Webhook
↓
状態変化を早く知る
polling / reconciliation
↓
通知の取りこぼしを復旧する
idempotency
↓
重複eventや再処理でside effectを二重実行しない
OpenAIのWebhookは最大72時間再送されますが、duplicate deliveryもあり得ます。webhook-idによる重複排除と、未完了jobを後から確認するreconciliationを残しておくと、どちらか一方へ依存しすぎずに済みます。
もう一つ注意したいのがretentionです。Background Modeでstoreを省略またはfalseにした場合、後からretrieveできる期間は約10分のtemporary polling periodに限られます。長時間後の復旧が必要なら、データ保持要件とstoreの設定を先に確認しておく必要があります。
AI APIだから特別な障害設計が必要というより、外部の非同期サービスを扱うときと同じです。
「接続が切れないようにする」より、「接続が切れても状態を追い直せる」構成にしておくと、Applicationの再起動やdeployを通常の運用として扱いやすくなります。
参考
Background mode | OpenAI API
Run long running tasks asynchronously in the background.
https://developers.openai.com/api/docs/guides/background
Webhooks | OpenAI API
https://developers.openai.com/api/docs/guides/webhooks
Data controls in the OpenAI platform
Your data is your data. An overview of how OpenAI uses your data, including retention and usage policies.
https://developers.openai.com/api/docs/guides/your-data