用智能体把 83 万行 TypeScript 重写成 Rust:一次 14.5 周迁移的九个机制
原文:GitHub Blog – 《Migrating the GitHub Copilot runtime to Rust, using Copilot》(https://github.blog/ai-and-ml/generative-ai/migrating-the-github-copilot-runtime-to-rust-using-copilot)
GitHub 工程师 Stephen Toub 在 9 月 16 日发了一篇 65 分钟阅读量的复盘:他们用 GitHub Copilot 应用和 Copilot CLI,把 Copilot agent runtime 从 TypeScript / Node.js 全量重写成生产级 Rust,AI 智能体写了其中大部分代码,整个过程拆成 128 个 PR 增量合入 main,边做边发版。
我在上一篇文章里写过用智能体迁移 Fortran 老代码的复盘,那篇的重点是"校验先行"。这篇的重点不一样:它是一份关于"怎么把一个正在服役的大型服务原地换掉"的施工方案——原系统不能停,新代码要能顶上去,中间还得容得下两种语言共存。
对做 AI Agent 的人来说,这篇文章的价值不在于"83 万行"这个数字,而在于它把一次大规模迁移拆成了九个可以单独拎出来复用的机制。下面逐个拆。
一、先看这组数字
先把体量说清楚,因为后面的所有取舍都跟体量有关。
到 8 月 21 日迁移结束时,runtime 已经是 100% 生产 Rust:832,378 行生产 Rust 代码,外加 468,689 行 Rust 单元测试,以及 174,675 行 E2E TypeScript 测试。独立的 GitHub Copilot SDK 仓库另外增加了约 130,000 行 E2E 测试,覆盖 Node.js、Python、Go、C#、Rust、Java 六种语言。
过程数据更能说明问题。移植窗口是 5 月 12 日到 8 月 21 日,约 14.5 周,期间向 main 合入 128 个移植 PR,main 发布了 135 个版本(100 个预发布 + 35 个稳定版)。作者最初的估算是 runtime 约 130,000 行 TypeScript,后来发现实际有约 430,000 行生产 TypeScript 流经了移植过程——低估了三倍多。
还有一个反直觉的数字:移植期间 runtime 接收了约 300,000 行 TypeScript,却移除了 430,000 行;同时约 1,200,000 行生产 Rust 进入、365,000 行离开。进来的比留下的多得多,说明这不是逐行对译,而是边迁边重构、边删冗余。
二、机制一:原子替换,而不是大爆炸或双版本并行
面对"全量换语言"这件事,一般有三条路:一次切过去(big bang)、新旧两套并行维护、或者逐块增量替换。作者明确选了第三种,原文的说法是 each piece is flipped atomically from TypeScript to Rust——每一块从 TypeScript 原子地翻转到 Rust,靠剩余 TypeScript 与新 Rust 之间的互操作接口保持连续性。随着时间推移,生产 runtime 里 TypeScript 越来越少、Rust 越来越多,直到有一天没有 TypeScript,只剩 Rust。
这个选择换来两个关键性质:
其一,main 分支始终可发布。原文写得很直白——the runtime’s main branch is always shippable。没有"冻结期",也没有一个所有人都要等着的大切换日。
其二,回滚粒度是单块,不是整体。如果某个组件翻过去之后出问题,受影响的范围就是那一块,而不是整个 runtime。
代价是必须先把互操作桥修好,也就是后面机制三要讲的部分。这是一个典型的前置投入换过程安全的交易。
三、机制二:从叶子向内,先立测试模式再翻译
移植顺序不是按代码目录来的,而是按依赖方向来的。作者的规划是从叶子向内(leaves inward):先做纯 helper、内容排除逻辑、shell 工具、会话文件系统操作,用这些建立翻译和测试的范式;然后做有状态的子系统;工具、hook、模型客户端和 MCP 建立在这些基础上;会话编排这类最核心的部分放到最后。
这个顺序背后有个很实在的理由:先迁没有共享状态、有强测试覆盖的纯逻辑,因为它们的正确性可以被测试单独证明。等到迁有状态组件时,测试基础设施和回归判断标准已经跑通了。
起手动作也很轻。前两个 PR 只做地基——建立 Rust workspace、工具链、lint 规则、CI、构建流水线和编码指令,没有业务代码。第三个 PR(第一个真正的移植 PR)只挑了三个无副作用的 helper 走完整的端到端流程,作为 pilot。
原文还有一句值得记住的总结:有用的单元往往不是"组件",而是"波次"(waves)。也就是先纯逻辑波、再状态所有权波、再编排波、最后删回退代码并简化 Rust。按波次推进而不是按模块推进,这跟大多数人的直觉是反的。
四、机制三:临时 napi 门与永久 C ABI 门
两套语言共存,接口设计是成败关键。这篇文章把互操作拆成了两层。
第一层是临时的内部互操作,用 napi-rs。Rust 函数标上 #[napi] 就会生成 N-API glue 和 index.d.ts;async fn 自动变成 Promise;标了 #[napi(object)] 的类型自动变成普通对象;Rust 要回调 TypeScript 就用 threadsafe functions。这一层只在移植期间存在,目标是把调用点全部收口。
规模有多大?8 月 3 日互操作峰值时,内部有 2,019 个 N-API 导出和 3,356 个 TypeScript 调用点。迁移完成时,这两个数字都归零。
第二层是永久对外表面。SDK 原本通过 JSON-RPC 双向契约与 headless CLI 子进程通信,移植后 runtime.node 变成共享库,同时提供两扇门:给 CLI 用的 napi 门,和给各语言 FFI 加载用的 C ABI 门。C ABI 门只暴露 19 个导出函数,按 dispatch 方式设计,把 364 个路由(340 个供 SDK 消费方调用,24 个是 runtime 回调 SDK)统一收进来,JSON-RPC 字节在进程内传输,避免了进程跳跃。
这里的工程判断很关键:内部接口可以宽,外部接口必须窄。临时互操作层允许 2,019 个导出随便用,永久对外契约压缩到 19 个函数。这一点对做 Agent 平台的人尤其重要——你的工具面越窄,后面改起来越自由。
顺带一个能直接抄的规则:napi 导出只要做实事就必须是异步的,必要时用 spawn_blocking。原因是 CLI 是单线程事件循环,同步跨边界会冻住界面。
use napi_derive::napi;
// #[napi] 会为这个函数生成 N-API glue 与对应的 index.d.ts 类型声明
#[napi]
pub async fn reindex_sessions(root: String) -> napi::Result<u32> {
// 重活必须挪出事件循环线程,否则会阻塞 CLI 的 UI
let n = tokio::task::spawn_blocking(move || {
do_heavy_reindex(&root) // 同步解析数百个文件
})
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(n)
}
五、机制四:十类回归,这才是 Agent 迁移真正的坑
作者把移植期间出现的回归整理成了十类,截至 9 月 14 日全部已修复。这张表我认为是全文最值得抄的部分,因为它直接告诉你"让智能体逐行翻译"会在哪里失手。
| 语义模糊 | TS 的 number 变成 Rust 的 f64,整数 42 被写成 42.0,Go 和 C# 端解组失败;字段声明成 i64 但流式传出 5446.712845,会话直接不可读 |
| 环境隐式行为 | toLocaleDateString 的时区没显式传入;Intl 时区解析可能返回 undefined 而破坏 napi 转换;Windows 上取进程报告会按环境变量去下载 PDB,卡住好几分钟 |
| 只迁了一半 | turn-cap 检查更新了 abort 状态但没取消进程内的模型循环;任务完成状态持久化了但没投影到活动状态 |
| 阻塞主线程 | 索引重建同步解析数百文件,界面卡了近一分钟 |
| 控制台窗口闪现 | Windows 子进程漏了 CREATE_NO_WINDOW,Node 版本曾用 monkeypatch 藏住,Rust 里忘了这一层 |
| 生命周期管理 | 这是最大的一簇:hook 中途 dispose 留下孤儿 tool_use 把对话楔住;shell 取消没清干净;沙箱切换只更新了一个计数器 |
| 功能遗漏 | 漏迁 SDK 回调还顺手删掉了对应 E2E 测试;会话中止丢了进程内取消;工具搜索被替换后把三种搜索降级成了一种正则搜索 |
| 依赖库行为差异 | Rust 的 MCP SDK 对畸形 JSON-RPC 的响应方式不同,导致启动时无限循环挂住 |
| 分支漂移 | 每周数百个 PR,分支互相漂移导致 rebase 失败 |
| 变慢 | 丢失记忆化等优化,功能正确但性能退化 |
看完这张表会发现一个规律:真正难错的不是 Rust 语法,而是那些旧代码一直在默默依赖、却没人写下来的行为。第一类是语言层面的数值与时区细节,第二类是平台的隐式默认值,第三类是一对操作只迁了一半。
所以智能体迁移的验收标准不能是"能编译",也不能只是"测试通过"。作者的做法是让专门的审查子代理做旧 TypeScript 与新 Rust 的逐行比对,确认行为等价,并要求删干净废弃的 TypeScript 代码、保住 E2E 测试不被改动。
六、机制五:人类的位置叫"控制回路",不叫"派活等结果"
这篇复盘最有意思的数据是会话日志。整个移植过程产生了 12,760,995 个事件、31,247 条用户消息、1,385,214 条助手消息、1,857,409 次工具启动;跑了 23,096 次编译命令、19,485 次测试命令;完成了 5,116 次上下文压缩。全部工具调用 1,130,921 次,其中 61% 来自子代理。
而作者本人手写或口述的消息只有约 2,600 条,占 31,247 条用户消息的十二分之一左右。这 2,639 条人类消息的意图分布是:31.0% 是审查 / 测试 / CI,17.4% 是在挑战技术或设计决策,15.0% 是在推着智能体把活做完。
这个分布非常说明问题。人类的工作量不在于写代码,而在于三件事:验证产出、质疑决策,以及识别"智能体把一个中间状态当成了终点"。作者的原话是,他的角色更接近 operate the control loop——检查结果、挑战技术决策、执行质量门、在智能体把中途停车点当成终点线时推它一把。
他还纠正了一个流行的想象:AI 哗哗吐代码的画面几乎是反的。在这个规模上,工作看起来更像是迭代式调查——检查当前状态、形成假设、做一次有针对性的修改,然后重复。
七、机制六:并行会话必须有裁决者
并行跑多个会话很爽,但作者踩了一个坑并写进了复盘。
当时有一个会话用了编排技能,擅自去抓取了另一个会话正在做的 session.ts 的 760 个文件 diff 并合并进来。作者说根因在他自己——提示里没有明确禁止,只是给会话起了名字,但没写"这块别碰"。由此得出的规则是:要显式声明边界,而不是靠命名暗示。
更根本的问题是:两个会话覆盖相邻代码时,谁说了算?原文的结论是 peers need a tiebreaker——并行的相邻会话必须指定一个协调者或者一个人类,否则任何一方都无法约束另一方,会出现单方面合并。
这条对做多智能体编排的人几乎是必踩的。并行度越高,越需要显式定义"谁有合并权"。
八、机制七到九:三条不显眼但很关键的约束
剩下三条约束不显眼,但每一项都直接决定迁移能不能持续。
机制七是 E2E 测试对智能体只读。因为有一次移植顺手删掉了 SDK 回调相关的 E2E 测试,团队立刻立了规矩:智能体不得在未经明确同意的情况下改动 E2E 测试。这个约束的道理很简单——测试是智能体唯一的"地面真值",一旦允许它为了通过而改测试,整个验证回路就失效了。
机制八是提示缓存前缀必须稳定。整个过程的提示缓存命中率是 96.22%,缓存写 3.07%,全新输入只有 0.71%。要做到这一点,prompt 的结构必须保持稳定顺序:system prompt → 工具定义 → 对话历史。这既是成本问题,也是稳定性问题——缓存命中率高,反过来证明上下文没有被频繁打碎。
机制九是 Agent Merge 加人工抽查。内建的 Agent Merge 循环处理了全部 128 个移植 PR,自动修 CI 失败、处理评审意见和合并冲突。但作者保留了抽检,并且真的抓到了问题:有一次代理错误地加上了一个允许破坏 schema 的标签,把 schema 丢失掩盖了过去,作者发现后要求复原。
所以"自动化合并"和"自动化放行"是两件事。前者可以完全交给智能体,后者不行。
九、代价清单:这套打法不是免费的
把好处说完,也要把代价列清楚。
第一是 unsafe 没有被消灭。最终代码里有 158 个 unsafe 块,分布在 36 个文件,另有 26 个 unsafe fn、26 个 unsafe extern、9 个 unsafe impl。分布是:C ABI 占 32.3%,Windows API 占 31.0%,POSIX/libc 占 29.1%,SQLite C API 占 4.4%,动态库加载占 2.5%,进程环境占 0.6%。作者的解释很到位:每个 unsafe 块都标记了 Rust 保证真正结束的地方——对面是一个 C 函数、一个系统调用、一个来自外部运行时的指针,或者进程级的宿主状态。
第二是性能提升的说法是定性的。原文只说 runtime 性能提升了 orders of magnitude,没有给出具体基准数字。之前每个 SDK 消费者至少要 100 MB 工作集,因为里面塞了第二个运行时 Node/V8,还要跨边界做序列化;改成 Rust 后变成原生进程内嵌入。方向是清楚的,但具体倍数没有公开数据,以官方文档和后续基准为准。
第三是进程事故。15 个并发代理同时构建和测试,直接把作者的笔记本跑挂了,只能由父会话广播停止构建。
最后也是最重要的一条边界,作者自己写得很清楚:这些智能体并没有消除对"理解系统、能为方向、护栏和发布背书"的工程师的需求。而且他明确说,选 Rust 不代表每个大型 TypeScript 程序都应该变成 Rust。
十、把九个机制落成一份自检清单
前面拆的九条,可以收敛成一份可以直接用的迁移自检表(下面这段是自拟示意,不是原文里的脚本,只是把上面的机制翻译成可执行判断):
# 自拟示意:单个移植 PR 的原子替换自检,非官方代码
REGRESSION_TAXONOMY = {
"ambiguous_semantics": "数值精度/整数与浮点/时区,逐字段核对序列化结果",
"ambient_behavior": "平台隐式默认值,如进程报告、控制台窗口、locale",
"half_ported_pair": "凡是成对出现的操作,检查两半是否都迁过来了",
"blocking_main_thread": "重活是否走了 spawn_blocking,事件循环有没有被占住",
"lifecycle": "hook / 取消 / 沙箱切换是否清干净,有无孤儿对象",
"overlooked_feature": "对照旧模块的导出清单,确认零遗漏且测试未被删改",
}
def review_port_pr(diff, e2e_before, e2e_after):
checks = []
# 1. 旧实现必须已被删除,不能留下两套逻辑并存
checks.append(("ts_deleted", not diff.remaining_typescript_impl))
# 2. E2E 测试只增不减、不被修改(硬约束)
checks.append(("e2e_intact", e2e_after.includes(e2e_before)))
# 3. 逐行语义比对由独立子代理完成,不由写码的同一会话自证
checks.append(("semantic_diff_done", diff.reviewed_by_peer_subagent))
# 4. 六类回归各留一个断言,缺哪类补哪类
for kind in REGRESSION_TAXONOMY:
checks.append((f"guard_{kind}", diff.has_assertion_for(kind)))
return checks # 任一项 False 就不进 main
这份清单里最容易被忽略的是第二条和第三条。第二条保证验证回路没被污染,第三条保证"谁写的"和"谁验的"不是同一个会话。
十一、收束
这篇复盘能带走的其实是一句听起来很普通的话:移植代码很容易,让它正确很难。原话是 porting code is easy, making it correct is hard。
在这个案例里,“correct” 不是靠更强的模型做到的,而是靠一组看起来很朴素的工程约束堆出来的:原子替换保证任何时刻都能发版;从叶子向内保证测试范式先立起来;互操作桥把内部宽接口和外部窄接口分开;十类回归清单把"没人写下来的旧行为"显式化;E2E 测试对智能体只读;提示缓存稳定保证上下文不碎;并行会话配裁决者;自动合并配人工抽检。
还有一个细节我很喜欢。作者说 Rust 那个"所有人都说难"的借用检查器,在整个过程里其实是个安静的存在(a quiet background presence)——静态分析记录显示,rustc 报错的 8,678 次里,名称解析占了 37%,缺失方法或字段占 22%,类型不匹配占 14%,trait bound 占 11%,而所有权、借用、生命周期加起来只有 1.7%。cargo check 的运行有 87.1% 一次干净。
也就是说,在智能体写代码的场景里,真正的成本不在语言的高级特性上,而在接线错误上。哪一层该谁调、字段叫什么、返回什么类型——这些"没技术含量"的地方,才是消耗迭代次数的地方。
从面试角度说,如果被问到"怎么用智能体做大规模代码迁移",能讲清楚"原子替换 + 叶子向内 + 临时互操作桥收口 + 回归分类清单 + 控制回路式人工角色"这条主线,比说"我们用 AI 写了几十万行"要有说服力得多。





