AIエージェントニュース編集部

Inngestで防ぐLLMの二重課金:途中再開とレート制御による耐障害性

LLM API を呼び出すマルチステップ処理は、タイムアウトや 429 レート制限で頻繁に失敗します。たとえば 5 ステップ中の 3 つ目で処理が落ちた場合、素朴にジョブ全体を再実行すると、成功済みの 1 つ目と 2 つ目のトークン課金が二重に発生し、外部 DB への書き込みも重複してしまいます。こうした無駄なコスト増や不整合を避けるには、どう設計すべきでしょうか。本記事では、Durable Execution 基盤の Inngest と TypeScript を使い、ステップ単位の状態永続化、自動リトライ、レート制限を組み込んだ LLM パイプラインの設計パターンを解説します。

LLMパイプライン運用で頻発する3大障害

LLMパイプラインの障害は、大きく次の 3 つに分類できます。

  1. タイムアウト: 長い出力や推論の重いモデルで応答が遅れます。サーバーレス環境では、関数の実行時間上限にも当たります。
  2. 429 レート制限: RPM や TPM の上限を超えると返ります。並列度を上げた時や、バッチ処理が重なった時に起きやすいです。
  3. 中間障害: 複数ステップの途中でプロセスが落ちる、デプロイで再起動する、後段の外部 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 つです。

  1. 副作用や LLM 呼び出しは step.run() に閉じ込め、失敗時は失敗したステップだけを再実行する。
  2. エラーを分類し、429 には RetryAfterError、再試行しても直らない 4xx には NonRetriableError を使い分ける。
  3. concurrency と throttle で 429 を起きにくくし、外部書き込みには冪等キーを付ける。

まずは、既存のパイプラインのうち最も課金が重いステップを step.run() で切り出し、リトライ設定を入れるところから試してください。ステップ分割の粒度は、コスト、再実行時の副作用、保存する中間データのサイズを基準に決めるのが現実的です。

関連記事