GitHub 披露用 Copilot 自身辅助完成 Copilot 运行时向 Rust 的迁移,代码量达 80 万行,是 AI 编程工具首次承担如此大规模的生产级重写。
GitHub Copilot CLI、GitHub Copilot 应用和 GitHub Copilot SDK 均由 Copilot 智能体运行时(Copilot agent runtime)提供支持——这是一个可嵌入应用程序和服务的智能体化工具链。它最初使用 TypeScript 在 Node.js 和 V8 JavaScript 引擎上编写,用于如今的 GitHub Copilot 云端智能体(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 智能体运行时的入口。这样做使它们能够专注于自身的核心业务价值,将细节交给运行时。考虑到行业发展速度以及所采用的智能体循环需要在激烈竞争中始终保持领先,这一点就更加重要。
所以,共享运行时,好事一桩。问题在于被共享之物的本质。
如果我们看 CLI,它在逻辑上是位于智能体循环之上的终端 UI(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 进程来托管智能体循环作为进程外运行,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 中的崩溃会使会话随之崩溃。这意味着任何部署这个系统的人至少需要管理两个进程来监督、监控和调试。
相反,我们想要一个运行时:
鉴于所有这些原因,以及较软性的原因(如团队经验和行业方向),我们选择了 Rust。这绝不是声称每个大型 TypeScript 程序都应该变成 Rust。我们的需求强调通过 C ABI 进行嵌入、低启动和稳态开销以及可预测的资源使用。Rust 使这些目标成为可能,代价是其他复杂性,例如我们必须显式表示生命周期和共享状态(后面讨论的回归生命周期问题突出了这的影响)。正确的目标语言确实因应用而异。
随后有两个关键的相关任务被着手进行:
第一,将 TUI 特定代码从运行时中分离出来,使前者严格层叠在后者之上,更具体地说严格层叠在 SDK 公共表面积之上。如今,CLI 仍在多处直接调用运行时内部;将其完全迁移到 SDK 的表面积上是正在进行的工作。
第二,将该运行时层移植到 100% Rust,产生一个纯原生二进制文件,为所有语言前端提供 C ABI 以供进程内消费,并提供一个基于 stdin/stdout 或套接字的服务器,以备仍需要进程外运行时。
这篇文章主要涵盖第二项:将运行时移植到 Rust。
2026 年 5 月初的初步移植计划估计运行时约有 13 万行 TypeScript。出于范围界定目的,这个初步测量相当准确,但事实证明,在两个关键方面也极具误导性。在移植的同时:
大量引入新 TypeScript 的 Pull Request 持续增加了仓库中的 TypeScript 代码量。数十名借助 AI 智能体辅助的开发者每周合并数百个 Pull Request。
综合各项因素估算,大约有 43 万行生产环境 TypeScript 代码最终经过了这次迁移。这些相同的因素也使得过程中的进展难以观察:在接近尾声之前,生产环境 TypeScript 代码量看似保持相对稳定,甚至略有增长,因为迁移工作与新增工作保持着同步。
这一点更加令人困惑,因为在此期间还有来自其他来源的 Rust 代码进入仓库;在迁移早期,进入的代码更可能是以 TypeScript 为主,而到了后期,则更可能是以 Rust 为主。
在迁移期间,运行时吸收了约 30 万行生产环境 TypeScript 并淘汰了约 43 万行,同时引入了约 120 万行生产环境 Rust 并淘汰了约 36.5 万行。换言之,上图中 TypeScript 代码行数看似稳定的表现,实际上隐藏了大量的 TypeScript 流转。
原地迁移策略
这张图也突出展示了迁移方式的一个重要特点:原地完成。
对于这种规模的 重写,主要有两种方法:
大爆炸。新 Rust 运行时作为完整替代方案独立开发,待准备就绪时一次性全部切换。大爆炸式切换有两种变体。 a. 停止世界。主分支上的所有其他工作全部暂停,等待重写完成,重写在主分支上进行。 b. 并行开发。重写在功能分支上进行,同时主分支的工作继续进行,重写需要不断跟上主分支的变更并持续合并主分支的改动。
原地进行。作为组件级迁移,运行时被逐个组件增量重写。这种原地方法也有两种变体。 a. 原子替换。每个组件从 TypeScript 到 Rust 原子性地切换,剩余 TypeScript 与新 Rust 之间的互操作提供连续性。随着时间推移,生产环境运行时中 TypeScript 越来越少,Rust 越来越多,直到有一天不再有 TypeScript,全部都是 Rust。 b. A/B 测试。组件在迁移时并不删除,而是同时维护 TypeScript 和 Rust 两个可热切换的选项,待信心趋于平稳后再删除 TypeScript。
我们选择了方案 2a,出于多种原因:
没有人会经历工作停摆。主分支保持活跃状态。每一位不直接参与迁移的开发者都能继续正常工作,只有当他们长期在飞的 Pull Request 恰好碰到了同时被迁移的代码时才会受到影响,这种情况下他们需要 rebase 并让 AI 智能体帮助迁移他们正在进行的变更。
运行时的主分支始终可发布。每个 Pull Request 用一个调用 Rust 的薄适配层替换现有的 TypeScript 实现,并在一 次原子变更中删除旧代码。新代码立即在原地被调用。
重写是增量且可审查的。每个 Pull Request 迁移一个组件或一个切面,因此变更范围更小,diff 更容易审查,无论是人工审查还是 AI 智能体审查,或两者结合。
大多数迁移都是相当小且自包含的,最大程度减少了与并发 Pull Request 的代码漂移。在某些情况下,如果 TypeScript 组件过大,可以先将其重构为更容易迁移的组件。
所有现有的端到端测试,覆盖 CLI 和 SDK,全程都基于新的 Rust 代码运行,给予我们信心和大量验证。如果一个 Pull Request 导致所需测试失败,它就不会合入。
我们也没有选择方案 2b 的变体——即同时维护同一组件的多个版本。在过去几个月里每周有数百个 Pull Request 合入仓库,代码库不断快速演进。同时用两种不同语言和两套不同依赖库维护同一代码的两个不同版本,会带来巨大的复杂性。而且这些组件并非都完美隔离;有些在逻辑上是独立的,对系统的其余部分有简单的 API 访问方式,但有些则有着广泛的关联,要让那张依赖图按组件热切换简直是噩梦。受益于谨慎并行切换最多的子系统,恰恰是并行最困难的。例如,会话编排不是能调用两个不同版本的纯函数,不能用实验标志加 if/else 来解决。它拥有可变状态,双向驱动回调,贯穿几乎所有其他子系统,因此"两个版本都跑并比较"意味着要维护一个持有对话状态和服务的组件的两个分歧副本,并祈祷它们在数百个并发编辑中保持同步。使得组件难以迁移的耦合,恰恰也使得它几乎不可能在影子模式下运行而不引入比所避免的更多的回归。这种切换方式所能带来的好处主要是为了获得信心,而我们可以通过其他方式做到。
验证也通过增量发布进行。有了大爆炸式切换方案,我们会把所有内容放在一个长期分支中,迁移整个运行时,然后一次性切换。这意味着消费者在同一时刻经历每一次被迁移的代码行,包括所有在仓库内测试中漏掉的回归。通过每次两个组件、这里一个组件那里一个组件地增量滚动变更,使我们能够在已部署的构建中通过真实消费者使用(最常见的是微软和 GitHub 内部的第一方)获得最后一公里的验证,同时将回归风险保持在最低水平。在大约 14 周半的迁移窗口期间,主分支发布了 135 个版本,包含 100 个预发布版本和 35 个稳定版本,平均每天约 1.3 个版本。大约每天也有 1.3 个迁移 Pull Request 打开,使得每个版本都携带少量且可知的已迁移组件集(我们通常尽力但并非总是成功地将迁移首先放入预发布版本)。在最近七天的 npm 下载样本中,预发布版本仅占下载量的 10.5%,表明初始曝光相对有限,同时我们监控反馈渠道以寻找破坏信号,并在下一个预发布版本中快速响应修复。报告的问题更容易与已知的近期变更相关联,也更容易被定位根因并快速修复。通过这种方式,在较长时间内增量进行迁移实际上是优势而非阻碍(即更快不一定更好)。到 8 月 21 日,运行时已 100% 为生产环境 Rust:832,378 行生产环境 Rust 代码,468,689 行 Rust 单元测试,以及 174,675 行 E2E TypeScript 测试。独立的 GitHub Copilot SDK 仓库在 Node.js、Python、Go、C#、Rust 和 Java 中增加了约 13 万行 E2E 测试代码。
在全面投入之前,我们建立了信心并验证了方案。我们从两个 pull request 入手,建立了 Rust 工作区、工具链、lint 规则、CI、构建流水线及编码规范,随后引入了 runtime crate 以及代码生成和互操作模式,同时移植了一批精选的纯逻辑原语——之所以选它们,是因为它们没有 I/O 或共享状态,且已有完善的测试。只有在这些合并之后,第一个主力移植 pull request 才让三个无副作用的辅助函数走完了完整流程。这些函数充当了交付试点,将代码库布局、FFI、打包、测试和 review 的假设转化为约定,后续更大规模的移植可以直接复用。基本上,我们从头到尾测试了这套 machinery。计划继续按从叶节点向内的顺序排列工作:纯辅助函数、内容排除、shell 工具以及会话文件系统操作建立翻译和测试模式;有状态的子系统紧随其后;工具、钩子、模型客户端和 MCP 则构建在这些组件之上。会话编排(runtime 中耦合度最高、自然并行度最低的部分)排在最后。
| 时间段 | Pull requests | 中位变更行数 |
|---|---|---|
| 5 月 1–15 日 | 8 | 3,250 |
| 5 月 16–31 日 | 29 | 421 |
| 6 月 1–15 日 | 40 | 5,073 |
| 6 月 16–30 日 | 31 | 8,253 |
| 7 月 1–15 日 | 109 | 5,514 |
| 7 月 16–31 日 | 142 | 8,159 |
| 8 月 1–15 日 | 191 | 3,861 |
| 8 月 16–30 日 | 49 | 9,445 |
早期的移植——小型叶节点组件——进展很快。但更大的子系统没有一步到位;例如,MCP 支持经过了 7 个专门的 pull request 才完成,而工具则通过一个六部分的系列推进,随后还需要额外工作来迁移编排并移除剩余的 TypeScript。钩子、认证、遥测、插件、设置和持久化也遵循类似的路径。
实际上,移植的有效单元并不总是"一个组件"。它往往是一个穿过相关行为区域的 wave:先迁移纯逻辑,再迁移状态所有权,然后迁移编排,接着移除 fallback,最后在临时互操作消失后简化 Rust。
这次移植涉及两个主要的互操作层:
临时内部互操作。 任何时候一个函数被移植到 Rust,该函数都需要能够被调用原始 TypeScript 函数的 TypeScript 代码所调用。同样,我们需要让 Rust 函数能够调用 TypeScript 回调。这种互操作需求是一个实现细节,且极其不稳定。随着 Rust 内部表面积的扩大,所需的 TypeScript 垫片数量也会增加,因为它们与需要从 TypeScript 调用的 Rust 方法是一对一的关系。当这些调用方也被移植到 Rust 时,现有的垫片层被删除,新的垫片层被放置到位。最终我们到达 runtime 库的公共入口点,垫片也随之消失。
SDK surface。 所有 SDK 库都需要能够位于 runtime 之上并暴露其功能。在移植前的世界里,这是通过一个双向 JSON-RPC 层来实现的:runtime 通过该层暴露,SDK 将函数调用请求作为 JSON-RPC 方法调用 payload 发送,runtime 解析请求并调用相关 API,然后通过同一传输将结果发回,让 SDK 解析并返回。反向通道同样存在:runtime 需要能够回调 SDK 客户端,例如钩子通知和权限请求,这些在 SDK 客户端中表现为使用各语言惯用语言特性的回调(例如 C# 中的 delegate)。
我们通过 napi-rs 项目中的 napi Rust crate 实现了(1)。你用一个函数加上 #[napi] 注解,napi-rs 宏会生成 N-API 注册胶水代码,使该函数可以从 JavaScript 调用,同时生成一个 TypeScript 声明文件 index.d.ts。同步的 Rust 函数变成普通的 JavaScript 函数,async fn 变成返回 promise 的 JavaScript 函数,带有 #[napi(object)] 注解的结构体在另一侧变成普通对象。
流量必须双向流动。许多已移植的组件暂时依赖于尚未移植的东西,所以 Rust 需要回调到 TypeScript——例如,Rust 中的工具实现向仍是 TypeScript 的模型层请求推理,或触发一个钩子,或为一个它想运行的命令请求权限决策。napi-rs 用"线程安全函数"来处理这个问题,它允许在 Tokio 工作线程上运行的 Rust 代码回调到 Node 的主线程上的 JavaScript 回调。Node 安装一次回调,Rust 持有它并在需要反向调用时调用它。每一个都是临时性质的:回调存在仅仅是因为另一端的东西还是 TypeScript,当那个东西被移植时它就被删除了。
临时接缝在 8 月 3 日达到峰值,共有 2,019 个内部 N-API 导出和 3,356 个 TypeScript 调用点。完成时,runtime 完全是 Rust,因此没有内部互操作:0 个临时内部 N-API 导出,0 个 TypeScript 调用点残留。(我之前提到 CLI 仍然有一些对 runtime 的内部访问正在移除中;这些导出不在此计数范围内。)
第二个互操作层——SDK surface——是两者中永久的那个。Copilot SDK 支持六种语言:TypeScript、Python、Go、C#、Java 和 Rust。它们都使用相同的双向 JSON-RPC 契约,最初都以相同方式访问它:将 Copilot CLI 以无头模式作为子进程启动,通过管道或套接字与之通信。在移植期间这仍然是默认方式。这也意味着任何语言的 SDK 消费者都要打包或定位一个完整的 Node 实现,每次事件和每条消息都要支付一次进程跳转的开销,并管理两个进程而非一个。
将 runtime 移植到 Rust 才使另一种方案变得可行。发布的 runtime.node 是一个普通的平台共享库(.node 扩展是 Node.js 原生插件的约定;其底层是 .dll、.so 或 .dylib),现在它呈现两扇前门指向同一个引擎。一扇是 napi 门,Node 进程将其作为原生插件加载;这是 CLI 的路径(目前……未来计划是让它完全走 SDK 路径)。另一扇是 C ABI 门,任何语言都可以通过 FFI 将其加载到自己的进程中并调用。相同的进程内 runtime 通过各语言的本机互操作机制来选择:
| SDK | Native bridge | 进程内客户端选择 |
|---|---|---|
| C# | P/Invoke | new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForInProcess() }) |
| Go | pure Go | copilot.NewClient(&copilot.ClientOptions{Connection: copilot.InProcessConnection{}}) |
| Java | JNI |