請求ダッシュボードを開いて、前月比 1.8 倍という数字を見た朝がありました。トラフィックは 1 割しか増えていません。増えていたのは、Sonnet 4.6 に投げたリクエストの割合だけでした。
原因はすぐに分かりました。「品質が落ちるのが怖い」という理由だけで、短い分類も定型の要約も、すべて上位モデルに固定していたのです。
その反動で、今度はコストを恐れて全部を Haiku 4.5 に寄せた時期もありました。すると本当に推論が要る少数のリクエストで精度が落ち、公開前の品質チェックに弾かれる回数が増えます。再実行のぶんを足し戻すと、節約になっていませんでした。
このジレンマは「どちらを選ぶか」では解けません。リクエストの複雑度を事前に判定し、その複雑度に応じて最適なモデルを自動選択する設計 — インテリジェントモデルルーティング — でしか解けないものです。
以下は、技術メディアの運用基盤と iOS / Android アプリのカスタマーサポート bot という、性格の違う 2 系統で 6 ヶ月以上走らせてきた実装です。判定器の設計、サーキットブレーカー、そして「閾値をどうやってデータから決めるか」まで、個人開発で運用してはじめて気づいた落とし穴ごと書き残しておきます。
モデルルーティングが必要な理由:コストと品質のトレードオフを見える化する
まず、なぜモデルルーティングが重要なのかを数字で理解しましょう。
Claude API の価格体系(2026年時点):
- Sonnet 4.6: 入力トークン $3/100万トークン、出力トークン $15/100万トークン
- Haiku 4.5: 入力トークン $0.80/100万トークン、出力トークン $4/100万トークン
Sonnet は Haiku の約 3〜4 倍のコストがかかります。月間 1000万トークンを処理するアプリケーションの場合:
- 全て Sonnet で処理: 約 $7,500/月
- 全て Haiku で処理: 約 $2,000/月(ただし品質低下のリスク)
- インテリジェントルーティング(複雑度が高いリクエストの 40% を Sonnet、残り 60% を Haiku で処理): 約 $3,800/月
つまり、ルーティングを導入することで Sonnet オンリーに比べて約 49% のコスト削減ができます。かつ複雑な問題は高精度なモデルで処理するため、品質はむしろ向上します。
なぜこのアプローチを好むのか
私が本番プロジェクトでこのパターンを採用する理由は3つです。
第一に、単純で予測可能です。複雑な A/B テストや動的プロンプトの工夫を積み重ねるよりも、「このリクエストは Haiku で十分か」という明確な判定基準を持つことで、カオスを避けられます。
第二に、問題が発生したときの原因特定が容易です。精度が低かった場合、「ルーティング判定が間違っていた」か「選択されたモデルの問題か」かを切り分けられます。
第三に、組織全体に説明しやすいです。経営層に対しても「複雑度に応じてモデルを選び分けている」というメッセージは理解しやすく、継続的な投資を得やすくなります。
リクエスト複雑度の判定:分類器の基本設計
インテリジェントルーティングの心臓部は、リクエストの複雑度を判定する分類器です。「複雑度が高い」とは、何を意味するでしょう?
実装現場から見ると、以下の要素がリクエスト複雑度に関連します。
- 入力内容の量: テキストが長いほど、より詳細な理解が必要
- 推論ステップの多さ: 「AをもとにBを判定し、その結果からCを予測する」のような多段階推論
- 専門的な知識の要求: 特定の分野の専門用語や領域知識が必要か
- 曖昧性の度合い: 定義が明確か、それとも解釈の幅が広いか
- 出力品質への依存度: ビジネスインパクトが大きい判断か、それとも参考程度か
実装の観点からは、複雑度判定戦略は3段階で設計するのが実用的です。
レイヤー 1: キーワード&パターンマッチング(最軽量)
最初の判定は、リクエストに含まれるキーワードやパターンから、単純な規則で実施します。これは計算コストがほぼゼロで、レイテンシに影響しません。
// Layer 1: キーワード・パターンベース分類
function isComplexRequestByKeywords(message) {
const complexKeywords = [
'analyze', 'compare', 'evaluate', 'impact', 'strategy',
'architecture', 'design', 'optimize', 'research',
'論文', '分析', '比較', '評価', '戦略', '設計',
'アーキテクチャ', 'レビュー', '改善案'
];
const messageUpper = message.toUpperCase();
const matchCount = complexKeywords.filter(kw =>
messageUpper.includes(kw.toUpperCase())
).length;
// 複雑なキーワードが3個以上含まれていたら複雑判定
if (matchCount >= 3) {
return true;
}
// コードレビュー、アーキテクチャ設計は確実に複雑と判定
const structuralPatterns = [
/code\s+review/i,
/architecture\s+design/i,
/refactor/i,
/performance\s+(optimization|improvement)/i
];
return structuralPatterns.some(pattern => pattern.test(message));
}
// テスト
console.log(isComplexRequestByKeywords('Update the greeting message'));
// → false
console.log(isComplexRequestByKeywords('Analyze the performance impact and strategy for optimization'));
// → true
なぜこの方法を最初に使うのか:計算が速く、ほぼ確実に複雑さを判定できるケース(コードレビュー、アーキテクチャ相談など)を先に捕捉して、不要な計算を削減します。
レイヤー 2: トークン数と構造化情報(軽量分析)
キーワードマッチングで判定できなかった場合、より精密な分析を行います。
// Layer 2: トークン数と構造に基づく判定
function isComplexRequestByTokens(message) {
// Claude API の tokenize エンドポイントを使用
// (実装では Anthropic SDK の計算を利用)
const tokenCount = Math.ceil(message.length / 4); // 概算(実際はAPIで正確に取得)
// 1000トークン以上は複雑な可能性が高い
if (tokenCount >= 1000) {
return true;
}
// JSON やコードブロックが含まれている場合、構造化分析が必要
const hasJsonOrCode = /\{[\s\S]*?\}|\[[\s\S]*?\]|```[\s\S]*?```/m.test(message);
const hasMultipleSections = (message.match(/^#{1,3}\s+/gm) || []).length >= 3;
// 複雑な構造化データ + 中程度のボリューム
if (hasJsonOrCode && tokenCount >= 300) {
return true;
}
// 複数セクション + 中程度のボリューム
if (hasMultipleSections && tokenCount >= 500) {
return true;
}
return false;
}
// テスト
console.log(isComplexRequestByTokens('What is 2+2?'));
// → false
console.log(isComplexRequestByTokens(`
Please review this API endpoint:
\`\`\`
POST /api/users
{
"name": "string",
"email": "string",
"preferences": {
"notifications": boolean,
"privacy": "public" | "private"
}
}
\`\`\`
What are the security implications?
`));
// → true
重要な発見: 実装を通じて気づいたのは、トークン数 + 構造化データの有無の組み合わせが、実際の必要な推論能力とよく相関するということです。公式ドキュメントではこの点は明示されていませんが、本番環境で複数プロジェクトを運用してみると非常に実用的です。
レイヤー 3: セマンティック分析(精密分析)
レイヤー 1 と 2 で判定できなかった「微妙なケース」を処理するため、セマンティック分類を使用します。ただし、このレイヤーは追加のモデル呼び出しが発生するため、実装には注意が必要です。
// Layer 3: セマンティック分類(微妙なケースのみ対象)
async function classifyBySemantic(message, client) {
// 注意: このレイヤーはコスト増加を招く。
// 本番環境では「判定が必要なリクエストの 20% 以下」に限定すること
const classificationPrompt = `
You are a request complexity classifier. Analyze this user request and determine:
1. Does it require multi-step reasoning? (yes/no)
2. Does it require specialized domain knowledge? (yes/no)
3. Is the expected output quality critical to the user? (yes/no)
Request: "${message}"
Respond ONLY with JSON: {"multiStep": boolean, "specialized": boolean, "critical": boolean}
`.trim();
try {
const response = await client.messages.create({
model: 'claude-haiku-4-5-20251001', // Use Haiku even for classification
max_tokens: 100,
messages: [{
role: 'user',
content: classificationPrompt
}]
});
const text = response.content[0].type === 'text' ? response.content[0].text : '{}';
const result = JSON.parse(text);
// 3つの複雑度指標のうち 2つ以上が true なら複雑判定
const complexityScore = [
result.multiStep,
result.specialized,
result.critical
].filter(Boolean).length;
return complexityScore >= 2;
} catch (error) {
console.error('Semantic classification failed:', error);
// 分類に失敗した場合は安全側に倒す(複雑と判定)
return true;
}
}
実装時の工夫: セマンティック分類自体も API 呼び出しであり、コストがかかります。そこで重要なのは、「レイヤー 1・2 で判定できなかった微妙なケースのみ」にこの処理を限定することです。実装では判定フローの外で統計を取り、「実際にレイヤー 3 が必要ですったケースは全体の何%か」を継続的に監視します。必要ないなら削除します。
統合ルーティング実装:3層分類器の組み立て
3つのレイヤーを組み合わせた実装パターンです。
// 統合ルーティング実装
interface RoutingDecision {
model: 'sonnet-4-6' | 'haiku-4-5';
reasoning: string;
layer: number;
}
async function routeRequest(
message: string,
client: Anthropic
): Promise<RoutingDecision> {
// Layer 1: キーワード・パターン(0ms、確実)
if (isComplexRequestByKeywords(message)) {
return {
model: 'claude-sonnet-4-6-20250514',
reasoning: 'Complex keywords detected',
layer: 1
};
}
// Layer 2: トークン数・構造(1ms、高速)
if (isComplexRequestByTokens(message)) {
return {
model: 'claude-sonnet-4-6-20250514',
reasoning: 'Token count or structural complexity detected',
layer: 2
};
}
// Layer 3: セマンティック分類(100-300ms、追加コスト)
// ただし、全体の 20% 以下に制限
const randomSample = Math.random();
if (randomSample < 0.2) {
try {
const isComplex = await classifyBySemantic(message, client);
if (isComplex) {
return {
model: 'claude-sonnet-4-6-20250514',
reasoning: 'Semantic complexity detected',
layer: 3
};
}
} catch (error) {
// 分類エラー時は安全側(Sonnet)を選択
return {
model: 'claude-sonnet-4-6-20250514',
reasoning: 'Semantic classification failed, defaulting to Sonnet',
layer: 3
};
}
}
// デフォルト: 単純なリクエストは Haiku で処理
return {
model: 'claude-haiku-4-5-20251001',
reasoning: 'Simple request, using Haiku',
layer: 0
};
}
// 実際の処理に統合
async function processRequest(message: string, client: Anthropic) {
const decision = await routeRequest(message, client);
const response = await client.messages.create({
model: decision.model,
max_tokens: 1024,
messages: [{
role: 'user',
content: message
}],
metadata: {
routing_decision: decision.model,
routing_layer: decision.layer
}
});
return {
response: response.content[0].type === 'text' ? response.content[0].text : '',
routingDecision: decision
};
}
層の選択が重要な理由: Layer 1 と Layer 2 は確定的で高速です。一方 Layer 3 はセマンティック分析なため、追加の API 呼び出しコストが発生します。本番環境では「サンプリングで統計を取り、実際に必要な層をデータドリブンに決定する」アプローチをお勧めします。
サーキットブレーカーとフォールバック戦略:本番環境での障害対応
ここまでのルーティング実装は「理想的な状態」を想定しています。しかし本番環境では、Sonnet が一時的に利用不可になったり、レイテンシが跳ね上がったりすることがあります。
そのときのフォールバック戦略を実装しないと、サービス全体が停止します。以下は、サーキットブレーカーパターンを用いた堅牢な実装です。
// サーキットブレーカー+フォールバック実装
interface CircuitBreakerState {
model: 'sonnet' | 'haiku';
failureCount: number;
lastFailureTime: number;
isOpen: boolean;
}
class ResilientRouter {
private circuitBreaker: CircuitBreakerState = {
model: 'sonnet',
failureCount: 0,
lastFailureTime: 0,
isOpen: false
};
private readonly FAILURE_THRESHOLD = 5; // 5回失敗でサーキット開く
private readonly RECOVERY_TIME = 60000; // 60秒後に自動復旧試行
async callModel(
message: string,
preferredModel: 'sonnet-4-6' | 'haiku-4-5',
client: Anthropic
): Promise<string> {
let model = preferredModel;
// サーキットブレーカーが開いている場合、Haiku にフォールバック
if (this.circuitBreaker.isOpen) {
const timeSinceFailure = Date.now() - this.circuitBreaker.lastFailureTime;
if (timeSinceFailure < this.RECOVERY_TIME) {
console.log(`Circuit breaker open, falling back to Haiku`);
model = 'claude-haiku-4-5-20251001';
} else {
// 回復時間経過、サーキット閉じて再試行
this.circuitBreaker.isOpen = false;
this.circuitBreaker.failureCount = 0;
}
}
try {
const response = await client.messages.create({
model: model,
max_tokens: 1024,
messages: [{
role: 'user',
content: message
}]
});
// 成功時はカウンター reset
this.circuitBreaker.failureCount = 0;
return response.content[0].type === 'text' ? response.content[0].text : '';
} catch (error) {
this.circuitBreaker.failureCount++;
this.circuitBreaker.lastFailureTime = Date.now();
// Sonnet が失敗した場合、Haiku に自動フォールバック
if (model === 'claude-sonnet-4-6-20250514') {
console.warn(`Sonnet failed (${this.circuitBreaker.failureCount}/${this.FAILURE_THRESHOLD}), trying Haiku`);
if (this.circuitBreaker.failureCount >= this.FAILURE_THRESHOLD) {
this.circuitBreaker.isOpen = true;
console.error('Circuit breaker opened due to repeated failures');
}
// Haiku への自動フォールバック
try {
const fallbackResponse = await client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
messages: [{
role: 'user',
content: message
}]
});
return fallbackResponse.content[0].type === 'text' ? fallbackResponse.content[0].text : '';
} catch (fallbackError) {
// Haiku も失敗した場合はエラーを投げる
throw new Error(
`Both Sonnet and Haiku failed. Original: ${error}. Fallback: ${fallbackError}`
);
}
}
// Haiku が直接失敗した場合
throw error;
}
}
}
// 使用例
const router = new ResilientRouter();
const result = await router.callModel(
'コードをレビューしてください:...',
'claude-sonnet-4-6-20250514',
client
);
なぜこの実装が重要か:本番環境でモデルルーティングを導入すると、「複雑な問題は Sonnet」という期待値が組織内に生まれます。その期待値を守るために、Sonnet が一時的に利用できなくなったときの対応が不可欠です。ただし無条件に Haiku にフォールバックするのではなく、「連続する失敗が何回達したらサーキットを開くか」という判定基準を設定することで、リトライストーム(無限ループ)を防ぎます。
複雑度判定の監視とチューニング:継続的改善ループ
ルーティング実装が本番環境で動き始めたら、次は継続的改善です。「実際のルーティング判定がどう機能しているのか」を定量的に監視する仕組みが必須です。
コスト追跡とモデル選択の分析
// ルーティング分析用のダッシュボードデータ収集
interface RoutingMetrics {
timestamp: string;
requestId: string;
layer: number;
selectedModel: 'sonnet' | 'haiku';
inputTokens: number;
outputTokens: number;
responseTime: number;
userSatisfaction?: number; // 1-5 scale
}
async function collectMetrics(
decision: RoutingDecision,
inputTokens: number,
outputTokens: number,
responseTime: number
): Promise<RoutingMetrics> {
return {
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
layer: decision.layer,
selectedModel: decision.model === 'claude-sonnet-4-6-20250514' ? 'sonnet' : 'haiku',
inputTokens,
outputTokens,
responseTime
};
}
// 月次コスト分析
function analyzeMonthlyMetrics(metrics: RoutingMetrics[]) {
const sonnetCost = 0.003; // $3 per 1M input tokens
const haikuCost = 0.0008; // $0.80 per 1M input tokens
let sonnetTotal = 0;
let haikuTotal = 0;
let sonnetCount = 0;
let haikuCount = 0;
for (const m of metrics) {
if (m.selectedModel === 'sonnet') {
sonnetCount++;
sonnetTotal += (m.inputTokens * sonnetCost / 1000000) +
(m.outputTokens * 0.015 / 1000000);
} else {
haikuCount++;
haikuTotal += (m.inputTokens * haikuCost / 1000000) +
(m.outputTokens * 0.004 / 1000000);
}
}
console.log(`
=== Monthly Routing Analysis ===
Sonnet: ${sonnetCount} requests, $${sonnetTotal.toFixed(2)}
Haiku: ${haikuCount} requests, $${haikuTotal.toFixed(2)}
Total: $${(sonnetTotal + haikuTotal).toFixed(2)}
Sonnet %: ${((sonnetCount / (sonnetCount + haikuCount)) * 100).toFixed(1)}%
`);
// 理想値: Sonnet は全体の 30-40%(複雑なリクエストの割合)
const sonnetPercentage = (sonnetCount / (sonnetCount + haikuCount)) * 100;
if (sonnetPercentage > 50) {
console.warn('Warning: Sonnet ratio is too high. Tighten classification criteria.');
} else if (sonnetPercentage < 15) {
console.warn('Warning: Sonnet ratio is too low. You may be under-allocating complex requests.');
}
}
監視の目的: 単なるコスト追跡ではなく、「ルーティング判定が正しく機能しているか」を定量的に検証します。本番環境では通常、Sonnet の利用率が全体の 30〜40% になるのが目安です。それより極端に高い or 低い場合は、分類器の閾値を調整する信号になります。
ユーザーフィードバックループ
最後に、ユーザーからのフィードバックをルーティング改善に組み込みます。
// ユーザーフィードバックを活用した学習
interface FeedbackEvent {
requestId: string;
selectedModel: 'sonnet' | 'haiku';
userRating: number; // 1-5
feedback: string;
}
async function handleUserFeedback(feedback: FeedbackEvent) {
// 1. Haiku を選択したのに、ユーザーが低評価(1-2)をつけた
// → その似たリクエストを今後 Sonnet で処理するよう学習
if (feedback.selectedModel === 'haiku' && feedback.userRating <= 2) {
console.log('Low quality feedback for Haiku, upgrading similar requests to Sonnet');
// 類似リクエストパターンを学習データに追加
// (本番環境では機械学習モデルを再トレーニング)
}
// 2. Sonnet を選択したのに、ユーザーが高評価(5)をつけた
// && 実はシンプルなリクエストだった
// → 分類器が over-allocate している可能性
if (feedback.selectedModel === 'sonnet' && feedback.userRating === 5) {
console.log('High quality with Sonnet - verify if classification was accurate');
}
}
重要な工夫: フィードバックループを実装するときの落とし穴は「全ての低評価を Sonnet にアップグレードする」という単純なアプローチです。実際には、低評価の原因はモデルの選択ではなく「プロンプトの不十分さ」や「ユーザーの期待値のズレ」であることも多いです。そのため、フィードバックは「統計的に有意な パターン」として集約してから、改善を判断する点が肝心です。
よくある実装ミスと対策:3つの痛い教訓
本番環境でこのパターンを導入したプロジェクトから、共通の失敗パターンが浮かび上がります。
ミス 1: 分類器の複雑度を過度に上げる
症状: Layer 3 のセマンティック分類を全リクエストに適用しはじめた結果、API コストが増加し、レイテンシも劣化しました。
原因: 分類精度を上げることに執心し、「分類自体のコスト」を見落としました。
Before(間違った実装):
// ❌ すべてのリクエストでセマンティック分類
async function routeRequest(message) {
const layer1 = isComplexRequestByKeywords(message);
const layer2 = isComplexRequestByTokens(message);
const layer3 = await classifyBySemantic(message); // 常に実行!
// ...
}
After(修正版):
// ✅ 統計採取時のみセマンティック分類、本番は Layer 1-2 に制限
async function routeRequest(message, isStatisticsPhase = false) {
const layer1 = isComplexRequestByKeywords(message);
if (layer1) return 'sonnet';
const layer2 = isComplexRequestByTokens(message);
if (layer2) return 'sonnet';
// 統計採取モード:20% サンプリングで Layer 3 実行
if (isStatisticsPhase && Math.random() < 0.2) {
const layer3 = await classifyBySemantic(message);
if (layer3) return 'sonnet';
}
return 'haiku';
}
対策: 分類器の性能改善は「追加コスト vs 精度向上」のトレードオフです。本番では Layer 1-2 で十分なケースが多く、Layer 3 は「統計採取フェーズ限定」にしましょう。
ミス 2: モデル障害時に無限フォールバックループ
症状: Sonnet API が障害になり、自動的に Haiku にフォールバックしたはずなのに、エラーが増殖し続け、結果として全リクエストが失敗しました。
原因: 各リクエストが独立してフォールバック判定していたため、サーキットブレーカーの状態が共有されず、全てのリクエストが Sonnet → Haiku → エラー というパターンを繰り返しました。
Before(間違った実装):
// ❌ 各リクエストが独立してリトライ
async function callModel(message) {
try {
return await client.messages.create({ model: 'sonnet', ... });
} catch {
// 毎回 Haiku にフォールバック
return await client.messages.create({ model: 'haiku', ... });
}
}
After(修正版):
// ✅ グローバルなサーキットブレーカー状態を共有
class GlobalRouter {
static circuitBreakerState = { isOpen: false, failureCount: 0 };
async callModel(message) {
let model = GlobalRouter.circuitBreakerState.isOpen
? 'haiku'
: 'sonnet';
try {
return await client.messages.create({ model, ... });
} catch (error) {
GlobalRouter.circuitBreakerState.failureCount++;
if (GlobalRouter.circuitBreakerState.failureCount >= 5) {
GlobalRouter.circuitBreakerState.isOpen = true;
}
// Sonnet 失敗時のみ Haiku にフォールバック
if (model === 'sonnet') {
return await client.messages.create({ model: 'haiku', ... });
}
throw error;
}
}
}
対策: サーキットブレーカーの状態は グローバル(インスタンス全体で共有) に保つ必要があります。そうすることで、1つのリクエストで Sonnet 障害を検知すれば、後続のリクエストは自動的に Haiku に切り替わります。
ミス 3: コスト削減を優先して、品質を無視する
症状: 「月額コスト 40% 削減という目標を達成した」と喜んでいたのに、ユーザーから「最近の回答精度が落ちた」というクレームが増えた。
原因: ルーティング閾値を Sonnet 寄りに引き上げ、実は Sonnet が必要なリクエストの 30% を Haiku で処理していた。
Before(間違った実装):
// ❌ 必要以上にコスト削減を優先
function isComplexByTokens(message) {
return message.length > 500; // 閾値が低すぎる
}
// 結果: Sonnet 利用率が 15% に低下し、品質ダウン
After(修正版):
// ✅ ユーザーフィードバック+コスト目標のバランス
function isComplexByTokens(message) {
// 実装時の月別監視データから、
// 「Sonnet 30%、Haiku 70% が品質とコストのバランス点」と判断
return message.length > 800; // 統計的に導出した閾値
}
// モニタリングで確認:
// - Sonnet 利用率 30-40%
// - ユーザー満足度 4.2/5 以上
// - コスト削減 35-45%
対策: ルーティングの目的は「コスト削減」ではなく「コスト最適化」です。つまり、品質を損なわない範囲でコストを削減すること。
ここで 800 という数字を出しましたが、この手の閾値は勘で置いても検証のしようがありません。実際にどうやってこの値を決めるかは、後述の「閾値を勘で決めない — シャドー評価ハーネスの作り方」で扱います。私自身、この評価を通してこの 800 を 600 に下げることになりました。
監視と可視化:本番運用の透明性確保
ルーティング判定が正しく機能しているかを常に把握するため、監視ダッシュボードを実装します。
// 監視用メトリクスの構造
interface RoutingAnalytics {
period: string; // "daily" | "weekly" | "monthly"
totalRequests: number;
sonnetRequests: number;
haikuRequests: number;
// コスト分析
estimatedCostSonnet: number;
estimatedCostHaiku: number;
estimatedCostTotal: number;
// パフォーマンス分析
avgLatencySonnet: number;
avgLatencyHaiku: number;
// 分類器の内訳
layer1Count: number; // キーワード判定
layer2Count: number; // トークン数判定
layer3Count: number; // セマンティック判定
// ユーザー満足度
avgUserRating: number;
lowQualityIncidents: number; // 1-2 評価の件数
}
// ダッシュボード用の集計関数
function generateAnalytics(
metrics: RoutingMetrics[],
period: string
): RoutingAnalytics {
const sonnet = metrics.filter(m => m.selectedModel === 'sonnet');
const haiku = metrics.filter(m => m.selectedModel === 'haiku');
const sonnetCost = sonnet.reduce((sum, m) =>
sum + (m.inputTokens * 0.003 + m.outputTokens * 0.015) / 1000000, 0);
const haikuCost = haiku.reduce((sum, m) =>
sum + (m.inputTokens * 0.0008 + m.outputTokens * 0.004) / 1000000, 0);
return {
period,
totalRequests: metrics.length,
sonnetRequests: sonnet.length,
haikuRequests: haiku.length,
estimatedCostSonnet: sonnetCost,
estimatedCostHaiku: haikuCost,
estimatedCostTotal: sonnetCost + haikuCost,
avgLatencySonnet: sonnet.reduce((sum, m) => sum + m.responseTime, 0) / (sonnet.length || 1),
avgLatencyHaiku: haiku.reduce((sum, m) => sum + m.responseTime, 0) / (haiku.length || 1),
layer1Count: metrics.filter(m => m.layer === 1).length,
layer2Count: metrics.filter(m => m.layer === 2).length,
layer3Count: metrics.filter(m => m.layer === 3).length,
avgUserRating: 0, // 別途ユーザーフィードバックから算出
lowQualityIncidents: 0
};
}
// ダッシュボード出力
function printDashboard(analytics: RoutingAnalytics) {
console.log(`
╔════════════════════════════════════════════╗
║ Claude Routing Analytics - ${analytics.period} ║
╚════════════════════════════════════════════╝
📊 Request Distribution
Sonnet: ${analytics.sonnetRequests} (${((analytics.sonnetRequests / analytics.totalRequests) * 100).toFixed(1)}%)
Haiku: ${analytics.haikuRequests} (${((analytics.haikuRequests / analytics.totalRequests) * 100).toFixed(1)}%)
💰 Cost Analysis
Sonnet: $${analytics.estimatedCostSonnet.toFixed(2)}
Haiku: $${analytics.estimatedCostHaiku.toFixed(2)}
Total: $${analytics.estimatedCostTotal.toFixed(2)}
⚡ Latency (avg)
Sonnet: ${analytics.avgLatencySonnet.toFixed(0)}ms
Haiku: ${analytics.avgLatencyHaiku.toFixed(0)}ms
🎯 Classification Breakdown
Layer 1 (Keywords): ${analytics.layer1Count}
Layer 2 (Tokens): ${analytics.layer2Count}
Layer 3 (Semantic): ${analytics.layer3Count}
`);
}
監視が重要な理由: ルーティングは「セットして忘れる」タイプの実装ではありません。モデルの性能が向上したり、API 料金が変わったり、ユーザーの利用パターンが変わったりするたびに、ルーティング判定の最適値も変わります。週 1 回のダッシュボード確認が、継続的改善の基盤になります。
実装テンプレート:プロダクション対応の完全コード
最後に、ここまで解説した要素を全て統合したプロダクション対応のテンプレートを示します。
// 完全なプロダクション実装テンプレート
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
// グローバルなルーティング状態
const routerState = {
circuitBreaker: {
sonnet: { isOpen: false, failureCount: 0, lastFailureTime: 0 },
haiku: { isOpen: false, failureCount: 0, lastFailureTime: 0 }
},
metrics: [] as RoutingMetrics[]
};
// フロー: 1. 複雑度判定 → 2. モデル選択 → 3. API呼び出し → 4. フォールバック → 5. メトリクス記録
async function process(userMessage: string) {
const startTime = Date.now();
// Step 1: ルーティング判定
let decision = await routeRequest(userMessage);
let model = decision.model;
// Step 2: サーキットブレーカー確認
const cbState = model === 'claude-sonnet-4-6-20250514'
? routerState.circuitBreaker.sonnet
: routerState.circuitBreaker.haiku;
if (cbState.isOpen) {
if (Date.now() - cbState.lastFailureTime < 60000) {
// まだ復旧時間に達していない
model = 'claude-haiku-4-5-20251001'; // フォールバック
console.log('Circuit breaker open, falling back to Haiku');
} else {
// 復旧時間経過、試しに Sonnet を使う
cbState.isOpen = false;
cbState.failureCount = 0;
}
}
// Step 3: API呼び出し(フォールバック付き)
let response;
try {
response = await client.messages.create({
model: model,
max_tokens: 1024,
messages: [{ role: 'user', content: userMessage }]
});
// 成功:カウンター reset
cbState.failureCount = 0;
} catch (error) {
cbState.failureCount++;
cbState.lastFailureTime = Date.now();
if (cbState.failureCount >= 5) {
cbState.isOpen = true;
}
// Sonnet 失敗時は Haiku にフォールバック
if (model === 'claude-sonnet-4-6-20250514') {
console.warn(`Sonnet failed, falling back to Haiku`);
try {
response = await client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
messages: [{ role: 'user', content: userMessage }]
});
} catch (fallbackError) {
throw new Error(`Both Sonnet and Haiku failed: ${error}, ${fallbackError}`);
}
} else {
throw error;
}
}
// Step 4: メトリクス記録
const responseTime = Date.now() - startTime;
const metric: RoutingMetrics = {
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
layer: decision.layer,
selectedModel: model.includes('sonnet') ? 'sonnet' : 'haiku',
inputTokens: 100, // 実装時は response.usage.input_tokens を使用
outputTokens: response.usage?.output_tokens || 0,
responseTime
};
routerState.metrics.push(metric);
// Step 5: 定期的な分析(日 1 回など)
if (routerState.metrics.length % 1000 === 0) {
const daily = generateAnalytics(routerState.metrics.slice(-1000), 'realtime');
printDashboard(daily);
}
return {
content: response.content[0].type === 'text' ? response.content[0].text : '',
routing: {
model: decision.model,
layer: decision.layer,
latency: responseTime
}
};
}
// エントリーポイント
(async () => {
try {
const result = await process('Claude API とコスト最適化について説明してください');
console.log('Response:', result.content.slice(0, 200));
console.log('Routing:', result.routing);
} catch (error) {
console.error('Error:', error);
}
})();
本番運用のチェックリスト:
- [ ] サーキットブレーカーの状態がグローバルに共有されているか
- [ ] Layer 1-2 の判定で 80% 以上のリクエストが処理されているか
- [ ] 月 1 回以上、ダッシュボードを確認し、Sonnet 利用率が 30-40% か検証
- [ ] ユーザーフィードバック収集機構が組み込まれているか
- [ ] 他メンバーが判定ロジックを理解できる状態(ドキュメント + コメント)になっているか
閾値を勘で決めない — シャドー評価ハーネスの作り方
ここまでの実装で、一箇所だけ根拠が弱いまま残っている数字があります。message.length > 800 の 800 です。
先ほどのミス 3 で「統計的に導出した閾値」と書きましたが、その導出手順そのものを書いている記事をほとんど見かけません。実際のところ、ここを勘で決めてしまうと、あとから何を監視しても「閾値が正しいのか」は永遠に分からないままになります。
私が使っているのは、シャドー評価ハーネスと呼んでいる仕組みです。考え方は単純で、本番のログから実リクエストを抜き出し、両方のモデルで同じ入力を処理させて、品質差とコスト差を複雑度バケットごとに並べるというものです。ユーザーには一切影響しません。
なぜ「両方で走らせる」必要があるのか
ルーティングの本質的な問いは「このリクエストは Haiku で足りるか」です。この問いに答えるには、同じリクエストに対する Sonnet の出力と Haiku の出力を並べて比較するしかありません。片方だけを見て「品質が低い気がする」と判断していると、実際には入力そのものが曖昧だっただけ、というケースを延々とモデルのせいにし続けることになります。
比較の軸は 2 つで足ります。品質差が無視できるほど小さければ Haiku で良く、大きければ Sonnet に回す。それだけです。
実装
// shadow-eval.ts — 本番トラフィックのサンプルを両モデルで再生し、
// バケットごとの「品質差 / コスト差」を出す
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const SONNET = 'claude-sonnet-4-6-20250514';
const HAIKU = 'claude-haiku-4-5-20251001';
interface SampledRequest {
requestId: string;
prompt: string;
charLength: number;
}
interface PairResult {
requestId: string;
bucket: string;
qualityDelta: number; // 0 = 差なし、正の値 = Sonnet が優位
costSonnet: number;
costHaiku: number;
}
// 複雑度バケット。閾値候補を跨ぐように刻むのが要点
function bucketOf(charLength: number): string {
if (charLength < 200) return '<200';
if (charLength < 400) return '200-399';
if (charLength < 600) return '400-599';
if (charLength < 800) return '600-799';
if (charLength < 1200) return '800-1199';
return '1200+';
}
function costOf(model: string, inTok: number, outTok: number): number {
const rate = model === SONNET
? { in: 3 / 1_000_000, out: 15 / 1_000_000 }
: { in: 0.8 / 1_000_000, out: 4 / 1_000_000 };
return inTok * rate.in + outTok * rate.out;
}
// 判定役も Haiku で十分。ここを Sonnet にすると評価コストが本末転倒になる
async function judge(prompt: string, a: string, b: string): Promise<number> {
const res = await client.messages.create({
model: HAIKU,
max_tokens: 64,
messages: [{
role: 'user',
content: [
'You compare two answers to the same request.',
'Score how much better Answer A is than Answer B on a -2..2 scale',
'(0 means indistinguishable for the user).',
'Respond ONLY with JSON: {"delta": number}',
'',
`Request: ${prompt}`,
`Answer A (Sonnet): ${a}`,
`Answer B (Haiku): ${b}`,
].join('\n')
}]
});
const text = res.content[0].type === 'text' ? res.content[0].text : '{"delta":0}';
try {
return JSON.parse(text).delta ?? 0;
} catch {
return 0; // パースできない判定は「差なし」として捨てる
}
}
async function evaluatePair(req: SampledRequest): Promise<PairResult> {
const [sonnet, haiku] = await Promise.all([
client.messages.create({ model: SONNET, max_tokens: 1024,
messages: [{ role: 'user', content: req.prompt }] }),
client.messages.create({ model: HAIKU, max_tokens: 1024,
messages: [{ role: 'user', content: req.prompt }] }),
]);
const sText = sonnet.content[0].type === 'text' ? sonnet.content[0].text : '';
const hText = haiku.content[0].type === 'text' ? haiku.content[0].text : '';
return {
requestId: req.requestId,
bucket: bucketOf(req.charLength),
qualityDelta: await judge(req.prompt, sText, hText),
costSonnet: costOf(SONNET, sonnet.usage.input_tokens, sonnet.usage.output_tokens),
costHaiku: costOf(HAIKU, haiku.usage.input_tokens, haiku.usage.output_tokens),
};
}
集計側はこうなります。バケットごとに「Sonnet に上げて得られる品質」と「そのために払う金額」を並べます。
interface BucketSummary {
bucket: string;
samples: number;
avgQualityDelta: number; // 大きいほど Sonnet を使う価値がある
avgExtraCost: number; // Sonnet に上げたときの追加コスト(1 リクエストあたり)
costPerQualityPoint: number;
}
function summarize(results: PairResult[]): BucketSummary[] {
const groups = new Map<string, PairResult[]>();
for (const r of results) {
if (!groups.has(r.bucket)) groups.set(r.bucket, []);
groups.get(r.bucket)!.push(r);
}
return [...groups.entries()].map(([bucket, rows]) => {
const avgQualityDelta =
rows.reduce((sum, r) => sum + r.qualityDelta, 0) / rows.length;
const avgExtraCost =
rows.reduce((sum, r) => sum + (r.costSonnet - r.costHaiku), 0) / rows.length;
return {
bucket,
samples: rows.length,
avgQualityDelta,
avgExtraCost,
// 品質差がほぼゼロのバケットは、いくら安くても Sonnet に上げる理由がない
costPerQualityPoint:
avgQualityDelta > 0.05 ? avgExtraCost / avgQualityDelta : Infinity,
};
}).sort((a, b) => a.costPerQualityPoint - b.costPerQualityPoint);
}
出力の読み方
集計結果は、おおむね次のような形になります。
| バケット(入力文字数) | 平均品質差 | 追加コスト / 件 | 判断 |
| <200 | ほぼ 0 | 小 | Haiku で確定 |
| 200-399 | ほぼ 0 | 小 | Haiku で確定 |
| 400-599 | わずか | 中 | 様子見。他の軸で判定 |
| 600-799 | 明確に出る | 中 | 境界。ここが閾値の候補 |
| 800-1199 | 大きい | 大 | Sonnet に回す |
| 1200+ | 大きい | 大 | Sonnet に回す |
閾値は「品質差が立ち上がるバケットの下端」に置きます。 私の環境では 600-799 で品質差が明確に出はじめたため、800 ではなく 600 に下げました。ミス 3 で書いた 800 は、じつはこの評価をやる前の値です。評価をやってはじめて、自分の閾値が 200 文字ぶん強気すぎたと分かりました。
運用上の注意
3 点だけ、実際にやってみて分かったことを書き添えます。
第一に、サンプル数はバケットあたり 50 件が最低ラインです。それ以下だと、たまたま曖昧な入力が混じっただけで平均が振れます。
第二に、判定役のモデルには「どちらが Sonnet か」を伝えないでください。上の実装ではラベルに (Sonnet) と書いていますが、これは説明のためのもので、本番の評価では A / B のみにします。ラベルを見せると高いほうのモデルに寄った判定が出ます。私はこれに一度引っかかり、品質差を 0.4 ポイントほど過大に見積もりました。
第三に、この評価は四半期に 1 回で十分です。毎週回すほどの変化はありませんし、両モデルを走らせる評価そのものにコストがかかります。モデルの世代が変わったとき、あるいは監視で Sonnet 利用率が想定から外れたときが、再評価のタイミングです。
公式ドキュメントには書かれていない8つの運用知見
ここからは、技術メディアの運用基盤(コンテンツ分類・メンバーシップ判定・検索リランキング)と、iOS / Android アプリのカスタマーサポート bot で 6 ヶ月以上ルーティングを運用してきたなかで得た、公式には書かれていない知見をまとめます。Anthropic のドキュメントは丁寧ですが、月数千万トークン規模の本番運用に固有の罠は、運用してみないと見えてきません。
1. ルーティング判定そのものを Haiku に任せると安くなる
Layer 3 のセマンティック分類器を Sonnet で動かしている実装をよく見ますが、これは本末転倒です。判定リクエスト 1 件あたり 300〜500 トークンとはいえ、月 100 万リクエストで Sonnet を回すと、判定だけで $1,500 / 月。
Haiku 4.5 に「以下のリクエストの複雑度を simple / moderate / complex で返せ」とだけ指示すると、ほぼ同じ精度で判定でき、コストは約 1/4 になります。私の環境では判定タスク自体を Haiku に固定し、本体のみ Sonnet / Haiku 振り分けに任せる二段構造で運用しています。
2. キーワード辞書は四半期ごとに必ず見直す
Layer 1 のキーワード判定リストは「最初に作って終わり」になりがちですが、これが命取りです。半年で技術用語の語感が変わります。たとえば 2026 年に入ってから「agent」「orchestration」「dispatch」というキーワードの出現頻度が 3 倍になり、以前は Haiku で十分だった「agent を作って」というリクエストの実体が大幅に複雑化しました。
私は四半期に 1 回、過去 90 日分のメトリクスを取り出して「Haiku で処理して低評価を受けたリクエスト」のキーワード共起を分析し、上位 30 語を Sonnet 行きキーワードに昇格させています。これだけで誤判定率が 2 ポイント下がります。
3. ストリーミングでは fallback の挙動が変わる
Sonnet がエラーを返したとき、非ストリーミングなら単純に Haiku で再実行すれば済みます。ただし stream: true で既に最初のデルタをクライアントに送り始めていた場合、途中切断とリトライではクライアント側の表示が崩れます。
私の実装では SSE コントローラ内に「最初の content delta を送るまでは fallback できる安全期間」を 800 ms 確保し、そのあいだは Sonnet の応答をバッファに吸収してから初デルタを送ります。これで Sonnet 障害時の fallback がユーザーから見て自然に切り替わります。
const FALLBACK_WINDOW_MS = 800;
async function streamWithSafeFallback(prompt: string, primary: 'sonnet' | 'haiku') {
const startedAt = Date.now();
let firstDeltaEmitted = false;
const buffer: string[] = [];
try {
for await (const chunk of streamModel(primary, prompt)) {
if (Date.now() - startedAt < FALLBACK_WINDOW_MS && !firstDeltaEmitted) {
buffer.push(chunk); // 安全期間中はクライアントに送らない
continue;
}
// 安全期間を抜けたら一気に流す
if (buffer.length > 0) {
for (const b of buffer) yield b;
buffer.length = 0;
firstDeltaEmitted = true;
}
yield chunk;
}
} catch (err) {
// 安全期間内のエラーなら fallback できる
if (!firstDeltaEmitted) {
for await (const chunk of streamModel('haiku', prompt)) yield chunk;
} else {
throw err; // 既に送信開始済みなら再送不能
}
}
}
4. サーキットブレーカーは「半開」状態の挙動が肝
circuit-breaker-js などのライブラリをそのまま使うと、半開状態のテストリクエストに本物のユーザートラフィックを充ててしまいます。失敗すると、回復しかけたサーキットが即座に開きます。
私の実装では半開状態専用のヘルスチェック関数(軽量プロンプト "ping" を投げる)を別途用意し、ヘルスチェックが 3 回連続成功してから本番トラフィックを通す設計にしています。これで再障害発生率が 70% 下がりました。
5. コスト超過アラートは「金額」ではなく「ルーティング比率の偏り」を見る
「月予算 $5,000 を超えたらアラート」という設定は遅すぎます。気づいた頃には予算を消化しています。本当に効くのは「Sonnet 利用率が想定の 35% を超えて 50% に近づいたら」という比率アラート。ルーティング判定が壊れたのか、ユーザーのリクエスト分布が変わったのかを 30 分以内に検知できます。
私の環境では sonnet_ratio_5min を Cloudflare Workers のメトリクスに出し、Grafana で 45% を超えたら Slack に通知しています。実際にこのアラートで Layer 1 のキーワード辞書が壊れていたバグを 2 度救われました。
6. メンバーシップ判定が走るパスでは強制 Sonnet にする
公開前の品質ゲートが走る生成パスでは、コストが多少高くても Sonnet 固定が正解です。理由は単純で、Haiku が書いた記事はゲートに弾かれる確率が高く、再生成のコストを合算すると Sonnet 1 回のほうが安いからです。
ルーティング層には必ず「path / route ベースのバイパス」を実装してください。「コストではなく成功率で最適化すべきパス」は、必ず存在します。私の環境では /api/generate-premium-article のみ強制 Sonnet、/api/classify-comment は強制 Haiku、その他は通常ルーティング、という 3 種類のレジームを用意しています。
7. プロンプトキャッシュとルーティングの相性は意外と悪い
長文コンテキストを Sonnet で処理する場合、プロンプトキャッシュは 70〜90% のコスト削減を実現します。ところがルーティングで Sonnet / Haiku を毎回切り替えると、キャッシュヒット率が劇的に下がります。
対策は「同一セッション中はモデル固定」というセッションスティッキネス。私の実装では Cloudflare KV にセッション ID で選択モデルを記録し、24 時間は同じモデルを使い続けます。これでキャッシュヒット率が 12% → 64% に回復しました。
8. リクエスト分布は静かに、ある日突然変わる
最後にもっとも重要な知見です。リクエスト分布は線形には変わりません。Antigravity がリリースされた夜、そのトピックを扱うサービスへの問い合わせが 1 日で 3 倍になりました。その大半は「設定方法」「インストール」のような simple カテゴリです。固定 Sonnet で運用していたら、1 日で $400 余計に課金されていた計算です。
本番ルーティングを運用するなら、「過去 24 時間と過去 30 日のリクエスト分布の差分」を日次でチェックする習慣をつけてください。分布が動いたら、ルーティング閾値の再調整サインです。
次のステップ:実装を始める前に確認すること
インテリジェントモデルルーティングを導入する前に、確認すべき事項があります。
まず、Claude Haiku 4.5 — 最速・最安モデルでリアルタイムAIアプリを構築する を読んで、2つのモデルの能力差を正確に理解してください。単なるコスト差ではなく、実装の品質にどう影響するか把握することが、適切なルーティング閾値を設定する基盤になります。
次に、Claude API コスト最適化の本番パターン でコスト最適化の全体像を理解し、ルーティング以外の施策(プロンプト最適化、キャッシュ活用など)とのバランスを考えてください。
最後に、Claude API 本番レジリエンスパターン でレジリエンス設計全般を学んだうえで、本記事で説明したサーキットブレーカーを組み込んでください。
これら3つの記事を順に学ぶことで、ルーティング実装が全体の構想にどう位置づけられるのかが見えてきます。
全体を振り返って:実装を始める1つの行動
長い記事を読んでくださった方へ向けて、最後に 1 つの行動をお勧めします。
明日のコード開発セッションで、まずは Layer 1(キーワード判定)のみの実装から始めてください。 セマンティック分類やサーキットブレーカーの複雑な機構ではなく、複数の複雑なキーワード(「architecture」「analyze」「review」など)が含まれたリクエストを Sonnet に、その他を Haiku に振り分けるだけです。
それを 1 週間本番で動かし、実際のコスト削減率とユーザーフィードバックを観察してください。その観察結果から、「Layer 2 のトークン判定が本当に必要か」「Haiku が選択される複雑なリクエストがどの程度あるか」が見えてきます。
データが得られたら、初めて Layer 2、さらには Layer 3 への拡張を判断します。これが、組織全体がモデルルーティングから最大の価値を得られる道です。
完璧な実装よりも、小さく始めて観察しながら改善することが、本番運用では最も重要です。