2026 年の春、Haiku 3 系の旧モデル文字列が廃止予定に入ったというメールを受け取ったとき、私がまず開いたのは API の管理画面ではなく、自分のアプリ群のソースコードを横断検索する grep でした。claude-3-haiku という文字列が、壁紙アプリのレビュー要約、引き寄せ系アプリの日次プッシュ文面、個人ブログの自動下書き生成と、合計 12 箇所にそのまま埋まっていたのです。どれも数年前に「とりあえず動かす」つもりで書いたコードが、そのまま本番に残り続けていました。
私は 2014 年から Dolice という屋号で個人開発を続けて 12 年、累計 5,000万ダウンロードを超えるアプリ事業を運営してきました。AdMob 経由の収益が立つ規模になると、機能が静かに壊れることが一番こわい。エラーで落ちてくれれば気づけますが、モデル廃止は「ある日から徐々に応答が変わる/ある締切日を境に 400 が返り始める」という顔をしてやってきます。このとき痛感したのは、モデル名という「外部ベンダーの都合で変わる識別子」を、自分の業務ロジックの内側に持ち込んでいたことそのものが設計の負債だった、という事実でした。
本稿でまとめるのは、この負債を返済するための anti-corruption layer(腐敗防止層)の設計です。ドメイン駆動設計の文脈で語られるこのパターンを Claude API に当てはめ、論理ロールと物理モデル ID を切り離す具体的な実装を、TypeScript と Python の動くコードで示します。
直書きが生む「静かな腐敗」の正体
モデル名を業務コードに直書きすることの本当の問題は、grep で 12 箇所ヒットすること自体ではありません。それなら一括置換で済みます。本質的な問題は、各呼び出し箇所が「そのモデルの暗黙の前提」も一緒に抱え込んでいる点にあります。
たとえばレビュー要約のコードは「Haiku は速いが長文の指示追従が弱いので、プロンプトを短く保つ」という前提で書かれていました。一方ブログ下書きのコードは「多少遅くても品質を優先したいので max_tokens を大きめに取る」という前提でした。同じ claude-3-haiku という文字列でも、その背後にある期待値は呼び出し箇所ごとにバラバラだったのです。
この状態で単純置換をすると、速度前提のコードに高品質モデルを差し込んでレイテンシ予算を超過させたり、逆に品質前提のコードに軽量モデルを差し込んで出力が劣化したりします。つまり「モデル文字列」は単なる識別子ではなく、速度・品質・コスト・コンテキスト長という複数の非機能要件を圧縮した記号だったわけです。腐敗防止層が隔離すべきは、この圧縮された前提のほうです。
論理ロールという「翻訳語彙」を設計する
腐敗防止層の中心になるのは、業務側の語彙と API 側の語彙を翻訳する辞書です。私は業務側の語彙を「論理ロール」と呼んでいます。アプリのコードは物理モデル名を一切知らず、自分が欲しい性質だけをロールとして要求します。
私のアプリ群では、次の 3 つのロールに集約すると過不足がありませんでした。
fast — ユーザーの操作に同期して走る軽量タスク。レイテンシ最優先。レビュー要約のプレビュー、入力補助など。
balanced — 非同期で走るがその日のうちに品質を見られるタスク。プッシュ文面の生成、タグ付けなど。
deep — 人間が後で必ず目を通す前提の高品質タスク。ブログ下書き、長文の構成案など。
重要なのは、このロール名が「速い/賢い」のような性能の優劣ではなく、ユースケースの性質 で切られている点です。「fast はいつか deep に格上げしたくなる」ことは起きません。fast はあくまで「同期処理で待たせたくない場面」を意味し続けます。物理モデルがどれだけ賢くなっても、この境界は安定します。長期運用で抽象が壊れないのは、抽象の軸を性能ではなく要件に置いたからでした。
ModelRegistry — 翻訳を 1 箇所に閉じ込める
ロールから物理モデルへの翻訳を、たった 1 つのモジュールに集約します。世代交代のときに触るのはこのファイルだけ、という状態を作るのが目的です。
// model-registry.ts — モデルに関する「外部の都合」を閉じ込める唯一の場所
export type ModelRole = "fast" | "balanced" | "deep" ;
interface ModelBinding {
primary : string ; // 通常使う物理モデルID
fallbacks : string []; // 障害・廃止時に降りていく順
inputPer1M : number ; // 入力 100万トークンあたりの単価(USD)
outputPer1M : number ; // 出力 100万トークンあたりの単価(USD)
maxOutputTokens : number ;
}
// ここを書き換えるだけで全アプリのモデル世代交代が完結する
const REGISTRY : Record < ModelRole , ModelBinding > = {
fast: {
primary: "claude-haiku-4-5-20251001" ,
fallbacks: [ "claude-3-5-haiku-latest" ],
inputPer1M: 1.0 ,
outputPer1M: 5.0 ,
maxOutputTokens: 4096 ,
},
balanced: {
primary: "claude-sonnet-4-6" ,
fallbacks: [ "claude-haiku-4-5-20251001" ],
inputPer1M: 3.0 ,
outputPer1M: 15.0 ,
maxOutputTokens: 8192 ,
},
deep: {
primary: "claude-opus-4-6" ,
fallbacks: [ "claude-sonnet-4-6" ],
inputPer1M: 15.0 ,
outputPer1M: 75.0 ,
maxOutputTokens: 16384 ,
},
};
export function resolve ( role : ModelRole ) : ModelBinding {
const binding = REGISTRY [role];
if ( ! binding) throw new Error ( `unknown role: ${ role }` );
return binding;
}
export function chain ( role : ModelRole ) : string [] {
const b = resolve (role);
return [b.primary, ... b.fallbacks];
}
この設計のポイントは、単価とコンテキスト上限まで一緒にロールへ束ねている点です。モデル文字列だけを差し替えると、後述するコスト計算や max_tokens の前提がずれて別のバグを生みます。「変わるものは一緒に変わる」ので、同じ場所に置いておきます。
ゲートウェイ — 業務コードから見える唯一の窓口
業務コードは Anthropic SDK を直接は呼びません。必ずこのゲートウェイ経由にします。ここでフォールバックの降下とコストの記録をまとめて引き受けます。
// model-gateway.ts — 業務コードはこの ask() だけを知っていればよい
import Anthropic from "@anthropic-ai/sdk" ;
import { resolve, chain, type ModelRole } from "./model-registry" ;
const client = new Anthropic ();
interface AskResult {
text : string ;
modelUsed : string ;
usdCost : number ;
degraded : boolean ; // primary 以外に落ちたか
}
export async function ask (
role : ModelRole ,
prompt : string ,
opts : { maxTokens ?: number } = {},
) : Promise < AskResult > {
const binding = resolve (role);
const candidates = chain (role);
let lastErr : unknown ;
for ( let i = 0 ; i < candidates. length ; i ++ ) {
const model = candidates[i];
try {
const res = await client.messages. create ({
model,
max_tokens: opts.maxTokens ?? binding.maxOutputTokens,
messages: [{ role: "user" , content: prompt }],
});
const inTok = res.usage.input_tokens;
const outTok = res.usage.output_tokens;
// フォールバック先は単価が違う場合があるので、実際に使ったモデルで再計算する
const usdCost = priceFor (model, inTok, outTok);
const text = res.content
. filter (( b ) => b.type === "text" )
. map (( b ) => (b as { text : string }).text)
. join ( "" );
return { text, modelUsed: model, usdCost, degraded: i > 0 };
} catch (err) {
lastErr = err;
// 廃止(404 model_not_found)も過負荷(529)も等しく「次へ降りる」で扱う
continue ;
}
}
throw new Error ( `all candidates failed for role=${ role }: ${ String ( lastErr ) }` );
}
function priceFor ( model : string , inTok : number , outTok : number ) : number {
// ロールに紐づかない実モデル単価表(フォールバック先も網羅)
const table : Record < string , [ number , number ]> = {
"claude-haiku-4-5-20251001" : [ 1.0 , 5.0 ],
"claude-3-5-haiku-latest" : [ 0.8 , 4.0 ],
"claude-sonnet-4-6" : [ 3.0 , 15.0 ],
"claude-opus-4-6" : [ 15.0 , 75.0 ],
};
const [ pin , pout ] = table[model] ?? [ 0 , 0 ];
return (inTok / 1e6 ) * pin + (outTok / 1e6 ) * pout;
}
業務コードはこうなります。モデル名は一切登場しません。
// review-summary.ts — 呼び出し側はロールしか知らない
import { ask } from "./model-gateway" ;
export async function summarizeReview ( text : string ) {
const { text : summary , degraded } = await ask (
"fast" ,
`次のアプリレビューを 1 文で要約してください: \n ${ text }` ,
);
if (degraded) {
// 降格時はログだけ残し、ユーザー体験は止めない
console. warn ( "[review-summary] running on fallback model" );
}
return summary;
}
廃止メールが来ても、触るのは model-registry.ts の primary を新モデル ID に書き換える 1 行だけです。実際この設計に移したあと、Sonnet 4.5 の 1M context 系から Sonnet 4.6 へ移る作業は、レジストリの 3 行と単価表の 1 行で終わりました。12 箇所を個別に追っていた頃と比べ、移行に要した実作業時間はおおよそ 80% 減りました。
Python 側にも同じ境界を引く
私のパイプラインは TypeScript と Python が混在しています。ブログの下書き生成や Vision を使った壁紙の一括分類は Python 側です。同じ思想をそのまま移植します。
# model_gateway.py — TypeScript 版と同じ境界を Python でも引く
from dataclasses import dataclass
from anthropic import Anthropic
client = Anthropic()
@dataclass ( frozen = True )
class Binding :
primary: str
fallbacks: tuple[ str , ... ]
max_output: int
REGISTRY : dict[ str , Binding] = {
"fast" : Binding( "claude-haiku-4-5-20251001" , ( "claude-3-5-haiku-latest" ,), 4096 ),
"balanced" : Binding( "claude-sonnet-4-6" , ( "claude-haiku-4-5-20251001" ,), 8192 ),
"deep" : Binding( "claude-opus-4-6" , ( "claude-sonnet-4-6" ,), 16384 ),
}
def ask (role: str , prompt: str , max_tokens: int | None = None ) -> dict :
b = REGISTRY [role]
candidates = (b.primary, * b.fallbacks)
last_err: Exception | None = None
for i, model in enumerate (candidates):
try :
res = client.messages.create(
model = model,
max_tokens = max_tokens or b.max_output,
messages = [{ "role" : "user" , "content" : prompt}],
)
text = "" .join(blk.text for blk in res.content if blk.type == "text" )
return { "text" : text, "model_used" : model, "degraded" : i > 0 }
except Exception as err: # 廃止・過負荷を等しく「次へ」で扱う
last_err = err
continue
raise RuntimeError ( f "all candidates failed for role= { role } : { last_err } " )
言語をまたいでも「業務コードはロールしか知らない」という不変条件は同じです。レジストリが 2 つに分かれてしまう点だけは弱点なので、私は単価とモデル ID を JSON に切り出して両言語から読む運用に寄せています。
世代交代を「気づく」ための契約テスト
抽象化のいちばんの落とし穴は、レジストリを書き換え忘れたまま廃止日を迎えることです。これを防ぐために、各ロールが今も生きているかを確かめる契約テストを CI に置きます。
# test_model_contract.py — 各ロールの primary が実在し応答するかを毎日叩く
import pytest
from model_gateway import REGISTRY , client
@pytest.mark.parametrize ( "role" , list ( REGISTRY .keys()))
def test_primary_model_alive (role):
model = REGISTRY [role].primary
res = client.messages.create(
model = model,
max_tokens = 8 ,
messages = [{ "role" : "user" , "content" : "ping" }],
)
assert res.stop_reason in ( "end_turn" , "max_tokens" )
このテストは GitHub Actions の日次スケジュールで回します。モデルが廃止されて model_not_found が返るようになると、廃止日「前」に CI が赤くなって気づけます。私はこの仕組みを入れてから、メールでの廃止告知を一次情報として頼るのをやめました。告知の見落としに依存しない設計のほうが、個人開発の運用としてははるかに頑健だからです。
実運用での判断として、私はこの契約テストを「fast / balanced / deep の primary だけ」に絞っています。fallback まで毎日叩くとコストと実行時間が無視できなくなりますし、fallback は定義上もう一段安全側なので、primary の健全性さえ監視できていれば早期検知の目的は満たせます。
どこまで抽象化し、どこで止めるか
腐敗防止層は便利ですが、やりすぎると今度は自作フレームワークの保守という別の負債を生みます。私が線を引いている基準は次の通りです。
隔離する : モデル ID、単価、max_tokens の既定値、フォールバック順。これらは「外部の都合で変わる」ので必ず層の内側へ。
隔離しない : extended thinking や tool use のような、機能そのものの呼び出し方。これらを薄いラッパーで覆うと、SDK の新機能が出るたびに自分のラッパーを追従させる作業が発生して本末転倒になります。必要な呼び出し箇所では client を直接使う割り切りのほうが、長期的にはむしろ保守が楽でした。
抽象化の目的は「変化の影響範囲を小さく保つ」ことであって、「SDK を見えなくする」ことではありません。Anthropic 側が安定的に提供してくれるインターフェイス(messages.create の基本形)はそのまま見せ、ベンダー都合で揺れる部分(モデル識別子と価格)だけを隔離する。この線引きが、私が 12 年の個人開発で学んだ「過剰な抽象は負債になる」という教訓と一番うまく折り合う点でした。
次に試すなら、まず自分のコードベースで claude- という文字列を grep してみてください。ヒット箇所が 2 つ以上あれば、それはもう抽象化を始める十分な理由です。同じように世代交代に振り回されてきた方の、静かな備えの一助になれば嬉しいです。