遅い生成にも耐えるLLM APIタイムアウト戦略
単一の固定タイムアウトがLLM呼び出しを壊す理由と、接続・初回トークン・アイドルの各期限を設定し、ストリーミングで生存を保ち、AbortControllerでクリーンにキャンセルする方法。
普段は3秒で答えるチャットモデルへのリクエストが、まれに90秒かかることがあります。クライアント全体に単一の雑な30秒タイムアウトを設定すれば、遅いが正しい生成を打ち切る一方で、本当にスタックしたものには何の役にも立ちません。解決策はより大きな数値ではなく、それぞれ異なるものを測る複数の期限です。すなわち、接続を待つ時間、最初のトークンを待つ時間、そしてトークンが流れ始めてから沈黙を許容する時間です。この記事では、その階層化されたタイムアウトの構築方法、なぜストリーミングが計算を変えるのか、そして期限やユーザーの「止めて」の一言で作業を即座にキャンセルする方法を示します。
LLMのレイテンシは特殊であり、それが問題の根源です。通常のREST呼び出しは平均値の周辺に密集したレイテンシ分布を持つため、平均の数倍上に固定したタイムアウトで安全です。モデル生成のレイテンシはヘビーテールであり、クライアントからは見えないものに依存します。モデルが選択する出力長、単語を書く前に隠れたトークンを費やす推論モデルかどうか、上流のキューイング、コールドルーティングなどです。中央値に合わせたタイムアウトはテールを切断し、テールに合わせたタイムアウトは本当にハングしたリクエストが接続とクォータをあまりにも長く保持することを許してしまいます。
タイムアウトがカバーしようとしている期限を分離する
「タイムアウト」は少なくとも4つの独立した上限を指す一語です。それらを個別に名付けることが、まさにコツのすべてです。
- 接続タイムアウト — TCP/TLS接続の確立にかかる上限。これは短くすべきです(数秒程度)。接続が遅いのはネットワークやDNSの問題であり、モデルが遅いのではありません。
- 初回トークンまでの時間(TTFT)の期限 — モデルが応答を開始するまで待つ時間。推論モデルでは、何かを出力する前に考える場合があるため、これが正当に数十秒に達します。
- アイドル / トークン間タイムアウト — ストリームが開始したら、チャンク間で許容する最大の間隔。健全なストリームは継続的にトークンを送信します。長い沈黙は上流が停止したことを意味します。
- 合計 / 実時間の期限 — 病的なケースが無制限に走らないようにするための、リクエスト全体に対する絶対的な上限。
単一の固定タイムアウトはこの4つすべてを1つの数値に潰してしまうため、どれもうまく機能しません。長時間生成の信頼性は、寛大なTTFTと合計予算を設定しつつアイドルタイムアウトをタイトに保つことから生まれます。そうすればストールでは素早く失敗しつつ、本当の作業中には辛抱強く待てます。
ストリーミングは硬い合計タイムアウトを柔らかいアイドルタイムアウトに変える
できる最も効果的な変更はストリーミングです。stream: true を送ると、モデルは応答全体をバッファリングする代わりにServer-Sent Eventsでトークンを返し、「応答全体がN秒以内に完了したか」を測るのをやめ、「次のチャンクがN秒以内に到着したか」を測り始めます。これは問題をあなたに有利な形に反転させます。5,000トークンの回答はもはや合計時間の打ち切りのリスクを負いません。各チャンクがアイドルクロックをリセットするからです。まだストリーミングクライアントを構築していないなら、SSEによるLLMレスポンスのストリーミングがワイヤーフォーマットとパースを扱っています。ここでは、それが解き放つタイミング挙動だけに注目します。
fetch ストリームに対するアイドルタイムアウトのロジックは次のようになります。すべてのチャンクでリセットされ、本当の沈黙の間だけ発火するウォッチドッグです。
async function streamWithIdleTimeout(url, body, { idleMs = 20000 } = {}) {
const controller = new AbortController();
let timer = setTimeout(() => controller.abort(new DOMException("idle", "TimeoutError")), idleMs);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer AVE_GATEWAY_API_KEY" },
body: JSON.stringify({ ...body, stream: true }),
signal: controller.signal,
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
clearTimeout(timer); // a chunk arrived: reset the watchdog
timer = setTimeout(() => controller.abort(new DOMException("idle", "TimeoutError")), idleMs);
process.stdout.write(decoder.decode(value, { stream: true }));
}
} finally {
clearTimeout(timer);
}
}
アイドル予算(20秒の沈黙)が合計時間から切り離されていることに注目してください。リクエストはトークンを生成し続ける限り何分でも走れますが、本物のストールは1つのアイドルウィンドウ内で発火します。それこそ、固定の合計タイムアウトでは表現できない挙動です。
AbortControllerでキャンセルし、エラー名を読む
すべてのタイムアウトは実際に作業を停止する仕組みを必要とし、ブラウザや最新のランタイムではその仕組みが AbortController です。その signal を fetch に渡し、abort() を呼ぶと接続が切断されます。コントローラーを自分で組まずに単純な実時間上限を設けたい場合、AbortSignal.timeout(ms) は指定したミリ秒後に自身を中断するシグナルを返します。
// Hard 60s cap on a non-streaming call:
const res = await fetch(url, { method: "POST", signal: AbortSignal.timeout(60000), /* ... */ });
2つのキャンセル経路は異なるエラーを報告し、それらを区別することでリトライすべきかどうかを決められます。手動の controller.abort() は AbortError という名前の DOMException で fetch を拒否し、AbortSignal.timeout() からのシグナルは TimeoutError という名前で拒否します。「ユーザーが停止を押した」と「硬い期限」を組み合わせるには、AbortSignal.any([userSignal, AbortSignal.timeout(60000)]) でシグナルをマージし、err.name で分岐します。
try {
await callModel();
} catch (err) {
if (err.name === "TimeoutError") retryOrEscalate(); // our deadline fired
else if (err.name === "AbortError") stopQuietly(); // the user cancelled
else throw err; // a real failure
}
ユーザーのキャンセルをリトライすべき失敗として扱うのは古典的なバグです。ユーザーが明示的に殺したリクエストを再発火し、トークン代を払い、メトリクスを汚染します。名前で分岐しましょう。
サーバー側には独自のタイムアウトがあり、そちらが勝つ
クライアントがプロバイダーに直接ではなくゲートウェイや自前のバックエンドと通信する場合、そのホップは独自の期限を課し、経路上で最も短いものが結果を決めます。よくある失敗は、中間プロキシが60秒で接続を切断しているのにクライアントが辛抱強く5分待つ、あるいはレスポンスバッファリングが最初のバイトを上限を超えて遅らせたために「ハング」したストリーミング応答をロードバランサーのアイドルタイムアウトが閉じる、といったものです。次の3つのサーバー側ルールがこれを防ぎます。
- キャンセルを上流に伝播する。 クライアントが切断したら、プロバイダーに対して行ったリクエストを中断します。キャンセルはベストエフォートであり、切断後もしばらくはプロバイダーが生成と課金を続ける可能性があります。それでも中断を伝播して初めて、誰も読まないトークンへの支出を止められる可能性が生まれます。これは
AbortControllerのサーバー側の鏡像です。 - ストリーム応答をバッファリングしない。 転送前にボディを蓄積するプロキシは最初のバイトを遅らせ、フラッシュされたストリームなら決して発火しなかったはずの上流アイドルタイムアウトを発火させかねません。
- 期限をランタイムに渡す。 Goでは
context.Contextの期限が慣用的な運び手です。期限が切れると、送出中のHTTPリクエストがキャンセルされ、リソースが解放されます。どの言語にも等価物があります。要点は、タイムアウトがラッパーだけでなくソケットに届かなければならないということです。
ゲートウェイは一貫したポリシーを強制する自然な場所です。すでにクライアント接続を終端し、プロバイダーの資格情報を保持しているからです。各上流をその背後に登録すれば、どのプロバイダーがリクエストを処理しても1つの期限と1つのキャンセル経路を適用でき、中断前に実際に消費されたトークンも使用ログに記録されるため、キャンセルされた呼び出しを正直に計上できます。ただしローカルのカウントはベストエフォートです。最終的な上流の使用量が届かなければ推定値になることがあるため、課金上重要なものはプロバイダー自身の使用量と照合してください。
タイムアウトとリトライは2つではなく1つのシステム
発火したタイムアウトはリトライロジックへの信号であり、両者は一緒に設計しなければ互いに衝突します。次の2つのルールがよくある病理を防ぎます。
- リトライが意味を持つのは高速な失敗の後だけ。 合計タイムアウトが長く、期限切れでリトライすると、単一の遅いリクエストが背中合わせに積み重なった複数の遅いリクエストになり、レイテンシとコストを倍増させます。1試行あたりの期限を、リトライが待ち続けるより安上がりになるほどタイトに保ち、試行回数に上限を設けましょう。
- タイムアウトしたリクエストは実行済みかもしれない。 接続を切ったとき、プロバイダーはあなたが読まなかった応答をすでに生成しているかもしれません。盲目的にリトライすると二重課金や副作用の重複が起こりえます。タイムアウトの後のリトライが安全になるよう、タイムアウトを冪等性戦略と組み合わせましょう。その仕組みは LLM APIのリトライと冪等性 にあります。
タイムアウトはレート制限とも相互作用します。タイムアウトの連発の後に即座のリトライが続くと、上流の視点からは負荷スパイクに見え、429 レスポンスに追い込まれかねません。期限をバックプレッシャーと協調させること、つまり殴り続けるのではなくキューに入れることは、LLM APIのレート制限とバックプレッシャー で扱っています。単一のリクエストではなくプロバイダー全体が遅い場合、正しい手はしばしばトラフィックを移すことです。LLM APIのフェイルオーバー戦略 を参照してください。
推論モデルにはより長い忍耐予算が必要
推論モデルはタイミングのプロファイルを十分に変えるため、独自の数値に値します。可視の出力を生成する前に隠れた推論トークンを費やすため、モデルが完璧に働いていてもTTFTが長くなることがあり、高速なチャットモデル向けに較正したTTFT期限では早すぎるうちに終了させてしまいます。2つの調整が役立ちます。推論モデルのルートではTTFTと合計予算を引き上げること、そしてストリーミングを優先し、可視トークンが始まった後は合計上限ではなくアイドルタイムアウトが支配するようにすることです。推論パラメーターは別に調整し、LLMコンテキストウィンドウ管理 の方針に従って隠れたトークンのための十分な余裕を確保してください。
FAQ
LLM API呼び出しにはどのタイムアウト値を使うべきですか?
単一の値は存在せず、それこそが階層化戦略の要点です。短い接続タイムアウト(数秒程度)、最も遅いモデルファミリーに合わせた寛大なTTFT期限、ストリーム応答向けのタイトなアイドルタイムアウト(数十秒の沈黙)、そして最後の砦としての絶対的な合計上限を使いましょう。1つの数値を調整すると、テールの正しさとストール検出をトレードオフせざるを得なくなります。期限を分離すれば両方を手に入れられます。
期限によるタイムアウトとユーザーのキャンセルをどう区別しますか?
エラーの name を確認します。AbortSignal.timeout() からのシグナルは TimeoutError で拒否し、手動の controller.abort() は AbortError で拒否します。TimeoutError ではリトライまたはエスカレーション、AbortError では静かに停止、それ以外はすべて再スローします。両者を混同すると、ユーザーが意図的に殺したリクエストをリトライしてしまいます。
リクエストを中断すると、実際にプロバイダーの課金は止まりますか?
キャンセルがプロバイダーに届いた場合のみで、それでもベストエフォートです。ブラウザでは controller.abort() があなたのサーバーへの接続を閉じます。次にサーバーが自身の上流リクエストも中断しなければ、生成が続き、誰も読まないトークンの代金を請求される可能性があります。すべてのホップを越えてキャンセルを伝播して初めて、中断が実際のコスト管理として働く可能性が生まれます。ローカルのトークン数は推定値になり得るため、プロバイダー自身の使用量または請求書と照合して確認してください。
なぜストリーミング応答が、ストリーミングせずに完了するまでハングするのですか?
ほぼ常に、中間層でのレスポンスバッファリングが原因です。転送前にボディを蓄積するリバースプロキシやフレームワークはストリーミングを無効にし、最初のバイトを遅らせることでアイドルタイムアウトも発火させかねません。text/event-stream 応答についてはバッファリングを無効にし、到着したチャンクをフラッシュしましょう。
1つのルートを4つの期限に分ける
本番のルートを1つ取り、その単一のタイムアウトを4つに分割してください。接続タイムアウト、TTFT期限、ストリームのアイドルタイムアウト、そして合計上限です。そのルートを stream: true に切り替え、上記のアイドルウォッチドッグを接続し、意図的にリクエストをストリーム途中で中断します。ローカルに記録された出力トークン数が減れば、期限がソケットまで届いた良い兆候です。ただし、それは証明ではなくシグナルとして扱い、コストが重要な場合はプロバイダー自身の使用量と照合してください。切断後も上流が短時間課金を続ける可能性があります。