02AI5 min

新しいモデルは毎回、違うものを壊していく

一年半でモデルの移行が六回。どれも文字列をひとつ書き換えるだけでは済みませんでした。最初はどれもそう見えたのですが。

著者: Salva Castells

カバー画像: Tim Green, CC BY 2.0.
この記事の内容

アニメの意味検索に、候補二十五件を LLM で並べ替えるリランカーがあります。初日から temperature: 0 でした。再現可能な順序が欲しければ当たり前の設定です。モデルを推論系のものに変えたら、呼び出しがエラーを返し始めました。そのモデルは temperature を拒否します。無視するのではなく、拒否します。

修正はその行を消すことではありませんでした。reasoning_effort に置き換え、既定値 low の定数として切り出し、同じモデルを呼んでいたもう一か所、フランチャイズのあらすじを合成する取り込みスクリプトにも通す。三ファイル、新しい環境変数ひとつ、更新すべきドキュメント一本。パラメータひとつのために。

これが一年半で六回起きました。しかも毎回違う形で。

壊れ方は四種類

集めてみると、いつも同じ四つが繰り返されます。

  • 消えるパラメータ。 推論系モデルの temperature。正確な等価物はありません。reasoning_effort は同じものではなく、別のつまみです。値を移行するのではなく、新しい値を決めることになります。
  • ペイロードの形。 ビジョンのパーサーを Chat Completions から Responses API に移すのは、メソッド名の変更ではありません。messages は input になり、{"type": "text"} は input_text になり、image_url は {url, detail} というオブジェクトをやめて文字列になり、detail は同じ階層の兄弟になります。
  • レスポンスの形。 同じ移行で、response.choices[0].message.content は response.output_text になりました。トークンのカウンタもひとつではなくなり、input_tokens と output_tokens に分かれました。そのせいで関数のシグネチャを -> str から -> tuple[str, int, int] に変え、並列の future を回収するときに手で足し合わせる必要が出ました。
  • 品質が黙って変わる。 これが最悪です。エラーが出ないからです。モデルは応答し、JSON は検証を通り、結果だけが悪くなります。

最初の三つは初回のデプロイで分かります。四つ目は何週間も食らい続けます。

OpenRouter と LangChain は問題を移すだけ

論理的な反応は、上に層をかぶせることです。私も二度やりました。

文字起こしの SaaS には、Anthropic の SDK を直接呼ぶ processWithClaude(text, prompt) を持つ lib/claude.ts がありました。まるごと消して、OpenRouter に対するシングルトンの AIService に置き換え、modelName と temperature をエンドポイントの FormData から流れてくるパラメータにしました。実際の利得として、モデルの切り替えはリクエストで別の文字列を送ることになりました。

プロジェクトとコンテキストを持つチャットアプリでは、その層が LangChain でした。@langchain/anthropic と @langchain/google-genai を使い、プロバイダごとにまとまったドロップダウンを出せるようにしました。そこでもう半分を学びました。層のほうも壊れます。初期のコミットのひとつは、そのまま "update dependencies for @langchain/anthropic and zod to latest versions" という名前です。動き続ける API の上の抽象を latest に上げるのは、理解している問題を理解していない問題と交換することです。

こうした層がやることは本物ですが限定的です。呼び出しの形を揃えてくれます。能力は揃えてくれません。モデル A が temperature を受け取り B が受け取らないなら、層は両方のパラメータを通してくれて、エラーは結局届きます。違うのは、それがライブラリの内側から、スタックトレース三段下から来ることだけです。

製品の途中で切り替えるコスト

スケジュール生成のモデルを google/gemini-3-flash-preview から deepseek/deepseek-v3.2 に変えた日、差分は三ファイルに触れました。app/api/generate/route.ts、app/api/adjust-schedule/route.ts、app/api/verify-checks/route.ts。同じリテラルが、三つの別々の JSON.stringify の中に三回コピーされていました。これはプロバイダの失敗ではなく私の失敗で、そして本当のコストです。モデル名が呼び出し地点に埋め込まれていると、移行は find-and-replace と、取りこぼしがないことを祈る作業になります。

文書解析のサービスでは、モデルとコスト計算を定数ファイルに集約してこれを直そうとしました。三週間でリファクタリング三回かかりました。18日に一回、その翌日に一回、そしてその前にもう一回。コミットのひとつには "reafactor constants" と誤字が残っていて、その日の午後の様子をよく表しています。定数の層が一度で固まらなかったのは、別々の二つのこと、つまりどのモデルを使うかと、いくらかかるかを、同じ構造に押し込もうとしていたからです。

いまの呼び出しの隔離のしかた

大げさなことはしません。ルールは三つです。

モデルは呼び出し地点にリテラルで置かない。 常に、既定値付きで環境から読む定数にします。RERANK_MODEL = process.env.RERANK_MODEL || "gpt-5.4-mini" のように。本番でモデルを切り替えるのにデプロイが必要であってはいけません。

モデル固有のパラメータはモデルと一緒に運ぶ。 あるモデルが reasoning_effort を必要とし、別のモデルが temperature を必要とするなら、それは関数の設定ではなくモデルの設定です。六つのシグネチャを引き回す単独の引数ではなく、モデルごとのオブジェクトにします。

境界では常に防御する。 OpenAI の呼び出しにタイムアウトを入れたとき、埋め込みのバッチにもこれを入れました。

if (embeddings.length !== input.length) {
  throw new Error(
    `Embedding provider returned ${embeddings.length} vectors for ${input.length} inputs`
  );
}

四行です。ベクトルとテキストの静かなずれ、つまり間違ったアニメに割り当てられた埋め込みでデータベースが埋まる事態を、即座のエラーに変えます。同じコミットの AbortSignal.timeout(30_000) は、ぶら下がった呼び出しに対して同じ役目を果たします。

何を安定とみなすか

安定なもの。HTTP のエンドポイントがあること、JSON を受け取ること、JSON を返すこと。そこまでです。

安定でないもの。モデル名、サンプリングのパラメータ、メッセージの形、レスポンスの形、トークンのカウンタの場所、価格、そして今日のモデルが半年後も存在していること。

いま使っているモデルに合わせて設計はしません。それがなくなる日に合わせて設計します。