AIREITER

329 个命令,仍缺 128 个:迁移验收靠集合运算,不靠模型

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

把旧语言里的一个函数交给模型,要求它翻成新语言:代码写得干净、地道,命名也符合规范,测试还能通过。这样的操作重复两百次,很容易让人觉得迁移已经收工。

但“翻译完成”和“迁移完成”不是一回事。单个函数翻得对不对,正是模型擅长的领域;整个系统是否完整迁过去,却是另一个问题。它本质上是集合运算,而这恰恰不该交给模型,也是模型最容易让你产生错觉的地方。

下面是一场真实 Go-to-Python 迁移的账本。结论很简单:账必须由脚本来算,模型只负责解释原因。

先看三个关键数字

旧 Go 注册表里有 23 个平台、329 个命令。新 Python 端从 argparse 声明中推导出的命令集合,与旧注册表精确相交后得到 201 个。这意味着,有 128 个命令只存在于旧端:它们既没有迁入 Python,也没有明确保留为桩实现。

329 = 201 + 128。这道减法没有任何技术门槛,却是整场迁移里唯一真正回答“是否完成”的问题;而在逐个翻译函数时,你几乎永远看不到它。缺失属于“缺席错误”:不会抛异常、没有报错、测试也不会失败,只是一个本该存在的名字根本不存在。它从未出现在聊天框里,所以即便面对两百个绿色勾,你也看不见它。

为什么不要留桩,也不要做兼容代理

迁移进行到一半时,最诱人的做法是:给尚未完成的命令留个占位,比如抛出 raise NotImplementedError;或者加一层兼容代理,转发给旧二进制程序,以便“端点目录看起来完整”。别这么做。空壳的代价比明确缺口更高,原因有三个。

首先,桩实现会破坏核对结果。命令名进入新端集合后,diff 变成 0,你会以为迁移完成了。缺口是诚实的红色告警;桩实现则是绿色的谎言,把“还剩 128 个”伪装成“全部已存在”。

其次,兼容代理会把尚未清除的依赖永久固化。代理继续转发到旧 Go 二进制程序,旧运行时就永远无法删除。迁移的目的本来是摆脱旧技术栈,而转发代理会让旧栈以“临时兼容”的名义搬进来,然后长期住下去。

最后,半成品端点会误导调用方。无论是 Agent 还是人,看到目录都会默认它可用;调用后才撞上 runtime_unavailable,更糟的是得到一个表面成功、实际悄悄返回空结果的响应。

诚实地保留缺口,反而是成本最低的选择:diff 会立刻标红,所有人都能看到还剩多少工作。这和应用逆向中的证据阈值是同一个原则:明确标记“暂不可用”,永远比交付一个半成品便宜。

一份可复用的核对脚本骨架

核对的核心只有一句话:两侧的命令集合都必须从声明中自动推导,不能靠人手抄写。手工维护一份“已迁移列表”,等于引入第三个会和代码脱节的事实来源;两周之后,它往往就是最先出问题的地方。

新端,也就是 Python 端,唯一的事实来源是每个平台 cli.py 中的 argparse 声明。catalog 模块遍历子命令,导出一个 {platform/command} 集合,通过 python -m reverse describe --format json 输出。为什么声明可以成为唯一事实来源,以及 catalog 如何全自动推导,详见这篇 interface-as-code 文章。旧 Go 端本身已经是一个 platform -> command 映射,也就是编译进二进制文件的不可变 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 条“未迁移”的事实,但事实不等于结论。每一条都要决定保留还是删除,而决策需要理由。这才是模型该发挥作用的地方。

逐项解释“为什么没有迁移”:是死代码?上游端点已经下线?暂时延期?还是最棘手的一种情况——它没有被删除,只是被合并进另一个命令,名字消失了,能力却还在。对于这种“已合并、非删除”的隐性对应关系,单看缺失列表根本找不出来,必须同时阅读两侧注册表才能对上。

交集中的 201 个也并不安全。已经迁移,不代表语义保持一致:同名命令的默认值悄悄变了、分页语义被调换了、两个错误码被合并成一个。这就是语义漂移,比缺口更隐蔽,因为 diff 是绿色的,它根本不会进入 missing。检查漂移需要模型阅读两侧实现,并判断“行为是否等价”;最终仍要通过差分测试确认,也就是四阶段工作流第三阶段中的 fixture 对比。面对一个看似已经成功的翻译,仍能指出“这里行为变了”,正是指纹识别文章中反证部分讨论的能力;弱模型只会复述“已成功迁移”。

职责划分因此很清楚:判断“它是否存在”归脚本,判断“是否应该保留、行为是否改变”才需要推理。这次 diff 标红的命令中,有 4 个经过审查后确认应当保留,并被恢复为一等的新命令。脚本负责判定,模型负责解释,人来做决策,三层各司其职。

不同环节该用什么模型

要注意,下面四档模型全部用于解释层。决策层,也就是 diff,完全不用模型。这正是本文与其他“AI 迁移”文章之间的分界线。

环节

所需能力

推荐选择

model id

同时输入两份注册表,识别“未删除而是合并到其他位置”的对应关系

长上下文,能一次读完两侧完整声明

Kimi K3

kimi-k3

对 128 个缺失项做第一轮保留或删除判断,产出结构化草稿

成本低,可高并发调用数百次

Claude Sonnet 5

claude-sonnet-5

判断语义漂移:已迁移的命令是否改变行为,阅读两侧实现

推理能力强,敢于指出“这里变了”

Claude Opus 5

claude-opus-5

已迁移但 fixture 无法匹配,结合参数或响应结构解释差异

中等强度的归因推理

GPT-5.6 Sol

gpt-5.6-sol

最值得测试的是第三档。语义漂移判断考验的正是“模型会不会反驳一个看似已经成功的翻译”,这里也是切换模型最容易改变结果的环节。测试流程如下:

  1. 找一场你自己的真实双语言迁移,先用脚本得到 missing 集合,这一步完全不用模型。

  2. 从中人工标注 10 到 15 项,形成带真值的对照组:删除、保留、合并到其他位置或延期。

  3. 将同一份“逐项解释为何保留或删除”的提示词分别交给 claude-opus-5 和一个低价档模型,重点看两件事:保留或删除的理由是否指向具体代码事实,还是只给出“可能已废弃”这类模糊表述;以及两者各自识别出多少“合并到其他位置”的对应关系。

  4. 它找到多少隐性对应关系,就是你判断是否敢把第一轮审查交给它的依据。

难点不在选模型,而在切换成本

四个模型来自三家供应商,对应三套 SDK、三种认证方式、三种错误格式。为了切换档位而重写三次客户端并不值得,于是多数人从头到尾只跑一个模型;到了最需要推理档的语义漂移审查环节,仍在用只能输出模糊判断的低价档模型,于是一路放过所有绿色的漂移。

AIReiter 把这一层抹平了:一把 key、一个兼容 OpenAI 的接口,四个档位都在背后;只需修改请求体里的 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 最便宜;语义漂移审查则是让 claude-opus-5 对十几个复杂案例反复判断,单项成本最高。两者都属于 Claude 档位,30% 折扣正好覆盖调用最密集、成本最高的部分。gpt-5.6-sol 用于差异归因,GPT 价格减半。

  • 获取 API key

  • 免注册试用:先手动跑几个缺失项,看看它是否能识别“合并到其他位置”的案例,再决定是否接入流程。

结语

“已翻译”是单函数带来的幻觉,“已迁移”必须由 diff 来结算。集合运算交给脚本,解释交给模型,决策交给人;这个顺序不能颠倒,尤其不能让模型替你做判定。

还有一步最容易被忽略:缺失列表必须写进 README,并在长期内保持可见。这 128 项会一直挂在那里,直到变成 0,或者每一项都写明“不会迁移,原因是 X”。只存在于某个 PR 讨论里的核对,不算核对,因为下一个接手的人看不见它,仍会重新踩进这 128 个坑。这也是本文、interface-as-code 文章以及不构建统一响应 Model 的文章共同的立场:让唯一事实来源自己说话,不要把结论分散在每个人的记忆里。