LLMを使ったアプリケーションを運用していると、特定のプロバイダーに依存するリスクが気になります。API障害、レート制限、モデルごとの一時的なエラー、予算上限など、失敗する理由は意外と多くあります。
そこで今回は、複数のLLMプロバイダーをあらかじめ登録し、呼び出しに失敗した場合は次のプロバイダーへ切り替えるフォールバック構成を作ります。
結論から言うと、プロバイダー固有の処理をアダプターに閉じ込め、呼び出し側は共通インターフェースだけを見る構成にすると、切り替え処理をシンプルに保てます。
今回作る構成
処理全体の流れを図にすると次の通りです。
今回はNode.jsの標準fetchだけを使い、特定のSDKに依存しない構成にしました。
処理の流れは次のとおりです。
- 優先順位順にプロバイダーを並べる
- 先頭のプロバイダーへリクエストする
- 一時的なエラーならリトライする
- それでも失敗したら次のプロバイダーへ切り替える
- すべて失敗した場合だけエラーを返す
プロバイダーごとにAPI形式が異なる場合でも、アダプター側で変換します。今回は、チャット形式のリクエストとレスポンスを共通化します。
mkdir llm-fallback
cd llm-fallback
npm init -y
npm install -D typescript tsx @types/node
npx tsc --init
Node.jsは標準fetchが利用できる20系以降を前提にしています。
共通インターフェースを定義する
まず、アプリケーション側が扱う型を定義します。
// src/llm.ts
export type Message = {
role: "system" | "user" | "assistant";
content: string;
};
export type GenerateInput = {
messages: Message[];
temperature?: number;
};
export type GenerateOutput = {
text: string;
provider: string;
model: string;
};
export interface LLMProvider {
readonly name: string;
generate(input: GenerateInput): Promise<GenerateOutput>;
}
呼び出し側はLLMProviderだけを扱います。APIキーの名前やエンドポイント、レスポンスのJSON形式などは、このインターフェースの外へ漏らしません。
OpenAI互換形式のプロバイダーを実装する
複数のサービスで似たチャットAPIが使える場合は、共通のアダプターにできます。ただし、実際の互換範囲はプロバイダーによって異なるため、運用前に各APIの仕様を確認します。
// src/openai-compatible.ts
import type {
GenerateInput,
GenerateOutput,
LLMProvider,
} from "./llm.js";
type Config = {
name: string;
baseUrl: string;
apiKey: string;
model: string;
timeoutMs?: number;
};
export class OpenAICompatibleProvider implements LLMProvider {
readonly name: string;
constructor(private readonly config: Config) {
this.name = config.name;
}
async generate(input: GenerateInput): Promise<GenerateOutput> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
this.config.timeoutMs ?? 15_000,
);
try {
const response = await fetch(
`${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
model: this.config.model,
messages: input.messages,
temperature: input.temperature ?? 0.2,
}),
signal: controller.signal,
},
);
if (!response.ok) {
const body = await response.text();
const error = new Error(
`${this.name}: HTTP ${response.status} ${body.slice(0, 300)}`,
);
Object.assign(error, { status: response.status });
throw error;
}
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const text = data.choices?.[0]?.message?.content;
if (!text) {
throw new Error(`${this.name}: response does not contain text`);
}
return {
text,
provider: this.name,
model: this.config.model,
};
} finally {
clearTimeout(timeout);
}
}
}
AbortControllerでタイムアウトを設定している点が重要です。タイムアウトがないと、障害中のプロバイダーを待ち続け、フォールバックが実行されない可能性があります。
リトライとフォールバックを分ける
リトライとフォールバックは似ていますが、役割が異なります。
同じプロバイダーへもう一度送るのがリトライで、別のプロバイダーへ切り替えるのがフォールバックです。
すべてのエラーをリトライすると、認証エラーやリクエスト不正でも無駄な待ち時間が発生します。そこで、429や5xx、タイムアウトなど、一時的なエラーだけを対象にします。
// src/fallback.ts
import type {
GenerateInput,
GenerateOutput,
LLMProvider,
} from "./llm.js";
function getStatus(error: unknown): number | undefined {
if (typeof error === "object" && error !== null && "status" in error) {
const status = (error as { status?: unknown }).status;
return typeof status === "number" ? status : undefined;
}
return undefined;
}
function isRetryable(error: unknown): boolean {
const status = getStatus(error);
if (status === 408 || status === 429) return true;
if (status !== undefined && status >= 500) return true;
return error instanceof Error && error.name === "AbortError";
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function callWithRetry(
provider: LLMProvider,
input: GenerateInput,
retryCount: number,
): Promise<GenerateOutput> {
let lastError: unknown;
for (let attempt = 0; attempt <= retryCount; attempt++) {
try {
return await provider.generate(input);
} catch (error) {
lastError = error;
if (!isRetryable(error) || attempt === retryCount) {
throw error;
}
const delay = 300 * 2 ** attempt;
await sleep(delay);
}
}
throw lastError;
}
export async function generateWithFallback(
providers: LLMProvider[],
input: GenerateInput,
): Promise<GenerateOutput> {
const errors: string[] = [];
for (const provider of providers) {
try {
return await callWithRetry(provider, input, 1);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(message);
console.warn(`[llm-fallback] ${provider.name} failed: ${message}`);
}
}
throw new Error(`All LLM providers failed:\n${errors.join("\n")}`);
}
この例では、各プロバイダーを最大2回呼び出します。つまり、プロバイダーが3つある場合、最悪で6回のリクエストが発生します。課金やレート制限を考えると、リトライ回数は小さく始めるのが安全です。
環境変数で優先順位を設定する
プロバイダーやモデルをコードに直接書かず、環境変数から設定します。
LLM_PRIMARY_URL=https://primary.example.com/v1
LLM_PRIMARY_KEY=primary-api-key
LLM_PRIMARY_MODEL=primary-model
LLM_SECONDARY_URL=https://secondary.example.com/v1
LLM_SECONDARY_KEY=secondary-api-key
LLM_SECONDARY_MODEL=secondary-model
実際のアプリケーションでは、秘密情報を.envファイルやソースコードへコミットしないようにします。デプロイ先のシークレット管理機能から注入する形にします。
呼び出し側は次のようになります。
// src/main.ts
import { OpenAICompatibleProvider } from "./openai-compatible.js";
import { generateWithFallback } from "./fallback.js";
const providers = [
new OpenAICompatibleProvider({
name: "primary",
baseUrl: process.env.LLM_PRIMARY_URL!,
apiKey: process.env.LLM_PRIMARY_KEY!,
model: process.env.LLM_PRIMARY_MODEL!,
}),
new OpenAICompatibleProvider({
name: "secondary",
baseUrl: process.env.LLM_SECONDARY_URL!,
apiKey: process.env.LLM_SECONDARY_KEY!,
model: process.env.LLM_SECONDARY_MODEL!,
}),
];
const result = await generateWithFallback(providers, {
messages: [
{
role: "system",
content: "日本語で簡潔に回答してください。",
},
{
role: "user",
content: "フォールバック構成の利点を一文で説明してください。",
},
],
});
console.log({
provider: result.provider,
model: result.model,
text: result.text,
});
実行コマンドは以下です。
npx tsx src/main.ts
ログには利用したプロバイダー名を必ず残すようにしました。障害発生時に、どのプロバイダーへ切り替わったのか確認できるためです。
実運用で追加したい対策
最小構成としては動きますが、運用ではいくつか注意が必要です。
まず、フォールバック先のモデルが完全に同じ能力とは限りません。JSON出力やツール呼び出しを使う場合は、プロバイダーごとにレスポンスを検証し、形式が崩れた場合も失敗として扱います。
また、書き込み系の処理では無条件の再実行に注意が必要です。LLMへの問い合わせ自体は再実行できても、その出力を使った外部API操作まで重複する可能性があります。外部処理にはリクエストIDや冪等性キーを持たせます。
さらに、次のメトリクスを記録すると改善しやすくなります。
- プロバイダーごとの成功率
- レスポンスタイム
- タイムアウト回数
- フォールバック発生回数
- 入力・出力トークン数
- エラーのHTTPステータス
今回の実装を試してみて、特に重要だと感じたのは「切り替え処理そのもの」よりも、タイムアウト、エラー分類、ログ設計でした。ここが曖昧だと、障害時に別の障害を増やしてしまいます。
まとめ
複数LLMプロバイダーのフォールバック構成は、次の方針で組むと扱いやすくなります。
- プロバイダー固有の処理をアダプターへ分離する
- 呼び出し側は共通インターフェースだけを使う
- 一時的なエラーだけをリトライする
- タイムアウトを必ず設定する
- 切り替え先と利用結果をログに残す
- 出力形式や外部処理の重複を検証する
最初から複雑なルーティング基盤を作らなくても、共通インターフェースと短いフォールバック処理だけで、特定プロバイダーへの依存をかなり減らせます。まずは開発環境で意図的にタイムアウトや429を発生させ、期待どおり切り替わることを確認してから本番へ進めるのがおすすめです。