- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 开篇 — 顺序错了,规模越大亏得越多
- 第 0 步 — 在翻译之前先立裁判,并且先给裁判做调试
- 第 1 步 — 规则手册、依赖图,以及 3 个文件的预演
- 第 2 步 — 沿可验证的边界切分,把队列交给磁盘
- 第 3 步 — 测什么:转换了多少行不是进度
- 第 4 步 — 三种失败模式与各自的检测法
- 收尾 — 这套流程的价值不在速度,而在可以退回来
- 参考资料
开篇 — 顺序错了,规模越大亏得越多
2026 年上半年,大规模 LLM 迁移的案例接连公开。Bun 用 11 天把 53 万 5 千行 Zig 搬到了 Rust(发布文),Anthropic 的 Mike Krieger 则用一个周末把 16 万 5 千行 Python 搬到了 TypeScript(收录于方法论整理中的案例)。两个案例的语言、规模、团队构成都不同,可流程却相似得惊人。
而在那套流程里最重要的不是某项具体技法,而是顺序。先动手翻译、把验证留到后面补,那么规模越大,损失就以指数级放大。搬错 100 个文件之后才发现规则错了,重跑 100 个就行;搬完 1,400 个才发现,那个时候下一阶段的工作已经压在上面了。
这篇文章只从公开的一手来源(Anthropic 的迁移工具包与方法论文档,以及 Bun 的发布文)里抽出可复现的流程,按顺序排开。我只留下了不绑定特定模型或工具的部分,凡是没有得到验证的地方我都写明了。对 Bun 案例本身的分析放在另一篇文章里,这里只看流程。
第 0 步 — 在翻译之前先立裁判,并且先给裁判做调试
最常见的错误就是跳过这一步。迁移是靠把人工评审换成机器判定来换取速度的。没有判定者,你换到的不是速度,只是省掉了验证。
行为预言机用下面三样里的至少一样来搭。
可移植的测试套件是最好的选择。条件只有一个 — 测试只能碰公开表面。那些直接调用内部函数、或者检查私有状态的测试,语言一换就得跟着扔掉,因此当不了预言机。Bun 之所以能做到 11 天,决定性的条件就在这里。测试是用 TypeScript 写的,和运行时的实现语言无关,而且移植过程中被删除或跳过的测试是 0 个。
黄金输出是测试薄弱的遗留系统里的第二选择。定下一批真实输入,把旧实现的输出固化成文件,再和新实现的输出逐字节比对。在批处理作业、报表生成器、解析器这类输入输出确定的系统上效果很好。
差分执行是三者中最强的。把同样的输入同时灌进两个实现,比较结果。Krieger 的案例就是这种形态:他用 7 个真实场景搭了一套对等性测试装置,并立下任何行为变化都算 bug 的规则。不允许「有意的改进」这种例外,是其中的核心。一旦允许例外,预言机就成了可以谈判的东西。
而这里还有一步是大多数人会漏掉的 — 先给裁判做调试。你得把预言机跑在原始代码上确认全部通过,再跑在被故意改坏的原始代码上确认它确实会失败。什么都判失败的裁判,通常不是对,而是坏了。
# 裁判验证:该通过的时候通过,该失败的时候失败吗
git stash list >/dev/null 2>&1 || exit 1
# 1) 在完好的原始代码上必须全绿
./run-oracle.sh --target=legacy || echo "FAIL: 裁判连原始代码都判不过"
# 2) 在被故意改坏的原始代码上必须亮红灯
# (翻转一个比较运算这种最小变异就足够了)
git apply mutations/flip-one-comparison.patch
./run-oracle.sh --target=legacy && echo "FAIL: 裁判抓不住变异。得先修预言机"
git apply -R mutations/flip-one-comparison.patch
跑这两行所花的时间,永远比日后调试「测试全过但生产坏了」要便宜。
第 1 步 — 规则手册、依赖图,以及 3 个文件的预演
规则手册是一份用来把翻译判断只下一次的文档。工具包附带的元规则可以直接拿来当判别标准 — 只要是两个智能体可能给出不同答案的问题,那就是规则手册的条目。错误处理惯用法的对应、null 的表示、整数溢出策略、日志格式、命名规范、并发原语的映射,都是典型条目。Bun 的 PORTING.md 约 600 行,写它花的时间是编码之前的大约 3 小时。
依赖图用确定性的脚本来生成。不要去问模型「这个文件依赖什么」。一段解析 import 语句的 30 行脚本更准确,也更可复现。这张图的产物是作业顺序(从叶子往根)和循环清单。循环必须提前知道 — 源语言宽容放过的循环依赖,在目标语言里一口气炸成几千个模块错误,是这类工作的常客事故。
缺口清单是目标语言新要求的那部分信息的列表。从 Zig 到 Rust,所有权和生命周期归在这里;从 Python 到 TypeScript,则是显式的接口契约。这些信息在源代码里并不存在,所以它不是翻译而是决策。决策由人来做,并写进规则手册。
再往下是预演。只挑 3 个文件,把整条流水线跑一遍。一个智能体按规则手册翻译,另一个不看规则手册、以该语言资深工程师的方式翻译,第三个来审计两份结果的差异。这里产出的译文全部扔掉,只改规则。Bun 在这一步抓到了两件事,要是就这么走下去,会扩散到全部 1,448 个文件。如果说有哪个地方值得把人力时间砸进去,就是这里。
第 2 步 — 沿可验证的边界切分,把队列交给磁盘
挑选作业单元时,标准不是大小而是可验证性。搬完一个单元之后,机器得能判定它「成没成」。文件级单元之所以合适,是因为只要看目标文件在不在、能不能编译,判定就结束了。
在这里必须把结构保留和重新设计分开。保持文件对文件对应的结构保留式移植,预演、逐行比对、文件级队列全都成立。反过来,一旦决定边搬边重画模块边界,这些装置会同时垮掉 — 没有可比对的源代码行,单元膨胀成模块,回退半径也跟着变大。想把搬迁和重画一次做完的诱惑,是这套流程里最常见的失败原因。请把顺序拆开。先搬,等预言机是绿的,再重画。
完成判定交给磁盘状态。这一个决定同时给了你可恢复性和可回退性。
# 队列 = 清单里还没有输出文件的那些条目。不要保留除此之外的状态。
awk -F'\t' '{ if (system("[ -f " $2 " ]") != 0) print }' migration/manifest.tsv \
> migration/queue.tsv
wc -l < migration/queue.tsv # 剩余工作量
git worktree list # 每个工作树给一份独立的队列切片
你去问智能体进度的那一刻,进度本身就变成了模型输出,也就成了幻觉的对象。文件在不在,是不会幻觉的。
可回退性靠三件事维持。按单元拆分提交,把回退半径保持在单个文件;把翻译阶段和修补阶段分到不同的分支或工作树;把规则手册的修订时点写进提交信息。最后一条之所以重要,是因为规则一旦变了,你必须能在此前生成的产物里圈定出需要重新生成的范围。
编译器放在循环的哪个位置,也在这一步定下来。像 TypeScript 或 Go 这样类型检查很快的,就放进翻译循环里拿即时反馈;像 Cargo 这样慢的,就等翻译全部结束后跑一次,把错误列表当作下一轮队列。Bun 走的是后者,约 1,600 个编译错误在 12 小时内清掉了。
第 3 步 — 测什么:转换了多少行不是进度
迁移报告里出现得最多、也最没用的数字就是「转换的行数」。这个数字是产出量,不是进度。真正会改变决策的指标是下面五个。
| 指标 | 定义 | 它告诉你什么 |
|---|---|---|
| 裁判通过率 | 预言机用例中新实现通过的比例 | 唯一的完成定义。Bun 从 972 个失败文件降到 23 个,再降到 0 |
| 规则违反复发率 | 评审意见中,已经写在规则手册里的条目被再次违反的比例 | 这个值不降,出问题的就是流程而不是规则 |
| 差分评审率 | 人真正读过的行数 除以 生成的行数 | 风险的真实大小。到了百万行,这个值老实算就是个位数百分比 |
| 单位返工次数 | 一个文件平均被重新丢回队列的次数 | 超过 2 次,说明规则手册没能覆盖那个领域 |
| 残留标记数 | TODO(port)、BUG(port) 注释剩下的个数 | 合并之后仍然存在的真实债务。它能让你不把合并日误当成完成日 |
其中我强烈建议一定要算出并记录差分评审率。在百万行规模上老实计算,这个值大多低于 5 个百分点。如果这个数字让你不舒服,那份不舒服就是准确的反应。它就是这种做法所承担的风险大小,也是必须向决策者汇报的数值。别藏起来,取而代之的是把剩下那 95 个百分点是被什么判定的(编译器、预言机、对抗性评审者)一并写上。
评审的经济学可以这样归纳。人该读的不是生成出来的代码,而是生成规则,以及那些规则产出的代表性样本。600 行规则手册、3 个预演文件的差分,还有评审者之间出现分歧的那些用例。这三样人是读得完的,而且读了之后结果真的会变。反过来,把 1,448 个文件粗略扫一遍的评审,花掉了时间却改变不了结果。
第 4 步 — 三种失败模式与各自的检测法
静默的语义漂移。编译过得去、测试也过得去,行为却微妙地不同。它主要出现在浮点舍入、排序稳定性、null 与空值的区分、错误传播的顺序、时区处理、整数溢出行为上。因为发生在测试没覆盖到的路径上,所以测试抓不到。检测办法只有一个 — 差分执行:把生产流量的采样或真实输入日志灌进两个实现,比较输出。Krieger 之所以立下「任何行为变化都是 bug」这条规则,原因就在这里。开始列例外清单的那一刻,你阻止这种失败模式的手段就没有了。
幻觉 API。指调用了不存在的函数、错误的签名、旧版本参数顺序的代码。搬到静态类型语言时,编译器会全部抓住,所以在实务上不算大问题。危险的是目标是动态语言的时候。搬到 Python 或 Ruby,错误的调用会一直活到运行时;如果那条路径上没有测试,它会在生产环境里第一次爆掉。目标若是动态语言,就必须把 import 解析和签名检查加成一个独立的静态分析阶段,并把这个阶段放进翻译循环里。
因为不相干的原因而通过的测试。三者中最危险的一种。有三种典型形态。第一,写新代码的那个智能体顺手把测试也改了 — 它同时当了裁判和被告。请把预言机文件在迁移分支上设成禁止写入,需要改动就走人工审批。第二,把异常吞掉的测试 — 失败路径会悄悄变成通过。第三,靠提前 return 跳过校验部分的测试。
# 每一轮都确认预言机在迁移过程中没有被改动过
git diff --stat migration-base..HEAD -- tests/ | tail -1
# 不要只看通过数,要连「实际执行的 assertion 数」一起看。
# 如果通过数没变而 assertion 数掉了,说明测试悄悄被掏空了。
./run-oracle.sh --report=assertions | tee migration/assertions-$(date +%s).txt
这三种失败模式有一条共同的应对原则。如果评审者把同一条意见提了三次,那就不是单个 bug 而是系统性错误,所以不要去改文件,而要改规则手册并重新生成受影响的批次。一旦开始用手一个个文件地打补丁,规则和代码就会分叉,从那一刻起重新生成就不可能了。
收尾 — 这套流程的价值不在速度,而在可以退回来
归纳起来,顺序是这样的。
- 建好行为预言机,并且先确认这台预言机在被故意改坏的代码上会失败。
- 做出规则手册、依赖图、缺口清单,再用 3 个文件的预演把规则捶打一遍。译文扔掉,只留规则。
- 沿可验证的边界切分成队列,把完成判定交给磁盘状态,让中断与恢复变成免费的。
- 别看转换的行数,去看裁判通过率、规则违反复发率、差分评审率、单位返工次数、残留标记数。
- 评审者反复提的意见要落到规则上而不是落到文件上,然后重新生成受影响的范围。
这套流程给你的真正好处不是速度,而是你可以全部扔掉重跑一遍。只要规则手册、队列和预言机是分离的,最坏情况下也不过是删掉分支、改好规则、再跑一次。以前那些要做好几年的迁移之所以是押上职业生涯的项目,是因为它退不回来,而不是因为它难。
如果只留一句,就是这句 — 如果在开始翻译之前,你还没定下由什么来判定「结束」,那么这次迁移还没准备好开始。
参考资料
- How Anthropic runs large-scale code migrations with Claude Code — 6 步方法论原文
- anthropics/code-migration-kit-with-claude-code — 提示词 00~06、规则手册模板、依赖映射器、构建守护进程
- Rewriting Bun in Rust — 预言机测试套件与预演的案例
- Hacker News — 关于 Anthropic runs large-scale code migrations 的讨论(含质疑意见)
- The Pragmatic Engineer — 大规模 AI 迁移的前提条件分析
- Bun 用 11 天把 Zig 重写成 Rust — 案例分析篇(相关文章)
- 重构的经济学,成本何时收回(相关文章)