Inngestで防ぐLLMの二重課金:途中再開とレート制御による耐障害性
LLM API を呼び出すマルチステップ処理は、タイムアウトや 429 レート制限で頻繁に失敗します。たとえば 5 ステップ中の 3 つ目で処理が落ちた場合、素朴にジョブ全体を再実行すると、成功済みの 1 つ目と 2 つ目のトークン課金が二重に発生し、外部 DB への書き込みも重複してしまいます。こうした無駄なコスト増や不整合を避けるには、どう設計すべきでしょうか。本記事では、Durable Execution 基盤の Inngest と TypeScript を使い、ステップ単位の状態永続化、自動リトライ、レート制限を組み込んだ LLM パイプラインの設計パターンを解説します。
LLMパイプライン運用で頻発する3大障害
LLMパイプラインの障害は、大きく次の 3 つに分類できます。
- タイムアウト: 長い出力や推論の重いモデルで応答が遅れます。サーバーレス環境では、関数の実行時間上限にも当たります。
- 429 レート制限: RPM や TPM の上限を超えると返ります。並列度を上げた時や、バッチ処理が重なった時に起きやすいです。
- 中間障害: 複数ステップの途中でプロセスが落ちる、デプロイで再起動する、後段の外部 API が 5xx を返すといったケースです。
特に厄介なのは 3 つ目です。素朴にキューのジョブ全体を再試行すると、成功済みの LLM 呼び出しまでやり直します。コストが増え、LLM の出力は非決定的なので、1 回目と 2 回目で結果が変わることもあります。必要なのは「失敗したステップだけを再実行し、成功済みの結果は再利用する」仕組みです。
Durable Executionとは:ステップ単位の状態永続化
Durable Execution は、処理の進行状況を永続化し、障害後に途中から再開できる実行モデルです。Inngest では、関数内の step.run() が 1 つの再実行単位になります。ステップが成功すると、その戻り値が Inngest 側に保存されます。関数が再度呼び出されても、完了済みのステップは実行されず、保存済みの結果が返ります(公式ドキュメントでは memoization と説明されています)。
この仕組みには、設計上の前提が 3 つあります。
- ステップの戻り値は JSON シリアライズ可能である必要があります。サイズにも上限があるため、巨大な本文はオブジェクトストレージに置き、ステップ間では参照だけを渡します。
step.run()の外側のコードは、再呼び出しのたびに実行されます。LLM 呼び出しや DB 書き込みなどの副作用は、必ずステップの中に入れます。- ステップごとに別の HTTP リクエストとして実行される構成なので、1 回の実行時間上限はステップ単位で効きます。処理全体が長くても、各ステップが上限内に収まれば動きます。
実行モデルの詳細や上限値は SDK のバージョンやプランで変わるため、導入前に公式ドキュメントの Limits のページを確認してください。
実践:TypeScriptで組むマルチステップLLM処理と自動リトライ設計
ドキュメント要約パイプラインを例にします。取得、要約、分類、保存の 4 ステップです。以下は Inngest TypeScript SDK の v3 系の書き方です。バージョンによって createFunction の引数構成が異なる場合があるので、利用中のバージョンの公式ドキュメントと照らして読み替えてください。
import { Inngest, NonRetriableError, RetryAfterError } from "inngest";
export const inngest = new Inngest({ id: "llm-pipeline" });
export const summarizeDoc = inngest.createFunction(
{
id: "summarize-doc",
retries: 5,
idempotency: "event.data.documentId",
},
{ event: "doc/uploaded" },
async ({ event, step }) => {
const text = await step.run("fetch-document", () =>
loadDocumentText(event.data.documentId)
);
const summary = await step.run("summarize", () =>
callLlm({ task: "summarize", input: text })
);
const category = await step.run("classify", () =>
callLlm({ task: "classify", input: summary })
);
await step.run("save-result", () =>
saveResult({
documentId: event.data.documentId,
summary,
category,
// 書き込み側の冪等キーとして使う
idempotencyKey: `${event.data.documentId}:v1`,
})
);
return { category };
}
);
classify が失敗しても、再試行されるのは classify だけです。summarize の結果は保存済みなので、要約の課金は二重になりません。ステップ名は保存結果との対応に使われるため、運用中に安易にリネームしないでください。
リトライの設計では、エラーの種類ごとに挙動を分けます。429 はサーバーが示す待機時間に従い、入力不正のように再試行しても直らないエラーは即座に打ち切ります。
async function callLlm(params: { task: string; input: string }) {
const res = await fetch(process.env.LLM_ENDPOINT!, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(params),
signal: AbortSignal.timeout(60_000),
});
if (res.status === 429) {
const sec = Number(res.headers.get("retry-after") ?? 30);
throw new RetryAfterError("rate limited", `${sec}s`);
}
if (res.status >= 400 && res.status < 500) {
throw new NonRetriableError(`client error: ${res.status}`);
}
if (!res.ok) {
throw new Error(`upstream error: ${res.status}`); // 通常のリトライ対象
}
return (await res.json()).text as string;
}
AbortSignal.timeout でクライアント側のタイムアウトを明示しておくと、ハングした呼び出しをリトライの流れに乗せられます。RetryAfterError と NonRetriableError は、どちらも inngest パッケージが提供しています。デフォルトの再試行は指数バックオフ的に間隔が空くので、5xx には通常の Error を投げるだけで足ります。
なお、保存のような外部副作用は、at-least-once の実行になる前提で設計します。ステップが成功する直前にプロセスが落ちると、同じステップが再実行されるためです。書き込み側に冪等キーを渡すか、upsert にしておくのが安全です。
APIクォータ超過を防ぐ:concurrencyとthrottle設定
リトライだけでは、429 を「起きてから直す」ことしかできません。Inngest では関数設定で、起きにくくする制御を宣言できます。似た名前の機能が 3 つあるので、役割を整理します。
- concurrency: 同時に実行されるステップ数の上限です。
keyを指定すると、テナントやモデル単位で上限を分けられます。 - throttle: 一定期間に開始される実行数を制限します。超過分は捨てずにキューで待機します。
- rateLimit: 期間内の上限を超えたイベントを破棄します。重複トリガーの抑止向きで、LLM のクォータ保護には向きません。
LLM のクォータ保護では、concurrency と throttle を組み合わせるパターンがよく使われます。
{
id: "summarize-doc",
retries: 5,
concurrency: [
{ limit: 20 }, // 全体の同時実行
{ limit: 3, key: "event.data.tenantId" }, // テナント単位の公平性
],
throttle: {
limit: 60,
period: "1m",
key: "event.data.tenantId",
},
}
注意点が 1 つあります。throttle は関数実行の開始レートを制御するもので、1 回の実行内で LLM を複数回呼ぶ場合、API 側の RPM は実行数の倍数で消費されます。1 実行あたり 3 回呼ぶなら、throttle の limit は API 上限の 3 分の 1 を目安に設定します。TPM が制約になる場合は、リクエスト数ではなくトークン量で効くため、余裕を持たせて値を決め、ダッシュボードで 429 の発生率を見ながら調整してください。
また、複数の関数が同じ API キーを共有する場合、関数ごとの設定では合算の上限を守れません。その場合は、LLM 呼び出しを専用の関数に切り出し、そこに concurrency を集約する構成も検討に値します。
まとめ:壊れやすいLLM呼び出しをプロダクション品質の基盤に変える
押さえるべきポイントは 3 つです。
- 副作用や LLM 呼び出しは
step.run()に閉じ込め、失敗時は失敗したステップだけを再実行する。 - エラーを分類し、429 には
RetryAfterError、再試行しても直らない 4xx にはNonRetriableErrorを使い分ける。 - concurrency と throttle で 429 を起きにくくし、外部書き込みには冪等キーを付ける。
まずは、既存のパイプラインのうち最も課金が重いステップを step.run() で切り出し、リトライ設定を入れるところから試してください。ステップ分割の粒度は、コスト、再実行時の副作用、保存する中間データのサイズを基準に決めるのが現実的です。


