モデルIDの文字列を1行書き換えて、スクリプトを走らせた夜がありました。
返ってきたのは 400 でした。メッセージを読むと、モデルが悪いのではなく、私の呼び出しの側に残っていた指定がひとつ、新しいモデルの受け付ける形から外れていたのです。直すこと自体は数分で済みました。胃のあたりが重くなったのは、そのリクエストが「本番で最初に走る1通」だったからです。
そのときから、切り替えの前に 自分の呼び出しの形を、候補モデルへ先に再生しておく ことを習慣にしました。大がかりな評価基盤ではありません。1トークンに近い小さなリクエストを数本流すだけです。
切り替えで壊れるのは、モデルの性能ではなく呼び出しの形です
モデルを替えるとき、私たちはつい出力の品質を気にします。精度は落ちないか、費用はどうか、と。もちろん大事です。
ただ、品質を比べる段階に進む前に、リクエストそのものが受け付けられるかどうかという入口があります。ここで引っかかると、品質の比較どころではありません。
入口で止まる原因は、だいたい次の三つに収まります。
| 原因 | 手元のコードでの見え方 | 気づくタイミング |
|---|---|---|
| パラメータの形が合わない | thinking の指定、temperature など、以前から付けていた引数 | 最初のリクエストで 400 |
| 組み合わせが合わない | tool_choice で特定のツールを強制しつつ別の機能を併用している | 特定の経路を通ったときだけ 400 |
| 能力の有無が違う | 前のモデルでは使えた機能が、候補では使えない | 機能を使う画面・バッチに到達したとき |
二つ目と三つ目が厄介です。普段通らない経路で起きるので、動作確認を1回通しただけでは見つかりません。
公式の移行ガイドは「地図」で、自分の呼び出しは「現在地」です
移行ガイドには、モデルごとに変わった点が整理されています。ありがたい資料です。けれど、ガイドが挙げる変更点のうち、自分のコードが 実際にどれを踏んでいるか は、ガイドからは分かりません。
私は読み合わせの作業を、次の順に分けるようになりました。
- 自分のコードが発行している呼び出しの「形」を、一覧にして書き出します
- その形を、そのまま候補モデルへ送って受理されるかを確かめます
- 受理されなかった形についてだけ、ガイドを読んで直します
ガイドを頭から読んで全項目を照合するより、はるかに短く済みます。照合の対象が、自分の呼び出しだけに絞れるからです。
呼び出しの形を1ファイルに書き出す
最初にやるのは、棚卸しです。自分のコードが発行している呼び出しを、引数の組み合わせごとに1行ずつ書き出します。
# call_shapes.py
# 本番コードが実際に発行している呼び出しの「形」を、1か所に集めたもの。
# モデルIDは入れない(候補モデルは再生時に差し込む)。
# max_tokens は受理判定のためだけなので、ごく小さくしておく。
DUMMY_TOOL = {
"name": "ping",
"description": "疎通確認用のダミーツール",
"input_schema": {"type": "object", "properties": {}},
}
SHAPES = {
# 一番ふつうの呼び出し
"plain": {
"max_tokens": 16,
"messages": [{"role": "user", "content": "ok と返してください"}],
},
# 思考を明示的に切る呼び出し(自分のコードに同じ指定がある場合だけ残す)
"thinking_disabled": {
"max_tokens": 16,
"thinking": {"type": "disabled"},
"messages": [{"role": "user", "content": "ok と返してください"}],
},
# ツールを必ず使わせる呼び出し
"tool_choice_any": {
"max_tokens": 16,
"tools": [DUMMY_TOOL],
"tool_choice": {"type": "any"},
"messages": [{"role": "user", "content": "ping を呼んでください"}],
},
# システムプロンプト付き(長さはダミーでよい)
"system_prompt": {
"max_tokens": 16,
"system": "あなたは疎通確認用のアシスタントです。",
"messages": [{"role": "user", "content": "ok と返してください"}],
},
}ポイントは、自分のコードに存在しない形は入れない ことです。使っていない引数まで網羅しようとすると、一覧が膨らんで、読み合わせの意味が薄れます。逆に、月に1回しか通らないバッチの呼び出しは、必ず入れます。普段通らない経路こそ、再生で拾いたいからです。
候補モデルへ再生して、受理・拒否・不明に分ける
次が再生の本体です。依存を増やしたくないので、SDK ではなく素の HTTP で書きました。
# replay.py
import json
import os
import sys
import httpx
from call_shapes import SHAPES
BASE = "https://api.anthropic.com/v1"
HEADERS = {
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
def replay(model: str) -> dict:
"""各 shape を max_tokens 16 で候補モデルに送り、受理されたかを返す。"""
results = {}
with httpx.Client(timeout=30) as client:
for name, body in SHAPES.items():
r = client.post(f"{BASE}/messages", headers=HEADERS,
json={"model": model, **body})
if r.status_code == 200:
results[name] = {"verdict": "accepted"}
elif r.status_code == 400:
# 形が受け付けられなかった。理由文をそのまま残す
msg = r.json().get("error", {}).get("message", "")
results[name] = {"verdict": "rejected", "reason": msg[:300]}
else:
# 429・529・5xx は形の問題ではないので「不明」に回す
results[name] = {"verdict": "unknown", "status": r.status_code}
return results
if __name__ == "__main__":
model = sys.argv[1]
out = replay(model)
print(json.dumps(out, ensure_ascii=False, indent=2))
# 1つでも rejected があれば、切り替えは止める(終了コード 1)
sys.exit(1 if any(v["verdict"] == "rejected" for v in out.values()) else 0)判定を「受理・拒否・不明」の三つに分けたのには理由があります。過負荷の 529 や、レート制限の 429 を「拒否」に数えてしまうと、混雑した日に切り替えが理由なく止まります。形の問題は 400 だけ、と決めておくと、判定がぶれません。
実行は、切り替え候補のIDを引数に渡すだけです。
python replay.py <候補モデルのID>
echo $? # 0 なら全 shape が受理、1 なら拒否あり拒否が出たら、reason に入っている理由文を読みます。そこで初めて移行ガイドを開き、該当の箇所だけを直します。
Models API の能力情報は「先に見る」ための近道にします
1通ごとに実際のリクエストを送るのは確実ですが、少し粗いやり方でもあります。10月7日の更新で、Models API の応答に、thinking の無効化(disabled)を受け付けるかどうかを示す能力情報が加わったと、リリースノートで読みました。
私は、これを「再生の前に走らせる早見表」として使うつもりでいます。ただし、応答の細かい形は版によって変わりうるので、決め打ちでは読みません。
# capabilities.py
import httpx
from replay import BASE, HEADERS
def thinking_disabled_supported(model: str):
"""True / False / None(情報なし)のどれかを返す。"""
r = httpx.get(f"{BASE}/models/{model}", headers=HEADERS, timeout=30)
if r.status_code != 200:
return None
node = r.json().get("capabilities", {})
for key in ("thinking", "types", "disabled"):
if not isinstance(node, dict) or key not in node:
return None # 見つからなければ「不明」にして、再生に任せる
node = node[key]
if isinstance(node, bool):
return node
if isinstance(node, dict) and "supported" in node:
return bool(node["supported"])
return NoneNone を返す道を最初から用意しているのが肝心です。能力情報が見つからないときは、「できない」と読むのではなく「不明」と読み、再生に判断を預けます。能力情報は先に見る近道、再生は最後の確認 — この二段にしておくと、どちらかが古くなっても事故になりません。
切り替えの手順は、4行で済ませています
自分の運用では、次の流れに落ち着いています。
capabilities.pyで能力情報を引き、Falseが出たら、その shape を使う箇所を先に直しますreplay.pyを候補モデルで走らせます(所要は数秒、費用はほぼ無視できる程度です)- 終了コードが 0 になってから、本番のモデルIDを書き換えます
- 書き換えの直後に、もう一度
replay.pyを本番の設定で走らせます
最後の4番目は、地味ですが効きます。書き換えのミスや、環境変数の食い違いは、事前の検証では見えないからです。
再生でも拾えないものがあります
誤解のないように、限界も書いておきます。この再生が確かめるのは リクエストが受理されるか だけです。
出力の品質、費用の変化、応答の癖の違いは、別の確認になります。受理されたから安全、とはなりません。再生は「入口で転ばないための確認」であって、移行の完了を保証するものではないのです。
それでも、入口で転ぶ回数が減ると、品質の確認に使える気力が残ります。眠れない夜に本番のログを睨む時間を、少しでも短くしたい——私がこの小さな仕組みを置いている理由は、そこにあります。
切り替えるのは、モデルより先に自分の呼び出しです。 順番をこの一文に決めてから、本番で 400 を最初に見ることはなくなりました。いま思えば、あの夜の重さは、順番を決めていなかったことへの手応えだったのかもしれません。
次の一歩として、今日のうちに SHAPES へ、自分のコードで実際に使っている呼び出しを3本だけ書き出してみていただければと思います。3本あれば、再生は動き出します。