AIREITER

329 個指令、仍缺 128 個:遷移驗收該靠集合運算,不該交給模型

最近更新: 2026-07-31 07:49:12

把舊語言的一個函式交給模型,請它改寫成新語言;它交出風格乾淨、命名也符合慣例的程式碼,測試還通過。這樣重複兩百次後,很容易就宣布遷移完成。

但「翻譯完成」與「遷移完成」其實是兩回事。單一函式有沒有翻對,正是模型擅長的題目;整個系統是否都已遷移,則是集合運算。這類工作不該交給模型,而且模型也最容易在這裡讓你誤判。

以下是一個真實 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

reddit

8

weibo

7

bilibili

5

zhihu

3

linkedin

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

kimi-k3

對 128 個缺失項目先做保留或捨棄判斷,產出結構化草稿

成本低,可高併發處理數百次呼叫

Claude Sonnet 5

claude-sonnet-5

判斷 semantic drift:已遷移,但行為是否改變;需閱讀兩端實作

推理能力強,願意明確指出「這裡變了」

Claude Opus 5

claude-opus-5

已遷移但 fixture 無法比對時,依參數或回應結構解釋差異

中等推理能力的歸因分析

GPT-5.6 Sol

gpt-5.6-sol

最值得測試的是第三層。semantic drift 判斷真正考驗的是「你會不會反駁一個看似已成功的翻譯」;這也是更換模型最可能改變結果的環節。測試流程如下:

  1. 找一個你自己的真實雙語言遷移案,先用腳本跑出 missing 集合;這一步完全不用模型。

  2. 人工為其中 10 到 15 項標記 ground truth,作為對照組:drop、keep、merged elsewhere、deferred。

  3. 把同一份「逐項解釋應保留或捨棄」的 prompt,同時交給 claude-opus-5 與低價層模型。觀察兩件事:保留或捨棄的理由,是否指出具體程式碼事實,還是只給你「可能已棄用」這種含糊說法;以及各自找出多少「併入別處」的對應關係。

  4. 它找出的隱藏對應數量,就是你判斷是否敢把第一輪工作交給它的依據。

真正的阻力不是選模型,而是切換成本

四個模型、三家供應商、三套 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 半價。

  • 取得 API key

  • 免註冊試用:先手動跑幾個缺失項目,看看它能否找出「併到別處」的案例,再決定是否整合進流程。

結語

「已翻譯」是單一函式造成的錯覺;「已遷移」必須由差集結算。集合運算交給腳本,差異解釋交給模型,決策留給人;這個順序不能打亂,尤其不能讓模型來做判定。

還有一個最容易被跳過的步驟:缺失清單必須寫進 README,長期公開可見。128 必須一直掛在那裡,直到變成 0,或每一項都有明確記錄的「不遷移,因為 X」。只存在某段 PR 討論裡的驗收不算驗收,因為下一位接手的人看不到它,只會再踩一遍那 128 個坑。這也是本文、介面即程式碼那篇文章,以及不建立統一回應 Model 那篇文章共同的核心:讓唯一真相來源自行發聲,不要把結論散落在人們的記憶裡。