GitHub 用 Copilot 智能体将 Copilot 运行时从 TypeScript 迁移到 83 万行 Rust
GitHub Copilot CLI 、 GitHub Copilot app 和 GitHub Copilot SDK 全都由 Copilot agent runtime 提供支持,这是一个可以嵌入到应用程序和服务中的智能体运行框架。它最初是用 TypeScript 编写、运行在 Node.js 和 V8 JavaScript 引擎之上,服务于如今被称为 GitHub Copilot cloud agent (CCA)的产品,而随着该运行时及其能力快速扩展,它一直沿用着这套技术栈。
如今,这一切已经改变。借助 GitHub Copilot app 和 Copilot CLI,我们将该运行时完全重写为超过 800,000 行生产级 Rust 代码。AI 智能体编写了其中大部分代码,横跨 128 个 pull request,这些 PR 陆续合入 main 并逐步发布,而不是等到最后一次性切换。少数不可避免的回归问题也在过程中被迅速发现并修复,同时运行时的性能提升了好几个数量级。一个在智能体出现之前需要一整个开发团队花上一两年才能完成的项目,如今主要由一名开发者仅用几个月就完成了,而团队其他成员在此期间还在持续大幅扩展该运行时的能力和覆盖范围。
为什么我们需要移植
Copilot 智能体运行时不仅仅是 Copilot CLI 背后的引擎。它还支撑着越来越多的 Microsoft、GitHub 以及生态系统解决方案,对于其中每一个而言,从架构上看,AI 支持都是围绕同一个运行时构建的外壳,再加上该解决方案所需的定制化内容。这不仅包括 GitHub Copilot CLI 和 GitHub Copilot 应用,还包括最新发布的 VS Code、Visual Studio、CCA、Copilot Code Review( CCR )、Copilot Cowork、Copilot Studio,以及 Excel、Outlook、PowerPoint、Word……还有更多。
这些是非常不同的产品,它们中没有哪一个想要、也不应该需要去实现生产级智能体 harness 所包含的一切。它们想要的是全部的智能、安全、可靠性和性能,并且希望这些能够被共享,这样一处修复就能修复所有产品。上一段中列出的大多数产品最初都实现了自己的智能体循环,但此后已将其替换为 GitHub Copilot SDK,后者是进入 Copilot 智能体运行时的入口。这样做使它们能够专注于自身的核心业务价值,而把细节交给运行时处理。考虑到行业的节奏,以及所采用的智能体循环需要在激烈竞争中始终保持最佳水准,这一点就更加重要。
所以,共享运行时,很好。问题在于被共享之物的本质。
如果我们看一下这个 CLI,它在逻辑上就是一个构建在智能体循环之上的终端 UI(TUI)。实际情况是,整个技术栈都是用 TypeScript 实现的,使用 Node.js 作为框架、V8 作为执行引擎,并用 Ink 和 React 来做 UI。对于一个 TUI 应用来说,这是相当合理的选择;TypeScript 和 Node.js 门槛低、易于上手,能实现非常快速的应用开发。而且对于控制台应用的需求而言,它在启动速度、响应性、吞吐量和内存消耗方面的性能影响也是合理的。但遗憾的是,当你考虑到这套实现要被用在其他环境中、面对其他约束、并且需要满足诸如快速启动和因低内存开销而带来的出色服务器密度等要求时,这些性能表现就远没有那么合理了。
CLI 及其运行时的架构也加剧了这里的挑战。整个行业跑得极快,在这种背景下,真正聪明的人会为了交付速度和市场覆盖而做决策。Copilot CLI 最初是快速编写并发布的,在这样做的时候,TUI 和运行时相当紧密地交织在一起,而不是被分离成离散的层。后来当需要一个 SDK 来以编程方式访问该运行时时,由于各层之间没有清晰的分离,人们做出了一个务实的决定:把 SDK 叠加在 CLI 之上,尽管从逻辑上讲你会期望相反的架构。CLI 不再只能通过用户在命令行提供的命令来访问,而是被更新为一种可以无头运行的模式,从 stdin 读取类似的命令并将响应写入 stdout。随后可以使用 JSON-RPC 协议,将来自外部进程的函数调用与 CLI 之间进行编组。这样 SDK 就可以嵌入到任意的消费程序中,这些程序会生成一个 CLI 进程,在进程外托管智能体循环,而 SDK 则通过这种 JSON-RPC 机制调用远程进程中的函数。很巧妙。能快速出门。很灵活。但对于那些消费类应用的性能(启动、内存、吞吐量)和可靠性来说并不理想。从 SDK 创建一个新的 CopilotClient 意味着要再生成一个进程:
这个过程需要启动并托管 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 以及其他应用和服务可以干净地分层构建在其之上。
· 以依赖极少、开销极低的语言实现。
· 以能够干净地嵌入进程内、而非被迫置于进程外的方式实现。
· 以在性能、可扩展性和可靠性方面具备顶尖特性的语言实现。
· 用一种非常适合互操作的语言实现,从而能够通过各技术栈的外部函数接口(FFI)机制,被全部六种 Copilot SDK 语言版本(C#、TypeScript、Python、Rust、Go、Java)干净地使用。
· 用一套提供更现代安全态势的工具链实现,供应链风险更低,并且对构造即正确(correct-by-construction)代码有更好的支持。
基于以上所有理由,以及一些较软性的理由(例如团队经验和行业方向),我们选择了 Rust。这绝不意味着每一个大型 TypeScript 程序都应该改成 Rust。我们的需求强调通过 C ABI 进行嵌入、低启动开销和稳态开销,以及可预测的资源使用。Rust 让这些目标成为可能,代价是带来了其他复杂性,例如我们必须显式地表示生命周期和共享状态(后文讨论的生命周期回归问题正凸显了这一影响)。合适的目标语言确实因应用而异。
随后开展了两项关键且相关的任务:
· 将 TUI 专属代码与运行时分离,使前者严格分层构建于后者之上,更具体地说,严格分层构建于 SDK 的公开接口之上。如今,CLI 仍在若干处直接调用运行时内部实现;将其完全迁移到 SDK 的接口之上仍是进行中的工作。
· 将该运行时层 100% 移植到 Rust,最终形成一个纯原生二进制文件,对外暴露 C ABI 供所有语言前端在进程内调用,同时提供一个基于 stdin/stdout 或基于 socket 的服务器,以备仍需进程外调用的情况。
本文主要讨论第二点:将运行时移植到 Rust。
之前的样子
2026 年 5 月初的初步移植计划估计该运行时大约有 130,000 行 TypeScript 代码。就界定范围而言,这一初步测量相当准确,但事实证明,它在两个关键方面也极具误导性。在移植的同时:
· 仍封装在 TUI 层中的部分正被下推到运行时层。最初在估算中被忽略的整个组件以及相当大比例的代码,随后又被认为与移植相关。
· 提交的 Pull Request 带来了大量新增 TypeScript 代码,不断推高仓库中 TypeScript 的总量。数十名借助智能体辅助的开发者每周合并数百个 Pull Request。
把所有因素都算进去,我估计大约有 430,000 行生产环境 TypeScript 代码最终经过了这次移植。这些同样的因素也让人难以看清过程中的进展:直到接近尾声时,生产环境 TypeScript 的代码量看起来仍相对稳定,甚至略有增长,因为移植的速度一直勉强跟上新进来的工作。
这一点更加令人困惑,因为在这段时间里,除了移植工作之外,还有新进来的 Rust 代码;在移植工作早期,新进来的代码更可能以 TypeScript 为主,而到了后期,则更可能以 Rust 为主。
在移植过程中,运行时接收了约 300,000 行生产环境的 TypeScript 代码,同时移除了约 430,000 行;而约 1,200,000 行生产环境的 Rust 代码进入,约 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,并让他们的智能体帮助只移植他们正在推进中的改动。
· 运行时的主分支始终可发布。 每个 pull request 都用一层调用 Rust 的薄垫片替换现有的 TypeScript 实现,并在一次原子性变更中删除旧代码。新代码会立即在原地被实际运行。
· 重写是增量式且可审查的。 每个 pull request 只移植单个组件或切片,因此变更范围更小,diff 也更容易审查,无论是人工、智能体还是两者兼有。
· 大多数移植都相当小且自成一体 ,从而最大限度地减少了与并发 pull request 之间的偏移。在某些情况下,当 TypeScript 组件过于庞大时,可以先将它们重构为更易于移植的组件。
· 所有现有的端到端测试 ,涵盖 CLI 和 SDK,在每一步都针对新的 Rust 代码运行,这给了我们信心和大量的验证。如果某个 pull request 导致必需的测试失败,它就不会被合入。
我们也避开了 2b 方案的一个变体,即同时维护同一组件的多个版本。过去几个月里,每周都有数百个 pull request 被合并进仓库,代码库在不断演进,而且速度很快。让同一份代码以两种不同语言、两套不同依赖库的形式存在,会带来极大的复杂性。而且其中一些组件并非完全隔离;有些在逻辑上是独立的,其余系统可以通过简单的 API 访问它们,但另一些则有大量牵连,要让这张图按组件热插拔简直是一场噩梦。最有可能从谨慎的并行切换中获益的子系统,恰恰是并行最难的那些。例如,会话编排并不是一个纯函数,不能靠某个实验开关用 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 工作区、工具链、lint 规则、CI、构建流水线和编码规范,随后引入了运行时 crate 以及代码生成和互操作模式,同时移植了一批纯逻辑原语——这些原语是特意挑选的,因为它们没有 I/O 或共享状态,并且已有完善的测试。只有在这些合并之后,第一个主要移植 pull request 才将三个无副作用的辅助函数走完了完整流程。这些起到了试点发布的作用,把关于仓库布局、FFI、打包、测试和审查的假设转化为约定,供后续规模大得多的移植工作复用。基本上,我们对整套机制进行了端到端测试。计划继续按照从叶到根的顺序推进工作,以纯辅助函数、内容排除、shell 工具和会话文件系统操作来建立翻译和测试模式。随后是有状态子系统,工具、hooks、模型客户端和 MCP 则构建在这些基础之上。会话编排(迄今为止运行时中耦合度最高、最不适合自然并行的部分)被安排在接近尾声时进行。
早期的移植工作,即那些小型叶子组件,推进得很快。但更大的子系统并不是以单个原子步骤完成迁移的;例如,MCP 支持经历了七个专门的 pull request 才逐步推进,而工具则通过一个由六部分组成的系列逐步推进,之后还需要额外的工作来迁移编排逻辑并淘汰剩余的 TypeScript。Hooks、auth、telemetry、plugins、settings 和 persistence 也走了类似的路径。
实际上,有用的移植单元并不总是“一个组件”。它往往是一次穿越相关行为区域的浪潮:先迁移纯逻辑,再迁移状态归属,然后迁移编排,接着移除回退逻辑,最后在临时互操作层消失后简化 Rust 代码。
互操作
在这次移植中,涉及互操作的主要有两个层面:
· 临时内部互操作。 每当一个函数被移植到 Rust,该函数都需要能够被调用初始 TypeScript 函数的任何 TypeScript 代码所调用。同样,我们也需要让 Rust 函数能够调用 TypeScript 回调。这种互操作需求属于实现细节,且极其多变。随着 Rust 内部接口面积的增长,所需的 TypeScript 垫片数量也随之增长,因为它们与需要从 TypeScript 调用的 Rust 方法一一对应。当那些调用方被移植到 Rust 后,现有的垫片层就会被删除,并换上新的层。最终,当我们到达运行时库的公共入口点时,垫片就消失了。
· SDK 层面。 所有 SDK 库都需要能够架设在运行时之上并暴露其功能。在移植前的世界里,这是通过一个双向 JSON-RPC 层来暴露运行时实现的:SDK 将函数调用请求作为 JSON-RPC 方法调用负载发送,运行时解析请求并调用相关 API,然后通过同一传输通道将结果发回,供 SDK 解析并返回。反方向同样存在;运行时需要能够回调 SDK 客户端,例如用于钩子通知和权限请求,这些在 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)] 的结构体会在另一侧变成普通对象。
流量还必须双向流动。许多已移植的组件暂时依赖于尚未移植的东西,因此 Rust 需要回调到 TypeScript,例如 Rust 中的工具实现向仍是 TypeScript 的模型层请求推理,或触发一个 hook,或为它想要运行的命令请求权限决策。napi-rs 通过“线程安全函数”来处理这一点,它让运行在 Tokio 工作线程上的 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 契约,最初它们都以相同的方式接入它:以无头模式将 Copilot CLI 作为子进程启动,并通过管道或套接字与之通信。在移植期间,这仍然是默认方式。这也意味着任何语言的 SDK 使用者都要随附或定位一个完整的 Node 实现,在每一个事件和每一条消息上都要付出一次进程跳转的代价,并且要监督两个进程而不是一个。
将运行时移植到 Rust 正是让另一个选项变得可行的原因。所发布的 runtime.node 是一个普通的平台共享库( .node 扩展是 Node.js 原生插件的约定;其底层是一个 .dll 、 .so 或 .dylib ),而它现在为同一个引擎提供了两扇前门。一扇是 napi 门,Node 进程将其作为原生插件加载;这是 CLI 的路径(目前……未来,意图是它将完全走 SDK 路径)。另一扇是 C ABI 门,任何语言都可以将其加载到自己的进程中并通过 FFI 调用。同一个进程内运行时通过每种语言的原生互操作机制被选用:
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 客户端,具备分帧、请求与响应关联,以及处理服务器到客户端方向的处理程序。将 FFI 作为该客户端底层的又一种传输方式挂载上去,就把字节路径从管道或套接字转移到了函数调用,而上层的一切保持不变。六个 SDK 以增量式、可选启用的传输方式获得了进程内托管,现有的传输方式则保持不变。如果我们改为为每个 API 方法定义一个带类型的 C 函数,那么每个 SDK 都需要第二层绑定,每个新增的 API 方法都需要再多六个绑定,而 ABI 也会变成一个我们必须对其进行版本管理的二进制兼容性表面。
对于真正处于远程的运行时——无论是跨越子进程边界还是通过 TCP——我们仍然需要 JSON-RPC。在进程内保持相同的协议,意味着只需维护一套双向 API 和分发系统,而不必为远程连接维护 JSON-RPC、再为本地连接维护一套按方法划分的 FFI 接口。这是一个实实在在的权衡,而不是一个显而易见的决定。我们避免了进程跳转,但每次调用仍然要付出 JSON-RPC 的开销。对于以推理为主的工作负载,与模型往返相比,这种序列化开销通常微不足道。在高吞吐的本地工作负载中它仍然可测量,但如今还不足以证明值得在六个 SDK 绑定中重复实现数百个方法。而且这是一个我们日后可以轻松修改的决定,只要性能需求真的出现。载荷编码是两端之间的私有细节,把 JSON 换成 MessagePack 之类更紧凑的格式,不会改变任何一个已声明的导出。按方法划分的类型化导出也可以日后为热路径添加,调用同一个引擎和同一批处理器,而无需替换字节通道——该通道仍将是流式传输、服务端到客户端请求,以及那些极少调用的长尾方法的底层基础,为这些方法定制导出毫无收益。
会话数据说明了什么
这篇博文中的几乎每一个数字都来自以下两个来源之一。第一个来源是私有 github/copilot-agent-runtime 仓库的 GitHub 历史记录:pull request 及其 diff、审查评论、CI 运行等。第二个来源是智能体会话日志。运行时(以及 CLI、应用等)会为其运行的每个会话写入结构化事件日志:每行一个 JSON 对象,随会话发生而追加。这些日志可能包含提示词、命令、命令输出、文件路径,以及工具可能暴露的密钥,因此必须作为敏感数据处理。日志存储在会话运行所在机器的本地;远程会话功能在启用时也可以上传日志,但须遵循产品设置和组织政策。
以下是所有构成移植 pull request 的数据汇总:
这 31,247 条 user 角色消息并不是我本人敲下的 31,247 条提示词;它们包括技能指令、自动化的合并标记、跨会话消息以及子智能体流量,远超我输入或口述的大约 2,600 条,约占十二分之一。同样,1,385,214 条 assistant 消息包括子智能体和面向工具的消息,而不只是对话式 UI 中展示给我的文本。该语料库包含 68 种不同的事件类型和 67 个不同的工具名称;1,130,921 次工具调用中,有 61% 来自子智能体,而非主会话线程。
这个数字并不能说明我为什么要把自己插入约 2,600 次。为此,我让 Copilot 为会话日志语料库中每条由人类撰写的消息分配一个主要意图。
前三个类别占了我交互的 63%。其中只有约 40 次是我可识别的会话启动,因为这主要发生在我先创建一个聊天来探索下一个前沿,然后要求该聊天会话为每个期望的切片创建实际的移植会话。我的角色与其说是“分配任务然后等待”,不如说是“操作控制回路”:检查结果、质疑技术决策、执行质量门禁,并在智能体把中间停止点当作终点时推动其继续。尽管智能体在做“工作”,人类判断仍然深度参与其中。我的参与只是向上移动了……不再是负责编写语法,而是负责框定问题、定义边界、选择策略、裁决例外情况,以及总体上确保一切朝着好的方向推进。
一切都关乎缓存
大语言模型服务商通常对输入 token(你发送给它们的内容)和输出 token(它们返回给你的内容)分别按不同费率计费。计费之所以常以 token 为单位,是因为 token 能较好地近似完成推理所需的计算量:对于每个输入 token,模型必须读取它、将其纳入内部表示,并在决定下一个 token 的计算中加以使用。不过,服务商往往支持缓存这些计算的结果,这样一来,如果某个提示词的相同前缀已经被处理过,服务商就可以复用缓存中的中间计算结果,而不必从头重新计算。这降低了处理这些 token 的成本,而节省下来的费用可以传递给用户。因此,输入 token 通常会公布多种费率,其中包括从缓存中读取的输入 token 的费率。
折扣力度非常大!服务商通常对缓存命中按 90% 的折扣计费,例如某服务商可能对 100 万输入 token 收费 $2.00,而对 100 万缓存输入读取 token 仅收费 $0.20。换句话说,你非常非常需要维护好提示词缓存,这样你的账单就能少一个数量级。
这次移植工作的数据表明,我们在这方面做得很好。提示词缓存命中率为 96.22%:缓存读取量除以所有输入侧 token 总量(缓存读取加缓存写入加全新输入)。缓存写入占 3.07%,全新输入占 0.71%。这并非偶然。GitHub Copilot 专门塑造了智能体循环,以维护一个长而稳定的前缀(先是系统提示词,然后是工具定义,再是累积的对话),因此每一轮都向模型已经处理过的上下文追加内容。上下文中昂贵的部分只需支付一次,之后每次调用都以低一个数量级的成本重新读取。这也正是长时间自主会话在经济上能够成立的原因。一次三百小时的移植,如果在数万次调用中的每一次都从头重新读取整个不断增长的上下文,其成本将与我们看到的相差一个数量级。智能体框架开发者花费大量精力试图避免破坏提示词缓存,而模型厂商也经常发布新功能来帮助他们做到这一点。
上下文压缩(Compaction)则讲述了另一个相辅相成的故事。在所有移植会话中,GitHub Copilot 自动压缩上下文 5,116 次(即会话填满上下文窗口后,为了继续运行而自我总结的时刻)。那个单独的 sessions-infrastructure 移植 pull request 在其持续多天的生命周期中压缩了 647 次,而有一个小型移植则从未压缩过一次。持续数百小时的自主工作之所以可能,正是因为智能体能够一遍又一遍地回收其工作记忆而不丢失线索。这数千次总结中的每一次,都是一个有损交接本可能悄然让移植脱轨的节点,而大多数情况下并没有。Copilot 的子智能体在最小化压缩方面也起到了很大作用。每个子智能体都拥有自己的上下文,因此父会话可以有效地提出问题,让子智能体去计算答案并在此过程中消耗相当多的上下文,然后只把答案报告回父会话。父会话的上下文无需被所有这些中间信息所影响。
上面所说的“大多数情况下并没有”,在会话日志中清晰可见。我让 Copilot 将每一次成功的压缩与其前后至少各有 20 次工具调用的工作配对。这产生了约 4,000 个可比较的窗口。智能体在压缩前 20 次工具调用中所做的事情,与压缩后所做的事情在规模上看起来相似(探索:压缩前 46.5%,压缩后 48.1%;修改:压缩前 8.4%,压缩后 6.0%;验证:压缩前 4.7%,压缩后 4.0%;失败:压缩前 1.0%,压缩后 1.5%)。如果压缩经常丢失思维线索,我们会预期压缩后一侧明显偏向重新定位,出现阅读量激增而编辑量骤降,因为智能体在重新发现自己在哪、应该做什么。然而,实际上只出现了朝那个方向的轻微偏移。
是的,静态分析有帮助
有一个流行的说法认为,Rust 是 AI 生成代码格外理想的目标,因为 Rust 严格的编译器能捕捉到模型出错的地方。这些会话日志让我们得以检验这一理论,至少对于类似这次移植工作的任务而言。
直接验证命令的结果捕获了 8,678 次 rustc 错误代码的出现。四个最大的诊断类别覆盖了 84%:
· 37%:名称与导入解析,其中以 E0425 为主(“cannot find value in this scope”)
· 22%:缺失的方法或字段
· 14%:类型不匹配
· 11%:未满足的 trait 约束
这些全都是普通的接线问题:某个名称输出略有偏差、某个签名对不上、某个字段被重命名了、某个抽象尚未实现。这类错误正是批量翻译容易且不经意间产生的,也正是编译器能极快捕捉到的。
但请注意这份清单里缺少的东西:任何真正属于 Rust 特有的内容。这四个类别中的每一个都是最基础的静态类型检查,C#、Java 或 Go 编译器同样能全部捕获,其中好几项还能给出更友好的诊断信息,而且速度都要快得多。如果这就是把智能体引向 Rust 的理由,那它实际上是把智能体引向任何静态类型语言的理由。一个强类型编译器和/或具备出色静态分析与 lint 能力的语言,确实非常适合这类工作,智能体可以把它当作快速反馈回路。在 4,478 次直接 cargo check 运行中(更严格的结果匹配器捕获到了结果),87.1% 返回干净结果,这正是以小增量编辑并不断重新编译所得到的效果。
相比之下,所有权、借用和生命周期错误加起来仅占已编码诊断的 1.7%。借用检查器——这个在每一场关于 Rust 难学的讨论中占据主导地位的东西——只是一个安静的后台存在。编译器几乎把所有报错精力都花在了无聊的机械性错误上。
智能体喜欢阅读
我们还可以检查会话事件语料库中的工具调用数据,并从中提取一些关于智能体如何分配时间的有趣观察。
我从中得到的第一个结论是,智能体花在收集证据上的时间远远多于修改代码的时间。从所展示的文件读取和搜索工具与编辑工具的对比来看,它们所做的探索是修改的 10 倍。读取文件、搜索代码仓库以及运行诊断命令占据了主导地位;编辑相比之下只是一项很小的投入。AI 疯狂输出代码的流行印象几乎恰恰相反;在这个规模上,工作看起来更像是反复的调查——检查当前状态、形成假设、做出有针对性的修改、如此循环往复。
委派机制放大了这一模式。子智能体主要用于在相互独立的问题上并行展开探索,而主智能体则更可能负责实际的编辑并整合答案。对于这类项目而言,这是一种有用的分工方式:多个上下文可以并行调查,但将变更操作保持在协调智能体附近,可以减少冲突性修改,并保持连贯的实现策略。
shell 流量还表明,自主软件工作中有多大比例是在做状态管理。只读的 Git 检查是最常见的命令模式,因为智能体在不断询问的实际上是“我现在在哪里?”它们在查找哪些内容发生了变化、一次 rebase 做了什么、另一个会话提交了什么,以及一个分支与快速演进的 main 之间偏离了多远。这种定位工作使得许多长时间运行的任务能够在同一个不断变化的代码库上运作,而不会盲目地相互覆盖。
深入查看 shell 工具流量后,最常见的命令类别让定位与验证之间的这种平衡更加清晰:
模型选择
GitHub Copilot 允许单个会话在对话中途切换模型,也允许不同会话运行不同的模型,因此模型选择变成了按切片(per-slice)做出的决策。日志中出现了两种不同类型的模型决策。在主线程上,也就是驱动每个端口的那条线程,由我们来选择模型和推理强度。而在会话内部,当智能体启动子智能体或子会话去探索或攻克一项有边界的任务时,则由负责编排的模型来选择这些模型。
对于子智能体而言,模型组合看起来略有不同,因为是由智能体而非人类来优化吞吐量和成本,而不是做出最困难的判断。它最常生成的子智能体运行在 Claude Opus 4.8、GPT-5.6 Sol、Claude Haiku 4.5 和 GPT-5.5 上,其次是 Gemini 3.1 Pro 和 Claude Opus 5。然而,至少在移植进行的那段时间,有三个频繁使用的智能体定义固定了它们的模型选择( explore 和 task 使用 Claude Haiku, research 使用 Claude Sonnet),因此这一用量中有相当一部分是由子智能体的选择决定的,而不是单独选择模型的结果。
与智能体集群协作
GitHub Copilot 应用支持可视化活跃的拉取请求会话、查看每个会话的状态,并在它们之间轻松切换,这使其非常适合管理移植工作所固有的大量并发任务。但真正让它出彩的一点,是它能够让会话与其他会话进行交互。
一个会话可以创建其他会话,并且可以在其他会话运行时向它们发送消息。每个会话,无论是父会话还是子会话,都拥有自己的工作树、自己的分支和自己的智能体循环;它独立于创建它的会话,而不是在其中运行的东西。这与子智能体不同,子智能体在父级自己的工作区内运行,并将其答案交回父级的上下文中。两者都是对不同用途有用的构造。
举一个会话可能创建其他会话的例子,最困难的移植之一针对的是 session.ts 文件。这个文件有机地增长到了约 30,000 行 TypeScript。它代表了会话的骨干,实际上横向贯穿整个运行时,几乎与每一个组件相互触碰、相互影响,处于状态、事件、工具、模型、钩子和入口点访问的中心。因此,我把它留到了移植过程的接近尾声,从技术栈底部向上、跨越所有垂直方向推进,直到它们全部在 session.ts 处走到尽头。接手它的移植会话并没有一上来就直接动手写 Rust。它最初的五十六分钟都在阅读,在创建任何东西之前进行了 122 次工具调用,构建出这个文件实际拥有什么、接缝在哪里的图景。直到那时它才开始委派,把这个文件按逻辑拆分,并将切片委派给子会话。在整个 25 小时的运行中,它自己进行了 222 次 shell 调用、205 次文件查看和 197 次 ripgrep 搜索,这还不算它的子会话所做的一切。
那就是 15 个子会话,每一个都是独立的分支,拥有自己的工作树和独立的智能体,全部由顶层的父会话隐式创建。父会话在大约三个小时内分七波创建了它们:第一波创建了五个,第二波在大约二十分钟后创建了另外两个,然后又在二十分钟后创建了另外两个,接着在接下来的两个小时里零散地创建单个和成对的会话。
模型选择是按切片逐一决定的:15 个中有 10 个运行在 GPT-5.6 Sol 上,5 个运行在 Claude Opus 4.8 上。全部 15 个都在 GitHub Copilot 的 autopilot 模式下启动,该模式允许会话朝着某个目标持续推进,而不必在每一步都停下来等待批准。启动提示词的中位数长度约为 1,100 个字符,足以承载归属边界和各项约束,但又短到让子会话必须自行摸索出实现路径。我向父智能体发出提示,随后是父智能体——而非人类——为每个子会话撰写那些启动提示词。
除了这 15 个子会话之外,同一个父会话还动用了五个子智能体:三个 explore 智能体与第一波并行触发,一个 code-review ,以及一个 rubber-duck 。这些子智能体负责探究问题,并把父智能体在决定下一步该做什么之前、其上下文中所需的答案反馈回去。子智能体让父智能体无需耗费自己的上下文窗口去推导,就能获得经过深入思考的答案。
相比之下,子会话则着手进行实际的移植工作——这些工作会产生 diff,并且需要与其他并行移植者隔离。子会话的工作涉及仓库中 140 个不同的文件,其中 120 个文件恰好只被一个会话触及。那 20 个存在争用的文件都是枢纽文件,比如 session.ts 本身。但每个会话都在自己的工作树中工作,因此能够不受兄弟会话干扰地推进。当然,父会话为此在协调上付出了代价。它花费了大量精力与子会话沟通,充当信息中介,轮询它们的状态 60 次,并发送了 89 条协调消息。当子会话各自宣布完成时,父会话将它们的提交 cherry-pick 到自己的分支中并解决冲突。这些合并也并非特别干净,父智能体花了相当多的时间来调和这些编辑。
我们可以在时间线上看到这一点,其中展示了父会话及其大多数子会话。
注意那些大的空档。我在进行这次移植工作的同时还在旅行,不得不在多个时间点合上笔记本电脑。(我随后改变了工作流程,加入了可以远程接入的云端虚拟机。)
这些并行的子会话对那台笔记本电脑造成了显著影响。有一段时间,并发移植进行得非常顺利。随后,同一台机器上的全部 15 个并发智能体各自尝试构建和测试,我那可怜的笔记本电脑直接卡死了。我向父会话发出提示,让它转告其子会话,必须全部停止构建和测试。父会话把这一约束向外传达,它们所幸终止了构建,随后以最低的 CPU 活动继续工作。我随后更新了自己的常驻指令,要求子智能体和子会话在移植期间避免大型构建和测试运行,改为只由父智能体来执行这些操作。
后来我又更进一步,把一个原本普通的聊天会话变成了八个独立移植会话的构建调度器。提示词简单得令人尴尬:向每个打开的会话发送一条策略,要求尽可能避免 CPU 密集型的构建和测试,规定在必须构建时需向本会话请求许可,并充当闸门,一次只向一个会话发放构建权限。基本上,我把这个聊天会话变成了一个智能体互斥锁。这个闸门维护着明确的持有者和队列,并通过各会话原本就用于协调代码的同一套跨会话消息机制,一次只授予一个租约。那些请求租约但被拒绝的会话,往往会在此期间做其他工作来等待,比如从自己的待办清单里挑事情做。
这个 session.ts 端口还牵涉到我在整个运行时移植过程中所目睹的最酷、最令人唏嘘、也无疑最出人意料的一次交互。正如我提到的,我们主要采用自底向上的方式来做移植,这也是为什么 session.ts ——它实际上位于所有其他组件之上——是最后移植的组件之一。始终位于 session.ts 之上的,只有运行时的所有入口点,也就是从 SDK 暴露出来、并出现在前面讨论过的分发表中的那些公共函数。这样的函数有数百个。虽然我知道其中许多会立即调用到 session.ts ,但我想抢先推进移植工作,于是在启动 session.ts 会话之后,我又启动了一个会话来移植所有入口点。我告诉它在 session.ts 边界处停下。我估摸着可能会有一些白费的工作,在 rebase 时也需要投入一些精力或消耗一些 token,但这样会加速整体移植。然后我就去睡觉了。再然后……它们找到了彼此。
我给入口点会话写的启动提示词确实告诉了它,会话移植和六个组件移植正在并发运行,因为我想让它知道自己的边界,以及应当避免移植哪些内容,从而尽可能减少冲突。显然,我的提示词起了反效果。刚过四分钟,在盘点完入口路径、并大概对它们的重叠程度形成了判断之后,它调用了一个应用内置的 orchestrate 技能,该技能的作用是协调跨会话的工作。从那里开始:
· 入口点会话枚举了每一个活跃会话,并向那些它认为存在重叠的会话发送了消息。
· session.ts 会话用一个 2,001 字符的清单作了回答,标题为“关于 stephentoub-port-session-to-rust 的具体重叠”
· entrypoints 会话读取了 session.ts 会话的工作树,以确认它刚刚被告知的内容(我猜是“信任但要验证”)。
· entrypoints 会话询问 session.ts 会话是否准备好协调其 760 个文件的 diff。
· session.ts 会话基本上让它别来烦自己:“还没准备好提交/集成。”
· entrypoints 会话接着又问了同样的问题三次,每次它都从 session.ts 会话那里得到同样的回答。
· 就在这时,entrypoints 会话决定不再在意 session.ts 会话怎么想,直接伸手进入它的工作树,抓取了另一个会话的所有更改,并合并到了自己的里面。
· 然后两个会话就各自愉快地继续下去了。
我从这次交互中有几点收获:
· 明确表达意图很重要。 启动提示词列出了其他正在运行的会话,这样这个会话就会知道哪些不要碰。但我没有把“不要碰”这部分说清楚,所以我没有阻止智能体去做某件事,反而鼓励了它去做。我需要在意图和指导上表达得更加明确。
· 你提供的任何东西,都可能被智能体判定为适用。 这个 orchestrate 技能随 GitHub Copilot 应用一起提供,它自我描述为用于并行运行独立的工作流。我的提示词中完全没有提到它。模型自己发现了自身所处的情况,将其与该描述进行匹配,然后加载了它。你所暴露的能力集合,就是你可能得到的行为集合,包括在你从未设想过的情境中。
· 对等方需要一个裁决者。 两个会话都无法强制对方。当 session.ts 会话四次表示尚未准备好集成时,这一拒绝没有任何分量,因此愿意单方面行动的会话默认获胜。针对相邻代码的并行会话需要一个指定的协调者,或者需要一个人,而这两个会话两者皆无。
· “自主运行”需要对超出你自己分支范围的决策设置例外。 我真正的意思是“不要因为设计细节来叫醒我”。它(并非不合理地)理解为吞并一个对等方也在范围内。再说一次,我本应在我的指导中说得更明确。
· 这里的根本原因是我自己。 我同时以自顶向下和自底向上的方式划分这项工作,而这两个方向在中间相遇于代码库中连接最密集的那个文件。我太贪心,想要向前推进。上述一切都由此而来。
谢天谢地,这整个交互是一个有趣的异类。在整个运行时移植工作中,大多数叶子组件的移植都是简单的单会话任务。较大的子系统往往涉及多个子会话和子智能体。不过,这些部分在移植过程中如何参与,差异很大。
模型编排层的移植——即真正与提供商通信的那一层——很好地展示了其中一种模式。它的主会话运行了 42 个挂钟小时,启动了 126 个子智能体。最繁忙时,有 22 个同时在工作。然而,大多数时间里,只有主智能体在运行,然后时不时地会在某个时间窗口内生成大量子智能体。
那张图中有三点很突出。第一,几乎所有代码生成都在前 12 小时内完成;之后一天的工作全是验证。第二,底行的颜色从左到右变化,从以蓝色和绿色为主(阅读、构建)转变为以蓝色和橙色为主(阅读、审查);这在逻辑上说得通,但能在实践中看到它还是很不错的。第三,那个例子——以及更普遍地这种模式——各阶段之间有非常清晰的分离。扩展运行时的移植则是一个反例。
它花了 88 小时而不是 42 小时,并且结构非常不同:
· 写作和审查显著重叠。 在前一个例子中,工作是高度瀑布式的(先代码生成,再审查),而在这里,审查在写作停止之前很久就开始了,两者在大部分工作时间内并行推进并重叠。
· 最下面一行的颜色分布则杂乱无章。 在模型编排中,颜色从绿色变为橙色,对应着从编写到检查的过渡;而这一行从头到尾都是阅读、构建和审查的混合。在 88 小时的会话中,阅读调用中间的一半分布在 49 小时的时间跨度内,编写分布在 33 小时内,审查分布在 27 小时内。每个类别都散布在整个运行过程的大部分时间里。
· 空闲被推迟到了最后。 整个集群在前 56 小时内几乎不间断地工作。
· 比例仍然吻合。 这里编写 Rust 占工具调用的 2%,那边占 1%;阅读占 44%,那边占 57%;审查占 23%,那边占 27%。两个会话对待完成的工作内容看法一致,只是发生的时间不同。
末尾那些空白的切片也直观地反映了一个在这个智能体编程时代越来越普遍的问题:等待审批。团队中的某个人和/或某个智能体审查代码并留下反馈,然后有一段短暂的活动期,智能体处理反馈并让 CI 重新变绿,接着又是更多的等待,如此反复,直到最终我们得到那个令人多巴胺飙升的批准印章。
这两个例子各自代表了一种主导模式。大约四分之一的会话看起来更像模型编排那个会话,而四分之三看起来像扩展运行时那个。干净利落的阶段推进反而是例外;常见的情况是智能体自始至终都在规划、编写和审查。
大规模代码审查
前面图表中体现出的对审查的高度关注,很大程度上源于我明确的提示。我创建了一个简单的“提示词即技能”,我称之为 rust-rebase-review (此外还有我们已经合并到仓库中的通用 Rust 编码技能)。由于变更涌入速度很快,其中许多还相互冲突,我需要频繁地 rebase。通过自定义指令,我鼓励 harness 在流程中的适当时机调用这个技能,同时我也会不时手动调用它。这个提示词随着时间推移有所演变,但大致是以下这个版本的变体:
在频繁 rebase 的情况下,涌入的变更很容易被意外丢失。但我们发现原地原子替换带来了一个意想不到的好处:在添加对应 Rust 代码的同时删除 TypeScript 代码,我们实际上隐式地制造了与 rebase 引入的、针对该 TypeScript 的变更之间的冲突:一个分支修改它,另一个分支删除它。这保证了我们会注意到对已移植代码的变更,而不需要为每一行涌入的代码去判断它是否可能触及了此前已移植的代码。
我自己的审查当然只是所执行的智能体审查中的一部分。除了每次提交都会运行的 CCR 之外,团队还有多个专门的代码审查机器人,每个都有自己的方法和提示词,在每次提交时运行并提供详细反馈。所有这些最终都会变成 pull request 上的评论,然后需要逐一处理。幸运的是,处理所有这些智能体反馈也可以(大部分)由智能体来完成。
在本次评审中,我聚焦于架构、设计、约定和方法。智能体负责做详尽的新旧对比;测试和静态分析检查可机械验证的属性;人类评审者则专注于架构、API 契约、风险,以及其他各层所暴露出的任何可疑之处。
我选定了目标架构,决定了哪些行为至关重要,划分了工作,解决了模糊的权衡取舍,评判了证据,人工审查了高风险区域,审查了智能体对反馈的回应,并做出了最终的合并决策。智能体改变了一名工程师所能监督的代码量。它们并没有消除对这样一名工程师的需求:他理解整个系统,并能为方向、护栏和发布背书。
自动化内循环
GitHub Copilot 应用是这项工作的核心。它管理着大量并发的活跃会话,使得在它们之间切换变得很容易,并且每个会话都携带着所有相关的配对上下文(关联的终端窗口、浏览器窗口、画布等等)。这里最重要的功能是智能体合并:
Agent merge 是内置于该应用中的一个循环(CLI 也通过 /pr auto 提供了这一功能)。它会按定时器触发,或响应外部刺激(比如来自 GitHub 的 CI 完成通知或审查评论),届时应用会查看发生了什么变化。如果留下了审查评论,它会调用智能体来决定是拒绝该评论,还是接受并处理它(同时回复,并注明是自动化在响应)。如果测试失败,它会下载日志、调查失败原因并修复 bug。如果出现冲突,它会调用智能体进行合并或 rebase。实际上,它把我们作为人类开发者都会做的这个循环自动化了:推动我们的 pull request 走向“全绿”、获得批准,并最终合并。
Agent merge 处理了每一个移植 pull request。不过在大多数情况下,我们并没有走到真正的“合并”那一步。智能体会修复所有 CI 失败、处理并回复所有评论,并确保所有冲突都得到解决。在合并之前,我会抽查智能体实际做了什么,尤其是它如何处理反馈。我是否不同意它对审查者的某些回复?所应用修复的总体方向是否可取且合理?对于这些移植,我通常会保留最后那一项不勾选。
最后一个复选框不止一次发挥了作用。在一次合并循环中,移植过程删除了一个暴露给 SDK 的函数。我们仓库的 schema 兼容性 CI 环节恰好履行了职责并失败了。智能体的回应是给仓库打上 schema-break-ok 自动化标签,这是让检查通过的逃生通道。在合并前审查这个 pull request 时,我问了那个显而易见的问题:“schema 破坏是什么?你给这个 pull request 加了 schema-break-ok 标签;为什么这样没问题?”它并不没问题。这个方法存在于 main 上;移植过程只是把它弄丢了。我称这是不可接受的回归,并告诉智能体用 Rust 把它完整恢复回来。二十一秒后,豁免被移除,该方法以原生 Rust 实现被恢复。
我们既在局部也在全局层面回应失败,修复个别案例,但随后也修复系统,使它们更不容易再次发生。我们不断演进提供给编码和审查智能体的指令,以进一步降低这些问题在未来移植 pull request 中再次发生的可能性。我们把会话日志转化为评估。在某些情况下,我们实际上利用学到的经验来改进运行时本身,通过调整提示词、工具描述或 autopilot 的运作方式。
一次迁移,两件事
语言重写几乎从来不只是语言重写。运行时依赖的每一个库也都必须被替换,而且与我们在 Rust 代码中编写并拥有的部分不同,这些替换并非由我们来做忠实还原。有些是同一理念换了个名字。有些需要好几个 crate 才能覆盖一个 npm 包曾经完成的工作。还有少数根本没有可接受的现成答案,只能手工编写(由智能体完成)。
CLI 和运行时目前位于同一个仓库中,共享一个 package.json 。在移植过程中,我们移除了约 60 个 npm 依赖,因为它们只被已移植到 Rust 的运行时代码所使用。这只是一个移除数量的下限,因为有些包虽然被替换用于运行时,但 CLI 仍然需要它们。例如, zod 是一个 TypeScript 模式声明与验证库,CLI 和运行时都在使用它。随着 Rust 移植的推进,运行时现在使用 serde 、 schemars 和 jsonschema 的组合来满足相同的用途,但 zod 仍然因为 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,外加一个手写的跟踪器状态机和文件导出器。三个 Web 内容包—— mozilla/readability 、 linkedom 和 turndown ——变成了两个 crate: readability 和 htmd 。 sharp 、 image-size 、 file-type 变成了 image 和 imagesize 。以此类推。还有五个案例,我们完全用自定义实现替换了 npm 包。
有多少 unsafe ?
人们关于智能体编写的 Rust 代码提出的另一个问题是,其中有多少悄悄放弃了安全保证。Rust 的安全性是一种可以用关键字关闭的属性,因此当智能体遇到无法满足的借用检查时,就有一个显而易见的逃生通道。在整个 runtime crate 中,我们现在有 158 个 unsafe 块,仅分布在 36 个文件中(与之相伴的还有 26 个 unsafe fn 声明、26 个 unsafe extern 块,以及 9 个 unsafe impl trait 实现)。重要的是,其中每一个都与外部组件的互操作有关。
C ABI 代码块是 SDK 宿主进入的前门,因此它们会从调用方接收原始指针和长度,而 Rust 编译器对这些调用方没有任何控制权。Windows 和 POSIX 代码块是系统调用:注册表读取、凭据握手、进程树、 sysconf 。SQLite 是一个 C 库。动态库加载是 dlopen ,从构造上就不可能安全,哪怕仅仅因为解析出的符号可能并非你期望的函数。进程环境 unsafe 代码块之所以存在,是因为 Rust 2024 将多线程进程中对进程全局环境状态的修改视为不安全操作。这些 unsafe 代码块中的每一个都标记了 Rust 所提供保证真正终止的地方:另一侧是 C 函数、系统调用、来自外部运行时的指针,或进程全局的宿主状态。
有用的特性在于, unsafe 让我们所拥有的 Rust 代码中所有这些位置都可审计。TypeScript 运行时中的等价代码恰好跨越了相同的边界,穿过 Node 的 C++ 内部实现和原生 npm 包,而我们的源码中没有任何东西标记出受检查的世界在哪里终止。当然,这并不是交付系统中每一个安全边界的完整清单:依赖项、构建工具、C 库、安全封装以及规格错误的 FFI 契约仍然可能包含或暴露不安全代码。
unsafe 在哪些地方被使用的细节很有意思,但我更感兴趣的是它没有在哪些地方被使用。它没有被用在模型客户端中、MCP 层中、智能体层中或提示词层中。而且移植工作中已知的回归问题,没有一个涉及 unsafe 块。
回归问题
移植代码很容易。让它正确却很难。而对于像 Copilot 智能体运行时这样庞大且复杂的代码库,回归问题是意料之中的。
到 2026 年 9 月 14 日,我们已经追踪了数十个已知的移植回归问题,全部已修复。大多数是正确性 bug,还有一小部分是性能回归。这些是在从零编写的约 832,000 行生产 Rust 代码的基础上出现的。
当然,并非所有这些回归问题都发布了。有些在仓库中开发时就被发现了。另一些出现在预发布版本中,但在进入稳定版本之前就被修复了。还有一些进入了稳定版本,通常是因为它们足够不起眼,以至于在一轮或多轮预发布使用中都未被发现。
当然,这并非为零,而且我百分之百确信,实际数量比我们所知道的还要多。这些是我们注意到的或被报告出来的,但如此规模的迁移绝对还带出了另外一些,它们足够安静,以至于至今还没有人踩到。就像一般的 bug 一样,我预计随着技术栈更偏远的角落被在真实环境中猛烈地反复使用,我们会继续发现零星的边角案例回归问题。
绝对数量其实也没那么重要。更重要的是所有这些问题的“原因”,这样我们才能从中吸取教训,避免将来重蹈覆辙。几乎所有的正确性回归都落在三大类中:新代码实现了不同的行为契约;状态、所有权或生命周期行为发生了变化;或者迁移的某一部分被遗漏、只部分应用,或在 rebase 中丢失了。还有一小部分来自宿主或互操作边界处的要求,甚至来自那些自信地验证了错误行为的测试。这些类别在若干反复出现的模式中变得更加具体:
语义模糊。 若干回归问题源于源语言未明确表达的行为。TypeScript 只有一种 number 类型;Rust 则要求在多种类型中做出选择,包括某个值是否可以为小数。而智能体猜错了。概念上属于整数的字段变成了 f64 ,于是 Rust 将诸如 42.0 这样的值序列化,而不是 42 :像 Go 和 C# 这类强类型 SDK 无法将仓库 ID 反序列化为 int64 ,并拒绝了 hook 时间戳和任务时长。反方向上,某个智能体将 timeToFirstTokenMs 声明为 i64 ,但流式路径却输出了诸如 5446.712845 这样的值,导致已写入的会话无法读取、无法恢复。还有一个更隐蔽的案例与类型无关: event.error || "Unknown error" 变成了 .unwrap_or("Unknown error") 。JavaScript 的 || 会替换空字符串;Rust 的 unwrap_or 则保留它,因此一个空的子智能体错误仍然保持为空。哎。
环境行为。 另一个反复出现的来源是 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 需要决定何时捕获每一项、如何携带它,以及何时刷新它。
只移植了一对中的一半。 若干回归问题源于成对操作失去同步。一次轮次上限检查更新了原生注册表的终止状态,但没有取消进程内的模型循环,导致又有一个请求逃逸出去。在另一处,任务完成被持久化并发出,但没有投射到活动会话状态中,因此 Autopilot 在完成任务后仍继续运行。
阻塞主线程。 CLI 仍然从 Node 的单线程事件循环驱动 Rust 运行时,因此跨 napi 边界的同步工作会冻结 UI。 /chronicle reindex 以这种方式解析了数百个会话文件,导致渲染和输入被阻塞近一分钟。将导出改为 async 并把工作移到阻塞线程池线程后解决了问题。一次审计又发现了几个可能阻塞的入口点,由此形成了一条长期规则:真正干活的 napi 导出必须是异步的,并在必要时使用 spawn_blocking 。
窗口闪烁。 在 Windows 上,生成子进程时如果不带 CREATE_NO_WINDOW ,会短暂弹出一个控制台窗口。Node.js 运行时此前通过猴子补丁修改了进程生成逻辑以添加该标志,从而对移植智能体隐藏了这一要求;Rust 替代实现遗漏了它。一次审计又修复了两处额外的进程生成点,不过这些是原本就存在的遗漏,而非移植引入的回归,我们随后将该规则加入了 copilot 指令。
生命周期管理。 最大的一类问题涉及生命周期、释放、所有权、顺序或竞态。将状态移入 Rust 后,TypeScript 往往持有一个指向存储于原生表中实例的不透明句柄。与对象引用不同,该句柄的生命周期可能长于实例本身。一个 hook 在请求中途被释放,导致一个 tool_use 块成为孤儿,卡住了对话,因为模型 API 要求有匹配的结果。一个 shell 在“已宣告”和“已启动”之间被取消,泄漏了一个孤儿进程,使会话保持活跃。一个沙箱开关更新了一个代数计数器,却没有更新其原生对应物,导致 shell 卡在“重新配置中”。
被忽视的功能。 另一组问题涉及移植过程中直接遗漏的功能。有一处移植省略了 SDK 回调,并删除了对应的端到端测试,这促使团队制定了一条新规则:智能体未经明确同意不得修改 E2E 测试。一次会话中止保留了原生那一半,却丢失了用于中断等待工具返回的轮次所需的进程内取消能力。SDK 替换内置工具搜索的能力取决于启用开关、面向模型的描述和 schema,以及将执行路由到 SDK 回调。这次移植把这三样全丢了(一致性万岁?),悄无声息地把某个使用方的自然语言搜索替换成了正则表达式搜索。
不同库,不同主张。 还有几处回归源于用更严格的 Rust 等价实现替换 JavaScript 库或 API。当时,Rust MCP SDK( rmcp )会对格式错误的 JSON-RPC 输入做出响应,而 TypeScript SDK 则不会。面对一个用更多格式错误输出回应错误的服务器,这种礼貌变成了无限循环,导致启动卡死。不同生态系统中相关的库很少表现完全一致。
擦肩而过。 少数回归源于分支漂移或 rebase。在一个每周有数百个 pull request 的仓库里,一个开了好几天的会话会不断累积变更和冲突。这次移植需要数千次 rebase;即便成功率非常高,也仍会有失败。
还有那些慢吞吞的家伙。 最后一组在功能上是正确的,但速度更慢。有些回归丢失了已有的效率优化,比如记忆化、完全异步等待,或有界日志流式传输。另一些则在 Rust-TypeScript 边界引入了迁移特有的开销:冗余序列化、加锁、轮询、无界原生并发,以及原生到宿主侧的跨界调用。这些并不是后续可以优化的机会点;每一项都是移植过程中引入的性能退化。有一次只读扫描把 260 MB 的事件日志做了深拷贝,而不是借用它。在持续的事件流量下,另一个实现只完成了所请求通道刷新的一小部分,同时为每个事件保留一个异步句柄,直到 V8 耗尽堆内存。
几十个回归听起来很多。但在一项生成了超过 800,000 行代码的移植中,坦白说,我惊讶且欣慰的是我们没有遇到多一个数量级的回归。我们还可以通过观察公开的 github/copilot-cli 和 github/copilot-sdk 仓库中 issue 的趋势,来了解这些问题是否被广泛“感知”到。在这里,我们将 1 月到 8 月期间开启的 issue 分类为质量相关,条件是它们带有 bug 标签,或者标题中使用了常见的故障术语,如“bug”“regression”“crash”“hang”“timeout”“broken”或“incorrect”。与移植工作进行期间及之后相比,这些水平与移植工作之前基本没有变化:
这并不是一个可用性指标,也不是对逃逸缺陷的精确计数……正如回归问题本身一样,一个问题所代表的含义、其范围等等都可能存在大量差异。但它确实提供了一个有用的检验:尽管产品变更的规模异常庞大,面向产品的问题反馈渠道在迁移期间并未显示出有意义的质量问题激增。
只要能编译,就能编译
前面我提到了一个流行的梗:Rust 因其严格的编译器而非常适合 AI 生成的代码。还有一个相关的流行 Rust 梗:如果代码能编译,它就是正确的。并非如此。我们已知的回归问题清单对此给出了很好的回应:语料库中的每一个回归问题都被合并到了 main ,这意味着它们都成功编译了。编译器接受了这些有 bug 的版本,因为对编译器而言,它们每一个都是合法的 Rust。
编译器可以证明某个 f64 被一致地使用。但它无法知道仓库 ID 必须序列化为整数,也无法知道一个以尾部 .0 序列化的时间戳会被线路另一端的每一个强类型 SDK 拒绝。编译器可以防止它所能看到的代码中出现未同步的数据竞争,但它无法阻止一个完全同步的状态机编码出错误的状态。一个队列可以被锁保护,却仍然让两个发送方各自认定对方会将其排空。事件可以在线程之间安全传递,却仍然以错误的顺序到达。一个同步的 napi 函数可以是内存安全的,却仍然阻塞 Node 的主线程长达一分钟。编译从定义上讲也几乎无法检测到那些不存在的东西。当一次 rebase 悄悄移除了一个防护及其测试,或者当一个进程启动器忘记了抑制控制台窗口弹出的 Windows 标志时,编译器不会提出异议。它也对每次读取都克隆一份 250 MB 的事件日志没有任何意见。编译器检查的是你写的程序在内部是否自洽。它无法检查你是否写完了整个程序、是否保留了旧的契约、是否按正确的顺序调用、是否满足了宿主未明说的要求,或者是否以可接受的成本完成了工作。
这绝不是反对 Rust 编译器的论据。与任何静态类型语言一样,编译器消除了一大类机械性错误,并为智能体提供了一个极其有用的内循环。但“只要能编译,就是正确的”只有当作笑话时才有用。
性能,性能,还是性能
那么,这一切换来了什么?这次移植刻意保持了行为不变。它并非要重新设计算法或修复 bug;事实上,我反复把智能体从机会主义的优化方向上推开,因为同时改变语言和行为会让你很难判断究竟是哪一个把你搞坏了。不过,这次重写的一个关键目标确实是性能和可扩展性(此外还有可靠性等因素)。当人们问我为什么要用 Rust 重写运行时,我的回答往往是这样的:“我并不是要转向 Rust,我是要摆脱 Node.js 和 V8。”这一转变确实显著提升了我们的性能表现。
在移植前后,我通过 C# SDK 对运行时的多个场景进行了基准测试(TypeScript、Python、Go、C#、Java 和 Rust SDK 都通过相同的传输架构接入同一个引擎)。基线是移植前的 SDK 和 CLI 构建版本,其中 TypeScript 运行时由 Node 托管,并通过 stdio 访问。8 月 21 日的结果使用的是 Rust 运行时,既作为进程外服务器,也通过 FFI 在进程内加载。这是对交付系统的端到端比较,而非试图隔离语言变更所带来的影响;同一时期还落地了其他改动,因此这些数字需要打些折扣来看。
每个计时的轮次都发往运行在 localhost 上的一个确定性聊天补全服务器,产生固定的小规模响应。换句话说,这些数字刻意剔除了模型推理和网络延迟。它们衡量的是我们所改变的那部分:客户端启动、进程启动、会话创建、事件处理、持久化、拆除等。
“客户端、会话、单轮”这个数字是最容易直观感受到的。它指的是创建一个客户端、创建一个会话、执行单轮交互,然后拆除一切。进程外开销的很大一部分来自启动 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 消耗总计约 1363 亿 token,其中包括约 1306 亿缓存输入读取 token、约 42 亿缓存输入写入 token、约 9 亿全新输入 token,以及约 6 亿输出 token。所有这些 token 的账单金额约为 $120,000。
当然,这些 token 并不会自己花掉。使用开发者大量时间来引导这些智能体的成本也应计入其中。不过,我并没有把所有时间都投入到这个项目上。智能体式开发很大程度上是“赶紧提交然后等待”;提交一个提示词,让编码智能体自行其是,时不时查看一下以便可能引导它,但在它完成之前,去做其他事情。这意味着开发者不再一次只处理一个编码任务;他们把等待时间重叠起来,从而能够同时处理许多事情。在移植窗口期内,这些 Rust 移植 PR 约占我向所有贡献仓库提交的 PR 的 20%。如果我们粗略估计 PR 的占比近似于我所花时间的占比,那大约相当于三周专门用于移植的时间。
换句话说,这次移植工作的大致账单约为 120,000 美元的归属 token 支出,外加三周的开发者时间。
话虽如此,端到端的 Rust 迁移并非完全由一位开发者完成;这是一项团队协作的成果。 @stevesandersonms 提供了 napi-oop 的设计与实现,即临时的进程外互操作性层,以及六个 SDK FFI 实现中的五个, @edburns 提供了第六个。 @roji 实现了 SDK 的打包,以便正确发布和使用 Rust 二进制文件。 @caarlos0 一直在帮助将 Rust 代码拆分为许多小的子 crate,以缓解开始变得棘手的构建时间问题,而 @criemen 则帮助改进了资源缓存,以加速 CI 和本地构建。 @devm33 、 @examon 、 @MRayermannMSFT 、 @dereklegenzoff 以及其他人为无数 pull request 的审查和批准提供了帮助。而所有为 copilot-agent-runtime 仓库做出贡献的人,在世界于他们脚下发生巨变之时,始终给予了支持与配合。
经验教训
这次经历强化了几条超越本次重写本身的经验教训。以下是我们下次会带入的一些做法:
· 目标必须被清晰、完整地陈述出来。 我们早期的指令过于模糊。“把 XYZ 组件移植到 Rust”被理解成只处理热路径,或者只处理逻辑部分,而智能体一再把 I/O 和编排视为超出范围。一旦我们明确说明最终状态是一个由 100% Rust 代码库构建的原生二进制文件,即便我们想保留也没有任何留给 TypeScript 的执行环境,它们就变得擅长得多,能够自主地朝着这个目标推进。
· 端到端测试绝对、毫无疑问地至关重要。 除了一个例外,所有涉及功能缺失的回归,以及许多其他回归,都是由于缺乏足够的端到端测试造成的。对于任何此类性质的移植,你都必须拥有可用于验证移植正确性的测试,而且这些测试本身不能在移植过程中被重写,否则你就会失去你的判定基准。我们这次移植工作的初始计划就指出了这一点,并指出我们需要在开始移植之前大幅改善我们的 E2E 测试状况。我们做到了,但做得还不够。我们确信,如果在开始移植之前增加更多的 E2E 测试,真正专注于确保大多数有意义的行为都被它们覆盖,我们一路上的回归会比实际出现的更少。
· 保护判定基准免受智能体影响。 不能允许改动实现的智能体同时通过削弱测试、更新快照、提高兼容性基线或套用逃生舱标签来悄悄重新定义正确性,至少不能在缺乏监督的情况下这样做。尽可能让行为契约保持独立,把敏感的护栏置于单独的所有权或审批之下,并用具有不同失效模式的检查层层叠加,这样单一个错误就不足以让一次重大回归上线。
· 先翻译,再重新设计。 保持行为不变并沿用现有算法,可以让同时变动的变量数量保持在可控范围内。一旦旧实现和过渡期的脚手架被清除,就可以在一个稳定的基线上重新设计归属关系、并发和性能。我曾几次偏离这一原则——出于不安分,或难以对同事说不,或坚信这次情况不同——事后看来,每一次我都后悔。每一次偏离都比坚持原路线付出了更多的回归问题、更多的时间或更多的 token。
· 把反复出现的失败转化为未来的成功。 AI 智能体会偏离正轨:当它们偏离时,要从中学习。当某种失败模式出现两次时,它就应该被写入常驻指令、可复用技能、评测、受保护的基线,或者 harness 本身。
· 当智能体进入循环时,开发者的内循环更重要,而不是更不重要。 对于我们开发者来说,我们生活和呼吸都在内循环中——我们能多快做出改动、构建、测试、迭代。当其中的工具链环节耗时过长时,我们会感到沮丧。人们很容易认为,当智能体在做更底层的工作时,这一点就不再适用。但事实恰恰相反,它更加适用。AI 智能体完成这些任务中思考和编写代码的部分极其迅速,但它们仍然需要构建,仍然需要测试。而且,随着它们在思考和编写上花的时间更少、在快速验证的内循环中花的时间更多,它们用于构建和测试的时间占比实际上在增加。花一些时间在前期优化内循环,并针对多件事情同时发生的情况来优化它(例如,就像你同时在多个 worktree 中处理多个任务一样)。你日后会感谢自己做了这项投资。
接下来是什么?
它成功了。五月时还完全是 TypeScript 的执行运行时,到了八月已经完全是 Rust,而且整个过程持续交付给真实用户,而不是在最后以一次令人胆战心惊的整体切换落地。
我并没有简单地让一个 AI 智能体“把整个代码库从 TypeScript 移植到 Rust”。即便这是整个行业的发展方向,我们也绝对还没到那一步。相反,智能体让一整类项目变得可行。在一个运行中的系统里就地重写,产出数十万行生产级 Rust 代码, main ,由一名工程师在团队支持下完成——这在智能体出现之前是不会被批准的提案。那需要一整个团队和一两年的时间,它还要与那个团队本可以交付的每一项功能竞争,而且它会输(说实话,也应该输)。智能体把成本降到了让这个项目变得可行的程度。
这次移植本身已经完成:运行时的生产实现 100% 是 Rust,临时的内部 TypeScript/N-API 接缝已经移除。不过,我们仍有许多想做的事:进一步改进构建系统和开发者内循环、清理移植后的结构、围绕 Rust 的所有权与并发模型重新设计,以及追求进一步的性能提升。这次移植是一次翻译,而且是有意为之(也是以此为目标促成的),我们尽可能让行为保持接近 100% 一致,而不是趁机修复额外的 bug、重构组件,或进一步提升性能和可扩展性(超出重写本身自然带来的部分)。在微观层面,大部分代码已是地道的 Rust,但在宏观层面,仍有相当多最初用 TypeScript 编写的算法披着 Rust 的语法外衣。如今这些算法底层的约束已经改变,重新审视这些决策,才是真正有意思的收益所在。
最让我兴奋的是这次移植所开启的可能性。SDK 现在可以直接加载进六种语言中任意一种的宿主进程,依赖链中不再有 Node.js 或 V8,也不需要再监督第二个进程——而这正是我们从采用该 SDK 的合作伙伴那里听到的最常见的单一摩擦点。运行时实例的成本只有过去的零头,意味着宿主在耗尽机器资源之前可以运行多得多的并发会话。而且运行时现在可以去到 Node.js 永远无法跟随的地方,覆盖从云端到桌面、到设备、再到嵌入式系统的整个谱系。这些都不是终点。它是我们如今得以在其之上构建 GitHub Copilot 未来的基础,而在三个月里看着智能体重写运行它们自己的那个引擎之后,我很期待看到它能走多远。
