先月、私が運用している AI エージェントが朝の 9 時から 11 時のあいだ Gemini API の 503 を返し続けました。Google Cloud のステータスページには障害情報が出ておらず、X(旧 Twitter)で検索してようやく一部リージョンで間欠的にエラーが出ていることが分かった、そんな状況でした。
このときサービスは完全に停止したわけではありません。ただ、レスポンスがエラーで埋め尽くされ、ユーザーから「壊れていますか」という問い合わせが十数件届きました。一晩で失った信頼は、半年かけて積み上げたものより大きく感じられました。
ここではあの朝の反省から組み直した「止まらない LLM 構成」の全実装を公開します。Antigravity のエージェント環境で、Gemini・Claude・ローカル Gemma 4 を組み合わせ、プロバイダーが落ちてもユーザー体験を守るためのルーター層・サーキットブレーカー・段階的フォールバックを、コピー&ペーストで動くコードとともに解説します。
この構成を本気で組み直したのは、私が個人開発でアプリ事業とサイト運営を並行して回しているからでもあります。アプリの広告配信も、サイトの定型処理も、裏側では同じように外部 API に依存しています。どこか一つの応答が滞ると、収益とユーザーの信頼が同じ速さで削れていく——その感覚を何度か味わううちに、「止まらないこと」を後付けではなく最初の設計に織り込む癖がつきました。ここから先は、その癖を具体的なコードと数字に落とし込んでいきます。
マルチプロバイダー構成が必要になる瞬間
「LLM が落ちる」と聞くと、多くの方は「そんなに頻繁に起きない」と感じるかもしれません。確かに公式ステータスページで可視化される大規模障害は月に 1〜2 回程度です。
しかし実運用では、次のような部分的障害が毎週のように起きています。
特定リージョンだけ 503 が頻発する(グローバル指標では健全)
レート制限に達したが、復旧まで 3〜5 分かかる
モデルが一時的に極端に遅くなる(p99 が 30 秒を超える)
特定のトークン数だけ INTERNAL エラーを返す
単一プロバイダー構成だと、これらがそのままユーザーへ露出します。マルチプロバイダー構成の本質的な価値は「完全停止を防ぐこと」ではなく、「部分障害を吸収してユーザーに気づかせないこと」です。
この前提を共有した上で、どう組めば破綻しないかを設計していきます。
3 層の判断軸 — コスト・遅延・能力で切り替える
実装に入る前に、ルーティングの判断基準を整理しておきます。私が本番で回している構成は次の 3 層で考えています。
第 1 層は 能力軸 です。そのリクエストに最低限必要な能力をプロバイダーが提供しているかどうかで判定します。たとえば 100 万トークンのコンテキストが必要なら Gemini 一択、ツールコールの複雑な JSON スキーマが必要なら Claude、オフライン環境なら Gemma を使う、といった具合です。
第 2 層は 遅延軸 です。同等能力を満たすプロバイダーが複数ある場合、p95 レイテンシが低い方を優先します。ただし現在のレスポンス時間ではなく、過去 5 分間の移動平均を見るのがコツです。スパイクで判断すると切り替えが震えます。
第 3 層は コスト軸 です。能力と遅延が同等なら、トークン単価が安い方を選びます。これは最後の判断軸にするのが重要です。コストを最優先にすると、障害時にコストで負けた高品質プロバイダーへの切り替えがうまく働きません。
この順序を逆にすると、たとえば「コストで Gemini を選んだが 503 が返ってきた → Claude にフォールバック → でも Claude の方が遅い → さらに Gemma にフォールバック」というループが発生しやすくなります。必ず「能力 → 遅延 → コスト」の順で判定してください。
実装 1: プロバイダーの抽象化と型定義
まず、プロバイダーを差し替え可能にするための共通インターフェースを定義します。ここで手を抜くと、後で 3 層の判断軸を実装するときに全体が崩れます。
// src/llm/provider.ts
// 目的: 各 LLM プロバイダーを同一インターフェースで扱い、ルーター層で切り替え可能にする
export interface LLMRequest {
messages : Array <{ role : "system" | "user" | "assistant" ; content : string }>;
maxTokens ?: number ;
temperature ?: number ;
jsonSchema ?: Record < string , unknown >; // 能力軸: structured output 必要かの判定に使う
minContextTokens ?: number ; // 能力軸: 必要な入力トークン数
}
export interface LLMResponse {
content : string ;
inputTokens : number ;
outputTokens : number ;
provider : string ; // "gemini" | "claude" | "gemma-local"
latencyMs : number ;
attemptCount : number ; // フォールバック何回目で成功したか
}
export interface LLMProvider {
readonly name : string ;
readonly capabilities : {
maxContext : number ; // コンテキストウィンドウ上限
supportsJsonSchema : boolean ; // Structured Output 対応
requiresNetwork : boolean ; // ローカル実行可能かどうか
};
readonly cost : {
inputPer1M : number ; // USD
outputPer1M : number ; // USD
};
invoke ( req : LLMRequest , signal : AbortSignal ) : Promise < LLMResponse >;
healthCheck () : Promise < boolean >; // ping 相当
}
期待する出力: このインターフェースを実装したプロバイダー(後述の GeminiProvider, ClaudeProvider, GemmaLocalProvider)は、ルーター層から provider.invoke(req, signal) として呼び出されます。
なぜ LLMResponse に provider と attemptCount を含めるかというと、本番で問題が起きたときに「どのプロバイダーが何回目の試行で応答したか」を監査ログに残すためです。これがないと、障害の再現がほぼ不可能になります。
続いて、各プロバイダーの実装です。ここでは Gemini の例だけ載せますが、Claude・Gemma も同じパターンで書けます。
// src/llm/gemini-provider.ts
import { GoogleGenerativeAI } from "@google/generative-ai" ;
import type { LLMProvider, LLMRequest, LLMResponse } from "./provider" ;
export class GeminiProvider implements LLMProvider {
readonly name = "gemini" ;
readonly capabilities = {
maxContext: 1_048_576 ,
supportsJsonSchema: true ,
requiresNetwork: true ,
};
readonly cost = { inputPer1M: 0.15 , outputPer1M: 0.6 };
private client : GoogleGenerativeAI ;
private modelName = "gemini-2.5-flash" ;
constructor ( apiKey : string ) {
this .client = new GoogleGenerativeAI (apiKey);
}
async invoke ( req : LLMRequest , signal : AbortSignal ) : Promise < LLMResponse > {
const start = Date. now ();
const model = this .client. getGenerativeModel ({
model: this .modelName,
generationConfig: {
maxOutputTokens: req.maxTokens ?? 4096 ,
temperature: req.temperature ?? 0.7 ,
... (req.jsonSchema ? { responseMimeType: "application/json" , responseSchema: req.jsonSchema } : {}),
},
});
// AbortSignal を SDK に渡す: タイムアウト時に確実にソケットを閉じるため
const result = await model. generateContent ({
contents: req.messages. map (( m ) => ({ role: m.role === "assistant" ? "model" : m.role, parts: [{ text: m.content }] })),
}, { signal });
const text = result.response. text ();
const usage = result.response.usageMetadata;
return {
content: text,
inputTokens: usage?.promptTokenCount ?? 0 ,
outputTokens: usage?.candidatesTokenCount ?? 0 ,
provider: this .name,
latencyMs: Date. now () - start,
attemptCount: 1 ,
};
}
async healthCheck () : Promise < boolean > {
try {
const controller = new AbortController ();
const timer = setTimeout (() => controller. abort (), 3000 );
await this . invoke (
{ messages: [{ role: "user" , content: "ok" }], maxTokens: 5 },
controller.signal
);
clearTimeout (timer);
return true ;
} catch {
return false ;
}
}
}
重要なポイント : invoke で必ず AbortSignal を受け取り、SDK に渡すこと。これを怠ると、タイムアウトを設定してもソケットが開きっぱなしになり、プロセスのメモリが増え続けます。私も最初これで 3 日ほどハマりました。
実装 2: サーキットブレーカーとヘルスチェック
プロバイダーを差し替えられるだけでは不十分です。「落ちているプロバイダーに何度も投げる」挙動を防ぐ層が必要になります。これがサーキットブレーカーです。
// src/llm/circuit-breaker.ts
// 目的: 連続失敗回数を監視し、閾値超過で指定期間は呼び出しをスキップする
export type CircuitState = "closed" | "open" | "half-open" ;
export interface CircuitBreakerConfig {
failureThreshold : number ; // 連続失敗何回で open にするか
openDurationMs : number ; // open 状態を維持する時間
halfOpenSuccessCount : number ; // half-open で何回成功したら closed に戻すか
}
export class CircuitBreaker {
private state : CircuitState = "closed" ;
private consecutiveFailures = 0 ;
private consecutiveHalfOpenSuccess = 0 ;
private openedAt = 0 ;
constructor (
private readonly name : string ,
private readonly config : CircuitBreakerConfig ,
) {}
canExecute () : boolean {
if ( this .state === "closed" ) return true ;
if ( this .state === "open" ) {
const elapsed = Date. now () - this .openedAt;
if (elapsed >= this .config.openDurationMs) {
this .state = "half-open" ;
this .consecutiveHalfOpenSuccess = 0 ;
return true ;
}
return false ;
}
// half-open: 限定的に試行を許可
return true ;
}
recordSuccess () : void {
if ( this .state === "half-open" ) {
this .consecutiveHalfOpenSuccess ++ ;
if ( this .consecutiveHalfOpenSuccess >= this .config.halfOpenSuccessCount) {
this . reset ();
}
return ;
}
this .consecutiveFailures = 0 ;
}
recordFailure () : void {
if ( this .state === "half-open" ) {
// half-open で 1 回でも失敗したら即 open に戻す
this .state = "open" ;
this .openedAt = Date. now ();
return ;
}
this .consecutiveFailures ++ ;
if ( this .consecutiveFailures >= this .config.failureThreshold) {
this .state = "open" ;
this .openedAt = Date. now ();
}
}
private reset () : void {
this .state = "closed" ;
this .consecutiveFailures = 0 ;
this .consecutiveHalfOpenSuccess = 0 ;
}
getState () : CircuitState {
return this .state;
}
}
設計判断の背景 : half-open 状態は、open から即 closed に戻すと「まだ不安定なプロバイダー」に全トラフィックを戻してしまう危険があるため用意します。私の本番設定は failureThreshold: 3, openDurationMs: 30000, halfOpenSuccessCount: 2 です。3 回連続で失敗したら 30 秒閉鎖し、復帰テストは 2 回成功で確定、という挙動です。
よく失敗例として見かけるのは、failureThreshold: 10 のような大きな値にするケースです。これだとユーザーがすでに 10 回エラーを見てから回路が開くことになり、フォールバックの意味が薄れます。3〜5 回が実用的な境界です。
実装 3: 段階的フォールバックのルーター
ここが記事の核心です。3 層の判断軸・各プロバイダー・サーキットブレーカーを統合し、実際に使えるルーターを組み立てます。
// src/llm/router.ts
// 目的: リクエストを能力・遅延・コストの順で評価し、最適なプロバイダーへ振り分ける
import type { LLMProvider, LLMRequest, LLMResponse } from "./provider" ;
import { CircuitBreaker } from "./circuit-breaker" ;
interface ProviderSlot {
provider : LLMProvider ;
breaker : CircuitBreaker ;
recentLatencyMs : number []; // 直近 N 回のレイテンシ
}
export class LLMRouter {
private slots : ProviderSlot [] = [];
private readonly latencyWindow = 20 ;
constructor ( providers : LLMProvider []) {
this .slots = providers. map (( p ) => ({
provider: p,
breaker: new CircuitBreaker (p.name, {
failureThreshold: 3 ,
openDurationMs: 30000 ,
halfOpenSuccessCount: 2 ,
}),
recentLatencyMs: [],
}));
}
async execute ( req : LLMRequest , timeoutMs = 20000 ) : Promise < LLMResponse > {
const candidates = this . rankProviders (req);
if (candidates. length === 0 ) {
throw new Error ( "No capable provider available for this request" );
}
const errors : Array <{ provider : string ; error : unknown }> = [];
let attemptCount = 0 ;
for ( const slot of candidates) {
if ( ! slot.breaker. canExecute ()) {
continue ; // open 状態のプロバイダーはスキップ
}
attemptCount ++ ;
const controller = new AbortController ();
const timer = setTimeout (() => controller. abort (), timeoutMs);
try {
const res = await slot.provider. invoke (req, controller.signal);
clearTimeout (timer);
slot.breaker. recordSuccess ();
this . recordLatency (slot, res.latencyMs);
return { ... res, attemptCount };
} catch (err) {
clearTimeout (timer);
slot.breaker. recordFailure ();
errors. push ({ provider: slot.provider.name, error: err });
// 次の候補に進む
}
}
const errMsg = errors. map (( e ) => `${ e . provider }: ${ String ( e . error ) }` ). join ( " / " );
throw new Error ( `All providers failed after ${ attemptCount } attempts — ${ errMsg }` );
}
private rankProviders ( req : LLMRequest ) : ProviderSlot [] {
// 第 1 層: 能力軸でフィルタ
const capable = this .slots. filter (( s ) => {
if (req.jsonSchema && ! s.provider.capabilities.supportsJsonSchema) return false ;
if (req.minContextTokens && req.minContextTokens > s.provider.capabilities.maxContext) return false ;
return true ;
});
// 第 2 層: 遅延軸でソート(直近 20 回の中央値)
// 第 3 層: 同遅延帯ならコスト順
return capable. sort (( a , b ) => {
const latA = this . medianLatency (a);
const latB = this . medianLatency (b);
const latDiff = latA - latB;
if (Math. abs (latDiff) > 500 ) return latDiff; // 500ms 以上の差は遅延で判断
return a.provider.cost.outputPer1M - b.provider.cost.outputPer1M;
});
}
private medianLatency ( slot : ProviderSlot ) : number {
if (slot.recentLatencyMs. length === 0 ) return 1500 ; // 初期値
const sorted = [ ... slot.recentLatencyMs]. sort (( a , b ) => a - b);
return sorted[Math. floor (sorted. length / 2 )];
}
private recordLatency ( slot : ProviderSlot , latencyMs : number ) : void {
slot.recentLatencyMs. push (latencyMs);
if (slot.recentLatencyMs. length > this .latencyWindow) {
slot.recentLatencyMs. shift ();
}
}
}
期待する出力: router.execute(req) を呼ぶと、能力を満たすプロバイダーを遅延・コスト順に並べ、サーキットブレーカーが閉じているものから順に試行します。全て失敗した場合のみ例外を投げ、そのメッセージには失敗したプロバイダーと原因が全て含まれます。
使い方はこうなります。
// src/main.ts
import { GeminiProvider } from "./llm/gemini-provider" ;
import { ClaudeProvider } from "./llm/claude-provider" ;
import { GemmaLocalProvider } from "./llm/gemma-local-provider" ;
import { LLMRouter } from "./llm/router" ;
const router = new LLMRouter ([
new GeminiProvider (process.env. GEMINI_API_KEY ! ),
new ClaudeProvider (process.env. ANTHROPIC_API_KEY ! ),
new GemmaLocalProvider ( "http://localhost:1234/v1" ), // LM Studio
]);
const res = await router. execute ({
messages: [{ role: "user" , content: "製品説明を JSON で生成してください" }],
jsonSchema: { type: "object" , properties: { name: { type: "string" }, description: { type: "string" } } },
maxTokens: 500 ,
});
console. log ( `Provider: ${ res . provider }, Attempts: ${ res . attemptCount }, Latency: ${ res . latencyMs }ms` );
// 例: "Provider: claude, Attempts: 2, Latency: 1843ms"
// Gemini が失敗し Claude にフォールバックしたことが 1 行で分かる
本番で使う際は、APIKey を Upstash Redis によるエッジキャッシュとレート制限 のような仕組みと組み合わせて保護すると、より堅牢になります。
タイムアウト・リトライ設定の落とし穴
ここまでの実装で骨格はできました。しかし、タイムアウトとリトライの設定を雑にすると、フォールバックが機能しません。私が実際に踏んだ失敗から、4 つの落とし穴を共有します。
落とし穴 1: タイムアウトが長すぎる
全体のタイムアウトを 60 秒にすると、Gemini で 59 秒待ってから Claude に切り替える挙動になります。ユーザーは 59 秒もタイプライターを見つめることになります。私のおすすめは、プロバイダー単位のタイムアウトを p95 の 1.5 倍に設定することです。Gemini の p95 が 8 秒なら、個別タイムアウトは 12 秒です。
落とし穴 2: SDK 内部のリトライと二重化する
Google や Anthropic の公式 SDK は、デフォルトで 2〜3 回の内部リトライを行います。この上にルーター側でリトライを重ねると、ユーザーは最大 9 回の待機を経験します。SDK の内部リトライは無効化するか、せいぜい 1 回に抑えてください。Anthropic SDK なら maxRetries: 0 を設定します。
落とし穴 3: ストリーミング API とタイムアウトの相性
ストリーミングレスポンスの場合、最初のチャンクが返るまでの時間と、全体が完了するまでの時間を区別する必要があります。全体タイムアウトだけだと、長い応答で正常動作中に切断されます。time-to-first-byte と total-duration の 2 段階で設定してください。
落とし穴 4: フォールバック中のユーザー通知タイミング
フォールバックしたことを即座に UI に表示すると「何か失敗した」という印象を与えます。本番運用では、初回の試行が 3 秒以上かかる場合のみ「別のモデルで処理しています」とさりげなく表示し、切り替え自体は裏で進める設計にしています。
本番運用で詰まった 4 つの問題
実装が動き始めても、本番 2〜3 週間で新しい問題が見えてきます。ここは記事のハイライトです。
問題 1: Gemma ローカルのヘルスチェックが遅くてルーティング全体が停滞した
当初、すべてのプロバイダーのヘルスチェックを同期で順番に回していました。Gemma(LM Studio)の初回起動時は 10 秒ほどかかるため、ルーティング判断に 10 秒プラスされる状態でした。対策として、ヘルスチェックは完全に非同期(バックグラウンド、60 秒間隔)で回し、結果をキャッシュする構造に変えました。
問題 2: サーキットブレーカーの状態がプロセス再起動でリセットされる
インスタンスを 10 台で負荷分散していると、1 台が再起動するたびにブレーカーが closed に戻り、まだ不安定なプロバイダーに 3 回失敗するまでトラフィックを流してしまいます。解決策は Redis に状態を保存することです。Antigravity で扱うなら Cloudflare AI Gateway のような共有キャッシュ層と組み合わせると楽です。
問題 3: JSON スキーマ対応プロバイダーに偏る
Structured Output を要求するリクエストが多いプロダクトでは、Gemma ローカルに処理が流れず、負荷分散として機能しませんでした。LLMRequest.jsonSchema を持たないリクエストを意図的に増やす、あるいは Gemma で JSON スキーマを使うラッパーを作るなどの判断が必要です。私は前者を選びました(構造化する必要のないプロンプトは平文で受ける)。
問題 4: コスト計算が月末でズレる
各プロバイダーのダッシュボードと自前集計の数値が 3〜5% ずれます。原因はトークナイザの違いです。Gemini・Claude・Gemma でトークン定義が微妙に異なり、同じテキストでもカウントが変わります。自前集計は「目安」として使い、決定的な数値は各ダッシュボードを正とする運用にしました。
サーキットブレーカーとヘルスチェックの背景にある考え方を、LLM 以外の文脈で整理できます。
モニタリングとアラート設計
最後に、運用のためのモニタリング項目です。ダッシュボードには最低この 5 指標を置いてください。
プロバイダー別の成功率(直近 5 分の移動平均)
プロバイダー別の p95 レイテンシ
フォールバック発生率(attemptCount > 1 の割合)
サーキット open イベント数(時系列)
日次コスト(プロバイダー別・ユースケース別)
アラート設計では、「成功率 < 95% が 5 分続いたら通知」「サーキットが 10 分以上 open のままなら通知」の 2 つを必ず入れます。前者は部分障害、後者は長期障害の検知です。
細かい点ですが、フォールバック発生率が 20% を超えたら「プライマリプロバイダーの選定が間違っている」サインです。本来プライマリにすべきモデルを二次に置いていないか、見直すタイミングになります。
エージェントの運用監視を本格的に整えるなら、Langfuse を使ったエージェント可観測性ガイド も併読してください。プロバイダー単位のトレースと組み合わせると、どのリクエストでどのプロバイダーが使われ、何が起きたかが 1 画面で追えます。
プロバイダー選定の早見表
毎回ルーターのコードを読み返すのは現実的ではありません。私は次の早見表を手元に置き、新しいユースケースが来るたびに「どのプロバイダーを一次に置くか」をここで仮決めしてから実装に入っています。
この表で大切なのは「最終防衛線にローカル Gemma を必ず一枠置く」という方針です。クラウド 2 社が同時に揺れる確率は低いものの、私自身、両方のレート制限に同じ夕方の時間帯でぶつかった経験があります。品質が多少落ちても応答が返り続けることのほうが、無言のエラーよりはるかにユーザーに優しいと考えています。
3 か月運用して見えた実測値
設計の正しさは、運用してみないと分かりません。上記の構成を本番で 3 か月回したあと、ダッシュボードから拾った代表的な数字を共有します。プロダクトの特性で値は変わりますが、桁感の参考にはなるはずです。
注目してほしいのは、エラー率が一桁以上下がった一方で、p95 レイテンシはほとんど悪化していない点です。フォールバックは「全リクエストに毎回かかる重し」ではなく、「6% のリクエストにだけ静かに効くショックアブソーバー」として働いているのが数字から読み取れます。コストが +4% で収まったのは、ローカル Gemma に約 9% を逃がしている効果が大きいです。
ここで一つ正直に書いておくと、最初の 1 か月はフォールバック率が 18% まで跳ね上がりました。原因はプライマリの選定ミスで、構造化出力が多いユースケースなのに Gemini を一次に置いていたためです。Claude を一次に入れ替えた途端に 6% へ落ち着きました。早見表の「構造化出力が必須なら Claude を一次」という行は、この失敗から書き起こしたものです。
障害ふりかえりテンプレート — 次の朝に同じ後悔をしないために
仕組みを組んでも、障害は形を変えて再びやってきます。大切なのは、起きた障害を確実に学びへ変換することです。私はフォールバックが大量発生した日には、必ず次のテンプレートで短い記録を残しています。長く書く必要はありません。15 分で埋まる粒度が続けるコツです。
## 障害メモ YYYY-MM-DD
### 何が起きたか(事実だけ)
- 発生時刻 / 復旧時刻:
- 影響を受けたプロバイダー:
- フォールバック発生率のピーク:
- ユーザーに露出したエラー件数:
### 検知はどう働いたか
- 最初に気づいたきっかけ(アラート / 問い合わせ / 自分の目視):
- アラートは想定どおり鳴ったか:
### ルーターはどう振る舞ったか
- サーキットは open になったか / 何秒で:
- フォールバック先は適切だったか:
- 想定外の挙動:
### 次への一手(1 つだけ)
- 設定値の変更 / コードの修正 / 監視の追加 のどれか 1 つ:
このテンプレートで効くのは、最後の「次への一手を 1 つだけ」という制約です。障害の直後は反省点が山ほど見えますが、一度に全部直そうとすると結局どれも中途半端になります。私は壁紙アプリの広告まわりで何度も障害対応をしてきましたが、毎回「一晩で一手」を積み重ねた構成のほうが、勢いで大改修した構成より確実に頑丈になりました。フェイルオーバーの設計も同じで、運用で削り出した小さな修正の集積が、最終的にいちばん信頼できる防御になります。
全体を振り返って — 今日できる最初の一歩
「マルチプロバイダー構成はいつか必要」と頭では分かっていても、忙しい中で手が付かない領域です。それでも、たった 30 分でできる最初の一歩があります。
今日やるべきこと: 現在使っている 1 プロバイダーの 503 を手元で再現させ、エラーハンドリングがどう振る舞うか観察する
環境変数の API キーをわざと不正な値に書き換えて、自分のエージェントが落ちる様子を見るのです。これだけで、フォールバックの必要性が数字ではなく体感として腹落ちします。
そこから先は、この記事のコードをベースに、あなたのプロダクトのリクエスト特性に合わせて判断軸の重み付けを調整していってください。完璧な構成を最初から作ろうとせず、1 つずつ段階を踏むのが結局は最短ルートだと、何度か失敗しながら学びました。
サービスが止まらないことは、ユーザーにとって「当たり前」です。その当たり前を支える仕組みを自分の手で作れるのは、AI 時代のエンジニアにとって大きな強みになるはずです。