Anthropic 如何借助 Claude Code 进行大规模代码迁移

8
分类技术博客
作者Anthropic
来源跳转
发表时间

内容

代码迁移——即将一个生产级代码库移植到新语言——直到最近都还是要耗费数年的工程。

过去一个月里,Anthropic 的个别开发者借助 Claude Fable 5、Claude Opus 4.8 和 动态工作流,迁移了 10 个代码包,规模从数万行到数十万行不等。本文将结合这几个项目的经验,介绍两个案例以及最佳实践。

Bun 的联合创始人兼 Anthropic 技术员工 Jarred Sumner 使用 Claude Code 将 Bun 从 Zig 迁移到 Rust。不到两周时间内产出了 100 万行代码,并且在合并前,Bun 现有测试套件在 CI 中达到了 100% 通过。合并后暴露出 19 个回归问题,目前都已修复。Rust 移植版已于 6 月随 Claude Code 一并发布。

Anthropic Labs 联合负责人 Mike Krieger 在一个周末内将一个 Python 代码库迁移为 16.5 万行 TypeScript。这一过程包括数百个 agent、8 个阶段闸门、3 轮对抗式审查,以及一次最终一致性检查:将每条命令的输出与 Python 原始版本逐一 diff 对比。

Claude Code 的新能力改变了这类长期被搁置项目的可行性计算。下面是我们如今采用的六步流程,来自这些迁移项目带来的经验。

核心洞见是:你不是在修代码。你是在修产生这些代码的流程(循环)

为什么以及何时迁移语言

在直接讲 如何做 之前,先讨论 何时做为什么做 更有必要,因为这类项目背后的假设已经发生了变化。

团队之所以启动迁移,是因为从最初构建到当前项目之间,环境已经改变。要么某个已知权衡开始成为瓶颈,要么出现了更好的方案,要么原有生态正在萎缩。

例如,Jarred 最初选择 Zig,是因为它兼具接近 C 语言的性能和极致的简洁性,非常适合一个独立创始人“在 LLM 出现前、奥克兰一间狭小公寓里,用 1 年时间写 Bun”。这种简洁性也伴随着已知代价,他在这里有详细说明

时间快进到 2026 年。Bun 的 CLI 月下载量已超过 1000 万,并且在 Claude Code 内部被大量使用。

即使放到上个季度,这些权衡也还不足以证明冻结路线图并投入资源做一个跨季度项目是合理的。语言迁移确实能带来更小、更快、更安全的系统,但没有人愿意为此买单。

软件工程师还必须面对这类过去动辄成百上千人的巨型项目所带来的职业风险。你可能要并行维护两套代码库数个季度甚至数年,而如果最后只实现了 90% 的一致性,那麻烦只会比开始时更大。

而现在,最糟糕的情况不过是删掉分支,重新来过。

当然,仍然需要有足够充分的商业理由。虽然百万行级迁移不再需要在四年里耗费 300 万到 400 万美元的工程资源,但执行成本仍然可能达到数万到数十万美元甚至更多。例如,Bun 的迁移消耗了 59 亿个未缓存输入 token 和 6.9 亿个输出 token,按 API 定价约为 16.5 万美元。Mike 的主要移植部分则用了 2700 万 token。

Jarred 的百万行 PR。

不过,迁移项目如今不再需要具有“生死攸关”级别的理由。 比如 changelog 里一年一度的内存 bug 修复,或者一个长期存在的性能瓶颈,现在就足以成为迁移动因。

Mike 的项目是由编译步骤触发的。他们团队开发的内部工具以单个二进制文件的形式交付给用户。用 Python 工具链生成这个二进制文件,在每个平台上大约需要 8 分钟;按整个构建矩阵算,每次发布都要等待约 30 分钟。迁移完成后,同样的编译现在只需大约 2 秒,二进制启动速度提升了 6 倍,团队还得以废弃一条独立的部署流水线。

为什么 AI 会改变代码迁移的成本模型

Claude Fable 5 是我们能力最强、可正式使用的通用模型。Fable 和 Opus 4.8 在委派、调度和验证多条并行工作流方面尤其出色,能够借助子 agent 找到达成目标的多条路径。

大型代码迁移特别适合这些高级模型,因为:

  • 工作天然可并行。可以在文件、crate 等成千上万个彼此独立的单元上同时执行,agent 不必彼此等待。
  • 上下文清晰且完整。旧代码本身就是模型的绝佳规格说明。它也可以作为核心参考,帮助构建供翻译 agent 遵循的指南。
  • 自带裁判。许多大型代码库都包含测试套件,agent 可以用它来验证工作成果。只要验证是客观的,agent 的表现就会更好,因为模型可以连续多天与真实结果对抗,而无需人工仲裁质量。
  • 队列会自己生成。当编译或测试失败时,这就成了下一个需要 agent 处理的任务。
  • 它们要求一致性与边界情况处理:流程本身被设计成让漂移无处藏身:审查者会为每个发现引用对应规则,因此违规会变成队列项,而不是悄悄产生分歧。并且当某个 agent 碰到边界情况时,这个修复会变成后续所有 agent 都要遵循的一条规则。

如下文所示,Mike 和 Jarred 都在迁移流程中的关键步骤使用了 Fable,尤其是在一种顾问式模式中,结合多个模型类别来优化 token 消耗。

大规模代码迁移的六个步骤

下面的流程已做泛化,可适用于多种语言和场景。更多细节可参阅 Jarred 的博客

前置条件

开始迁移项目之前,前提是先建立一个强有力的裁判(judge),否则你就没有退出条件,也无法衡量成功与否。

这个裁判必须能够以同等标准评估原始代码和目标代码。原语言编写的测试套件往往依赖一些目标代码中不存在的内部函数。

要构建这个裁判:

  • 对现有测试进行分类。使用 Claude 识别哪些测试可以表示为外部调用,哪些依赖于无法迁移的内部实现。
  • 为可移植性重写。将面向外部的测试改写为可同时作用于原始代码和移植版本的断言。使用对抗式 agent 验证重写后的测试没有削弱断言强度。
  • 验证裁判。先在原始代码上运行,确认其通过;再在故意损坏的代码上运行,确认其失败——连破坏都测不出来的裁判,不算裁判。

Jarred 有一套用第三种语言(TypeScript)编写的大型测试套件,但大多数项目不会是这种情况。对于他的 Python 到 TypeScript 移植,Mike 构建了一个由 7 个真实场景组成的等价性验证框架,并将任何行为变化都视为需要修复的 bug。

在进入各个阶段之前,这张图可能有助于理解整体流程。它基本遵循 Jarred 的方法论,每个阶段都设有审查和闸门。Mike 采用了类似的总体结构和相似的循环工作流,但他把整个迁移端到端地跑了一遍,根据结果修改规则和工作流,然后再跑一遍——每次都丢弃产物,直到第三轮才采用结果。

第 1 步 —— 创建规则手册、依赖关系图和差距清单

在这个阶段,我们要为迁移打下基础:列出那些不能只靠翻译、而必须重构的代码位置;编写代码翻译规则手册;以及建立依赖关系图,以便安排迁移实现的工作流顺序。

顺序很重要:规则手册必须先于差距清单。差距清单由规则手册默认覆盖不到的内容定义,两者会在联合审计中一起接受检查。

规则手册

规则手册 的具体形态,取决于你在项目开始时必须做出的关键架构决策。最核心的决定是:新代码是否沿用相同结构,还是完全重新设计。

如果是前者(Jarred 的做法),规则手册主要就是一些查表规则,用于在语言之间映射类型和惯用法,并指向差距清单中那些更难翻译的组件。如果是后者(Mike 的做法),它就会是一份设计文档。

Jarred 通过和 Claude 对话创建了自己的规则手册,并为每一个歧义领域制定了相应策略。他还使用了 8 个子 agent,分别针对他凭直觉识别出的 8 类常见失败模式进行审查。

依赖关系图

要想把并行迁移的工作流有效拆分开,你需要理解文件依赖关系,这样才能知道哪些文件应该先迁移,哪些文件应归入同一批次。某些语言和代码库有明确的清单文件,使这件事变得很容易;但对于遗留代码库以及 C/C++、Python 等许多流行语言来说,这些依赖关系需要被发现并绘制出来。

Claude Code 可以调度 agent 创建并运行一段确定性脚本来生成这张图。迁移工具包中的提示词使用了一个工作流来创建“审查—修复”循环。注:这个 starter kit 是本文所述流程的通用模板,并不是这些具体移植项目实际运行所用的东西。

差距清单与怀疑派审查者

新语言相较旧语言会有一些必须满足的不同要求。对于 Zig 到 Rust 的迁移,这个差异在于手动内存管理(C 和 C++ 的情况也类似)。例如:

Zig

fn readConfig(allocator: std.mem.Allocator) ![]u8 {
    const buf = try allocator.alloc(u8, 1024);
    // ...fill buf...
    return buf; // 调用方必须释放它——但只有注释这么说
}

// 忘记写 'defer allocator.free(buf)' 的调用方依然能通过编译 —— 泄漏只会在运行时暴露。

Rust

fn read_config() -> Vec<u8> {
let buf = vec![0u8; 1024];
// ...fill buf...
buf // 所有权转移给调用方;内存会自动释放
}
// 移动之后还想继续使用?重复释放?都无法编译。
// 忘了释放?根本没有 free 调用可忘——drop 会自动执行。

对于 Python 到 TypeScript,差距在于接口和契约。Python 不要求显式声明它接受什么形状的对象、或者返回什么;但 TypeScript 要求。例如:

Python

def register(handler):
    handler.setup()
    return handler.run({"retries": 3})

# 这里任何拥有 .setup() 和 .run() 的对象都可以。到底有哪些对象被传进来了?得把整个代码库读一遍才知道。

TypeScript

interface RunResult {
  ok: boolean
}

interface Handler {
  setup(): void;
  run(opts: {
    retries: number
  }): Promise < RunResult > ;
}

function register(handler: Handler): Promise < RunResult > {
  handler.setup();
  return handler.run({
    retries: 3
  });
}

// 这个契约必须先写下来,代码才能通过编译

Jarred 和 Mike 都创建了差距清单文件,用来记录这些隐性知识。Jarred 是先把这些差距在前期梳理出来,也就是我们这里采用的方法;而 Mike 则是先翻译,再在事后通过审计生成差距清单。你可能需要两种方法都做。

可查看这个示例 Claude Code 提示词,用于创建差距清单文件

第 2 步 —— 压力测试规则

这一步包含一次小规模迁移,作为大迁移的“试运行”。

在这一步中,Jarred 用一个 agent 按规则手册翻译 3 个文件,用另一个 agent 以“资深 Rust 工程师”的方式翻译 3 个文件,再用第三个 agent 通过 diff 生成新的翻译规则。正是在这个阶段,他发现了两个关键问题;如果这些问题在全部 1,448 个文件中扩散开来,将会造成大量故障。

这个提示词可能类似于这里

这种压力测试只适用于保持结构的迁移,即同一文件的两种翻译可以逐行对比。如果你的规则手册是一份重新设计文档——像 Mike 那样——那么等价的测试就是直接用对抗式审查者攻击这份设计文档,然后通过一次可丢弃的端到端运行来验证它。

无论如何,都要把已翻译的文件全部丢弃。目标是完善规则,而不是推进增量进度。

第 3 步 —— 翻译全部代码

从剩余步骤开始,你要运行同样的多 agent 循环架构:实现、审查、修复。

你可以把实现者的工作卸载给更小的模型,把审查者留给更强的模型。例如,Mike 在主迁移中一次性派出 12 个子 agent 时,用的是 Claude Sonnet。

工作队列应该是机械化的。批处理脚本通过检查翻译后的文件是否已经存在于磁盘上来判断是否完成,然后将待处理文件切分成若干批次交给实现 agent。由于队列每次都会从磁盘重新构建,因此迁移天然具备可恢复性。

在这个阶段,agent 对自己承担的工作量可能会过于保守。修复方法可以是一个直接、明确的提示词指令,并附带上下文:编译器会在下一步捕捉错误。

任何翻译器无法自信执行的内容,都会被标记为 // TODO(port): <reason>,留待第 4 步处理。从这里开始,待办列表会自己生成:编译器罗列错误,烟雾测试发现崩溃,测试套件报告失败。

两个对抗式审查者用各自独立的上下文评估实现者的工作;如果审查者之间存在分歧,则交给第三个 agent 处理。当某个审查者在多个文件中反复抓到同一个错误时,修复方式不是逐文件修改,而是在规则手册中补上一句话,然后重新生成受影响的批次。规则手册会在这个步骤中持续增长;代码不会被手工打补丁来“对付”规则手册。

这一步一个重要的设计决策,是编译器放在什么位置。Mike 在每个循环里都运行 TypeScript 编译器,因为它只需几秒钟就能检查一个单元。Jarred 则完全把编译器排除在循环之外,推迟到下一步,因为 cargo 需要数分钟。

在这一步里,大量繁重工作已经完成,提示词也开始变短了。

第 4、5、6 步 —— 编译、运行、对齐行为

这三个步骤共享同样的循环架构,而且所需的人类判断会逐步减少,所以我们把它们放在一起讲。

例如,第 4 步 常常会因迁移所涉语言和规模不同,而直接并入第 3 步。

根据编译步骤的规模和难度,agent 甚至可能根本不执行这一步。Jarred 使用一个 orchestrator 脚本,在整个工作区上统一调用一次编译器来完成它。随后,“修复型 agent”并行处理错误列表,同时接受对抗式审查。然后再次构建,如此反复。

审查错误列表有助于发现可能需要调整的系统性问题。例如,Jarred 遇到了数千个 Rust 模块错误,这些错误是在修复了 Zig 的惰性编译所容忍的循环导入之后才浮现出来的。他通过编码逻辑来分类:哪些依赖应该删除、移动,或者重构边界,从而修复了这个循环。

第 5 步 也有类似编译器错误列表那样的机械化事实来源:烟雾测试的崩溃。同样,这里的循环修复方式也是按类别归纳问题;在这种情况下,是按根因分组,并由对抗式子 agent 审查。

第 6 步,也就是这个故事的终点,是比较两个代码库中程序的行为。

到这一步,我们的文件已经完成翻译、编译和烟雾测试。现在是时候对它们进行分片,并针对它们运行测试套件(来自前置条件阶段)了。对于失败项,使用“修复型 agent”处理:让它们同时对照两个代码库审查失败的测试。对抗式审查者再对这些修复进行检查。

这个循环的下一阶段是一个 build daemon,它是唯一被允许重建二进制文件的进程。修复者提交补丁;daemon 将它们批量处理,只重建一次,再重新运行受影响的测试,并把结果反馈回来。这样就把最昂贵的操作串行化了,而不是让多个 agent 各自独立触发。

当同一种失败在许多测试中重复出现时,修复就会向上游推进:修改生成该 bug 的规则,只重新生成受这条规则影响的文件。

Mike 的做法在这里很重要,因为很多开发者没有现成的、已经迁移好的测试套件。Mike 让 Claude 编写了一个小脚本,用来针对新的移植版和原始 Python 代码库同时运行 7 个真实场景,并对结果做 diff。每个失败场景都有自己的修复 agent,循环一直运行到 7 个场景全部通过为止。

然后他又更进一步。Claude 设计了自己的端到端测试套件,并在夜间自动运行,连续 4 个晚上修复出错点并重新执行。结果,它捕捉到了任何场景列表都无法预判的那些细小问题。

这里的教训是:缺少测试套件并不会阻断这一步。如果你无法直接继承一个裁判,那就让 Claude 构建一个。不管怎样,你的原始代码库始终是事实标准。

代码迁移最佳实践

每一次运行都让我们学到了一些前一次没学到的东西。可以肯定的是,你的下一次迁移也会教给你一些这份指南无法涵盖的内容。但有几条实践在所有项目中都经受住了验证:

  • 不要盲从这份指南。 每次迁移都不同。把它当作起点,并在承诺执行前先和 Claude 一起为你的具体迁移做规划。
  • 不要盯着单个失败项。 单个失败项是循环机制该处理的事。修复型 agent 会把它们逐个清掉。你的注意力应该放在模式上。
  • 让审查具有对抗性,让验证机械化。 对抗式审查能支持更长时间运行的任务,而且通常值得消耗这些 token。让脚本——编译器、diff、测试套件——来担任裁判。
  • 不要对所有事情都使用最大模型。 token 开销主要集中在你的循环中,所以要有意识地设计循环。小模型很适合处理高并发的实现分发;把最大模型留给审查者,以及那些会为其他 agent 编写规则的任务。
  • 把人力前置投入。 规则手册和压力测试最耗时间。之后的大部分工作只是不断清空队列。
  • 让工作队列机械化且可恢复。 “完成”应该意味着“输出文件已经存在于磁盘上”。

关注循环结果,而不是代码本身

Jarred 的 Bun 迁移现在已经上线,尽管任何迁移都有权衡。例如,大约 4% 的 Rust 代码位于 unsafe 块中,主要是 C/C++ 边界上的单行指针操作。

但新的代码库在可衡量意义上更好了。团队工具能够检测到的所有内存泄漏都已修复:某个重复执行 2000 次构建的基准测试,内存占用从 6745 MB 降到了 609 MB。二进制文件在 Linux 和 Windows 上都缩小了 19%。而跨语言优化则让 HTTP 服务以及 next buildtsc 等真实工作负载的性能提升了 2% 到 5%。

不妨想一想,你那些长期搁置的迁移,是否到了重新计算成本收益的时候。挑一个你一直在将就的代码库,问 Claude:它的迁移流程会是什么样。

相关阅读

评论

(0)
未配置登录方式
暂无评论