Fable 5 が一般提供になった日、私はすぐに手元の自動運用パイプラインで試そうとして、一つの地味な壁にぶつかりました。普段は Claude API を直接叩いているのですが、ある処理だけはレイテンシとリージョンの都合で Amazon Bedrock 経由に寄せたい。ところがコードに書いてある model の文字列が、Bedrock ではそのまま通らないのです。
同じ「Fable 5」を指しているはずなのに、API では claude-fable-5、Bedrock では推論プロファイルの ARN、Vertex AI では publisher 配下のモデルパス——呼び先によって名前がまるで違います。個人開発で一人運用していると、この差は「ちょっとした不便」では済みません。model 文字列がコードベースのあちこちに直書きされていると、プロバイダーを一つ増やすだけで全箇所を手で書き換えるはめになります。
本稿で扱うのは、この散らばった識別子を「論理モデル名 → 物理識別子」という一段の抽象で畳み直す方法です。能力の差と存在確認まで含めて、一人でも安全に運用できる設計を、実装コードとともに見ていきます。
ハードコードした model 文字列が移行を静かに止める
最初に、なぜ直書きが問題になるのかを具体的に見ておきます。私のパイプラインでは、記事生成・要約・分類・整合性チェックといった用途ごとに別々のモジュールが Claude を呼んでいました。Fable 5 を試そうとした時点で、grep -rn 'claude-' src/ | grep -c model を数えたら、model: の直書きが 12 箇所ありました。
このうち何箇所かは Sonnet 世代の古い識別子のまま残っていて、しかもどれが現役でどれが惰性で残っているのか、コードを読むだけでは判別できません。プロバイダーを切り替えるという話以前に、「いま自分はどのモデルを、どこで、いくつ呼んでいるのか」を一覧できない状態になっていたのです。
直書きが効かなくなる本質は、model 文字列が三つの異なる関心事を一語に圧縮してしまっている点にあります。すなわち「どの世代・性能のモデルか(論理的な意図)」「どのプロバイダーのどの識別子か(物理的な所在)」「いつ固定したか(バージョンの安定性)」です。この三つが分離されていないと、片方を変えたいだけでも文字列全体を書き換えることになります。
論理モデル名という一段の抽象を入れる
解決の出発点は単純で、コードからは「論理モデル名」だけを参照させ、物理識別子への変換を一箇所に閉じ込めます。論理名は性能や役割で名付けます。たとえば reasoning-default(既定の推論モデル)、fast-cheap(軽量・低コスト)、long-output(長文一括生成向け)のように、用途で語れる名前にしておくと、呼び出し側のコードが「何をしたいか」を表すようになります。
物理識別子は、論理名とプロバイダーの組から決まります。次のような対応を一枚のテーブルとして持ちます。
論理モデル名 プロバイダー 物理識別子(例)
reasoning-default anthropic claude-fable-5
reasoning-default bedrock (推論プロファイル ARN)
reasoning-default vertex (publisher モデルパス)
fast-cheap anthropic claude-haiku-4-5-20251001
物理識別子をここに具体名で書かないのは意図的です。ARN やモデルパスは環境変数・シークレットとして注入し、テーブルには「キー」だけを持たせます。こうすると、識別子そのものをコードベースに焼き付けずに済み、リージョンやプロジェクトを差し替えても設定の更新だけで対応できます。
環境を見て識別子を解決するリゾルバ
抽象を入れたら、実際に解決する関数を書きます。まずは改善前と改善後を並べて、何が変わるのかを見てください。
改善前。呼び出し側にプロバイダー固有の文字列が露出しています。
// Before: 呼び出し側に物理識別子が直書きされている
const res = await client.messages. create ({
model: "claude-fable-5" , // Bedrock では通らない
max_tokens: 4096 ,
messages,
});
改善後。呼び出し側は論理名しか知りません。
// After: 論理名だけを渡し、解決は resolver に閉じ込める
const m = resolveModel ( "reasoning-default" );
const res = await client.messages. create ({
model: m.id,
max_tokens: Math. min ( 4096 , m.maxOutputTokens),
messages,
});
リゾルバ本体はこうなります。実行環境(どのプロバイダーに向いているか)を一度だけ読み取り、論理名と突き合わせて物理識別子と能力情報を返します。
type Provider = "anthropic" | "bedrock" | "vertex" ;
interface ResolvedModel {
id : string ; // そのプロバイダーで実際に使う識別子
provider : Provider ;
maxOutputTokens : number ;
supportsPromptCache : boolean ;
supportsLongCacheTtl : boolean ; // 1時間TTLの拡張キャッシュ
}
// 物理識別子は env から注入し、コードには焼き付けない
const REGISTRY : Record < string , Partial < Record < Provider , ResolvedModel >>> = {
"reasoning-default" : {
anthropic: {
id: "claude-fable-5" ,
provider: "anthropic" ,
maxOutputTokens: 128_000 ,
supportsPromptCache: true ,
supportsLongCacheTtl: true ,
},
bedrock: {
id: process.env. BEDROCK_FABLE5_PROFILE ?? "" ,
provider: "bedrock" ,
maxOutputTokens: 64_000 , // 環境ごとの実上限に合わせる
supportsPromptCache: true ,
supportsLongCacheTtl: false ,
},
},
};
function currentProvider () : Provider {
return (process.env. CLAUDE_PROVIDER as Provider ) ?? "anthropic" ;
}
export function resolveModel ( logical : string ) : ResolvedModel {
const p = currentProvider ();
const entry = REGISTRY [logical]?.[p];
if ( ! entry || ! entry.id) {
throw new Error (
`モデル解決に失敗: logical=${ logical } provider=${ p }(識別子が未設定です)`
);
}
return entry;
}
ポイントは、解決に失敗したときに例外で止めることです。識別子が空文字のまま API に渡ると、プロバイダー側が model: invalid のような一般的なエラーを返し、原因が「設定の欠落」なのか「モデルの廃止」なのか区別がつかなくなります。リゾルバの段階で「どの論理名が、どのプロバイダーで未設定か」を名指しで落とすと、調査が一気に短くなります。
能力の差を識別子に同梱する
マルチプロバイダーで一番こわいのは、識別子が解決できても「能力が同じだと思い込む」ことです。同じ Fable 5 でも、プロバイダーや契約によって 1 時間 TTL の拡張プロンプトキャッシュが使えたり使えなかったり、出力トークンの実上限が違ったりします。これを呼び出し側が知らずに前提にすると、ある環境でだけ静かに挙動が変わります。
そこで能力はモデル識別子と一緒に持ち、機能を使う前に必ずフラグで分岐します。たとえばプロンプトキャッシュの指定はこう書きます。
const m = resolveModel ( "reasoning-default" );
const cacheControl = m.supportsLongCacheTtl
? { type: "ephemeral" , ttl: "1h" as const }
: m.supportsPromptCache
? { type: "ephemeral" as const } // 既定TTLに自動で縮退
: undefined ; // キャッシュ自体が無い環境
const system = cacheControl
? [{ type: "text" , text: SYSTEM_PROMPT , cache_control: cacheControl }]
: SYSTEM_PROMPT ;
能力フラグを介すると、未対応環境では「エラーで落ちる」のではなく「機能を一段落とした状態で動く」ようにできます。私のパイプラインでは、長文一括生成を long-output 論理名に分け、その maxOutputTokens を実測上限に合わせておくことで、max_tokens の指定がプロバイダー上限を超えて弾かれる事故をなくしました。能力は「あると仮定する」のではなく「フラグで確かめてから使う」のが基本です。
起動時に識別子の実在をプリフライト検証する
リゾルバが返す識別子は、あくまで設定上の値です。ARN のタイプミスや、まだそのリージョンに展開されていないモデルを指していても、リゾルバ単体では気づけません。本番投入の瞬間ではなく、起動時に一度だけ「この識別子は本当にこのプロバイダーに存在するか」を最小コストで確かめておくと安心です。
検証は、最小トークンのダミー呼び出しで十分です。max_tokens: 1 の極小リクエストを投げ、認証・識別子・リージョンの三点が噛み合っているかだけを見ます。
export async function preflight ( logical : string ) : Promise < void > {
const m = resolveModel (logical);
try {
await client.messages. create ({
model: m.id,
max_tokens: 1 ,
messages: [{ role: "user" , content: "ping" }],
});
} catch ( e : any ) {
// 404/NotFound 系は「識別子がそのプロバイダーに無い」サイン
throw new Error (
`プリフライト失敗: ${ logical } → ${ m . id } @ ${ m . provider } / ${ e ?. status ?? "?"} ${ e ?. message ?? e }`
);
}
}
無人で回るパイプラインでは、この一手間が効きます。私は自動投稿のスケジュールタスクで、本処理に入る前に使う論理名だけプリフライトを走らせています。設定をいじった直後の最初の実行で識別子の取り違えに気づければ、深夜に「全件失敗して翌朝に気づく」という最悪の経路を避けられます。max_tokens: 1 なので、検証のコストはほぼ無視できます。
ピン留めと浮動エイリアスでアップグレードを安全にする
論理モデル名を入れると、もう一つ嬉しい副作用があります。アップグレードの安全装置を仕込みやすくなることです。論理名には二系統を用意します。一つは特定スナップショットに固定した「ピン留め」(例: 日付サフィックス付きの識別子)、もう一つは最新を指す「浮動エイリアス」です。
本番のクリティカルな経路はピン留めを使い、モデルの中身が勝手に変わらないようにします。一方で、影響の小さい補助タスクや評価用の経路は浮動エイリアスにしておき、新モデルの挙動を低リスクで観察します。リゾルバのテーブル上では、これは単に論理名を二つ持つだけで表現できます。
// 同じ物理モデルでも、固定と浮動を別の論理名で持つ
// "reasoning-pinned" → 日付入りスナップショット(本番の安定経路)
// "reasoning-latest" → 浮動エイリアス(評価・非クリティカル経路)
新世代が出たとき、まず reasoning-latest を使う経路で出力を見比べ、問題がなければ reasoning-pinned の指す識別子を更新する——という昇格手順を、コードの書き換えなしに設定変更だけで回せます。model 文字列を直書きしていた頃は、この「一部だけ新モデルで試す」が地味に面倒でした。
運用で踏んだ3つの落とし穴
最後に、私はこの仕組みを実際に回してみて、いくつか気づいた点があります。順に共有します。
プロバイダーの取り違えが静かに起きる : CLAUDE_PROVIDER の環境変数を設定し忘れると、リゾルバは既定の anthropic に落ちます。これ自体は安全な既定ですが、「Bedrock に寄せたつもりが API を叩いていた」という勘違いに繋がります。起動ログに解決後の provider と id を必ず出すことをお勧めします。目で確認できる一行があるだけで、取り違えにその場で気づけます。
能力フラグの更新漏れ : プロバイダー側が後から 1 時間キャッシュに対応しても、テーブルの supportsLongCacheTtl が false のままなら、こちらは縮退した設定で動き続けます。エラーは出ないぶん気づきにくいので、能力フラグはリリースノートを読んだときに見直す運用チェック項目に含めることを推奨します。
浮動エイリアスのドリフト : reasoning-latest を本番の主要経路でうっかり使うと、ある日モデルが更新されて出力傾向が変わり、品質ゲートが想定外に反応することがあります。浮動エイリアスは「観察用」と用途を明文化し、本番のクリティカル経路では必ずピン留めを使う、という線引きを徹底するのが安全です。
識別子の散らばりは、プロバイダーを増やそうとした瞬間に初めて痛みとして現れます。まだ直書きで困っていない方も、いま一度 grep で model: の数を数えてみてください。一桁を超えていたら、論理名への置き換えを一つだけ始めてみるのがよい第一歩になります。私自身、最初に reasoning-default の一本だけをリゾルバ経由に変えたところから、少しずつ移していきました。
お読みいただきありがとうございました。同じように一人で複数の経路を抱えている方の、設定を畳み直すきっかけになれば幸いです。