LLM アプリ運用 #5 信頼性 — レートリミット・リトライ・フォールバック
第4回まではお金の話でしたが、今回は止まらないための話です。LLM API の運用では、429(レートリミット)と 529(過負荷)は障害ではなく日常です。トラフィックが好調な日ほど、より頻繁に出会います。信頼性設計のゴールは、この日常的な拒否の前でユーザー体験が崩れないようにすることです。
レートリミット(rate limit)の仕組み — 何が上限に引っかかるのか #
レートリミットは一つの数字ではありません。1 分あたりのリクエスト数(RPM)と 1 分あたりのトークン数(入力・出力は別々)がそれぞれ設定されていて、どれか一つでも超えれば 429 が返ってきます。運用で重要な帰結が二つあります。
- リクエスト数が少なくてもトークンで引っかかることがあります。 長いコンテキストを持つ RAG・エージェントのリクエストは、数件重なるだけでトークン上限に達します。第1回の計測でリクエスト数とトークン量を別々に見るのはこのためです。
- 上限はモデルごとです。 第2回のルーティングは信頼性の仕組みでもあります。haiku へ向かうトラフィックは opus の上限を消費しないので、ルーティングは上限というパイプそのものを分けてくれます。
429 応答には retry-after ヘッダー(何秒後に再度来るべきか)が載ってきます。この値を無視して即座にリトライしても、上限をさらに固くするだけです。
リトライ — SDK がくれるものと自分で押さえるべきもの #
基本はAI エージェント開発 第1回で見たとおりです。SDK が 429 と 5xx 系を指数バックオフで自動リトライし(デフォルト 2 回)、max_retries で調節します。運用の観点から付け加えることが三つあります。
client = anthropic.Anthropic(
max_retries=4, # 夜間バッチなど遅延を許容できる経路は多めに
timeout=60.0, # デフォルト(10分)は運用には長すぎる。経路ごとに設定
)- リトライの予算は経路ごとに違います。 ユーザーが待っているチャットボットで 4 回のリトライ(数十秒)は無意味です。リアルタイム経路は短く(1〜2回)、バックグラウンド経路は長めに取ります。
- リトライしてはいけないエラーを区別します。 400(不正なリクエスト)と 401(認証)は何百回送り直しても同じです。SDK はこれを自動で区別しますが、自前のリトライロジックを重ねるとき、4xx をリトライループに入れてしまうミスがよくあります。
- リトライもコストです。 第1回のログにリトライ回数を残しておくと、「コストが増えたのにトラフィックは横ばい」の犯人がリトライの暴走だったケースを捕まえられます。
タイムアウトとストリーミング — 長い応答の信頼性 #
LLM の応答時間は出力の長さに比例するため、長い生成では数十秒かかるのが正常です。ここで二種類の事故が起きます。タイムアウトを短く設定して正常な応答を切ってしまうか、長く設定して死んだ接続を延々と待ち続けるかです。標準的な解決策は、長い応答の経路をストリーミングに切り替えることです。
with client.messages.stream(
model="claude-opus-4-8",
max_tokens=16000,
messages=messages,
) as stream:
for text in stream.text_stream:
push_to_user(text) # 最初のトークンからユーザーに見える
response = stream.get_final_message()ストリーミングは体感の遅延(最初のトークンまでの時間)を減らす UX 機能として紹介されがちですが、運用の観点では信頼性の機能です。接続が生きているという信号がトークン単位で届くため、「死んでいるのか遅いのかわからない」区間が消えます。max_tokens が大きいリクエストで SDK がストリーミングを要求するのも同じ理由(長い無応答接続のタイムアウトリスク)です。出力が長くなりうる経路は、ストリーミングをデフォルトにしておきます。
フォールバック — それでもだめなときの段階的後退 #
リトライで解決しない状況(持続的な 429、529、障害)に備えて、後退の階段をあらかじめ設計しておきます。上から試して、だめなら降りていきます。
- モデル格下げ — 主力モデルが詰まったら、同じリクエストを一段小さいモデルへ送ります。品質は少し下がりますが、サービスは続きます。第2回のルーティングテーブルに
fallback列を追加する形になります。 - キューイング — リアルタイム性の低いリクエストは「しばらくしてから処理されます」として受け付け、キューに入れます。第4回のバッチパイプラインがそのまま緩衝装置になります。
- 丁寧な失敗 — 最後までだめなら、早く、明確に失敗します。「現在リクエストが集中しています。しばらくしてからもう一度お試しください」のほうが、30 秒のスピナーよりも良い体験です。
FALLBACK = {"claude-opus-4-8": "claude-sonnet-4-6",
"claude-sonnet-4-6": "claude-haiku-4-5"}
def call_with_fallback(model: str, **kwargs):
try:
return call_llm(model=model, **kwargs)
except (anthropic.RateLimitError, anthropic.InternalServerError):
fallback = FALLBACK.get(model)
if fallback is None:
raise
logger.warning("fallback: %s -> %s", model, fallback)
return call_llm(model=fallback, **kwargs)フォールバックの発動をログに残すことが重要です。フォールバックは対症療法であって治療ではないため、頻繁に発動しているなら根本原因(上限引き上げの申請、トラフィックの分散、キャッシュによるトークン削減)に手を打つべき信号です。
負荷を作らない側 — 同時実行の制限 #
最後のピースは方向が逆です。受ける側ではなく、自分が送る速度の制御です。トラフィックの急増がそのまま API 呼び出しの急増になると、上限に自分から突っ込むことになります。呼び出し経路に同時実行の上限(セマフォ)を置けば、急増分は待ち行列で少し並び、API 側の 429 は減ります。エージェント第5回の並列サブエージェントや評価の一括実行のように、内部で同時呼び出しを生むコードでは特に必要です。上限の中で一定に流れるトラフィックのほうが、押し寄せては詰まるを繰り返すトラフィックよりも総スループットも高くなります。
よくつまずくところ #
- 429 を障害として扱う — レートリミットは設計の対象であって、アラートの対象ではありません。ただし発生率のトレンドは見るべきです。トレンドの上昇は、上限引き上げや削減作業の信号です。
- すべての経路に同じタイムアウトを使う — 分類(1 秒で終わる)と長い生成(30 秒が正常)のタイムアウトが同じであるはずがありません。第2回のルーティングテーブルにタイムアウトも経路ごとに置きます。
- フォールバックを作って忘れる — 静かに格下げされたまま数週間動いていると、品質低下がデフォルトになります。フォールバックの発動率をダッシュボードに載せます。
まとめ #
今回は止まらない構造を作りました。
- レートリミットはリクエスト数とトークン数が別々に設定されています。retry-after を尊重するリトライは SDK に任せ、リトライの予算は経路ごとに決めます。
- 長い応答の経路ではストリーミングが信頼性の仕組みです。タイムアウトも経路ごとに置きます。
- フォールバックはモデル格下げ → キューイング → 丁寧な失敗の階段として設計し、発動率を監視します。送る側の同時実行制限が、上限への衝突そのものを減らします。
コストと信頼性が揃ったので、残る脅威は外から来ます。次回の「LLM アプリ運用 #6 セキュリティ — プロンプトインジェクションとデータ境界」では、入力でアプリを操ろうとする試みを扱います。