Claude API を使い始めた頃、私は正直なところ「Anthropic 側は十分に安定しているから、自前の冗長化は不要」と思っていました。実際のところ、コールの 99.9% 以上は問題なく返ってきます。ところが本番環境に置いた途端、残りの 0.1% が静かにサービスを殺しにきます。リージョン単位の一時的な劣化、特定モデルだけ起きるレート制限、長尺プロンプトだけ発生するタイムアウト — こうした小さな綻びは、まとめて「ある日の夜のピークタイムに同時発生する」性質があるのです。
ここではSonnet・Haiku・Opus を含む Claude のモデル群をフォールバックチェーンとして扱い、サービスを止めずに降格配信する本番パターンを整理します。読み終えたとき、あなたは「単一モデル障害で全停止する設計」から「劣化しながら生き残る設計」へ、具体的なコードとともに移行できるはずです。
単一モデル前提の設計が壊れる3つの瞬間
マルチモデルフォールバックを語る前に、なぜそもそも冗長化が要るのかを整理しておきます。私が現場でぶつかって、対処に数日かけた3つのパターンです。
- レート制限が特定モデルだけに当たる: Sonnet の requests-per-minute を使い切ったタイミングでも、同じアカウントの Haiku にはまだ余裕がある、という状況は日常的に起きます。単一モデル前提だと、この 429 でユーザー画面全体がエラーになります。
- 長尺プロンプトだけタイムアウトする: 同じエンドポイントでも、1 万トークンのプロンプトは 30 秒で返ってきても、10 万トークンの RAG コンテキストが詰まった瞬間にタイムアウト率が跳ね上がります。モデル側の問題ではなく、自分のプロンプト設計が招く局所障害です。
- 特定モデルのデプロイ直後に応答品質がばらつく: 新モデルがロールアウトされた直後は、まれに応答時間が通常の 2〜3 倍になります。Extended Thinking 系のパラメータを入れていると顕著で、古い Sonnet に一時的に逃がせると体感品質を保てます。
この 3 つは、それぞれ対処法が違います。ところが共通項があって、「プライマリが失敗したら、別モデルに降格する」というチェーンを持っているだけで、最悪のユーザー影響を回避できるのです。完璧な解ではありませんが、何もしないよりはるかに効きます。
フォールバック設計の 3 つの判断軸
どのモデルをどの順番で並べるかは、プロダクトの性格で変わります。私が設計レビューで必ず確認している 3 つの軸を先に書いておきます。
- 品質の許容幅: 応答精度が Sonnet 4 でなければ成立しないユースケース(複雑な推論・コーディング)か、Haiku でも十分なユースケース(FAQ・要約・単純な分類)か。品質許容幅が広いほど、フォールバック先の選択肢が増えます。
- コスト天井: Opus を常用すると月額コストが跳ね上がります。フォールバック先に高コストモデルを置くと、障害時にコストが急増します。コスト天井を事前に決めておくこと。
- レスポンス時間の SLO: 体感レスポンスを 2 秒以内に収めたいサービスで、Extended Thinking を有効にした Opus をフォールバック先に置くと、むしろ SLO を壊します。速度重視なら Haiku を早めに出すチェーンを作ります。
私が標準にしているチェーン設計は次の 3 パターンです。
- 品質優先チェーン:
claude-sonnet-4-6→claude-opus-4-6→claude-haiku-4-5-20251001(Sonnet で失敗したら Opus、最後の最後で Haiku) - 速度優先チェーン:
claude-sonnet-4-6→claude-haiku-4-5-20251001→ エラー返却(Opus は外す) - コスト優先チェーン:
claude-haiku-4-5-20251001→claude-sonnet-4-6→ エラー返却(まず Haiku で試す)
多くのプロダクトは「品質優先」と「速度優先」を機能別に使い分けるのが現実的です。ダッシュボードの要約は速度優先、ユーザーへの回答生成は品質優先、という具合に切り分けます。
基本実装 — 直列フォールバックを TypeScript で組む
まずは Circuit Breaker なしの、最小構成のフォールバックを書きます。これが動くようになるだけで、多くのサービスは事故の 80% を回避できます。
何を解決するコードか: Claude API への 1 回のリクエストで、モデルチェーンの順に試行し、429(レート制限)・529(過負荷)・5xx(サーバーエラー)・タイムアウトに該当する場合のみ次モデルへ降格します。
// claude-fallback-client.ts
import Anthropic from "@anthropic-ai/sdk";
type FallbackChain = readonly string[];
interface FallbackConfig {
readonly models: FallbackChain;
readonly perCallTimeoutMs: number;
readonly onFallback?: (fromModel: string, toModel: string, reason: string) => void;
}
// デフォルトの品質優先チェーン
const DEFAULT_CHAIN: FallbackChain = [
"claude-sonnet-4-6",
"claude-opus-4-6",
"claude-haiku-4-5-20251001",
] as const;
export class ClaudeFallbackClient {
private readonly client: Anthropic;
constructor(apiKey: string) {
this.client = new Anthropic({ apiKey });
}
async createMessage(
params: Omit<Anthropic.MessageCreateParamsNonStreaming, "model">,
config: Partial<FallbackConfig> = {},
): Promise<{ message: Anthropic.Message; modelUsed: string }> {
const chain = config.models ?? DEFAULT_CHAIN;
const timeoutMs = config.perCallTimeoutMs ?? 30_000;
const errors: { model: string; error: unknown }[] = [];
for (let i = 0; i < chain.length; i++) {
const model = chain[i];
try {
const message = await this.callWithTimeout(
() => this.client.messages.create({ ...params, model }),
timeoutMs,
);
return { message, modelUsed: model };
} catch (err) {
errors.push({ model, error: err });
if (!this.shouldFallback(err) || i === chain.length - 1) {
// フォールバック対象外のエラー(400, 401 など)は即時スロー
// 最後のモデルでも失敗した場合はスロー
throw new AggregateError(
errors.map((e) => e.error),
`All models in chain failed: ${errors.map((e) => e.model).join(" → ")}`,
);
}
const next = chain[i + 1];
config.onFallback?.(model, next, this.describeError(err));
}
}
throw new Error("unreachable");
}
private shouldFallback(err: unknown): boolean {
if (err instanceof Anthropic.APIError) {
// 429: レート制限 / 529: 過負荷 / 5xx: サーバーエラー
if (err.status === 429 || err.status === 529) return true;
if (err.status !== undefined && err.status >= 500) return true;
return false;
}
// タイムアウトとネットワークエラーはフォールバック対象
if (err instanceof Error) {
if (err.name === "AbortError") return true;
if (err.message.includes("ECONNRESET") || err.message.includes("ETIMEDOUT")) return true;
}
return false;
}
private describeError(err: unknown): string {
if (err instanceof Anthropic.APIError) return `${err.status} ${err.name}`;
if (err instanceof Error) return err.name;
return "unknown";
}
private async callWithTimeout<T>(fn: () => Promise<T>, ms: number): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fn();
} finally {
clearTimeout(timer);
}
}
}
// 使用例
const client = new ClaudeFallbackClient(process.env.ANTHROPIC_API_KEY!);
const { message, modelUsed } = await client.createMessage(
{
max_tokens: 1024,
messages: [{ role: "user", content: "Summarize quantum tunneling in 3 sentences." }],
},
{
onFallback: (from, to, reason) => {
console.warn(`[fallback] ${from} → ${to} (${reason})`);
},
},
);
console.log(`[ok] model=${modelUsed}`);
console.log(message.content[0].type === "text" ? message.content[0].text : "");このコードのポイントは「どのエラーでフォールバックするかを明示的に区別している」ところです。400(Bad Request)や 401(認証エラー)でフォールバックするとチェーン全体で同じエラーが返ってきてレイテンシが無駄になるだけなので、shouldFallback で早期に切り分けます。ここを曖昧にしている実装は、障害時にログが荒れて原因特定に時間がかかる傾向にあります。
Circuit Breaker を組み込んで連鎖障害を止める
直列フォールバックだけでは、プライマリモデルが完全に落ちたとき、すべてのリクエストが「プライマリで数秒待ってから次へ」を繰り返すため、レイテンシが全体的に悪化します。ここで Circuit Breaker パターンを入れます。
何を解決するコードか: プライマリモデルが短時間に連続失敗した場合、一時的にチェーンから外して自動回復を待つ。プライマリ宛の「呼ばれるが確実に落ちるリクエスト」を発生させないことで、全体スループットを維持します。
// circuit-breaker.ts
type CircuitState = "closed" | "open" | "half-open";
interface BreakerOptions {
readonly failureThreshold: number; // この回数連続で失敗したら open
readonly openDurationMs: number; // open 状態を保つ時間
readonly halfOpenTrialLimit: number; // half-open で試す回数
}
const DEFAULTS: BreakerOptions = {
failureThreshold: 5,
openDurationMs: 30_000,
halfOpenTrialLimit: 1,
};
export class CircuitBreaker {
private state: CircuitState = "closed";
private failureCount = 0;
private openedAt = 0;
private halfOpenTrials = 0;
constructor(private readonly name: string, private readonly opts: BreakerOptions = DEFAULTS) {}
canExecute(): boolean {
if (this.state === "closed") return true;
if (this.state === "open") {
if (Date.now() - this.openedAt >= this.opts.openDurationMs) {
this.state = "half-open";
this.halfOpenTrials = 0;
return true;
}
return false;
}
// half-open: 限定的に試行を許可
return this.halfOpenTrials < this.opts.halfOpenTrialLimit;
}
onSuccess(): void {
this.failureCount = 0;
if (this.state !== "closed") {
this.state = "closed";
}
}
onFailure(): void {
this.failureCount += 1;
if (this.state === "half-open") {
this.halfOpenTrials += 1;
this.state = "open";
this.openedAt = Date.now();
return;
}
if (this.failureCount >= this.opts.failureThreshold) {
this.state = "open";
this.openedAt = Date.now();
}
}
describe(): string {
return `${this.name}[${this.state} failures=${this.failureCount}]`;
}
}
// ClaudeFallbackClient と組み合わせる
const breakers = new Map<string, CircuitBreaker>();
function getBreaker(model: string): CircuitBreaker {
let breaker = breakers.get(model);
if (!breaker) {
breaker = new CircuitBreaker(model);
breakers.set(model, breaker);
}
return breaker;
}
// createMessage ループ内の改修例
// for (const model of chain) の中で:
// const breaker = getBreaker(model);
// if (!breaker.canExecute()) continue; // open ならスキップして次モデルへ
// try {
// const message = await this.callWithTimeout(...);
// breaker.onSuccess();
// return { message, modelUsed: model };
// } catch (err) {
// if (this.shouldFallback(err)) breaker.onFailure();
// // ...
// }私が現場で学んだ閾値の目安は、failureThreshold: 5 / openDurationMs: 30 秒 / halfOpenTrialLimit: 1 です。失敗閾値を小さくしすぎるとモデルが復旧しているのに「まだ壊れていると判断して」チェーンに戻らず、機会損失が発生します。逆に大きすぎると、すでに壊れているモデルに投げ続けて SLO を壊します。このパラメータは、半日ごとにメトリクスを見て微調整する前提で置くのが実務的です。
ストリーミングでフォールバックを成立させるパターン
一括応答のフォールバックは比較的単純ですが、ストリーミングは一段難易度が上がります。「SSE が開始した後で、途中でエラーが発生したら、すでに送出したトークンをどう扱うか」という問題が出てくるためです。
私は本番でこれを運用する際、フォールバックを 2 段構えにしています。
- 接続確立前のフォールバック:
client.messages.stream()の initial response(HTTP ヘッダ)を受け取る前に 429 や 5xx が発生した場合は、ストリーム未開始として扱い、そのまま次モデルへ降格します。ユーザー側にはまだ何も届いていないので、降格はユーザーに感知されません。 - ストリーム開始後のエラーは「末尾に謝罪文を付ける」で妥協する: トークンが流れ始めた後のエラーは、降格しても「また最初から」を送るとユーザー体験が崩れます。エラーがどこで発生したかの印を付け、「※回答が途中で切れました。再送します」と表示して新しいリクエストで続きを生成する方が運用上は楽です。
何を解決するコードか: ストリーム開始前のエラー(初回 HTTP 応答で 429/5xx)はフォールバック対象、開始後のエラーはユーザーに可視化する形で記録します。
// stream-fallback.ts
import Anthropic from "@anthropic-ai/sdk";
export async function streamWithFallback(
client: Anthropic,
params: Omit<Anthropic.MessageStreamParams, "model">,
chain: readonly string[],
onDelta: (text: string) => void,
): Promise<{ modelUsed: string; completedCleanly: boolean }> {
for (let i = 0; i < chain.length; i++) {
const model = chain[i];
try {
// stream() は async iterable を返す
const stream = client.messages.stream({ ...params, model });
// 初回イベントを待つ(接続確立前エラーはここで捕捉できる)
let streamStarted = false;
for await (const event of stream) {
if (!streamStarted && event.type === "message_start") {
streamStarted = true;
}
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
onDelta(event.delta.text);
}
}
return { modelUsed: model, completedCleanly: true };
} catch (err) {
if (!isStreamStartError(err) || i === chain.length - 1) {
// ストリーム開始後のエラーは、ユーザーに見える形でログに残し、
// 再接続は上位のハンドラに任せる
onDelta("\n\n*[通信が途中で切れました。再送します]*");
return { modelUsed: model, completedCleanly: false };
}
// 次モデルへフォールバック
}
}
throw new Error("unreachable");
}
function isStreamStartError(err: unknown): boolean {
if (err instanceof Anthropic.APIError) {
return err.status === 429 || err.status === 529 || (err.status ?? 0) >= 500;
}
return false;
}実際に運用してみて分かったのは、ストリーム開始後のフォールバックを無理に綺麗にやろうとしない方が良いということです。綺麗にやろうとすると「前モデルが生成したトークンを次モデルに渡して続きを書かせる」という実装になりますが、モデル間のトークナイザの差で自然な文章にならないケースが多発します。「一度切って、UI で再送する」方が結果的にユーザー満足度が高い、というのが私の結論です。
よくある間違い・落とし穴
ここまでを実装するだけでかなり堅牢になりますが、現場で私が何度も踏んだ落とし穴を共有しておきます。
- 落とし穴1: すべての 4xx でフォールバックしてしまう: 400(Bad Request)や 401(Auth)でフォールバックすると、次モデルでも同じエラーが返ってくるためレイテンシが 3 倍になるだけで、何も改善しません。フォールバック対象は 429 / 529 / 5xx / タイムアウトの 4 種類に限定すること。
- 落とし穴2: Circuit Breaker のタイマーをプロセスローカルに持つ: サーバーを複数台で運用しているのに、Breaker の状態を各プロセスのメモリで持つと、モデル障害時に「A のプロセスは open だが B のプロセスはまだ closed」という不整合が起きます。プロダクション規模が大きいなら Redis に Breaker の状態を載せることを検討してください。
- 落とし穴3: Opus をフォールバック最終段に置く: 「品質を守るために Opus を保険に」と思いがちですが、Opus は速度が遅くコストも高いため、プライマリ障害時に全トラフィックが Opus に流れると SLO とコストの両方を壊します。Opus は最終段ではなく、あくまで「品質が必須の経路」の最初の選択肢として使う設計を推奨します。
- 落とし穴4: フォールバックしたことをユーザーに隠蔽しすぎる: 「ユーザーには気づかせない」が理想ではありますが、モデル間で応答ニュアンスが明確に変わる場合、UI 側で「現在は軽量モードで応答しています」と小さく表示する方が信頼性は上がります。特に B2B SaaS では、応答品質が急に変わるのに理由が見えないとクレーム化しやすいです。
- 落とし穴5: onFallback コールバックのロギングを同期で行う: フォールバックイベントの記録を
console.logではなく DB への同期書き込みで行う実装を見かけますが、DB が詰まっている時にフォールバック処理自体を詰まらせます。イベントはいったんキューに積んで、非同期で処理するのが鉄則です。
落とし穴 1 と 3 は、私が実際に「フォールバック機構を入れたのに障害を悪化させた」経験から得た教訓です。どちらも「設計時には思いつかないのに、本番で発火すると致命的」というたちの悪いパターンなので、レビュー時に必ず確認してください。
モニタリングとコスト監視 — 運用で見るべき指標
フォールバックを入れたら終わりではありません。フォールバックが発生したこと自体が本来は異常事態なので、必ず可視化してください。私が運用ダッシュボードに必ず置いている指標は 5 つです。
- モデル別の成功率: 各モデルの 200 OK 率。プライマリが 99.5% を下回り始めたら要調査。
- フォールバック発生率: 全リクエスト中、フォールバックが発動した割合。通常時は 0.1% 未満、障害時は急増します。
- チェーン末尾到達率: チェーンの最後のモデルでも失敗した割合。これが 0.01% を超えたらインシデント扱い。
- モデル別のコスト: Opus へのフォールバックが急増するとコストが跳ね上がる。日次でアラートを設定します。
- Circuit Breaker の状態遷移: open → half-open → closed の遷移タイミング。モデル復旧の早期検知に使う。
Cloudflare AI Gateway や OpenTelemetry を使うと、これらの可視化が楽になります。既存記事の Claude API OpenTelemetry 可観測性ガイド や Cloudflare AI Gateway 本番モニタリング も合わせて参考にしてください。
また、この設計の土台にあるのはレート制限との付き合い方です。claude-sonnet-4-6 のレート制限を上手く扱うには、Claude API レート制限と課金の完全ガイド を並行して読んでおくと、チェーン設計の閾値の根拠が腹落ちすると思います。
全体を振り返って — 最初の一歩は「プライマリだけに依存していないか」の棚卸し
今日この記事を読んで、もし「うちのサービスは Sonnet だけで動いている」と気づいたなら、まず 1 時間だけ時間を取って、本記事の直列フォールバック実装(ClaudeFallbackClient)を試しに組み込んでみてください。Circuit Breaker もモニタリングも後回しで構いません。チェーンを [sonnet, haiku] の 2 段だけにして、onFallback でログを取る状態からスタートすると、本番での実際のフォールバック発生率が見えてきます。その数字を見てから、Circuit Breaker や Opus の扱いを検討する順序が、最もコスト効率が良いです。
高可用性は一度に完成させるものではなく、段階的に強化していく前提で設計するのが現実的です。今日の一歩が、将来の深夜の呼び出しを 1 回減らします。