朝、Dolice Labs の 4 サイトの自動投稿を回す前に、いつものように changelog と公式のお知らせを開きました。前日まで動作確認に使っていたモデルのうち一つが、技術的な障害ではない理由で、全外国籍ユーザー向けに短時間のうちに停止されていました。引退の予告メールが来ていたわけでも、429 が返り始めたわけでもありません。ただ、昨日まで応答していたモデル名が、今日は受け付けられなくなっていた。
私が個人開発で運用しているのは記事生成のパイプラインで、人命や決済が絡むものではありません。それでも、headless 実行で夜間に走る工程が「あるモデル名を前提に書かれている」という事実は、急に重く感じられました。フォールバックは一応書いてあったのですが、その分岐は 429 と 529、つまり一時的な過負荷だけを想定していたのです。恒久的な引退や、外部都合による突然の撤回は、同じ fallback() の中で十把一絡げに扱われていました。
この日に学んだのは、「使えない」には性質の違う複数の顔があり、それらを区別せずに一つの例外処理へ流し込むと、復旧の判断を誤る、ということでした。一時的な過負荷なら数分待てば戻ります。けれど撤回されたモデルを数分おきに叩き続けるのは、無駄な失敗を積み増すだけです。引退したモデルに至っては、待っても永遠に戻りません。ここから設計するのは、この三種類を別の状態として持ち、状態に応じて振る舞いを変える可用性ステートマシンと、それを土台にしたルーターです。
「使えない」には三つの顔がある
まず、自動運用を止める「使えない」を性質で分類します。私は次の三つに整理しました。
ひとつ目は 引退(retirement) です。ベンダーが事前に告知し、ある期日を境に恒久的に受け付けなくなるものです。今日、旧世代の claude-sonnet-4 と claude-opus-4 が API から引退しました。これは予測可能で、移行先も決まっています。待っても戻らないので、検知したら即座に後継へ切り替えるのが正解です。
ふたつ目は 撤回(withdrawal) です。技術的な障害ではなく、方針・法令・セキュリティ上の判断などの外部都合で、短時間のうちに一時停止されるものです。期日の予告はなく、復旧の見込みは「いずれ戻るかもしれない」程度の不確実性を持ちます。冒頭で触れたケースがこれにあたります。引退と違って後継が用意されているわけではないので、別の論理ロールへ横移動するか、その工程を一時的に縮退させる判断が要ります。
みっつ目は 過負荷(overload) です。429 Too Many Requests や 529 Overloaded のように、数分から数時間で自然に戻る一時的な不可用です。ここで恒久的な切り替えをしてしまうと、本来使いたかった上位モデルから不必要に降格したままになります。指数バックオフで待ち、回復したら元に戻すのが筋です。
この三つを一つの catch で扱うと、撤回されたモデルに過負荷向けのバックオフを延々と適用したり、過負荷のモデルを引退扱いで恒久降格したりという、判断の取り違えが起きます。状態を分けて持つ理由はここにあります。
論理ロールと物理モデルIDを切り離す
ステートマシンを設計する前に、土台となるレジストリを整えます。業務コードがモデル文字列を直接知っているうちは、撤回や引退のたびに数十箇所を書き換えることになります。私自身、最初はそれで痛い目を見ました。
そこで、コードが参照するのは 論理ロール (fast / balanced / deep)だけにし、論理ロールから物理モデルIDへの対応と、各モデルの可用性状態を一箇所に集約します。
// model-registry.ts
export type Role = "fast" | "balanced" | "deep" ;
export type Availability = "available" | "overloaded" | "withdrawn" | "retired" ;
interface ModelEntry {
id : string ; // 物理モデルID
inputPer1M : number ; // 入力トークン単価(USD / 1M tokens)
outputPer1M : number ; // 出力トークン単価
availability : Availability ;
// 撤回・引退時にこのロールで次に試す代替モデルID(同ロール内の縮退先)
understudy ?: string ;
}
// ロールごとに「優先度順」の候補列を持つ。先頭が第一候補。
export const REGISTRY : Record < Role , ModelEntry []> = {
deep: [
{ id: "claude-opus-4-8" , inputPer1M: 5.0 , outputPer1M: 25.0 , availability: "available" },
{ id: "claude-sonnet-4-6" , inputPer1M: 3.0 , outputPer1M: 15.0 , availability: "available" },
],
balanced: [
{ id: "claude-sonnet-4-6" , inputPer1M: 3.0 , outputPer1M: 15.0 , availability: "available" },
{ id: "claude-haiku-4-5" , inputPer1M: 1.0 , outputPer1M: 5.0 , availability: "available" },
],
fast: [
{ id: "claude-haiku-4-5" , inputPer1M: 1.0 , outputPer1M: 5.0 , availability: "available" },
],
};
業務コードは route("deep") のように論理ロールでモデルを要求し、物理IDを一切知りません。撤回されたモデルを全工程から外したいときは、REGISTRY の該当エントリの availability を 1 行書き換えるだけで済みます。これが「変更を 1 ファイルに閉じ込める」という設計の核です。
可用性ステートマシンの遷移を定義する
次に、各モデルの availability がどう遷移するかを明文化します。曖昧にしておくと、運用中に「いつ元へ戻すのか」を毎回その場の判断で決めることになり、再現性が失われます。
遷移は次のように定めました。
available → overloaded: 429 または 529 を一定回数連続で受けたとき。タイムスタンプを記録し、クールダウン後に自動で available へ戻す候補とする。
overloaded → available: クールダウン(既定 10 分)経過後、最初の成功応答を確認できたとき。
available → retired: 引退告知の期日を過ぎた、または引退を示すエラー(model_not_found 系で恒久的なもの)を受けたとき。自動では元に戻さない。
available → withdrawn: 撤回を示す挙動(直前まで成功していたモデルが、入力検証エラーや権限エラーで一斉に弾かれ始める)を検知したとき。後継が未定なので同ロール内の次候補へ縮退する。戻すのは運用者の明示操作のみ。
retired と withdrawn を自動復帰させないのが要点です。過負荷だけが時間で自然回復する状態であり、残り二つは「外の世界の事情」が変わらない限り戻りません。機械が勝手に戻すと、撤回されたモデルへ何度も突っ込んで失敗を量産します。
// availability-machine.ts
const OVERLOAD_COOLDOWN_MS = 10 * 60 * 1000 ; // 10分
interface Health {
state : Availability ;
overloadedAt ?: number ;
consecutive429 : number ;
}
const health = new Map < string , Health >(); // key: モデルID
export function noteResult (
id : string ,
outcome : "ok" | "overloaded" | "retired" | "withdrawn" ,
) {
const h = health. get (id) ?? { state: "available" , consecutive429: 0 };
if (outcome === "ok" ) {
h.state = "available" ;
h.consecutive429 = 0 ;
h.overloadedAt = undefined ;
} else if (outcome === "overloaded" ) {
h.consecutive429 += 1 ;
if (h.consecutive429 >= 3 ) {
h.state = "overloaded" ;
h.overloadedAt = Date. now ();
}
} else if (outcome === "retired" ) {
h.state = "retired" ; // 自動復帰しない
} else if (outcome === "withdrawn" ) {
h.state = "withdrawn" ; // 運用者操作でのみ復帰
}
health. set (id, h);
}
export function effectiveState ( id : string ) : Availability {
const h = health. get (id);
if ( ! h) return "available" ;
// 過負荷だけは時間で自然回復させる
if (h.state === "overloaded" && h.overloadedAt &&
Date. now () - h.overloadedAt > OVERLOAD_COOLDOWN_MS ) {
return "available" ; // クールダウン明け。次の成功で確定する
}
return h.state;
}
ルーターを実装する
レジストリと健康状態がそろえば、ルーターは「論理ロールを受け取り、いま使える先頭候補を返す」だけの薄い層になります。撤回・引退・過負荷の区別はすべて effectiveState に吸収されているので、ルーター自身は分岐を持ちません。
// router.ts
import Anthropic from "@anthropic-ai/sdk" ;
import { REGISTRY, Role } from "./model-registry" ;
import { noteResult, effectiveState } from "./availability-machine" ;
const client = new Anthropic (); // ANTHROPIC_API_KEY は環境変数から
function pickModel ( role : Role ) : string {
const candidates = REGISTRY [role]
. filter (( m ) => effectiveState (m.id) === "available" );
if (candidates. length === 0 ) {
throw new Error ( `role ${ role } に利用可能なモデルがありません` );
}
return candidates[ 0 ].id;
}
function classifyError ( err : unknown ) : "overloaded" | "retired" | "withdrawn" | "other" {
const e = err as { status ?: number ; error ?: { type ?: string } };
if (e.status === 429 || e.status === 529 ) return "overloaded" ;
// 恒久的なモデル未検出は引退とみなす
if (e.error?.type === "not_found_error" ) return "retired" ;
// 直前まで成功していたモデルが権限・検証で弾かれ始めたら撤回の疑い
if (e.status === 403 || e.error?.type === "permission_error" ) return "withdrawn" ;
return "other" ;
}
export async function route ( role : Role , params : Omit < Anthropic . MessageCreateParams , "model" >) {
let lastErr : unknown ;
// 同ロール内の候補数だけ試す
for ( let attempt = 0 ; attempt < REGISTRY [role]. length ; attempt ++ ) {
const model = pickModel (role);
try {
const res = await client.messages. create ({ ... params, model });
noteResult (model, "ok" );
return { res, model };
} catch (err) {
lastErr = err;
const kind = classifyError (err);
if (kind === "other" ) throw err; // 想定外はそのまま投げる
noteResult (model, kind);
// overloaded はクールダウン後に同モデルが復帰しうるが、
// この呼び出しでは次候補へ即フォールバックして応答を返す
}
}
throw lastErr;
}
ここで効いてくるのが、エラー種別を noteServer ではなく classifyError で先に分類している点です。403 や権限エラーは、設定ミスでも起きますが、直前まで成功していたモデルで突然始まった場合 は撤回のシグナルとして扱う価値があります。誤検知が怖い場合は「直近 N 分以内に成功実績があるモデルに限り withdrawn と判定する」というガードを足すと安全です。
日次プリフライトで撤回を早期に検知する
夜間バッチが走る前に、各ロールの先頭候補へ極小の呼び出しを 1 回だけ投げて、撤回・引退を早期に見つけるプリフライトを置きます。実際に走る重い工程の中で初めて気づくより、コストも事故も小さく済みます。
# preflight.py — 1 ロール 1 回の軽量 probe
import os
import anthropic
client = anthropic.Anthropic() # ANTHROPIC_API_KEY は環境変数
PROBE_BY_ROLE = {
"deep" : "claude-opus-4-8" ,
"balanced" : "claude-sonnet-4-6" ,
"fast" : "claude-haiku-4-5" ,
}
def probe (model_id: str ) -> str :
try :
client.messages.create(
model = model_id,
max_tokens = 1 ,
messages = [{ "role" : "user" , "content" : "ok" }],
)
return "available"
except anthropic.APIStatusError as e:
if e.status_code in ( 429 , 529 ):
return "overloaded" # 一時的。バッチは通常どおり進めてよい
if e.status_code == 404 :
return "retired" # 後継へ切り替えが必要
if e.status_code == 403 :
return "withdrawn" # 縮退判断が必要
raise
if __name__ == "__main__" :
blocking = []
for role, model_id in PROBE_BY_ROLE .items():
state = probe(model_id)
print ( f " { role :9s } { model_id :20s } -> { state } " )
if state in ( "retired" , "withdrawn" ):
blocking.append((role, model_id, state))
# retired / withdrawn が見つかったロールはバッチ前に人へ通知
if blocking:
for role, model_id, state in blocking:
print ( f "::alert:: role= { role } model= { model_id } state= { state } " )
max_tokens=1 の probe は 1 回あたりごくわずかな課金で済みます。私の運用では 3 ロール分で 1 日 1 回、月にしても数円規模に収まりました。これを夜間バッチの直前に挟むだけで、「重い工程の途中で初めて撤回に気づく」事故がほぼなくなりました。
フォールバック時にコスト記録を狂わせない
見落としやすいのが、フォールバックでモデルが変わったときのコスト計上です。deep ロールが claude-opus-4-8 から claude-sonnet-4-6 に降格すると、同じトークン数でも単価が変わります。降格を記録せずに第一候補の単価で計上し続けると、月末のコスト集計が静かにずれていきます。
ルーターは「実際に成功したモデルID」を返すので、計上は必ずその返り値を使います。
// cost.ts
import { REGISTRY } from "./model-registry" ;
export function recordCost (
usedModelId : string ,
inputTokens : number ,
outputTokens : number ,
) : number {
const entry = Object. values ( REGISTRY ). flat (). find (( m ) => m.id === usedModelId);
if ( ! entry) throw new Error ( `未知のモデル: ${ usedModelId }` );
const usd =
(inputTokens / 1_000_000 ) * entry.inputPer1M +
(outputTokens / 1_000_000 ) * entry.outputPer1M;
// 第一候補ではなく「実際に使われたモデル」の単価で計上する
return usd;
}
降格は安く済むこともありますが、fast ロールが過負荷で balanced に上がると逆に単価が上がる構成もあり得ます。どちら向きの付け替えも自動で正しく反映されるよう、計上は常に実使用モデルを起点にしておくのが安全です。今日のように課金体系が見直される局面では、この「実使用ベースの計上」がそのまま実コストの観測手段になります。
本番運用で見えた判断と落とし穴
数週間この構成で回して見えてきたことを、率直に書いておきます。
第一に、撤回の自動検知はあくまで補助 だと割り切るのが実用的でした。403 を撤回と断じるのは誤検知の余地が大きく、私は最終的に「プリフライトの probe で withdrawn が出たら人へ通知し、レジストリの状態変更は運用者が確定する」運用に落ち着きました。自動でやるのは過負荷の縮退と復帰までです。本番で機械に任せてよい範囲と、人が判断すべき範囲の線引きは、誤検知のコストで決めるのが筋だと考えています。
第二に、同ロール内に必ず別ベンダー由来でない代替を一つ持っておく こと。今日の撤回のように、特定の系統がまとめて使えなくなる事態では、同じ系統の別モデルへ逃げても無事とは限りません。deep の縮退先には、性能は落ちても確実に残る世代を据えておくと、最悪でも「縮退して走り切る」が選べます。
第三の落とし穴は、overloaded のクールダウンを短くしすぎると、過負荷のピーク中に何度も第一候補へ復帰しては弾かれる振動が起きる点です。私は 10 分から始めて、529 が続く時間帯の実測を見ながら調整しました。固定値の決め打ちより、過負荷の継続時間を計測してから決めるのを推奨します。
最後に、この設計の本質は「待てば戻るもの」と「外の事情が変わらない限り戻らないもの」を、コードの中ではっきり別の状態として扱うことに尽きます。今日のような日が来たとき、慌ててコードを書き換えるのではなく、レジストリの 1 行を変えるだけで自動運用が止まらない——その状態にしておけることが、個人で複数の仕組みを回す上での静かな安心につながると感じています。
次の一歩として、まずはご自分のパイプラインで「いま使っているモデル文字列が業務コードの何箇所に直書きされているか」を grep で数えてみてください。その数が、世代交代や撤回が来たときに書き換える箇所の数です。