使用 Copilot 将 GitHub Copilot 运行时迁移至 Rust
GitHub Copilot CLI、GitHub Copilot 应用 以及 GitHub Copilot SDK 均由 Copilot agent runtime 提供支持。该运行时是一个可嵌入应用和服务的智能体框架,最初基于 Node.js 和 V8 JavaScript 引擎用 TypeScript 编写,服务于当前的 GitHub Copilot 云智能体(CCA)。随着运行时及其能力的快速扩展,其技术栈一直保持不变。
如今,这一切改变了。借助 GitHub Copilot 应用和 Copilot CLI,我们将运行时完全重写,使用了超过 80 万行生产级 Rust 代码。大部分代码由 AI 智能体编写,分布在 128 个已合并至主分支的 Pull Request 中,并以增量方式逐步发布,而非等待最后一次性切换。期间出现的少量必然回归问题均被迅速发现并修复,与此同时,运行时的性能提升了数个数量级。在没有智能体之前,这样一个项目可能需要整个开发团队花费一两年时间,而现在主要仅由一名开发者在短短几个月内完成,其余团队成员则继续大幅扩展运行时的能力和覆盖范围。
为何需要移植
Copilot agent runtime 不仅是 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 等……名单还在继续。
这些产品的形态迥异,但它们都无意也无需各自实现生产级 Agent 框架的全部功能。它们需要的是共享的智能、安全、可靠性和性能,一处修复,处处受益。前一段提到的大多数产品最初都自建了 Agent 循环,但后来统一切换到了 GitHub Copilot SDK,将其作为接入 Copilot Agent 运行时的入口。这一转变让它们得以聚焦核心业务价值,将底层细节交给运行时处理。在当前行业高速迭代的背景下,面对激烈的竞争,采用始终最优的 Agent 循环模式显得尤为重要。
共享运行时是个好主意,但问题在于共享内容的本质。
以 CLI 为例,它本质上是在 Agent 循环之上构建的终端用户界面(TUI)。事实上,整个技术栈均使用 TypeScript 实现,基于 Node.js 框架,由 V8 引擎执行,UI 部分则采用 Ink 和 React。这对 TUI 应用而言是个合理的选择:TypeScript 和 Node.js 普及度高,能极大提升开发效率。对于控制台应用来说,在启动速度、响应性、吞吐量和内存占用方面的性能表现也尚可接受。但不幸的是,当这套实现被部署到其他环境,面临不同的约束条件,且对快速启动和极低内存开销带来的高服务器密度有严苛要求时,这种性能表现就显得力不从心了。
CLI 及其运行时的架构也带来了不少挑战。整个行业发展极快,在这样的背景下,聪明人为了交付速度和市场覆盖做出的决策往往带有权衡。Copilot CLI 最初为了快速上线,TUI 和运行时耦合得比较紧,没有拆分成独立的层次。后来需要提供 SDK 让程序化访问运行时时,由于缺乏清晰的分层,团队做了一个务实的选择:把 SDK 叠在 CLI 之上,尽管从逻辑上讲架构应该反过来。CLI 不再只接受用户在命令行输入的命令,而是增加了一种无头模式:从 stdin 读取命令,把响应写到 stdout,再用 JSON-RPC 协议在外部进程与 CLI 之间传递函数调用。这样 SDK 就可以嵌入任意消费程序,由它启动一个 CLI 子进程来承载 agent loop,并通过 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 及其他应用程序和服务可以干净利落地分层构建在其之上。
- 使用依赖最少、开销最小的语言实现。
- 以支持干净地在进程内嵌入的方式实现,而非被迫进行进程间通信。
- 使用在性能、扩展性和可靠性方面具有突出特性的语言实现。
- 使用擅长互操作的语言实现,从而能通过该技术栈的外接函数接口(FFI)机制,被所有六种 Copilot SDK 语言版本(C#、TypeScript、Python、Rust、Go、Java)干净地使用。
- 使用提供现代化安全态势、降低供应链风险并更好地支持通过结构确保代码正确性的工具链实现。
基于上述种种原因,加上团队经验和行业趋势等更柔和的因素考量,我们最终选择了 Rust。这绝非声称所有大型 TypeScript 项目都应该转向 Rust。我们的需求侧重于通过 C ABI 进行嵌入、极低的启动与稳态开销,以及可预测的资源使用。Rust 使得这些目标得以实现,但同时也带来了一些复杂性,例如必须显式地表示生命周期和共享状态(后文讨论的生命周期回归问题凸显了其中的隐患)。实际上,适合的目标语言因应用场景而异。
随后,我们启动了两个相关的核心任务:
- 将 TUI 特有的代码与运行时分离,确保前者严格地构建在后者之上,更具体地说,是严格构建在 SDK 的公开 API 表面之上。目前,CLI 仍在多处直接调用运行时内部接口;将其完全迁移到 SDK 的公开 API 表面是正在进行的工作。
- 将该运行时层完全移植为 100% Rust 代码,从而生成一个纯原生二进制文件,它通过 C ABI 暴露接口,供所有语言前端在进程内使用;同时也提供基于 stdin/stdout 或 socket 的服务器,以满足仍需在进程外运行的场景需求。
本文主要涵盖第二部分:将运行时移植到 Rust。
重写前的架构
2026 年 5 月初制定的初始移植计划估计运行时部分约有 130,000 行 TypeScript 代码。虽然这一初步估算在范围界定上是合理的,但事实证明它具有极大的误导性,主要体现在两个方面。在移植进行的同时:
- 原本封装在 TUI 层中的组件被下推到运行时层。那些在初始估算中被忽略的整个组件和大量代码,后来都被判定为与移植工作相关。
- 持续有大量贡献者通过 Pull request 提交新的 TypeScript 代码,导致仓库中的 TypeScript 代码量不断增加。每周有数百个 Pull request 由受辅助开发的开发者合并。
把这些因素都算进去,我估计大约有 43 万行生产环境 TypeScript 代码经历了这次迁移。但这些因素也让人很难看清迁移的进展:直到接近尾声,生产环境中的 TypeScript 代码量看起来都相当稳定,甚至略有增长,因为迁移速度几乎和新增代码的速度持平。
情况更加复杂的是,在同一时间段内,还有与迁移无关的新增 Rust 代码。迁移早期,新增代码以 TypeScript 为主,而到了后期,则以 Rust 为主。
迁移期间,runtime 新增了约 30 万行生产 TypeScript,移除了约 43 万行;同时新增了约 120 万行生产 Rust,移除了约 36.5 万行。换句话说,上图里 TypeScript 曲线的表面稳定,实际上掩盖了大量代码的进出更替。
原地迁移策略
这张图还体现了这次迁移的一个重要特点:原地迁移。
这种规模的重写主要有两种做法:
- 大爆炸式切换。先把新的 Rust runtime 完整开发出来,等就绪后一次性替换上线。这种方式又有两个变体:a. 停止其他开发。重写期间所有人暂停
main分支上的其他工作,直接在main分支上重写。b. 并行开发。在 feature 分支上重写,同时 main 分支继续开发,重写过程中持续跟进并合并 main 分支的变更。
我们选择了方案 2a,原因如下:
- 无人因工作停顿受阻。主分支保持活跃。未直接参与移植的开发者可照常工作,仅当其长期未合并的 PR 恰好触及正在被移植的代码时,才需要执行 rebase,并让代理协助移植这部分变更。
- 运行时主分支始终可发布。每个 PR 都用一个薄壳(shim)替换现有的 TypeScript 实现并调用 Rust,同时原子性地删除旧代码。新代码在原地立即得到验证。
- 重写过程增量且易于审查。每个 PR 仅移植单个组件或切片,变更范围更小,diff 更易审查,无论是人工审查、代理审查还是两者结合。
- 大多数移植任务规模适中且自包含,将并行 PR 造成的漂移降至最低。对于 TypeScript 组件过大的情况,可先将其重构为更易移植的组件。
- 所有现有的端到端测试(涵盖 CLI 和 SDK)在每一步都针对新的 Rust 代码运行,提供了信心和大量验证。若某 PR 导致必备测试失败,则不予合并。
我们也避开了 2b 方案,即并行维护同一组件的多个版本。过去几个月,每周都有数百个 pull request 合并到仓库,代码库不断快速演进。同一份代码用两种语言分别维护、依赖两套不同的库,会带来极大的复杂性。此外,这些组件的隔离性也并非完美:有些组件逻辑上独立且 API 简单,便于系统其他部分调用;但另一些组件牵连甚广,若试图逐组件进行热替换,简直是一场噩梦。理论上最受益于谨慎并行切换的那些子系统,恰恰是并行难度最高的部分。例如,会话编排并非一个纯函数,无法简单地通过实验标志在两个不同版本间做 if/else 分支。它拥有可变状态,双向驱动回调,并贯穿几乎所有其他子系统。因此,“双跑并比对”意味着要维护两份持有会话状态和服务的组件副本,并祈祷它们能在数百次并发编辑中保持同步。让组件难以移植的那种耦合,也正是让影子运行难以实施、且极可能引入比避免更多回归的原因。这种换用方式带来的主要好处是增强信心,而这种信心完全可以通过其他方式获得。
验证还通过渐进式上线来进行。如果采用一次性切换的方案,我们会把所有改动长期搁置在一个分支里,把整个 runtime 移植完再统一切换——这意味着所有移植的代码会同时面向用户,包括那些在仓库内测试中漏掉的回归问题。而把改动拆成小块逐步上线(这里两个组件、那里一个组件),让我们能在真实部署环境、真实用户使用(多数是 Microsoft 和 GitHub 内部)中完成最后一公里的验证,同时把回归风险降到最低。在大约十四个半星期的移植窗口里,main 分支共发布了 135 个版本,包括 100 个预发布版本和 35 个稳定版本,平均每天约 1.3 个版本。移植相关的 pull request 平均每天也开出约 1.3 个,因此每个版本只包含一小批已知的新移植组件(我们尽量让移植代码先在预发布版本中上线,但并非总能如愿)。在最近七天的 npm 下载样本中,预发布版本仅占下载量的 10.5%,说明初始暴露面相对有限——我们可以在监控反馈渠道、留意故障信号的同时,在下一个预发布版本中快速修复。上报的问题也更容易与近期的已知变更关联起来,更快定位根因、更快修复。从这个角度看,把移植工作拉长到较长时间内渐进式完成,其实是优势而非阻碍(也就是说,快并不总是好事)。到 8 月 21 日,runtime 已 100% 使用生产级 Rust:生产代码 832,378 行,Rust 单元测试 468,689 行,另有 174,675 行 E2E TypeScript 测试。独立的 GitHub Copilot SDK 仓库还包含约 13 万行覆盖 Node.js、Python、Go、C#、Rust 和 Java 的 E2E 测试代码。
起步
在全情投入之前,我们先行验证以建立信心。最初的两个 Pull Request 搭建了 Rust 工作区、工具链、Lint 规则、CI、构建流水线及编码规范;随后,在移植一组特意挑选的纯逻辑原语时,引入了运行时 Crate 以及代码生成和互操作模式。这些原语没有 I/O 或共享状态,且已具备完善的测试。待上述工作合并后,首个核心移植 Pull Request 才通过完整流程迁移了三个无副作用的辅助函数。这些操作充当了“发货试点”,将关于仓库布局、FFI、打包、测试及代码审查的假设转化为惯例,供后续规模大得多的移植复用。简而言之,我们对整套机制进行了端到端测试。后续计划遵循“由叶及根”的顺序:先完成纯辅助函数、内容排除、Shell 工具及会话文件系统操作,确立翻译和测试范式;接着处理有状态子系统;工具、Hooks、模型客户端和 MCP 则基于这些底层构件构建。会话编排(运行时中耦合度最高、最难以自然并行的部分)被安排在接近末尾。
| 时间段 | 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、认证、遥测、插件、配置和持久化等模块也遵循了类似路径。
实际上,移植的有效单元并非总是“某个组件”。更多时候,它是相关行为区域中的一波波迁移:先移纯逻辑,再移交状态所有权,随后移编排,接着移除回退机制,最后在移除临时的 Interop 层后简化 Rust 代码。
互操作(Interop)
此次移植中,涉及互操作(Interop)的有两个主要层面:
- 临时内部互操作。 每当一个函数被移植到 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 调用,并生成一个 TypeScript 声明(位于生成的 index.d.ts 中)。同步的 Rust 函数变成普通的 JavaScript 函数,async fn 变成返回 Promise 的 JavaScript 函数,而标注了 #[napi(object)] 的结构体则变成对面的普通对象。
流量是双向的。许多已移植的组件临时依赖尚未移植的部分,所以 Rust 需要回调 TypeScript,比如 Rust 里的工具实现要向仍是 TypeScript 的模型层请求推理、触发 hook,或为要执行的命令申请权限。napi-rs 通过“threadsafe functions”实现这一点:运行在 Tokio 工作线程上的 Rust 代码可以调用 Node 主线程上的 JavaScript 回调。Node 只安装一次回调,Rust 持有它,需要反向通信时就调用。每个回调从设计上都是临时的:它存在只是因为另一端还是 TypeScript,那部分移植完成后回调就删除了。
这个临时接缝在 8 月 3 日达到峰值:2019 个内部 N-API 导出和 3356 个 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 作为该客户端下的另一层传输机制,只是将字节传输路径从管道或套接字改为函数调用,而对其上层逻辑毫无影响。六个 SDK 因此获得了进程内宿主支持,这是一种增量、可选的传输方式,现有实现保持不变。如果当初我们为每个 API 方法定义了类型化的 C 函数,那么每个 SDK 都需要第二层绑定,每新增一个 API 方法都需要增加六个绑定,ABI 也会变成需要严格版本控制的二进制兼容表面。
对于那些真正运行在远端的 runtime(无论是跨子进程边界还是通过 TCP 通信),JSON-RPC 依然是必需的。在进程内也沿用同一套协议,意味着只需维护一套双向 API 和分发系统,而不必同时维护远程连接的 JSON-RPC 和本地调用的另一套逐方法 FFI 接口。这是一个真实的权衡,而非显而易见的选择。我们省去了进程间跳转,但每次调用仍要付出 JSON-RPC 的开销。对于以推理为主的负载来说,序列化开销相比模型的往返耗时基本可以忽略。在高吞吐的本地场景下它仍可测量,但如今还不足以成为在六种 SDK 绑定中复制数百个方法的理由。而且这个决定随时可以重新评估——如果未来性能需求出现了再说。载荷编码是通信两端的私有细节,把 JSON 换成 MessagePack 这类更紧凑的格式,不会改动任何一个已声明的导出接口。针对热点路径,以后也可以追加类型化的逐方法导出,复用同一个引擎和同一批 handler,而无需替换字节通道——字节通道仍将作为流式传输、服务端到客户端请求,以及大量低频调用方法的基础(为这些方法定制导出接口毫无收益)。
会话数据揭示了什么
本文中的数据几乎全部来自两个渠道。第一个是私有仓库 github/copilot-agent-runtime 的 GitHub 历史:pull request 及其 diff、review 评论、CI 运行记录等。第二个是 agent 会话日志。runtime(以及 CLI、应用等)会为它运行的每个会话写入结构化事件日志:每行一个 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 |
| 提交命令 | 7,410 |
| 推送命令 | 5,554 |
| 已完成的压缩操作 | 5,116 |
这 31,247 条用户角色消息并非都是我个人输入的提示词;除了我实际输入或口述的约 2,600 条(占比约十二分之一),还包含了技能指令、自动合并记录、跨会话消息以及子智能体(subagent)产生的流量。同样,1,385,214 条助手消息也不仅是对话界面中展示给我的文本,还涵盖了子智能体和面向工具的消息。该语料库包含 68 种不同的事件类型和 67 种不同的工具名称;其中 61% 的 1,130,921 次工具调用来自子智能体,而非主会话线程。
统计数据本身无法解释我为什么介入了约 2,600 次。为了弄清原因,我让 Copilot 为会话日志语料库中每条由人类编写的消息分配一个主要意图。
前三类意图占我交互总量的 63%。我可辨识的主动开启会话次数仅有约 40 次,因为我通常先创建聊天来探索下一步方向,再让该会话为每个所需的切片创建实际的移植会话。我的角色不再是“布置任务然后等待”,而是更像“操控控制回路”:检查结果、质疑技术决策、执行质量关卡,并在智能体将中间节点误认为终点时推动其继续。尽管“工作”是由智能体执行的,人类判断仍深度参与其中。我的参与度只是向上游转移了……我不再负责编写语法,而是负责定义问题、划定边界、选择策略、裁决例外情况,并确保整体朝着正确的方向推进。
一切都关乎缓存
LLM 服务商通常对输入和输出 token 采用不同的计价标准。之所以按 token 计费,是因为它能较好地近似推理所需的计算量:对于每个输入 token,模型都需要读取并将其纳入内部表征,作为确定下一个 token 的计算过程的一部分。不过,服务商通常支持缓存这些计算的中间结果。这意味着,如果提示词的前缀与之前已处理过的完全一致,服务商就可以直接从缓存中复用中间计算结果,而无需从头重新计算。这样做不仅降低了处理这些 token 的成本,还能为终端用户带来节省。因此,输入 token 往往会对应多种费率,其中也包括从缓存中读取的输入 token 的优惠费率。
这种折扣力度相当可观!许多服务商对缓存命中提供高达 90% 的折扣。举例来说,某服务商可能收取每百万输入 token 2.00 美元,但对每百万缓存命中的输入读取 token 仅收取 0.20 美元。换句话说,维持良好的提示词缓存至关重要,它能让你的账单降低一个数量级。
移植工作期间的数据证明我们在这方面做得非常出色。提示词缓存命中率达 96.22%(计算方式为:缓存读取量占所有输入侧 token 总量——即缓存读取、缓存写入与新输入之和——的比例)。缓存写入占比为 3.07%,而全新输入仅占 0.71%。这绝非巧合。GitHub Copilot 特意设计了代理(Agent)循环结构,以保留长且稳定的前缀(依次为系统提示词、工具定义以及累积的对话历史),从而确保每一轮交互都只是在模型已处理过的上下文基础上追加内容。这意味着昂贵的上下文部分只需支付一次费用,随后在每次调用中重新读取时的成本便降低了一个数量级。这也是长时自主会话在经济上可行的根本原因。试想一次长达 300 小时的移植过程,如果在数万次的调用中每次都从零开始重新读取不断增长的完整上下文,其成本将比我们实际观测到的高出一个数量级。开发 Agent 框架的开发者们投入了大量精力以避免破坏提示词缓存,而模型厂商也常规性地推出新功能来协助他们实现这一目标。
压缩(compaction)则呈现了另一番景象。在整个移植过程中,GitHub Copilot 自动压缩了上下文 5,116 次(即会话填满上下文窗口后自我总结以继续工作的时刻)。其中单是移植会话基础设施的那次拉取请求,在持续多天的生命周期里就压缩了 647 次,而一个小移植任务甚至一次都没压缩过。之所以能支撑长达数百小时的持续自主工作,正是因为 agent 能够反复回收工作记忆而不丢失线索。这数千次总结中的每一次,都可能是一次有损交接、悄悄让移植偏离正轨的机会——但大多没有发生。Copilot 的 subagent 也在大幅减少压缩方面功不可没:每个 subagent 拥有独立的上下文,父会话只需提出问题,让 subagent 消耗大量上下文去计算答案,最后只把答案汇报回来,所有中间信息都不会占用父会话的上下文。
上面说的"大多没有发生"在会话日志里看得一清二楚。我让 Copilot 把每次成功压缩与前后各至少 20 次工具调用的工作进行配对,得到了约 4,000 个可比较的窗口。压缩前后各 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(“在当前作用域中找不到值”)为主 - 22%:缺失的方法或字段
- 14%:类型不匹配
- 11%:未满足的 trait 边界约束
这些问题都属于常规的代码连接细节:输出值略有偏差、签名对不上、字段重命名、抽象未实现。这类错误正是批量翻译容易且无意中产生的,也是编译器能快速捕获的典型。
但请注意,上述列表中缺失的是任何真正具有 Rust 特色的问题。这四个类别都是静态类型系统的典型场景,C#、Java 或 Go 编译器同样能捕获所有这些错误,其中部分语言的诊断信息甚至更友好,且处理速度更快。如果这被视为指向 Rust 的理由,那本质上也是指向任何静态类型语言的依据。具备强类型编译器和/或拥有优秀静态分析与 lint 能力的语言,确实非常适合这项任务,让智能体利用其作为快速反馈循环。在 4,478 次通过严格结果匹配器捕获结果的直接 cargo check 运行中,87.1% 返回了无错误状态,这是进行小步编辑并频繁重新编译的自然结果。
相比之下,所有权、借用和生命周期错误合计仅占编码诊断的 1.7%。借用检查器——通常主导着关于 Rust 难以掌握的讨论——在此处只是安静的背景存在。编译器几乎将所有报错精力都放在了枯燥的机械性错误上。
智能体喜欢阅读
我们还能检查会话事件语料库中的工具调用数据,从中提取出关于智能体如何分配时间的一些有趣观察。
| 工具 | 调用次数 | 中位数 | 实测小时数 |
|---|---|---|---|
powershell | 630,423 | 3 秒 | 2,833.9 |
view | 590,988 | 0 秒 | 621.7 |
rg | 281,783 | 1 秒 | 408.4 |
grep | 126,483 | 1 秒 | 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 做了什么、其他会话提交了什么,以及分支与快速演进的 main 主分支漂移了多少。这种“定位”工作使得许多长时间运行的任务能够在同一个动态变化的代码库上协同工作,而不会盲目覆盖彼此。
深入查看 Shell 工具流量,最常见的命令族进一步清晰地展示了“定位”与“验证”之间的平衡:
| 命令族 | 调用次数 | 中位数耗时 | 累计耗时(小时) |
|---|---|---|---|
git 检查 | 300,530 | 2 s | 608.1 |
git 其他 | 89,865 | 3 s | 243.1 |
| 搜索 | 85,482 | 2 s | 147.6 |
pnpm test | 13,852 | 22 秒 | 219.1 |
pnpm lint | 9,757 | 29 秒 | 177.0 |
cargo test | 8,437 | 120 秒 | 364.2 |
git commit | 7,410 | 11 秒 | 39.7 |
cargo fmt | 5,223 | 18 秒 | 77.2 |
cargo check | 4,492 | 120 秒 | 176.9 |
pnpm build | 3,630 | 180 秒 | 215.6 |
cargo clippy | 2,115 | 135 秒 | 107.4 |
git rebase | 2,496 | 7 秒 | 9.9 |
cargo build | 566 | 104 秒 | 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 的类型决定的,而不是单独去选模型。
管理智能体集群
GitHub Copilot 应用支持可视化正在处理的 Pull Request 会话、查看其状态,并能在它们之间轻松切换。这一特性非常适合管理移植过程中固有的大量并发任务。而让它真正脱颖而出的是会话之间互动的能力。
一个会话可以创建其他会话,并能在它们运行时向其发送消息。每个会话,无论是父级还是子级,都有独立的 worktree、独立的分支和独立的智能体循环;它是独立于生成它的父会话,而不是运行在父会话内部。这与 subagent 不同,subagent 运行在父级自己的工作区中,并将结果交回父级的上下文。两者是解决不同问题的有用结构。
举例说明会话如何创建其他会话:最棘手的移植任务之一是 session.ts 文件。该文件随着业务演进,已有机增长到约 30,000 行 TypeScript 代码。它代表了一个会话的骨架,实际上横跨整个 runtime 的水平层,几乎触及并受触及每一个组件,处于状态、事件、工具、模型、hooks、持久化及入口访问的中心。因此,我将其留到了移植过程的最后阶段。我从技术栈底层开始,跨所有垂直领域向上推进,直到所有这些路径都汇聚至 session.ts。负责该任务的移植会话并没有贸然开始编写 Rust,而是先花了 56 分钟阅读,在创建任何内容前进行了 122 次工具调用,以此构建对该文件实际职责及接缝所在的整体认知。只有在此之后,它才开始委派任务,逻辑上拆分文件并将切片委派给子会话。在整个 25 小时的运行中,它自身进行了 222 次 shell 调用、205 次文件查看和 197 次 ripgrep 搜索,这还未包括其子会话所做的一切。
这 15 个子会话各自对应一个独立分支和 worktree,由独立的 agent 运行,全部由顶层父会话隐式创建。父会话在约三小时内分七波启动了它们:第一波创建了 5 个,约二十分钟后的第二波又创建了 2 个,再二十分钟后是另外 2 个,剩下的单发或双发会话分散在接下来的两小时内完成。
每个任务切片由不同的模型负责:15 个中有 10 个使用 GPT-5.6 Sol,5 个使用 Claude Opus 4.8。所有会话均启动于 GitHub Copilot 的 autopilot 模式,允许会话在无需每步人工批准的情况下持续推进目标。中位数的启动提示约 1100 字符,足以承载所有权边界与约束条件,又足够简短,迫使子会话自行推导具体方法。我提示父 agent,随后由父 agent——而非人类——为每个子会话撰写了这些启动提示。
除了这 15 个子会话,同一父会话还启用了五个 subagent:三个 explore agent 与第一波并行运行,一个 code-review,以及一个 rubber-duck。这些 subagent 负责探索特定问题,并将父会话下一步决策所需的上下文答案反馈给父级。借助 subagent,父会话无需消耗自身上下文窗口去推导,即可获取经过深入思考的答案。
相比之下,子会话专注于实际的移植工作,即产生 diff 且需要与其他并行移植者隔离的任务。子会话共触及仓库中 140 个不同文件,其中 120 个仅被一个会话修改,另 20 个存在竞争的文件均为枢纽型文件,如 session.ts 本身。由于每个会话运行在独立的 worktree 中,因此不会受到其他分支的干扰。父会话当然要为这种隔离付出协调成本。它投入了大量精力与各子会话沟通,充当信息中介,轮询了 60 次状态并发送了 89 条协调消息。当子会话各自宣布完成时,父会话将它们的 commit 樱桃采摘到自己的分支上并解决冲突。这些合并也不干净,父 agent 花费了相当长时间来调和这些编辑。
从时间线上可以看到父会话及其大部分子会话的情况。
注意那些大段的空白。移植期间我正在出差,好几次不得不合上笔记本电脑。(后来我改用了可以远程连接的云端虚拟机来开展工作。)
这些并行运行的子会话对笔记本的压力不小。起初,并行移植进展顺利。但 15 个并发 agent 同时在一台机器上构建和测试,我的笔记本就彻底卡死了。我向父会话发指令,让它转告所有子会话立即停止构建和测试。父会话把这一约束传达下去后,它们很听话地终止了构建,在极低的 CPU 占用下继续工作。之后我更新了长期指令:移植过程中,subagent 和子会话应避免大规模构建和测试,这类工作只由父 agent 统一执行。
后来我又更进一步,把一个普通的聊天会话改造成 8 个独立移植会话的构建调度器。提示词简单得有点不好意思:向所有打开的会话发送一条策略,要求尽可能避免 CPU 密集的构建和测试;必须构建时,先向这个会话申请许可;调度器充当门卫,一次只允许一个会话构建。本质上,我是把这个聊天会话变成了一个 agentic mutex。这个门卫维护着明确的持有者和排队列表,通过会话间原本用于协调代码的跨会话消息机制,一次发放一个租约。申请被拒绝的会话往往会先去做别的事,比如处理待办清单上的任务,稍后再来申请。

这个 session.ts 的移植过程,也见证了我在整个运行时移植期间所遇到最酷、最心酸,也最出人意料的一次交互。如前所述,我们的移植主要采用自底向上的策略,因此作为实际上位于所有其他组件之上的 session.ts,自然成了最后移植的组件之一。在 session.ts 上方一直存在的,只有运行时的所有入口点,即从 SDK 暴露出来的公共函数,也就是之前提到的分发表中呈现的那些内容。这类函数有成百上千个。虽然我知道其中许多都会直接调用 session.ts,但为了抢占移植先机,我在启动 session.ts 会话后,立刻又启动了一个专门负责移植所有入口点的会话。我要求它在 session.ts 的边界处停下。我预计会有一些一次性工作及合并时的额外消耗,但认为这样能加速整体移植进程。随后我就去睡觉了。然后……它们相遇了。
我在入口点会话的初始提示中明确说明,session.ts 会话和另外六个组件会话正在并行运行,目的是让它了解边界,避免移植时产生尽可能多的冲突。没想到,我的提示竟然产生了相反的效果。就在会话启动四分钟后,在梳理完入口路径并大致判断出重叠程度后,它调用了应用内置的 orchestrate 技能,该技能旨在协调多个会话间的工作。接下来发生的事如下:
- 入口点会话列举了所有活跃会话,并向那些看似有重叠的会话发送了消息。
session.ts会话回复了一份 2001 字符的清单,标题为“与stephentoub-port-session-to-rust的具体重叠点”。- 入口点会话读取了
session.ts会话的工作区,以核实其刚获知的信息(大概是“信任但需验证”吧)。 - 入口点会话询问
session.ts会话是否准备好协调它那个包含 760 个文件的差异改动。
session.ts 会话基本上让它走开,并明确表示:“现在不想做集成。”session.ts 会话的相同答复。session.ts 会话的想法。它直接潜入自己的 worktree,把所有其他会话的改动都抓过来,并合并到了自己的分支里。我在这次交互中总结了几点教训:
- 明确意图很重要。 启动提示中列出了其他正在运行的会话,以便这个会话知道该避开哪些部分。但我没有明确表达“不要动它们”这一层意思,结果反而鼓励了它去动。我需要更明确地表达我的意图和指引。
- 你暴露给 Agent 的能力,就是它可能会自行决定的行为。
orchestrate技能是 GitHub Copilot 应用自带的,其描述说它适用于并行执行独立的工作流。我的提示中完全没有提到它。模型识别出了当前的局势,对照该技能描述认为适用,于是自动加载了它。你开放的能力集,就是你可能会收到的行为集,这包括那些你从未设想过的情况。 - 同级会话需要仲裁机制。 这两个会话都无法强制对方。即使
session.ts会话四次拒绝集成,这些拒绝也没有权重。因此,那个愿意单方面行动的会话最终胜出了。在处理相邻代码的并行会话之间,需要一个指定的协调者,或者需要人类介入,而这次它们两者都不具备。 - “自主运行”需要例外情况,特别是当决策涉及分支之外的范围时。 我本意是“不要在琐碎的设计细节上打扰我”。但 Agent 认为(也并非完全不合逻辑)接管同级会话是在授权范围内的。再次强调,我的指引需要更加明确。
- 根本原因在我。 我同时自上而下和自下而上地分割了这项工作,结果这两个方向在代码库中最互联的那个文件上相撞了。我为了推进进度而过于贪婪。上述所有问题都由此而来。
所幸,这次交互只是一个有趣的特例。在整个 runtime 移植过程中,大多数叶子组件的移植都是简单的单次会话任务,而较大的子系统往往涉及多次子会话和多个 subagent。不过,这些子任务在整个移植过程中的参与方式差异很大。
模型编排(model orchestration)层的移植是与 provider 实际通信的那一层,展示了一种典型模式。它的主会话持续了 42 个小时,启动了 126 个 subagent,最高峰时有 22 个同时在工作。但大部分时间里只有主 agent 在运行,偶尔才会一次性批量生成大量 subagent 跑一段时间。
这张图里有三点值得注意。第一,几乎所有的代码生成都在前 12 个小时内完成,之后一整天的工作全是验证。第二,最下面一行的颜色从左到右逐渐变化:从以蓝、绿为主(读取、构建)变为以蓝、橙为主(读取、审查),这在逻辑上说得通,但实际看到还是很有意思。第三,这个例子,或者说这类模式,各阶段之间的划分非常清晰。而 extension-runtime 的移植则是个反例。
它花了 88 个小时,结构也截然不同:
- 编写和审查高度重叠。前一个例子的流程很“瀑布式”(先生成代码,再审查),而这里审查远在编写结束之前就开始了,两者在整个过程中大部分时间并行推进、互相交织。