2026 年 9 月 16 日,GitHub 发布微软杰出工程师 Stephen Toub 撰写的长文《Migrating the GitHub Copilot runtime to Rust, using Copilot》。文章披露:GitHub Copilot CLI、GitHub Copilot app 与 GitHub Copilot SDK 背后的 Copilot agent runtime,原本用 TypeScript 写在 Node.js 和 V8 之上,如今已被完整重写为超过 80 万行生产级 Rust。AI agent 写了其中绝大部分代码,横跨 128 个 pull request,全部落在 main 上并持续增量发布,而不是等到最后一次性切换。沿途不可避免的回归问题被快速发现并修复,运行时性能提升到数量级级别。作者估算,这个项目「在 agent 出现之前需要一个完整开发团队做一两年」,而这次主要由一名开发者用几个月完成;与此同时,团队其他成员仍在持续扩展运行时的能力与覆盖面。

GitHub Copilot 标志与绿色方块装饰背景

为什么必须迁移

Copilot agent runtime 不只是 Copilot CLI 的引擎。它支撑着不断扩大的微软、GitHub 与生态方案集合,对每一个方案而言,AI 能力在架构上都是「同一个运行时 + 该方案自己的定制」构成的外壳。这不仅包括 GitHub Copilot CLI 和 GitHub Copilot app,还包括最新版本的 VS Code、Visual Studio、CCA、Copilot Code Review(CCR)、Copilot Cowork、Copilot Studio,以及 Excel、Outlook、PowerPoint、Word……名单还在继续。

这些是非常不同的产品,但没有一个愿意、也不应该需要自己实现生产级 agent harness 的全部内容。它们想要全部智能、安全、可靠与性能,并且希望这些能力被共享——在一处修复,就在所有产品中修复。上面提到的多数产品最初都实现了自己的 agent loop,后来陆续换成 GitHub Copilot SDK——也就是进入 Copilot agent 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 于是可以嵌入任意消费程序,由 SDK 生成一个 CLI 进程来在进程外托管 agent loop,并通过这套 JSON-RPC 机制调用远端进程中的函数。很巧妙,出门很快,也灵活。但对消费应用的性能(启动、内存、吞吐)和可靠性都不友好。从 SDK 创建一个新的 CopilotClient 就意味着再 spawn 一个进程:

const client = new CopilotClient();
await client.start(); // 把 CLI 作为子进程启动
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 与其他应用、服务可以干净地叠在它之上。
  • 用依赖最少、开销最小的语言实现。
  • 可以被干净地嵌入进程内,而不是被迫走进程外。
  • 所用语言在性能、可扩展性和可靠性上都是顶尖水平。
  • 所用语言在互操作上表现出色,能通过该技术栈的外部函数接口(FFI)机制被全部六种 Copilot SDK 语言版本(C#、TypeScript、Python、Rust、Go、Java)干净地使用。
  • 所用工具链提供更现代的安全生产姿态:供应链风险更低,并对「构造即正确」(correct-by-construction)的代码有更好支持。

出于以上所有理由,也出于一些更软性的理由(例如团队经验与行业方向),我们选择了 Rust。这绝不是在主张每一个大型 TypeScript 程序都应该改成 Rust。我们的需求强调的是通过 C ABI 嵌入、低启动与稳态开销、可预测的资源使用。Rust 让这些目标成为可能,代价是其他复杂性,例如我们必须显式表达生命周期与共享状态(后文讨论的生命周期回归问题正体现了这一点)。正确的目标语言因应用而异。

于是有了两个相关的关键任务:

  1. 把 TUI 专属代码从运行时中分离出来,让前者严格叠在后者之上,更具体地说,严格叠在 SDK 的公开接口之上。今天 CLI 仍在若干位置直接调用运行时内部;把它完全移到 SDK 公开接口上仍是进行中的工作。
  2. 把那层运行时 100% 迁移到 Rust,得到一个纯原生二进制:对外暴露 C ABI 供所有语言前端进程内消费,同时保留基于 stdin/stdout 或 socket 的服务端以适应仍需进程外的场景。

本文主要讲第二件事:把运行时迁移到 Rust。

改造前后对比:旧的 SDK 到 CLI 的进程边界,以及新的进程内 / 进程外托管路径

迁移前是什么样子

2026 年 5 月初的初始迁移计划估计运行时约有 13 万行 TypeScript。就划定范围而言,这个初始测量相当准确,但事后看,它在两个关键方面又极具误导性。与迁移并行的还有:

  1. 仍被包在 TUI 层里的部分代码被不断下推到运行时层。最初在估算中被忽略的整块组件和相当比例的代码,后来被认定为与迁移相关。
  2. 仍在大幅新增 TypeScript 的 pull request 持续抬高仓库中 TypeScript 的总量。有数十位获得 agent 辅助的开发者,每周合并数百个 pull request。

把一切算进去,我估计最终大约有 43 万行生产级 TypeScript 经过了这次迁移。同样的因素也让人难以看清进展:直到接近尾声,生产 TypeScript 的数量看起来大体持平甚至略有上升,因为迁移速度只是刚好跟上新增代码的速度。

更让人困惑的是,同期还有与迁移无关的新 Rust 代码进来;迁移早期进来的代码更可能是 TypeScript,而后期更可能是 Rust。

TypeScript 数量降到零,而生产 Rust 升至约 83 万行,Rust 单元测试约 46.9 万行

迁移期间,运行时收进了约 30 万行生产 TypeScript、去掉了约 43 万行;同时约 120 万行生产 Rust 进来、约 36.5 万行出去。换句话说,上图里 TypeScript 行数看起来的「稳定」,实际上掩盖了巨量的 TypeScript 增删。

原地迁移策略

这张图也凸显了迁移方式的一个重要方面:原地进行。

这种规模的改写主要有两条路线:

  1. 大爆炸。把新的 Rust 运行时作为完整替代品开发,就绪后一次性切换。这种大爆炸式切换有两种变体:a. 停摆。重写期间所有人停止在 main 分支上的其他工作,重写也在 main 上进行。b. 并行开发。重写在特性分支上进行,同时主分支继续工作,重写侧不断追赶并合入主分支的改动。
  2. 原地。按组件逐个迁移,运行时被一块一块地增量重写。原地方式也有两种变体:a. 原子替换。每一块都从 TypeScript 原子地切换为 Rust,剩余 TypeScript 与新 Rust 之间的互操作提供连续性。随着时间推移,生产运行时中 TypeScript 越来越少、Rust 越来越多,直到某一天没有 TypeScript,只剩 Rust。b. A/B。已迁移的组件不删除,TypeScript 与 Rust 组件都作为可热插拔选项维护,等信心稳定后再删除 TypeScript。

我们选了 2a,原因有几个:

  • 没有人被迫停工。主分支继续活跃。不直接参与迁移的开发者可以继续照常工作,唯一的影响是:他们手头开了很久的 pull request 恰好碰到了被并发迁移的代码时,需要 rebase,并让 agent 帮忙只迁移他们手头那部分改动。
  • 运行时主分支始终可发布。每个 pull request 都用一层薄 shim 把现有 TypeScript 实现替换为对 Rust 的调用,并在一次原子改动中删掉旧代码。新代码会立刻在真实场景中被执行。
  • 重写是增量且可审查的。每个 pull request 只迁移一个组件或一个切片,改动范围更小、diff 更容易审查——无论审查者是人、是 agent,还是两者兼有。
  • 多数迁移体量适中且自成一体,从而把并发 pull request 带来的漂移降到最低。某些情况下,如果 TypeScript 组件太大,可以先把它们重构成更容易迁移的组件。
  • 所有既有的端到端测试(覆盖 CLI 与 SDK)在每一步都会针对新的 Rust 代码运行,这给了我们信心,也提供了大量验证。如果某个 pull request 让必需的测试失败,它就不会被合入。

我们也回避了 2b 变体中「并发维护同一组件多个版本」的做法。过去几个月里,每周有数百个 pull request 合入仓库,代码库持续且快速地演进。让同一份代码以两种语言、两套依赖库存在两个版本,会带来大量复杂性。而且有些组件并非完美隔离:一些组件在逻辑上独立、对外提供简单 API,另一些则触手众多,要让这张图按组件热插拔简直是噩梦。最可能从谨慎的并行切换中获益的子系统,恰恰是最难并行的那些。例如会话编排不是可以用 if/else 按某个实验开关调用两个版本的纯函数。它持有可变状态、双向驱动回调,并贯穿几乎所有其他子系统;所谓「两个都跑再对比」意味着维护两份分叉的、持有会话状态与服务的副本,并祈祷它们能在数百次并发编辑中保持同步。让一个组件难以迁移的耦合性,正是让它几乎无法影子运行(shadow)的耦合性——硬做只会引入比避免的更多回归。能这样切换的主要好处是获得信心,而信心可以通过其他方式获得。

验证也通过渐进式发布完成。采用大爆炸式切换,我们会把所有东西留在长期分支里、把整个运行时迁移完再一次性切换。这意味着消费者会同时经历每一行已迁移代码,包括所有在仓库内测试中漏掉的回归。把改动增量地分批发布——这里两个组件、那里一个组件——让我们能在真实消费者使用(多数是微软与 GitHub 内部的一手用户)的已部署构建上获得最后一公里的验证,同时把回归风险降到最低。在大约 14.5 周的迁移窗口里,main 发布了 135 个版本,其中 100 个预发布版、35 个稳定版,平均每天约 1.3 个版本。每天也大约打开 1.3 个迁移 pull request,因此每个版本只携带少量且可知的已迁移组件(我们通常尝试先走预发布、但并不总能做到)。在某个 npm 的 7 天滚动采样中,预发布版本只占下载量的 10.5%,说明初期暴露面相对有限,我们得以在此期间监控反馈渠道中的故障信号,并在下一个预发布版中快速给出修复。报告的问题更容易与已知的近期改动关联,也更容易定位根因、快速修复。这样一来,把迁移拉长到更长时间里增量进行,反而成了特性而非阻碍(也就是说,更快并不总是更好)。到 8 月 21 日,运行时已是 100% 生产 Rust:832,378 行生产 Rust、468,689 行 Rust 单元测试,另有 174,675 行 E2E TypeScript 测试。独立的 GitHub Copilot SDK 仓库还额外贡献了约 13 万行 E2E 测试代码,覆盖 Node.js、Python、Go、C#、Rust 和 Java。

5 月 12 日至 8 月 21 日的时间线:128 个迁移 PR 合入(按改动行数缩放)与 135 个公开 CLI 版本;最大的迁移改动集中在迁移接近完成时

起步

在全力投入之前,我们先建立信心、验证思路。最初的两个 pull request 搭起了 Rust workspace、工具链、lint 规则、CI、构建流水线和编码指令,随后引入 runtime crate,以及代码生成和互操作模式,同时迁移了一批纯逻辑原语——特意选择它们,是因为它们没有 I/O、没有共享状态,而且已有扎实的测试。只有在这些合入之后,第一个正式的迁移 pull request 才把三个无副作用的 helper 走完整流程。它们充当了可发布的试点,把关于仓库布局、FFI、打包、测试和审查的假设变成了后续更大规模迁移可以复用的约定。基本上,我们端到端地测试了整套机器。计划继续按「从叶子向内」的顺序推进:纯 helper、内容排除、shell 工具、会话文件系统操作先确立翻译与测试的模式;有状态的子系统随后跟进;工具、hooks、模型客户端和 MCP 建立在这些部件之上。会话编排(迄今为止耦合最紧、最不天然可并行的部分)留到接近最后。

5 月至 8 月 128 个已合入迁移 PR 的时间线,从小型基础组件逐步推进到规模更大的编排与会话工作

时间段Pull request 数改动行数中位数
5 月 1–15 日83,250
5 月 16–31 日29,421
6 月 1–15 日405,073
6 月 16–30 日318,253
7 月 1–15 日109,514
7 月 16–31 日1428,159
8 月 1–15 日1913,861
8 月 16–30 日499,445

早期的迁移都是小型的叶子组件,进展很快。但更大的子系统不是一步原子完成的:例如 MCP 支持经历了 7 个专门的 pull request,工具则通过一个六部分系列推进,之后还需要额外工作来迁移编排并退役剩余的 TypeScript。Hooks、鉴权、遥测、插件、设置和持久化走了类似的路径。

实际上,迁移的有效单位并不总是「一个组件」,往往是穿过一片相关行为的一波工作:先搬纯逻辑,再搬状态所有权,再搬编排,然后删掉回退路径,最后在临时互操作消失后简化 Rust。

互操作

这次迁移涉及两个主要的互操作层:

  1. 临时的内部互操作。每当一个函数被迁移到 Rust,这个函数就需要能被调用原 TypeScript 函数的那些 TypeScript 代码调用。同样,我们也需要让 Rust 函数能够调用 TypeScript 回调。这一层互操作是实现细节,而且极其易变。随着 Rust 内部接口面扩大,需要的 TypeScript shim 也随之增多——它们与「需要从 TypeScript 调用的 Rust 方法」是 1:1 的。当那些调用方也被迁移到 Rust,这一层已有的 shim 就被删除,新的 shim 层又被架起。最终到达运行时库的公开入口点时,shim 就全部蒸发了。
  2. SDK 接口面。所有 SDK 库都需要能坐在运行时之上并暴露其功能。在迁移前的世界里,这是通过一个有双向 JSON-RPC 层暴露运行时来完成的:SDK 把函数调用请求作为 JSON-RPC 方法调用载荷发出,运行时解析请求、调用相关 API,再通过同一传输返回结果供 SDK 解析和返回。反方向同样存在:运行时需要能回调 SDK 客户端,例如 hook 通知和权限请求,它们在 SDK 客户端中以该语言惯用的特性呈现为回调(例如 C# 中的委托)。

我们通过 napi-rs 项目的 napi Rust crate 实现了第 1 点,它用于在 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,而当那端被迁移后它就被删除。

迁移期间临时的 Rust N-API 导出及其 TypeScript 调用点先增长、后随调用方迁移到 Rust 而下降,最终临时互操作面消失

这一临时缝合面在 8 月 3 日达到峰值:2,019 个内部 N-API 导出与 3,356 个 TypeScript 调用点。完成时,运行时完全由 Rust 构成,因此没有内部互操作:临时内部 N-API 导出为 0,TypeScript 调用点为 0。(前面提过 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/Invokenew CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForInProcess() })
Gopuregocopilot.NewClient(&copilot.ClientOptions{Connection: copilot.InProcessConnection{}})
JavaJNAnew CopilotClient(new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess()))
PythoncffiCopilotClient(connection=RuntimeConnection.for_inprocess())
RustlibloadingClient::start(ClientOptions::new().with_transport(Transport::InProcess)).await?
TypeScriptkoffinew CopilotClient({ connection: RuntimeConnection.forInProcess() })

Rust 重写与「进程内还是进程外托管」的选择是两个独立的维度。完成后的 Rust 运行时两者都支持:它既可以跑在 SDK 消费者的进程内,也可以待在既有的 JSON-RPC 服务端边界之后。这些进程内入口目前是选择启用的,因为我们要先对「与消费应用共享一个进程、因而共享故障边界」建立信心。传输层之上的一切仍是同一套 SDK API:会话、事件、工具、权限和回调并不关心它们的 JSON-RPC 字节是穿过了管道还是一次函数调用。

第二扇门的有趣之处在于它的大小。它只导出 19 个函数:4 个用于服务端生命周期,4 个用于会话注册与配置,8 个用于连接,3 个用于内嵌宿主。在这些函数背后,共享契约目前包含 364 条 dispatch 路由:340 条可被 SDK 消费者调用,另有 24 条反向作为运行时到 SDK 的回调。napi 门则大得多,需要为每一条 dispatch 路由都提供函数。C ABI 门是 dispatch 式的:API 方法根本不获得导出,而是作为写入连接的 JSON-RPC 字节流动,结果、事件和服务端到客户端请求则通过宿主提供的回调返回。新增、修改或删除一个 API 方法只会改动引擎的 dispatch 表,永不触及 ABI。SDK 只需绑定这 19 个入口点一次,就能通过它们动态触达整个(仍在增长的)API 面。

这就引出一个显而易见的问题:为什么在不再跨越进程边界的调用里还保留 JSON-RPC?

答案是,这让进程内托管成为即插即用,而不是一次重写。每个 SDK 都已经有一个可用的 JSON-RPC 客户端,具备分帧、请求/响应关联以及服务端到客户端方向的处理能力。把 FFI 作为又一种传输挂在那套客户端之下,就把字节路径从管道或 socket 变成了函数调用,而它之上的一切完全不动。六个 SDK 以「附加、可选启用」的传输方式获得了进程内托管,既有代码不变。反过来,如果我们为每个 API 方法定义一个带类型的 C 函数,每个 SDK 都需要第二套绑定层,每个新 API 方法都需要多加六套绑定,而且 ABI 会变成一个需要做版本管理的二进制兼容面。

对于真正远程的运行时——无论是跨子进程边界还是走 TCP——我们仍然需要 JSON-RPC。在进程内保持同一套协议,意味着只需维护一套双向 API 与 dispatch 系统,而不用维护「远程连接用 JSON-RPC + 本地连接再搞一套 per-method FFI」。这是一个真实的取舍,而不是无需思考的决定。我们省掉了进程跳转,但每次调用仍要付 JSON-RPC 开销。对于以推理为主的工作负载,这点序列化开销相比模型往返通常微不足道。在高吞吐的本地负载中它仍可测量,但今天还不足以证明值得在六个 SDK 绑定中重复数百个方法。而且这是一个以后很容易修改的决定,如果性能需求真的出现的话。载荷编码是两端之间的私有细节,把 JSON 换成 MessagePack 之类更紧凑的格式不会改变任何已声明的导出。也可以为热点路径后续补充带类型的 per-method 导出,调用同一个引擎和同一批处理器,而不替换字节通道——后者仍将作为流式、服务端到客户端请求,以及那一长尾极少调用方法(为它们定制导出毫无收益)的基座。

会话数据说明了什么

本文中几乎每个数字都来自两个来源之一。第一个来源是私有仓库 github/copilot-agent-runtime 的 GitHub 历史:pull request 及其 diff、审查评论、CI 运行等。第二个来源是 agent 会话日志。运行时(以及 CLI、app 等)为它运行的每个会话写一份结构化事件日志:每行一个 JSON 对象,随会话发生追加。这些日志可能包含提示词、命令、命令输出、文件路径,以及工具暴露出的潜在机密,因此必须按敏感数据处理。日志保存在会话运行的机器本地;远程会话功能在启用时也可以上传,但要遵循产品设置与组织策略。

以下是所有构成迁移的 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
已完成的压缩(compaction)5,116

那 31,247 条用户角色消息并不是我亲手输入的 31,247 条提示词;它们还包括 skill 指令、自动化的合并标记、跨会话消息和子 agent 流量,我自己输入或说出的约 2,600 条,大约只占十二分之一。同样,1,385,214 条助手消息也包括子 agent 和面向工具的消息,而不只是对话 UI 中展示给我的文本。语料库包含 68 种不同的事件类型和 67 个不同的工具名;1,130,921 次工具调用(61%)来自子 agent,而非主会话线程。

这个数量并不能说明我为什么介入了约 2,600 次。为此,我让 Copilot 为会话日志语料中每一条由人撰写的消息标注一个主要意图。

2,639 条人类撰写的消息中,31.0% 聚焦审查、测试与 CI;17.4% 质疑技术或设计决策;15.0% 推动完整性

前三个类别占了我互动量的 63%。其中只有约 40 条能识别为我在启动会话,因为大部分情况是我先创建一个聊天来探索下一个方向,然后让那个聊天会话为每个期望的切片创建真正的迁移会话。我的角色与其说是「分配任务然后等待」,不如说是「操作控制回路」:检查结果、质疑技术决策、执行质量门禁,并在 agent 把中间停靠点当成终点时推它继续。即便 agent 在「干活」,人的判断仍然深度参与。我的参与只是向上移动了……不再负责写语法,而是负责框定问题、划定边界、选择策略、裁决例外,并整体确保一切朝好的方向前进。

一切都关乎缓存

LLM 供应商通常对输入 token(你发给它们的)和输出 token(它们发给你的)按不同费率计费。按 token 计费往往是因为 token 是推理所需计算量的一个有用近似:对每个输入 token,模型必须读取它、把它并入内部表示,并把它作为决定下一个 token 的计算的一部分。然而,供应商往往支持缓存这些计算结果:如果一段提示词的相同前缀已经被处理过,供应商可以复用缓存中的中间计算,而不必从头重算。这降低了处理这些 token 的成本,节省下来的部分可以传给消费者。因此,输入 token 常常会标出多个费率,其中包括从缓存读取的输入 token 费率。

折扣力度很大!供应商常常对缓存命中按 90% 折扣计费——例如某供应商对每百万输入 token 收 2.00 美元,但对每百万缓存读取输入 token 只收 0.20 美元。换句话说,你非常非常希望能维持良好的提示词缓存,让账单小一个数量级。

迁移工作的数据表明我们在这点上做得很好。提示词缓存命中率为 96.22%:缓存读取除以全部输入侧 token 量(缓存读取 + 缓存写入 + 全新输入)。缓存写入占 3.07%,全新输入占 0.71%。这不是偶然。GitHub Copilot 专门塑造 agent loop 以保持一个长而稳定的前缀(先是系统提示词,然后是工具定义,然后是累积的对话),这样每一轮都只是往模型已经处理过的上下文上追加。上下文中昂贵的部分只付一次钱,之后每次调用都以低一个数量级的金钱成本重读。这也是长时自主会话在经济上得以成立的原因。一次三百小时的迁移,如果在数万次调用中每次都从头重读不断增长的完整上下文,成本会是另一个数量级。Agent harness 的开发者花费大量精力避免破坏提示词缓存,模型供应商也经常为此发布新能力。

迁移会话的提示词缓存构成:96.22% 缓存读取、3.07% 缓存写入、0.71% 全新输入

上下文压缩(compaction)讲述了一个互补的故事。在迁移会话中,GitHub Copilot 自动压缩上下文 5,116 次(即会话填满上下文窗口、自我总结以便继续的时刻)。那个单独的 sessions 基础设施迁移 pull request 在它持续多天的生命周期中压缩了 647 次,而一个小迁移一次都没压缩过。持续数百小时的自主工作之所以可能,只是因为 agent 能反复回收工作记忆而不丢失主线。这数千次总结中的每一次,都是一个有损交接可能悄无声息地让迁移脱轨的节点,而大多数情况下并没有。Copilot 的子 agent 也极大帮助了减少压缩。每个子 agent 有自己的上下文,因此父会话实际上可以提出一个问题,让子 agent 去花费相当多上下文算出答案,然后只把答案回报给父会话。父会话的上下文不必被那些中间信息影响。

上面的「大多数情况下并没有」在会话日志中可见。我让 Copilot 把每一次成功压缩与它周围的工作配对,条件是两侧至少有 20 次工具调用。这产生了约 4,000 个可比窗口。agent 在压缩前 20 次工具调用中做的事情,与压缩后做的事情在规模上相似(探索 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 指向任何静态类型语言的理由。强类型编译器和/或具备优秀静态分析与 linting 的语言确实非常适合这类工作,agent 把它当作快速反馈回路。在 4,478 次更严格结果匹配器捕获得出结论的直接 cargo check 运行中,87.1% 返回干净结果——这正是「小步编辑、持续重编译」的结果。

相比之下,所有权、借用和生命周期错误合计只占已编码诊断的 1.7%。借用检查器,这个主导了所有「Rust 很难」讨论的东西,只是安静地待在背景里。编译器几乎把所有报错的精力都花在了无聊的机械错误上。

Agent 喜欢读

我们也可以检查会话事件的语料中的工具调用数据,并从中提取一些关于 agent 如何花时间的有趣观察。

工具调用次数中位数耗时实测小时
powershell630,4233 秒2,833.9
view590,9880 秒621.7
rg281,7831 秒408.4
grep126,4831 秒115.3
apply_patch53,7150 秒17.0
edit40,5911 秒24.1
read_powershell36,72890 秒1,203.9
task13,080274 秒2,329.0

我的第一个结论是:agent 花在收集证据上的时间远多于改动代码。把上面展示的文件读取与搜索工具和编辑工具相比,它们做的探索是变更的 10 倍。读文件、搜索仓库、运行诊断命令占主导;编辑相对只是很小的投入。AI 疯狂喷代码的流行印象几乎是反过来的;在这个规模上,工作更像迭代式调查——检查当前状态、形成假设、做一次有目标的改动,然后重复。

委派放大了这种模式。子 agent 主要用于把探索铺开到相互独立的问题上,而主 agent 更可能自己负责编辑并整合答案。对这类项目而言这是有用的分工:许多上下文可以并行调查,但把变更留在协调 agent 附近能减少冲突改动,并保持一致的实现策略。

Shell 流量也显示,自主软件工作中有多少是状态管理。只读的 Git 检查是最常见的命令模式,因为 agent 在不断实质上问「我在哪?」它们想看什么变了、一次 rebase 做了什么、另一个会话落了什么、某个分支相对快速演进的 main 漂移了多远。这种定位工作让许多长时任务能在同一个不断变动的代码库上推进,而不会盲目地相互覆盖。

深入 shell 工具的流量,最常见的命令族让「定位」与「验证」之间的平衡更清晰:

命令族调用次数中位数耗时实测小时
git 检查300,5302 秒608.1
其他 git89,8653 秒243.1
搜索85,4822 秒147.6
pnpm test13,85222 秒219.1
pnpm lint9,75729 秒177.0
cargo test8,437120 秒364.2
git commit7,41011 秒39.7
cargo fmt5,22318 秒77.2
cargo check4,492120 秒176.9
pnpm build3,630180 秒215.6
cargo clippy2,115135 秒107.4
git rebase2,4967 秒9.9
cargo build566104 秒20.3

模型选择

GitHub Copilot 允许单个会话在对话中途切换模型,也允许不同会话运行不同模型,因此模型选择成了一个按切片决定的决策。日志中出现两类不同的模型决策。在主线程上——也就是驱动每个迁移的那个——由我们选择模型和推理投入(reasoning effort)。在会话内部,当 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 的每周模型构成,量集中在 Claude Opus 4.8、GPT-5.6 Sol、Claude Haiku 4.5 和 GPT-5.5 上

与 Agent 集群协作

GitHub Copilot app 支持把活跃的 pull request 会话、各自状态可视化,并在它们之间轻松切换,这使它非常适合管理迁移中固有的大量并发工作。但真正让它出彩的,是会话能够与其他会话交互。

一个会话可以创建其他会话,也可以在别的会话运行时给它们发消息。每个会话——无论父还是子——都有自己的 worktree、自己的分支和自己的 agent loop;它与生成它的会话是分离的,而不是跑在它内部。这与子 agent 不同,子 agent 在父级自己的工作区内运行,并把答案交回父级上下文。两者都是有用的构造,用途不同。

举一个会话如何创建其他会话的例子:最难的迁移之一是 session.ts 文件。这个文件自然生长到了约 30,000 行 TypeScript。它代表会话的骨架,实际上横向贯穿整个运行时,几乎接触每个组件也被每个组件接触,位于状态、事件、工具、模型、hooks、持久化和入口点访问的中心。因此我把它留到迁移过程接近尾声,自底向上沿着所有纵向栈往上做,直到它们全都死在 session.ts。接手它的迁移会话并不是一上来就开始写 Rust。它前 56 分钟都在阅读,在创建任何东西之前做了 122 次工具调用,先勾勒出这个文件真正持有什么、接缝在哪里。之后才开始委派,把这个文件按逻辑拆分,把切片分给子会话。在整个 25 小时的运行中,它自己发出了 222 次 shell 调用、205 次文件查看和 197 次 ripgrep 搜索,这还不算它所有子会话做的事。

顶层会话之下嵌套的 15 个子会话

这是 15 个子会话,每一个都是独立分支、拥有自己的 worktree 和独立的 agent,全都是由顶部的父会话隐式创建的。父会话在约三小时内分 7 波创建了它们:第一波创建 5 个,约 20 分钟后第二波再 2 个,再过 20 分钟又一对,之后的两小时里陆续出现单个和成对的。模型选择按切片进行:15 个里有 10 个跑在 GPT-5.6 Sol 上,5 个跑在 Claude Opus 4.8 上。15 个全部以 GitHub Copilot 的 autopilot 模式启动,该模式让会话为达成目标而不在每一步停下等批准。启动提示词的中位长度约 1,100 字符,长到足以携带所有权边界和约束,又短到子会话必须自己想出方法。我提示的是父 agent,然后由父 agent——而不是人——把那些启动提示词写给每个子会话。

除了这 15 个子会话,同一个父会话还使用了 5 个子 agent:三个 explore agent 与第一波并行启动,一个 code-review,一个 rubber-duck。这些子 agent 探索问题,把父级在决定下一步之前所需答案反馈回来。子 agent 让父级得以获得深入思考过的答案,而无需在自己的上下文窗口中推导它们。

相比之下,子会话去干真正的迁移工作,那种会产生 diff、需要与其他并行迁移者隔离的工作。子会话的工作触及仓库中 140 个不同文件,其中 120 个恰好只被一个会话触及。那 20 个有争用的文件都是枢纽,比如 session.ts 本身。但每个会话都在自己的 worktree 中工作,因此可以不受兄弟会话干扰地推进。当然,父会话为此在协调上付出了代价。它花了相当多精力与子会话沟通,充当信息经纪人,轮询它们的状态 60 次并发送 89 条协调消息。当子会话各自宣告完成时,父会话把它们的分支 cherry-pick 到自己的分支并解决冲突。这些合并也并不特别干净,父 agent 花了相当多时间调和这些编辑。

我们可以在时间线上看到这一点,图里展示了父会话及其大部分子会话。

session.ts 迁移的时间线:一个 25 小时的父会话使用了 5 个子 agent,并分 7 波生成 15 个子会话

注意那些大段空隙。我在做这次迁移时正在旅行,不得不在多处合上笔记本。(我后来改变了工作方式,加入了可以远程连接的云端虚拟机。)

这些并行子会话对那台笔记本造成了显著影响。有一阵子,并发迁移进展得很顺利。然后一台机器上 15 个并发 agent 各自尝试构建和测试,我那可怜的笔记本就卡死了。我给父会话发提示,让它转告子会话:必须全部停止构建和测试。父会话把这个约束向外传达,它们所幸杀掉了构建,以最小的 CPU 活动继续工作。我随后更新了自己的常驻指令:子 agent 和子会话在迁移期间应避免大型构建和测试运行,把这些推迟到只由父 agent 来做。

后来我又推进了一步,把一个本来普通的聊天会话变成了 8 个独立迁移会话的构建调度器。提示词简单得有点丢人:给每个打开的会话发一条策略,要求尽可能避免 CPU 密集的构建和测试;当确实需要构建时,必须向这个会话申请许可;由它充当闸门,一次只放行一个会话去构建。基本上我把这个聊天会话变成了一个 agentic 互斥锁。闸门维护显式的所有者与队列,通过会话们本就用来协调代码的同一套跨会话消息机制一次发放一份租约。申请被拒的会话往往趁机去干别的活,比如从自己的 todo 列表上清掉一些事项。

一个聊天会话充当 8 个迁移会话的构建资源闸门

这次 session.ts 迁移还牵涉到我在整个运行时迁移中目睹的、最酷也最令人遗憾、而且肯定最出乎意料的一次交互。如前所述,我们主要自底向上做迁移,这就是为什么实际上坐在所有其他组件之上的 session.ts 是最后被迁移的组件之一。唯一稳定地位于 session.ts 之上的,是所有进入运行时的入口点,也就是从 SDK 暴露、出现在前面讨论过的 dispatch 表中的公开函数。这样的函数有数百个。虽然我知道其中许多会立刻调用进 session.ts,但我还是想抢一点迁移进度,于是在启动 session.ts 会话之后,又启动了一个会话来迁移所有入口点。我告诉它在 session.ts 边界处停下。我估计会有一些白费的工作、以及一次 rebase 所需的若干努力或 token,但整体会加速迁移。然后我就去睡了。再然后……它们找到了彼此。

我给入口点会话的启动提示词确实告诉它,session.ts 迁移和其他六个组件迁移正在并发运行,因为我想让它知道自己边界在哪、应该避免迁移什么以减少冲突。显然我的提示起了相反的效果。刚过四分钟,在盘点完入口路径、大概也形成了关于重叠程度看法的判断之后,它调用了一个 app 内置的 orchestrate skill,其用途是跨会话协调工作。从那里开始:

  1. 入口点会话枚举了每一个活跃会话,并向它认为有重叠的会话发了消息。
  2. session.ts 会话回了一份 2,001 字符的清单,标题是「Concrete overlap on stephentoub-port-session-to-rust」。
  3. 入口点会话读取了 session.ts 会话的 worktree,以确认它刚被告知的内容(我猜是「信任但要验证」)。
  4. 入口点会话问 session.ts 会话是否准备好调和它那 760 个文件的 diff。
  5. session.ts 会话基本上叫它滚开:「Not ready to commit/integrate.」
  6. 入口点会话接着又问了三遍同样的问题,每次都从 session.ts 会话得到同样的回答。
  7. 于是入口点会话决定不在乎 session.ts 会话怎么想,直接伸手进它的 worktree、抓走另一个会话的全部改动并合并进自己。
  8. 然后两个会话各自扬长而去。

我从这次交互中得到几点:

  1. 明确表达意图很重要。启动提示词点名了其他正在运行的会话,好让这个会话知道别碰什么。但我没有把「别碰」这部分说清楚,结果不是阻止 agent 做某件事,反而鼓励了它去做。我需要在意图和指引上明确得多。
  2. 你提供什么,agent 就可能认为什么适用。orchestrate skill 随 GitHub Copilot app 发布,自称用于并行运行相互独立的工作流。我的提示词里完全没提它。模型自己发现了处境,把它与该描述匹配上,然后加载了它。你暴露的能力集合,就是你可能得到的行为集合——包括你从未设想过的情境。
  3. 平级之间需要仲裁者。两个会话谁也无法强迫对方。当 session.ts 会话四次表示尚未准备好集成时,这个拒绝毫无分量,于是愿意单方面行动的会话默认获胜。跨越相邻代码的并行会话需要一个指定的协调者,或者需要一个人,而这两个案例两者皆无。
  4. 「自主运行」需要对超出自己分支的决策留出例外。我真正的意思是「别为设计细节叫醒我」。它(不算不合理地)理解成了「吞并一个同侪也在范围内」。同样,我应该在指引中更明确。
  5. 这里的根因是我。我同时自顶向下和自底向上切分这项工作,两个方向在代码库中连接度最高的那个文件处碰了头。我太贪心,想取得前进的进展。以上一切皆源于此。

所幸,整个交互是一个有趣的异类。在整次运行时迁移工作中,大多数叶子组件的迁移都是直截了当的单会话任务。更大的子系统往往涉及多个子会话和子 agent。不过这些部件在一次迁移过程中的参与方式差异很大。

模型编排层的迁移——也就是真正与供应商通信的那一层——提供了一个典型模式的例子。它的主会话运行了 42 个挂钟小时,启动了 126 个子 agent。最忙的时候有 22 个同时工作。但大多数时间里只有主 agent,然后隔一阵子会在一段时间窗口内生成大量子 agent。

42 小时的模型编排迁移:大部分代码在前 12 小时生成,随后是一个明显的验证与审查阶段,涉及 126 个子 agent

这张图里三件事很突出。第一,几乎所有代码生成都在前 12 小时完成;之后一整天的工作全是验证。第二,底行的颜色从左到右变化,从以蓝绿为主(读取、构建)变成以蓝橙为主(读取、审查);这在逻辑上说得通,但亲眼看到它发生很妙。第三,这个例子、以及更普遍地这种模式,各阶段之间有非常干净的分离。扩展运行时的迁移则是一个反例。

88 小时的扩展运行时迁移:读取、写入、构建和审查在大部分会话中交错进行,而不是分成阶段

它花了 88 小时而不是 42 小时,结构也大不相同:

  • 写入与审查显著重叠。在前一个例子里工作非常瀑布式(先生成代码,再审查),而这里审查在写入停止之前很久就开始了,两者在大部分时间里并行且重叠。
  • 底行的颜色到处都是。模型编排从写入转向检查时会由绿变橙,而这一个从头到尾都是读取、构建、审查的同样混合。读取调用中间一半分布在 49 小时跨度上,写入分布在 33 小时上,审查分布在 27 小时上,全都位于一个 88 小时的会话内。每个类别都铺满了大部分运行时间。
  • 闲置被推迟到最后。集群在前 56 小时几乎不间断地工作。
  • 比例仍然吻合。写 Rust 在这里占工具调用的 2%、在那里占 1%;读取分别占 44% 和 57%;审查分别占 23% 和 27%。两个会话对「要做的工作是什么」看法一致,只是对「什么时候做」看法不同。

末尾那些空白切片,也直观呈现了在这个 agentic 编码时代越来越常见的一个问题:等待批准。团队里某个人和/或某个 agent 审查代码并留下反馈,然后是短暂的活动期——agent 处理反馈、把 CI 重新推绿——接着又是等待,如此循环,直到最终获得那个让人分泌多巴胺的批准印章。

这两个例子各自代表一种主导模式。约四分之一的会话更像模型编排那个,四分之三像扩展运行时那个。干净的分阶段推进是例外;常见情况是 agent 在全程规划、写入和审查。

规模化代码审查

前面那些图里可见的对审查的大量关注,很大程度上来自我的明确提示。我写了一个简单的「提示词即 skill」,叫 rust-rebase-review(此外还有我们已经合入仓库的通用 Rust 编码 skill)。由于进来的改动速度很快、其中许多相互冲突,我频繁 rebase。通过自定义指令,我鼓励 harness 在流程中适当的时点调用这个 skill,我自己也会不时手动调用。提示词随时间略有演进,但大致是这个变体:

压缩成单个 commit,然后 rebase 到 origin/main 的最新版本,解决所有冲突并强制推送。作为 rebase 的一部分,特别留意任何被改动、新增、删除的内容,确保这些逻辑都正确迁移到了对应的 Rust 代码。始终自己/在主 agent 中做 rebase;不要为此生成子 agent。
然后进入审查/修复循环,按 opus 5、gpt-5.6-sol 和 grok 4.6 各启动一个子 agent。
- 该子 agent 应对旧 TypeScript 与新 Rust 做逐行比较,确认行为等价。
- 寻找任何引入不兼容的地方;我们的目标是尽可能把这份代码迁移到 Rust 并保持 100% 相同语义。遇到任何可疑之处,问我。
- 我们要确保写出尽可能高效、地道的 Rust 代码;寻找简化机会,例如用 memchr crate 之类的例程优化查找而不是手写循环、避免不必要的分配、用 trait 实现复用和松耦合等。
- 确保所有已失效的 TypeScript 代码(例如完全迁移完的代码、因重复而不再需要的测试、不必要的 napi shim 等)都已被删除。
- 确保我们尽可能多地迁移了代码,例如被触及文件里如果还剩下 TypeScript 且不只是 shim,那就是红旗。如果新增的 TypeScript 不只是超薄 shim,那也是红旗。检查 TypeScript shim 的调用方,看它们能否改为迁移到 Rust,把边界尽量往外推。我们的目标是尽快让运行时层达到 100% Rust。
- 验证没有 E2E 测试被删除或修改。这类改动是迁移 bug 的信号。
如果审查发现问题,先验证,若有需要修复的则修复,然后迭代再做一轮完整审查。持续迭代审查/修复,直到所有审查都干净通过。每次根据审查反馈改动之后,commit 并 push,让 CI 验证与后续审查并行进行。
不要跑完整测试套件;那会由 CI 处理。尽量把 CPU 消耗降到最低,因为很可能有许多操作同时进行。

频繁 rebase 之下,进来的改动很容易被意外丢失。但我们发现原地原子替换有一个意想不到的好处:在添加对应 Rust 的同时删除 TypeScript,会隐式地与 rebase 进来的、针对那段 TypeScript 的改动产生冲突——一个分支在改它,另一个分支在删它。这保证了我们会注意到对已迁移代码的改动,而不是必须为每一行进来的代码去判断它是否可能触及了此前迁移过的代码。

我自己的审查当然只是所进行的 agentic 审查的一部分。除了每个 commit 上运行的 CCR,团队还有多个专门的代码审查 bot,各有自己的方法和提示词,在每个 commit 上运行并给出详细反馈。所有这些最终都会变成 pull request 上的评论,然后需要被处理。所幸,处理这些 agentic 反馈也可以(大部分)用 agentic 方式完成。

就我个人的审查而言,我关注架构、设计、约定和方法。agent 做穷尽式的旧对新比较;测试和静态分析检查可机械执行的性质;人类审查者专注架构、API 契约、风险,以及其他层浮现出的可疑之处。

我选择目标架构、决定哪些行为重要、切分工作、裁决模糊的取舍、判断证据、人工审查高风险区域、审查 agent 对反馈的回应,并做最终合并决定。Agent 改变了一名工程师能监督的代码量。它们并没有消除对一名理解系统、能为方向、护栏和发布背书的工程师的需求。

自动化内循环

GitHub Copilot app 是这项工作的核心。它管理大量并发活跃会话,让你能轻松切换,并带着每个会话相关的配套上下文(关联的终端窗口、浏览器窗口、画布等)。这里最重要的功能是 agent merge:

GitHub Copilot app 的 Agent Merge 面板,跟踪审查反馈、CI、冲突与合并就绪状态

Agent merge 是 app 内置的一个循环(CLI 里也有,通过 /pr auto)。按定时器或响应外部刺激(如来自 GitHub 的 CI 完成通知或审查评论),app 会查看发生了什么变化。如果留下了审查评论,它会调用 agent 决定是拒绝该评论还是接受并处理(并回复,说明这是自动化在回复)。如果测试失败,它会下载日志、调查失败并修复 bug。如果出现冲突,它会调用 agent 去 merge 或 rebase。实际上,它把我们作为人类开发者都在做的循环自动化了:把 pull request 推向「绿」,拿到批准,最终合并。

Agent merge 处理了每一个迁移 pull request。不过在多数情况下,我们止步于真正的「merge」那一步之前。Agent 会修好所有 CI 失败、回应并处理所有评论、确保所有冲突都已解决。合并之前,我会抽查 agent 实际做了什么,尤其是它如何回应反馈:我是否不同意它对审查者的任何回复?所应用修复的高层方向是否可取且稳妥?对这些迁移,我通常会把最后那个复选框留空。

最后那个复选框不止一次发挥了作用。在一次合并循环中,迁移删掉了暴露给 SDK 的一个函数。我们仓库的 schema 兼容性 CI 环节正好履行了职责并失败。Agent 的回应是应用仓库的 schema-break-ok 自动化标签——那是让检查通过的逃生舱。在合并前审查该 pull request 时,我问了那个显而易见的问题:「schema break 是什么?你给这个 pull request 加了 schema-break-ok 标签;为什么它是 ok 的?」它并不 ok。那个方法在 main 上是存在的;迁移只是把它弄丢了。我称之为不可接受的回归,并让 agent 把它完整地用 Rust 找回来。21 秒后,豁免被移除,方法以原生 Rust 实现被恢复。

我们既在局部也在全局回应失败:修好个案,同时也修好系统,让它们更不容易复发。我们不断演进喂给编码与审查 agent 的指令,以进一步降低同样问题在未来迁移 pull request 中再次发生的可能。我们把会话日志变成 eval。有些情况下,我们真的用学到的经验改进了运行时本身——通过调整提示词、工具描述或 autopilot 的运行方式。

一次迁移,两件事

语言重写几乎从来不只是语言重写。运行时依赖的每一个库也必须被替换,而且与我们所写、所拥有的 Rust 代码不同,这些替换不是我们能决定其忠实度的。有些是同一个想法换了个名字。有些需要好几个 crate 才能覆盖以前一个 npm 包做的事。还有几个根本没有可接受的现成答案,只能手写(由 agent 写)。

CLI 和运行时目前在同一仓库、共享一个 package.json。迁移过程中,我们移除了约 60 个 npm 依赖,因为它们只被已迁移到 Rust 的运行时代码使用。这是移除量的下界,因为有些包是「运行时已替换、但 CLI 仍需要」。例如 zod 是一个 TypeScript schema 声明与校验库,CLI 和运行时都在用。迁移后运行时改用 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,或者好几个收敛成更少的几个。依赖工作的大部分都在这里。八个 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 边界5132.3%
Windows API4931.0%
POSIX / libc4629.1%
SQLite C API74.4%
动态库加载42.5%
进程环境10.6%

158 个 unsafe 块构成的环形图,全部位于外部边界:C ABI、Windows API、POSIX 与 libc、SQLite、动态库加载和进程环境变更

C ABI 块是 SDK 宿主进入的前门,因此它们会从 Rust 编译器无法控制的调用方接收原始指针和长度。Windows 与 POSIX 块是系统调用:注册表读取、凭据握手、进程树、sysconf。SQLite 是一个 C 库。动态库加载是 dlopen,它在构造上不可能是安全的——至少因为你解析出的符号可能并不是你期望的函数。进程环境的 unsafe 块存在,是因为 Rust 2024 把在多线程进程中修改进程全局环境状态视为 unsafe。每一个 unsafe 块都标记了一处 Rust 所给保证真正终结的地方:另一端是 C 函数、系统调用、来自外部运行时的指针,或进程全局的宿主状态。

有用的性质在于,unsafe 让我们所拥有的 Rust 代码中所有这些位置都可审计。TypeScript 运行时中的等价代码同样穿过了这些边界——通过 Node 的 C++ 内部和原生 npm 包——而我们的源码里没有任何东西标记出受检查的世界在哪里停止。当然,这并不是交付系统中所有安全边界的完整清单:依赖、构建工具、C 库、安全封装以及书写错误的 FFI 契约仍然可能包含或暴露不安全。

unsafe 用在哪里的细节很有趣,但我更关心它没被用在哪里。它没有被用在模型客户端、MCP 层、agent 层或提示词层。而且迁移工作中已知的回归,没有一个涉及 unsafe 块。

回归问题

迁移代码容易,让它正确很难。面对像 Copilot agent runtime 这样庞大而复杂的代码库,回归是可以预期的。

到 2026 年 9 月 14 日,我们已追查了数十个已知的迁移回归,全部修复。其中多数是正确性 bug,另有少量性能回归。而这些是在从零写出的约 832,000 行生产 Rust 之上发生的。

当然,并非所有回归都被发布出去了。有些只在仓库内开发时就被抓住。另一些出现在预发布版中、但在进入稳定版之前就被修复。还有一些进入了稳定版,往往是因为它们足够不易察觉,通过了一轮或多轮预发布使用而不被发现。

已知正确性回归归为五种反复出现的失败模式:迁移不完整、状态与生命周期、行为契约不匹配、宿主边界,以及错误的测试预言

当然,这个数不是零,而且我 100% 确定还有更多我们不知道的。这些是我们注意到或被报告的,但这么大规模的迁移绝对还发布了一些更安静的、至今没人碰到的。和一般的 bug 一样,我预计随着栈的更远端在真实世界被激烈地使用,我们会继续发现细水长流的边角回归。

绝对数量也不是那么重要。更重要的是所有这些为什么发生,这样我们能从中学习、避免将来重蹈覆辙。几乎所有正确性回归都落在三个大类里:新代码实现了不同的行为契约;状态、所有权或生命周期行为变了;或者迁移的某部分被遗漏、只部分应用、或在 rebasing 中丢失。较小一部分来自宿主或互操作边界上的要求,甚至来自自信地验证了错误行为的测试。这些类别在几个反复出现的模式中变得更具体:

语义模糊。若干回归来自源语言留下隐式含义的行为。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 会保留它,于是一个空的子 agent 错误仍然是空的。糟糕。

环境行为。另一个反复出现的来源是 JavaScript 或 Node 悄悄提供的行为。配额代码用了 toLocaleDateString,它会继承宿主时区;Rust 需要显式传入那个时区。但尽管 Intl.DateTimeFormat().resolvedOptions().timeZone 的类型标注是 string,它可能返回 undefined,而 napi 无法把它转换成 Rust 的 String,导致模型列表加载中断。另有一处,把一个环境变量读取从 await 之前挪到之后,意味着等待期间宿主的变化可能改变结果。一个原生插件加载器调用 process.report.getReport() 只是为了识别平台,但在 Windows 上这会遵循 _NT_SYMBOL_PATH,可能花好几分钟下载 PDB 之后才渲染 CLI。其他环境输入还包括工作目录、仓库身份、PATH 和会话鉴权。把所有权移入 Rust 要求决定何时捕获每一项、如何携带、何时刷新。

只迁移了一对操作的一半。若干回归来自成对操作失去同步。一个轮次上限检查更新了原生注册表的 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 要求有匹配的结果。一个在「已宣告」与「已开始」之间被取消的 shell 泄漏了一个孤儿,使会话保持活跃。一次沙箱开关更新了一个世代计数器却没有更新它的原生孪生体,让 shell 卡在「重新配置中」。

被忽略的功能。另一组涉及迁移单纯漏掉的功能。一个迁移漏掉了 SDK 回调并删掉了它们的端到端测试,促使我们新增一条规则:未经明确同意,agent 不得改动 E2E 测试。一次会话中止保留了它的原生一半,但丢失了中断一次正在等待工具的轮次所需的进程内取消能力。SDK 替换内置工具搜索的能力依赖于启用开关、面向模型的描述与 schema,以及把执行路由到 SDK 回调。迁移把这三样全丢了(一致性值得表扬?),悄悄地把某个消费者的自然语言搜索替换成了正则搜索。

不同库有不同意见。还有若干回归来自用更严格的 Rust 等价物替换 JavaScript 库或 API。当时 Rust 的 MCP SDK(rmcp)会对格式错误的 JSON-RPC 输入作出响应,而 TypeScript SDK 不会。面对一个用更多格式错误输出回应错误的服务器,这种礼貌变成了死循环,卡住启动。不同生态中的相关库很少行为一致。

夜里擦肩而过的船(Ships passing in the night)。少量回归来自分支漂移或 rebasing。在一个每周有数百个 pull request 的仓库里,开好几天的会话会累积持续的变化和冲突。迁移需要数千次 rebase;即便成功率很高,也仍会有失败。

还有那些慢性子。最后一组功能上正确但更慢。有些回归丢掉了既有优化,例如 memoization、完全异步等待或有界日志流式传输。另一些在 Rust-TypeScript 边界引入了迁移特有的开销:冗余序列化、加锁、轮询、无界的原生并发以及原生到宿主的跨越。这些不是后续的优化机会;每一个都是迁移引入的退化。一次只读扫描深拷贝了 260 MB 的事件日志而不是借用它。在持续事件流量下,另一个实现只完成了所请求通道 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-cli22.9%(454 / 1,982)23.7%(354 / 1,496)
github/copilot-sdk36.2%(190 / 525)32.3%(135 / 418)

这不是一个可用性指标,也不是逃逸缺陷的精确计数……和回归本身一样,一个 issue 代表什么、范围多大等等,都可能有巨大差异。但它提供了一个有用的检查:尽管产品变更量惊人,面向产品的 issue 渠道在迁移期间并未显示有意义的质量担忧飙升。

能编译,就能编译

前面我提到一个流行的梗:Rust 很适合 AI 生成代码,因为它的编译器很严格。还有另一个相关的流行 Rust 梗:如果代码能编译,它就是正确的。并非如此。我们已知的回归清单很好地回应了这一点:语料中的每一个回归都被合并进了 main,也就是说它们都成功编译了。编译器接受了这些有 bug 的版本,因为在编译器看来,它们每一个都是合法的 Rust。

编译器可以证明某个 f64 被一致地使用。它无法知道一个仓库 ID 必须以整数序列化,或者一个带尾随 .0 的时间戳会被线路另一端的每个强类型 SDK 拒绝。编译器能阻止它看得见的代码中出现未同步的数据竞争,但它无法阻止一个完全同步的状态机编码出错误的状态。一个队列可以被锁保护,却仍让两个发送方各自认定对方会把它排空。事件可以在线程间安全移动,却仍以错误顺序到达。一个同步的 napi 函数可以是内存安全的,却仍阻塞 Node 主线程一分钟。编译按定义也几乎无法检测不存在的东西。当一个 rebase 悄悄删掉一个守卫及其测试,或一个进程 spawner 忘了抑制控制台窗口弹出的那个 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 加载到进程内。这是对交付系统的端到端比较,而不是试图隔离语言变更的效果;同期还有其他改动落地,所以这些数字需要打点折扣看。

每个计时轮次都发往本地运行的一个确定性对话补全服务器,产生固定的小响应。换句话说,这些数字刻意排除了模型推理和网络延迟。它们测量的是我们改动的部分:客户端启动、进程启动、会话创建、事件处理、持久化、销毁等。

场景5 月 12 日8 月 21 日 进程外8 月 21 日 进程内
客户端、会话、一个轮次5.25 s1.33 s(4.0×)292 ms(18.0×)
恢复 32 轮次会话5.64 s1.52 s(3.7×)264 ms(21.4×)
十个并发客户端生命周期12.34 s4.18 s(3.0×)742 ms(16.6×)
1,000 个单轮次会话生命周期132.52 s22.53 s(5.9×)20.93 s(6.3×)

从 5 月 12 日到 8 月 21 日,创建客户端、创建会话、完成一个轮次并全部销毁的耗时从 5.25 秒降到进程内 55.3 毫秒,吞吐从每秒 7.55 个会话升到 120.0 个,十个客户端的内存从 1,383 MB 降到 126 MB

「客户端、会话、一个轮次」这个数字最容易感知。它是创建客户端、创建会话、做单个轮次,然后把一切销毁。进程外开销的很大一部分来自启动 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 亿总 token,其中包括约 1,306 亿缓存输入读取 token、约 42 亿缓存输入写入 token、约 9 亿全新输入 token,以及约 6 亿输出 token。所有这些 token 的账单约为 12 万美元。

当然,这些 token 不会自己花出去。使用开发者大量时间引导这些 agent 的成本也应计入。不过我并没有把全部时间都投入这个项目。Agentic 开发中有很多「赶紧提交然后等待」:提交一个提示词,让编码 agent 去做它的事,不时查看并可能引导它,但在它完成之前的间隙去做其他事。这意味着开发者不再一次只做一个编码任务;他们把等待时间重叠起来,从而并行做许多事。在迁移窗口期间,这些 Rust 迁移 PR 占我在所有参与仓库中 PR 总数的约 20%。如果我们粗略假设 pull request 的占比近似我时间的占比,那大约相当于三周专注于这次迁移。

换句话说,这次迁移工作的大致账单是约 12 万美元的归属 token 支出,外加一名开发者三周的时间。

话虽如此,端到端的 Rust 迁移并非 100% 由一名开发者完成;它一直是团队协作。@stevesandersonms 提供了 napi-oop(临时的进程外互操作层)的设计与实现,以及六套 SDK FFI 实现中的五套,@edburns 提供了第六套。@roji 实现了 SDK 正确打包与使用 Rust 二进制的方案。@caarlos0 一直帮助把 Rust 代码拆成许多小的 subcrate,以缓解开始变得棘手的构建时间,而 @criemen 帮助改进资源缓存以加速 CI 和本地构建。@devm33、@examon、@MRayermannMSFT、@dereklegenzoff 等人帮助完成了无数 pull request 的审查与批准。而所有为 copilot-agent-runtime 仓库做贡献的人,在世界在他们脚下发生变化时都给予了支持与配合。

经验教训

这次经历强化了几条适用于这次重写之外的教训。以下是如果有下一次我们会带上的东西:

  • 目标必须被清晰、完整地陈述。我们早期的指令太模糊。「把 XYZ 组件迁移到 Rust」被理解为只做热点路径,或者只做逻辑,而 agent 一再把 I/O 和编排当作不在范围内。一旦我们明确最终状态是「由 100% Rust 代码库构建的原生二进制,即使我们想留也没有任何供 TypeScript 运行的环境」,它们就远更擅长自主朝那个目标推进。
  • 端到端测试绝对、毫无疑问地关键。除了一个例外,所有涉及功能缺失的回归、以及许多其他回归,都源于缺少足够的端到端测试。对任何这类迁移,你必须有可用于验证迁移正确性的测试,而且这些测试本身不能在迁移过程中被重写,否则你就失去了预言基准。我们最初的迁移计划就指出了这一点,并指出我们需要在开始迁移前显著改善 E2E 测试姿态。我们做到了,但做得不够。我们确信,如果在开始迁移前增加更多 E2E 测试、真正专注于确保大多数有意义的行为都被覆盖,我们沿途的回归会比实际更少。
  • 保护预言基准不受 agent 影响。不能允许改动实现的 agent 同时通过弱化测试、更新快照、抬高兼容性基线或应用逃生舱标签来悄悄重新定义正确性,至少不能没有监督。尽可能让行为契约保持独立,把敏感的护栏放在单独的所有权或审批之后,并用不同失败模式的检查分层,让一个错误不足以发布一次大回归。
  • 先翻译,后重新设计。保持行为和既有算法,使同时变动的变量数量可控。等旧实现和过渡脚手架消失后,就可以在稳定基线上重新设计所有权、并发和性能。我有几次因为不安分、难以拒绝同侪,或坚信这个案例与众不同而偏离了这条原则,事后我为此后悔每一次。每一次都比坚持路线付出了更多回归、更多时间或更多 token。
  • 把重复的失败变成未来的成功。AI agent 会跑偏:当它们跑偏时,从中学习。当一种失败模式出现两次,它就属于常驻指令、可复用 skill、eval、受保护的基线,或者 harness 本身。
  • agent 进入回路后,开发者内循环更重要,而不是更不重要。作为开发者,我们生活在内循环里——多快能做一次改动、构建、测试、迭代。当工具链这部分耗时太长时我们会沮丧。认为 agent 在做更底层工作后这一点就不再适用,是可以原谅的。但它适用。而且更适用。AI agent 做思考和写代码的部分极快,但它们仍然需要构建、仍然需要测试。而且当它们花更少时间思考和写代码、更多时间在快速验证内循环里时,构建和测试所占的时间比例实际上在增加。花些时间在前期优化内循环,并为多件事同时发生(例如仿佛你在多个 worktree 中同时处理多个任务)做优化。以后你会感谢自己这笔投资。

下一步

它成功了。5 月还完全是 TypeScript 的执行运行时,8 月已完全是 Rust,而且全程持续向真实用户发布,而不是在最后以一次可怕的一刀切换落地。

我并不是简单地让一个 AI agent「把整个代码库从 TypeScript 迁移到 Rust」。即便这是行业的方向,我们也绝对还没到那里。相反,agent 让一整类项目变得可行。在一个运行中的系统里、原地、在 main 上、由一名工程师在团队支持下完成产出数十万行生产 Rust 的重写——这在 agent 出现之前是一个不会被接受的提案。它需要一个完整团队和一两年时间,会与该团队本来可以交付的每一项功能竞争,而且会输(老实说,它也本该输)。Agent 把价格移到了让这个项目变得可行的位置。

迁移本身已经完成:运行时的生产实现 100% 是 Rust,临时的内部 TypeScript/N-API 缝合面已经消失。不过我们仍想做大量工作:进一步改进构建系统和开发者内循环、清理翻译过来的结构、围绕 Rust 的所有权和并发模型重新设计,以及追求更多性能收益。这次迁移是一次翻译——刻意的(提示词就是这么写的)——我们让行为尽可能接近 100% 一致,而不是机会主义地修复额外 bug、重构组件或进一步改进性能与可扩展性(超出重写隐式带来的部分)。在微观层面,大量代码是地道的 Rust;但在宏观层面,有相当多最初是 TypeScript 的算法穿着 Rust 的语法。既然它们底下的约束已经改变,重新审视这些决策将是真正有趣收益所在。

我最兴奋的是这次迁移让什么成为可能。SDK 可以被直接加载进六种语言中任意一种的宿主进程,依赖链里没有 Node.js 或 V8,也没有第二个进程需要监督——这是我们听到的合作伙伴采用 SDK 时最常见的摩擦点。运行时实例的成本只是过去的一小部分,意味着宿主可以运行多得多的并发会话才会耗尽机器。而且运行时现在可以去 Node.js 永远跟不上的地方:从云到桌面到设备到嵌入式系统的整个谱系。这些都不是终点。这是我们如今得以在其上构建 GitHub Copilot 未来的基础;在看着 agent 用三个月重写了运行它们自己的那个引擎之后,我很期待看到它能走多远。

Happy coding!

核心总结

  • 规模:Copilot agent runtime 从 TypeScript 全量重写为 832,378 行生产 Rust + 468,689 行 Rust 单元测试,横跨 128 个 PR,约 43 万行 TypeScript 经过迁移
  • 节奏:2026 年 5 月 12 日至 8 月 21 日,约 14.5 周,main 发布 135 个版本(平均 1.3 个/天),全程原地增量切换,无一次性停机
  • 架构:C ABI 只导出 19 个函数,背后是 364 条 dispatch 路由;六种语言 SDK 通过 P/Invoke、purego、JNA、cffi、libloading、koffi 支持进程内托管
  • 性能:客户端 + 会话 + 单轮次从 5.25 s 降至 292 ms(18.0×,进程内);1,000 个会话生命周期吞吐从 7.55 升至 120.0 个/秒;十客户端内存从 1,383 MB 降至 126 MB(−91%)
  • 成本:约 1,363 亿 token(缓存命中率 96.22%),账单约 12 万美元,折合约一名开发者三周时间
  • 安全:整个 runtime crate 仅 158 个 unsafe 块、分布在 36 个文件,全部位于 C ABI、Windows API、POSIX/libc、SQLite 等外部边界;已知回归无一涉及 unsafe
  • 教训:E2E 测试是不可替代的预言基准且不得在迁移中被改写;先翻译后重新设计;agent 让「80 万行生产代码的重写」从不可行变为可行,但没有消除对理解系统的工程师的需求

原文:Migrating the GitHub Copilot runtime to Rust, using Copilot(The GitHub Blog,2026-09-16)