官方更新 Claude Blog 2026-07-17T00:00 正文 ok 2199 words

Anthropic 如何利用 Claude Code 进行大规模代码迁移

英文标题:How Anthropic runs large-scale code migrations with Claude Code

Anthropic 分享了使用 Claude Code 进行大规模代码迁移的六步流程,包括建立规则书、依赖图与缺口清单,并通过压力测试、批量翻译、编译、运行与行为匹配等阶段完成迁移。文章以 Zig 到 Rust、Python 到 TypeScript 两个真实项目为例,展示了对抗性审查与自动化循环的策略,强调成体系地修正问题、前置人力投入并让验证过程机械化的最佳实践,对面临代码库迁移的开发者及团队具有重要参考价值。
打开原文相关 6 · 机会 1
media24.5 KB media118.9 KB media403 B media459.2 KB media37.4 KB media23.5 KB media21.9 KB media60.9 KB media49.2 KB media11.1 KB media11.7 KB media60.5 KB

中文

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

以下流程已经过泛化处理,适用于多种语言和场景。如需了解更多细节,可参阅 Jarred 的博客 。

前置条件

在开始迁移项目之前,必须准备好一个强有力的评判器,否则你就没有退出条件或成功衡量标准。

评判器必须能够在相同条件下评估原始代码和目标代码。用原语言编写的测试套件通常会依赖内部函数,而这些函数在目标代码中并不存在。

要构建这个评判器:

  • 对现有测试进行分类。使用 Claude 识别哪些测试可以表达为外部调用,哪些依赖于不会被移植的内部实现。
  • 针对可移植性进行重写。将面向外部的测试转换为可以同时针对原始代码和移植代码运行的断言。利用对抗性智能体来验证重写的测试没有弱化断言。
  • 验证评判器。在原始代码上运行它,确认其通过。然后在故意制造了问题的代码上运行,确认它失败 —— 无法捕获破坏的评判器就不叫评判器。

Jarred 有一个用第三方语言(TypeScript)编写的大型测试套件,但大多数项目并非如此。在执行从 Python 到 TypeScript 的移植时,Mike 创建了一个包含七个真实场景的对等测试器具,只要行为发生任何变化,就将其视为需要修复的缺陷。

在深入每个阶段之前,这张图表可能有助于你理解。该流程大体遵循 Jarred 的方法,并在每个阶段都设置了审查和关卡。Mike 也遵循类似的结构,使用了相似的循环工作流,但他从头到尾运行整个迁移过程,再根据结果修改规则和工作流,然后重新运行 —— 每次都丢弃输出,直到第三次运行才保留最终结果。

第一步 —— 创建规则书、依赖图和缺口清单

在这一阶段,我们要为迁移打下基础:创建一份需要重构(而非简单翻译)的代码点清单、一本指导翻译的规则书,以及用于组织迁移执行任务流的依赖图。

顺序很重要:规则书必须在缺口清单之前。缺口清单是根据规则书中默认规则无法覆盖的部分来定义的,二者会在联合审计中一起验证。

规则书

规则书的具体形式取决于你在开始时必须做出的关键架构决策。其中最重要的是,新代码是遵循相同的结构,还是完全重新设计。

如果是前者(Jarred 的情况),规则书将主要是对照表,用于在语言之间转换类型和惯用法,同时指向缺口清单中较难翻译的组件。如果是后者(Mike 的情况),它则是一份设计文档。

Jarred 通过与 Claude 对话来创建规则书,针对每一个有歧义的领域形成策略。他还使用了八个专门设计的子智能体,根据自身的直觉,分别针对八类常见的失败模式进行审查。

依赖图

你需要理解文件依赖关系,以便有效地切分并行迁移的工作流,知道应该先迁移哪些文件,以及哪些文件需要放在同一个批次中。某些语言和代码库有明确的清单可以轻松做到这一点,但对于遗留代码库和许多常见语言(如 C/C++、Python),这些依赖关系需要被发现并绘制出来。

Claude Code 可以部署智能体来创建并运行一个确定性的脚本,以生成这份依赖图。迁移工具包中的提示词使用了一个工作流来创建审查与修复的循环。 注意:入门工具包是本文所阐述流程的通用模板 —— 并非这两个具体移植项目实际运行的东西。

缺口清单与怀疑论审查者

新语言有着与旧语言必须满足的不同需求。对于从 Zig 到 Rust 的迁移,差异在于手动内存管理(C 和 C++ 同理)。例如:

Zig

fn readConfig(allocator: std.mem.Allocator) ![]u8 {
    const buf = try allocator.alloc(u8, 1024);
    // ...填充 buf...
    return buf; // 调用者必须释放这块内存 —— 但只有这条注释说明了这一点
}

// 一个忘记了 'defer allocator.free(buf)' 的调用者仍然可以通过编译 —— 内存泄漏只在运行时显现。

Rust

fn read_config() -> Vec<u8> { 
let buf = vec![0u8; 1024]; 
// ...填充 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 提示词示例。

第二步 —— 对规则进行压力测试

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

在这个步骤中,Jarred 使用一个智能体根据规则书翻译三个文件,使用另一个智能体“像资深 Rust 工程师那样”翻译三个文件,再用一个智能体利用差异来创建新的翻译规则。在此阶段他发现了两个关键问题,如果当时直接扩展到全部 1,448 个文件,将会引发大量问题。

提示词可能类似于这样。

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

无论如何,请丢弃所有翻译出来的文件。此步骤的目标是改进规则,而不是取得渐进式的进度。

第三步 —— 全部翻译

在后续步骤中,你都将运行同一种多智能体循环架构:实现、审查与修复。

你可以将实现工作卸载到较小的模型,而将较大模型保留用于审查。例如,Mike 在主迁移过程中展开 12 个子智能体时,使用了 Claude Sonnet。

工作队列应该是机械化的。一个批处理脚本通过检查磁盘上是否存在已翻译的文件来决定任务是否完成,然后将未完成的文件切分成批次,分派给实现智能体。由于队列每次都是从磁盘重新构建的,因此迁移天然具有可恢复性。

在这个阶段,智能体可能会对工作量过于谨慎。解决办法可以是一条直白、着重的提示词指令,并告诉它们下一阶段编译器会捕获错误。

翻译器无法自信执行的任何内容都会被标记为 // TODO(port): <原因>,留到第四步处理。从此开始,待办清单会自我生成:编译器列举错误,冒烟测试发现崩溃,测试套件报告失败。

两名对抗性审查者使用不同的上下文评估实现者的工作,审查者之间的分歧会交由第三个智能体裁决。当审查者不断在不同文件中发现同一错误时,解决方案不是逐个文件修补。你应该向规则书中添加一句话,然后重新生成受影响的批次。在此步骤中规则书会持续增长,而代码从不会被手动对照规则进行修补。

在这一步需要注意一个重要的设计决策,即编译器的位置。Mike 在每个循环内都运行 TypeScript 编译器,因为它可以在几秒内完成一个单元的检查。Jarred 则完全禁止编译器进入循环,将其推迟到下一步,因为 cargo 需要几分钟。

到了这一步,大部分繁重的工作已经完成,提示词也开始变短。

第四、五、六步 —— 编译、运行并匹配行为

这三个步骤共享同一种循环架构,并且需要的人为判断逐步减少,因此我们在这里一并讨论。

例如,第四步 常常会根据语言和迁移规模融入第三步。

根据编译器步骤的规模和难度,智能体可能根本不需要运行这一步。Jarred 通过一个跨整个工作区调用一次编译器的编排脚本来执行该步骤。然后,“修复者智能体”在对抗性审查下并行处理错误列表。接着再次构建,重复此过程,周而复始。

查看错误列表有助于发现可能需要调整的系统性问题。例如,Jarred 遇到了成千上万条 Rust 模块错误,这些错误是在修复了循环导入(Zig 的惰性编译可以容忍这类导入)之后浮现的。他通过编写逻辑来判断应该删除、移动哪个依赖还是在边界处重构结构,从而修复了该循环。

第五步 同样拥有一个与编译器错误列表类似的机械真理来源:冒烟测试造成的崩溃。同样,循环修复的方法是将问题归类,这里是根据根本原因将引发崩溃的原因分类,并由对抗性子智能体进行审查。

第六步,也是我们故事的结尾,是比较程序在两个代码库中的行为。

此时我们的文件已经过翻译、编译和冒烟测试。现在需要将它们拆分,并针对它们运行来自前置条件阶段的测试套件。让“修复者智能体”对照两个代码库审查失败的测试,以此处理失败用例。对抗性审查者则检查它们的修复。

这个循环中的下一个环节是一个构建守护进程,它是唯一允许重新构建二进制文件的过程。修复者编写补丁;守护进程将它们成批处理,只构建一次,重新运行受影响的测试,并将结果反馈回来。这会将最昂贵的操作串行化,而不是让多个智能体独立触发构建。

当同样的失败在大量测试中反复出现时,修复措施应当向上游移动:你需要修改导致该缺陷的规则,并且只重新生成受该规则影响的文件。

Mike 的方法在这里很重要,因为很多开发者可能没有现成或已移植的测试套件。Mike 让 Claude 创建了一个小脚本来针对新移植版本和原始 Python 代码库运行七个真实场景,并对结果进行比较。每个失败的场景都有自己的修复智能体,循环会一直运行直到全部七个场景通过。

然后他更进一步。Claude 自行设计了一套端到端测试套件,并在夜间自主运行,修复损坏的部分后重新运行,连续进行了四晚。最终,它捕捉到了没有任何场景列表能预测到的那些细微痛处。

这里的教训是,缺少测试套件并不会阻碍这一步。如果你无法继承一个裁判,就让 Claude 构建一个。无论如何,原始代码库就是你的基准真相。

代码迁移最佳实践

每一次运行都教给我们之前一次未能学到的东西。可以肯定的是,你的下一次迁移也会让你学到本指南无法涵盖的经验。但以下几条实践在每一个项目中都经得起考验:

  • 不要盲目遵循本指南。 每个迁移都是不同的。将它视为一个起点,并在正式投入之前与 Claude 一起规划你特定的迁移。
  • 不要纠结于个别失败。 个别失败是循环机制的工作任务。修复者智能体会消灭它们。你的注意力应当放在模式上。
  • 让审查具有对抗性,让验证机械化。 对抗性审查允许运行耗时更长的任务,通常值得消耗 token。让脚本 —— 编译器、差异比较工具、测试套件 —— 成为裁判。
  • 不要对一切任务使用最大的模型。 Token 消耗集中在你的循环中,因此请有意识地设计它们。较小的模型可以很好地处理大批量的实现扇出工作;将你最大的模型留给审查者以及任何为其他智能体编写规则的工作。
  • 将人工时间前置。 规则书和压力测试是最耗时的。之后的大部分工作主要是队列在逐渐消耗。
  • 让工作队列机械化且可恢复。 “完成”应当意味着“输出文件存在于磁盘上”。

审查循环的结果,而非代码

Jarred 的 Bun 迁移现在已投入生产,尽管任何迁移都有权衡取舍。例如,大约 4% 的 Rust 代码位于 unsafe 代码块内,其中大部分是 C/C++ 边界处的单行指针操作。

但新代码库可衡量的质量更高。团队工具能够检测到的所有内存泄漏都已被修复:一项包含 2,000 次重复构建的基准测试中,内存用量下降

English / 原文

Six steps for large code migrations

The process below has been generalized to be relevant to multiple languages and scenarios. For additional details, you can read Jarred’s blog .

Prerequisites

A prerequisite before starting on your migration project is to have a strong judge in place, otherwise you won’t have an exit condition or measure of success.

The judge must be able to evaluate both the original code and the target code on equal terms. Test suites written in the original language will often depend on internal functions that won't exist in the target code.

To build this judge:

  • Categorize existing tests. Use Claude to identify which tests are expressible as external calls and which depend on internals that won't port.
  • Rewrite for portability. Convert the external-facing tests into assertions that can run against both the original and the port. Use adversarial agents to verify the rewritten tests don't weaken the assertions.
  • Validate the judge. Run it against the original code to confirm it passes. Then run it against deliberately broken code to confirm it fails — a judge that doesn't catch breakage isn't a judge.

Jarred had a large test suite written in a third language (TypeScript), but that will not be the case for most projects. For his Python-to-TypeScript port, Mike created a parity harness of seven real-world scenarios and considered any behavior change a bug to be fixed.

Before we get into each stage, this graphic may help you follow along. This mostly follows Jarred’s methodology, with reviews and gates at each stage. Mike followed a similar overall structure using similar loop workflows, but he ran the entire migration end to end, revised the rules and the workflow based on the results, and ran it again — discarding the output each time until the third run.

Step 1 — Create the rulebook, dependency map, and gap inventory

In this stage we are creating the foundations of our migration: an inventory of places where code will need to be refactored rather than just translated, a rulebook for how to translate our code, and a dependency map to order our migration implementation workstreams.

The order matters: the rulebook must come before the gap inventory. The gap inventory is defined by what the rulebook's defaults won't cover, and the two are tested together in a joint audit.

Rulebook

The exact shape of the rulebook depends on key architectural decisions you must make at the start. Chief among them, if the new code will follow the same structure, or if it will be completely redesigned.

If it’s the former (Jarred), the rulebook will primarily be lookup tables that translates types and idioms between languages while pointing to the gap inventory for the harder-to-translate components. If it’s the latter (Mike), it will be a design document.

Jarred created his rulebook by chatting with Claude, forming a policy for each area of ambiguity. He also used eight subagents specifically designed to review for 8 different categories of common failure modes based on his own intuition.

Dependency map

You need to understand file dependencies to effectively break up workstreams for a parallel migration so you know which files to migrate first and which files to contain in the same batch. Some languages and codebases have explicit manifests that make this easy, but for legacy codebases and many popular languages like C/C++ and Python, these dependencies need to be discovered and mapped.

Claude Code can deploy agents to create and run a deterministic script to produce this map. The prompt in the migration kit uses a workflow to create a review-and-fix loop. Note: The starter kit is a generalized template of the process laid out in this post — it's not what these specific ports ran on.

Gap inventory and skeptic reviewers

The new language has different requirements from the old language that must be met. For Zig to Rust the difference was manual memory management (C and C++ work the same way). For example:

Zig

fn readConfig(allocator: std.mem.Allocator) ![]u8 {
    const buf = try allocator.alloc(u8, 1024);
    // ...fill buf...
    return buf; // caller must free this — but only the comment says so
}

// A caller that forgets 'defer allocator.free(buf)' still compiles — the leak only surfaces at runtime.

Rust

fn read_config() -> Vec<u8> { 
let buf = vec![0u8; 1024]; 
// ...fill buf... 
buf // ownership moves to the caller; memory is freed automatically 
} 
// Use it after it's moved? Free it twice? Neither compiles. 
// Forget to free it? There's no free call to forget — drop is automatic.

For Python to TypeScript the gap was interfaces and contracts. Python doesn’t require a contract declaring what shape of object it will accept or what it returns, but TypeScript does. For example:

Python

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

# Any object with .setup() and .run() works here. Which objects actually get passed in? Read the whole codebase to find out.

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 }); } 

// The contract must be written down before this compiles

Both Jarred and Mike created gap inventory files capturing this implicit knowledge. Jarred inventoried these gaps up front, which is what we do here, while Mike chose to translate first and then create the gap inventory by auditing afterwards. You may need to do both.

Check out this sample Claude Code prompt to create a gap inventory file.

Step 2 — Stress-test the rules

This step involves a mini-migration that serves as a “shakedown cruise” for the larger migration.

In this step, Jarred used one agent to translate three files using the rulebook, one agent to translate three files “like a senior Rust engineer,” and one agent to use the diff to create new translation rules. At this stage he caught two critical issues that would have created numerous issues if fanned out across all 1,448 files.

The prompt may look something like this.

This type of stress test only works for structure-preserving migrations , where two translations of the same file are comparable line by line. If your rulebook is a redesign — like Mike's — the equivalent test is attacking the design document directly with adversarial reviewers, then validating it with a disposable end-to-end run.

Regardless, throw out any translated files. The goal is to refine the rules, not make incremental progress.

Step 3 — Translate everything

For the remaining steps, you run the same multi-agent loop architecture: implement, review, and fix.

You can offload implementer work to smaller models and keep reviewers on larger ones. For example, Mike used Claude Sonnet when he fanned out 12 subagents for the main migration.

The work queue should be mechanical. A batch script decides what’s done by checking whether the translated file exists on disk, then slices the pending files into batches for the implementer agents. Because the queue is rebuilt from disk every time, the migration is resumable by construction.

At this stage, agents can be overly cautious with how much work they do. The fix can be a blunt, emphatic prompt instruction with context that the compiler will catch mistakes in the next step.

Anything the translator can’t execute confidently gets flagged with // TODO(port): to be dealt with in step 4. From here on, the to-do lists write themselves: the compiler enumerates the errors, the smoke tests find the crashes, the suite reports the failures.

Two adversarial reviewers evaluate the work of the implementers using separate contexts and disagreement between reviewers goes to a third agent. When a reviewer keeps catching the same mistake across files, the fix isn't per-file. You add one sentence to the rulebook and regenerate the affected batch. The rulebook keeps growing through this step; the code never gets hand-patched against it.

One important design decision to note in this step is where the compiler sits. Mike ran the TypeScript compiler inside every loop, because it checks a unit in seconds. Jarred banned the compiler from the loop entirely and deferred it to the next step, because cargo takes minutes.

At this step, much of the heavy lifting has been done and the prompts start to get shorter.

Steps 4, 5, 6 — Compile, run, and match behavior

These three steps share the same loop architecture and need progressively less human judgment, so we cover them together.

Step 4 , for example, may often dissolve into step 3 depending on the language and size of the migration.

Depending on the size and difficulty of the compiler step, agents may not run this at all. Jarred executed this with an orchestrator script that invoked the compiler once across the whole workspace. “Fixer agents” then ran through the error list in parallel with adversarial review. The build runs again, rinse and repeat.

Reviewing the error list is helpful to catch systemic issues that may require adjustments. For example, Jarred ran into thousands of Rust module errors that surfaced after fixing cyclic imports that Zig's lazy compilation tolerated. He fixed the loop by encoding logic to classify which dependence to delete, move, or restructure the boundary.

Step 5 also has a mechanical source of truth similar to the compiler error list: crashes from the smoke test. Again, the loop fix was to group issues into categories, in this case grouping causes by root cause that are reviewed by adversarial subagents.

Step 6 and the end of our story is comparing the programs’ behavior across the two codebases.

Our files have now been translated, compiled, and smoke tested. Now it's time to shard them and run the test suite (from the prerequisite stage) against them. Tackle failures with "fixer agents" that review the failed tests against both codebases. Adversarial reviewers check their fixes.

The next stage in this loop is a build daemon, which is the only process allowed to rebuild the binary. Fixers write patches; the daemon batches them, rebuilds once, re-runs the affected tests, and feeds the results back. This serializes the most expensive operation instead of letting multiple agents trigger it independently.

When the same failure repeats across many tests, the fix moves upstream: you amend the rule that produced the bug and regenerate only the files that rule touched.

Mike's approach matters here, because many developers won't have a built-out or ported test suite. Mike had Claude create a small script to run 7 real-world scenarios against both the new port and the original Python codebase, and diffed the results. Each failing scenario got its own fix agent, and the loop ran until all seven passed.

Then he went one step further. Claude designed its own end-to-end test suite and ran it autonomously overnight, fixing what broke and re-running four nights in a row. As a result, it caught the paper cuts no scenario list would have predicted.

The lesson is that a missing test suite doesn't block this step. If you can't inherit a referee, have Claude build one. Your original codebase is the ground truth either way.

Code migrations best practices

Every run taught us something the previous one didn't. It’s a safe bet your next migration will teach you things this guide can't. But a few practices held up across every project:

  • Don't follow this guide blindly. Each migration is different. Treat this as a starting point, and plan your specific migration with Claude before committing to it.
  • Don’t focus on individual failures. Individual failures are the loop's job. Fixer agents burn those down. Your attention belongs on the patterns.
  • Make review adversarial and verification mechanical. Adversarial review allows for longer running tasks and is often worth the token consumption. Let scripts — a compiler, a diff, a test suite — be the referee.
  • Don't use the largest model for everything. Token spend concentrates in your loops, so design them deliberately. Smaller models handle the high-volume implementation fan-out well; save your largest model for reviewers and for anything that writes rules other agents will follow.
  • Front-load the human hours. The rulebook and the stress test are the most time-consuming. Everything after is mostly queues burning down.
  • Make the work queue mechanical and resumable. Done should mean "the output file exists on disk."

Review loop results, not code

Jarred’s Bun migration is now in production, although every migration has tradeoffs. For example, about 4% of the Rust code sits inside "unsafe" blocks, mostly single-line pointer operations at C/C++ boundaries.

But the new codebase is measurably better. Every memory leak the team's tooling can detect has been fixed: one benchmark of 2,000 repeated builds dropped from 6,745 MB of memory to 609. The binary is 19% smaller on Linux and Windows. And cross-language optimization made it 2–5% faster across HTTP serving and real-world workloads like next build and tsc.

Consider whether it’s time to re-run the math of your long deferred migration. Pick the codebase you've been tolerating and ask Claude what the migration process looks like for it.

Related

‍