耐えきる LLM API のレート制限とバックプレッシャー
LLM のレート制限ヘッダーを読み、バックオフとジッターで 429 を吸収し、キューと同時実行数の上限でクライアント側のバックプレッシャーをかけて、過負荷時に優雅に負荷を逃がす方法。
LLM プロバイダーがあなたをレート制限すると、HTTP 429 が返ります。そして最も素朴な反応——すぐ再試行——は事態を悪化させます。失敗したリクエストも毎分あたりの上限に計上されるからです。持続的な解決策は、二つの層が協調して働くことです。プロバイダーが送るレート制限ヘッダーを読み、残り予算がどれだけあるかを把握すること。そして自分の側でバックプレッシャー——境界付きキュー、同時実行数の上限、ジッター付きバックオフ——をかけ、需要が供給を上回ったときにシステムが溶け落ちるのではなく減速するようにすることです。本稿ではこの両層と、それらを実装するコードを扱います。
レート制限は覆い隠すべきエラー状態ではありません。それは容量のシグナルです。それをシグナルとして扱うシステムは予測可能に劣化します。それを無視するシステムは、プロバイダーを叩き続けることとユーザーのリクエストを捨てることの間で振動します。
何かを調整する前に、ヘッダーを読む
OpenAI はすべてのレスポンスで、直接検査できるヘッダーを通じてレート制限の状態を返します。リクエストベースの組は次のとおりです。
x-ratelimit-limit-requests—— ウィンドウ内のリクエスト数の上限x-ratelimit-remaining-requests—— 残りいくつあるかx-ratelimit-reset-requests—— リクエストウィンドウがリセットされるまでの時間
これと並行して、トークンベースの組——x-ratelimit-limit-tokens、x-ratelimit-remaining-tokens、x-ratelimit-reset-tokens——があり、x-ratelimit-remaining-project-tokens のようにプロジェクト単位のバリアントもあります。現実の制限の大半はリクエスト数ではなくトークンで先に効きます。長コンテキストの単一呼び出しが、トークン予算の大きな一切れを消費しうるからです。これらのヘッダーをすべてのレスポンスで記録すれば、スロットリングが 429 の壁になる前に迫ってくるのが見えます。
resp = client.chat.completions.with_raw_response.create(
model="gpt-5.6",
messages=[{"role": "user", "content": prompt}],
)
h = resp.headers
print(h.get("x-ratelimit-remaining-requests"), h.get("x-ratelimit-remaining-tokens"))
これらのヘッダーを記録することが運用の土台になります。レスポンスごとに x-ratelimit-remaining-requests と x-ratelimit-remaining-tokens を残せば、429 が始まる前に、どのアップストリームが上限へ近づいているかを監視できます。すべてのレスポンスがすでに通過するゲートウェイは、この記録を一元化するのに適しています。ただし、チャネルごとの残予算曲線は、生のヘッダーを基に自分で構築するものであり、レスポンスが計算済みの値として渡してくれるものではありません。
429 は指数バックオフとジッターで再試行する
429 が実際に届いたら、再試行の前にバックオフします。指数バックオフは失敗のたびに待機時間を倍加させます。ジッターはそれをランダム化し、クライアントの一群が同じ瞬間に一斉再試行して再衝突しないようにします——それは、あなたが回復しつつある過負荷を再現するサンダリングハード(thundering herd)です。
import random, time
def backoff_sleep(attempt, base=0.5, cap=20.0):
# full jitter: random point in [0, min(cap, base * 2**attempt))
ceiling = min(cap, base * (2 ** attempt))
time.sleep(random.uniform(0, ceiling))
for attempt in range(6):
r = call_model(prompt)
if r.status_code != 429:
break
backoff_sleep(attempt)
二つのルールがこれを健全に保ちます。第一に、遅延と試行回数の両方に上限をかけること——無限の再試行は障害を下流へ移すだけです。第二に、再試行可能なものだけを再試行すること。429 と一過性の 5xx/タイムアウトは再試行の価値がありますが、リクエストの形式不正による 400 は毎回同じように失敗します。再試行された書き込みは作業を重複させうるので、再試行は冪等性と組み合わせてください——完全な扱いは LLM API の再試行と冪等性 にあります。
レスポンスに Retry-After ヘッダーが含まれる場合は、算出したバックオフより優先してください——サーバーはウィンドウがいつ再び開くかを正確に伝えており、その値を尊重すれば回復も速く、必ず失敗する試行を無駄にすることも避けられます。指数スケジュールは、サーバーが明示的なヒントを与えない場合のフォールバックと考えましょう。
バックプレッシャー:源を遅くせよ、床を落とすな
再試行は失敗した単一の呼び出しを扱います。バックプレッシャーは、受け入れ速度がプロバイダーの受容量を単純に上回る状況を扱います。待ち行列理論からの核心的な考え方は、生産者とプロバイダーの間に境界付きバッファを置き、バッファが満杯のときは、提供できない無制限の作業を受け入れるのではなく生産者を待たせる、というものです。
三つのメカニズムが組み合わさってバックプレッシャー戦略になります。
| メカニズム | 何を境界づけるか | 防ぐ障害モード |
|---|---|---|
| 同時実行数の上限(セマフォ) | 処理中のリクエスト | すべて 429 になる数千のソケットを開くこと |
| 境界付きキュー | 開始待ちの作業 | スパイク下での無制限なメモリ増大 |
| 受け入れ制御 / 負荷逃がし | 新たに受け入れる作業 | 間に合わせて提供できないリクエストを受け入れること |
同時実行数の上限は、最もてこ比の高い変更であり、最も追加しやすいものです。同時に処理中の呼び出しに上限をかけ、プロバイダーの許容度を超えるのではなく一致させます。
import asyncio
sem = asyncio.Semaphore(20) # never more than 20 concurrent upstream calls
async def guarded_call(prompt):
async with sem:
return await call_model_async(prompt)
セマフォが満杯のとき、新しい呼び出し元は待ちます——その待ちこそがバックプレッシャーです。それは制約をプロバイダー境界で爆発させるのではなく、源まで押し戻します。
キューが満杯なら、意図的に負荷を逃がす
境界付きキューは、持続的なスパイクの間にいずれ満杯になります。そのとき何が起きるかは製品上の判断であり、それを明示することこそが要点のすべてです。
- 速やかに拒否する ——
Retry-After付きの503を返し、呼び出し元に後で再試行させます。遅い回答が明確な「後でやり直して」より悪い、対話型トラフィックに最適です。 - 優先度で逃がす —— 低優先度の作業(バッチ、バックグラウンド)を捨てるか先送りし、対話型リクエストを流し続けます。
- 格下げする —— 溢れた分を、余裕がありそうな、より安く速いモデルへ振り向けます。
アンチパターンは、すべてを受け入れて静かにレイテンシを膨らませ、結局リクエストがタイムアウトするまで放置する無制限キューです。これは可視で回復可能な拒否を、不可視でシステム全体の停滞へと変えてしまいます。キューを境界づけ、「末尾から落ちる」ことが何を意味するかを決めましょう。
ゲートウェイがポリシーを一元化する場所
各サービスで実装されたバックプレッシャーはドリフトします。それぞれが異なる上限を選び、互いに悪く干渉するからです。すべてのトラフィックがすでに通過するゲートウェイは、一貫したポリシーを一つ適用する自然な場所です。本プロジェクトでは、プロバイダー接続はそれぞれ独自のクォータと優先度を持つチャネルとして登録され、あるチャネルがスロットリングし始めると、ゲートウェイはすでに健全なチャネルへフェイルオーバーし(LLM API のフェイルオーバー戦略)、調整に使えるリクエスト単位の使用量を記録しています。より高度な受け入れ制御は、同じチョークポイントへ重ねるアーキテクチャです。
- レスポンスヘッダーからチャネルごとの残りトークン予算とリクエスト予算を追跡する、
- チャネルごとの同時実行数の上限を適用し、ある過熱チャネルが他を飢えさせないようにする、
- 回復トラフィックが障害を増幅しないよう、集約されたリトライ予算を一つだけ持つ。
この三つは、初期状態から提供されるスイッチではなく、ゲートウェイに実装する設計として扱ってください。ゲートウェイの価値は、それらを追加する場所が一つしかないことにあります。一元化が重要なのには具体的な理由があります。十のサービスが各自独立に 429 で再試行すると、それらの再試行トラフィックの合計だけで上限を超えうるためです。共有のリトライ予算が、その自傷的な増幅を防ぎます。
影響順に並べた、層状の防御
これらを順に適用してください。それぞれが次のための時間を稼ぎます。
- 計測する —— レート制限ヘッダーを記録し、スロットリングを早期に可視化する。
- 同時実行を制限する —— プロバイダーの許容度に一致するセマフォ。
- キューを境界づける —— 固定バッファ、決して無制限にしない。
- ジッター付きでバックオフする ——
429/5xxで、4xxクライアントエラーでは決して行わない。 - 逃がすか格下げする —— 満杯キューのための明示的なポリシー。
- 負荷を分散する —— 複数チャネルとフェイルオーバーで実効上限を引き上げる。
最初の二つの変更で、たいてい 429 の痛みの大半が消えます。残りは、残ったスパイクを停止ではなく優雅な減速へと変えます。
リクエストだけでなく、トークンに予算を割く
トークン制限はたいていリクエスト制限より先に効くので、呼び出しのトークンコストを送信前に見積もり、それに応じて予算を確保してください。一つのリクエストは入力トークン(あなたのプロンプト、加えて連結された履歴)と出力トークン(max_tokens/max_output_tokens で境界づけられる)の両方を消費します。リクエスト数だけで作業を受け入れると、長コンテキスト呼び出しのバーストが、リクエストカウンターが健全に見える間にトークン上限を突き破りうる——そしてそれらの呼び出しはことごとく 429 で返ってきます。
実用的な受け入れルールは、トークンを意識したゲートです。x-ratelimit-remaining-tokens から残りトークン予算を追跡し、保留中の各呼び出しの最悪ケースのトークン数を見積もり、その見積もりが残りの余裕に収まる呼び出しだけを受け入れます。これはトークンバケットの考え方を LLM トラフィックに適用したもの——リセット間隔で補充し、受け入れ時に消費し、バケットが少ないときは呼び出し元を待たせます。
max_tokens/max_output_tokensを実際に見込むサイズまで抑えます。過大な上限は、使いもしない予算を確保し、他の呼び出しを飢えさせます。- 連結された履歴を数えます。保存された会話状態では、以前の入力トークンも依然として入力トークンとして課金されるので、長いスレッドは見た目より 1 ターンあたりのコストが高くなります。
- 単一リクエストがトークンウィンドウの大きな一切れを一度に確保してしまうなら、一つの巨大な呼び出しより多数の小さな呼び出しを選びます。
トークン見積もりを適切なサイズにすることは、しばしば同時実行の調整より大きなてこです。429 になる呼び出しがそもそも受け入れられなくなるからです。ここではコストと容量は同じダイヤルであり、それが LLM API コストの削減 につながる理由です。
FAQ
どのステータスコードがレート制限を意味し、それを再試行すべきですか?
HTTP 429 です。再試行可能ですが、バックオフとジッターを伴う場合のみで、決して即座には行いません——失敗したリクエストも毎分あたりの上限に計上されるので、タイトな再試行ループはスロットリングを深めます。試行回数と遅延に上限をかけてください。
実際にぶつかるのは、リクエスト制限ですか、トークン制限ですか?
たいていトークン制限です。x-ratelimit-remaining-tokens を x-ratelimit-remaining-requests と並べて確認してください。長いプロンプトや大きな max_tokens を伴うワークロードは、リクエスト数にほとんど触れずにトークン予算を使い果たしうるので、リクエスト数だけで予算を組むと誤導されます。
同時実行数の上限を上げるとスループットは増えますか?
プロバイダーの許容度までに限ります。その点を越えると、余分な同時実行はより多くの 429、より多くの再試行、そしてより低い有効スループットを生みます。セマフォは、429 率がほぼゼロに留まる最大の値に調整し、そこで止めてください。
より大きなキューは持続的な過負荷の解決策ですか?
いいえ。より大きなキューは真実の瞬間を先送りし、満杯になる間にテールレイテンシを膨らませるだけです。受け入れが容量を持続的に上回るなら、いかなるバッファも救ってくれません——負荷逃がし、より高いレート制限、あるいはより多くのチャネルが必要です。キューはバーストを吸収するのであって、トレンドを吸収するのではありません。
セマフォ一つとヘッダー記録から始める
モデル呼び出しの周りに同時実行セマフォを一つ加え、すべてのレスポンスで x-ratelimit-remaining-tokens の記録を始めてください。記録した値と 429 を一日分グラフにし、残りトークンの落ち込みがエラーと重なるなら、落ち込みが止まるまでセマフォを下げます。その単一のフィードバックループが、もっと手の込んだものを構築する前に、レート制限インシデントの大半を取り除きます。