核心不是改代码。代码错误只是表面结果,真正要修的,是那个持续产出错误代码的循环。
过去必须有生死攸关的理由。现在一个长期的内存问题、一个拖慢交付的构建瓶颈,就足以启动一次迁移。
把系统看成一条装配线:小模型负责实现,大模型负责挑错,编译器与测试负责判定。人只设计装配线、编写规则。
保留旧结构,还是同时重新设计?这个选择决定了规则手册、编译器位置和整条流水线的走法。
把生产系统从一种语言重写成另一种语言,过去通常意味着一场持续数年的高风险工程。
难点不只是代码量大。迁移期间,团队要同时维护新旧两套实现。产品路线图会被拖慢,关键工程师长期被占用。几年之后,新系统还可能只有九成行为与旧系统一致,而剩下的一成,往往比最初的问题更难处理。因此,很多团队宁愿继续忍受内存错误、构建缓慢和技术债,也不愿押注一次大规模重写。
过去一个月,Anthropic 的工程师用 Claude Code 迁移了十个代码包,规模从几万行到几十万行不等。其中两个项目尤其有代表性。
Jarred Sumner 把 Bun 的核心实现从 Zig 迁到了 Rust。两周内生成约一百万行代码,涉及 1448 个文件,合并前 Bun 原有测试在持续集成环境中全部通过,上线后发现的 19 个回归也已修复。Mike Krieger 则在一个周末内,把一个 Python 代码库重写成约 16.5 万行 TypeScript,过程中使用了上百个 agent(能够独立领取任务、修改代码和验证结果的模型执行单元)。
成本并没有消失,只是风险结构发生了变化。这使迁移的立项标准明显降低:过去必须有生死攸关的理由,现在一个长期存在的内存问题,或者一个持续拖慢交付的构建瓶颈,就可能足以启动。Mike 的项目就是因为编译太慢——原来的 Python 工具链在每个平台上需要约八分钟,完整发布矩阵要等待三十分钟;迁到 TypeScript 后,同样的编译约两秒完成,二进制启动速度提高六倍,还停掉了一条单独维护的部署流水线。
传统迁移很容易被理解成翻译工作:打开一个文件,把旧语言改写成新语言,遇到错误再逐个修正。
这种方法在几千行代码上或许可行。到了上千文件、上百万行的规模,人已经无法逐项监督。真正可扩展的做法,是把注意力从代码移到生产代码的流程上。
可以把整个系统看成一条装配线:小模型负责大量实现,大模型负责挑错,编译器、测试和输出差异负责判定结果。人不再逐个修改文件,而是设计装配线、编写规则手册,并观察哪些错误反复出现。如果同一种问题出现在几十个文件里,正确动作不是修几十次,而应该修改规则手册中对应的规则,然后重新生成受影响的文件。
代码错误只是表面结果。真正需要修正的,是那个持续产出错误代码的循环。
自动化迁移要连续运行几天,首先必须回答一个问题:机器凭什么知道任务已经完成?
如果没有明确的判定标准,循环就无法停止。模型可以不断生成看似合理的代码,却没有办法证明新系统与旧系统行为一致。这个判定系统必须能用同一套标准检查旧代码和新代码,而困难在于,很多旧项目的测试依赖内部函数、私有类型或语言特有机制,无法直接搬到另一种语言。
第一步,分类。让 Claude 判断现有测试里,哪些表达的是外部可观察行为,哪些绑定了内部实现。
第二步,改写。把外部行为改写成新旧系统都能运行的断言,再安排对抗式 agent 检查,确保改写过程中没有偷偷降低标准。
第三步,验证裁判本身。正确的旧代码必须通过,故意破坏的旧代码必须失败。一套抓不住人为错误的测试,没有资格担任迁移裁判。
两个案例在这里就分了岔。Bun 的条件比较理想,它原有的测试主要由 TypeScript 编写,不依赖 Zig 或 Rust 的内部结构,同一套测试可以原封不动地检查迁移后的实现。Mike 的项目没有这种条件,他为此建立了一套行为一致性框架,选取七个真实使用场景,分别运行 Python 版和 TypeScript 版,再逐项比较输出,任何差异都被视为缺陷。
多数项目更接近后一种情况。没有现成的跨语言测试并不意味着不能迁移,而是要先让 Claude 帮助建立一套。旧系统仍然是最终标尺。
Anthropic 把迁移分成六个阶段,但它们并不是六套独立方法。整个过程始终使用同一条流水线,只是每一轮更换不同的裁判。
脚本先扫描磁盘,找出尚未完成的任务,再把任务切成批次放入队列。多个小模型并行实现,两个大模型从不同角度进行对抗式评审,如果意见不一致,再交给第三个 agent 裁决。最后,由编译器、测试或输出差异判定结果。通过的任务进入下一阶段,失败的任务重新回到队列。
六个阶段中,真正需要人投入大量判断的主要是前两个。后面的工作,更多是在消耗队列和计算资源。
缺口清单记录的,是新语言强制要求、旧语言却没有明确表达的东西。下面两段代码能说清这个概念。
fn readConfig(a: Allocator) ![]u8 {
const buf = try a.alloc(u8, 1024);
return buf; // 谁来释放,只写在注释里
}
// 调用方忘记释放,依然能编译,
// 问题要到运行时才暴露。
fn read_config() -> Vec<u8> {
let buf = vec![0u8; 1024];
buf // 所有权转移,自动释放
}
// 用了已转移的值、重复释放、
// 生命周期不成立,都编译不过。
Zig → Rust,缺口是内存所有权。Zig 靠人记着释放,Rust 要求所有权关系能被编译器证明。这类隐含知识必须先写进缺口清单。
def register(handler):
handler.setup()
return handler.run({"retries": 3})
# 任何带 setup/run 的对象都行,
# 真实传入了什么,要搜遍全库才知道。
interface Handler {
setup(): void;
run(o: {retries: number}): Promise<Result>;
}
function register(h: Handler) { ... }
// 契约不写下来,无法通过类型检查。
Python → TypeScript,缺口是接口契约。Python 接受任何带对应方法的对象,TypeScript 要求先定义 Handler 接口,把对象的方法、参数和返回值明确写下来。
Mike 的项目在第六阶段还多走了一步:把七个真实场景逐一交给修复 agent,直到新旧输出全部一致;随后,Claude 又自行设计了一套端到端测试,连续四个晚上自主执行、发现问题并修复,捕获了场景清单没有预先覆盖的边缘问题。
Bun 和 Mike 的项目都使用了六阶段循环,但开局选择不同,因此流程细节差异很大。
Bun 选择保留原有结构,Mike 选择重新设计。这一个选择,会一路影响到规则手册的形态、缺口清单的时机、编译器放在哪里,以及整条流水线要跑几遍。
Mike 前两遍的价值在于改进设计、规则和编排,而不是交付代码。这说明流程没有固定模板:即使使用相同的六个阶段,也要根据目标结构、编译成本和测试条件重新安排。
Bun 的 Rust 版本已经运行在生产环境中。它并非完全没有代价:约 4% 的 Rust 代码位于 unsafe 块中,多数是与 C、C++ 边界交互时的单行指针操作。但迁移结果可以被量化。
更重要的变化仍然是风险。过去最危险的情况,是投入数年后发现新系统只有九成行为与旧系统一致,团队既无法继续迁移,也无法轻易退回。现在,失败的主要成本变成 token、API 费用和几天运行时间;流程有问题,就删除结果、修正规则、再跑一遍。
这并不意味着所有迁移都值得做,也不意味着可以照搬某套固定脚本。每个代码库的裁判、缺口、依赖和目标结构都不同,正式开始前仍然需要先为具体项目设计流程。Anthropic 提供了一个迁移入门工具包,但它只是对通用流程的抽象,并不是 Bun 或 Mike 项目实际使用的原始系统;另一个 code-modernization 插件主要服务于遗留系统现代化和框架升级,也不等同于跨语言重写。
真正值得复用的不是某份模板,而是一种工程思路:不要把大规模迁移看成无数次代码修改。先建立能够持续生成、持续质疑、持续判分的循环,然后把人的判断写进规则,把机器的确定性写进门禁,让流水线自己把规模消化掉。