用 Copilot 把 GitHub Copilot 运行时迁移到 Rust
GitHub Copilot CLI、GitHub Copilot 应用和 GitHub Copilot SDK 背后都是 Copilot agent runtime(智能体运行时),这是一个可以嵌入各类应用和服务的智能体运行框架。它最初用 TypeScript 编写,运行在 Node.js 和 V8 JavaScript 引擎上,服务于如今的 GitHub Copilot 云端 agent(CCA)。随着运行时本身及其能力快速扩张,这套技术栈一直沿用至今。现在情况变了。我们借助 GitHub Copilot 应用和 Copilot CLI,把整个运行时完全重写成 80 多万行生产级 Rust 代码。绝大部分代码由 AI 智能体编写,横跨 128 个 pull request,分批合入 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……还有更多。这些产品差异极大,但没有任何一个愿意、也不应该去自己实现一套完整的生产级智能体框架。它们想要的是智能、安全、可靠和性能,而且希望这些能力可以共享——一处修复,处处生效。上一段提到的那些产品,最初大多各自实现了自己的智能体循环,但后来都换成了 GitHub Copilot SDK,也就是进入 Copilot 智能体运行时的入口。
这样一来,他们就能专注核心业务价值,把细节交给运行时处理。考虑到行业节奏飞快,而且所用的智能体循环(agent loop)必须在激烈竞争中始终保持最优,这一点尤为重要。所以,共享运行时是好事。问题出在被共享的东西本身。看 CLI,它在逻辑上就是一个终端用户界面(TUI),架在智能体循环之上。而实际情况是,整个技术栈都用 TypeScript 实现,以 Node.js 为框架,V8 作执行引擎,UI 则用 Ink 和 React。对于一个 TUI 应用来说,这是相当合理的选择;TypeScript 和 Node.js 门槛低、上手广,能实现极快的应用开发。对控制台应用的需求而言,启动速度、响应能力、吞吐量和内存占用这些性能影响也算合理。但如果你要把这套实现用到其他环境、面对其他约束——比如要求快速启动,或者因内存占用低而追求高服务器密度——那就远没那么合理了。CLI 及其运行时的架构也加剧了这些困难。整个行业跑得极快,在这种背景下,非常聪明的人会为了交付速度和市场覆盖做决策。Copilot CLI 最初编写和发布都很快,在这个过程中,TUI 和运行时相当交织,而不是分成独立的层。后来当需要一个 SDK 来以编程方式访问该运行时时,由于缺少清晰的层次分离,只能务实地把 SDK 架在 CLI 之上,尽管逻辑上你期望的是相反的架构。CLI 不再只能通过用户在命令行输入的指令来访问,而是增加了一种模式,可以无头(headless)运行,从标准输入读取类似指令,把响应写到标准输出。然后就可以用 JSON-RPC 协议,在外部进程和 CLI 之间编组函数调用。
这个 SDK 可以嵌入任意宿主程序,启动一个 CLI(命令行)子进程,把智能体循环放在独立进程里运行,SDK 通过 JSON-RPC 机制调用远程进程中的函数。思路巧妙,能快速上线,灵活性也好。但对宿主应用的性能(启动速度、内存占用、吞吐量)和稳定性来说并不理想。用这个 SDK 创建一个新的 CopilotClient,就意味着要再启动一个进程:
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)机制干净地调用它;工具链提供更现代的安全保障,供应链风险更低,对“构造即正确”的代码支持更好。
基于以上所有原因,再加上一些软性因素(比如团队经验和行业方向),我们选择了 Rust。这绝不意味着每个大型 TypeScript 程序都应该改成 Rust。
我们的需求强调通过 C ABI 嵌入、低启动和稳态开销,以及可预测的资源使用。Rust 让这些目标成为可能,代价是其他一些麻烦,例如必须显式表达生命周期和共享状态(后文讨论的生命周期回归就凸显了这一点带来的影响)。正确的目标语言确实因应用而异。随后开展了两项关键且相关的任务:把 TUI 专用代码与运行时分离,让前者严格叠加在后者之上,更具体地说,严格叠加在 SDK 对外接口之上。如今,CLI 仍在若干地方直接调用运行时内部实现;把它完全迁到 SDK 对外接口上的工作还在进行。把运行时层 100% 移植到 Rust,最终得到一个纯原生二进制:它暴露 C ABI,供所有语言前端在进程内调用,同时提供基于 stdin/stdout 或 socket 的服务器,以备仍需进程外调用的情况。本文主要讲第二项:把运行时移植到 Rust。
之前是什么样
2026 年 5 月初的初步移植计划估计,运行时大约有 13 万行 TypeScript。就估算范围而言,这个初步数字还算准确,但后来发现,它在两个关键方面又极具误导性。
移植的同时,仍裹在 TUI 层里的部分正被下推到运行时层。最初估算时忽略的整个组件和相当大比例的代码,后来又被认为与移植相关。那些带来大量新 TypeScript 的 PR 不断推高仓库中的 TypeScript 总量。数十名借助 agent 辅助的开发者每周合并数百个 PR。把所有因素都算进去,我估计最终约有 43 万行生产级 TypeScript 代码经过了这次移植。
这些因素也让整个过程中的进展难以看清:直到接近尾声,生产 TypeScript 的体量似乎都保持相对稳定,甚至略有增长,因为移植速度只是刚好跟上不断进来的新工作。
这就让情况更复杂了:在同一段时间里,除了移植代码,还有新代码不断进来。移植早期,进来的代码大多是 TypeScript;到了后期,进来的代码又大多是 Rust。整个移植过程中,运行时接收了约 30 万行 TypeScript 生产代码,同时删掉了约 43 万行;而 Rust 生产代码进来了约 120 万行,清掉了约 36.5 万行。换句话说,上图中 TypeScript 行数看起来平稳,其实掩盖了大量 TypeScript 代码的进出。
就地移植策略
那张图还突出了移植方式的一个重要特点:就地替换。这种规模的代码重写主要有两种思路:
- 大爆炸式:把新的 Rust 运行时做成一个完整的替代版本,等全部就绪后一次性切换。这种大爆炸式切换又有两种变体:
- a. 冻结世界:重写期间所有人停止在 main 分支上的其他工作,重写直接在 main 分支进行。
-
b. 并行开发:重写在功能分支进行,main 分支继续正常开发,重写分支不断追赶并合并 main 分支的变更。
-
就地移植:一个组件一个组件地移植,运行时被逐步重写。这种方式也有两种变体:
- a. 原子替换:每个组件原子性地从 TypeScript 切换成 Rust,剩下的 TypeScript 和新 Rust 之间通过互操作保持连贯。随着时间推移,生产运行时里 TypeScript 越来越少,Rust 越来越多,直到某天 TypeScript 彻底消失,只剩下 Rust。
- b. A/B 方案:移植组件时不直接删除,而是把 TypeScript 和 Rust 两个版本都保留为可热插拔的选项,等信心稳定后再删掉 TypeScript 版本。
我们选择了 2a,原因有几个:没人会因此停工,main 分支始终保持活跃。
没有直接参与移植工作的开发者可以照常工作。唯一会受到影响的情况是:某个长期未合并的 PR 恰好碰到了正在被并行移植的代码,这时他们需要做一次 rebase,让 agent 帮忙把在途改动也移植过去。runtime 的主分支始终保持可发布状态。每个 PR 都是一次原子替换:用一层薄薄的 shim 调 Rust 实现,替换掉原来的 TypeScript 代码,并删除旧代码,一步到位。新代码立刻就在实际场景中得到验证。整个重写是增量的、可审查的。每个 PR 只移植一个组件或一个切片,改动范围小,diff 也更容易审查——不管审查的是人、agent,还是两者配合。大多数移植规模适中、自包含,因此和并行 PR 之间的冲突漂移降到最低。有些 TypeScript 组件实在太大,就先重构成更容易移植的小组件。CLI 和 SDK 的全部端到端测试在每一步都会跑一遍新的 Rust 代码,给我们很大的信心和充分的验证。如果某个 PR 导致必须通过的测试失败,就不能合入。
我们也没有选择 2b 方案,也就是同时维护同一组件的多个版本。过去几个月里,每周有数百个 PR 合入代码库,代码一直在快速演化。用两种语言维护同一份代码的两个版本、依赖两套不同的库,复杂度会爆炸。而且这些组件并不是都完美隔离:有些逻辑上独立、API 简单,系统其他部分调用很方便;另一些则牵连甚广,想让整张依赖图按组件热插拔简直是噩梦。那些最适合谨慎并行切换的子系统,恰恰是并行最难做的。比如会话编排(session orchestration)就不是一个纯函数,没法靠某个实验开关做 if/else 来调用两个不同版本。
它持有可变状态,双向驱动回调,还几乎贯穿所有其他子系统。因此,想“新旧两套同时运行再对比”,就意味着要维护两份分叉的组件副本——而这个组件又承载着对话的状态和服务——还要祈祷它们在数百次并发修改中始终保持同步。耦合让组件难以移植,也正是这种耦合让影子运行几乎不可行,因为弄不好引入的回归比避免的还多。以这种方式替换的好处,主要是增强信心,而我们本来也可以通过其他方式获得信心。验证也通过增量发布来完成。若采用“大爆炸”式切换,就要把所有改动放在长期分支上,等整个运行时移植完,再一次性上线。这意味着用户会一次性遇到所有移植的代码,包括那些在仓库内测试中漏掉的回归问题。增量发布部分改动——这里两个组件,那里一个组件——让我们能在已部署的构建中拿到最后一公里的验证,用真实用户的使用情况(大多是 Microsoft 和 GitHub 内部的一手用户)来检验,同时把回归风险降到最低。在大约十四周半的移植窗口期内,main 发布了 135 个版本,其中包括 100 个预发布版和 35 个稳定版,平均每天约 1.3 个版本。每天还大约打开 1.3 个移植 PR,这样每个版本都只包含一小批可明确知道的移植组件(我们通常尽量先走预发布,但不总能做到)。在最近七天的 npm 样本中,预发布版本仅占下载量的 10.5%,这说明最初的暴露范围相对有限。我们一边监控反馈渠道,看有没有出问题的信号,一边在下一个预发布版中快速修复。上报的问题更容易和最近已知的改动对应起来,也更容易定位根因并快速修复。这样一来,花更长时间增量移植反而成了优势,而不是阻碍(也就是说,快并不总是更好)。
到 8 月 21 日,运行时已经 100% 是生产级 Rust:832,378 行生产 Rust 代码,468,689 行 Rust 单元测试,外加 174,675 行 TypeScript 端到端(E2E)测试。独立的 GitHub Copilot SDK 仓库又在 Node.js、Python、Go、C#、Rust 和 Java 上增加了约 13 万行端到端测试代码。
起步阶段
在全面投入之前,我们先建立信心、验证可行性。开头两个 PR 搭好了 Rust 工作区、工具链、lint 规则、CI、构建流水线和编码规范,接着引入 runtime crate,以及代码生成和互操作模式,同时移植一批纯逻辑组件——挑这些组件是因为它们不涉及 I/O、没有共享状态,而且原本就有完善的测试覆盖。这些落地之后,第一个正式移植 PR 才把三个无副作用的辅助函数走完整套流程。它们起到了试点作用,把关于仓库结构、FFI、打包、测试和代码审查的假设变成了约定,后续更大规模的移植可以直接复用。说白了,我们完整验证了整套机制。
计划按从叶子到根的顺序推进:纯辅助函数、内容排除、shell 工具集、会话文件系统操作,先确立翻译和测试的模式。然后是有状态的子系统,工具、钩子、模型客户端和 MCP 在此基础上搭建。会话编排(这是运行时中耦合度最高、最难并行处理的部分)放在最后。
| 时间段 | PR 数量 | 变更行数中位数 |
|---|---|---|
| 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 支持经过了七个专门的 PR 才完成,工具模块走了一个六部分的系列,之后还需要额外工作来迁移编排逻辑、清理剩余的 TypeScript。钩子、认证、遥测、插件、设置和持久化也走了类似的路。
实际上,真正好用的移植单位并不总是「一个组件」,而往往是一波推进:先搬纯逻辑,再搬状态归属,接着搬编排逻辑,然后去掉回退路径,最后在临时互操作层撤掉之后把 Rust 代码简化一遍。
互操作
这次移植中涉及互操作的主要有两层:
临时的内部互操作。 只要某个函数被移植到 Rust,就必须能被原本调用这个 TypeScript 函数的那些 TypeScript 代码调用。反过来,Rust 函数也需要能调用 TypeScript 回调。这种互操作只是实现细节,而且变动非常频繁。随着 Rust 内部接口面扩大,需要的 TypeScript 垫片(shim)也会变多——它们和需要从 TypeScript 调用的 Rust 方法是一一对应的。当这些调用方也被移植到 Rust 之后,原来那层垫片就会被删掉,换成新的一层。到最后触及运行时库的公开入口时,垫片就彻底消失了。
SDK 接口层。 所有 SDK 库都需要能架在运行时之上,暴露它的功能。移植之前,做法是通过一个双向的 JSON-RPC 层来暴露运行时:SDK 把函数调用请求作为 JSON-RPC 方法调用的载荷发出去,运行时解析请求、调用相应 API,再通过同一条通道把结果发回来,由 SDK 解析并返回。反方向同样存在——运行时也需要能回调 SDK 客户端,比如发送钩子通知和请求权限,这些在 SDK 客户端里表现为回调,使用各语言惯用的特性(例如 C# 中的委托)。
第 (1) 项目标我们是通过 napi-rs 项目的 napi Rust crate 实现的。这个项目专门用来在 Rust 里构建 Node 原生扩展。只要给函数加上 #[napi] 注解,napi-rs 的宏就会生成 N-API 注册所需的粘合代码,让这个函数可以被 JavaScript 调用,同时在生成的 index.d.ts 中为它生成对应的 TypeScript 声明。
同步的 Rust 函数会变成普通的 JavaScript 函数,async fn 会变成返回 Promise 的 JavaScript 函数,标了 #[napi(object)] 的结构体到了 JS 那一侧就是普通对象。数据还得双向流动。很多已经迁移完成的组件,短时间内仍要依赖那些还没迁移的东西,于是 Rust 反过来得调用 TypeScript。例如,Rust 里实现的工具要请仍然由 TypeScript 写的模型层做推理,或者触发一个钩子,或者为它想执行的某条命令申请权限。
napi-rs 用「线程安全函数」(threadsafe function)解决这个问题:Rust 代码跑在 Tokio 的工作线程上,可以借此回调 Node 主线程里的 JavaScript 函数。Node 把回调注册一次,Rust 持有它,需要反向调用时再拿出来用。这类回调天生都是临时的:它存在,仅仅因为另一端还是 TypeScript;等那一端也迁移完,回调就会被删掉。这层临时接缝在 8 月 3 日达到峰值:2,019 个内部 N-API 导出,对应 3,356 个 TypeScript 调用点。到迁移完成时,运行时已经全是 Rust,内部互操作也就不需要了:临时内部 N-API 导出归零,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 调用。两种方式选中的都是同一个进程内运行时,靠各语言自己的原生互操作机制接入:
| 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 客户端,包含分帧、请求与响应的关联、以及处理服务端到客户端方向的各种处理器。把 FFI 当作这套客户端下面多出来的一种传输方式挂上去,就能把字节通路从管道或 socket 换成一次函数调用,而上层的一切原封不动。六个 SDK 就这样以增量、可选传输的方式获得了进程内托管能力,已有的部分完全没动。反过来说,如果我们为每个 API 方法定义一个带类型的 C 函数,那么每个 SDK 都得再加一层绑定,每新增一个 API 方法就要多出六份绑定,ABI 也会变成一个我们必须维护版本兼容的二进制接口。而且对于那些真正是远程的运行时——无论是跨子进程边界还是走 TCP——我们仍然需要 JSON-RPC。在进程内沿用同一套协议,意味着只需维护一套双向 API 和分发系统,而不用搞成「远程连接走 JSON-RPC、本地连接再套一层逐方法 FFI」的双轨制。这是一个实实在在的取舍,而不是理所当然的决定。我们省掉了进程跳转,但每次调用仍要付 JSON-RPC 的开销。对于以推理为主的工作负载来说,这点序列化开销相比于模型往返本身,通常微不足道。在高吞吐的本地负载中它依然可以测量出来,但今天还不足以让我们有理由把上百个方法在六个 SDK 绑定里重复实现一遍。而且这个决定以后很容易改——如果性能需求真的浮现出来,随时可以调整。
载荷编码是两端各自的私有细节。把 JSON 换成更紧凑的格式,比如 MessagePack,也不会影响任何一个对外声明的导出。之后如果遇到热路径,还可以补上按方法导出的类型化接口,调用同一个引擎、同一批处理器,不必替换字节通道——它仍是流式传输、服务端到客户端请求,以及那些很少被调用的长尾方法的底层载体,为这些方法定制导出接口不值得。
会话数据说明了什么
本文中几乎每一个数字,都来自两个来源之一。第一个来源是私有仓库 github/copilot-agent-runtime 的 GitHub 历史记录:拉取请求及其 diff、审查评论、CI 运行等。第二个来源是智能体的会话日志。运行时(以及构建于其上的 CLI、应用等)会为每跑一次会话就写一份结构化事件日志:每行一个 JSON 对象,会话发生时就追加写入。这些日志可能包含提示词、命令、命令输出、文件路径,以及工具可能暴露出来的敏感信息,所以必须当作敏感数据对待。日志只留在跑会话的那台机器本地;如果启用了远程会话功能,也可以上传,但要受产品设置和组织策略约束。
下面汇总了所有构成迁移植工作的拉取请求里的数据:
| 指标 | 数量 |
|---|---|
| 事件 | 12,760,995 |
| 用户消息 | 31,247 |
| 助手消息 | 1,385,214 |
| 钩子(hook)开始和结束事件 | 6,438,562 |
| 工具启动 | 1,857,409 |
| 编译命令 | 23,096 |
| 测试命令 | 19,485 |
| Rebase 命令 | 2,496 |
| 提交命令 | 7,410 |
| 推送命令 | 5,554 |
| 完成的压缩(compaction) | 5,116 |
那 31,247 条用户角色消息,并不等于我亲手敲进去的 31,247 条提示词。它还包含技能指令、自动合并的节拍消息、跨会话消息,以及子智能体的往来通信。我自己打字或说出来的提示词大约只有 2,600 条,约占十二分之一。同样,那 1,385,214 条助手消息,也包含了子智能体和面向工具的消息,不只是会话界面里展示给我的文本。语料中共有 68 种不同的事件类型、67 个不同的工具名;其中 1,130,921 次工具调用(占 61%)来自子智能体,而不是主会话线程。数字本身没法告诉我,为什么我要亲自插进去大约 2,600 次。
为此,我让 Copilot 给会话日志语料中每条人工编写的消息分配一个主要意图。前三个类别占了我交互总量的 63%。其中只有大约 40 次能算作我发起的会话启动,因为大多数情况下,我会先创建一个聊天来探索下一步方向,然后让那个聊天会话为每个想要的切片创建实际的移植会话。我的角色不像是“派个任务然后等着”,更像是“操作控制回路”:检查结果、质疑技术决策、执行质量把关,在智能体把中途的停顿点当成终点时推它继续走。虽然“活儿”是智能体在干,但人的判断仍然深度参与。我的参与只是上移了一层……我不再负责写语法,而是负责框定问题、划定边界、选择策略、裁定例外情况,以及从整体上确保一切朝着正确的方向推进。关键就在于缓存。LLM 提供商通常对输入 token(你发给它们的)收一个价,对输出 token(它们发给你的)收另一个价。计费往往按 token 来算,因为 token 是衡量推理所需计算量的一个实用近似:对每个输入 token,模型必须读取它、把它纳入内部表示,并在决定下一个 token 的计算中使用它。不过,提供商通常支持缓存这些计算的结果,这样一来,如果提示的某个相同前缀已经被处理过,提供商就可以复用缓存中的中间计算结果,而不必从头重算。这就降低了处理这些 token 的成本,而省下来的钱可以传递给用户。因此,输入 token 往往有多个报价,其中包括从缓存读取的输入 token 的价格。折扣力度很大!提供商通常对缓存命中的部分按 90% 折扣计费,比如某个提供商对 100 万输入 token 收 2.00 美元,但对 100 万缓存读取的输入 token 只收 0.20 美元。
换句话说,想让账单少一个数量级,就得把提示缓存(prompt cache)维护好,这点至关重要。从移植过程中的数据来看,我们在这方面做得相当不错。提示缓存的命中率达到了 96.22%——也就是缓存读取量除以所有输入侧的 token 总量(缓存读取 + 缓存写入 + 全新输入)。缓存写入占 3.07%,全新输入只占 0.71%。这不是碰巧。GitHub Copilot 特意设计了 agent 循环,让它保留一段长而稳定的前缀(先是系统提示,然后是工具定义,再是累积的对话历史),这样每一轮只需往模型已经处理过的上下文后面追加内容。上下文中最贵的部分只付一次钱,之后每次调用重读时,成本就降了一个数量级。这也是长时间自主会话在经济上能成立的根本原因。假如一个长达三百小时的移植任务,在数万次调用中每次都从头重读不断增长的完整上下文,那成本会比我们实际看到的高出好几个数量级。搞 agent 框架的开发者花了大量精力去避免破坏提示缓存,模型厂商也经常推出新功能来帮他们做到这一点。
上下文压缩(compaction)则从另一个角度印证了同样的事。在整个移植过程中,GitHub Copilot 自动压缩上下文 5,116 次——也就是会话把上下文窗口填满后,自己做个摘要以便继续跑下去。那个单会话基础设施移植的 PR 持续了好几天,期间压缩了 647 次;而另一个小型移植一次都没压缩过。能持续几百小时的自主工作,靠的就是 agent 可以反复回收自己的工作记忆而不丢失主线。这几千次摘要,每一次都是一个有损交接可能悄悄让移植跑偏的节点,但多数时候并没有出问题。
Copilot 的子代理(subagent)在减少压缩次数上也起了很大作用。每个子代理有自己独立的上下文,所以父会话可以提一个问题,让子代理去消耗大量上下文算出答案,然后只把答案带回给父会话。
父级的上下文不必被这些中间信息拖累。上文说的「大多不会」,在会话日志里看得见。我让 Copilot 把每次成功压缩和它前后至少各有 20 次工具调用的工作配对起来,得到约 4000 个可对比的窗口。压缩前 20 次工具调用里,agent 干的事和压缩后规模差不多(探索:压缩前 46.5%、压缩后 48.1%;修改:压缩前 8.4%、压缩后 6.0%;验证:压缩前 4.7%、压缩后 4.0%;失败:压缩前 1.0%、压缩后 1.5%)。如果压缩经常丢掉思路,压缩后那一侧应该明显表现为「重新定位」:大量读取、编辑几乎停摆,因为 agent 在重新搞清自己在哪、该干什么。但实际情况只是朝这个方向微微偏移了一点。
是的,静态分析确实有用
有个流传很广的说法:Rust 特别适合 AI 生成代码,因为 Rust 严格的编译器能揪出模型的错误。会话日志让我们能验证这个说法,至少对这类移植任务而言。直接取自验证命令的结果里,记录了 8678 次 rustc 的报错码。四大类诊断占了 84%:
- 37%:名称与导入解析,主要是 E0425(「cannot find value in this scope」,在当前作用域找不到该值)
- 22%:方法或字段缺失
- 14%:类型不匹配
- 11%:trait bound 不满足
这些全都是普通的接线问题:名字写错一点、签名对不上、字段被改过名、抽象没实现。批量翻译很容易、也常常会不小心弄出这类错误,而编译器又能很快抓到它们。
但请注意这个列表里没有什么:没有任何真正属于 Rust 特有的东西。这四类都是静态类型的家常便饭,C#、Java 或 Go 的编译器照样能抓到,其中好几类报错还更友好,而且全都快得多。如果这就是「让 agent 用 Rust」的理由,那它其实是在说:让 agent 用任何静态类型语言都一样。
强类型编译器,或者一门具备优秀静态分析和 lint 能力的语言,天然很适合这类工作——智能体可以把它当成一个快速反馈回路。在 4,478 次严格结果匹配器捕获到结果的直接 cargo check 调用中,87.1% 顺利通过,这正符合“小步修改、频繁重编译”的节奏。相比之下,所有权、借用和生命周期错误加起来只占所有被归类诊断的 1.7%。借用检查器——这个每次聊到 Rust 难学都会被提起的主角——在这里只是安静地待在背景里。编译器几乎把所有报错精力都花在了枯燥的机械性错误上。
智能体喜欢阅读。
我们还可以查看会话事件语料中的工具调用数据,从中得出一些有意思的观察,看看智能体把时间花在了哪里。
| 工具调用 | 次数 | 中位数耗时 | 累计小时 |
|---|---|---|---|
| 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 |
我的第一个结论是:智能体花在收集证据上的时间,远远超过改代码的时间。把上面这些读文件和搜索类工具与编辑类工具对比一下,探索的量是修改量的 10 倍。读文件、搜索仓库、跑诊断命令占据了绝大部分时间,编辑只占相对很小的一部分。人们对 AI 的印象往往是“哗哗地往外吐代码”,但在这个规模上看,实际情况几乎正好相反——工作看起来更像是反复调查:查看当前状态、形成假设、做针对性修改、再重复。委派模式进一步放大了这一特征。子智能体主要用来把探索工作铺开到多个彼此独立的问题上,主智能体则更倾向于亲自负责编辑、并整合各方答案。对于这类项目,这是一种很有效的分工方式:可以在多个上下文中并行调查,但把修改操作留在协调智能体附近,能减少冲突改动,保持实现策略的一致性。
Shell 流量还揭示了一件事:自主软件工作里有很大一部分其实是状态管理。只读的 Git 检查是最常见的命令模式,因为代理们一直在问同一个问题——“我现在在哪儿?”它们想搞清楚哪些文件变了、某次 rebase 干了什么、另一个会话提交了什么、当前分支离飞速演进的主干漂了多远。正是这类定位工作,让许多长时间运行的任务能在同一个不断变动的代码库上并行推进,而不至于盲目覆盖彼此的改动。
再看 shell 工具流量的内部构成,最常出现的命令类别把「定位」与「验证」之间的这种平衡体现得更清楚:
| 命令类别 | 调用次数 | 中位耗时 | 实测总小时 |
|---|---|---|---|
| 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 允许同一个会话在对话中途切换模型,也允许不同会话跑不同的模型,于是选模型变成了按切片(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 应用能可视化正在进行的拉取请求会话、显示各自状态,还能轻松切换,因此特别适合管理移植过程中大量并发的任务。但真正让它出彩的,是会话之间可以互相交互。一个会话能创建其他会话,还能向正在运行的其他会话发消息。每个会话,无论父会话还是子会话,都有独立的 worktree、独立的分支和独立的智能体循环;它是独立于创建它的会话而存在的,而不是跑在父会话内部。这和子智能体不同:子智能体运行在父会话自己的工作区里,只把结果回传到父会话的上下文中。两种机制适合不同场景,各有各的用处。
举个例子说明会话怎么创建其他会话。整个移植过程中最难的一块是 session.ts。这个文件逐渐膨胀到了约 3 万行 TypeScript,它相当于会话的骨架,横跨整个运行时,几乎和每个组件都有交互,处在状态、事件、工具、模型、钩子、持久化和入口访问的中心。正因如此,我把它留到了接近移植尾声才动手,先从栈底往上、沿着各个纵向模块推进,直到它们全都交汇到 session.ts。
接下这个文件的移植会话,没有一上来就埋头写 Rust。它前 56 分钟都在阅读,创建任何东西之前先做了 122 次工具调用,搞清楚这个文件到底管什么、边界在哪里。之后才开始委派任务,把文件按逻辑拆分,一块块交给子会话。整个 25 小时的运行中,除了子会话做的所有工作,它自己还执行了 222 次 shell 调用、205 次文件查看和 197 次 ripgrep 搜索。这就是 15 个子会话,每个都有独立的分支、worktree 和智能体,全部由顶层父会话隐式创建。
父会话在大约三小时里分七批把它们建了出来:第一批五个,约二十分钟后第二批又是两个,再过二十分钟又是两个,之后两个小时里则零零散散地单个或成对出现。模型是按分片挑的:15 个里有 10 个跑在 GPT-5.6 Sol 上,5 个跑在 Claude Opus 4.8 上。15 个全部以 GitHub Copilot 的自动驾驶模式启动——这种模式下,会话可以一路朝目标推进,不必每一步都停下来等批准。启动提示词的长度中位数约 1,100 个字符,足够交代职责边界和各种约束,又不至于长到让子会话不用自己琢磨做法。给父代理下提示的是我,而发往各个子会话的那些启动提示词,是父代理自己写的,不是人写的。除了这 15 个子会话,同一个父会话还用了 5 个子代理:三个探索代理与第一批同时启动,一个代码审查代理,一个橡皮鸭代理。这些子代理负责把问题探究清楚,再把父代理决定下一步之前需要放进上下文的答案回传给它。有了子代理,父代理就能拿到想透了的答案,而不必自己耗上下文窗口去推导。相比之下,子会话动手干的是真正的移植活——这种工作会产出 diff,需要和其他并行的移植者隔离开。子会话的工作一共改动了仓库里 140 个不同的文件,其中 120 个只被某一个会话碰过。剩下那 20 个被争抢的文件全是枢纽型文件,比如 session.ts 本身。但每个会话都在自己的工作树里干活,所以不会被兄弟会话干扰。当然,这份清净是父代理用协调换来的。它花了不少精力跟子会话沟通,充当信息中转站:轮询它们的状态 60 次,发出 89 条协调消息。等各个子会话陆续宣告完成,父代理就把它们的提交摘取到自己的分支上,并解决冲突。这些合并也说不上干净,父代理为了调和各方改动花了不少时间。
我们可以在时间线上看到父会话及其大部分子会话。注意那些大段空白——移植期间我在旅行,中途不得不几度合上笔记本。(后来我改了工作方式,改用可以远程连接的云端虚拟机。)这些并行的子会话对那台笔记本的负担相当重。有一段时间,并发移植进行得很顺利。然后,一台机器上 15 个并发智能体各自尝试构建和测试,我那可怜的笔记本就彻底卡死了。我向父会话发消息,让它转告所有子会话,必须停止构建和测试。父会话把这条约束传达了下去,它们也很配合,杀掉构建进程,随后以最低的 CPU 占用继续工作。之后我更新了常驻指令:子智能体和子会话在移植期间应避免大规模构建和测试,把这类任务交给父智能体统一执行。再后来我又进了一步,把一个原本普通的聊天会话变成构建调度器,负责管理 8 个独立的移植会话。提示词简单得出奇:向所有开启的会话发送策略,尽量避免 CPU 密集型构建和测试;需要构建时必须向本会话申请许可;本会话充当闸门,一次只放行一个会话进行构建。基本上,我把这个聊天会话变成了一个智能体互斥锁(mutex)。闸门维护明确的持有者和队列,通过会话间已有的消息机制一次发放一个许可。申请被拒的会话通常会先去做别的工作,比如处理自己的待办清单。这次 session.ts 的移植,也是整个运行时移植过程中我见过的最酷、最伤感、也最出人意料的交互之一。前面提到过,我们主要采用自底向上的方式移植,所以像 session.ts 这种实际上位于所有其他组件之上的文件,反而是最后才移植的组件之一。
session.ts 之上唯一始终存在的东西,就是运行时的全部入口点——也就是从 SDK 暴露出来、并出现在前面提到的分发表里的那些公共函数。这样的函数有几百个。我知道其中很多会立刻调用 session.ts,但还是想提前动手移植。于是在启动 session.ts 那个会话之后,我又开了一个会话来移植所有入口点,并告诉它到 session.ts 的边界就停下。我估计这中间会有一些白做的功,rebase 时也要花些精力、烧掉一些 token,但总体上能加快移植进度。然后我就去睡觉了。结果……它们俩自己联系上了。
我给入口点会话写的启动提示词里,确实说明了 session.ts 移植和六个组件移植正在并行进行,目的是让它清楚自己的边界,知道不该碰哪些东西,尽量少起冲突。显然,我的提示词起了反效果。刚过四分钟,它盘点完入口路径、大致摸清了重叠情况,就调用了一个应用内置的 orchestrate 技能——这个技能的作用就是协调多个会话之间的工作。接下来的事情是这样的:
- 入口点会话列出所有活跃会话,给那些它认为有重叠的会话发了消息。
- session.ts 会话回了一份 2001 字符的清单,标题是「Concrete overlap on stephentoub-port-session-to-rust」。
- 入口点会话读了 session.ts 会话的工作树,核实刚才听到的说法(大概算是「信任但要核实」)。
- 入口点会话问 session.ts 会话,准备好处理它那 760 个文件的 diff 了吗。
- session.ts 会话基本是让它别烦自己:「还没准备好提交/合并。」
- 入口点会话又问了三遍,每次都得到同样的回答。
- 于是入口点会话决定不再理会 session.ts 会话怎么想,直接伸手进它的工作树,把对方的改动全抓过来合并进自己的分支。随后两个会话各自继续干活去了。
这次交互让我学到几点:把意图讲清楚很重要。启动提示里列出了其他正在运行的会话,本意是让这个会话知道哪些东西别碰。但我没把"别碰"这点说明白,结果非但没拦住 agent,反倒等于在鼓励它动手。我的意图和指导都该写得更明确。
你暴露出去的任何东西,agent 都可能认为它适用。orchestrate 技能随 GitHub Copilot 应用一起提供,自我介绍里写着用于并行运行相互独立的工作流。我的提示里压根没提它。模型自己判断了当前处境,拿去和那段描述一比对,然后就把它加载了。你暴露多少能力,就可能拿到多少行为,包括你从没设想过的场景。
平级的会话之间需要有人拍板。两个会话谁也压不住谁。session.ts 那个会话连着四次说不该集成,但这些拒绝毫无分量,于是那个愿意单干的会话就默认赢了。在相邻代码上并行的会话,要么指定一个协调者,要么有人盯着,而这两个都没有。
"自主运行"需要给那些伸到自己分支之外的决策留个例外。我真正想说的是"设计细节别来吵我",它理解成(也不算没道理)吞并同伴也在授权范围内。还是那句话,我的指导该写得更明确。
这里的根因是我自己。我同时从上往下和从下往上切分这项工作,两个方向在中间撞上了,撞的还是整个代码库里连接最密集的那个文件。我太贪心,想一步到位往前推。上面说的这一切,根子都在这儿。
好在,这次交互只是个有意思的例外。整个运行时移植过程中,大多数叶子组件的移植都是直接了当的单会话任务。更大的子系统通常要动用多个子会话和子 agent。不过在整个移植过程里,这些角色参与的方式差别很大。模型编排层的移植——也就是真正和各 provider 通信的那一层——就是某种模式的好例子。
用 Copilot 把 GitHub Copilot 运行时迁移到 Rust
它的主会话墙钟时间达到 42 小时。