将GitHub Copilot运行时迁移到Rust,使用Copilot
在智能体出现之前,这种规模的改写是负担不起的。以下是将Copilot智能体运行时移植到80万行生产级Rust代码实际需要付出的代价。
中文
复制

GitHub Copilot CLI、GitHub Copilot 应用和 GitHub Copilot SDK 背后都是 Copilot agent runtime——一个可嵌入应用和服务的 agent 运行框架。它最初用 TypeScript 编写,运行在 Node.js 和 V8 JavaScript 引擎上,支撑着如今的 GitHub Copilot cloud agent(CCA);随着 runtime 及其能力快速扩张,它始终留在这套技术栈上。
现在情况变了。借助 GitHub Copilot 应用和 Copilot CLI,我们将 runtime 完全重写为 80 多万行生产级 Rust 代码。大部分代码由 AI agent 编写,横跨 128 个 pull request,陆续合入 main 并分批发布,而非等到最后一次性切换。过程中不可避免出现的少数回归问题都被及时发现并修复,同时 runtime 的性能提升了好几个数量级。在 agent 出现之前,这样的项目需要一整个开发团队花上一两年;这次主要由一位开发者完成,只用了几个月,而团队其他成员在此期间仍在持续大幅扩展 runtime 的能力和覆盖范围。
为什么要移植
Copilot agent runtime 不只是 Copilot CLI 背后的引擎。它支撑着越来越多的 Microsoft、GitHub 及生态解决方案,从架构上看,每一个方案的 AI 支持都是围绕同一个 runtime 加一层外壳,再加上该方案所需的定制。这其中包括 GitHub Copilot CLI 和 GitHub Copilot 应用,也包括最新版本的 VS Code、Visual Studio、CCA、Copilot Code Review(CCR)、Copilot Cowork、Copilot Studio,以及 Excel、Outlook、PowerPoint、Word……还有更多。
这些产品差异极大,没有一个愿意、也不应该去实现生产级 agent 框架所需的全部东西。它们想要的是完整的智能、安全、可靠性和性能,并且希望这些能力共享——一处修复,处处受益。上一段列出的产品大多最初各自实现了自己的 agent loop,但后来都换成了 GitHub Copilot SDK,也就是进入 Copilot agent runtime 的入口。这样一来,它们就能专注于自己的核心业务价值,把细节交给 runtime。考虑到行业的节奏,以及所用的 agent loop 必须在激烈竞争中始终保持最优,这一点尤为重要。
所以,共享运行时本身没问题,问题在于被共享的东西究竟是什么。
从逻辑上讲,CLI 就是架在 agent loop 之上的一层终端 UI(TUI)。但实际情况是,整个技术栈都用 TypeScript 实现,框架是 Node.js,执行引擎是 V8,UI 则基于 Ink 和 React。对于一个 TUI 应用来说,这算是相当体面的选择:TypeScript 和 Node.js 门槛低、易上手,应用开发速度极快。就控制台应用的需求而言,它在启动、响应、吞吐和内存占用方面的表现也算合理。但如果你要把这套实现搬到别的环境里,面对别的约束——比如要求快速启动,要求低内存开销以支撑更高的服务器密度——那就不太合理了。
CLI 的架构和它的运行时也让问题更难办。整个行业跑得飞快,在这种环境下,聪明人做决策时优先考虑的是交付速度和市场覆盖。Copilot CLI 最初写得快、发得也快,TUI 和运行时因此缠在一起,没有拆成独立的层。后来需要 SDK 来以编程方式访问这个运行时,由于层与层之间没有清晰分离,务实的做法是把 SDK 架在 CLI 之上——尽管理论上你期望的架构应该反过来。CLI 不再只能通过用户在命令行输入的命令来访问,而是新增了一种模式,可以 headless 运行,从 stdin 读取类似的命令,把响应写到 stdout。这样就可以用 JSON-RPC 协议在外部进程和 CLI 之间来回传递函数调用。SDK 于是可以嵌入任意调用方程序,由后者 spawn 一个 CLI 进程,把 agent loop 托管在进程外,SDK 通过这套 JSON-RPC 机制调用远程进程里的函数。很巧妙。出门快。灵活。但对那些调用方应用的性能(启动、内存、吞吐)和可靠性来说,并不好。从 SDK 里新建一个 CopilotClient,就意味着再 spawn 一个进程:
const client = new CopilotClient();
await client.start(); // spawns the CLI as a subprocess
const session = await client.createSession({
/* ... */
});
这个过程需要启动并托管 Node 和 V8。这意味着要解析 CLI 中由 TypeScript 代码生成的大量 JavaScript,为其生成字节码,还可能在后续 JIT 层级中对热点代码做优化。意味着要承担 V8 带来的全部内存开销。意味着要继承 Node 的线程模型——默认情况下,这会迫使我们把所有 CPU 密集型工作串行化。也意味着仅仅为了发起函数调用,就得强制走进程间通信。意味着每一个 SDK 使用者,无论用哪种语言,都要附带 Node.js 或一个内含 V8 的打包二进制。意味着 C#、Python、Go、Java 和 Rust 的 SDK 每个客户端都要多付出一整个第二语言运行时,工作集至少 100 MB 起步,而应用程序本身根本用不到这个运行时。意味着每一个事件、每一条消息、每一次对抽象会话文件系统的读写,都要跨进程边界传递。意味着 Node 一崩溃,会话也跟着完蛋。还意味着任何部署它的人,至少有两个进程要监管、监控和调试。
我们想要的运行时是这样的:
- 不包含 TUI,TUI 是它自己的库,TUI 以及其他应用和服务可以干净地分层构建在其之上。
- 用依赖极少、开销极低的语言实现。
- 实现方式要能干净地嵌入进程内,而不是被迫走进程外。
- 用一门在性能、可扩展性和可靠性方面都具备顶尖特性的语言实现。
- 用一门互操作性出色的语言实现,从而能被全部六种 Copilot SDK 语言版本(C#、TypeScript、Python、Rust、Go、Java)通过各自技术栈的外部函数接口(FFI)机制干净地调用。
- 实现所用的工具链要提供更现代的安全态势,供应链风险更低,对构造即正确(correct-by-construction)代码的支持更强。
基于以上所有理由,再加上一些不那么硬性的理由(比如团队经验和行业方向),我们选择了 Rust。这绝不意味着每个大型 TypeScript 程序都应该改写成 Rust。我们的需求强调的是通过 C ABI 嵌入、低启动开销和低稳态开销,以及可预测的资源占用。Rust 让这些目标成为可能,代价是带来了其他麻烦,比如我们必须显式表达生命周期和共享状态(后文讨论的生命周期回归问题就凸显了这一点的后果)。合适的宿主语言因应用而异,这是理所当然的。
随后展开了两项相关的关键工作:
- 将 TUI 专属代码与 runtime 分离,让前者严格叠加在后者之上,更准确地说,是严格叠加在 SDK 的公开接口之上。目前 CLI 仍在多处直接调用 runtime 内部实现;将其完全迁移到 SDK 接口之上仍在进行中。
- 将该 runtime 层完全移植到 Rust,最终得到一个纯原生二进制文件,对外暴露 C ABI 供所有语言前端在进程内调用,同时保留基于 stdin/stdout 或 socket 的服务器,以便在仍需跨进程时使用。
本文主要讲第二项:把 runtime 移植到 Rust。
之前是什么样子
2026 年 5 月初的初步移植计划估算 runtime 大约有 130,000 行 TypeScript。就划定范围而言,这个初始测量相当准确,但事实证明,它在两个关键方面也极具误导性。在移植的同时:
- 仍裹在 TUI 层里的部分被不断下推到 runtime 层。一些完整组件,以及最初在估算中被忽略的相当大比例的代码,后来都被认为与移植相关。
- 持续贡献大量新 TypeScript 的 pull request 不断推高仓库中的 TypeScript 总量。数十名借助 agent 的开发者每周合并数百个 pull request。
把所有因素算进去,我估计最终有大约 430,000 行生产环境 TypeScript 经过了这次移植。这些同样的因素也让人难以看清过程中的进展:直到接近尾声时,生产环境 TypeScript 的规模看起来仍相对稳定,甚至略有增长,因为移植的速度只是勉强跟上新代码的涌入。
这里的情况更为复杂,因为同一时间段内还有与移植无关的 Rust 代码在持续合入;在移植初期,合入的代码更可能以 TypeScript 为主,而到了后期,则更可能以 Rust 为主。
移植期间,运行时接收了约 300,000 行生产 TypeScript 代码,同时移除了约 430,000 行;而生产 Rust 代码合入了约 1,200,000 行,移除了约 365,000 行。换句话说,上图中 TypeScript 行数看似稳定,实际上掩盖了大量 TypeScript 的增删变动。
原地移植策略
这张图也凸显了此次移植方式的一个重要特点:原地进行。
这种规模的改写主要有两种思路:
- 大爆炸式。 新的 Rust 运行时作为完整的替代方案开发,就绪后一次性整体切换。这种大爆炸式切换有两种变体。a. 全面停工。 在改写期间,所有人停止
main分支上的其他工作,改写全部在main中完成。b. 并行开发。 改写在一个功能分支上进行,主分支继续正常开发,改写方需要不断跟进并合并主分支的变更。 - 原地式。 按组件逐个移植,运行时被一块一块地增量改写。这种原地方式同样有两种变体。a. 原子替换。 每个部分从 TypeScript 原子性地切换为 Rust,剩余的 TypeScript 与新 Rust 之间通过互操作保持连续性。随着时间推移,生产运行时中 TypeScript 越来越少,Rust 越来越多,直到某一天不再有 TypeScript,只剩 Rust。b. A/B。 组件移植后并不删除,而是将 TypeScript 和 Rust 两个版本都保留为可热插拔的选项,待信心趋于稳定后再删除 TypeScript。
我们选择了方案 2a,原因有很多:
- 没有人会被迫停工。 主分支保持活跃。每个不直接参与移植的开发者都可以照常工作,唯一会受到影响的情况是:自己手上挂了很久的 pull request 恰好碰到了正在被并发移植的代码,这时他们需要 rebase,并让自己的 agent 帮忙只移植自己尚未合并的改动。
- runtime 的主分支始终可发布。 每个 pull request 都用一层薄薄的 shim 替换掉原有的 TypeScript 实现,由 shim 调用 Rust,并在一次原子改动中删除旧代码。新代码立刻就在真实环境中被跑起来。
- 重写是增量的,也是可审查的。 每个 pull request 只移植一个组件或一个切片,改动范围更小,diff 也更容易审查——无论是人、agent,还是两者一起。
- 大多数移植规模适中、自成一体,与并发 pull request 之间的漂移因此被压到最小。有些 TypeScript 组件实在太大,那就先把它们重构成更容易移植的组件。
- 所有现有的端到端测试,覆盖 CLI 和 SDK,每一步都会跑在新的 Rust 代码上,给我们信心,也带来大量验证。如果某个 pull request 导致必需的测试失败,它就不会被合入。
我们也避开了方案 2b 的变体,也就是同时维护同一组件的多个版本。过去几个月里,每周有数百个 pull request 合入仓库,代码库一直在快速演进。同一份代码用两种语言、两套依赖库维护两个版本,会带来大量复杂度。而且有些组件并不是完全隔离的:有些在逻辑上独立,对外提供简单的 API 供系统其余部分调用,另一些则伸出大量触手,想让这张图按组件热插拔简直是噩梦。最有可能从谨慎的并行切换中获益的子系统,恰恰是并行最难做的那些。比如 session orchestration 并不是一个纯函数,没法靠某个实验开关用 if/else 调用两个不同版本。它持有可变状态,双向驱动回调,还贯穿几乎所有其他子系统,所以“两个都跑、再对比”意味着要维护两份分叉的副本——而这份组件承载着整个对话的状态和服务——还得指望它们在数百次并发编辑中始终保持同步。让一个组件难以移植的耦合,也正是让它几乎无法影子运行的那种耦合,硬做只会引入比避免掉的更多的回归。能以这种方式切换带来的好处,主要是获得信心,而信心我们可以用别的方式拿到。
验证同样通过渐进式发布来完成。如果采用一次性切换,我们就得把所有改动集中在一个长期分支里,移植整个运行时,然后一次性切换。这意味着使用者会一次性接触到全部移植后的代码,包括仓库内测试未能发现的所有回归。而把改动分批逐步推出——这里两个组件、那里一个组件——让我们能在已部署的构建中完成最后一公里的验证,接触真实使用者(多数情况下是 Microsoft 和 GitHub 内部的第一方用户),同时将回归风险压到最低。在约十四周半的移植窗口期内,main 发布了 135 个版本,其中包括 100 个预发布版本和 35 个稳定版本,平均每天约 1.3 个版本。每天也大约有 1.3 个移植 pull request 被打开,因此每个版本携带的移植组件数量少且明确(我们通常尽量先通过预发布版本发布移植,但并非总能做到)。在连续七天的 npm 抽样中,预发布版本仅占下载量的 10.5%,说明最初的暴露面相对有限;我们在此期间持续监控反馈渠道中是否有异常信号,并在下一个预发布版本中迅速修复。上报的问题更容易与已知的近期改动关联起来,也更容易定位根因并快速修复。这样一来,用更长时间渐进式地完成移植反而成了优势而非阻碍(也就是说,更快并不总是更好)。到 8 月 21 日,运行时已 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。
起步
在全面投入之前,我们先建立信心、验证可行性。最初的两个 pull request 搭好了 Rust workspace、工具链、lint 规则、CI、构建流水线和编码规范,随后引入 runtime crate,以及代码生成和互操作模式,同时移植一批纯逻辑原语——挑选它们的理由很明确:没有 I/O、没有共享状态,而且已有完善的测试。等这些落地之后,第一个正式移植 pull request 才把三个无副作用的 helper 走完整套流程。它们相当于出货前的试运行,把关于仓库布局、FFI、打包、测试和评审的假设固化成约定,供后续规模大得多的移植复用。说白了,我们把整套机制端到端跑通了一遍。计划继续按从叶子到根的顺序推进:纯 helper、内容排除、shell 工具、会话文件系统操作,先确立翻译和测试的模式。接着是有状态的子系统,工具、hooks、模型客户端和 MCP 都建立在这些基础之上。会话编排——runtime 中耦合最重、也最不适合并行的一块——留到接近尾声。
| 时间段 | Pull request 数 | 变更行数中位数 |
|---|---|---|
| 5 月 1–15 日 | 8 | 3,250 |
| 5 月 16–31 日 | 2 | 9,421 |
| 6 月 1–15 日 | 40 | 5,073 |
| 6 月 16–30 日 | 31 | 8,253 |
| 7 月 1–15 日 | 10 | 9,514 |
| 7 月 16–31 日 | 14 | 28,159 |
| 8 月 1–15 日 | 19 | 13,861 |
| 8 月 16–30 日 | 4 | 99,445 |
早期的移植都是小的叶子组件,推进很快。但更大的子系统不是一步到位完成的:比如 MCP 支持经过七个专门的 pull request 才成型,工具则是一个六部分的系列,之后还需要额外的工作来迁移编排、清掉剩余的 TypeScript。hooks、auth、telemetry、plugins、settings 和持久化也走了类似的路。
实际上,移植的有效单位并不总是“一个组件”。它往往是一波牵动多个相关行为区域的改动:先搬纯逻辑,再搬状态归属,然后搬编排,接着删掉回退路径,最后在临时互操作层消失之后简化 Rust 代码。
互操作
这次移植中涉及互操作的主要有两层:
- 临时的内部互操作。 每当一个函数被移植到 Rust,它就得能被那些调用原 TypeScript 函数的 TypeScript 代码调用。同样,我们也需要让 Rust 函数能调用 TypeScript 回调。这层互操作属于实现细节,变化极其频繁。随着 Rust 内部接口面积扩大,需要的 TypeScript 垫片数量也随之增加,因为凡是需要从 TypeScript 调用的 Rust 方法,都对应一个垫片,一一对应。当这些调用方被移植到 Rust 之后,原有的那层垫片就被删除,再换上新的垫片。最终我们会抵达运行时库的公开入口点,垫片也就随之消失。
- SDK 接口层。 所有 SDK 库都需要能架在运行时之上,把它的功能暴露出来。在移植之前,做法是让运行时通过一个双向 JSON-RPC 层暴露,SDK 把函数调用请求作为 JSON-RPC 方法调用负载发出去,运行时解析请求、调用相应 API,再通过同一传输通道把结果发回来,由 SDK 解析并返回。反方向同样存在:运行时需要能回调 SDK 客户端,比如用于 hook 通知和权限请求,这些在 SDK 客户端里以回调形式出现,具体用什么语言特性则视该语言的惯用法而定(例如 C# 中的委托)。
我们借助 napi-rs 项目的 napi Rust crate 实现了第 (1) 点,这个 crate 就是用来在 Rust 里构建 Node 原生插件的。给函数加上 #[napi] 注解,napi-rs 的宏就会生成 N-API 注册胶水代码,让该函数可以从 JavaScript 调用,同时在生成的 index.d.ts 里为它生成一份 TypeScript 声明。同步的 Rust 函数会变成普通的 JavaScript 函数,async fn 会变成返回 promise 的 JavaScript 函数,而标注了 #[napi(object)] 的 struct 在另一侧则变成普通对象。
流量还得双向走。许多已经移植的组件会临时依赖尚未移植的部分,于是 Rust 需要回调 TypeScript——比如 Rust 中的工具实现要向仍为 TypeScript 的模型层请求推理,或者触发一个 hook,或者为它想执行的命令申请权限判定。napi-rs 用“threadsafe functions”解决这个问题:运行在 Tokio worker 线程上的 Rust 代码可以回调 Node 主线程上的 JavaScript 函数。Node 安装一次回调,Rust 持有它,每次需要反向调用时就用它。每一个这样的回调从构造上就是临时的:它存在只是因为另一端还是 TypeScript,等那一端移植完,它就被删掉。
这个临时接缝在 8 月 3 日达到峰值:2,019 个内部 N-API 导出,3,356 个 TypeScript 调用点。到完成时,运行时已完全是 Rust,因此不再有内部互操作:0 个临时内部 N-API 导出,0 个 TypeScript 调用点。(前面提到 CLI 仍有一些对运行时的内部访问,我们正在移除;这些导出不计入此处。)
第二层互操作,也就是 SDK 表面,是两者中永久的那一层。Copilot SDK 面向六种语言发布:TypeScript、Python、Go、C#、Java 和 Rust。它们都讲同一套双向 JSON-RPC 契约,最初也都以同样的方式接入:以 headless 模式把 Copilot CLI 作为子进程拉起,然后通过管道或 socket 与它通信。移植期间这仍是默认方式。这也意味着,任何语言的 SDK 使用者都要随包发布或自行定位一份完整的 Node 实现,每个事件、每条消息都要付出一次进程跳转的代价,并且要管理两个进程而不是一个。
把运行时移植到 Rust,是让另一个选项变得可行的前提。发布出来的 runtime.node 就是一个普通的平台共享库(.node 扩展是 Node.js 原生插件的惯例,底下是 .dll、.so 或 .dylib),如今它在同一个引擎上开了两扇门。一扇是 napi 门,Node 进程以原生插件的方式加载它,这也是 CLI 走的路径(目前如此……将来的打算是完全走 SDK 路径)。另一扇是 C ABI 门,任何语言都能把它加载进自己的进程,通过 FFI 调用。同一个进程内运行时,由各语言各自的原生互操作机制来选用:
| SDK | 原生桥接 | 进程内客户端选择 |
|---|---|---|
| C# | P/Invoke | new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForInProcess() }) |
| Go | purego | copilot.NewClient(&copilot.ClientOptions{Connection: copilot.InProcessConnection{}}) |
| Java | JNA | new CopilotClient(new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess())) |
| Python | cffi | CopilotClient(connection=RuntimeConnection.for_inprocess()) |
| Rust | libloading | Client::start(ClientOptions::new().with_transport(Transport::InProcess)).await? |
| TypeScript | koffi | new CopilotClient({ connection: RuntimeConnection.forInProcess() }) |
Rust 重写与进程内、进程外托管之间的取舍是两个独立的维度。重写完成的 Rust 运行时两者都支持:它既能跑在 SDK 使用方的进程里,也能待在现有的 JSON-RPC 服务边界之后。这些进程内入口目前是选择性开启的,因为我们还在积累信心——毕竟这意味着要和调用方应用共用一个进程,也就共用了一个故障边界。传输层之上的一切仍是同一套 SDK API:会话、事件、工具、权限、回调,都不关心自己的 JSON-RPC 字节是穿过管道还是穿过一次函数调用。
第二扇门有意思的地方在于它的体量。它只导出 19 个函数:4 个用于服务生命周期,4 个用于会话注册与配置,8 个用于连接,3 个用于嵌入式宿主。在这些函数背后,共享契约目前包含 364 条分发路由:340 条可由 SDK 使用方调用,另有 24 条反向运行,作为运行时到 SDK 的回调。napi 门则大得多,每一条分发路由都需要对应的函数。C ABI 门是基于分发的:API 方法根本不导出,而是以 JSON-RPC 字节的形式写入连接,结果、事件和服务端到客户端的请求则通过宿主提供的回调返回。增加、修改或删除一个 API 方法,动的是引擎的分发表,永远不碰 ABI。SDK 只需绑定这 19 个入口点,就能通过它们动态触达整个仍在增长的 API 面。
这就引出一个显而易见的问题:一次调用已经不再跨越进程边界了,为什么里面还留着 JSON-RPC?
答案是,这样做让进程内托管成了即插即用,而不是重写。每个 SDK 本来就有可用的 JSON-RPC 客户端,具备分帧、请求与响应的关联,以及处理服务端到客户端方向的各种 handler。把 FFI 作为又一种传输方式挂到该客户端底下,字节路径就从管道或 socket 变成了一次函数调用,而它上面的一切原封不动。六个 SDK 以增量、可选的方式获得了进程内托管,现有传输方式没有任何改动。反过来,如果为每个 API 方法定义一个带类型的 C 函数,每个 SDK 都得再加一层绑定,每新增一个 API 方法就要多写六份绑定,ABI 也会变成一个必须做版本管理的二进制兼容面。
对于真正远程的运行时——无论是跨子进程边界还是走 TCP——我们同样需要 JSON-RPC。在进程内沿用同一套协议,意味着只需维护一套双向 API 和分发系统,而不是远程连接用 JSON-RPC、本地连接再搞一套按方法划分的 FFI 接口。这是实打实的取舍,不是不用想就能拍板的事。我们省掉了进程跳转,但每次调用仍要付 JSON-RPC 的开销。对以推理为主的工作负载来说,这点序列化开销相比模型往返通常微不足道。在高吞吐的本地负载下它仍然可测量,但眼下还不足以让我们在六个 SDK 绑定里把几百个方法各复制一遍。而且这个决定以后很容易改,真出现性能需求再调整就行。载荷编码是两端之间的私有细节,把 JSON 换成 MessagePack 之类更紧凑的格式,不会改动任何一个对外声明的导出。带类型的按方法导出以后也可以为热路径补上,调用同一个引擎、同一批 handler,无需替换字节通道——该通道仍是流式传输、服务端到客户端请求,以及那一大堆很少被调用的方法的底层,为这些方法定制导出没有任何收益。
会话数据说明了什么
本文中几乎每一个数字都来自两个来源之一。第一个来源是私有仓库 github/copilot-agent-runtime 的 GitHub 历史记录:pull request 及其 diff、review 评论、CI 运行等。第二个来源是 agent 会话日志。运行时(以及 CLI、应用等)会为它运行的每个会话写一份结构化事件日志:每行一个 JSON 对象,随会话进行追加写入。这些日志可能包含 prompt、命令、命令输出、文件路径,以及工具暴露出来的潜在密钥,因此必须按敏感数据处理。日志存放在会话运行所在的机器本地;远程会话功能在启用时也可以上传日志,具体取决于产品设置和组织策略。
以下是所有相关移植 pull request 的数据汇总:
| 指标 | 数量 |
|---|---|
| 事件 | 12,760,995 |
| 用户消息 | 31,247 |
| 助手消息 | 1,385,214 |
| Hook 开始与结束事件 | 6,438,562 |
| 工具启动 | 1,857,409 |
| 编译命令 | 23,096 |
| 测试命令 | 19,485 |
| Rebase 命令 | 2,496 |
| Commit 命令 | 7,410 |
| Push 命令 | 5,554 |
| 完成的压缩 | 5,116 |
这 31,247 条 user 角色消息并不是我亲手输入的 31,247 条 prompt;其中还包括 skill 指令、自动合并触发、跨会话消息和子 agent 流量,我自己输入或口述的大约只有 2,600 条,约占十二分之一。同样,1,385,214 条助手消息也包括子 agent 和面向工具的消息,而不只是对话界面里展示给我的文本。语料库包含 68 种不同的事件类型和 67 个不同的工具名称;1,130,921 次工具调用(61%)来自子 agent,而非主会话线程。
这个数字没有说明我为什么要把自己插入约 2,600 次。为此,我让 Copilot 为会话日志语料库中每条由人撰写的消息标注了一个主要意图。
前三个类别占了我交互量的 63%。其中只有大约 40 次是我能认出来的会话启动,因为大多数情况是我先建一个聊天来探索下一个方向,然后再让那个会话为每个想要的切片创建实际的移植会话。我的角色与其说是“派个任务然后等着”,不如说是“操作控制回路”:检查结果、质疑技术决策、把住质量关,以及在 agent 把中途的停靠点当成终点时推它一把。即便“活”是 agent 在干,人的判断仍然深度参与其中。我的参与只是上移了一层……我不再负责写语法,而是负责框定问题、划定边界、选择策略、裁决例外,并总体上确保一切朝着好的方向走。
关键全在缓存
LLM 提供商通常对输入 token(你发给它们的)收一个价,对输出 token(它们发给你的)收另一个价。计费往往按 token 来算,因为 token 是推断所需计算量的一个有用近似:对每个输入 token,模型都必须读取它、把它并入内部表示,并把它作为决定下一个 token 的计算的一部分。不过,提供商通常支持缓存这些计算的结果,因此如果提示的某个相同前缀已经被处理过,提供商就可以复用缓存中的中间计算,而不必从头重算。这降低了处理这些 token 的成本,而省下来的钱可以传递给用户。因此,输入 token 常常标出多个价格,其中包括从缓存读取的输入 token 的价格。
折扣很猛!提供商通常对缓存命中按 90% 的折扣计费,比如某个提供商对 100 万输入 token 收 $2.00,但对 100 万缓存读取的输入 token 只收 $0.20。换句话说,你非常非常需要维持良好的提示缓存,这样账单才能少一个数量级。
这次移植工作的数据说明我们做得不错。prompt 缓存命中率为 96.22%:缓存读取量除以全部输入侧 token 量(缓存读取加缓存写入加全新输入)。缓存写入占 3.07%,全新输入占 0.71%。这不是偶然。GitHub Copilot 在设计 agent 循环时,刻意维护一段长而稳定的前缀(先是 system prompt,然后是工具定义,再往后是累积的对话),于是每一轮都只是在模型已经处理过的上下文后面追加内容。上下文中昂贵的部分只付一次钱,之后每次调用重读它,成本要低一个数量级。长时自主会话在经济上之所以成立,原因也在这里。一个三百小时的移植任务,如果要在数万次调用中的每一次都从头重读整个不断增长的上下文,成本会比我们看到的数字高出另一个数量级。Agent harness 的开发者花了大量精力去避免破坏 prompt 缓存,模型厂商也经常推出新功能来帮他们做到这一点。
压缩则讲述了另一个相互补充的故事。在各移植会话中,GitHub Copilot 自动压缩上下文 5,116 次(即会话填满上下文窗口后,为了继续下去而自我总结的时刻)。那个 sessions-infrastructure 移植的单个 pull request 在持续多天的生命周期里压缩了 647 次,而一个小型移植一次都没有压缩过。持续数百小时的自主工作之所以可能,正是因为 agent 可以反复回收自己的工作记忆而不丢失线索。这数千次总结中的每一次,都是一个有损交接本可能悄无声息地让移植偏离正轨的节点,而大多数时候并没有。Copilot 的 subagent 对减少压缩也起了很大作用。每个 subagent 都有自己的上下文,因此父会话可以有效地提出一个问题,让 subagent 去消耗相当多的上下文算出答案,然后只把答案报告回父会话。父会话的上下文不必被这中间的所有信息所影响。
上文中的“基本没有”在会话日志里看得见。我让 Copilot 把每一次成功压缩与它前后的工作配对,前提是两侧各有至少 20 次工具调用。这样得到约 4,000 个可比较的窗口。压缩前 20 次工具调用与压缩后 20 次工具调用中,agent 的行为构成在规模上相近(探索占压缩前 46.5%、压缩后 48.1%,修改占 8.4% 和 6.0%,验证占 4.7% 和 4.0%,失败占 1.0% 和 1.5%)。如果压缩经常丢掉思路,压缩后那一侧应该明显偏向重新定位:读取量激增、编辑量骤降,agent 忙着重新弄清自己在哪、该做什么。实际只是朝这个方向轻微偏移。
是的,静态分析有用
有个流行的说法是,Rust 特别适合作为 AI 生成代码的目标,因为 Rust 严格的编译器能抓住模型出错的地方。会话日志让我们可以检验这个说法,至少对这类移植任务而言。
直接验证命令的结果中,共捕获 8,678 次 rustc 的错误码。最大的四类诊断占 84%:
- 37%:名称与导入解析,其中以
E0425(“cannot find value in this scope”)为主 - 22%:缺少方法或字段
- 14%:类型不匹配
- 11%:trait bound 未满足
这些全都是普通的接线问题:名字写错了一点、签名对不上、字段被改过名、抽象没有实现。这类错误正是批量翻译容易顺手制造出来的,也正是编译器能很快抓住的。
但注意这份清单里没有什么:没有任何真正属于 Rust 的东西。这四类全是静态类型的基本功,C#、Java 或 Go 的编译器同样能全部抓住,其中几类的报错还更友好,而且都快得多。如果这是把 agent 指向 Rust 的理由,那它其实也是把 agent 指向任何静态类型语言的理由。强类型编译器,和/或一门静态分析与 lint 做得极好的语言,确实很适合这类工作,agent 可以把它当作快速反馈回路。在更严格的结果匹配器捕获到结果的 4,478 次直接 cargo check 运行中,87.1% 干净通过——小步修改、不断重新编译,得到的就是这个结果。
相比之下,所有权、借用和生命周期错误加起来只占已编码诊断的 1.7%。借用检查器——这个在每一场关于 Rust 难学的讨论中占据主导的东西——只是一个安静的后台存在。编译器几乎把所有报错精力都花在了无聊的机械性错误上。
Agent 喜欢阅读
我们还可以检查会话事件语料库中的工具调用数据,从中提取一些关于 agent 如何分配时间的有趣观察。
| 工具 | 调用次数 | 中位数 | 实测小时数 |
|---|---|---|---|
powershell | 630,423 | 3 s | 2,833.9 |
view | 590,988 | 0 s | 621.7 |
rg | 281,783 | 1 s | 408.4 |
grep | 126,483 | 1 s | 115.3 |
apply_patch | 53,715 | 0 s | 17.0 |
edit | 40,591 | 1 s | 24.1 |
read_powershell | 36,728 | 90 s | 1,203.9 |
task | 13,080 | 274 s | 2,329.0 |
我的第一个结论是:agent 花在收集证据上的时间远远多于修改代码。把表中展示的文件读取和搜索工具与编辑工具对比,前者的工作量是后者的 10 倍。读文件、搜仓库、跑诊断命令占了绝对主导,编辑相比之下只是很小的投入。AI 疯狂吐代码的流行印象几乎正好说反了;在这个规模上,工作更像是反复调查——检查当前状态、形成假设、做一次有针对性的修改,然后循环往复。
委派放大了这一模式。子 agent 主要用于把探索分散到相互独立的问题上,而主 agent 更可能负责编辑并整合答案。对这类项目来说,这是一种有用的分工:多个上下文可以并行调查,但把修改集中在协调 agent 附近,能减少冲突改动,保持实现策略的一致性。
shell 流量还说明,自主软件工作中有很大一部分是状态管理。只读的 Git 检查是最常见的命令模式,因为 agent 在不断问的实际上是“我现在在哪?”它们在找什么变了、一次 rebase 做了什么、另一个会话合入了什么、某个分支离快速演进的 main 漂了多远。正是这种定位工作,让许多长时间运行的任务能在同一个不断变动的代码库上并行推进,而不会盲目覆盖彼此。
从 shell-tool 流量内部看,最常见的命令族更能说明这种定位与验证之间的平衡:
| 命令族 | 调用次数 | 中位数 | 实测小时数 |
|---|---|---|---|
git inspect | 300,530 | 2 s | 608.1 |
git other | 89,865 | 3 s | 243.1 |
| search | 85,482 | 2 s | 147.6 |
pnpm test | 13,852 | 22 s | 219.1 |
pnpm lint | 9,757 | 29 s | 177.0 |
cargo test | 8,437 | 120 s | 364.2 |
git commit | 7,410 | 11 s | 39.7 |
cargo fmt | 5,223 | 18 s | 77.2 |
cargo check | 4,492 | 120 s | 176.9 |
pnpm build | 3,630 | 180 s | 215.6 |
cargo clippy | 2,115 | 135 s | 107.4 |
git rebase | 2,496 | 7 s | 9.9 |
cargo build | 566 | 104 s | 20.3 |
模型选择
GitHub Copilot 允许在单个会话中途切换模型,也允许不同会话使用不同模型,因此模型选择成了按切片做出的决定。日志中出现了两类不同的模型决策。在主线程上——也就是驱动每项移植工作的那条线程——模型和推理强度由我们来选。而在会话内部,当 agent 启动子 agent 或子会话去探索、去完成一个有边界的任务时,选模型的是负责编排的那个模型。
子 agent 的模型构成略有不同:做优化的是 agent 而不是人,它追求的是吞吐和成本,而不是最难的判断。它最常启动的子 agent 跑在 Claude Opus 4.8、GPT-5.6 Sol、Claude Haiku 4.5 和 GPT-5.5 上,其次是 Gemini 3.1 Pro 和 Claude Opus 5。不过至少在移植进行的那段时间,有三个常用的 agent 定义把模型选择固定了下来(explore 和 task 固定为 Claude Haiku,research 固定为 Claude Sonnet),所以这部分调用量中有相当大一块是由选了哪个子 agent 决定的,而不是单独挑的模型。
使用 agent 集群
GitHub Copilot 应用能可视化活跃的 pull request 会话、显示各自状态,并让你轻松在它们之间切换,因此非常适合管理这次移植中固有的大量并发工作。但真正让它出彩的一点,是会话之间可以相互交互。
一个会话可以创建其他会话,也可以在它们运行时向它们发消息。每个会话,无论父会话还是子会话,都有自己的 worktree、自己的分支、自己的 agent 循环;它独立于创建它的会话,而不是在后者内部运行。这与 subagent 不同:subagent 运行在父会话自己的工作区内,并把结果交回父会话的上下文。两者都是有价值的构造,适用于不同的场景。
举例来说,一个会话可以这样创建其他会话。最难移植的当属 session.ts 文件。这个文件在演进中自然膨胀到了约 30,000 行 TypeScript。它构成了一个会话的骨架,实际上横向贯穿整个运行时,几乎与每个组件都有交互,处在状态、事件、工具、模型、hook、持久化和入口点访问的中心。因此,我把它留到了移植过程接近尾声的时候,从栈底沿着所有纵向模块一路向上推进,直到它们全都卡在 session.ts 上。接手它的移植会话并没有一上来就埋头写 Rust。它先花了五十六分钟阅读,在创建任何东西之前进行了 122 次工具调用,逐步弄清这个文件实际拥有什么、接缝在哪里。直到这时它才开始委派,按逻辑拆分文件,把各个切片交给子会话。在整个 25 小时的运行中,它自己发起了 222 次 shell 调用、205 次文件查看和 197 次 ripgrep 搜索,这还不算它所有子会话所做的工作。

那是 15 个子会话,每一个都是独立分支,有自己的 worktree 和独立的 agent,全部由顶层的父会话隐式创建。父会话在约三小时内分七批创建了它们:第一批创建了五个,约二十分钟后第二批又创建了两个,再过二十分钟又是两个,随后两小时里零散地创建单个和成对的会话。
模型按切片选择:15 个中有 10 个跑在 GPT-5.6 Sol 上,5 个跑在 Claude Opus 4.8 上。15 个全部以 GitHub Copilot 的 autopilot 模式启动,该模式让会话可以朝着目标推进,而不必在每一步停下来等待批准。启动提示词的中位数长度约为 1,100 个字符,足以承载归属边界和约束条件,又短到子会话必须自己想出实现路径。提示父 agent 的是我,而写给每个子会话的那些启动提示词,出自父 agent 而非人类之手。
除了这 15 个子会话,同一个父会话还使用了五个 subagent:三个 explore agent 与第一批同时并行启动,一个 code-review,一个 rubber-duck。这些 subagent 负责探究问题,把父会话在决定下一步之前所需、且必须进入其上下文的答案回传给它。有了 subagent,父会话就能拿到经过深入思考的答案,而不必消耗自己的上下文窗口去推导。
相比之下,子会话负责实际的移植工作——那些会产生 diff、且需要与其他并行移植者隔离的工作。子会话的工作触及仓库中 140 个不同的文件,其中 120 个只被一个会话触及。那 20 个有争用的文件都是枢纽文件,比如 session.ts 本身。但每个会话都在自己的 worktree 中工作,因此可以不受兄弟会话干扰地推进。当然,父会话为此在协调上付出了代价。它在与子会话沟通上花了相当多精力,充当信息中介,轮询它们的状态 60 次,发送了 89 条协调消息。当子会话各自宣告完成时,父会话把它们的提交 cherry-pick 到自己的分支并解决冲突。这些合并也谈不上干净,父 agent 花了相当多时间调和这些改动。
可以看到,在时间线上,父会话及其大部分子会话都显示了出来。
注意那些大段的空白。我在做这个移植的过程中一直在旅行,不得不在多个时间点合上笔记本电脑。(后来我改变了工作流程,加入了可以远程连接的云端虚拟机。)
这些并行的子会话对那台笔记本电脑造成了显著影响。有一段时间,并发移植进展得非常顺利。然后,同一台机器上的 15 个并发代理各自尝试构建和测试,我那可怜的笔记本电脑就彻底卡死了。我向父会话发了一条提示,让它转达给子会话:它们必须全部停止构建和测试。父会话把这一约束传达了出去,它们所幸终止了构建,转而以最低的 CPU 活动继续工作。随后我更新了常驻指令:子代理和子会话在移植期间应避免大型构建和测试运行,改为只由父代理来完成。
后来我又更进一步,把一个原本普通的聊天会话变成了八个独立移植会话的构建调度器。提示词简单得令人尴尬:向每个打开的会话发送一条策略,要求尽可能避免 CPU 密集型的构建和测试;当确实需要构建时,必须向本会话申请许可;并让它充当闸门,一次只发放一个会话的构建权限。基本上,我把这个聊天会话变成了一个代理互斥锁。闸门维护着明确的持有者和队列,通过会话之间原本就用来协调代码的跨会话消息机制,一次只授予一个租约。申请租约被拒的会话通常会一边等待一边做别的工作,比如从自己的待办列表里挑事情做。

session.ts 这次移植还牵出了我整个 runtime 移植过程中见过的最酷、最令人唏嘘、也最出人意料的一次交互。前面说过,我们这次移植基本是自底向上做的,所以 session.ts 这个实际上位于所有其他组件之上的东西,反倒成了最晚移植的组件之一。始终位于 session.ts 之上的,只有 runtime 的所有入口点,也就是 SDK 对外暴露、并出现在前面提到的 dispatch table 里的那些公开函数。这样的入口点有几百个。我知道其中很多会直接调用 session.ts,但我还是想抢个先,于是在启动 session.ts 会话之后,又开了一个会话去移植所有入口点。我让它到 session.ts 边界就停下。我估摸着会有一部分白干的工作,rebase 时也要花些力气或者烧掉一些 token,但总体上能加快移植进度。然后我就去睡了。再然后……它们找到了彼此。
我给入口点会话写的启动 prompt 里确实说了,会话移植和六个组件移植正在并发进行,我想让它清楚自己的边界,知道哪些不该碰,尽量少产生冲突。显然,我的 prompt 起了反效果。刚过四分钟,它盘点完入口路径,估计也对重叠程度有了判断,就调用了一个应用内置的 orchestrate skill,这个 skill 的用途正是协调多个会话之间的工作。接下来:
- 入口点会话枚举了所有活跃会话,给那些它认为有重叠的会话发了消息。
session.ts会话回了一份 2,001 字符的清单,标题是“Concrete overlap onstephentoub-port-session-to-rust”。- 入口点会话读了
session.ts会话的 worktree,核实刚才听到的说法(大概是“信任但要验证”吧)。 - 入口点会话问
session.ts会话,是否准备好处理它那份 760 个文件的 diff。 session.ts会话基本是让它一边去:“Not ready to commit/integrate.”- 入口点会话接着又问了同样的问题三次,每次都从
session.ts会话那里得到同样的回答。 - 于是入口点会话决定不管
session.ts会话怎么想了,直接伸手进它的 worktree,把另一个会话的所有改动抓过来合并进自己的。 - 然后两个会话各走各的路。
这次交互让我得到几点:
- 把意图说清楚很重要。 启动提示里点名了其他正在运行的会话,好让这个会话知道哪些不要碰。但我没有把“不要碰”这部分讲明白,结果不是阻止 agent 去做某件事,反而等于鼓励它去做。我本应在意图和指引上明确得多。
- 你开放出来的任何东西,agent 都可能认为适用。
orchestrateskill 随 GitHub Copilot 应用一起发布,自我描述是用于并行运行相互独立的工作流。我的提示里根本没提到它。模型自己发现了所处的局面,把它和那段描述对上,然后加载了它。你暴露出来的能力集合,就是你可能得到的行为集合,包括你从未设想过的情况。 - 平级之间需要一个裁决者。 两个会话谁也无法强制对方。当
session.ts会话四次表示自己还没准备好集成时,这个拒绝没有任何分量,于是那个愿意单方面行动的会话就默认赢了。并行会话如果改动的代码相邻,就需要指定一个协调者,或者需要一个人类,而这两个都没有。 - “自主运行”需要为那些越出自己分支的决策留一个例外。 我真正的意思其实是“别拿设计细节来吵醒我”。它(并非没有道理地)理解成吞并一个平级会话也在授权范围内。还是那句话,我本应在指引里说得更明确。
- 这里的根本原因是我。 我同时自上而下和自下而上地切分这项工作,两个方向在中间相遇,正好撞上代码库里连接最多的那个文件。我太贪心,想推进得更快。上面所有问题都由此而来。
谢天谢地,这整次交互只是一个有意思的离群案例。在整个运行时移植工作中,大多数叶子组件的移植都是单会话就能搞定的直接任务。更大的子系统往往涉及多个子会话和子 agent。不过这些部分在移植过程中如何参与,差异很大。
模型编排的移植——真正与各家提供商通信的那一层——为其中一种模式提供了很好的例子。它的主会话运行了 42 个墙钟小时,启动了 126 个子代理。最忙的时候,有 22 个同时在工作。但大多数时间里只有主代理,然后隔一阵子会在一段时间窗口内生成大量子代理。
这张图里有三点很突出。第一,几乎所有代码生成都在前 12 小时完成;之后一整天的活儿全是验证。第二,最下面一行的颜色从左到右变化,从以蓝色和绿色为主(读取、构建)变成以蓝色和橙色为主(读取、审查);这在逻辑上说得通,但能在实践中看到还是很不错。第三,那个例子——以及更一般地,这种模式——各阶段之间分得非常干净。扩展运行时的移植则是一个反例。
它花了 88 小时而不是 42 小时,结构也截然不同:
- 写入和审查大幅重叠。 在前一个例子里,工作是相当瀑布式的(先代码生成,再审查),而这里审查在写入停止之前很久就开始了,两者在大部分工作时间内并行并重叠。
- 最下面一行的颜色杂乱无章。 模型编排是从写入转向检查时由绿变橙,而这个是自始至终同样的读取、构建、审查混合。读取调用中间的一半分布在 49 小时的时间跨度内,写入分布在 33 小时内,审查分布在 27 小时内,而整个会话是 88 小时。每个类别都铺满了整段运行的大部分时间。
- 空闲被推迟到了最后。 集群在前 56 小时几乎不间断地工作。
- 比例仍然吻合。 这里写 Rust 占工具调用的 2%,那边是 1%;读取 44% 对 57%,审查 23% 对 27%。两个会话对要做的工作是什么看法一致,只是对它在什么时候发生看法不同。
末尾那些空白的切片,也直观呈现了这个 agentic coding 时代越来越常见的一个问题:等待批准。团队里的某个人或某个 agent 审查代码并留下反馈,接着是一小段活跃期,agent 处理反馈、把 CI 重新推回绿色,然后又是等待,如此反复,直到终于盖上那个令人多巴胺飙升的批准印章。
这两个例子各自代表一种主导模式。大约四分之一的会话更像模型编排那次,四分之三则更像扩展运行时那次。干净利落地按阶段推进反而是例外;常见的情况是 agent 一路规划、编写、审查。
规模化代码审查
前面几张图里那种对审查的偏重,很大程度上源于我明确的提示。我写了一个简单的 prompt-as-a-skill,叫 rust-rebase-review(此外还有我们已经合并进仓库的通用 Rust 编码 skill)。由于变更涌入得很快,其中不少还相互冲突,我需要频繁 rebase。通过自定义指令,我鼓励 harness 在流程中的合适节点调用这个 skill,我自己也会时不时手动调用。这个 prompt 随时间略有演变,但大致是这个的变体:
Squash into a single commit, then rebase on the latest in origin/main, resolving all conflicts, and force push. As part of rebasing, pay extra special attention to anything that has changed, been added, been removed, and ensure that logic is all ported over to the corresponding Rust code correctly. Always do the rebasing yourself / in the main agent; do not spawn a subagent for it.
Then enter a review/fix loop where you launch a subagent per opus 5, gpt-5.6-sol, and grok 4.6.
- That subagent should do a line-by-line comparison of the old TypeScript and the new Rust, confirming behavioral equality.
- Look for anything introducing any kind of incompatibility; our goal is to move this code into Rust with as close as is possible to 100% the same semantics. If you hit anything questionable, ask me about it.
- We want to ensure we're writing as efficient and idiomatic Rust code as we can; look for opportunities to simplify, to use routines like from the memchr crate to optimize searches instead of open-coded loops, avoid unnecessary allocation, use traits for reuse and loose coupling, etc.
- Ensure that all defunct TypeScript code (e.g. code that has been fully ported, tests that are now no longer necessary because they're duplicative, unnecessary napi shims, etc.) has been deleted.
- Ensure that we've ported as much code as possible, e.g. if there's any TypeScript remaining in touched files and that TypeScript is more than just a shim, that's a red flag. If new TypeScript that's not just a super thin shim is being added, that's a red flag. Look for any callers of TypeScript shims to see whether those callers can instead be ported to Rust, pushing the boundary as far as reasonably possible. Our goal is to soon get to 100% Rust in the runtime layer.
- Validate that no E2E tests have been deleted or changed. Such changes are an indication of a porting bug.
If a review surfaces issues, validate them, and then if there are any to fix, fix them, and iterate to do another full review. Continue iterating with reviewing/fixing until all reviews come back clean. After every set of changes in response to review feedback, commit and push so that CI validation runs concurrently with subsequent reviews.
Don't bother running full test suites; that'll be handled in CI. Try to minimize CPU-consuming efforts to the bare minimum, as we'll likely have many operations happening concurrently.
频繁 rebase 之下,涌入的变更很容易被意外弄丢。但我们发现原地原子替换有个意外的好处:在加入对应 Rust 的同时删除 TypeScript,等于隐式地让 rebase 带入的、针对那部分 TypeScript 的变更产生冲突——一边改它,另一边删它。这保证我们会注意到对已移植代码的改动,而不必对每一行涌入的代码去判断它是否可能碰到了此前已移植的部分。
我自己的审查当然只是全部 agentic 审查的一部分。除了每次提交都跑的 CCR,团队还有多个专门的代码审查 bot,各有各的做法和 prompt,每次提交都运行并给出详细反馈。这些最终都会变成 pull request 上的评论,然后需要逐条处理。好在处理这些 agentic 反馈本身也可以(基本)交给 agent 来做。
在我的评审工作中,我关注的是架构、设计、约定和思路。agent 负责做那些繁琐的新旧对比;测试和静态分析检查可机械验证的性质;人类评审者则专注于架构、API 契约、风险,以及其他环节暴露出的可疑之处。
目标架构由我选定,哪些行为重要由我决定,工作如何拆分由我划分,模糊的取舍由我裁定,证据由我判断,高风险区域由我人工审查,agent 对反馈的回应由我审阅,最终是否合并也由我拍板。agent 改变了一名工程师能够监督的代码量,但并没有消除这样一种需要:仍然要有一名理解整个系统、能够为方向、护栏和发布背书的工程师。
自动化内循环
GitHub Copilot 应用是这项工作的核心。它能管理大量并发的活跃会话,切换起来很方便,每个会话还能带上所有相关的配对上下文(关联的终端窗口、浏览器窗口、canvas 等)。这里最重要的功能是 agent merge:

agent merge 是内置于应用中的一个循环(CLI 里也有,通过 /pr auto 使用)。它会按定时器或响应外部刺激(比如 GitHub 发来的 CI 完成通知或评审评论)去查看有什么变化。如果有评审评论,它会调用 agent 判断是拒绝该评论,还是接受并处理(同时回复,说明是自动化在响应)。如果测试失败,它会下载日志、调查失败原因并修复 bug。如果出现冲突,它会调用 agent 进行 merge 或 rebase。实际上,它把我们人类开发者都会做的这个循环自动化了:推动 pull request 变“绿”、拿到批准,最终合并。
每一个移植 pull request 都由 agent merge 处理。不过在大多数情况下,我们并没有走到真正的“合并”那一步。agent 会修复所有 CI 失败、处理并回复所有评论,并确保所有冲突都已解决。合并之前,我会抽查 agent 实际做了什么,尤其是它如何回应反馈。我对它给评审者的某些回复是否有异议?所应用修复的总体方向是否可取、是否合理?对于这些移植,我通常不会勾选最后那一项。
最后一个复选框不止一次发挥了作用。在一次合并循环中,移植版删掉了 SDK 暴露的某个函数。我们仓库的 schema 兼容性 CI 环节尽职尽责地失败了。agent 的应对是打上仓库的 schema-break-ok 自动化标签——这是让检查通过的逃生通道。合并前审查这个 pull request 时,我问了那个显而易见的问题:“schema 破坏在哪?你给这个 pull request 加了 schema-break-ok 标签,凭什么就没事?”并不是没事。这个方法在 main 上是存在的,移植版只是把它弄丢了。我判定这是不可接受的回归,要求 agent 用 Rust 把它完整补回来。二十一秒后,豁免被撤销,方法以原生 Rust 实现恢复。
我们对失败既做局部处理,也做全局处理:修掉具体个案,同时修掉系统本身,让它们不那么容易再次发生。我们不断改进喂给编码 agent 和审查 agent 的指令,进一步降低同样的毛病在后续移植 pull request 中重演的概率。我们把会话日志变成了 eval。有些情况下,我们还把学到的经验直接用在运行时本身上——调整 prompt、工具描述,或者 autopilot 的工作方式。
一次移植,两场迁移
语言重写几乎从来不只是语言重写。运行时依赖的每一个库也都得替换,而且和那些我们自己写、自己拥有的 Rust 代码不同,这些替换品不由我们决定是否忠实。有些只是同一个东西换了个名字。有些要用好几个 crate 才能覆盖原来一个 npm 包的功能。还有少数根本找不到可接受的现成方案,只能手写(由 agent 写)。
CLI 和运行时目前放在同一个仓库里,共用一个 package.json。移植过程中,我们移除了约 60 个 npm 依赖,因为它们只被移植到 Rust 的运行时代码使用。这只是移除数量的下界,因为有些包是为运行时替换掉的,但 CLI 仍然需要。比如 zod 是一个 TypeScript 的 schema 声明与校验库,CLI 和运行时都在用。移植到 Rust 后,运行时改用 serde、schemars 和 jsonschema 的组合来满足同样的需求,但 zod 仍然留在 manifest 里,因为 CLI 还需要它。
有不少例子是一个 npm 包对应一个 crate,功能完全相同。js-tiktoken 变成 tiktoken-rs,o200k_base 编码方式不变。ignore 变成同名 crate,gitignore 语义一致。minimatch 变成 globset,fast-myers-diff 变成 similar,dompurify 变成 ammonia,github/keytar 变成 keyring。
另一些情况下,我们没法把包和 crate 一一对应。要么一个包拆成几个 crate,要么几个包合并成更少的 crate。依赖迁移的工作量主要花在这里。八个 opentelemetry/* 包变成了四个 crate,外加一个手写的 tracker 状态机和文件导出器。三个网页内容包 mozilla/readability、linkedom、turndown 变成了两个 crate:readability 和 htmd。sharp、image-size、file-type 变成了 image 和 imagesize。诸如此类。还有五处,我们干脆用完全自研的实现替换掉了整个 npm 包。
用了多少 unsafe?
关于 agent 写的 Rust,人们还会问另一个问题:其中有多少悄悄放弃了安全保证。Rust 的安全性是一个可以用关键字关掉的属性,所以 agent 一旦碰上满足不了的借用检查,就有一条现成的逃生通道。整个 runtime crate 里现在有 158 个 unsafe 块,分布在区区 36 个文件中(与之并列的还有 26 个 unsafe fn 声明、26 个 unsafe extern 块和 9 个 unsafe impl trait 实现)。关键在于,这些无一例外都是为了与外部组件互操作。
| unsafe 块存在的原因 | 块数 | 占比 |
|---|---|---|
| C ABI 边界 | 51 | 32.3% |
| Windows API | 49 | 31.0% |
| POSIX / libc | 46 | 29.1% |
| SQLite C API | 7 | 4.4% |
| 动态库加载 | 4 | 2.5% |
| 进程环境 | 1 | 0.6% |
C ABI 块是 SDK 宿主进来的前门,它们从 Rust 编译器管不着的调用方那里接收裸指针和长度。Windows 和 POSIX 块是系统调用:读注册表、凭据握手、进程树、sysconf。SQLite 是 C 库。动态库加载是 dlopen,它从构造上就不可能安全,别的不说,至少你解析出来的符号未必是你以为的那个函数。进程环境那个 unsafe 块之所以存在,是因为 Rust 2024 把多线程进程中修改进程级全局环境状态视为 unsafe。这 158 个 unsafe 块,每一个都标记着 Rust 所承诺的保证真正到头的地方:对面是一个 C 函数、一次系统调用、一个来自外部运行时的指针,或者进程级的宿主状态。
真正有用的地方在于,unsafe 让我们自己维护的 Rust 代码中所有这些位置都可审计。TypeScript 运行时里的等价代码同样跨越了这些边界,穿过 Node 的 C++ 内部实现和原生 npm 包,而我们的源码中没有任何东西标出受检查的世界到哪里为止。当然,这并不是交付系统中所有安全边界的完整清单:依赖、构建工具、C 库、安全封装,以及规格写错的 FFI 契约,仍然可能包含或暴露不安全代码。
unsafe 用在哪里,细节挺有意思,但我更关心它没用在哪里。模型客户端里没有用它,MCP 层没有,agent 层没有,prompt 层也没有。而移植过程中已知的回归,没有一个涉及 unsafe 块。
回归
移植代码很容易,让它正确很难。面对 Copilot agent 运行时这样庞大而复杂的代码库,回归是意料之中的事。
到 2026 年 9 月 14 日,我们已排查出数十个已知的移植回归,全部修复。其中大多是正确性 bug,另有一小部分是性能回归。这些是针对从零写起的约 832,000 行生产 Rust 代码而言的。
当然,并非所有这些回归都发布出去了。有些在仓库里开发时就已被发现。有些出现在预发布版中,但在进入稳定版之前就修掉了。还有一些进入了稳定版,往往是因为它们足够不起眼,撑过了一轮甚至多轮预发布使用而未被察觉。
这个数字当然不是零,而且我百分之百确定,实际存在的回归比我们知道的更多。这些是我们注意到或被报告的,但如此规模的迁移,绝对还发布了一些别的回归,只是它们足够安静,至今没人踩到。和一般的 bug 一样,随着技术栈更偏远的角落被真实环境高强度使用,我预计还会陆续发现零星的边角回归。
绝对数字其实没那么重要。更重要的是搞清楚这些问题的“为什么”,这样才能从中吸取教训,避免以后再犯。几乎所有的正确性回归都落在三大类里:新代码实现了不同的行为契约;状态、所有权或生命周期行为发生了变化;或者迁移的某一部分被遗漏、只部分应用,或在 rebase 中丢失。还有一小部分来自宿主或互操作边界的要求,甚至来自那些自信地验证了错误行为的测试。这些类别在几个反复出现的模式中变得更具体:
语义模糊。 有几个回归来自源语言隐式留下的行为。TypeScript 只有一种 number 类型;Rust 要求在几种类型中做出选择,包括值是否可以是小数。而 agent 猜错了。概念上是整数的字段变成了 f64,于是 Rust 序列化出的值形如 42.0 而不是 42:像 Go 和 C# 这样强类型的 SDK 无法把仓库 ID 反序列化成 int64,并拒绝了一个 hook 时间戳和任务时长。反方向上,一个 agent 把 timeToFirstTokenMs 声明为 i64,但流式路径发出的值形如 5446.712845,导致已写入的会话无法读取、无法恢复。还有一个更隐蔽的案例与类型无关:event.error || "Unknown error" 变成了 .unwrap_or("Unknown error")。JavaScript 的 || 会替换空字符串;Rust 的 unwrap_or 会保留它,于是空的 subagent 错误仍然是空的。哎。
环境行为。 另一个反复出现的来源是 JavaScript 或 Node 在背后提供的行为。配额代码用了 toLocaleDateString,它会继承宿主时区;Rust 需要显式传入该时区。但尽管 Intl.DateTimeFormat().resolvedOptions().timeZone 的类型是 string,它可能返回 undefined,而 napi 无法把它转换成 Rust 的 String,导致模型列表加载失败。在另一处,把环境变量的读取从 await 之前移到之后,意味着等待期间宿主的变化可能改变结果。一个原生插件加载器调用 process.report.getReport() 只是为了识别平台,但在 Windows 上这会遵循 _NT_SYMBOL_PATH,可能在渲染 CLI 之前花上几分钟下载 PDB。其他环境输入还包括工作目录、仓库身份、PATH 和会话认证。把所有权移入 Rust 要求决定何时捕获每一项、如何携带它,以及何时刷新它。
只移植一对操作中的一半。 好几处回归都源于成对操作失去同步。一次 turn-cap 检查更新了原生注册表的 abort 状态,却没有取消进程内的模型循环,于是又漏出去一个请求。另一处,任务完成被持久化、被发出,却没有投射到活动会话状态里,于是 Autopilot 在任务完成后继续跑。
阻塞主线程。 CLI 仍然从 Node 的单线程事件循环驱动 Rust 运行时,所以跨 napi 边界的同步工作会冻住 UI。/chronicle reindex 就是这样解析了数百个会话文件,渲染和输入被阻塞了近一分钟。把导出改成 async、把工作挪到阻塞线程池的线程上,问题就解决了。一次审计又找出几个可能阻塞的入口点,于是定下一条常设规则:真正干活的 napi 导出必须是异步的,必要时使用 spawn_blocking。
窗口闪现。 在 Windows 上,spawn 子进程时不加 CREATE_NO_WINDOW 会短暂弹出控制台窗口。Node.js 运行时曾经 monkey patch 了进程 spawn 来补上这个标志,把这一要求对移植 agent 隐藏了起来;Rust 的替代实现漏掉了它。一次审计又修了两处 spawn 点,不过那些是原本就有的遗漏,不是移植回归,我们还把这条规则写进了 copilot 指令。
生命周期管理。 最大的一类涉及生命周期、释放、所有权、顺序或竞态。把状态搬进 Rust,往往会让 TypeScript 手里只剩一个不透明句柄,指向存在原生表里的实例。和对象引用不同,这个句柄可以活得比实例更久。一个 hook 在请求中途被释放,留下一个 tool_use 块成了孤儿,把对话卡死,因为模型 API 要求有配对的 result。一个 shell 在“已宣告”和“已启动”之间被取消,泄漏出一个孤儿,让会话一直处于活动状态。一个 sandbox 开关更新了一个 generation 计数器,却没更新它在原生侧的双胞胎,于是 shell 卡在“reconfiguring”。
被忽略的功能。 还有一类是移植干脆漏掉的功能。有一处移植省略了 SDK 回调,还删掉了对应的端到端测试,于是新增一条规则:agent 未经明确同意不得改动 E2E 测试。一次会话中止保留了原生那一半,却丢掉了打断某个正卡在工具里等待的 turn 所需的进程内取消。SDK 替换内置工具搜索的能力依赖三件事:启用开关、面向模型的描述和 schema、把执行路由到 SDK 回调。移植把这三样全丢了(一致性万岁?),悄无声息地把某个使用方的自然语言搜索换成了正则搜索。
不同库,不同脾气。 还有不少回归源于把 JavaScript 库或 API 换成更严格的 Rust 等价物。当时,Rust MCP SDK(rmcp)会对格式错误的 JSON-RPC 输入作出响应,而 TypeScript SDK 不会。面对一个用更多格式错误输出回应错误的服务器,这种礼貌就变成了死循环,把启动过程卡死。不同生态里的同类库,行为很少完全一致。
夜里擦肩而过的船。 有一批回归源于分支漂移或 rebase。在一个每周有数百个 pull request 的仓库里,一个开了好几天的会话会不断累积变更和冲突。这次移植需要成千上万次 rebase;成功率再高,也总会留下失败。
还有那些慢吞吞的家伙。 最后一类功能上没问题,但更慢。有些回归丢掉了原有的效率优化,比如 memoization、完全异步的等待,或是有界的日志流式输出。另一些则在 Rust 与 TypeScript 的边界上引入了迁移特有的开销:多余的序列化、加锁、轮询、无上限的原生并发,以及原生与宿主之间的来回穿越。这些都不是留待日后优化的机会,每一个都是移植带来的性能退化。有一处只读扫描没有借用,而是深拷贝了一份 260 MB 的事件日志。在持续的事件流量下,另一个实现只完成了所请求 channel flush 的极小一部分,同时为每个事件保留一个异步句柄,直到 V8 耗尽堆内存。
几十个回归听起来很多。但在一场生成了超过 80 万行代码的移植里,说实话,我反而惊讶并且庆幸没有遇到多一个数量级的问题。看看公开的 github/copilot-cli 和 github/copilot-sdk 仓库里 issue 的趋势,也能大致判断这些问题是否被广泛“感受到”。这里我们把 1 月到 8 月新开的 issue 归为质量相关:带有 bug 标签,或标题里出现 “bug”“regression”“crash”“hang”“timeout”“broken”“incorrect” 这类常见故障词的。与移植期间和移植之后相比,移植之前的水平基本没有变化:
| 仓库 | 1–4 月,重写之前 | 5–8 月,重写期间 / 之后 |
|---|---|---|
| github/copilot-cli | 22.9% (454 / 1,982) | 23.7% (354 / 1,496) |
| github/copilot-sdk | 36.2% (190 / 525) | 32.3% (135 / 418) |
这既不是可用性指标,也不是对逃逸缺陷的精确计数……和回归本身一样,一个 issue 代表什么、范围有多大,都存在很多变数。但它提供了一个有用的检查:尽管产品变更量惊人,面向产品的 issue 渠道在迁移期间并没有出现值得关注的质量问题激增。
能编译就是对的
前面我提到过一个流行的梗:Rust 严格的编译器让它很适合 AI 生成的代码。还有一个相关的流行梗——只要代码能编译,它就是对的。并不是。我们已知的回归清单就是很好的反驳:语料库里的每一个回归都合并进了 main,也就是说它们全都编译通过了。编译器接受了这些有 bug 的版本,因为在它看来,每一个都是合法的 Rust。
编译器能证明一个 f64 被一致地使用。它不可能知道仓库 ID 必须以整数序列化,也不可能知道带尾部 .0 的时间戳会被链路另一端所有强类型 SDK 拒绝。编译器能防止它看得见的代码里出现未同步的数据竞争,但阻止不了一个同步得天衣无缝的状态机编码出错误的状态。一个队列可以有锁保护,却仍然让两个发送方各自认定对方会把它排空。事件可以在线程之间安全传递,却仍然以错误的顺序到达。一个同步的 napi 函数可以内存安全,却仍然阻塞 Node 主线程一分钟。编译按定义也几乎无法发现不存在的东西。当一次 rebase 悄悄删掉一个守卫和它的测试,或者某个进程启动器忘了加抑制控制台窗口弹出的 Windows 标志,编译器都不会有意见。它对每次读取都克隆一份 250 MB 的事件日志同样没有看法。编译器检查的是你写的程序内部是否自洽。它无法检查你是否写完了整个程序、是否保留了旧的契约、调用顺序是否正确、是否满足了宿主未写明的需求,或者是否以可接受的代价完成了工作。
这绝不能成为否定 Rust 编译器的理由。和任何静态类型语言一样,编译器消除了一大类机械性错误,也为 agent 提供了一个极其有用的内循环。但“能编译就是对的”只适合当笑话讲。
性能,性能,还是性能
那么这一切换来了什么?这次移植刻意保持行为不变,既没有重新设计算法,也没有修 bug;事实上,我反复把 agent 从顺手优化的方向上推开,因为同时改动语言和行为,会让你很难判断究竟是哪一项把你搞崩了。不过,这次重写的一个关键目标确实是性能和可扩展性(此外还有可靠性等因素)。当有人问我为什么要用 Rust 重写运行时,我的回答大致是:“我一开始并不是要转向 Rust,而是要离开 Node.js 和 V8。”这次迁移确实显著拉动了我们的性能指标。
我通过 C# SDK 对移植前后的运行时做了多场景基准测试(TypeScript、Python、Go、C#、Java 和 Rust 的 SDK 都通过同一套传输架构接入同一个引擎)。基线是移植前的 SDK 和 CLI 构建,TypeScript 运行时由 Node 托管,通过 stdio 访问。8 月 21 日的结果使用 Rust 运行时,既作为进程外服务器,也通过 FFI 加载到进程内。这是对交付系统的端到端对比,而不是试图隔离语言变更本身的影响;同一时期还落地了其他改动,所以这些数字要打个折扣看。
每个计时的轮次都发往本地运行的确定性 chat completion 服务器,返回固定的小响应。换句话说,这些数字刻意剔除了模型推理和网络延迟。它们衡量的是我们改动的那部分:客户端启动、进程拉起、会话创建、事件处理、持久化、清理,等等。
| 场景 | 5 月 12 日 | 8 月 21 日进程外 | 8 月 21 日进程内 |
|---|---|---|---|
| 客户端、会话、单轮对话 | 5.25 s | 1.33 s(4.0x) | 292 ms(18.0x) |
| 恢复 32 轮会话 | 5.64 s | 1.52 s(3.7x) | 264 ms(21.4x) |
| 十个并发客户端生命周期 | 12.34 s | 4.18 s(3.0x) | 742 ms(16.6x) |
| 1,000 个单轮会话生命周期 | 132.52 s | 22.53 s(5.9x) | 20.93 s(6.3x) |
“客户端、会话、单轮对话”这一项最容易直观感受到。它做的是创建客户端、创建会话、跑完一轮对话,然后把所有东西销毁。进程外开销的很大一部分来自启动 Node、初始化 V8,以及在第一轮对话开始之前加载、解析 TypeScript 代码生成的 JavaScript 应用并为其生成字节码。Rust 运行时省掉了这些 Node/V8 和 JavaScript 加载成本。
“1,000 个单轮会话生命周期”压力测试意味着我们能构建的东西上了一个台阶。它使用单个共享客户端,测量的是并发运行 100 条流水线,每条流水线创建一个会话、完成一整轮模型对话、销毁会话,如此连续重复十次。移植前的 TypeScript CLI 每秒完成 7.55 个这样的生命周期。Rust 进程外为 57.45。Rust 进程内为 120.0。
这是特定负载下的结果,并不代表 Rust 运行时普遍“快 15.9 倍”。但它恰恰是服务器托管方关心的负载:多个独立会话共享一个运行时。而且墙钟时间并没有把工作藏到另一个核上。在对同一份 100×10 负载单独做的一次资源采样中,移植前的进程树累计消耗了 312 秒 CPU 时间。Rust 各配置约为 110 秒。省下的这些 CPU 算力,托管方可以用来跑更多会话。
内存方面讲的是同一个故事,照例要提醒一句:内存指标特别容易被误用。看十客户端批量处理期间新增的常驻私有内存,移植前的进程树峰值比基线高出 1,383 MB。Rust 进程外方案峰值只有 247 MB。Rust 进程内方案则是 126 MB,低了一个数量级。这些数字当然会因用途和机器而异,但它们指向了核心目标:同一台机器上,服务能承载的客户端和会话数量远超以往,内存、进程数或 CPU 都不会先成为瓶颈。
最妙的是,这还只是基线移植。实现中很大一部分仍是用 Rust 忠实复刻的 TypeScript 式算法。新的所有权模型、并发模型和进程内架构所支撑的大规模重新设计,我们还没开始做。在优化工作尚未启动之前,进程内客户端完成创建、一个完整单轮会话和销毁只需约 55 毫秒,共享客户端能达到每秒 120 次单轮会话生命周期,实测十客户端内存增量下降了 91%——这是一个非常好的起点。
移植的代价
那么……这一切花了多少钱?请鼓掌……
整个移植工作的 token 消耗约为 1,363 亿,其中包括约 1,306 亿缓存输入读取 token、约 42 亿缓存输入写入 token、约 9 亿全新输入 token,以及约 6 亿输出 token。这些 token 的账单约为 12 万美元。
当然,token 不会自己花出去。还要算上占用开发者大量时间引导这些 agent 的成本。不过我并没有把所有时间都投入这个项目。Agent 式开发很大程度上是“赶紧提交然后等着”:提交提示词,让编码 agent 自己干活,时不时看一眼,可能调整一下方向,但在它完成之前,去做别的事情。这意味着开发者不再一次只做一项编码任务,而是把等待时间重叠起来,同时推进多件事。在移植期间,这些 Rust 移植 PR 约占我在所有参与仓库中 PR 总数的 20%。如果粗略假设 PR 占比近似于时间占比,那么大约相当于三周专门用于这次移植。
换句话说,这次移植的大致账单约为 12 万美元的归因 token 支出,外加一名开发者三周的时间。
话虽如此,端到端的 Rust 迁移并非完全由一名开发者完成,而是团队协作的结果。@stevesandersonms 提供了 napi-oop(临时的进程外互操作层)的设计与实现,以及六个 SDK FFI 实现中的五个,第六个由 @edburns 提供。@roji 实现了 SDK 的打包,使其能够正确发布并使用 Rust 二进制文件。@caarlos0 一直在帮助将 Rust 代码拆分为许多小的 subcrate,以缓解开始成为问题的构建时间,而 @criemen 则帮助改进了资源缓存,以加速 CI 和本地构建。@devm33、@examon、@MRayermannMSFT、@dereklegenzoff 等人帮助完成了无数 PR 审查和批准。而在 copilot-agent-runtime 仓库中贡献的每一个人,在世界于他们脚下发生剧变时,都给予了支持与配合。
经验教训
这次经历强化了几条经验,它们的作用超出了这次重写本身。以下是我们下次会带入的东西:
-
目标需要被清晰、完整地陈述。 我们早期的指令过于模糊。“将 XYZ 组件移植到 Rust”被理解为只处理热路径,或只处理逻辑,而 agent 反复将 I/O 和编排视为超出范围。一旦我们明确最终状态是一个由 100% Rust 代码库构建的原生二进制文件,即使我们想要也没有任何执行环境留给 TypeScript,它们就变得更擅长自主地朝这个目标推进。
-
端到端测试绝对、毫无疑问地关键。 除一个例外,所有涉及缺失功能的回归,以及许多其他回归,都是由于缺乏足够的端到端测试。对于任何此类移植,你必须有可用于验证移植正确性的测试,而这些测试本身不能在移植过程中被重写,否则你就失去了 oracle。我们最初的移植计划指出了这一点,并指出我们需要在开始移植之前显著改善 E2E 测试状况。我们做到了,但做得不够。我们确信,如果在开始移植之前添加更多 E2E 测试,真正专注于确保大多数有意义的行为都被它们覆盖,我们一路上的回归会比实际更少。
-
保护 oracle 免受 agent 影响。 改变实现的 agent 也不能被允许通过削弱测试、更新快照、提高兼容性基线或应用逃生舱标签来悄悄重新定义正确性,至少不能在没有监督的情况下这样做。尽可能保持行为契约独立,将敏感的护栏置于单独的所有权或审批之下,并用具有不同失败模式的检查进行分层,这样一次错误不足以发布一个大的回归。
-
先翻译,后重新设计。 保留行为和现有算法使同时变动的变量数量保持在可控范围内。一旦旧实现和过渡性脚手架消失,就可以针对稳定的基线重新设计所有权、并发和性能。我有几次偏离了这一点,出于不安分、难以对同伴说不,或坚信这个情况不同,事后看来我后悔每一次偏离。每一次都比坚持原路线付出了更多的回归、更多的时间或更多的 token。
-
把反复出现的失败转化为未来的成功。 AI agent 会偏离轨道:当它们这样做时,从中学习。当一种失败模式出现两次时,它就应该进入常设指令、可复用的 skill、eval、受保护的基线或 harness 本身。
-
智能体加入开发流程后,开发者内循环只会更重要,而不是更不重要。 对开发者来说,内循环就是日常:改代码、构建、测试、迭代,速度有多快,日子就过得有多顺。这个环节里工具链一慢,人就烦。有人会觉得,底层活儿都交给智能体了,内循环慢点也无所谓——这种想法可以理解,但它是错的。恰恰相反。AI 智能体思考和写代码的速度极快,但它们照样要构建,照样要测试。而且随着思考与写代码的时间占比下降、快速验证的内循环占比上升,构建和测试所占的时间比例实际上是增加的。所以,前期花点时间优化内循环,并且按“同时有多个任务并行”来优化(比如你同时在多个 worktree 里推进多个任务)。这笔投入,日后你会感谢自己。
接下来是什么?
它成功了。五月份还是纯 TypeScript 的执行运行时,到八月份已经变成纯 Rust,而且整个过程一直在持续交付给真实用户,而不是到最后来一次惊心动魄的整体切换。
我并没有简单地让 AI agent“把整个代码库从 TypeScript 移植到 Rust”。即便整个行业正朝着那个方向走,我们现在也远远没到那一步。真正发生的是,agent 让一整类项目变得可行。在一个线上系统里就地重写,产出几十万行生产级 Rust 代码,耗时 main,由一名工程师在团队支持下完成——这样的提案在 agent 出现之前根本不会被批准。它需要一个完整团队干上一两年,需要和那个团队本可以交付的所有功能竞争,而且会输(说实话,也应该输)。Agent 把成本压到了这个项目变得可行的位置。
移植本身已经完成:运行时的生产实现 100% 是 Rust,临时的内部 TypeScript/N-API 接缝已经移除。不过我们想做的事还有很多:继续改进构建系统和开发者内循环,清理翻译过来的结构,围绕 Rust 的所有权和并发模型重新设计,以及追求进一步的性能提升。这次移植是一次翻译,而且是刻意为之(prompt 就是这么写的),我们尽量让行为保持 100% 一致,而不是顺手修更多 bug、重构组件,或者进一步提升性能和可扩展性(超出重写本身自然带来的部分)。微观层面,大部分代码是地道的 Rust;但宏观层面,有相当多最初用 TypeScript 写的算法只是套上了 Rust 的语法。如今底层的约束已经变了,重新审视这些决策,才是真正有意思的收益所在。
最让我兴奋的是这次移植打开了什么。SDK 可以直接加载进六种语言中任意一种的宿主进程,依赖链里没有 Node.js 或 V8,也不需要再监督第二个进程——这是合作伙伴采用 SDK 时反馈最多的一处摩擦。一个运行时实例的成本只有过去的零头,意味着宿主在耗尽机器资源之前可以跑多得多的并发会话。而且运行时现在可以去 Node.js 永远跟不上的地方:从云端到桌面、到设备、再到嵌入式系统。这些都不是终点。这是我们用来构建 GitHub Copilot 未来的地基;在看了三个月 agent 重写运行它们自己的引擎之后,我很想看看它能走多远。
祝你编码愉快!