古い言語の関数をモデルに渡し、新しい言語への変換を頼む。命名規約まで自然なコードが返ってきて、テストも通る。これを200回繰り返せば、「移行は終わった」と言いたくなる。
ただし、ここには「翻訳できた」と「移行が完了した」を混同する落とし穴がある。個々の関数を正しく翻訳できているかは、モデルが得意とする領域だ。一方、システム全体が移行済みかどうかは別の問題になる。必要なのは集合演算であり、ここをモデルに任せるのは最も危険で、もっともらしく誤魔化されやすい。
実際のGoからPythonへの移行で出た数字を例に、なぜ棚卸しはスクリプトで確定し、モデルにはその説明だけを任せるべきなのかを見ていく。
まず見るべきは3つの数字
旧Go側のレジストリには23プラットフォーム、329コマンドがあった。新Python側では、各argparse宣言からコマンド集合を生成している。両者の共通部分は正確に201件だった。つまり、旧側にしか存在しないコマンドが128件ある。Pythonへ移行されたわけでも、スタブとして残されたわけでもない。
329 = 201 + 128。この引き算自体に技術的な難しさはない。だが、「移行は終わったのか」に答えられるのは移行全体でこれだけであり、関数を1つずつ翻訳しているとまず見えなくなる。欠落は不在によるエラーだ。例外も起きず、テストも失敗せず、存在すべき名前がないだけである。その項目は一度もチャットに入力されない。チェックマークが200個並んでいても、見つからない。
未実装スタブや互換プロキシを置かない理由
移行の途中では、未対応コマンドにraise NotImplementedErrorのスタブを置いたり、旧バイナリへ転送する互換プロキシを用意したりして、「エンドポイント一覧は揃っている」状態にしたくなる。やらない方がいい。空の殻は、欠落をそのまま残すより高くつく。理由は3つある。
まず、スタブは照合を壊す。コマンド名が新側の集合に入るため、差分は0件になる。実際には終わっていないのに、完了したように見えてしまう。欠落なら正直に赤く出る。スタブは「残り128件」を「すべて存在する」という緑の嘘に変える。
互換プロキシは、切り離すべき依存を恒久化する。旧Goバイナリへ転送する限り、旧ランタイムは削除できない。移行の目的は旧スタックを手放すことなのに、「一時的な互換性」の名目で旧スタックを居座らせることになる。
未完成のエンドポイントは呼び出し側も欺く。Agentでも人でも、カタログを見て動くものだと判断し、呼び出してからruntime_unavailableに遭遇する。あるいは、もっと悪いことに空の結果を返すだけの偽の成功で終わる。
欠落を正直に見せるのが、結局はいちばん安い。差分がすぐ赤くなり、残作業も誰にでも分かる。これはアプリのリバースエンジニアリングにおける証拠の閾値と同じ考え方だ。「まだ使えない」と明示する方が、中途半端なものを出荷するより常に安い。
宣言から差分を作る照合スクリプト
照合の核はシンプルだ。両側のコマンド集合を宣言から導出し、人手では転記しない。「移行済みリスト」を手で管理すると、コードとは別に第3の情報源が生まれる。そこは必ずコードとずれ、2週間もすれば最初に壊れる場所になる。
新側、つまりPython側の唯一の情報源は、各プラットフォームのcli.pyにあるargparse宣言だ。catalogモジュールがサブコマンドを走査し、{platform/command}形式の集合として出力する。実行コマンドはpython -m reverse describe --format jsonである。宣言を単一の情報源にできる理由と、カタログを完全自動で導出する仕組みについては、interface-as-codeの記事で扱っている。旧Go側はすでにplatform -> commandのマップ、つまりバイナリにコンパイルされた不変の許可リストなので、同じ形のJSONを書き出すのは容易だ。
2つのJSONがあれば、後は集合演算だけで済む。
# Both sides' command sets derive from declarations, not transcription.
# Transcribe by hand and you've added a third source of truth that will drift.
import json
from collections import Counter
def ids(path):
doc = json.load(open(path))
return {f"{p['name']}/{c['name']}"
for p in doc["platforms"] for c in p["commands"]}
old = ids("go-registry.dump.json") # old registry: immutable platform->command allowlist
new = ids("python-catalog.dump.json") # python -m reverse describe --format json
missing = old - new # old side only: each one needs a keep-or-drop verdict
added = new - old # new side only: new capability, logged separately
kept = old & new # intersection: migrated, but still check for semantic drift
assert missing | kept == old # every old-side item classified, none dropped
by_platform = Counter(pc.split("/")[0] for pc in missing) # goes straight into the README table
処理時間は数ミリ秒。コストはかからず、結果は決定的で、100%正しい。missingが128件の未移行コマンドであり、プラットフォーム別に集計すると次の表になる。
プラットフォーム | 未移行コマンド数 |
|---|---|
xiaohongshu | 33 |
tiktok | 30 |
hotspot | 21 |
douyin | 19 |
8 | |
7 | |
bilibili | 5 |
zhihu | 3 |
1 | |
netease_music | 1 |
合計 | 128 |
この工程にモデルの出番はない。
モデルに一覧比較を任せると高コストで、しかも間違う
スクリプトを使わず、2つの一覧をチャットに貼り付けて「329件のうち、この201件にないものは何か」と聞く。すると、ほぼ確実に3つの問題が起きる。
まず見落とす。リストが長くなると、モデルは要素ごとの集合差分を厳密に計算せず、「だいたい合っていそう」という推測で処理する。後半の項目は埋もれ、完全に見える回答でも十数件不足している。次に捏造する。両側にある項目を欠落と報告したり、本当に欠けている項目を移行済みと数えたりする。モデルが再現しているのは差分そのものではなく、照合レポートらしい文章だからだ。さらに再現性がない。同じ入力を2回渡しても欠落リストが変わる。毎回結果が変わる照合は、照合とは呼べない。
コスト面でも割に合わない。スクリプトなら数ミリ秒で終わるのに、モデル比較には数十万トークンと複数回の自己検証が必要になる。高く、遅く、信頼もできない。集合演算は集合演算ができるものに任せる。この記事で最も異論の少ない結論はこれだろう。
モデルの役割は、未移行の判定ではなく差分の説明
スクリプトは128件の「移行されていない」という事実を渡してくれる。しかし、事実だけでは結論にならない。各項目には残すか捨てるかの判断が必要であり、判断には理由が必要だ。ここからがモデルの領分になる。
1件ずつ、「なぜ移行されなかったのか」を説明させる。デッドコードなのか。上流のエンドポイントが廃止されたのか。後回しなのか。それとも最も厄介なケースとして、削除されたのではなく別コマンドに統合されたのか。名前は消えていても、機能は残っているかもしれない。この「削除ではなく統合」という隠れた対応関係は、欠落リストだけでは見つからない。両方のレジストリを同時に読ませて、対応を突き合わせる必要がある。
共通部分の201件も安全ではない。移行済みだからといって、意味まで保たれているとは限らない。同名コマンドのデフォルト値が密かに変わっている、ページネーションの意味が入れ替わっている、2つのエラーコードが1つに統合されている。これはセマンティックドリフトで、欠落より見つけにくい。差分は緑のままで、missingにも現れないからだ。ドリフトの確認には、両実装を読ませて「振る舞いは同等か」を判断させる必要がある。最終的には差分テストで確認する。具体的には、4段階ワークフローの第3段階で扱ったfixture比較だ。すでに成功扱いの翻訳を見ても「ここは振る舞いが変わっている」と言える能力こそ、フィンガープリンティングの記事にある反証の節で扱ったものでもある。弱いモデルは「正常に移行されました」と繰り返すだけになる。
役割分担は明確だ。「存在するか」はスクリプトが決める。「残すべきか」「挙動が変わったか」は推論が必要になる。今回、差分で赤く出た4コマンドはレビューの結果、必要なものだと判明し、新コマンドとして正式に復元された。スクリプトが判定し、モデルが説明し、人間が決める。3つの層を混ぜないことが重要だ。
工程ごとに使い分けるモデル
以下の4段階はいずれも説明レイヤーに属する。判定レイヤーである差分計算には、モデルを一切使わない。ここが、この記事と一般的な「AI移行」記事の決定的な違いである。
工程 | 必要な能力 | 選択 | model id |
|---|---|---|---|
両レジストリを一度に読み、「削除ではなく別の場所に統合された」対応を見つける | 長いコンテキスト。両側の完全な宣言を同時に読めること | Kimi K3 |
|
128件の欠落項目について、残すか捨てるかの一次判定を構造化して下書きする | 低コストで、高並列の数百件呼び出しに耐えること | Claude Sonnet 5 |
|
セマンティックドリフトの判定。移行済みでも振る舞いが変わっていないか、両実装を読んで確認する | 強い推論力。「変わっている」と指摘する姿勢 | Claude Opus 5 |
|
移行済みだがfixtureが一致しないケースを、パラメータやレスポンス形状から説明する | 中程度の推論による原因帰属 | GPT-5.6 Sol |
|
特に試す価値があるのは第3層だ。セマンティックドリフトの判定は、「すでに成功とされている翻訳に反論できるか」を直接問う。モデルを替えた効果が最も出るのもここである。手順は次の通り。
自分の実際の2言語間移行を用意し、スクリプトで
missing集合を出す。この工程でモデルは使わない。そのうち10〜15件に、正解ラベルを人手で付ける。分類は廃止、維持、別の場所へ統合、後回しのいずれかとする。
各項目について「維持か廃止か、その理由を説明せよ」という同じプロンプトを
claude-opus-5と安価な階層に渡す。見るべき点は2つ。維持・廃止の理由が具体的なコード上の事実を指しているか、それとも「おそらく廃止」といった曖昧な文言で済ませていないか。そして、それぞれが別の場所への統合を何件見つけられるかだ。隠れた対応関係を見つけられた件数を、一次判定を任せてよいかの判断材料にする。
問題はモデル選びではなく、切り替えの手間にある
3社から4モデルを使うとなると、SDKは3種類、認証方式も3種類、エラー形式も3種類になる。階層を切り替えるたびにクライアントを書き直すのは割に合わない。結果として多くの人は最初から最後まで1つのモデルで進め、推論モデルが最も必要なセマンティックドリフトのレビューでも、曖昧な答えしか返さない安価なモデルを使い、緑のドリフトをそのまま通してしまう。
AIReiterはこの層を一本化する。キーは1つ、インターフェースはOpenAI互換で、4つの階層をすべて背後に持つ。リクエストボディのmodelフィールドを変えるだけで切り替えられる。
# Semantic-drift review / per-item keep-or-drop: the reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<both implementations + this command migration status, ask if behavior is equivalent>"}]
}'
# First pass on 128 missing items in bulk: change the model field, leave the rest
# "model": "claude-sonnet-5"
# Diff attribution when a fixture won't match:
# "model": "gpt-5.6-sol"
すでにOpenAI SDKを使っているなら、base_urlをhttps://aireiter.com/api/v1に向けるだけで、ほかは変更不要だ。Anthropic SDKでは、同じキーを使ってPOST /api/v1/messagesを呼び出せる。
料金面でも、この使い分けは理にかなう。一次判定は数百件をまとめて処理し、移行のたびに再実行するため、高並列で使えるclaude-sonnet-5が最も安い。セマンティックドリフトのレビューは、難しい十数件をclaude-opus-5に何度も尋ねるため、1件当たりのコストが最も高い。どちらもClaudeの階層なので、30%オフは最も処理密度が高く、高額な部分にそのまま効く。差分原因の帰属はgpt-5.6-solが担い、GPTは半額になる。
登録なしで試す:まず数件の欠落項目を手で流し、「別の場所への統合」を見つけられるか確認してから、組み込むか決めるとよい。
まとめ
「翻訳できた」は個別関数が生む錯覚にすぎない。「移行できた」は差分で確定する。集合演算はスクリプトへ、説明はモデルへ、意思決定は人間へ。この順序を崩してはいけない。とくにモデルに判定まで任せてはならない。
もう1つ、飛ばされがちな工程がある。欠落リストはREADMEに載せ、長期間見える状態にしておく必要がある。128件は0件になるまで、あるいはすべてに「Xという理由で移行しない」と記録されるまで残す。PRの議論にしか存在しない照合は、照合とは言えない。次の担当者には見えず、同じ128件をまた踏むことになるからだ。これはこの記事、interface-as-codeの記事、そして統一レスポンスModelを作らない理由の記事に共通する考え方でもある。単一の情報源に語らせ、人の記憶の中に結論を分散させないことだ。