The GitHub Blog

Migrating the GitHub Copilot runtime to Rust, using Copilot

8.5内容质量
Migrating the GitHub Copilot runtime to Rust, using Copilot

TL;DR · AI 摘要

GitHub Copilot运行时用Rust重写,AI代理完成80%代码,性能提升百倍,单人开发周期缩短至数月。

核心要点

  • Rust重写使Copilot运行时性能提升至原来的100倍
  • AI代理完成128个PR的80%代码,开发周期从1-2年缩短至数月
  • Copilot SDK被VS Code等10+产品采用,实现能力共享

结构提纲

按章节快速跳转。

  1. GitHub Copilot运行时从TypeScript迁移到RustAI代理参与开发

  2. 支持多产品线需要统一高性能运行时,原TypeScript栈无法满足需求

  3. AI代理完成80%代码,128个PR分阶段合并,性能提升百倍

  4. 单开发者完成核心迁移,团队专注扩展功能,SDK被多产品采用

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • GitHub Copilot运行时迁移
    • 迁移动因
      • 多产品线统一需求
      • 性能瓶颈
    • 技术实现
      • Rust重写
      • AI代理开发
    • 成果影响
      • 性能百倍提升
      • SDK生态扩展

金句 / Highlights

值得收藏与分享的关键句。

#Rust#AI代理#GitHub Copilot#性能优化#软件迁移
打开原文

GitHub Copilot CLIGitHub Copilot 应用GitHub Copilot SDK均由 Copilot 代理运行时提供支持,这是一个可嵌入应用程序和服务的智能代理框架。最初该运行时使用 TypeScript 在 Node.js 和 V8 JavaScript 引擎上实现,用于现在的GitHub Copilot 云代理(CCA),随着运行时功能的快速扩展,该运行时一直沿用这一技术栈。

这一情况现已改变。通过 GitHub Copilot 应用和 Copilot CLI,我们完全用超过 80 万行生产级 Rust 代码重写了运行时。AI 代理编写了大部分代码,覆盖了 128 个已合并到主分支的 Pull Request,这些更改是逐步发布的,而非等待最终一次性切换。少数不可避免的回归问题在过程中被快速发现并修复,同时运行时性能提升了多个数量级。在代理出现之前需要整个团队耗费一到两年完成的项目,现在仅由一名开发者在几个月内就完成了,而团队其他成员则继续大幅扩展运行时的功能和覆盖范围。

为何需要迁移

Copilot 代理运行时不仅是 Copilot CLI 的引擎,还支持微软、GitHub 及生态系统中不断增长的解决方案集。从架构上看,每个解决方案的 AI 支持都是围绕相同运行时加上特定定制功能的外壳。这不仅包括 GitHub Copilot CLI 和 GitHub Copilot 应用,还包括最新版 VS Code、Visual Studio、CCA、Copilot 代码审查(CCR)、Copilot 协作、Copilot Studio,以及 Excel、Outlook、PowerPoint、Word 等诸多产品。

这些产品差异极大,且都不应或不需要重复实现生产级代理框架的所有功能。它们需要所有智能、安全、可靠和性能特性,并希望这些特性能共享,使一处修复能同步应用于所有产品。上文列出的大多数产品最初都实现了自己的代理循环,但现已替换为 GitHub Copilot SDK,这是接入 Copilot 代理运行时的入口。这样做使它们能够专注于核心业务价值,将细节交给运行时处理。在行业快速发展和代理循环需持续保持最佳水平的激烈竞争环境下,这一点尤为重要。

因此,共享运行时是优势。问题在于共享内容本身的性质。

如果查看 CLI,它本质上是构建在代理循环之上的终端用户界面(TUI)。恰巧的是,整个技术栈都是用 TypeScript 实现的,采用 Node.js 作为框架,V8 作为执行引擎,使用 Ink 和 React 构建用户界面。这在 TUI 应用中是一个合理的选择;TypeScript 和 Node.js 普及度高,能够实现非常快速的应用开发。对于控制台应用来说,就启动时间、响应速度、吞吐量和内存消耗等性能指标而言,表现也相对合理。但令人遗憾的是,当考虑将这种实现用于其他环境、面对不同约束条件时,比如需要快速启动和高服务器密度(因内存开销低),这些指标就变得不再合理了。

CLI 及其运行时的架构也在此处带来了挑战。整个行业正在以极快的速度发展,在这种背景下,真正优秀的人才在交付速度和市场覆盖方面做出决策。Copilot CLI 最初是快速编写并发布的,因此 TUI 和运行时之间存在较紧密的耦合,而非分层为独立模块。当需要为该运行时提供编程访问的 SDK 时,由于各层之间缺乏清晰分离,人们采取了一个务实的决定:将 SDK 构建在 CLI 之上,尽管从逻辑上讲,应该采用相反的架构。CLI 不再仅通过用户在命令行输入的命令进行访问,而是新增了一种无头模式,可以从 stdin 读取类似命令,并将响应写入 stdout。随后可以使用 JSON-RPC 协议将外部进程的函数调用传递到 CLI。SDK 可以嵌入任意消费程序中,这些程序会启动一个 CLI 进程来托管代理循环,SDK 通过 JSON-RPC 机制在远程进程中调用函数。这看起来很巧妙,快速部署,灵活。但对消费应用的性能(启动时间、内存、吞吐量)和可靠性来说并不理想。从 SDK 创建一个新的 CopilotClient 意味着要启动另一个进程:

code
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 都为每个客户端支付了额外的语言运行时开销,每个客户端大约需要 100MB 的工作集内存,而这些运行时对应用程序本身毫无用处。这意味着每个事件、每条消息、每个抽象会话的文件系统读写操作都需要跨进程边界传输。这意味着 Node 崩溃时会连带会话一起崩溃。这意味着每个部署此方案的人都至少要监督、监控和调试两个进程。

相反,我们想要一个运行时:

  • 不包含 TUI,TUI 是一个独立的库,这样 TUI 和其他应用程序及服务可以被干净地分层在之上。
  • 用一种具有最小依赖和最小开销的语言实现。
  • 以一种可以干净地嵌入到进程内部的方式实现,而不是被迫使用进程外的方式。
  • 用一种在性能、可扩展性和可靠性方面具有顶级特性的语言实现。
  • 用一种适合互操作性的语言实现,使得所有六种 Copilot SDK 语言版本(C#、TypeScript、Python、Rust、Go、Java)都可以通过该语言栈的外部函数接口(FFI)机制干净地使用它。
  • 使用提供更现代安全态势的工具链实现,减少供应链风险,并更支持构造正确的代码。

出于所有这些原因,以及更软性的原因(如团队经验和行业方向),我们选择了 Rust。这绝不是在声称每个大型 TypeScript 程序都应该转为 Rust。我们的需求强调通过 C ABI 进行嵌入、低启动和稳态开销,以及可预测的资源使用。Rust 使这些目标得以实现,但需要付出其他复杂性的代价,例如我们必须显式地表示生命周期和共享状态(后面讨论的生命周期回归突显了这一点的含义)。对于不同应用场景而言,目标语言的正确选择确实会有所不同。

随后,我们开展了两项关键相关任务:

  1. 将 TUI 特定代码与运行时分离,使得前者严格地构建在后者之上,更具体地说,严格地构建在 SDK 的公共接口之上。目前,CLI 仍在多个地方直接调用运行时的内部实现;将它完全迁移到 SDK 的接口上是正在进行的工作。
  2. 将该运行时层移植到 100% 的 Rust,生成一个纯原生二进制文件,通过 C ABI 提供给所有语言前端进行进程内使用,并提供基于 stdin/stdout 或基于套接字的服务器,以满足仍需进程外使用的情况。

本文主要涵盖第二项内容:将运行时移植到 Rust。

Image 1: Copilot 运行时架构在 Rust 重写前后的对比,展示了旧版 SDK 到 CLI 的进程边界和新版的进程内与进程外托管路径。
Image 1: Copilot 运行时架构在 Rust 重写前后的对比,展示了旧版 SDK 到 CLI 的进程边界和新版的进程内与进程外托管路径。

重写前的架构

2026 年 5 月初的初步移植计划估计运行时大约有 13 万行 TypeScript 代码。从范围界定的角度来看,这一初始测量结果是相对准确的,但事实证明在两个关键方面存在巨大误导。在移植过程中:

  1. 仍包裹在 TUI 层中的部分代码被下推到运行时层。最初在估算中被忽略的整个组件和大量代码后来被认为与移植相关。
  2. 持续有大量新增 TypeScript 代码的拉取请求进入仓库,使得 TypeScript 代码总量不断上升。数十名由代理协助的开发者每周合并数百个拉取请求。

综合考虑各种因素,我估计大约有43万行生产环境TypeScript代码经过了迁移端口。这些相同因素也使得在迁移过程中难以看到进展:直到接近尾声时,生产环境TypeScript代码量似乎保持相对稳定,甚至略有上升,因为迁移进度与新增工作保持同步。

这一情况更加复杂,因为在迁移期间还存在独立于迁移工作的新增Rust代码;在迁移初期,新增代码更可能以TypeScript为主,而在后期则更可能以Rust为主。

图2:TypeScript代码量降至零,而生产环境Rust代码量上升至约83万行,Rust单元测试代码量上升至约46.9万行。
图2:TypeScript代码量降至零,而生产环境Rust代码量上升至约83万行,Rust单元测试代码量上升至约46.9万行。

在迁移过程中,运行时模块接收了约30万行生产环境TypeScript代码,同时减少了约43万行;约120万行生产环境Rust代码被新增,同时约36.5万行代码被移除。换句话说,上述图表中TypeScript代码行数看似稳定的表现,实际上隐藏了大量TypeScript代码的变动。

原地迁移策略

该图表也突显了迁移实施的一个重要特点:原地迁移。

此类规模的重构主要有两种主要方法:

  1. 大爆炸式迁移。新Rust运行时作为完整替代方案开发,完成后一次性全部替换到主分支。这种大爆炸式迁移有两种变体。

a. 停机迁移。所有开发人员在main分支上的其他工作暂停,重构工作直接在main分支上进行。 b. 并行开发。重构工作在特性分支上进行,同时主分支继续开发,重构工作持续追赶并合并主分支的变更。

  1. 原地迁移。这是按组件逐步迁移的方式,运行时模块逐个增量重写。这种原地迁移策略也有两种变体。

a. 原子替换。每个组件从TypeScript原子性切换到Rust,通过剩余TypeScript与新Rust之间的互操作性实现连续性。随着时间推移,生产环境运行时中的TypeScript占比逐渐减少,Rust占比逐渐增加,直到某一天完全只剩下Rust。 b. A/B方案。在组件迁移后不立即删除TypeScript代码,而是同时维护TypeScript和Rust组件作为可热切换的选项,当对TypeScript的置信度达到平台期后才将其删除。

出于多种原因,我们选择了选项2a:

  • 没有人会经历工作停滞。主分支始终保持活跃状态。未直接参与端口开发的开发者可以继续日常工作,只有在他们长时间提交的拉取请求恰好涉及同时被端口修改的代码时,才需要重新变基并让代理工具协助迁移其未完成的更改。
  • 运行时的主分支始终可发布。每个拉取请求都会用一个调用Rust的薄层封装替换现有的TypeScript实现,并通过一次原子性操作删除旧代码。新代码会立即在原地进行测试。
  • 重写过程是渐进且可审查的。每个拉取请求仅迁移单个组件或代码片段,因此变更范围更小,无论是人工审查还是代理工具审查,差异内容都更容易理解。
  • 大多数端口操作规模适中且自包含,最大限度减少了并发拉取请求带来的代码漂移。在某些TypeScript组件过于庞大的情况下,可以先将其重构为更易迁移的组件。
  • 所有现有的端到端测试(涵盖CLI和SDK)在每个开发阶段都会针对新的Rust代码运行,为我们提供了充分的信心和大量验证。如果某个拉取请求导致关键测试失败,该请求将无法合并。

我们还避开了涉及同时维护同一组件多个版本的2b方案。过去几个月每周都有数百个拉取请求被合并,代码库持续快速演进。同时维护两种语言的相同代码版本,并使用两套不同的依赖库会带来大量复杂性。部分组件甚至无法完全隔离:虽然有些组件逻辑上独立且有简单的API供系统其他部分调用,但其他组件存在大量复杂依赖关系,实现按组件热切换的可替换图结构将是一场噩梦。那些最可能从谨慎并行迁移中获益的子系统,恰恰是实现并行最困难的模块。例如,会话编排不是一个可以用if/else条件语句根据实验标志切换不同版本的纯函数。它拥有可变状态,双向驱动回调,并贯穿几乎所有其他子系统,因此“同时运行两个版本并比较”的方案意味着需要维护两个不同版本的组件副本(用于保存对话状态和服务),并祈祷它们在数百次并发修改中保持同步。使组件难以迁移的耦合关系,恰恰也是使它几乎无法影子化而不会引入更多回归问题的耦合关系。这种切换方式带来的主要好处是增强信心,而我们可以通过其他方式实现这一目标。

验证也通过逐步发布的方式进行。如果采用一次性切换的方法,我们需要将所有内容保留在长期分支中,移植整个运行时,一次性完成切换。这意味着消费者会同时体验所有移植的代码行,包括在仓库内测试中遗漏的所有回归问题。通过逐步发布变更的各个部分(这里两个组件,那里一个组件),我们能够在实际消费者使用(通常是微软和GitHub内部的一线产品)的部署构建中获得最后一公里的验证,同时将回归风险降至最低。在大约十四周半的移植窗口期内,main分支发布了135次版本,包括100个预发布版本和35个稳定版本,平均每天约1.3次发布。每天也平均有约1.3个移植的拉取请求,使得每个版本都包含少量且可明确追踪的移植组件(我们通常尝试但并非总能优先在预发布版本中完成移植)。在最近七天的npm样本中,预发布版本仅占下载量的10.5%,表明初期暴露范围相对有限,同时我们在监控反馈渠道以发现潜在问题,并在下一个预发布版本中快速修复。报告的问题更容易与已知的近期变更关联,也更容易找到根本原因并迅速解决。通过这种方式,长时间逐步完成移植实际上是一个优势而非阻碍(即更快并不总是更好)。到8月21日,运行时已完全采用生产级Rust:包含832,378行生产级Rust代码和468,689行Rust单元测试代码,此外还有174,675行端到端TypeScript测试代码。独立的GitHub Copilot SDK仓库又增加了约13万行跨Node.js、Python、Go、C#、Rust和Java的端到端测试代码。

Image 3: 从5月12日到8月21日的时间线,显示128个移植拉取请求合并(按修改行数大小)和135次公开CLI发布;最大的移植变更集中在移植完成附近。
Image 3: 从5月12日到8月21日的时间线,显示128个移植拉取请求合并(按修改行数大小)和135次公开CLI发布;最大的移植变更集中在移植完成附近。

开始实施

在全面投入之前,我们先建立了信心并验证了可行性。我们首先创建了两个拉取请求,用于建立Rust工作区、工具链、代码规范、CI、构建流水线和编码指南,然后引入了运行时crate以及代码生成和互操作模式,同时移植了一批特定选择的纯逻辑原语(这些原语没有I/O或共享状态,并且已有强测试覆盖)。只有在这些变更合并之后,第一个主要移植拉取请求才开始将三个无副作用的辅助函数完整地通过整个流程。这些函数作为试点发布,将关于仓库结构、FFI、打包、测试和代码审查的假设转化为后续更大规模移植可以复用的惯例。基本上,我们对整个流程进行了端到端测试。计划继续从叶子节点向内推进工作,通过纯辅助函数、内容排除、shell工具和会话文件系统操作建立翻译和测试模式。随后是状态子系统,工具、钩子、模型客户端和MCP构建在这些模块之上。会话编排(运行时中最耦合且最不自然并行的部分)则会在最后阶段进行。

Image 4: Timeline of 128 landed port pull requests from May through August, progressing from small foundational components to larger orchestration and session work.
Image 4: Timeline of 128 landed port pull requests from May through August, progressing from small foundational components to larger orchestration and session work.

| 时间段 | 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支持通过7个专用的Pull Request逐步推进,而工具部分则通过六步系列实现,之后还需要额外工作来完成编排并移除剩余的TypeScript代码。钩子、认证、遥测、插件、设置和持久化等功能也遵循了类似的路径。

实际上,移植的有用单元并不总是“组件”。它经常表现为相关行为区域的波浪式推进:首先迁移纯逻辑,然后迁移状态所有权,接着迁移编排,再移除回退方案,最后在临时互操作性消失后简化Rust代码。

互操作性

此次移植涉及互操作性的两个主要层次:

  1. 临时内部互操作性。每当一个函数被移植到Rust时,该函数需要能够被初始TypeScript函数的调用者调用。同样,我们也需要让Rust函数能够调用TypeScript回调。这种互操作性需求是实现细节,且非常灵活。随着Rust内部表面积的扩大,所需的TypeScript shim数量也会增加,因为它们与需要从TypeScript调用的Rust方法呈1:1关系。当这些调用者被移植到Rust时,原有的shim层会被删除,并替换为新的层。最终我们到达运行时库的公共入口点,此时shim层将消失。
  2. SDK接口。所有SDK库都需要能够构建在运行时之上并暴露其功能。在移植前,这是通过双向JSON-RPC层实现的,SDK通过发送JSON-RPC方法调用负载来请求功能,运行时解析请求并调用相关API,然后通过相同传输通道将结果返回给SDK解析并返回。反向方向也存在;运行时需要能够回调SDK客户端,例如钩子通知和权限请求,这些在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)]注解的结构体会在另一侧变成普通对象。

流量也必须双向流动。许多移植的组件暂时依赖于尚未移植的内容,因此 Rust 需要回调到 TypeScript。例如,一个用 Rust 实现的工具需要向仍为 TypeScript 的模型层请求推断、触发钩子或请求运行命令的权限。napi-rs 通过“线程安全函数”处理这种情况,允许在 Tokio 工作线程上运行的 Rust 代码在 Node 的主线程上调用 JavaScript 回调。Node 仅安装一次回调,Rust 保存该回调并在需要反向通信时调用。这些回调本质上都是临时的:回调的存在仅因为另一端仍是 TypeScript,当该部分被移植后回调会被删除。

Image 5: 在逐步移植过程中,临时的 Rust N-API 导出及其 TypeScript 调用点数量先增长,随后随着调用方迁移到 Rust 且临时互操作接口消失而减少。
Image 5: 在逐步移植过程中,临时的 Rust N-API 导出及其 TypeScript 调用点数量先增长,随后随着调用方迁移到 Rust 且临时互操作接口消失而减少。

临时互操作接口在 8 月 3 日达到峰值,当时有 2019 个内部 N-API 导出和 3356 个 TypeScript 调用点。移植完成后,运行时完全由 Rust 构成,因此没有内部互操作:剩余的临时内部 N-API 导出和 TypeScript 调用点均为 0。(我之前提到过 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 也将变成需要版本管理的二进制兼容性接口。

我们仍然需要为那些确实位于远程的运行时(无论是跨进程边界还是通过TCP)使用JSON-RPC。在进程中保持相同的协议意味着维护一个双向API和分发系统,而不是为远程连接使用JSON-RPC,同时为本地连接每个方法单独维护一个FFI接口。这是一个需要权衡的真实取舍,而不是显而易见的决定。我们避免了进程跳转,但每次调用仍然需要支付JSON-RPC的开销。对于以推理为主的负载,这种序列化通常与模型往返相比微不足道。在高吞吐量本地负载中,这仍然可以测量,但目前还不足以证明要在六个SDK绑定中重复数百个方法。如果性能需求出现,我们也可以轻松地在未来修改这个决定。负载编码是两端的私有细节,将JSON替换为更密集的MessagePack不会改变任何声明的导出。对于热点路径,也可以稍后添加按方法的类型化导出,调用相同的引擎和相同的处理程序,而无需替换字节通道,字节通道仍将作为流式传输、服务器到客户端请求以及很少调用的方法的底层支撑,这些方法的定制导出不会带来任何好处。

会话数据所显示的内容[](https://github.com/github/blog/blob/85a7cc38da5e818bfc20aa1e2e6f23889c85aac7/blog-drafts/rustrewrite/rustrewrite.md#what-the-session-data-shows)

本文中的几乎所有数据都来自两个来源之一。第一个来源是私有仓库github/copilot-agent-runtime的GitHub历史记录:拉取请求及其差异、评审评论、CI运行等。第二个来源是代理会话日志。运行时(因此CLI、应用等)为每个运行的会话写入结构化事件日志:每行一个JSON对象,会话进行时追加记录。这些日志可能包含提示、命令、命令输出、文件路径以及工具暴露的潜在敏感信息,因此必须作为敏感数据处理。日志仅限于运行会话的机器本地;启用远程会话功能时,也可以根据产品设置和组织策略上传日志。

以下是所有移植拉取请求中数据的汇总:

| 指标 | 数量 | | --- | --- | | 事件 | 12,760,995 | | 用户消息 | 31,247 | | 助手消息 | 1,385,214 | | Hook开始和结束事件 | 6,438,562 | | 工具启动 | 1,857,409 | | 编译命令 | 23,096 | | 测试命令 | 19,485 | | 变基命令 | 2,496 | | 提交命令 | 7,410 | | 推送命令 | 5,554 | | 完成的压缩操作 | 5,116 |

这31,247条用户角色消息并不是我亲自输入的31,247条提示,它们包括技能指令、自动化合并标记、跨会话消息和子代理流量,除了我输入或说出的约2,600条消息(约占1/12)。同样,1,385,214条助手消息包括子代理和工具导向的消息,而不仅仅是对话式UI中显示给我的文本。语料库包含68种不同的事件类型和67种不同的工具名称;1,130,921次工具调用(占61%)来自子代理而非主线程。

这个数量并未说明我为何要插入自己约2,600次。为此,我让Copilot为会话日志语料库中的每条人工消息分配了一个主要意图。

Image 6: Of 2,639 human-authored messages, 31.0% focused on review, testing, and CI; 17.4% challenged technical or design decisions; and 15.0% pushed for completeness.
Image 6: Of 2,639 human-authored messages, 31.0% focused on review, testing, and CI; 17.4% challenged technical or design decisions; and 15.0% pushed for completeness.

前三个类别占我交互内容的63%。只有约40条消息能被识别为会话启动信号,因为大部分时候我首先会创建一个聊天会话来探索下一个目标,然后再让该会话为每个预期的切片生成实际的端口会话。我的角色更偏向于“操作控制循环”而非“分配任务并等待”:检查结果、质疑技术决策、执行质量门禁,并在代理将中间停止点误认为终点时施加压力。即使代理在执行“具体工作”,人类判断仍然深度参与其中。我的参与方式只是向上转移了——我不再负责编写语法,而是负责界定问题、设定边界、选择策略、裁决例外情况,并确保所有工作都朝着正确方向推进。

全部都与缓存有关

LLM服务提供商通常对输入令牌(发送给他们的内容)和输出令牌(他们发送给你的内容)分别收取不同费用。计费通常以令牌为单位,因为它们是对推理所需计算工作量的有用近似:对于每个输入令牌,模型必须读取它、将其纳入内部表示,并将其作为计算下一个令牌的一部分。然而,提供商通常支持缓存这些计算结果,如果提示的相同前缀已经被处理过,提供商可以从缓存中重用中间计算结果,而无需从头开始重新计算。这会降低处理这些令牌的成本,节省的费用可以转嫁给用户。因此,输入令牌通常会以多种费率进行宣传,包括从缓存中读取输入令牌的费率。

折扣非常可观!通常提供商对缓存命中收取90%的折扣,例如,某提供商可能对100万输入令牌收费2.00美元,但对100万从缓存读取的输入令牌仅收费0.20美元。换句话说,你真的非常需要维护良好的提示缓存,使账单减少一个数量级。

端口工作的数据表明我们在这一点上做得很好。提示缓存命中率为96.22%:缓存读取次数除以所有输入侧令牌总量(缓存读取次数加缓存写入次数加新输入)。缓存写入占3.07%,新输入占0.71%。这并非偶然。GitHub Copilot特别设计了代理循环以保持长且稳定的前缀(系统提示、工具定义、累积对话),因此每次交互都会将模型已处理的上下文追加到上下文中。上下文中昂贵的部分只需支付一次费用,之后每次调用时以不到十分之一的货币成本重新读取。这也是为什么长自主会话的经济模型能够成立的原因。如果一个持续300小时的端口工作在数万次调用中每次都从头开始重新读取其不断增长的上下文,成本将比我们实际看到的高出一个数量级。代理框架开发者投入大量精力避免破坏提示缓存,而模型供应商也经常推出新功能帮助他们实现这一点。

图7:在移植会话期间的提示缓存组成:96.22%缓存读取,3.07%缓存写入,0.71%新输入。
图7:在移植会话期间的提示缓存组成:96.22%缓存读取,3.07%缓存写入,0.71%新输入。

压缩功能讲述了一个互补的故事。在所有移植会话中,GitHub Copilot 自动压缩上下文 5,116 次(即当会话填满其上下文窗口并进行自我总结以继续运行的时刻)。单个会话-基础设施移植拉取请求在其多日生命周期中压缩了 647 次,而某个小型移植从未进行过一次压缩。只有代理能够反复重用其工作记忆而不会丢失主线,才使得持续数百小时的自主工作成为可能。每一次压缩都可能成为有损交接的节点,从而悄然导致移植失败,但大多数情况下并未发生。Copilot 的子代理在减少压缩方面也起到了关键作用。每个子代理都有自己的上下文,因此父会话可以有效地提问,让子代理去执行大量上下文计算以得出答案,然后仅将答案报告给父会话。父会话的上下文无需受到所有这些中间信息的影响。

上述“大多数情况下并未发生”在会话日志中可见。我让 Copilot 将每次成功的压缩与其周围的工作配对,前提是压缩前后至少各存在 20 次工具调用。这产生了约 4,000 个可比较的窗口。压缩前 20 次工具调用中代理的行为与压缩后的行为在规模上相似(压缩前探索占 46.5%,压缩后占 48.1%;压缩前修改占 8.4%,压缩后占 6.0%;压缩前验证占 4.7%,压缩后占 4.0%;压缩前失败占 1.0%,压缩后占 1.5%)。如果压缩经常导致思路中断,我们预计压缩后会出现明显的重新定位特征,表现为阅读量激增而编辑量骤降,因为代理需要重新发现其位置和应执行的操作。但实际上,这种趋势仅表现出轻微的转变。

是的,静态分析确实有帮助

有一个流行的梗认为,Rust 是 AI 生成代码的特别理想目标,因为 Rust 严格的编译器能捕捉到模型出错的地方。会话日志让我们可以测试这个理论,至少针对类似此次移植工作的任务。

直接验证命令的结果捕获了 8,678 次 rustc 的错误代码。最大的四个诊断类别覆盖了 84% 的错误:

  • 37%:名称和导入解析,主要由 E0425(“在此作用域中找不到值”)主导
  • 22%:缺少方法或字段
  • 14%:类型不匹配
  • 11%:未满足的 trait 约束

所有这些都属于常规的连接问题:名称输出略微错误,签名不匹配,字段被重命名,抽象未实现。这些正是批量翻译容易且无意中产生的错误,而编译器能迅速捕捉到这些错误。

但请注意列表中缺失的内容:任何真正特定于 Rust 的特性。这四个类别中的每一个都属于静态类型语言的常规功能,C#、Java 或 Go 编译器同样可以捕捉到所有错误,其中一些甚至能提供更友好的诊断信息,并且所有错误的检测速度都快得多。如果这就是选择 Rust 的理由,那实际上适用于任何静态类型语言。一个强类型编译器和/或具备出色静态分析和代码检查功能的语言,确实非常适合这项工作,代理工具可以将其作为快速反馈循环使用。在 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 秒 | 17.0 | | edit | 40,591 | 1 秒 | 24.1 | | read_powershell | 36,728 | 90 秒 | 1,203.9 | | task | 13,080 | 274 秒 | 2,329.0 |

我的初步结论是,代理工具在收集证据上花费的时间远多于修改代码。从文件读取和搜索工具与编辑工具的对比来看,它们的探索量是修改量的 10 倍。阅读文件、搜索仓库和运行诊断命令占据主导地位;而代码编辑则显得相对微不足道。人工智能频繁输出代码的流行形象几乎完全颠倒;在如此规模的项目中,工作更像是一种迭代式调查:检查当前状态,形成假设,进行针对性修改,然后不断重复。

委托机制进一步放大了这一模式。子代理主要用于分散探索独立问题,而主代理则更可能负责代码修改并整合答案。这种分工对这类项目非常有用:许多上下文可以并行调查,但将修改操作集中在协调代理附近,可以减少冲突变更并保持实现策略的连贯性。

shell 命令的流量也展示了自主软件工作中状态管理的重要性。只读 Git 检查是最常见的命令模式,因为代理工具不断询问“我现在在哪里?”。它们在寻找变更内容、变基操作的影响、其他会话的提交记录,以及分支与快速演进的 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 |

模型选择[](https://github.com/github/blog/blob/85a7cc38da5e818bfc20aa1e2e6f23889c85aac7/blog-drafts/rustrewrite/rustrewrite.md#model-selection)

GitHub Copilot 允许单个会话在对话过程中切换模型,也允许不同会话运行不同模型,因此模型选择成为每个切片的决策。日志中出现了两种不同类型的模型决策。在主线程上,每个端口的驱动程序选择模型和推理努力程度。在会话内部,当代理启动子代理或子会话以探索或完成有界任务时,协调模型会选择这些模型。

图像 8:主要移植会话中每周模型组合,集中在少量模型上,并在移植过程中发生变化。
图像 8:主要移植会话中每周模型组合,集中在少量模型上,并在移植过程中发生变化。

对于子代理,模型组合看起来略有不同,代理而非人类优化吞吐量和成本,而非最难的判断。最常启动的子代理运行在 Claude Opus 4.8、GPT-5.6 Sol、Claude Haiku 4.5 和 GPT-5.5 上,其次是 Gemini 3.1 Pro 和 Claude Opus 5。然而,在移植进行时,至少有三个频繁使用的代理定义固定了其模型选择(exploretask 使用 Claude Haiku,research 使用 Claude Sonnet),因此该部分的大量数据由子代理的选择而非单独选择模型决定。

图像 9:移植过程中生成的子代理每周模型组合,集中在 Claude Opus 4.8、GPT-5.6 Sol、Claude Haiku 4.5 和 GPT-5.5 上。
图像 9:移植过程中生成的子代理每周模型组合,集中在 Claude Opus 4.8、GPT-5.6 Sol、Claude Haiku 4.5 和 GPT-5.5 上。

与代理舰队协作

GitHub Copilot 应用程序支持可视化活动的拉取请求会话、每个会话的状态以及轻松切换会话的功能,这使其成为管理移植过程中固有的大量并发工作理想的工具。但真正使其脱颖而出的是会话之间交互的能力。

一个会话可以创建其他会话,并在其他会话运行时与其通信。每个会话(父会话或子会话)都会获得自己的工作树、自己的分支和自己的代理循环;它独立于创建它的会话,而不是在其内部运行。这与子代理不同,子代理在父会话的工作空间内运行,并将答案返回到父会话的上下文中。两者对于不同用途都是有用的构造。

作为会话可能创建其他会话的一个示例,session.ts文件是移植过程中最难处理的端口之一。这个文件自然增长到约3万行TypeScript代码,它构成了会话的核心,横向贯穿整个运行时,几乎被所有组件所依赖,同时也是状态、事件、工具、模型、钩子、持久化和入口点访问的中心。因此,我几乎将它留到移植过程的最后阶段,从堆栈底部的所有垂直领域逐步向上处理,直到所有路径最终汇聚到session.ts。负责移植该文件的会话并未立即开始编写Rust代码,而是在最初的56分钟内进行了大量阅读,进行了122次工具调用,才开始创建内容,构建出该文件实际拥有的功能和接口边界。只有在充分理解后,才开始进行逻辑拆分,将文件拆分为多个部分并委托给子会话处理。在整个25小时的运行过程中,该会话进行了222次shell调用、205次文件查看和197次ripgrep搜索,这还不包括其子会话所做的所有操作。

Image 10: Fifteen child sessions nested under the session that spawned them.
Image 10: Fifteen child sessions nested under the session that spawned them.

这是15个子会话,每个子会话都是一个独立的分支,拥有自己的工作树和代理,均由顶部的父会话隐式创建。父会话在大约三小时内分七波创建了这些子会话:第一波创建了五个,第二波在二十分钟后创建了另外两个,第三波在二十分钟后又创建了两个,之后的两小时内则交替创建单个和成对的子会话。

模型选择是按切片进行的:15个子会话中有10个运行在GPT-5.6 Sol上,另外5个运行在Claude Opus 4.8上。所有15个子会话都以GitHub Copilot的自动驾驶模式启动,这种模式允许会话在追求目标时无需在每一步都等待人工批准。中位数启动提示的长度约为1100个字符,足够承载所有权边界和约束条件,但又足够简短,迫使子会话自行思考解决方案。我向父代理发送了提示,然后父代理(而非人类)为每个子会话编写了启动提示。

除了这15个子会话外,父会话还同时启用了五个子代理:三个explore代理与第一波并行启动,一个code-review代理和一个rubber-duck代理。这些子代理负责探索问题,在父会话决定下一步行动前,将答案反馈给父会话以供其上下文使用。子代理使父会话能够获得深入思考后的答案,而无需消耗自己的上下文窗口来推导这些答案。

与之形成对比的是,子会话开始着手实际的端口移植工作,这些工作会产生差异(diff)并需要与其他并行移植者隔离。子会话的工作涉及仓库中140个不同的文件,其中120个文件仅被一个会话修改过。剩下的20个存在冲突的文件都是枢纽文件,例如session.ts本身。但每个会话都在自己的工作树(worktree)中工作,因此能够不受兄弟会话的干扰。当然,父会话为此付出了协调的代价。它花费了大量精力与子会话通信,充当信息中介,对子会话状态进行了60次轮询并发送了89条协调消息。当每个子会话宣布完成时,父会话将它们的提交记录挑选到自己的分支中并解决冲突。这些合并并不特别干净,父代理花费了相当多的时间来协调这些修改。

我们可以在时间轴上看到这一点,时间轴展示了父会话和大部分子会话的情况。

Image 11: Timeline of the session.ts port showing one 25-hour parent session using five subagents and spawning 15 child sessions in seven waves.
Image 11: Timeline of the session.ts port showing one 25-hour parent session using five subagents and spawning 15 child sessions in seven waves.

注意那些较大的空白区域。在进行这次端口移植时我正在出差,不得不在多个时间点关闭笔记本电脑。(之后我调整了工作流程,改用可以远程访问的基于云的虚拟机。)

这些并行的子会话对那台笔记本电脑产生了显著影响。一段时间内,并行移植进行得非常顺利。但当机器上的所有15个并发代理都尝试构建和测试时,我的笔记本电脑几乎完全卡死。我向父会话发出提示,要求它传达给子会话,让它们停止构建和测试。父会话将这一限制传达出去,它们很幸运地终止了构建过程,并以极低的CPU使用率继续工作。之后我更新了对子代理和子会话的指导方针,要求它们在端口移植期间避免进行大规模的构建和测试,而是仅由父代理执行这些操作。

后来我更进一步,将一个普通的聊天会话改造成八个独立端口移植会话的构建调度器。提示内容简单得令人尴尬:向所有开放会话发送一项政策,尽可能避免进行CPU密集型的构建和测试,当需要构建时必须向这个会话请求许可,并作为门禁,每次只允许一个会话进行构建。基本上,我将聊天会话转变成了一个代理互斥锁(mutex)。这个门禁机制维护了一个显式的拥有者和队列,并通过会话间已用于协调代码的跨会话消息机制,每次只授予一个会话构建权限。那些请求构建但被拒绝的会话通常会通过执行其他任务(例如从待办事项列表中处理任务)来等待。

Image 12: One chat session acting as the build resource gate for eight porting sessions.
Image 12: One chat session acting as the build resource gate for eight porting sessions.

这个session.ts移植过程也参与了我在整个运行时移植过程中目睹的最酷、最悲伤、也最出人意料的交互之一。正如我之前提到的,我们主要采用自底向上的方式完成移植,因此session.ts这个实际上位于所有其他组件之上的组件,成为最后一个被移植的组件。唯一始终位于session.ts之上的就是所有进入运行时的入口点,即从SDK暴露出来的公共函数,这些函数会出现在之前讨论的调度表中。这类入口点有数百个。虽然我知道其中许多会立即调用session.ts,但我想提前启动移植工作,因此在启动session.ts会话后,我启动了一个会话来移植所有入口点。我告诉它在session.ts边界处停止。我认为虽然可能需要一些临时工作或在重新基底时投入一定精力或令牌数量,但这将加速整体移植进度。然后我上床睡觉了。结果……它们找到了彼此。

我为入口点会话设置的启动提示明确告知它,会话移植和六个组件移植将同时运行,这样它就能了解自己的边界并避免尽可能多的冲突。显然,我的提示产生了完全相反的效果。在开始四分钟后,它完成了入口路径的清点,并形成了重叠程度的判断,随后调用了内置的orchestrate技能,该技能的目的是协调跨会话的工作。从那时起:

  1. 入口点会话列举了所有活跃会话,并向那些被认为存在重叠的会话发送了消息。
  2. session.ts会话以一个2,001字符的清单作为回应,标题为“stephentoub-port-session-to-rust上的具体重叠”。
  3. 入口点会话读取了session.ts会话的工作树,以确认它刚刚被告知的内容(大概是“信任但验证”的原则)。
  4. 入口点会话询问session.ts会话是否准备好协调其760个文件差异。
  5. session.ts会话基本上告诉它“别烦我了”:“尚未准备好提交/整合。”
  6. 入口点会话随后又重复了三次同样的问题,每次session.ts会话都给出了相同的回答。
  7. 在此之后,入口点会话决定不再理会session.ts会话的想法,直接进入其工作树,提取了其他会话的所有更改并将其合并到自己的工作中。
  8. 随后,两个会话各自继续自己的工作。

从这次交互中我学到的几点:

  1. 明确表达意图非常重要。启动提示中命名了其他运行会话,因此这个会话知道哪些内容需要保持原样。但我没有明确“保持原样”这一部分,结果反而鼓励代理执行操作,而非阻止它。我需要在意图和指导中更加明确。
  2. 你提供的任何内容都可能被代理决定适用。orchestrate技能内置于GitHub Copilot应用中,描述其用于并行运行独立工作流。我的提示中并未提及它。模型自行发现了当前情境,将其与描述匹配后加载了该技能。你暴露的能力集即是你可能获得的行为集合,即便这些情境是你从未设想过的。
  3. 并行会话需要仲裁机制。两个会话都无法说服对方。当session.ts会话四次拒绝整合时,这种拒绝没有分量,因此愿意单方面行动的会话默认获胜。相邻代码的并行会话需要指定协调者,或者需要人工介入,而这两个都没有。
  4. “自主运行”需要为涉及分支外决策的情况设置例外。我真正意思是“不要因设计细节唤醒我”。模型合理地认为合并同行代码在范围内。同样,我本应在指导中更加明确。
  5. 根本原因在于我自身。我同时采用自上而下和自下而上的方式划分工作,两种方向在代码库中最核心的文件交汇。我过于急切地推进工作。所有后续内容都源于此。

幸运的是,这次交互是一个有趣的例外情况。在整个运行时移植工作中,大多数叶组件移植都是简单的单会话任务。较大的子系统通常涉及多个子会话和子代理。不过,这些组件在移植过程中的参与方式差异显著。

模型编排层的移植(实际与提供方通信的层)提供了一个典型模式的示例。其主会话运行了42小时墙钟时间并启动了126个子代理。最繁忙时,22个代理同时工作。然而,大部分时间只有主代理在运行,偶尔会短暂生成大量子代理。

图13:42小时的模型编排移植在前12小时生成了大部分代码,随后进入涉及126个子代理的明显验证和审查阶段。
图13:42小时的模型编排移植在前12小时生成了大部分代码,随后进入涉及126个子代理的明显验证和审查阶段。

这张图中有三个显著特征。首先,几乎所有代码生成都在前12小时内完成;之后一天的工作全部是验证。其次,底部行的颜色从左到右变化,从以蓝色和绿色(阅读、构建)为主转变为以蓝色和橙色(阅读、审查)为主;这在逻辑上是合理的,但实际看到这种现象很有趣。第三,这个示例以及更普遍的这种模式,各阶段之间有非常清晰的分离。扩展运行时移植则是反例。

Image 14: The 88-hour extension runtime port interleaved reading, writing, building, and reviewing throughout most of the session rather than separating them into phases.
Image 14: The 88-hour extension runtime port interleaved reading, writing, building, and reviewing throughout most of the session rather than separating them into phases.

耗时从原来的42小时延长至88小时,结构差异显著:

  • 写作与审查高度重叠。与前一个示例中瀑布式的工作流程(先生成代码再审查)不同,此处审查工作早在写作完成前就已启动,且在大部分工作周期内持续并行推进。
  • 底部行的颜色分布极为分散。在模型编排流程中,随着工作从写作转向检查,颜色从绿色渐变为橙色,而此处从始至终都混合着阅读、构建和审查的活动。阅读调用的中间阶段跨越49小时,写作跨越33小时,审查跨越27小时,全部嵌套在88小时的会话中。每个类别都几乎覆盖整个运行周期。
  • 空闲时间被延后至末期。舰队在前56小时内几乎持续运行。
  • 比例关系依然匹配。此处编写Rust占工具调用的2%(彼处为1%),阅读占44%(彼处为57%),审查占23%(彼处为27%)。两次会话对需要完成的工作内容达成一致,只是执行时机存在差异。

结尾处的空白切片也直观反映了当前智能编程时代日益普遍的问题:等待审批。团队成员或代理审查代码并提出反馈后,代理会短暂处理反馈并推动CI恢复绿色,随后再次进入等待期,如此反复,直到最终获得令人愉悦的审批通过标识。

这两个示例各自代表了主流模式。约四分之一的会话更接近模型编排会话模式,而四分之三的会话则更接近扩展运行时模式。清晰的阶段化推进是特例;常见情况是代理从规划、编写到审查全程参与。

大规模代码审查

此前图表中对审查的重视很大程度上源于我的明确指令。我创建了一个名为rust-rebase-review的提示技能(除仓库中已合并的通用Rust编码技能外),由于频繁出现冲突的变更,我经常需要进行变基操作。通过自定义指令,我引导框架在适当阶段调用该技能,也偶尔手动触发。该提示随时间略有演变,但基本遵循以下模式:

code
将提交压缩为单个提交,然后基于origin/main最新提交进行变基,解决所有冲突并强制推送。在变基过程中,特别关注所有变更、新增、删除的内容,并确保逻辑正确迁移至对应Rust代码。务必自行在主代理中完成变基,不要为此创建子代理。

随后进入审查/修复循环,按opus 5、gpt-5.6-sol和grok 4.6分别启动子代理。
  • 该子代理应逐行比较旧版 TypeScript 和新版 Rust,确认行为一致性。
  • 寻找任何可能引入不兼容性的内容;我们的目标是将代码迁移到 Rust,尽可能保持 100% 的语义一致性。如果遇到任何存疑之处,请向我确认。
  • 我们希望确保编写尽可能高效且符合 Rust 习惯的代码;寻找简化机会,使用 memchr crate 中的现成函数优化搜索(而非手动编写循环),避免不必要的内存分配,使用 trait 实现代码复用和松耦合等。
  • 确保所有已废弃的 TypeScript 代码(例如已完全迁移的代码、因重复而不再需要的测试、不必要的 napi 适配层等)已被删除。
  • 确保尽可能迁移了所有代码,例如:如果已修改文件中仍有 TypeScript 代码且这些代码不只是适配层,这将是一个警告信号;如果新增了非极简适配层的 TypeScript 代码,这也是警告信号。检查所有调用 TypeScript 适配层的代码,确认这些调用是否可以迁移到 Rust,尽可能将边界推进到合理范围。我们的目标是尽快实现运行时层 100% 使用 Rust。
  • 验证是否删除或修改了任何 E2E 测试。此类修改可能表明存在迁移错误。

如果审查中发现问题,请验证这些问题,若需要修复则进行修复,并重复进行完整审查。持续进行审查/修复迭代,直到所有审查结果均无问题。每次根据审查反馈进行修改后,立即提交并推送代码,使 CI 验证与后续审查并行执行。

无需运行完整的测试套件;这部分将在 CI 中处理。尽量将消耗 CPU 的工作降至最低,因为我们将同时进行大量并发操作。

code

由于频繁的代码合并,新提交的更改很容易被意外丢失。但我们在原地原子交换中发现了一个意外的好处:在删除 TypeScript 的同时添加对应的 Rust 代码,这会隐式地与合并冲突产生的 TypeScript 修改产生冲突:一个分支修改它,另一个分支删除它。这确保了我们能及时发现已迁移代码的修改,而无需对每行新代码都判断是否可能涉及之前迁移过的代码。

当然,我自己的审查只是整个代理审查流程的一部分。除了每次提交都会运行的 CCR,团队还部署了多个专用代码审查机器人,每个机器人均有不同的审查策略和提示,它们会为每次提交提供详细反馈。所有这些反馈最终都会以评论形式出现在拉取请求中,需要逐一处理。幸运的是,处理这些代理反馈也可以通过代理系统自动完成(大部分情况如此)。

在我的审查中,我重点关注架构设计、编码规范和实现方法。代理负责执行详尽的新旧代码对比;测试和静态分析检查可机械验证的属性;人工审查者则专注于架构设计、API 协议、潜在风险以及由其他审查层发现的可疑代码区域。

我选择了目标架构,明确了需要关注的行为,划分了工作内容,解决了模糊的权衡取舍,评估了证据,手动审查了高风险区域,检查了代理对反馈的响应,并做出了最终的合并决策。代理改变了单个工程师能够监督的代码量。它们并没有消除对理解系统并能为方向、防护措施和发布负责的工程师的需求。

## 自动化内部流程

GitHub Copilot 应用程序是这项工作的核心。它能够管理大量并发的活跃会话,使用户可以轻松切换会话并携带所有相关配对上下文(关联的终端窗口、浏览器窗口、画布等)。这里最关键的功能是代理合并:

![图像15:GitHub Copilot 应用程序的代理合并面板,跟踪审查反馈、CI、冲突和合并准备情况。](https://github.blog/wp-content/uploads/2026/09/AgentMerge.png?resize=946%2C439)
Agent Merge 是内置在应用程序中的一个循环(CLI 也通过 `/pr auto` 提供此功能)。通过定时器或响应外部刺激(如 GitHub 的 CI 完成通知或审查评论),应用程序会检查发生了哪些变化。如果收到审查评论,它将调用代理来决定是拒绝该评论还是接受并处理它(并作出回应,注明这是自动化系统在响应)。如果测试失败,它将下载日志,调查失败原因并修复错误。如果发生冲突,它将调用代理进行合并或变基。实际上,它自动化了我们人类开发者都会执行的循环,推动拉取请求达到“绿色”状态,获得批准并最终合并。

Agent Merge 处理了所有移植拉取请求。在大多数情况下,我们并未真正执行“合并”操作。代理会修复所有 CI 失败,处理并回应所有评论,确保所有冲突都已解决。在合并前,我会抽查代理实际执行的操作,特别是它如何处理反馈。我是否对代理对审阅者的任何回应有异议?所应用的修复方向是否总体上合理且可行?对于这些移植,我通常会忽略最后一个检查框。

这个最后的检查框不止一次起到关键作用。在一次合并循环中,移植删除了 SDK 暴露的一个函数。我们仓库的模式兼容性 CI 检查环节准确执行了其职责并失败。代理的回应是应用仓库的 `schema-break-ok` 自动化标签,这是让检查通过的绕过机制。在合并前审查拉取请求时,我提出了显而易见的问题:“模式发生了什么变化?你给拉取请求添加了 `schema-break-ok` 标签,为什么这是可以接受的?”事实并非如此。该方法存在于 `main` 分支;移植只是丢失了它。我将其称为不可接受的回归,并要求代理用纯 Rust 代码完全恢复该方法。21 秒后,豁免标签被移除,该方法通过原生 Rust 实现得以恢复。

我们对故障进行了本地和全局层面的响应,既解决了个别案例,也对系统进行了改进以降低问题重复发生的可能性。我们持续优化提供给编码和审查代理的指令,进一步减少这些问题在后续移植拉取请求中再次发生的概率。我们将会话日志转化为评估数据。在某些情况下,我们甚至利用学到的经验通过调整提示语、工具描述或自动驾驶功能的操作方式来改进运行时本身。

## 一次迁移,两次任务

语言重写几乎从不只涉及语言层面的改变。运行时依赖的每个库都需要被替换,而与我们自己编写的Rust代码不同,这些替换工作并非我们能够完全掌控。有些库是用不同名称实现的相同概念,有些则需要多个crate来实现一个npm包的功能。少数情况下甚至没有现成的解决方案,必须由代理手动编写代码。

CLI和运行时目前位于同一个仓库中,共享一个`package.json`。在移植过程中,我们移除了约60个npm依赖项,因为这些依赖项仅被移植到Rust的运行时代码使用。这只是移除数量的下限,因为有些包虽然被运行时替换,但仍被CLI需要。例如,`zod`是一个TypeScript模式声明和验证库,CLI和运行时都曾使用过。在Rust移植后,运行时现在使用`serde`、`schemars`和`jsonschema`的组合来实现相同的功能,但`zod`仍保留在CLI的清单中。

有许多例子显示一个npm包被一个crate替代并执行相同任务。`js-tiktoken`被替换为执行相同`o200k_base`编码的`tiktoken-rs`。`ignore`被替换为具有相同gitignore语义的同名crate。`minimatch`被替换为`globset`,`fast-myers-diff`被替换为`similar`,`dompurify`被替换为`ammonia`,`github/keytar`被替换为`keyring`。

在其他情况下,我们无法实现包与crate的一一对应替换。有时一个包需要多个crate,或多个包合并为更少的crate。这是依赖项工作的主要部分。8个`opentelemetry/*`包被替换为4个crate,加上手动编写的跟踪状态机和文件导出器。三个web-content包`mozilla/readability`、`linkedom`和`turndown`被合并为两个crate:`readability`和`htmd`。`sharp`、`image-size`、`file-type`被合并为`image`和`imagesize`。还有五次完全用自定义实现替换了npm包。

## 不安全代码的使用量有多少?

关于代理编写的Rust代码,人们经常问的一个问题是其中有多少代码悄悄放弃了安全保证。Rust的安全性可以通过一个关键字关闭,因此当代理遇到无法满足的借用时,显然有一个逃生出口。在运行时crate中,我们现在有158个`unsafe`代码块,分布在36个文件中(同时还有26个`unsafe fn`声明,26个`unsafe extern`代码块和9个`unsafe impl` trait实现)。重要的是,每一个都与外部组件的互操作性有关。

| 为什么存在不安全代码块 | 代码块 | 占比 |
| --- | --- | --- |
| C ABI边界 | 51 | 32.3% |
| Windows API | 49 | 31.0% |
| POSIX / libc | 46 | 29.1% |
| SQLite C API | 7 | 4.4% |
| 动态库加载 | 4 | 2.5% |
| 进程环境 | 1 | 0.6% |
![Image 16: 158个不安全代码块的甜甜圈图,全部位于外部边界:C ABI、Windows API、POSIX和libc、SQLite、动态库加载以及进程环境修改。](https://github.blog/wp-content/uploads/2026/09/unsafe-boundaries.svg?resize=1024,427)
C ABI代码块是SDK主机进入的入口,因此需要接收来自Rust编译器无法控制的调用方传入的原始指针和长度。Windows和POSIX代码块是系统调用:注册表读取、凭证握手、进程树、`sysconf`。SQLite是一个C库。动态库加载是`dlopen`,由于解析的符号可能不是预期的函数,因此无法通过构造保证安全。进程环境的`unsafe`代码块存在是因为Rust 2024将多线程进程中修改进程全局环境状态视为不安全。这些`unsafe`代码块中的每一个都标志着Rust提供的保证真正终止的位置:另一侧是C函数、系统调用、来自外部运行时的指针或进程全局主机状态。

`unsafe`的有用特性是它使我们拥有的Rust代码中所有这些位置都可审计。TypeScript运行时中等效的代码通过Node的C++内部和原生npm包跨越了完全相同的边界,而我们源代码中没有任何内容标记出受控世界结束的位置。当然,这并不是交付系统中所有安全边界的完整清单:依赖项、构建工具、C库、安全包装器以及错误指定的FFI契约仍可能包含或暴露不安全代码。

`unsafe`使用位置的细节很有趣,但我更关注它未被使用的位置。它没有在模型客户端、MCP层、代理层或提示层中使用。而且已知的回归问题中没有一个涉及`unsafe`代码块。

## 回归问题

移植代码很容易。使其正确却很困难。对于Copilot代理运行时这样庞大而复杂的代码库,出现回归问题是可以预见的。

到2026年9月14日,我们已追踪并修复了数十个已知的移植回归问题。其中大部分是正确性错误,少数是性能回归。这些错误出现在从零开始编写的约832,000行生产级Rust代码中。

当然,并非所有这些回归问题都已发布。有些在仓库开发过程中就被发现。其他问题出现在预发布版本中,但在稳定版本发布前已修复。还有一些问题达到了稳定版本,通常是因为它们足够隐蔽,以至于在一轮或多轮预发布使用中未被发现。

![Image 17: Known correctness regressions grouped into five recurring failure modes: incomplete migration, state and lifetime, behavioral contract mismatches, host boundaries, and incorrect test oracles.](https://github.blog/wp-content/uploads/2026/09/regression-taxonomy-1.svg?resize=1024,350)
当然,这并不是零,我百分之确定还有更多我们尚未发现的回归问题。这些是我们注意到或被报告的案例,但如此大规模的迁移绝对还隐藏了一些尚未被触发的其他问题。和普通 bug 一样,我预计随着对堆栈更深层部分的持续测试,未来仍会陆续发现一些边缘情况的回归问题。

绝对数量本身并不那么重要。更重要的是这些问题发生的“原因”,这样才能从问题中学习并避免在未来重复。几乎所有正确性回归问题都属于三个主要类别:新代码实现了不同的行为契约;状态、所有权或生命周期行为发生了变化;或者迁移的某些部分被遗漏、仅部分应用或在重新基线化过程中丢失。另一小部分问题源于主机或互操作边界处的需求,甚至来自测试中错误地验证了错误行为。这些类别在一些重复出现的模式中变得更加具体:

**语义模糊。**多个回归问题源于源语言中隐含的行为。TypeScript 只有一种`number`类型;Rust 需要从多个选项中选择,包括值是否可以为小数。而代理程序的猜测是错误的。概念上是整数的字段变成了`f64`,因此 Rust 序列化值时使用了`42.0`而不是`42`:像 Go 和 C# 这样的强类型 SDK 无法将仓库 ID 反序列化为`int64`,并拒绝了钩子时间戳和任务持续时间。反过来,一个代理程序将`timeToFirstTokenMs`声明为`i64`,但流式传输路径输出了`5446.712845`这样的值,导致写入的会话无法读取和恢复。一个更微妙的情况与类型无关:`event.error || "Unknown error"`变成了`.unwrap_or("Unknown error")`。JavaScript 的`||`会替换空字符串;Rust 的`unwrap_or`会保留空字符串,因此子代理错误保持为空。哎呀。

**环境行为。**另一个重复出现的来源是 JavaScript 或 Node 隐式提供的行为。配额代码使用了`toLocaleDateString`,它继承了主机时区;Rust 需要显式传递该时区。但尽管`Intl.DateTimeFormat().resolvedOptions().timeZone`被类型化为`string`,它可能返回`undefined`,而 napi 无法将其转换为 Rust 的`String`,导致模型列表加载失败。在其他地方,将环境变量读取从`await`之前移动到之后意味着等待期间主机变更可能改变结果。一个原生插件加载器调用`process.report.getReport()`只是为了识别平台,但 Windows 上它会尊重`_NT_SYMBOL_PATH`,并在渲染 CLI 之前可能花费数分钟下载 PDB。其他环境输入包括工作目录、仓库标识、`PATH`和会话认证。将所有权迁移到 Rust 需要决定何时捕获每个输入、如何携带它以及何时刷新它。

**仅移植了配对中的一半。**多个回归问题源于配对操作不同步。一个“turn-cap”检查更新了本地注册表的中止状态,但未取消正在运行的模型循环,导致一个额外的请求逃脱。在其他地方,任务完成状态被持久化并发出,但未投影到活动会话状态中,因此Autopilot在任务完成后继续执行。

**阻塞主线程。**CLI仍然从Node的单线程事件循环驱动Rust运行时,因此跨napi边界进行的同步工作会冻结UI。`/chronicle reindex`通过这种方式解析了数百个会话文件,导致渲染和输入几乎被阻塞了一分钟。将导出改为`async`并将工作移至阻塞线程池线程解决了问题。审计发现了更多潜在的阻塞入口点,从而制定了规则:执行实际工作的napi导出必须是异步的,必要时需使用`spawn_blocking`。

**窗口闪烁。**在Windows上,不使用`CREATE_NO_WINDOW`创建子进程时会短暂打开控制台窗口。Node.js运行时曾通过猴子补丁修改进程创建以添加该标志,隐藏了对移植代理的要求;Rust替换方案省略了该标志。审计修复了另外两个创建点,但这些是原有遗漏而非移植回归,我们已将规则添加到协作者说明中。

**生命周期管理。**最大的集群问题涉及生命周期、处置、所有权、排序或竞争条件。将状态移至Rust时,TypeScript通常会持有对本地表中实例的不透明句柄。与对象引用不同,该句柄可能比实例本身更长。一个钩子在请求中途处置导致`tool_use`块被孤立,由于模型API需要匹配结果,对话因此卡住。一个沙箱在“宣布”和“启动”之间被取消,导致孤儿对象泄漏并保持会话活跃。一个沙箱切换更新了一个生成计数器但未更新其本地孪生,导致外壳卡在“重新配置”状态。

**被忽略的功能。**另一组问题涉及移植时遗漏的功能。一个移植省略了SDK回调并删除了端到端测试,促使新增规则:代理不得在未经明确同意的情况下修改E2E测试。一个会话中止保留了本地部分但丢失了进程内取消,无法中断等待工具的回合。SDK替换内置工具搜索的功能依赖于启用状态、面向模型的描述和模式,以及将执行路由到SDK回调。移植删除了所有三个要素(一致性万岁?),静默地将一个消费者的自然语言搜索替换为正则表达式搜索。

**不同库的不同观点。**更多回归问题源于用更严格的Rust等价库替换JavaScript库或API。当时,Rust MCP SDK(`rmcp`)会响应格式错误的JSON-RPC输入,而TypeScript SDK不会。在服务器返回更多格式错误输出的场景下,这种礼貌性变成了导致启动挂起的无限循环。不同生态系统中的相关库很少行为完全一致。

**夜航中的船只。**一些回归问题源于分支漂移或变基。在一个每周有数百个拉取请求的仓库中,持续数天的会话会积累大量变更和冲突。移植需要进行数千次变基;即使成功率很高,仍会存在失败情况。

**进展缓慢的部分。**最后一个组在功能上是正确的,但速度较慢。一些回归丢失了现有效率,例如记忆化、完全异步等待或有界日志流式传输。其他则在Rust-TypeScript边界引入了迁移特定的开销:冗余序列化、锁定、轮询、无界原生并发和原生到主机的跨域。这些并不是后续的优化机会;每个都是移植引入的退化。一个只读扫描深拷贝了一个260 MB的事件日志,而不是借用它。在持续的事件流量下,另一个实现仅完成了请求通道刷新的一小部分,同时每个事件都保留了一个异步句柄,直到V8耗尽堆内存。

数十个回归听起来很多。但在这个生成了超过800,000行代码的移植中,坦白说,我惊讶且高兴地发现没有遇到数量级更多的问题。我们也可以通过查看公共[github/copilot-cli](https://github.com/github/copilot-cli)和[github/copilot-sdk](https://github.com/github/copilot-sdk)仓库中的问题趋势,了解这些问题是否被广泛感知。在这里,我们将1月至8月期间打开的问题分类为质量相关,当它们带有bug标签或标题使用了“bug”、“regression”、“crash”、“hang”、“timeout”、“broken”或“incorrect”等常见失败术语时。与移植工作前相比,移植期间和移植后的质量水平基本保持不变:

| **仓库** | **重写前的1月至4月** | **重写期间/后的5月至8月** |
| --- | --- | --- |
| [`github/copilot-cli`](https://github.com/github/copilot-cli) | 22.9% (454 / 1,982) | 23.7% (354 / 1,496) |
| [`github/copilot-sdk`](https://github.com/github/copilot-sdk) | 36.2% (190 / 525) | 32.3% (135 / 418) |

这不是可用性指标或逃逸缺陷的确切数量……正如回归本身一样,一个问题所代表的内容、范围等可能存在大量变数。但它确实提供了一个有用的检查:尽管产品变更的规模空前巨大,但在迁移期间,面向产品的故障渠道并未显示出有意义的质量问题激增。

## 如果能编译,就说明正确

我之前提到过一个流行的梗,即Rust适合AI生成代码,因为它有严格的编译器。还有一个相关且流行的Rust梗,即如果代码能编译,那它就是正确的。不,我们的已知回归列表对此给出了很好的回应:语料库中的每个回归都被合并到`main`分支,这意味着它成功编译了。编译器接受了有缺陷的版本,因为在编译器看来,它们都是有效的Rust代码。

编译器可以验证`f64`类型是否被一致使用。但它无法知道仓库ID必须序列化为整数,或者时间戳如果以`.0`结尾进行序列化,会被另一端所有强类型SDK拒绝。编译器可以防止它能看见的代码中出现未同步的数据竞争,但无法阻止一个完全同步的状态机编码错误状态。一个队列即使受到锁保护,仍然可能让两个发送者各自得出对方会清空队列的结论。事件可以在线程间安全传递,但仍可能以错误顺序到达。一个同步的napi函数可以保证内存安全,但仍可能阻塞Node.js主线程长达一分钟。编译本身几乎按定义无法检测到不存在的问题。当变基操作静默移除了一个保护条件及其测试时,或者当进程生成器忘记添加抑制控制台窗口弹出的Windows标志时,编译器也无法提出异议。它对每次读取时都克隆250MB事件日志的行为也没有任何意见。编译器检查的是你编写的程序是否内部一致,但无法检查你是否编写了完整的程序、保留了旧的契约、按正确顺序调用接口、满足主机的隐含需求,或以可接受的成本完成工作。

这绝不是对Rust编译器的否定。与任何静态类型语言一样,编译器消除了大量机械性错误,并为开发人员提供了极其有用的内循环。但“只要能编译就能正确”这个说法充其量只是一个笑话。

## 性能、性能,还有性能

那么,我们通过所有这些努力到底获得了什么?端口迁移是刻意保持行为一致的。它没有打算重新设计算法或修复漏洞;事实上,我反复阻止开发人员进行机会主义优化,因为同时改变语言和行为会使判断是哪个导致问题变得困难得多。然而,重写的一个关键目标确实是性能和可扩展性(除了可靠性和其他因素)。当人们问我为什么决定用Rust重写运行时,我的回答通常是:“我并非为了迁移到Rust而迁移,而是为了摆脱Node.js和V8”。这次迁移确实显著提升了我们的性能指标。

在端口迁移前后,我通过C# SDK对运行时进行了多项场景的基准测试(TypeScript、Python、Go、C#、Java和Rust SDK都通过相同的传输架构连接到同一引擎)。基准测试对比的是端口迁移前SDK和CLI的构建版本,其中TypeScript运行时由Node.js托管并通过stdio连接。8月21日的结果使用了Rust运行时,既作为进程外服务器运行,也通过FFI在进程中加载。这是一个端到端系统对比,而非试图隔离语言变更的影响;同期还进行了其他变更,因此这些数据需要谨慎解读。

每个计时回合都发送到本地运行的确定性聊天完成服务器,生成固定且小的响应。换句话说,这些数据刻意排除了模型推理和网络延迟。它们测量的是我们变更的部分:客户端启动、进程启动、会话创建、事件处理、持久化、清理等。

| **场景** | **5月12日** | **8月21日进程外** | **8月21日进程内** |
| --- | --- | --- | --- |
| 客户端、会话、单轮交互 | 5.25 秒 | 1.33 秒(4.0倍) | 292 毫秒(18.0倍) |
| 恢复32轮会话 | 5.64 秒 | 1.52 秒(3.7倍) | 264 毫秒(21.4倍) |
| 十个并发客户端生命周期 | 12.34 秒 | 4.18 秒(3.0倍) | 742 毫秒(16.6倍) |
| 1000个单轮会话生命周期 | 132.52 秒 | 22.53 秒(5.9倍) | 20.93 秒(6.3倍) |
![Image 18: 从5月12日到8月21日,创建客户端和会话、完成单轮交互并销毁它们的耗时从5.25秒提升至55.3毫秒(进程内),吞吐量从每秒7.55次提升至每秒120.0次,十个客户端内存占用从1383 MB降至126 MB。](https://github.blog/wp-content/uploads/2026/09/performance-1.svg?resize=1024,452)
“客户端、会话、单轮交互”这一指标最容易感知。它表示创建客户端、创建会话、执行单轮交互并销毁所有资源的过程。进程外的大量开销主要来自启动Node.js、初始化V8引擎,以及在首次交互前加载、解析并生成TypeScript代码转换后的JavaScript应用的字节码。Rust运行时消除了这些Node.js/V8引擎和JavaScript加载的开销。

“1000个单轮会话生命周期”压力测试代表了我们能构建能力的阶梯式跃迁。它使用单个共享客户端,测量运行100个并发流水线,每个流水线创建会话、执行完整模型交互、销毁会话,并连续重复十次。移植前的TypeScript CLI每秒完成7.55次生命周期。Rust进程外实现每秒57.45次,Rust进程内实现每秒120.0次。

这是特定工作负载的结果;Rust运行时并非在所有场景下都“快15.9倍”。但它正是服务器主机最关心的工作负载:许多独立会话共享一个运行时。而且墙钟时间并未将工作隐藏在其他核心上。在对相同100x10工作负载进行独立资源采样时,移植前的进程树消耗了312秒的总CPU时间。Rust配置仅消耗约110秒。这是主机可用于更多会话的CPU资源。

内存指标讲述着同样的故事,但需要提醒的是内存指标容易被误用。观察十个客户端批次中新增的私有驻留内存,移植前的进程树峰值超出基准1383 MB。Rust进程外峰值仅为247 MB,Rust进程内峰值为126 MB,减少了整整一个数量级。虽然这些数字会因使用场景和机器差异而不同,但它们触及了核心目标:在内存、进程数或CPU成为限制资源之前,服务可以在同一台机器上承载远多的客户端和会话。

最棒的是,这仅是基础移植结果。目前大部分实现仍是TypeScript算法在Rust中的忠实复现。我们尚未进行新所有权模型、并发模型和进程内架构所支持的全面重构工作。在未进行优化工作的情况下,实现进程内客户端创建、完整单轮会话及销毁仅需约55毫秒,将共享客户端的单轮会话生命周期提升至每秒120次,并将十个客户端内存差值减少91%,这已是非常理想的起点。

## 移植的代价

那么……所有这些花费了多少资金?请准备好,下面揭晓……

我的所有移植工作总共消耗了约1363亿个代币,其中包含约1306亿个缓存输入读取代币、42亿个缓存输入写入代币、9亿个新输入代币和6亿个输出代币。所有这些代币的总费用约为12万美元。

当然,这些代币本身不会产生费用。还需要考虑投入大量开发时间指导这些代理程序的成本。不过,我并非将所有时间都投入到这个项目中。代理式开发本质上是"边等边干":提交一个提示,让编码代理自行处理,偶尔检查进度并可能进行干预,但在此期间可以处理其他任务。这意味着开发者不再只专注于单一的编码任务,而是通过重叠等待时间来实现并行处理多个任务。在移植期间,这些Rust移植的PR占我所有仓库提交的PR总量的约20%。如果粗略估算PR占比约等于时间占比,那么这项移植工作大约消耗了我三周的工作时间。

换句话说,这次移植工作的总成本约为12万美元的代币费用加上开发者约三周的工作时间。

需要说明的是,这次端到端的Rust迁移并非完全由单一开发者完成,而是团队协作的结果。[@stevesandersonms](https://github.com/stevesandersonms)负责设计和实现了`napi-oop`(临时进程外互操作层)以及六个SDK FFI实现中的五个,[@edburns](https://github.com/edburns)完成了第六个。[@roji](https://github.com/roji)实现了SDK的打包方案,确保能正确分发和使用Rust二进制文件。[@caarlos0](https://github.com/caarlos0)通过将Rust代码拆分为多个小型子crate来优化构建时间,[@criemen](https://github.com/criemen)则通过改进资产缓存方案加速了CI和本地构建。[@devm33](https://github.com/devm33)、[@examon](https://github.com/examon)、[@MRayermannMSFT](https://github.com/MRayermannMSFT)、[@dereklegenzoff](https://github.com/dereklegenzoff)等众多贡献者参与了无数次PR审查和批准。所有为copilot-agent-runtime仓库做出贡献的开发者都在这个快速变化的环境中给予了极大的支持和配合。

## 经验教训

这次经历强化了一些超越本次重构的通用经验。以下是我们下次会继续坚持的一些原则:

*   **目标必须明确且完整地表述。**我们早期的指令过于模糊。“将XYZ组件移植到Rust”被理解为仅涉及热点路径或仅涉及逻辑,代理反复将I/O和编排视为超出范围。当我们明确最终状态是通过100%纯Rust代码库构建的原生二进制文件,即使我们想保留TypeScript执行环境也无处可留时,他们推动自主实现目标的能力显著提升。
*   **端到端测试绝对、明确地至关重要。**除一个例外情况外,所有涉及功能缺失的回归问题,以及许多其他问题,都是由于缺乏充分的端到端测试。对于此类移植工作,必须拥有可用于验证移植正确性的测试,且这些测试本身不能在移植过程中被重写,否则将失去黄金标准。我们最初的移植计划明确指出这一点,并强调在开始移植前必须显著提升端到端测试能力。我们确实做了这些改进,但做得还不够。我们确信,如果在移植开始前增加更多端到端测试,特别关注确保覆盖大多数关键行为,那么在过程中遇到的回归问题会更少。
*   **保护黄金标准免受代理干扰。**当代理修改实现时,不能允许其通过弱化测试、更新快照、提高兼容性基线或添加逃生舱标签等方式默然重新定义正确性,除非经过监督。尽可能将行为契约独立化,将敏感控制措施置于独立所有权或审批流程之后,并通过不同失败模式的检查层进行防护,确保单次失误不足以导致重大回归。
*   **先翻译,后重构。**保持行为和现有算法的延续性,使同时变动的变量数量保持可控。当旧实现和过渡性支架被移除后,所有权、并发性和性能可以基于稳定基线进行重构。我曾几次偏离这一原则,出于焦躁或难以拒绝同行建议,或坚信特殊情况需要例外,事后对此深感遗憾。每一次偏离都导致更多回归问题、更多时间消耗或更多资源浪费。
*   **将重复失败转化为未来成功。**AI代理会偏离轨道:当发生时,要从中学习。当某种失败模式出现两次时,应将其纳入标准指令、可复用技能、评估项、受保护基线或测试框架本身。

*   **当代理参与其中时,开发人员的内部循环变得更加重要,而非减少。**对我们这些开发者而言,我们生活的重心就是内部循环——我们能多快做出修改、构建、测试、迭代。当工具链部分耗时过长时,我们会感到沮丧。也许有人会认为,当代理处理底层工作时,这种情况不会适用,但事实恰恰相反,情况更为严重。AI代理在这些任务中思考和编写代码的部分速度非常快,但它们仍然需要构建和测试。随着它们花更少时间思考和编写代码,而更多时间处于快速验证的内部循环中,构建和测试所占的时间比例实际上会增加。提前花些时间优化内部循环,并让它能够同时处理多项任务(比如你同时在多个工作树中处理多个任务),你之后会感谢自己做出的这项投资。

## 接下来会发生什么?

它奏效了。五月份还完全由 TypeScript 编写的执行运行时,到八月份已经完全改用 Rust 实现,并且在整个过程中持续交付给真实用户,而不是在最后以一次令人恐惧的切换一次性上线。

我并没有简单地让 AI 代理“将整个代码库从 TypeScript 移植到 Rust”。尽管从行业趋势来看,我们可能正朝着这个方向前进,但目前我们还远未达到这一阶段。相反,代理使整个类别项目成为可能。在代理出现之前,没有人会接受这样一个提案:由一名工程师在主分支中实时完成,生成数万行生产级 Rust 代码的重写。这需要一个团队花费一到两年时间,会与该团队原本可以交付的其他功能竞争,并且最终会失败(而且,老实说,确实应该失败)。代理将成本降低到了项目变得可行的程度。

移植工作本身已经完成:运行时的生产实现现在 100% 是 Rust,临时的内部 TypeScript/N-API 接口也已经消失。尽管如此,我们还有很多工作要做:进一步改进构建系统和开发者内部循环,清理翻译后的结构,围绕 Rust 的所有权和并发模型重新设计,以及追求更多的性能提升。这次移植是有意为之的翻译(也是被明确要求的),我们尽可能保持行为与原版 100% 一致,而不是趁机修复其他 bug、重新架构组件或进一步提升性能和可扩展性(除了重写本身带来的隐含改进)。在微观层面,大部分代码已经是惯用的 Rust 风格,但在宏观层面,很多最初用 TypeScript 编写的算法仍然披着 Rust 的语法外衣。现在,随着支撑这些决策的底层约束条件发生变化,重新审视这些决策将带来真正有趣的突破。

我最兴奋的是这次移植所开启的可能性。SDK 现在可以直接加载到任何六种语言的宿主进程中,无需依赖 Node.js 或 V8,也不需要额外的进程进行管理,而这正是合作伙伴在采用 SDK 时最常遇到的摩擦点。运行时实例的成本现在仅为原来的几分之一,这意味着宿主可以在机器资源耗尽之前运行更多并发会话。此外,运行时现在可以进入 Node.js 从未涉足的领域,从云端到桌面、设备到嵌入式系统,覆盖整个生态系统。这些都不是终点,而是我们如今可以构建 GitHub Copilot 未来的基础。在目睹代理用三个月时间重写了支撑它们运行的引擎之后,我迫不及待地想看看它究竟能走多远。

愉快编码!

## 作者

![Image 19: Stephen Toub](https://avatars.githubusercontent.com/u/2642209?v=4&s=200)

Stephen Toub 是微软的杰出工程师。