把舊語言的一個函式交給模型,請它改寫成新語言;它交出風格乾淨、命名也符合慣例的程式碼,測試還通過。這樣重複兩百次後,很容易就宣布遷移完成。
但「翻譯完成」與「遷移完成」其實是兩回事。單一函式有沒有翻對,正是模型擅長的題目;整個系統是否都已遷移,則是集合運算。這類工作不該交給模型,而且模型也最容易在這裡讓你誤判。
以下是一個真實 Go-to-Python 遷移案的帳:為什麼這筆帳必須由腳本結算,模型只負責說明。
先看三個關鍵數字
舊版 Go registry 有 23 個平台、329 個指令。新版 Python 從 argparse 宣告導出的指令集,與舊 registry 的交集恰好是 201 個。也就是說,有 128 個指令只存在於舊端:既沒有遷移進 Python,也沒有保留成 stub。
329 = 201 + 128。這個減法沒有任何技術難度,卻是整個遷移案中唯一能回答「是否完成」的計算;而你逐一翻譯函式時,恰恰看不見它。缺項屬於缺失型錯誤:不會拋錯、沒有 exception、測試也不會失敗,只是一個本該存在的名稱不存在。它從未進過對話框,因此就算盯著兩百個綠色勾勾,也看不出問題。
別用 stub 或相容性代理假裝完整
遷移做到一半,最誘人的做法通常是替尚未完成的指令留下佔位:用 raise NotImplementedError 當 stub,或加上一層轉送至舊 binary 的相容性代理,好讓「endpoint 目錄看起來完整」。別這麼做。空殼的代價比缺口更高,原因有三。
首先,stub 會破壞驗收。指令名稱進入新版集合後,差集變成 0,你便以為工作完成。缺口是誠實的紅燈;stub 則是把「還有 128 個要做」塗成「全部都在」的綠色謊言。
相容性代理則會把原本該清掉的依賴永久凍結。代理把呼叫轉送到舊 Go binary,舊 runtime 因此再也無法移除。遷移的目的本來就是卸下舊技術棧,但轉送代理會讓舊棧以「暫時相容」之名住下來,最後賴著不走。
半成品 endpoint 也會誤導呼叫端。無論是 Agent 或真人,看到目錄都會以為它可用,實際呼叫後卻撞上 runtime_unavailable;更糟的是,它表面成功卻悄悄回傳空結果。
誠實的缺口反而最便宜:差集立刻亮紅燈,每個人都看得見還剩多少。這與App 逆向工程的證據門檻是同一個原則:標示「目前不可用」,永遠比交付半成品更省成本。
驗收腳本:兩端都從宣告導出集合
驗收的核心只有一句:兩端的指令集合都要從宣告自動導出,不能由人手抄寫。手動整理一份「已遷移清單」,等於引入第三個真相來源;它必定會和程式碼脫節,而且兩週後通常最先出問題。
新版 Python 的唯一真相來源,是各平台 cli.py 裡的 argparse 宣告。catalog 模組會走訪子指令,匯出 {platform/command} 集合,並由 python -m reverse describe --format json 輸出。宣告為何能成為唯一真相來源,以及 catalog 如何全自動導出,可參考介面即程式碼那篇文章。舊版 Go 端原本就是 platform -> command map,也就是編譯進 binary 的不可變 allowlist,輸出同樣結構的 JSON 並不困難。
兩份 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 |
這一步沒有模型的角色。
讓模型逐行比對,既貴又不可靠
如果跳過腳本,直接把兩份清單貼進對話框,問模型「329 個中有哪些沒出現在這 201 個裡」,幾乎必然會出現三種問題。
它會漏項:清單一長,模型不會真的逐元素計算集合差,而是用「大致看起來合理」的方式取樣;尾端項目被稀釋後,產出的答案看似完整,實際少了十幾個。它也會憑空捏造:把兩邊都有的項目報成缺失,或把真正缺失的項目當成已遷移,因為它模仿的是驗收報告的樣子,不是在執行差集。最後,它無法重現:同一份輸入問兩次,缺失清單會不同;每次結果都不一樣的「驗收」,根本不叫驗收。
成本上也毫無優勢。腳本只要幾毫秒;讓模型比對則要消耗數十萬 token,還得多輪自我檢查,既昂貴、緩慢,又不值得信任。把集合運算交給會做集合運算的工具,是本文最沒有爭議的一句話。
模型真正該做的事:解釋差異,不是判定是否遷移
腳本交給你 128 個「尚未遷移」的事實,但事實不等於結論。每一項都要決定保留或捨棄,而決定需要理由。這才是模型該上場的地方。
請它逐項解釋「為什麼沒有遷移」。是死程式碼?上游 endpoint 已下線?暫緩處理?還是最棘手的一種:功能並未刪除,而是併入另一個指令,名稱消失了,能力卻仍存在。這種「已合併、非刪除」的隱藏對應,單看缺失清單找不出來,必須同時閱讀兩端 registry 才能對上。
交集中的 201 項也不代表安全。已遷移不表示語意完全保留:可能是同名指令的預設值悄悄變了、分頁語意被調換,或兩種錯誤碼被併成一種。這就是 semantic drift。它比缺口更隱密,因為差集是綠的,而且永遠不會進到 missing。檢查 drift 需要模型閱讀兩端實作,判斷「行為是否等價」,最後再用差異測試確認,也就是四階段工作流程第三階段的 fixture 比對。能對一個表面上已成功的翻譯說出「這裡的行為變了」,正是指紋辨識文章中反證段落談的能力;弱模型只會覆誦「已成功遷移」。
所以分工很清楚:判定「是否存在」屬於腳本;判定「是否該保留、行為是否改變」才需要推理。這次有 4 個被差集標紅的指令,經審查後確認本來就該存在,於是恢復為一級的新指令。腳本負責判定,模型負責解釋,人負責決策,三層各司其職。
不同任務,該用哪一種模型
以下四個層級全都落在解釋層。決策層,也就是差集比對,完全不使用模型;這正是本文與其他「AI 遷移」文章的分界線。
步驟 | 所需能力 | 建議選擇 | model id |
|---|---|---|---|
一次讀入兩端 registry,找出「未刪除、而是併到別處」的對應關係 | 長 context,可同時閱讀兩端完整宣告 | Kimi K3 |
|
對 128 個缺失項目先做保留或捨棄判斷,產出結構化草稿 | 成本低,可高併發處理數百次呼叫 | Claude Sonnet 5 |
|
判斷 semantic drift:已遷移,但行為是否改變;需閱讀兩端實作 | 推理能力強,願意明確指出「這裡變了」 | Claude Opus 5 |
|
已遷移但 fixture 無法比對時,依參數或回應結構解釋差異 | 中等推理能力的歸因分析 | GPT-5.6 Sol |
|
最值得測試的是第三層。semantic drift 判斷真正考驗的是「你會不會反駁一個看似已成功的翻譯」;這也是更換模型最可能改變結果的環節。測試流程如下:
找一個你自己的真實雙語言遷移案,先用腳本跑出
missing集合;這一步完全不用模型。人工為其中 10 到 15 項標記 ground truth,作為對照組:drop、keep、merged elsewhere、deferred。
把同一份「逐項解釋應保留或捨棄」的 prompt,同時交給
claude-opus-5與低價層模型。觀察兩件事:保留或捨棄的理由,是否指出具體程式碼事實,還是只給你「可能已棄用」這種含糊說法;以及各自找出多少「併入別處」的對應關係。它找出的隱藏對應數量,就是你判斷是否敢把第一輪工作交給它的依據。
真正的阻力不是選模型,而是切換成本
四個模型、三家供應商、三套 SDK、三種驗證方式、三種錯誤格式。為了切換層級而重寫三次 client 不值得,於是多數人乾脆全程只用同一個模型;等到最需要推理能力的 semantic drift 審查,又拿低價層來跑,只得到一堆含糊說法,讓所有綠色 drift 一路放行。
AIReiter把這一層整平:一把 key、一套 OpenAI 相容介面,四個層級都在後面;只要改 request body 裡的 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 時,則以同一把 key 呼叫 POST /api/v1/messages。
價格優勢正好落在這個流程上:第一輪要一次處理數百項,且每輪遷移都得重跑,高併發的 claude-sonnet-5 最省;semantic drift 審查則是對 claude-opus-5 反覆詢問十幾個困難案例,單項成本最高。兩者都是 Claude 層級,30% 折扣正好打在最密集與最昂貴的環節。gpt-5.6-sol 用來做差異歸因,GPT 半價。
免註冊試用:先手動跑幾個缺失項目,看看它能否找出「併到別處」的案例,再決定是否整合進流程。
結語
「已翻譯」是單一函式造成的錯覺;「已遷移」必須由差集結算。集合運算交給腳本,差異解釋交給模型,決策留給人;這個順序不能打亂,尤其不能讓模型來做判定。
還有一個最容易被跳過的步驟:缺失清單必須寫進 README,長期公開可見。128 必須一直掛在那裡,直到變成 0,或每一項都有明確記錄的「不遷移,因為 X」。只存在某段 PR 討論裡的驗收不算驗收,因為下一位接手的人看不到它,只會再踩一遍那 128 個坑。這也是本文、介面即程式碼那篇文章,以及不建立統一回應 Model 那篇文章共同的核心:讓唯一真相來源自行發聲,不要把結論散落在人們的記憶裡。