0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

本番でAIが「たまに落ちる」を許す設計 — LLM呼び出しの信頼性エンジニアリング(リトライ・タイムアウト・フォールバック)実践ガイド

0
Posted at

本番でAIが「たまに落ちる」を許す設計 — LLM呼び出しの信頼性エンジニアリング(リトライ・タイムアウト・フォールバック)実践ガイド

はじめに — 「ローカルでは動いたのに、本番でたまに落ちる」の正体

AIの機能、わりとサクッと作れる時代になりましたよね。

await client.messages.create(...) みたいに一行書けば、要約も、分類も、チャットも返ってくる。デモは完璧。社内で見せたら拍手。

でも、本番に出して数日。ある日、ユーザーから問い合わせが来ます。

「さっき、AIの返信ボタンを押したらエラーになったんですけど」

ログを見ると、こんなのが並んでいます。

429 Too Many Requests
503 Service Unavailable
529 overloaded_error
ReadTimeout: timed out after 60s

毎回じゃない。10回に1回、いや100回に1回くらい。でも、確実に落ちている。

これ、心当たりないですか。

正直に言いましょう。これはあなたのコードが下手だからじゃないんです。LLMのAPI呼び出しは、普通のAPIより落ちやすい。 そして、ローカルで何回か叩いただけだと、その「たまの失敗」にまず出会わない。だから、何の守りもない await ... 一発勝負のコードが、そのまま本番に行ってしまう。

この記事は、その「たまに落ちる」とどう付き合うかの話です。専門的には レジリエンス・エンジニアリング(resilience engineering=壊れにくさの設計) とか フォールトトレランス(fault tolerance=障害に耐える設計) と呼ばれる領域なんですが、難しい言葉は全部この記事の中で噛み砕いていきます。

最初に結論を置いておきます。

外部のLLMは「いつか必ず落ちる」前提で呼び出す。落ちたときに、何回・どう待って・いつ諦めて・どこに逃がすかを、あらかじめコードに書いておく。

これだけです。これを「リトライ」「タイムアウト」「冪等性」「フォールバック」「サーキットブレーカー」という5つの守りに分けて、そのまま貼れるコードと一緒に積み上げていきます。

無知の無知(落ちる理由をそもそも知らない状態)から、明日には「うちのAI機能、ちゃんと守り入れてあるよ」と言える状態まで、一緒に行きましょう。


1. なぜLLM呼び出しは「普通のAPI」より落ちやすいのか

守りを学ぶ前に、敵を知っておきます。「なんでそんなに落ちるの?」がわからないと、対策が腹落ちしないので。

まず、身も蓋もない事実から。クラウドのインフラ(サーバーとかストレージ)の稼働率はだいたい99.9%以上が当たり前なんですが、LLMプロバイダの稼働率は だいたい99〜99.5% と報告されています。これ、クラウドインフラの6〜14倍くらい「落ちやすい」ということなんですよね。

実際、OpenAIは2025年12月〜2026年3月の稼働率を 99.76% と公開しています。数字だけ見ると高そうに見えますけど、99.76%って 年間で約16時間ぶん落ちてる 計算なんです。1日まるごと近く、どこかで止まってる。

そして現場の肌感として、LLM API呼び出しの1〜5%は失敗すると言われています。100回呼んだら、1〜5回はコケる。これが「たまに落ちる」の正体です。

なんでこんなに落ちやすいのか。理由を分解すると、こうなります。

  • 遅い:普通のAPIはミリ秒で返るけど、LLMは数秒〜数十秒かかる。長く待つぶん、途中で切れる窓も広い。
  • レート制限が厳しい:「1分間にこれだけしか呼べません」という上限が、トークン数ベースでけっこうシビアに効く(後で詳しく)。
  • 課金が絡む:呼ぶたびにお金がかかる。だから「とりあえず連打」が一番やっちゃいけない。
  • 混雑で共倒れ:人気のモデルは世界中から叩かれるので、サーバーが一時的に容量オーバーになる(これがAnthropicの 529 overloaded_error)。
  • 長文ストリーミング:1文字ずつ流す(ストリーミング)応答は、途中でネットワークが切れたら「半分だけ届いて終わり」になる。

ここで大事なリフレームを1つ。

「落ちないようにする」のは無理。100%は防げない。だから「落ちても、ユーザーには大事にならないようにする」を目指す。

完璧な城壁を作るんじゃなくて、転んでも擦り傷で済むように手すりと柵を置く。そういう発想です。


2. まず「失敗を分類」する — リトライしていい失敗、ダメな失敗

守りの第一歩は、リトライ(retry=もう一回やってみる)です。

でも、ここでいきなり一番やりがちな事故を先に言っておきます。

❌ 何でもかんでもリトライするな。

なぜか。失敗には2種類あるからです。

  • retryable(リトライしていい失敗):もう一回やれば成功するかもしれない一時的な失敗。混雑、瞬間的なネットワーク断、サーバーの一時エラーなど。
  • non-retryable(リトライしちゃダメな失敗):何回やっても同じ結果になる、あるいはやり直すと悪化する失敗。リクエストの書き方が間違ってる、認証が切れてる、入力が長すぎる、など。

認証エラー(401)を10回リトライしても、永遠に通りません。むしろ無駄に叩いて、相手のレート制限を悪化させるだけ。だから、HTTPステータスコード(サーバーが返す3桁の番号)で「これはリトライしていいやつ?」を最初に判定するのが鉄則です。

ざっくりこういう線引きになります。

ステータス 意味 リトライ ひとこと
429 Too Many Requests(レート制限) ✅ する ただし retry-after ヘッダの秒数を必ず尊重
500 サーバー内部エラー ✅ する 一時的なことが多い
502 / 503 ゲートウェイ/サービス利用不可 ✅ する 一時的な混雑
529 overloaded_error(Anthropic、サーバー過負荷) ✅ する 相手のせい。数分で直ること多い
タイムアウト/接続断 ネットワーク系 ✅ する ただし冪等性に注意(後述)
400 Bad Request(リクエストが不正) ❌ しない 直すべきは自分のコード
401 / 403 認証・権限エラー ❌ しない キーや権限を直す
404 Not Found ❌ しない モデル名やパス間違い
422 入力が処理できない(長すぎる等) ❌ しない 入力を削る・分割する

400番台(4xx)は基本「あなたのリクエストがおかしい」系なので、リトライ対象外。例外が 429 で、これは「あなたは悪くないけど今は混んでる、少し待って」なのでリトライ対象。500番台(5xx)は「サーバー側が一時的にコケた」系なので、基本リトライ対象。

この線引きを、まず関数にしておきます。

# Python: この失敗、リトライしていい?を一箇所で判定する
RETRYABLE_STATUS = {429, 500, 502, 503, 529}

def is_retryable(status_code: int | None, exc: Exception | None = None) -> bool:
    # ネットワーク系の例外(接続断・タイムアウト)はリトライ対象
    if exc is not None:
        import httpx
        if isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError)):
            return True
    if status_code is None:
        return False
    return status_code in RETRYABLE_STATUS

ポイントは、「リトライしていいか」の判断を1箇所に集めること。あちこちのコードで if status == 429 と書き散らすと、必ずどこかで漏れます。判定はここ、と決めておく。


3. 守り① リトライ:指数バックオフ + フルジッター

「リトライしていい失敗」だと分かったら、いよいよリトライします。でも、ここにも罠があります。

❌ 失敗したらすぐ、同じ速さで何回も叩く ——これ、最悪です。

電話で例えますね。お店が「ただいま混み合っております」(=429)と言ってるのに、切ってすぐかけ直す。混んでるのにまた切ってかけ直す。これを全国のお客さんが一斉にやったら、お店の電話はずっと鳴りっぱなしで、いつまでも繋がらない。これを thundering herd(サンダリングハード=群衆の暴走) と言います。

だから、リトライには2つの工夫を入れます。

  1. 指数バックオフ(exponential backoff):待ち時間を毎回倍々に伸ばす。1秒 → 2秒 → 4秒 → 8秒…。失敗が続くほど、相手にやさしく間隔をあける。
  2. ジッター(jitter=ゆらぎ):待ち時間にランダムなブレを足す。みんなが「きっかり2秒後」に再挑戦すると、結局また一斉アクセスになるので、わざとバラけさせる。

このジッターの入れ方、実はAWSが昔から「どれが一番効くか」を実験して結論を出しています。AWSの "Exponential Backoff And Jitter" という有名な記事によると、Full Jitter(フルジッター) という方式が総合で一番良い、と。クライアントの再試行回数が最少で、完了までの時間も短くなる。式はこうです。

待ち時間 = random(0, min(cap, base × 2^リトライ回数))

base(基準の待ち、例:0.5秒)と cap(待ちの上限、例:30秒)を決めて、その範囲のどこかをランダムに引く。これだけ。

そして、もう1つ超重要なルール。

retry-after ヘッダが返ってきたら、自前のバックオフ計算より、そっちを優先する。

OpenAIもAnthropicも、429のときに 「あと何秒待って」という retry-after ヘッダを返してくれます。サーバーが「これだけ待てば空くよ」と教えてくれてるんだから、勝手な計算より素直にそれに従うのが一番速くて正確。早く再試行しても、どうせ弾かれます。

ぜんぶ入れた手書きリトライがこちらです。ライブラリ無しでも、この30行で本番品質になります。

import random
import time
import httpx

def call_with_retry(do_call, *, max_retries=5, base=0.5, cap=30.0):
    """
    do_call: 実際にLLMを呼ぶ関数。成功でレスポンスを返し、失敗で例外を投げる想定。
    """
    attempt = 0
    while True:
        try:
            return do_call()
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if not is_retryable(status) or attempt >= max_retries:
                raise  # リトライ不可、または回数を使い切ったら諦めて投げる

            # ① サーバーが retry-after をくれたら最優先で尊重する
            retry_after = e.response.headers.get("retry-after")
            if retry_after is not None:
                delay = float(retry_after)
            else:
                # ② なければ Full Jitter で待つ
                delay = random.uniform(0, min(cap, base * (2 ** attempt)))

            time.sleep(delay)
            attempt += 1
        except (httpx.ConnectError, httpx.ReadTimeout) as e:
            # ネットワーク系も retryable
            if attempt >= max_retries:
                raise
            delay = random.uniform(0, min(cap, base * (2 ** attempt)))
            time.sleep(delay)
            attempt += 1

「自分で書くのちょっと不安…」という人は、実績のあるライブラリに任せるのもアリです。Pythonなら tenacity が定番。OpenAIの公式クックブックでも紹介されています。

from tenacity import retry, wait_random_exponential, stop_after_attempt, retry_if_exception_type
import openai

@retry(
    retry=retry_if_exception_type(openai.RateLimitError),  # 429のときだけ
    wait=wait_random_exponential(min=1, max=60),            # Full Jitter相当
    stop=stop_after_attempt(6),                             # 最大6回で諦める
)
def ask(prompt: str):
    client = openai.OpenAI()
    return client.responses.create(model="gpt-5.5", input=prompt)

wait_random_exponential がまさに「指数バックオフ + ジッター」をやってくれる部分です。便利。

ちなみに、OpenAIやAnthropicの公式SDKは、デフォルトで数回リトライを内蔵しています。なので「素のSDKでもある程度は守られている」のは事実。ただ、リトライ回数・どの失敗を対象にするか・全体の予算をどうするかは、自分のアプリの都合で握っておきたい。だから、ここを理解して自分で設計できるのは大きいんですよね。


4. 守り② タイムアウト:無限に待つのを、やめる

リトライの次は、タイムアウト(timeout=時間切れの打ち切り)です。これ、地味だけど超大事。

何が怖いかというと、**「失敗」じゃなくて「いつまでも返ってこない」**状態です。

LLMの応答が遅延して、サーバーが沈黙したまま。タイムアウトを設定していないと、あなたのコードはそこで永遠に待ち続けます。その間、リクエストを処理するスレッドやコネクションは握られたまま。これが積み重なると、1つの遅いLLMが、アプリ全体を道連れにして固まる。これを cascade failure(カスケード障害=連鎖崩壊) と言います。

だから、「ここまで返ってこなかったら諦める」という線を必ず引く。そして、タイムアウトは1種類じゃないことも知っておくといいです。

  • 接続タイムアウト:そもそも繋がるまでの待ち(例:5秒)。
  • 全体タイムアウト:1回の呼び出し全体の上限(例:60秒)。
  • ストリーミングのアイドルタイムアウト:1文字ずつ流れてくる応答で「次の1文字が来ない時間」の上限(例:20秒来なかったら切る)。

ストリーミングのとき、全体タイムアウトだけだと困ることがあります。長い応答は正常でも60秒以上かかることがあるので。代わりに「次のかけらが○秒来なかったら、それは死んでる」というアイドルタイムアウトのほうが、生きてる/死んでるをうまく見分けられます。

TypeScript(Node.js)で、AbortController を使ってタイムアウト付き呼び出しにする例です。AbortController は「この処理、もう中断していいよ」の合図を送るための、ブラウザにもNodeにも標準で入ってる仕組みです。

// TypeScript: タイムアウト付きでLLMを呼ぶ(fetchベースの擬似例)
async function callWithTimeout(
  body: unknown,
  { timeoutMs = 60_000 }: { timeoutMs?: number } = {}
): Promise<Response> {
  const controller = new AbortController();
  // timeoutMs 経過したら「中断して」の合図を送る
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch("https://api.example-llm.com/v1/messages", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(body),
      signal: controller.signal, // ← これでタイムアウトと連動して中断できる
    });
    return res;
  } finally {
    clearTimeout(timer); // 成功しても失敗しても、必ずタイマーを後始末する
  }
}

finally で必ず clearTimeout する、これ忘れがちなので入れておきました。

そして、タイムアウトはリトライと組み合わせて初めて意味が出ることも押さえておきましょう。「60秒待って → 切る → 少し待って → もう一回」。この合わせ技で、たまたま遅かった1回をやり過ごせます。

ただし、ここで次の章の話につながる超重要な注意があります。タイムアウトで切ったけど、実はサーバー側では処理が最後まで走っていた、ということがある。 つまり「届いてないと思ってリトライしたら、実は2回実行されていた」。これを防ぐのが、次の冪等性です。


5. 守り③ 冪等性:リトライで「二重課金」しないために

ここ、この記事で一番大事なところかもしれません。マーカー引いてもいいくらい。

⚠️ 副作用のある操作を、何も考えずにリトライしてはいけない。

冪等性(べきとうせい / idempotency)という言葉、難しそうですが、意味はシンプルです。

冪等 = 同じ操作を1回やっても10回やっても、結果が変わらないこと。

エレベーターの「↑」ボタンを思い出してください。1回押しても5回押しても、エレベーターは1回来るだけ。これが冪等。一方、自販機の「購入」ボタンを5回押したら、5本出てきて5回お金が引かれる。これは冪等じゃない。

LLMの呼び出し自体(文章を生成するだけ)は、基本的に何回呼んでも実害がない…ように見えます。でも実際の機能は、こういう「副作用(side effect)」とセットになっていることが多いんですよね。

  • 生成結果をもとにメールを送る
  • 生成結果をDBに保存する
  • 課金処理でお金を引く
  • 外部に投稿・公開する

ここでリトライが牙を剥きます。「タイムアウトしたからもう一回」とリトライしたら、実は1回目も裏で成功していて、メールが2通飛ぶ。お金が2回引かれる。 これは、ユーザーの信頼を一発で壊す事故です。

守り方は2段構えです。

① プロバイダのIdempotency-Keyを使う(LLM呼び出しの重複防止)

OpenAIは Idempotency-Key という仕組みを用意しています。リクエストに一意なキーを付けておくと、同じキーの重複リクエストは、新規に処理せず、最初の結果をそのまま返してくれる。タイムアウト後の再送で、うっかり2回生成・2回課金されるのを防げます。

import uuid
import openai

client = openai.OpenAI()
# このユーザーのこの操作を表す、安定した一意キー
idem_key = f"summary-{user_action_id}"  # ← uuid4() でもいいが「同じ操作=同じキー」が肝心

resp = client.responses.create(
    model="gpt-5.5",
    input=prompt,
    extra_headers={"Idempotency-Key": idem_key},
)

キーの作り方が肝です。リトライのたびに新しいキーを振ったら意味がない。「同じ操作なら、何回リトライしても同じキー」になるようにする。だから uuid4() をリトライループの外で1回だけ作るか、user_action_id のような「ブレない事実」から組み立てます。

② 自分のアプリ側でも重複実行を止める(副作用の重複防止)

プロバイダのキーが守ってくれるのは「LLM呼び出し」まで。メール送信や課金みたいな自分のアプリの副作用は、自分で守る必要があります。やり方は、操作キーをDBに記録して「もうやった操作はスキップする」だけ。

// TypeScript: 同じ操作を2回実行しないガード(擬似コード)
async function runOnce(actionKey: string, effect: () => Promise<void>) {
  // actionKey に UNIQUE 制約を張ったテーブルに、まず予約を入れる
  const inserted = await db.insertIfAbsent("idempotency_keys", { key: actionKey });
  if (!inserted) {
    // すでに実行済み(または実行中)。何もしない=二重実行を防ぐ
    return;
  }
  await effect(); // 本当の副作用(送信・課金など)はここで1回だけ
}

合言葉はこれです。

読むだけ・生成するだけの操作はリトライOK。送る・消す・課金する・公開する操作は、冪等キーで守ってからリトライ。


6. 守り④ フォールバック:1つ落ちても、機能を生かす

リトライしても、待っても、相手が長時間ダウンしてたら、結局ダメ。じゃあ全部諦めるのか。いや、**逃げ道(フォールバック)**を用意しておきます。

フォールバック(fallback)= 本命がダメだったときの「次善の手」。LLMの世界だと、こんな逃げ道が考えられます。

  • 別モデルに逃がす:高性能モデルが混んでたら、軽いモデルで暫定的に答える。
  • 別プロバイダに逃がす:A社が落ちてたら、B社の同等モデルへ。
  • キャッシュ済みの答えを返す:よくある質問なら、前に生成した答えを使う。
  • 定型文で穏やかに降参する:「いま混み合っています。少し後にもう一度お試しください」と、エラー画面でなく人間的に返す(これを graceful degradation=穏やかな品質低下 と言います)。

大事なのは、「真っ白なエラー画面」だけは避けること。多少品質が落ちても、何か返ってくるほうがユーザーは安心します。

モデルを順番に試すフォールバックチェーンの例です。

// TypeScript: 上から順に試して、ダメなら次へ逃がす
type Caller = (prompt: string) => Promise<string>;

async function withFallback(prompt: string, chain: Caller[]): Promise<string> {
  let lastErr: unknown;
  for (const call of chain) {
    try {
      return await call(prompt); // 成功したら即返す
    } catch (err) {
      lastErr = err;
      // ここで「どのモデルが落ちて、どれに逃げたか」をログに必ず残す
      logger.warn("fallback: a caller failed, trying next", { err });
    }
  }
  // 全部ダメなら、最後の砦=定型文(graceful degradation)
  return "ただいま混み合っています。少し時間をおいて、もう一度お試しください。";
}

// 使い方:高性能 → 安い別モデル → 別プロバイダ、の順に逃がす
const answer = await withFallback(prompt, [
  (p) => callPrimaryModel(p),
  (p) => callCheapModel(p),
  (p) => callBackupProvider(p),
]);

ここで心配性の自分から一言。フォールバックは「静かに品質が落ちる」危険とセットです。「いつの間にか全部の応答が安いモデルから返ってて、品質が下がってたのに誰も気づかなかった」は、よくある事故。だから、フォールバックが発動したら必ずログとメトリクスに残す。「今日はフォールバック率が普段の10倍だ → 本命が不調だな」と気づけるように。逃がすこと自体より、逃がしたのに黙ってることのほうが怖いんです。


7. 守り⑤ サーキットブレーカー:落ちてる相手を、叩き続けない

最後の守りは、サーキットブレーカー(circuit breaker)。家の ブレーカー(電気の使いすぎで自動で電源を落とす、あれ) と同じ発想です。

問題はこういう状況です。プロバイダが完全にダウンしてる。なのに、リクエストが来るたびに「呼ぶ → タイムアウト60秒待つ → 失敗 → リトライ → また60秒…」を律儀に繰り返す。これ、落ちてると分かってる相手に、毎回フルで待たされて、無駄なトークン代も払って、ユーザーも待たされる。誰も得しません。

サーキットブレーカーは「この相手、さっきから失敗しまくってるな。しばらく呼ぶのやめとこ」と自動で判断して、一定時間こちらから呼ぶのを止める仕組みです。状態は3つ。

状態 意味 動き
Closed(閉) 通常運転 そのまま呼ぶ。失敗をカウントする
Open(開) 故障検知 一定時間、呼ぶのを即座に拒否(待たずに即失敗 or 即フォールバック)
Half-Open(半開) 復旧テスト中 たまに1回だけ試す。成功したらClosedに戻す。失敗したらまたOpen

「閉/開」がややこしいんですが、電気回路の用語なので「Closed=電気が流れる=通常」「Open=回路が切れる=遮断」と覚えると腹落ちします。

最小のサーキットブレーカーをTypeScriptで書くと、こんな感じです。

// TypeScript: 最小サーキットブレーカー
class CircuitBreaker {
  private failures = 0;
  private openedAt = 0;
  constructor(
    private threshold = 5,       // 連続5回失敗したら開く
    private cooldownMs = 30_000, // 30秒は呼ばない
  ) {}

  async call<T>(fn: () => Promise<T>): Promise<T> {
    // Open中:クールダウンが明けてなければ即拒否(相手を叩かない)
    if (this.openedAt && Date.now() - this.openedAt < this.cooldownMs) {
      throw new Error("circuit open: skipping call");
    }
    try {
      const result = await fn();
      this.failures = 0;   // 成功したらリセット(Closedへ)
      this.openedAt = 0;
      return result;
    } catch (err) {
      this.failures += 1;
      if (this.failures >= this.threshold) {
        this.openedAt = Date.now(); // しきい値超え → Open
      }
      throw err;
    }
  }
}

サーキットブレーカーとフォールバックはセットで使うと最高に効きます。「ブレーカーが開いてる(=本命ダウン中)→ 待たずに即、別モデルへ逃がす」。ユーザーは長い待ち時間すら体験せずに、別ルートの答えを受け取れる。


8. レート制限を「事前に」避ける — 守りから攻めへ

ここまでは「落ちたあとどうするか」でした。でも一番いいのは、そもそも429を食らわないことですよね。

レート制限(rate limit)は、プロバイダが「1分間にこれだけまでね」と決めている上限です。ここで、OpenAIとAnthropicの公式仕様を正確に押さえておきましょう(2026年6月時点)。

レート制限は 3つの軸で測られます。

  • RPM(requests per minute):1分あたりのリクエスト数
  • ITPM(input tokens per minute):1分あたりの入力トークン数
  • OTPM(output tokens per minute):1分あたりの出力トークン数

どれか1つでも超えたら429になります。「リクエスト数は余裕なのに、長文を投げすぎてトークン数で引っかかる」はあるあるです。

そして両社とも、レスポンスに**「あと何回/何トークン残ってるか」をヘッダで教えてくれます**。これがレート制限対策の地図になります。

  • OpenAI: x-ratelimit-remaining-requests, x-ratelimit-remaining-tokens, x-ratelimit-reset-requests など
  • Anthropic: anthropic-ratelimit-requests-remaining, anthropic-ratelimit-tokens-remaining, retry-after など

Anthropicの仕組みで知っておくと得なのが、token bucket(トークンバケツ)方式であること。1分ごとにガッとリセットされるんじゃなく、バケツに水が連続的に少しずつ補充されるイメージなので、「短いバースト(一瞬の集中アクセス)」は上限内でも弾かれることがあります。だから、一気に投げず、なだらかに流すのが効きます。公式も「急にトラフィックを増やすと acceleration limit で429になるので、徐々にランプアップして」と書いています。

実装としては、クライアント側で同時実行数を絞るのが一番手軽で効果的です。Pythonの asyncio.Semaphore(セマフォ=同時に通れる人数の門番)で、並列呼び出しの数に上限をかけます。

import asyncio

# 同時に走らせるLLM呼び出しは最大5本まで、という門番
semaphore = asyncio.Semaphore(5)

async def call_limited(do_call):
    async with semaphore:          # 5本埋まってたら、ここで順番待ち
        return await call_with_retry_async(do_call)

# 100件まとめて投げても、実際に同時に飛ぶのは最大5本に抑えられる
async def process_all(items):
    tasks = [call_limited(lambda it=it: ask(it)) for it in items]
    return await asyncio.gather(*tasks, return_exceptions=True)

return_exceptions=True にしておくと、1件コケても全体が巻き込まれて落ちない(失敗は失敗として個別に受け取れる)ので、バッチ処理ではほぼ必須です。

同時実行を絞るだけで、429はかなり減ります。「リトライで殴り返す」前に、「そもそも蛇口を細くする」。これが一番平和なんですよね。


9. 全部つなぐ — 最小レジリエンススタック1枚

5つの守りを見てきました。これを1つの呼び出しの流れに並べると、こうなります。本番のLLM呼び出しは、だいたいこの順番で通すのが定石です。

[アプリ] 
   ↓ ① 同時実行リミッタ(蛇口を細く / 429を事前回避)
   ↓ ② サーキットブレーカー(落ちてる相手なら即・逃がす)
   ↓ ③ タイムアウト付き呼び出し(無限待ちを殺す)
   ↓ ④ リトライ:retryable限定 + retry-after優先 + Full Jitter
   ↓     失敗が続いたら…
   ↓ ⑤ フォールバック(別モデル → 別プロバイダ → 定型文)
   ↓ (副作用は冪等キーで二重実行を防止)
[LLM API]

専門的には、この役割分担を Failure Budget Allocation(失敗予算の配分) と呼びます。

  • リトライは「一過性のノイズ」を吸収する(瞬間的な混雑・瞬断)
  • サーキットブレーカーは「劣化したエンドポイント」を吸収する(数十秒〜数分の不調)
  • フォールバックは「長期の障害」を吸収する(プロバイダが長時間ダウン)

それぞれ守る時間スケールが違うんですよね。だから1つで十分、じゃなくて、層にして重ねる。

そして、忘れちゃいけない最後の1枚が予算ガード(コストの天井)です。リトライもフォールバックも「呼び直す」操作なので、設定をミスるとコストが暴走します。「1リクエストあたり最大○回まで」「1日あたり最大$○まで」みたいな上限を、コードか監視で必ず持っておく。

守りを足すほど呼び出し回数は増える。だから「いくらまで使ったら止める」のブレーキも、必ずセットで。


10. 人間とAIの役割分担 — 何を握り、何を任せるか

ここまで実装の話をしてきましたが、この記事の裏テーマは「AI時代に、人間は何を設計するのか」です。

レジリエンス設計でいうと、線引きはけっこうハッキリしています。

領域 誰が決める 中身
SLO(どこまで落ちてOKか) 人間 「この機能は99.9%応答」「フォールバックは安モデルでも可」等の方針
失敗時の振る舞い 人間 落ちたら何を返すか、どこに逃がすか、いつ諦めるか
不可逆操作の扱い 人間 課金・送信・公開・削除をリトライ対象にするかの判断
コスト上限 人間 いくらまで使ったら止めるか
↑を実装するコード AI バックオフ関数、CBクラス、テスト、型定義の生成
失敗ログの一次分類 AI エラーの束を「retryable / non-retryable」に仕分ける下書き

つまり、「何を・なぜ(What / Why)」は人間が握り、「どう作るか(How)」はAIに任せる。AIはバックオフのコードを一瞬で書いてくれます。でも「課金処理を自動リトライしていいか」は、ビジネスの判断であって、AIに丸投げしちゃいけない領域なんですよね。

ここで、AIに手伝ってもらうためのプロンプト例を3本置いておきます。どれも「最後の判断は人間」を前提に、AIには材料出しをさせる作りです。

プロンプト1:失敗の棚卸し(分類の下書きをAIに作らせる)

あなたはSREです。以下のエラーログ(直近1週間ぶん)を読み、LLM API呼び出しの
失敗を「retryable / non-retryable / 要調査」の3つに分類してください。

# 出力フォーマット
| エラー種別 | HTTPコード | 分類 | リトライ方針 | 根拠 |

# ルール
- 各行に「なぜその分類か」の根拠を必ず書く
- 不確かなものは断定せず「要調査」に入れ、確認すべき点を書く
- 最終判断は人間が行う前提で、推奨にとどめること

# ログ
(ここにログを貼る。※APIキー・個人情報・固有IDはマスキングしてから貼る)

プロンプト2:リトライ&フォールバック方針のレビュー

以下は私たちのLLM呼び出しのリトライ/フォールバック設定です。
本番運用の観点で、危険な点・抜けを批判的にレビューしてください。

# 観点
- 非冪等な操作(課金/送信/公開/削除)をリトライ対象に含めていないか
- retry-after ヘッダを尊重しているか
- ジッターは入っているか(thundering herd対策)
- タイムアウトは設定されているか(無限待ち防止)
- フォールバック発動を記録・監視しているか
- コスト暴走の上限はあるか

# 設定
(ここに設定やコードを貼る)

指摘は「重大度(高/中/低)」付きで。直し方も添えて。ただし採用判断は人間が行う。

プロンプト3:不可逆操作の洗い出し

このコードベースの中で、LLMの結果をもとに実行される「副作用のある操作」を
すべて洗い出してください。特に、リトライされると二重実行が起きうる
不可逆操作(送信・課金・公開・削除・外部書き込み)を最優先で列挙し、
それぞれに冪等性の担保があるか(あるなら方法、ないなら危険度)を表にしてください。
最後に「人間が必ず確認すべき箇所」を3つに絞って提示してください。

11. やりがちな落とし穴6選 + 撤退ライン

ひととおり守りを学んだあとに、足を踏み外しやすいポイントをまとめます。心配性の自分が総点検しました。

# 落とし穴 なぜ危険 対策
1 何でもリトライ 401や400を再試行しても無駄に相手を叩き、429を悪化 retryable判定を1箇所に集約
2 ジッター無しの一斉リトライ thundering herdで自分たちが原因の障害に Full Jitterを必ず入れる
3 タイムアウト未設定 1つの遅延がアプリ全体を道連れ(カスケード障害) 全体+ストリームidleの2本立て
4 非冪等操作のリトライ 二重課金・メール二重送信=信頼の即死 冪等キー+アプリ側のrunOnceガード
5 静かなフォールバック 品質劣化に誰も気づかない 発動を必ずログ&メトリクス化
6 リトライ/フォールバックのコスト暴走 守りがそのまま請求書爆発に 1リクエスト/1日あたりの上限ガード

そして、忘れがちな「撤退ライン(ここまで来たら、リトライをやめる)」も決めておきます。

  • 回数の上限:「最大5回」など。無限リトライは禁止。
  • 総時間の上限:「トータル90秒で諦めて、ユーザーには穏やかに降参する」。待たせ続けない。
  • コストの上限:「この処理に$○以上かかったら止める」。

「諦める」を設計に入れるのは、負けじゃないんです。いつまでも粘るより、潔く次善の手に切り替えるほうが、ユーザーには優しい。


12. 安全に運用するための境界

レジリエンスは「壊れにくさ」の話でしたが、運用に出す以上、安全(セキュリティ)の境界も最後に一緒に押さえておきます。

  • 不可逆操作は、リトライ前提でも人間ゲートを検討する:課金・送信・公開・削除など「戻せない操作」は、冪等キーで守るのが最低ライン。金額や影響が大きいものは、自動リトライではなく「人間が最終OKを出す」ステップを挟むのが安全です。
  • エラーログにPII(個人情報)・シークレットを貼らない:失敗を分類するためにログをAIに読ませることがありますが、APIキー・個人情報・固有IDはマスキングしてから。ログは漏れる前提で、最初から入れない。
  • 外部由来のテキストは「データ」として扱う:フォールバックで別ソースの応答を混ぜるとき、その中身を「命令」として実行させない。プロンプトインジェクション(外部テキストに紛れ込んだ指示にAIが乗っ取られる)対策として、外部テキストはあくまでデータとして囲って渡す。
  • リトライとレート制限は表裏一体:自分のリトライが、巡り巡って自分のレート制限を悪化させる。だから「守り」を入れたら、必ず失敗率・リトライ率・フォールバック率・p99レイテンシを観測して、効きすぎ/効かなすぎを調整する。

レジリエンスは「入れて終わり」じゃなくて、「観測しながら育てる」ものなんですよね。


おわりに — 落ちる前提の柵は、明日の自分への置き手紙

ここまで、5つの守りを積み上げてきました。最後に、ぎゅっとまとめます。

  1. 失敗を分類する — retryableか、そうでないか。判定は1箇所に。
  2. リトライ — 指数バックオフ + Full Jitter。retry-after は最優先。
  3. タイムアウト — 無限待ちを殺す。カスケード障害を防ぐ。
  4. 冪等性 — 送る・課金する操作は、キーで守ってからリトライ。
  5. フォールバック & サーキットブレーカー — 1つ落ちても、機能は生かす。

最初に言ったとおり、LLMは必ず落ちます。 それは設計のミスじゃなくて、前提条件。だから、落ちないようにがんばるんじゃなくて、落ちても擦り傷で済むように、柵を置いておく。

これって、ちょっと後ろ向きに聞こえるかもしれません。でも、僕は逆だと思っていて。

柵があるから、安心してたくさん作れる。 落ちたら全部止まる、と思ってると、怖くて本番に出せない。でも「たまに落ちても大丈夫な設計」を一度作っておけば、その上で思いっきり機能を増やせる。レジリエンス層は、一度作ればずっと効き続ける資産なんですよね。使い捨ての対症療法じゃなくて、積み上がる土台。

そして、何より。

深夜にアラートで叩き起こされる未来の自分を想像してみてください。「429でサービス停止」「二重課金で謝罪対応」。今日30分かけてバックオフと冪等キーを入れておくのは、その未来の自分を、ちゃんと守ってあげる行為なんです。

過去の自分を責める軸じゃなくて、未来の自分への思いやりの軸。今日の小さな柵が、明日の自分の「あざっす」になる。僕はそういう気持ちで、こういう地味な守りを入れています。

最後に、今日からの一歩を4つだけ。全部いっぺんにやらなくていいです。1つでいい。

  1. いま本番で動いてるLLM呼び出しを1つ選ぶ。
  2. タイムアウトを設定する(まずこれだけで「無限待ち」が消える)。
  3. retryable限定のリトライ + Full Jitterを足す(10行で本番品質に近づく)。
  4. その呼び出しが副作用を持つなら、冪等キーで二重実行を止める。

ここまで読んでくれて、ありがとうございました。あなたのAI機能が、本番でも静かに、しぶとく動き続けますように。


参考にした一次情報・資料

※本記事のコードはすべて汎用サンプルです。モデル名・しきい値・ドメインはダミーなので、ご自身の環境に合わせて調整してください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?