Claude API を本番に置いてしばらく経つと、モデル単位のフォールバックでは救いきれない種類の障害が見えてきます。たとえば「Sonnet も Haiku も応答するが、p99 レイテンシが 4 秒に張り付いて、ユーザー画面のロード時間が体感で 2 倍に伸びている」ような時間帯です。エラーは返っていないので、Circuit Breaker は閉じたまま、しかし体感品質は静かに崩れていく。こうした状況は、モデル間スイッチングよりもっと粒度の粗い「サービス全体の階層的な降格」を持っていないと対処できません。
私は 2014 年から個人開発でアプリを公開し続けて 12 年、累計 5,000 万ダウンロードを超えるアプリ事業を Dolice という屋号で運営してきました。AdMob 経由の月次収益が立つ規模になると、サービス停止の数十分は確実に売上に響きます。だからこそ、Claude API を組み込んだ機能では「障害時に何を諦め、何を絶対に止めないか」を事前に契約しておく必要がありました。本稿でまとめるのは、その契約を 4 つの Tier に分けて自動的に切り替える設計です。
なぜ「モデル切替」だけでは不十分なのか
多くの可用性記事は「Sonnet が落ちたら Opus、Opus も落ちたら Haiku」というモデルチェーンで止まります。これは有効な手段ですが、本番で起きる障害の半分くらいは別の顔をしています。
- モデルは応答するが、SLO バーンレートが急上昇している時間帯: 5xx は出ていないが、ユーザー体感で 2 秒の機能が 6 秒かかっている。Circuit Breaker のしきい値以下なので、システム的には健全に見える状態です。
- アップストリームに連動した予算枯渇: 自社の月額予算が想定の 1.6 倍ペースで消費されているとき、技術的にはまだ叩けますが、月末まで持たない確率が高い。サービスを完全に止めるよりも、コスト重い機能から優先的に降格させたい状況です。
- 個別ユーザーの局所過負荷: 全体平均は健全だが、特定テナントが大量のトークンを消費して、その人だけ詰まっている。テナント単位で機能を間引きたい場面です。
これらは、モデル間のスイッチで処理する話ではなく、「機能そのものをどう簡素化するか」を扱う話です。グレースフル・デグラデーションは、ここの粒度を扱うための設計概念になります。
4 階層の Tier モデル
私が個人事業のアプリと 4 つの Lab サイト(Claude Lab・Gemini Lab・Antigravity Lab・Rork Lab)で標準にしている Tier モデルは以下の 4 つです。Claude API を呼ぶ機能ごとに、この Tier のいずれかにマッピングしておきます。
| Tier | 名称 | 動作 | コスト | レイテンシ目安 |
| T0 | Full Quality | Sonnet 4 / Opus 4 で本来の応答を返す | 100% | p99 1.5s |
| T1 | Reduced Quality | Haiku 4.5 に降格し、Extended Thinking を切る | 30〜40% | p99 600ms |
| T2 | Cached Response | KV/Redis のセマンティックキャッシュ、または前計算済みテンプレートを返す | 5〜10% | p99 80ms |
| T3 | Static Fallback | 「現在この機能はメンテナンス中です」のような静的な UX に切り替える | 0% | p99 30ms |
ポイントは、T2 と T3 が AI を一切叩かないことです。多くの設計は T0 と T1 で止まりますが、本番ピーク時の最後の砦は T2・T3 にあります。たとえば壁紙アプリの「今日のおすすめ説明文」のような機能は、T3 で「シンプルな静的説明」に置き換えても、ユーザー体験はほぼ崩れません。逆に、検索のような会話型機能は、T2 のキャッシュヒットがあれば 8 割の場面で違和感なく動きます。
Tier 切替を駆動する 3 つの SLI
Tier 間を機械的に切り替えるためには、何を見て判定するかを先に決めます。私は次の 3 つの SLI(Service Level Indicator)を 1 分窓で集計し、判定エンジンに食わせています。
- p99 レイテンシ: 直近 1 分間の Claude API 呼び出しの 99 パーセンタイル。閾値 800ms 超で Tier を 1 段下げる候補。
- 5xx 率: 同 1 分間の 5xx 系応答の割合。閾値 2% 超で即時 1 段下げ。
- 429 率: 同 1 分間の 429 応答の割合。閾値 5% 超で 1 段下げる候補。複数 5% を超えたら 2 段一気に下げる。
これらは独立して見るのではなく、SLO バーンレート(Burn Rate)として束ねるのが実用的です。1 時間ウィンドウのバーンレートが 14.4 倍を超えると、月次 SLO の 1 時間分を予算過剰消費していることになり、即時降格の合図とします。Google SRE Book の議論を AI 機能に適用したもので、私の運用での実体験ともよく合っています。
判定エンジンの実装(Python)
何を解決するコードか: 直近 1 分の SLI を Prometheus エンドポイントまたは内製カウンタから集計し、現在の Tier を決定する判定エンジンです。Cloudflare Workers の Durable Object でも、AWS Lambda + DynamoDB Streams でも同じロジックが動くように、状態を外部に寄せた純関数として実装します。
# tier_decider.py
from dataclasses import dataclass
from enum import IntEnum
from typing import Literal
class Tier(IntEnum):
T0_FULL = 0
T1_REDUCED = 1
T2_CACHED = 2
T3_STATIC = 3
@dataclass(frozen=True)
class SLISnapshot:
"""直近 1 分の SLI 集計。外部の Prometheus/CW から渡される前提。"""
p99_latency_ms: float
err_5xx_rate: float
err_429_rate: float
burn_rate_1h: float # SLO 月次目標に対する直近 1h の消費倍率
# Tier 降格判定のルール群(優先順位順)
def decide_tier(
current: Tier,
sli: SLISnapshot,
feature_priority: Literal["critical", "standard", "optional"] = "standard",
) -> tuple[Tier, str]:
"""次に取るべき Tier と、その判断理由のテキストを返す。"""
# 1. SLO バーンレート 14.4 倍超え -> 即時 T2 or T3
if sli.burn_rate_1h >= 14.4:
if feature_priority == "optional":
return Tier.T3_STATIC, f"burn_rate={sli.burn_rate_1h:.1f} optional -> T3"
return Tier.T2_CACHED, f"burn_rate={sli.burn_rate_1h:.1f} -> T2"
# 2. 5xx 率 2% 超 -> 1 段降格
if sli.err_5xx_rate >= 0.02:
return _step_down(current, reason=f"5xx={sli.err_5xx_rate:.1%}")
# 3. 429 率 5% 超 -> 1 段降格
if sli.err_429_rate >= 0.05:
return _step_down(current, reason=f"429={sli.err_429_rate:.1%}")
# 4. p99 レイテンシ 800ms 超 -> 1 段降格
if sli.p99_latency_ms >= 800.0:
return _step_down(current, reason=f"p99={sli.p99_latency_ms:.0f}ms")
# 5. すべての指標が healthy -> 1 段昇格(30 秒のヒステリシスは呼び出し側で)
if (
sli.p99_latency_ms < 500.0
and sli.err_5xx_rate < 0.005
and sli.err_429_rate < 0.01
and sli.burn_rate_1h < 2.0
):
return _step_up(current, reason="all SLIs healthy")
return current, "no change"
def _step_down(current: Tier, reason: str) -> tuple[Tier, str]:
next_tier = Tier(min(current.value + 1, Tier.T3_STATIC.value))
return next_tier, f"step_down to {next_tier.name}: {reason}"
def _step_up(current: Tier, reason: str) -> tuple[Tier, str]:
next_tier = Tier(max(current.value - 1, Tier.T0_FULL.value))
return next_tier, f"step_up to {next_tier.name}: {reason}"
このコードのポイントは、Tier の上下が常に 1 段ずつしか動かないことです。健全状態に戻った瞬間に T3 から T0 まで一気に駆け上がると、回復直後にもう一度詰まるパターンを繰り返します。私の運用では、復帰時は 30 秒間 healthy が続いてから 1 段ずつ上げる、というヒステリシスを呼び出し側に入れています。
クライアント側ラッパー(TypeScript)
判定エンジンが決めた Tier を、実際の Claude API 呼び出しに反映させるクライアント側ラッパーです。フロント寄りの BFF(Backend for Frontend)に置き、Cloudflare Workers で動かしている実装を抜粋します。
// claude-degraded-client.ts
import Anthropic from "@anthropic-ai/sdk";
type Tier = 0 | 1 | 2 | 3;
interface DegradedClientOptions {
apiKey: string;
cacheGet: (key: string) => Promise<string | null>;
cachePut: (key: string, value: string, ttlSec: number) => Promise<void>;
currentTier: () => Promise<Tier>; // tier_decider の出力を返す
featurePriority: "critical" | "standard" | "optional";
}
const MODEL_BY_TIER: Record<0 | 1, string> = {
0: "claude-sonnet-4-6",
1: "claude-haiku-4-5-20251001",
};
export class ClaudeDegradedClient {
private readonly anthropic: Anthropic;
constructor(private readonly opts: DegradedClientOptions) {
this.anthropic = new Anthropic({ apiKey: opts.apiKey });
}
async respond(
prompt: string,
cacheKey: string,
staticFallback: string,
): Promise<{ text: string; tierUsed: Tier; cached: boolean }> {
const tier = await this.opts.currentTier();
// Tier 2: セマンティックキャッシュ優先
if (tier >= 2) {
const hit = await this.opts.cacheGet(cacheKey);
if (hit) return { text: hit, tierUsed: 2, cached: true };
if (tier === 3) {
// T3: そもそも API を叩かない
return { text: staticFallback, tierUsed: 3, cached: false };
}
// T2 でキャッシュミスのときは、コスト最小の T1 で再生成
const text = await this.callModel(prompt, 1, /*thinking=*/ false);
await this.opts.cachePut(cacheKey, text, 3600);
return { text, tierUsed: 2, cached: false };
}
// Tier 0 / 1: 通常の Claude API 呼び出し
const useThinking = tier === 0 && this.opts.featurePriority === "critical";
const text = await this.callModel(prompt, tier, useThinking);
// 成功応答は次回のキャッシュ候補として保存
await this.opts.cachePut(cacheKey, text, 600);
return { text, tierUsed: tier, cached: false };
}
private async callModel(
prompt: string,
tier: 0 | 1,
thinking: boolean,
): Promise<string> {
const model = MODEL_BY_TIER[tier];
const msg = await this.anthropic.messages.create({
model,
max_tokens: tier === 0 ? 2048 : 1024,
messages: [{ role: "user", content: prompt }],
...(thinking
? { thinking: { type: "enabled", budget_tokens: 4096 } }
: {}),
});
return msg.content
.filter((b) => b.type === "text")
.map((b) => (b as { text: string }).text)
.join("");
}
}
T2 の挙動を「キャッシュヒット時はキャッシュ、ミス時は Haiku で再生成して書き戻す」にしている点が、現場で効きます。T2 入りした瞬間にコールド・キャッシュなら全員がエラー、ではユーザー体験が崩壊します。1 回目は Haiku で答えて書き戻し、2 回目以降は完全にキャッシュ応答に倒すと、Tier 2 が「コスト圧縮しながら静かに動く」現実的な状態になります。
失敗の質を分ける — fail-closed と fail-open の判断軸
T3 で「機能を諦める」とき、もうひとつ重要な判断があります。fail-closed(機能を完全に止める)にするか、fail-open(簡素な静的応答で続行する)にするか、です。
私の手元の判断基準はこうなっています。
- fail-closed にする機能: ユーザー資産に書き込む系(チャット履歴の自動要約、AI 経由のリスト編集、購入確定後の AI 説明文生成)。誤った内容を返すくらいなら止めるほうが安全です。
- fail-open にする機能: 表示専用・補助的な要素(記事末尾の関連リンク説明、検索結果の自然文サマリ、ホーム画面の今日のおすすめコピー)。AI 応答が静的テキストでも、UX が決定的に壊れないものはこちらに倒します。
Dolice の壁紙系アプリには 1 日 100 万件規模のリクエストが来ます。AdMob からの月次収益が立っている以上、画面を真っ白にする選択肢は最終手段です。多くの機能は fail-open に倒して「静かにダウングレード」する設計にしてあり、ユーザーから明確なクレームが来た例は過去 12 年で数件です。一方、ユーザー資産に書き込む系は迷わず fail-closed にして、「現在この機能は一時的にご利用いただけません」と明示しています。
カオス演習を CI に組み込む
設計したフォールバックは、実際に動くかどうかを定期的に確かめないと信頼できません。私は GitHub Actions のスケジュールで、週 1 回のカオス演習を Cloudflare Workers のプレビュー環境で走らせています。
# .github/workflows/chaos-degradation-drill.yml
name: chaos-degradation-drill
on:
schedule:
- cron: "0 18 * * 3" # 毎週水曜 03:00 JST
workflow_dispatch:
jobs:
drill:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Inject synthetic 429 storm
run: |
node scripts/inject-fault.mjs \
--target=preview \
--pattern=429-storm \
--duration=180
- name: Verify Tier descended to T2 or below
run: |
node scripts/assert-tier.mjs --expected-max=2 --within=60s
- name: Verify cached responses still served
run: |
node scripts/assert-cache-hit-rate.mjs --min=0.8
- name: Recovery within 5 minutes
run: |
node scripts/wait-tier.mjs --expected=0 --within=300s
- name: Post drill report to ops channel
if: always()
run: |
node scripts/post-drill-report.mjs --to=ops --result="${{ job.status }}"
この手の演習で最初に発見したのは、「コードの上では Tier 2 に降りているのに、観測上の tier_used メトリクスが古い値のまま 30 秒以上残る」という間抜けなバグでした。Cloudflare Workers の Durable Object に State を持たせていたところ、書き込みの伝播が遅れてダッシュボード側だけ過去 Tier を見ていた、というオチです。カオス演習を入れていなければ、本番で初めて気付くタイプの問題でした。
実運用で見えてきた 5 つの落とし穴
- ヒステリシスがないと Tier がフラップする: 復帰判定を即時にすると、回復直後に詰まって T0 ↔ T2 を 1 分単位で行き来します。30 秒の連続 healthy を待つだけで、ほぼなくなります。
- キャッシュ TTL を短くしすぎると T2 がコールド化する: 60 秒など短い TTL にすると、ピーク時の T2 がほぼ全件 cache miss になります。私はホットなキーで 10 分、コールドな個別キーで 60 分を採用しています。
- fail-open の静的応答を「真っ当な日本語」にしておく: 「データ取得に失敗しました」では UX が荒れます。「最新のおすすめは準備中です。少し時間を置いてお試しください」のような、機能ごとの落としどころを事前に書いておくと、T3 突入時の印象が変わります。
- アラートは Tier 遷移そのものを通知する: SLI のしきい値ではなく、T1 入り・T2 入り・T3 入りそれぞれを Slack に通知すると、運用側の「いま何が起きているか」が直感的になります。私は Tier の上下を絵文字で出して、流れだけ追える運用にしています。
- コスト指標も Tier に紐づけて記録する: T1 滞在時間と AdMob 売上の相関を 1 週間分プロットすると、「どの機能の T2 滞在が許容範囲か」を経営的に判断できるようになります。私の運用では、これが Premium プランの値付け根拠のひとつにもなっています。
個人開発でこの設計が効く 3 つの理由
最後に、なぜこの設計が個人開発と相性が良いかをまとめます。Dolice Labs の 4 サイトと壁紙系アプリ、そして本業のアート活動を 1 人で並行している現場感覚での所感です。
- 障害発生時、すぐに人を集めて対応する体制が組めない以上、1 人でも「自動で降格して止まらない」状態を作る価値が大きい。
- AdMob 経由の収益は機能ごとに濃淡があり、止めるべきでない機能と諦めて良い機能の差が明確。Tier モデルはここをそのまま設計に落とせる。
- 個人事業として継続するために、月次予算の予測可能性を保つことが必要。SLO バーンレートでの自動降格は、その月の AI 予算を予想外に飛ばさないための安全弁になる。
2014 年からアプリを公開し続けて 12 年、累計 5,000 万ダウンロードを支えてきたのは派手な機能ではなく、こうした「障害時にも静かに動く」地味な設計の積み重ねでした。Claude API を入れた機能でも、その積み重ねが効いてくる場面が増えてきました。同じ立場の個人開発者の方の参考になればと願っています。