LLM APIをアプリケーションから呼び出すと、たまにタイムアウトしたり、429 Too Many Requests が返ったりします。ここで単純に「失敗したら何度も再送する」と、障害を長引かせたり、同じリクエストを重複実行したりします。
私はLLM APIの呼び出し処理を、次の3点に分けて実装しました。
- 接続・読み取りのタイムアウトを設定する
- 一時的な失敗だけを指数バックオフ付きで再試行する
- クライアント側でもリクエスト頻度を制御する
この記事では、OpenAI互換形式のHTTP APIを例に、Pythonとrequestsだけで実装します。
まずエラーを分類する
すべてのエラーをリトライしてはいけません。
401や403は認証・権限の問題、400は入力値の問題である可能性が高いため、同じ内容を再送しても成功しません。一方、次のようなエラーは時間を置けば成功する可能性があります。
408 Request Timeout429 Too Many Requests500 Internal Server Error502 Bad Gateway503 Service Unavailable504 Gateway Timeout
429は一定時間内のリクエスト数が多すぎる状態で、レスポンスにRetry-Afterヘッダーが含まれることがあります。指定されていれば、その秒数を優先して待つのが基本です。MDNのHTTP 429解説
タイムアウトは接続と読み取りを分ける
タイムアウト未指定のHTTPクライアントは、相手が応答しない場合に長時間停止することがあります。requestsでは、timeout=(接続, 読み取り)のように個別指定できます。なお、これはリクエスト全体の制限時間ではないため、別途デッドラインも管理します。Requestsのタイムアウト仕様
LLMでは生成時間が長くなることがあるため、接続は短め、読み取りは用途に合わせて長めに設定します。今回の例では接続3秒、読み取り30秒を初期値にしました。
指数バックオフとジッターを使う
リトライ間隔を固定値にすると、同じタイミングで大量のクライアントが再接続してしまいます。そこで、待ち時間を次のように増やします。
0.5秒 → 1秒 → 2秒 → 4秒
さらにランダムな揺らぎ(ジッター)を加えることで、再試行の集中を避けます。
ただし、リトライ回数には上限を設けます。私は回数だけでなく「この処理全体に使える時間」も制限するようにしました。
実装例
LLM API呼び出し全体の流れを図にすると次の通りです。
まず仮想環境を作り、requestsをインストールします。
python -m venv .venv
source .venv/bin/activate
python -m pip install requests
Windows PowerShellの場合は次のコマンドです。
.venv\Scripts\Activate.ps1
python -m pip install requests
次が実装例です。LLM_API_URLは利用するAPIのエンドポイントに置き換えてください。
import os
import random
import threading
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests
RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
class RateLimiter:
"""プロセス内の最大リクエスト頻度を制御する"""
def __init__(self, requests_per_second: float):
self.interval = 1.0 / requests_per_second
self.lock = threading.Lock()
self.next_at = 0.0
def wait(self) -> None:
with self.lock:
now = time.monotonic()
wait_seconds = max(0.0, self.next_at - now)
self.next_at = max(now, self.next_at) + self.interval
if wait_seconds > 0:
time.sleep(wait_seconds)
def retry_after_seconds(response: requests.Response):
value = response.headers.get("Retry-After")
if not value:
return None
try:
return max(0.0, float(value))
except ValueError:
pass
try:
retry_at = parsedate_to_datetime(value)
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=timezone.utc)
return max(
0.0,
(retry_at - datetime.now(timezone.utc)).total_seconds(),
)
except (TypeError, ValueError):
return None
def call_llm(
prompt: str,
*,
limiter: RateLimiter,
max_attempts: int = 4,
deadline_seconds: float = 90.0,
) -> dict:
url = os.environ["LLM_API_URL"]
api_key = os.environ["LLM_API_KEY"]
payload = {
"model": os.getenv("LLM_MODEL", "example-model"),
"messages": [
{"role": "user", "content": prompt}
],
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
deadline_at = time.monotonic() + deadline_seconds
last_error = None
with requests.Session() as session:
for attempt in range(1, max_attempts + 1):
remaining = deadline_at - time.monotonic()
if remaining <= 0:
break
limiter.wait()
connect_timeout = min(3.0, max(0.1, remaining))
read_timeout = min(30.0, max(0.1, remaining))
try:
response = session.post(
url,
headers=headers,
json=payload,
timeout=(connect_timeout, read_timeout),
)
except (requests.Timeout, requests.ConnectionError) as exc:
last_error = exc
retry_after = None
else:
if 200 <= response.status_code < 300:
return response.json()
if response.status_code not in RETRYABLE_STATUS:
raise RuntimeError(
f"LLM API failed: {response.status_code} "
f"{response.text[:300]}"
)
last_error = RuntimeError(
f"temporary error: {response.status_code}"
)
retry_after = retry_after_seconds(response)
if attempt == max_attempts:
break
backoff = min(8.0, 0.5 * (2 ** (attempt - 1)))
jitter = random.uniform(0.0, 0.2)
delay = max(retry_after or 0.0, backoff + jitter)
remaining = deadline_at - time.monotonic()
if remaining <= 0:
break
time.sleep(min(delay, remaining))
raise TimeoutError(
f"LLM API did not succeed within the deadline: {last_error}"
)
if __name__ == "__main__":
limiter = RateLimiter(requests_per_second=2.0)
result = call_llm(
"リトライ設計の要点を3つ、日本語で説明してください。",
limiter=limiter,
)
print(result)
実行前に環境変数を設定します。
export LLM_API_URL="https://api.example.com/v1/chat/completions"
export LLM_API_KEY="your-api-key"
export LLM_MODEL="your-model"
python app.py
このコードでは、Retry-Afterが秒数の場合とHTTP日時の場合の両方を処理しています。また、指数バックオフよりサーバー指定の待ち時間が長い場合は、Retry-Afterを優先します。
レート制限は「同時実行数」と分けて考える
RateLimiterは、1秒あたりのリクエスト数を制御します。ただし、これは同時実行数の制限とは別です。
例えば、1秒に2件まで送れても、1リクエストが30秒かかれば、多数の処理が同時に実行される可能性があります。実運用では、次の2つを別々に設定します。
- リクエスト頻度: 1秒あたり何件送るか
- 同時実行数: 最大何件を処理中にするか
同時実行数は、ワーカー数やthreading.Semaphoreで制限できます。複数プロセスや複数サーバーから呼び出す場合は、プロセス内のRateLimiterだけでは不十分です。共有キューやRedisなど、全体で共有できる仕組みが必要になります。
リトライ時の重複実行に注意する
タイムアウトした場合、サーバー側では処理が完了している可能性があります。クライアントが応答を受け取れなかっただけで、同じリクエストを再送すると二重処理になることがあります。
LLMのテキスト生成だけなら影響が限定的な場合もありますが、生成結果を保存する、外部システムを更新する、といった処理を同時に行う場合は注意が必要です。
対策として、次のような設計にします。
- リクエストごとに一意な処理IDを付ける
- APIが対応していれば
Idempotency-Keyを利用する - API呼び出しとデータ保存を分離する
- リトライ回数をログに残す
- プロンプト本文やAPIキーはログに出さない
私はログに、処理ID、試行回数、HTTPステータス、待ち時間、総経過時間だけを記録するようにしています。これだけでも、429が多いのか、タイムアウトが多いのかを切り分けやすくなります。
まとめ
LLM APIを安定して利用するには、単純な再送ではなく、失敗の種類に応じた制御が必要です。
-
400や401などは原則リトライしない - 接続タイムアウトと読み取りタイムアウトを設定する
-
429ではRetry-Afterを尊重する - 指数バックオフとジッターで再試行を分散する
- リトライ回数だけでなく全体のデッドラインも設ける
- リクエスト頻度と同時実行数を別々に制御する
- タイムアウト後の重複実行を考慮する
まずは手元のAPI呼び出しをこの形に置き換え、ログでエラーの傾向を確認するところから始めるのがおすすめです。