2026.04.01AI AgentTypeScript

LLM は落ちる前提で書く — primary → retry → fallback の最小実装

SpecPilot の Agent を動かしていると、 LLM プロバイダ側のエラー がちょくちょく出る。Anthropic API の 529 (Overloaded)、Vertex AI のリージョン quota エラー、ネットワークタイムアウト。1 回当たりの失敗率は低いけれど、 Agent パイプラインが Extractor → Question → Designer → Linter の 4 段直列だと「どこかが必ず転ぶ」確率がそこそこ無視できなくなる

なので、Agent から SDK を直接叩くのをやめて、 callWithRetry() で包むようにした。やってることは単純で、 「primary を 3 回試して、ダメなら fallback に逃げてもう 3 回試す」だけ

全体

export async function callWithRetry(
  registry: ProviderRegistry,
  opts: LLMRequestOptions,
  retry: RetryOptions = {},
): Promise<LLMResponse> {
  const maxRetries = retry.maxRetries ?? 2;
  const initial = retry.initialDelayMs ?? 1000;

  const tryProvider = async (provider: LLMProvider): Promise<LLMResponse> => {
    let lastErr: unknown;
    for (let i = 0; i <= maxRetries; i++) {
      try {
        return await provider.call(opts);
      } catch (err) {
        lastErr = err;
        if (i < maxRetries) {
          await delay(initial * Math.pow(2, i));
        }
      }
    }
    throw lastErr;
  };

  try {
    return await tryProvider(registry.primary);
  } catch (err) {
    if (!registry.fallback) throw err;
    console.warn(
      `[agent-core] primary ${registry.primary.name} failed, falling back to ${registry.fallback.name}`,
      err,
    );
    return tryProvider(registry.fallback);
  }
}

50 行くらい。中身は exponential backoff の素朴な実装。

Exponential backoff は 1s → 2s → 4s

delay(initial * Math.pow(2, i))i は 0, 1, 2 と進むので、 1 秒 → 2 秒 → 4 秒。 3 回叩いて合計 7 秒待つ計算。

これより長くすると Agent の UX として「動いてる感」が無くなる。短すぎると、サーバー側の transient error が回復する前に諦めることになる。1-2-4 秒は経験的にちょうど良かった。

fallback は console.warn で「逃げた」を残す

console.warn(
  `[agent-core] primary ${registry.primary.name} failed, falling back to ${registry.fallback.name}`,
  err,
);

fallback が起動した事実は 必ずログに残す。なぜなら、 fallback で運よくレスポンスが返ってしまうと、ユーザー的には「動いた」ように見えて、 primary 側の障害に気づかない から。

console.warn を Cloud Logging や Sentry で拾うようにしておけば、「最近 Anthropic が頻繁に落ちてるな」みたいなパターンが見える。

呼ぶ側

Agent コードはこれだけ。

const registry = buildRegistry(process.env);
const result = await callWithRetry(registry, {
  systemPrompt: '...',
  userPrompt: '...',
  responseFormat: 'json',
});

buildRegistry の中で「Anthropic (Vertex) を primary、Gemini を fallback」みたいな関係を組む。env で LLM_DEFAULT_PROVIDER=gemini にすれば逆順にも切り替えられる。 Agent 側のコードは触らなくていい のが大事。

switch (def) {
  case 'gemini':
    primary = gemini;
    fallback = anthropic ?? openai;
    break;
  // ...
}

やらないこと


「LLM API は安定している」前提で書くと、確実に運用で痛い目を見る。 「30 回に 1 回は落ちる」前提で書くと、これくらいの薄い retry レイヤーで結構吸収できる。AI 系のコードでは、SDK を直接叩かずに必ず 1 段挟むのが個人的なルール。