title: "将 GitHub Copilot 运行时迁移到 Rust,使用 Copilot"
source_url: "https://github.blog/ai-and-ml/generative-ai/migrating-the-github-copilot-runtime-to-rust-using-copilot/"
author: "The GitHub Blog"
excerpt: "公共早报 GitHub 将支撑 CLI、App 和 SDK 的 Copilot agent runtime 从 Node.js/TypeScript 逐步迁至 Rust,并以 128 个 PR 在 main 持续交付。每次替换只覆盖一个组件或切片,由既有端到端测试校验,随后再逐步移除临时互操作层。它提供的实践重点,是把大规模改写拆成可审查的原子变更,保持行为契约,再处理性能与结构优化。"
GitHub Copilot CLI、GitHub Copilot app 和 GitHub Copilot SDK 都由 Copilot agent runtime 支撑,这是一个可以嵌入应用程序和服务的 agentic 框架。它最初用 TypeScript 在 Node.js 和 V8 JavaScript 引擎上编写,用于现在的 GitHub Copilot 云端 agent(CCA),随着运行时及其功能的高速增长,运行时一直保持在那个技术栈上。
这种情况现在已经改变了。使用 GitHub Copilot 应用和 Copilot CLI,我们用超过 800,000 行生产 Rust 代码完全重写了运行时。AI agents 编写了大部分代码,通过 128 个 pull request 在 main 分支持续交付并逐步发货,而不是等到最后才进行一次性的切换。少数不可避免的回归问题在此过程中被快速发现和修复,同时运行时的性能提高了几个数量级。一个在 agent 出现之前可能需要一个开发者团队花一到两年完成的项目,现在主要由一个开发者完成,只用了几个月,而且在此期间团队的其他成员继续大幅扩展运行时的能力和覆盖范围。
为什么我们需要移植 {#h-why-we-needed-to-port}
Copilot agent runtime 不仅仅是 Copilot CLI 的引擎。它支撑着越来越多的 Microsoft、GitHub 和生态系统解决方案,对于每个解决方案,AI 支持在架构上都是一个围绕相同运行时加上该解决方案需要的任何定制的 shell。这不仅包括 GitHub Copilot CLI 和 GitHub Copilot 应用,还包括最新版本的 VS Code、Visual Studio、CCA、Copilot Code Review(CCR)、Copilot Cowork、Copilot Studio,以及 Excel、Outlook、PowerPoint 和 Word……等等。
这些都是非常不同的产品,没有一个想要或应该需要实现生产 agent 框架的所有功能。他们想要所有的智能、安全、可靠性和性能,而且希望这些是共享的,这样在一个地方修复就能在所有地方修复。前一段列出的 most 产品最初都实现了自己的 agent 循环,但此后都用 GitHub Copilot SDK 替换了它,SDK 是 Copilot agent runtime 的入口点。这样做使他们能够专注于核心业务价值,将细节留给运行时。考虑到行业的步伐和所采用的 agent 循环需要在激烈竞争中始终保持领先,这一点更加重要。
所以,共享运行时是好的。问题是被共享的事物的本质。
如果我们看 CLI,它在逻辑上是 agent 循环之上的终端 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 协议将函数调用从外部进程封送(marshal)到 CLI 并从 CLI 返回。SDK 然后可以嵌入到任意消费程序中,消费程序会生成一个 CLI 进程来在进程外托管 agent 循环,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 的工作集 minimum,而他们的应用程序本来根本不需要这个运行时。它意味着每个事件、每条消息和每个抽象的会话文件系统读写都被推送到进程边界。它意味着 Node 中的崩溃会使会话与其一起崩溃。它意味着任何部署这个系统的人至少要监督、监控和调试两个进程。
相反,我们想要一个运行时:
- 不包含 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 或套接字的服务器,用于仍然需要进程外时。
这篇文章主要涵盖第二个方面:将运行时移植到 Rust。
移植前的样子 {#h-what-it-looked-like-before}
2026 年 5 月初的初始移植计划估计运行时大约有 130,000 行 TypeScript。为了便于界定范围,这个初步测量相当准确,但事实证明,在两个关键方面也相当误导。在移植的同时:
- 仍然包裹在 TUI 层中的代码片段正在被下推到运行时层。最初在估计中被忽略的整个组件和相当比例的代码后来被认为与移植相关。
- 贡献大量新 TypeScript 的 pull request 不断增加仓库中的 TypeScript 数量。每周有数十个 agent 辅助的开发者合并数百个 pull request。
考虑到所有因素,我估计大约有 430,000 行生产 TypeScript 最终通过了这次移植。同样的因素也使得沿途很难看到进展:直到接近尾声,生产 TypeScript 数量看起来相当稳定,甚至略有增加,因为移植与传入工作保持同步。
这因为还有不断传入的 Rust 代码而更加混乱——与移植分开;在移植工作的早期,传入代码更可能是 TypeScript 主导的,而在后期,则更可能是 Rust 主导的。
在移植期间,运行时吸收了约 300,000 行生产 TypeScript 并削减了约 430,000 行,而约 1,200,000 行生产 Rust 进入并约 365,000 行离开。换句话说,上面图表中 TypeScript 行的明显稳定性实际上隐藏了大量的 TypeScript 流失。
原地移植策略 {#h-in-place-porting-strategy}
该图表还突出显示了移植方式的一个重要方面:原地进行。
对于这种规模的重写有两种主要方法:
- 大爆炸。 新的 Rust 运行时作为完整的替代方案开发,然后在准备好时一次性全部切换。这种大爆炸切换有两种变体。
a. 停止世界。 每个人在重写进行期间停止 main 分支上的其他工作,重写在 main 中完成。
b. 并行开发。 重写在一个功能分支中进行,而工作在 main 分支继续,重写不断尝试跟上 main 分支的变化并合并 main 的变化。 - 原地进行。 这是逐个组件的移植,运行时被增量地一次重写一块。这种原地方法也有两种变体。
a. 原子替换。 每块从 TypeScript 原子性地切换到 Rust,剩余 TypeScript 和新 Rust 之间的互操作提供连续性。随着时间的推移,生产运行时中 TypeScript越来越少,Rust 越来越多,直到有一天,没有更多 TypeScript,只有 Rust。
b. A/B。 不是在组件被移植时删除它们,而是将 TypeScript 和 Rust 组件都维护为可热插拔的选项,一旦信心趋于平稳就删除 TypeScript。
我们选择了选项 2a,出于多种原因:
- 没有人经历工作停滞。 Main 分支保持活跃。每个不直接参与移植的开发者都可以继续做自己的事,只有当他们可能长期处于飞行状态的 pull request 恰好触及同时被移植的代码时才会受到影响,在这种情况下他们需要 rebase 并让他们的 agents 帮助移植他们正在进行的更改。
- 运行时的 main 分支始终可发货。 每个 pull request 用一个调用 Rust 的薄 shim 替换现有的 TypeScript 实现,并在一次原子更改中删除旧代码。新代码立即在原地被 exercised。
- 重写是增量式的且可审查的。 每个 pull request 移植一个单独的组件或切片,因此变更范围更小,diff 更容易审查,无论是由人还是由 agents 或两者兼而有之。
- 大多数移植都相当小且自包含,最大限度地减少了来自并发 pull request 的漂移。在某些情况下,如果 TypeScript 组件太大,它们可以首先被重构为更容易移植的组件。
- 所有现有的端到端测试,跨 CLI 和 SDK,在每一步都针对新的 Rust 代码运行,给我们信心和大量验证。如果一个 pull request 导致必需的测试失败,它就不会合并。
我们还回避了选项 2b 变体,即同时维护同一组件的多个版本。在过去的几个月里,每周有数百个 pull request 合并到仓库中,代码库不断发展,而且发展很快。同时维护两个不同版本的相同代码(两种不同语言和两套不同的依赖库)增加了大量复杂性。这些组件中有些也不是完美隔离的;虽然有些在逻辑上是独立的,具有简单的 API 供系统其他部分访问,但有些具有重要的触角,使该图按组件热插拔是一场噩梦。最有可能从谨慎的并行切换中受益的子系统恰恰是最难并行的。例如,会话编排不是一个你可以通过某个实验标志用 if/else 调用两个不同版本的纯函数。它拥有可变状态,在两个方向上驱动回调,并贯穿几乎每个其他子系统,所以"运行两者并比较"意味着维护一个组件的两个不同副本,该组件保存对话的状态和服务,并祈祷它们在数百个并发编辑中保持同步。使组件难以移植的耦合同样使其几乎不可能被 shadow 而不引入比它避免的更多的回归。能够以这种方式交换的好处主要是为了获得信心,我们可以通过其他方式获得信心。
验证也通过增量推出进行。对于大爆炸切换方法,我们会将所有内容保存在一个长期分支中,移植整个运行时,然后一次性切换。这意味着消费者一次体验每行移植的代码,包括通过仓库内测试的所有回归。通过一次推送两个组件、一次一个组件来增量推出部分变更,使我们能够在部署版本中获得真实消费者使用的最后验证里程(最常见的是 Microsoft 和 GitHub 内部的 first-party),同时将回归风险保持在最低限度。在大约十四周半的移植窗口期间,main 发货了 135 个版本,包括 100 个预发布版本和 35 个稳定版本,平均每天约 1.3 个版本。大约每天 1.3 个移植 pull request,这样每个版本都携带一小部分已知数量的移植组件(我们通常尝试但并不总是成功首先在预发布版中发货移植)。在 trailing 7 天的 npm 样本中,预发布版本仅占下载量的 10.5%,表明初始曝光相对有限,因为我们监控反馈渠道以获取有关事物破裂的信号,并在下一个预发布版中快速转身修复。报告的问题更容易与已知的最近变更相关联,更容易 root cause 并快速修复。通过这种方式,在较长的时间内增量进行移植实际上是功能而不是障碍(即更快并不总是更好)。到 8 月 21 日,运行时已是 100% 生产 Rust:832,378 行生产 Rust 和 468,689 行 Rust 单元测试,还有 174,675 行 E2E TypeScript 测试。单独的 GitHub Copilot SDK 仓库在 Node.js、Python、Go、C#、Rust 和 Java 中添加了约 130,000 行 E2E 测试代码。
开始行动 {#h-getting-going}
在全力投入之前,我们获得了信心并证明了这一点。我们从两个 pull request 开始,建立了 Rust 工作区、工具链、lint 规则、CI、构建管道和编码说明,然后在移植一组特意选择的纯逻辑原语时引入了运行时 crate 以及代码生成和互操作模式,选择这些原语是因为它们没有 I/O 或共享状态,并且已经有强有力的测试。只有在这些着陆之后,第一个主要移植 pull request 才经历了三个无副作用帮助函数的完整流程。这些作为 shipping pilots,将关于仓库布局、FFI、打包、测试和审查的假设转化为后续更大移植将重用的约定。基本上,我们端到端地测试了 machinery。计划继续从叶子向内排序工作,纯帮助函数、内容排除、shell 工具和会话文件系统操作建立了翻译和测试模式。有状态的子系统紧随其后,工具、hooks、模型客户端和 MCP 在这些片段上构建。会话编排(无疑是运行时中耦合度最高、最不容易并行的部分)将在接近尾声时进行。
| 时间段 | Pull requests | 中位数变更行数 | |------------|---------------|--------------------| | 5月1-15日 | 8 | 3,250 | | 5月16-31日 | 2 | 9,421 | | 6月1-15日 | 40 | 5,073 | | 6月16-30日 | 31 | 8,253 | | 7月1-15日 | 10 | 9,514 | | 7月16-31日 | 14 | 28,159 | | 8月1-15日 | 19 | 13,861 | | 8月16-30日 | 4 | 99,445 |
早期移植,小型叶子组件,移动很快。但更大的子系统没有在一个原子步骤中移动;例如,MCP 支持通过七个专门的 pull request 取得进展,而工具则通过一个六部分系列进行,然后在移动编排和淘汰剩余 TypeScript 时需要额外的工作。Hooks、auth、telemetry、plugins、settings 和 persistence 遵循类似的路径。
实际上,移植的有用单元并不总是"一个组件"。通常是一个波通过相关行为区域:首先移动纯逻辑,然后移动状态所有权,然后移动编排,然后移除 fallback,最后在临时互操作消失后简化 Rust。
互操作 {#h-interop}
这次移植涉及两个主要的互操作层:
- 临时内部互操作。 任何时候一个函数被移植到 Rust,该函数需要能够被任何调用初始 TypeScript 函数的 TypeScript 调用。同样,我们需要使 Rust 函数能够调用 TypeScript 回调。这种互操作需求是一个实现细节,极度 fluid。随着 Rust 内部表面积的增加,需要的 TypeScript shims 数量也会增加,因为它们与需要从 TypeScript 调用 Rust 方法的数量是一比一的。随着这些调用者被移植到 Rust,现有的 shim 层被删除,并放置一个新的层。最终我们到达运行时库的公共入口点,shims 蒸发。
- SDK 表面。 所有 SDK 库都需要能够坐在运行时之上并暴露其功能。在移植前的世界中,这是通过让运行时通过双向 JSON-RPC 层暴露来完成的,SDK 将函数调用请求作为 JSON-RPC 方法调用 payload 发送,运行时解析请求并调用相关 API,然后通过相同的传输发回结果,供 SDK 解析和返回。反向也存在;运行时需要能够回调 SDK 客户端,例如 hook 通知和权限请求,它们在 SDK 客户端中作为回调 surfac,使用每种语言认为惯用的任何语言特性(例如 C# 中的 delegate)。
我们通过 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)] 注解的结构体在另一侧变成普通对象。
流量必须双向移动。很多被移植的组件暂时依赖于尚未被移植的东西,所以 Rust 需要回调到 TypeScript,例如 Rust 中的工具实现向仍然是 TypeScript 的模型层请求推理,或提升一个 hook,或请求它想要运行的命令的权限决定。napi-rs 用"线程安全函数"处理这个问题,它让 Rust 代码在 Tokio 工作线程上运行,在 Node 的主线程上调用一个 JavaScript 回调。Node 安装一次回调,Rust 持有它并在需要向另一个方向移动时调用它。每一个都是临时构建的:回调存在只是因为另一端的东西还是 TypeScript,当那个东西被移植时它就被删除了。
临时接缝在 8 月 3 日达到峰值,有 2,019 个内部 N-API 导出和 3,356 个 TypeScript 调用位点。在完成时,运行时完全是 Rust,因此没有内部互操作:0 个临时内部 N-API 导出和 0 个 TypeScript 调用位点保留。(我之前提到 CLI 仍然有一些我们正在努力移除的运行时内部访问;这些导出不在此处计数。)
第二个互操作层——SDK 表面——是两个层中永久的那个。Copilot SDK 为六种语言提供:C#、Python、Go、Java、Rust 和 TypeScript。它们都使用相同的双向 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 个导出函数:四个用于服务器生命周期,四个用于会话注册和配置,八个用于连接,三个用于嵌入式主机。在这背后,共享契约当前包含 364 个调度路由:340 个可由 SDK 消费者调用,24 个以另一个方向运行,作为运行时到 SDK 的回调。napi 门要大得多,需要为每个这些调度路由提供函数。C ABI 门是基于调度的:API 方法根本没有导出,而是作为写入连接的 JSON-RPC 字节行进,结果、事件和服务器到客户端的请求通过主机提供的回调返回。添加、更改或删除 API 方法触及引擎的调度表,但从不触及 ABI。SDK 绑定这 19 个入口点一次,并通过它们动态地访问整个仍在增长的 API 表面。
这引出了一个明显的问题:为什么在一个不再跨进程边界的调用中仍然有 JSON-RPC?
答案是它使进程内托管成为 drop-in 而不是重写。每个 SDK 已经有了工作的 JSON-RPC 客户端,具有 framing、请求和响应关联以及服务器到客户端方向的处理程序。将 FFI 作为另一种传输安装在那个客户端下面,将字节路径从管道或套接字移动到函数调用,并保持其上方所有内容不变。六个 SDK 作为附加的、可选的传输获得了进程内托管,而现有的传输保持不变。相反,如果我们为每个 API 方法定义一个类型化的 C 函数,每个 SDK 将需要一个第二绑定层,每个新 API 方法将需要六个更多绑定,并且 ABI 将成为一个我们需要版本化的二进制兼容性表面。
我们仍然需要 JSON-RPC 用于真正远程的运行时,无论是跨子进程边界还是通过 TCP。保持相同的进程内协议意味着维护一个双向 API 和调度系统,而不是为远程连接使用 JSON-RPC,为本地使用第二套每方法 FFI 表面。这是一个真正的权衡,而不是一个显而易见的决定。我们避免了进程跳转,但我们仍然在每次调用时支付 JSON-RPC 开销。对于推理主导的工作负载,与模型往返相比,这种序列化通常是小事一桩。在高吞吐量本地工作负载中它仍然可测量,但今天还不足以保证在六个 SDK 绑定中复制数百种方法。而且如果我们将来需要性能,这是一个我们可以轻松修改的决定。Payload 编码是两个端点的私有细节,用 MessagePack 之类的更密集的东西交换 JSON 不会改变任何声明的导出。类型化的每方法导出也可以稍后为热点路径添加,调用相同的引擎和相同的处理程序,而不替换字节通道,字节通道仍然是流式传输、服务器到客户端请求和长尾方法的子strate,这些方法很少被调用,定制导出买不到什么。
会话数据显示了什么 {#h-what-the-session-data-shows}
这篇文章中几乎每个数字都来自两个来源之一。第一个来源是私有 github/copilot-agent-runtime 仓库的 GitHub 历史:pull request 及其 diff、review 注释、CI 运行等。第二个来源是 agent 会话日志。运行时(因此 CLI、app 等)为它运行的每个会话写入一个结构化事件日志:每个事件一行 JSON 对象,附加在会话发生时。这些日志可以包含提示、命令、命令输出、文件路径以及工具 surfac 的潜在 secrets,因此必须作为敏感数据处理。日志保存在会话运行的机器本地;远程会话功能在启用时也可以上传它,但需遵守产品设置和组织策略。
以下是所有组成移植 pull request 的数据摘要:
| 指标 | 数量 | |---------------------------|------------| | 事件 | 12,760,995 | | 用户消息 | 31,247 | | 助手消息 | 1,385,214 | | Hook 开始和结束事件 | 6,438,562 | | 工具开始 | 1,857,409 | | 编译命令 | 23,096 | | 测试命令 | 19,485 | | Rebase 命令 | 2,496 | | Commit 命令 | 7,410 | | Push 命令 | 5,554 | | 完成的压缩 | 5,116 |
那 31,247 条用户角色消息不是我个人输入的 31,247 个提示;它们包括 skill 指令、自动合并 tick、跨会话消息和子 agent 流量,而不是大约 2,600 条我输入或说出的消息,约占十二分之一。同样,1,385,214 条助手消息包括子 agent 和面向工具的消息,而不仅仅是显示在会话 UI 中的文本。语料库包含 68 种不同的事件类型和 67 种不同的工具名称;1,130,921 个工具调用,61%,来自子 agent 而不是主会话线程。
这个数字没有说明我为什么要插入大约 2,600 次。为此,我让 Copilot 为会话日志语料库中每个人类作者的消息分配一个主要意图。
前三个桶占我互动的 63%。只有大约 40 个是我可识别的会话启动,因为那主要是通过我首先创建一个聊天来探索下一个领域然后让那个聊天会话为每个所需的切片创建实际移植会话来发生的。我的角色更像是"分配任务然后等待"的反面,更多是"操作控制循环":检查结果、挑战技术决策、执行质量门控,并在 agent 将中间停止点视为终点时推动。当 agents 在做"工作"时,人类判断仍然非常 heavy 涉及。我的参与只是向上移动了……而不是负责编写语法,我负责定义问题、定义边界、选择策略、裁决异常,并总体确保一切都在良好的方向上移动。
都是关于缓存的 {#h-it-s-all-about-caching}
LLM 提供商通常对输入 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 专门设计 agent 循环以保持长而稳定的 prefix(系统提示,然后是工具定义,然后是累积的对话),所以每次 turn 追加到模型已经处理过的上下文。上下文中 expensive 的部分被支付一次,然后在每次后续调用时以低一个数量级的货币成本重新读取。这也就是长 autonomous 会话的经济学能够维持的原因。一个 300 小时的移植如果在数万个调用的每次上都从头重新读取其整个增长的上下文,将花费与我们看到的截然不同的数量级。Agent 框架开发者在避免破坏提示缓存方面投入了大量精力,提供商定期提供新功能来帮助他们做到这一点。
压缩讲述了一个互补的故事。在移植会话中,GitHub Copilot 自动压缩上下文 5,116 次(当会话填满其上下文窗口并总结自己以便继续时的时刻)。单个会话基础设施移植 pull request 在其多日生命周期中压缩了 647 次,而一个小移植从未压缩过一次。持续的多百小时 autonomous 工作是可能的,因为 agent 可以一遍又一遍地回收其工作内存而不丢失 thread。那数千次总结中的每一次都是可能悄悄脱离的 lossy handoff 点,而且大多数没有。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%)。如果压缩经常丢弃 thread of thought,我们会期望之后一方明显重新定向 heavy,读取激增和编辑崩溃,而 agent 重新发现它在哪里以及应该做什么。相反,只有轻微的转向那个方向。
是的,静态分析有帮助 {#h-yes-static-analysis-helps}
有一个流行的 meme 说 Rust 是 AI 生成代码的异常好的目标,因为 Rust 严格的编译器捕获模型出错的地方。至少对于看起来像这次移植工作的任务,会话日志让我们可以测试这个理论。
直接验证命令结果捕获了 8,678 个 rustc 错误代码。四个最大的诊断家族覆盖了 84%:
- 37%:名称和导入解析,以
E0425("在此作用域中找不到值")为主 - 22%:缺少方法或字段
- 14%:类型不匹配
- 11%:不满足的特征约束
这些无一不是普通的 wiring:一个稍微错误的名称输出、一个不对齐的签名、一个被重命名的字段、一个未实现的抽象。这些是批量翻译容易且意外产生的错误类型,也是编译器非常快速捕获的错误类型。
但注意该列表中缺少什么:任何真正特定于 Rust 的东西。这四个类别都是普通的静态类型检查面包和黄油,C# 或 Java 或 Go 编译器同样可以很好地捕获它们所有,其中一些诊断更友好,而且所有这些都快得多。如果这是指向 agents 指向 Rust 的论点,它实际上是一个指向将 agents 指向任何静态类型语言的论点。强类型编译器和/或具有出色静态分析和 linting 的语言确实非常适合这项工作,agents 将其用作快速反馈循环。在 4,478 个直接的 cargo check 运行中,更严格的结果匹配器捕获了结果,87.1% 返回干净,这就是您在小幅增量编辑和不断重新编译时得到的结果。
相比之下,所有权、借用和生命周期错误 combined 仅占编码诊断的 1.7%。借用检查器,这件事在每次关于 Rust 难度的对话中都占主导地位,是一个安静的背景存在。编译器几乎所有报错精力都花在无聊的机械错误上。
Agents 喜欢阅读 {#h-agents-like-reading}
我们还可以检查会话事件语料库中的工具调用数据,并从中提取一些关于 agents 如何花费时间的有趣观察。
| 工具 | 调用次数 | 中位数 | 测量小时数 |
|-------------------|-----------|------------|--------------------|
| 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 秒 | 17.0 |
| edit | 40,591 | 1 秒 | 24.1 |
| read_powershell | 36,728 | 90 秒 | 1,203.9 |
| task | 13,080 | 274 秒 | 2,329.0 |
我在这里的第一个要点是 agents 花费更多时间收集证据而不是更改代码。在显示的文件读取和搜索工具与编辑工具之间,它们的探索量是变更量的 10 倍。读取文件、搜索仓库和运行诊断命令占主导地位;编辑是相对较小的工作量。流行的 AI 吐出代码形象几乎反过来了;在这个规模上,工作看起来更像迭代调查、检查当前状态、形成假设、进行有针对性的更改、冲洗并重复。
委托放大了这种模式。子 agent 主要用于跨独立问题 fan out 探索,而主 agent 更可能拥有编辑并整合答案。对于这种项目,这是一种有用的分工:许多上下文可以并行调查,但将变更保持在更接近协调 agent 可减少冲突变更并保持连贯的实现策略。
Shell 流量也显示了 autonomous 软件工作中有多少是状态管理。只读 Git 检查是最常见的命令模式,因为 agents 不断在有效地问"我在哪里?"它们在寻找什么改变了,什么 rebase 做了,什么另一个会话着陆了,以及分支与快速发展的 main 偏离了多远。那个方向工作使许多长时间运行的努力能够针对同一个移动代码库操作,而不会盲目地相互覆盖。
查看 shell 工具流量内部,最常见的命令族使方向和验证之间的平衡更加清晰:
| 命令族 | 调用次数 | 中位数 | 测量小时数 |
|--------------------|-----------|------------|--------------------|
| git inspect | 300,530 | 2 秒 | 608.1 |
| git other | 89,865 | 3 秒 | 243.1 |
| search | 85,482 | 2 秒 | 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 |
模型选择 {#h-model-selection}
GitHub Copilot 允许单个会话在对话中途更改模型,允许不同会话运行不同模型,所以模型选择成为每个切片的决策。两个不同种类的模型决策出现在日志中。在 driving 每个移植的主线程上,我们选择了模型和推理努力。在会话内部,当 agent 生成子 agent 或 subagent 来探索或 grind through 一个有界任务时,编排模型选择了那些模型。
对于子 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),所以该 volume 的重要部分是由子 agent 而不是单独选择模型来确定的。
使用 agent 舰队 {#h-working-with-agent-fleets}
GitHub Copilot 应用支持可视化活动 pull request 会话、每个的状态以及在它们之间轻松切换,这使其成为管理移植固有的大量并发工作的理想选择。但真正让它发光的事情之一是它的会话与其他会话交互的能力。
一个会话可以创建其他会话,它可以在它们运行时向其他会话发送消息。每个会话,父或子,获得自己的工作树、自己的分支和自己的 agent 循环;它与生成它的会话分开,而不是在其中运行的东西。这与子 agent 不同,子 agent 在父的自己的工作区内运行并将其答案 hand 回父的上下文。两者都是对不同事物有用的构造。
作为会话如何创建其他会话的一个例子,最难的移植之一是 session.ts 文件。这个文件有机地增长到约 30,000 行 TypeScript。它代表了一个会话的主干,有效地水平跨越整个运行时,触及并被几乎每个组件触及,处于状态、事件、工具、模型、hooks、持久化和入口点访问的中心。因此,我将它留到移植过程接近尾声,从堆栈底部向上跨所有垂直领域工作,直到它们都在 session.ts 结束。承担它的移植会话没有开始就 just diving in 并编写 Rust。它在最初五十六分钟阅读,用 122 个工具调用然后才创建任何东西,建立一个关于文件实际拥有什么以及接缝在哪里的图片。直到那时才开始委托,逻辑上分割文件并向 subagent 委托切片。在整个 25 小时的运行中,它自己进行了 222 次 shell 调用、205 次文件查看和 197 次 ripgrep 搜索,另外还有其子会话所做的所有事情。

那是 15 个子会话,每个都是一个带有自己工作树和独立 agent 的单独分支,全部由顶层父会话隐式创建。父会话在约三小时内分七波创建它们:第一波创建五个,第二波约二十分钟后创建另外两个,然后又过了二十分钟创建另一对,然后在接下来的两个小时内分散着单个和成对的。
模型选择是按切片进行的:15 个中的 10 个运行在 GPT-5.6 Sol 上,5 个运行在 Claude Opus 4.8 上。所有 15 个都在 GitHub Copilot 的自动驾驶模式下启动,该模式让会话 pursue 一个目标而不需要在每个步骤停止批准。中位数启动提示约 1,100 个字符,长到可以携带所有权边界和约束,但短到子会话必须自己解决 approach。我提示父 agent,然后父 agent(而不是人类)为每个子会话编写那些启动提示。
除了那 15 个 subagent,同样的父会话还使用了五个子 agent:三个 explore agent 与第一波并行启动,一个 code-review 和一个 rubber-duck。这些子 agent 探索问题,向父会话提供它需要在其上下文中继续之前决定的深度思考答案。子 agent 允许父获得深度思考的答案,而不需要在自己的上下文窗口中花费时间来推导它们。
相比之下,子会话开始实际移植工作,产生 diff 并需要与其他并行移植者隔离的工作。子会话的工作触及了仓库中的 140 个不同文件,其中 120 个只被一个会话触及。20 个有争议的文件都是中心,如 session.ts 本身。但每个会话都在自己的工作树中工作,因此能够不受其兄弟的影响继续进行。当然,父为此付出了协调代价。它花了大量 effort 与其子会话通信,充当信息 broker,轮询它们的状态 60 次并发送 89 条协调消息。当子会话各自宣布完成时,父将它们的 commits cherry-pick 到自己的分支并解决冲突。这些合并也不是特别干净的合并,父 agent 花费了相当多的时间来调和编辑。
我们可以在时间线上看到那个,在时间线上显示父会话及其大多数子会话。
注意那些大间隙。我在旅行时进行这项移植工作,我不得不在各个时候合上笔记本电脑。(我随后更改了工作流程,incorporate 我可以远程进入的基于云的虚拟机。)
这些并行子会话对那台笔记本电脑产生了重大影响。有一段时间,并发移植进行得很顺利。然后所有 15 个并发 agent 在一台机器上 each 尝试构建和测试,我可怜的笔记本电脑陷入了停顿。我提示父,要求它向其子会话中继,它们都必须停止构建和测试。父向外 relay 了那个约束,幸运的是它们杀死了它们的构建并继续以最少的 CPU 活动工作。我随后更新了我 standing 指令,即子 agent 和 subagent 在移植期间应避免大型构建和测试运行,将该工作 defer 仅由父 agent 完成。