每日 AI 资讯效率辅助需求梳理项目排期代码辅助技术文档Bug排查技术复盘
九名工程师,一天内 1,211 个拉取请求:构建 Copilot Cowork 的经验教训
原标题:Nine engineers, 1,211 pull requests in a day: Lessons from building Copilot Cowork
另附一个案例研究,我们在其中把所有内容整合起来,在运行过程中将运行时从 Python 迁移到 Rust。
*今年一月,Microsoft 的一支小团队着手构建* *协作助手(Copilot Cowork)**,这是一个代表用户执行多步骤工作流的智能体执行层。这一成果将开发者如今拥有的强大 AI-assisted 工具和能力带给日常知识工作者,并全部具备* *工作智能(Work IQ)**、Microsoft 365 安全与治理、云执行,以及 Microsoft 的多模型架构。*
*这是该团队如何将 Copilot Cowork 从一个空仓库带到全球GA,历时约四个半月,随后又在它仍在运行时将运行时从 Python 迁移到 Rust 的故事。智能体编写了大部分代码。工作方式也随之改变,其他团队可以借鉴这些做法,像前沿开发团队一样构建:在快速推进的同时,始终兼顾可靠性、安全性和成本。*
---
10月21日,九名工程师向 协作助手(Copilot Cowork) 后端的主分支合并了 1,211 个拉取请求。这个数字在一月份对团队来说会显得荒谬,当时我们几个人向一个空仓库做了第一次提交。
智能体编写了大部分代码,但工程师仍负责定义工作和评判结果。我们的小团队编写了更清晰的规格说明,设定了架构和安全边界,并将审查重点放在风险更高的变更、生产影响,以及检查或审查者意见不一致的情况上。自动化检查和智能体审查者审查了每一项变更。智能体可以提出变更,但它们不能决定允许发生什么。
我们并没有计划以这种方式工作。但每隔几周,智能体就变得更强,我们流程中的一部分不再奏效,于是我们做出调整。
这些变化中产生了六项做法。它们涵盖我们如何规定工作、在明确边界内运行智能体、测试我们的检查、保留团队所学到的内容、为并行工作塑造代码库,以及为自动化循环设定限制。它们并不是照搬我们的 PR 数量的操作指南。它们是其他团队可以借鉴的经验教训,以便像前沿开发团队一样构建,同时始终兼顾可靠性、安全性和成本。
最后,我们将以那个把这些做法一起付诸检验的项目收尾:将实时运行时从 Python 迁移到 Rust。
经验教训 1:先写规格说明,再写代码
第一次提交是在 1月13日 做的。从一开始,智能体就编写代码。
在最初几周,我们发现拖慢我们的很少是智能体。瓶颈在于不清楚我们到底想要什么。一个模糊的请求得到的是看起来合理、快速浏览能过关,然后却做错事的代码。修复它花费的时间,比一开始就写出恰当请求所需的时间更长。
于是我们采用了一条至今仍然有效的规则:没有先写好的 markdown 规格说明,就不写代码。规格说明涵盖三件事:
- 这项变更做什么
- 我们如何知道它有效
- 它绝不能破坏什么
一份好的规格说明节省的时间远超它 upfront 的成本。但一份薄弱的规格说明会在几天后导致返工。智能体在实现上变得越快,我们就越需要精确说明意图、需求以及什么才算好。
我们的许多规格说明一开始是口述的。有人把一项功能讲一遍,智能体将其转化为规格说明的初稿,然后由人进行编辑。我认为这对我们速度的提升超过任何单次模型升级。
我们在 3月30日 向 前沿计划 中的首批企业客户推出了 Copilot Cowork。
经验教训 2:在清晰边界内运行范围狭窄的智能体
下一步是同时运行多个智能体。每个智能体都在自己那份仓库副本中工作,只处理一个任务、依据一份规格说明。一个智能体变成五个,五个变成几十个。
每月合并的 PRs 从一月份的 257 增长到五月份的 2,754。在 GA 之前我们最忙的一周有 783。
图 A。2026 年每月合并到主分支的拉取请求,标出了 Frontier (3月30日)、GA (6月16日),以及 Rust 切换(九月 23 – 十月 10)。十月涵盖 1st 到 20th。
并行工作比我们预期的更顺利,原因有两个:
- 任务范围狭窄: 只承担一个明确定义任务的智能体,比被要求一次做很多事的智能体犯的错误更少。
- 按任务选择模型: 我们将模型选择视为质量与成本之间的权衡,而不是一场使用最便宜模型的竞赛。有些任务需要更强的推理能力;对于其他任务,更快、更便宜的模型为我们提供了恰当的平衡。
在 Copilot Cowork 中,模型运行在没有用户凭据的容器中。容器外的一个受信任主机负责身份、策略、审批和工具,所有 Microsoft 365 操作都通过该主机进行。
我们不想限制智能体可以尝试什么,因为一个受到严格限制的智能体并不是很有用)。借助我们的架构,模型可以规划并请求任何事情,而由主机决定实际发生什么。
这种分离让我们能够增加并行工作的智能体数量,而无需赋予模型凭据或对操作的直接权限。主机仍在执行点应用身份、策略、审批和审计。
图 B。智能体容器不持有任何用户凭据,也没有直接访问 Microsoft 365 的网络路径。每个请求都跨越边界到达受信任主机,该主机负责身份、策略、审批、工具执行和审计。
一开始就构建这一点,比后来再添加要容易得多。它还以一种我们未曾预料到的方式帮到了我们。智能体只通过一个定义好的接口与主机通信,因此我们后来可以替换整个主机而不改变智能体——我们迁移到 Rust 时就是这么做的。
我们在 6月16日 实现了全球正式可用,距第一次提交约四个半月,支持多个模型和 16 个插件。
经验教训 3:让检查证明它确实检查了某些东西
在智能体编写大部分代码的情况下,九个人无法阅读每一项变更,而等待人工审查正成为每个 PR 最慢的环节。
几位工程师各自构建了自己的审查机器人,仿照他们自己审查代码的方式,这些机器人相互竞争。当一个机器人发现另一个机器人漏掉的问题时,漏掉问题的机器人就会得到一条新规则。随着时间推移,每个审查者都积累了大量从自身失误中学到的规则。现在每一个都经历了至少least10、000代;在每一代中,审查者执行审查,然后反思自己漏掉了什么,并在审查代码方面变得更好。
如今,有用的首次审查通常在拉取请求打开后几分钟内到达。在近期已合并 PRs 的一个样本中,中位数约为四分半钟。
随后是更慢、更专门化的审查:
- 一个遍历调用图,查找未连接起来的代码,以及那些即使变更被回退仍会通过的测试
- 一个查看运维问题,如重启安全性、指标和陈旧文档
- 一个从头到尾阅读每个已更改文件
- 一个对照被替换的代码检查行为
作者在变更仍然新鲜时获得快速反馈,而详细阅读仍然会发生。
当然,审查智能体仍然可能漏掉东西。有一次,一组审查智能体阅读了产品中最敏感部分之一的一项变更。每个都被要求尝试破坏它,没有一个发现问题。随后团队中的人阅读了同一份 diff,发现了四个阻塞性问题,这些问题在合并前全部得到修复。
快速的首次反馈很有用,但它不是跳过判断的许可。我们保留智能体审查作为第一道检查,并为关键变更至少增加一道人工检查。我们明确了哪些发现可以阻止合并。
我们还将发现分为两级:
- 阻塞性: 正确性、安全性、数据丢失,或者一种没有测试能在其出问题时捕获的新机制。
- 建议性: 其他所有内容。作者要么修复它,要么回复,并且不会强制进行另一轮审查。
另一条规则来自一个不可能失败的检查。我们对智能体读取的指令文件保持大小限制,因为每多一个字节都会在每次运行时消耗令牌。该限制被超出了,而强制执行它的检查却一直保持绿色。
脚本将检查的输出通过管道传给了 tail。shell 管道返回其最后一条命令的退出码,而那是 tail,它总是成功:
一旦我们开始查找,就在其他地方发现了同样的问题: - 一个格式化检查被交给了一个空文件列表,所以它没有检查任何东西就通过了 - 一个已更改文件列表在工作提交后立刻返回空 我们写了一条分两部分的规则: - 每项检查都报告它检查了多少个文件 - 每项新检查都附带一个让它故意失败的测试 我认为这一点在有智能体时比没有时更重要。智能体会一直工作到检查通过,所以如果一项检查可以在没有测试任何东西的情况下通过,智能体最终会找到那条路径。 ## 经验教训 4:将团队所学到的内容放入仓库 到八月,同样的错误不断重现。每个智能体会话开始时都没有对上一个会话的任何记忆,因此我们将团队的规则移入仓库: - 根目录和各组件中的指令文件 - 一个按特定主题组织规则文件的目录 现在,每个智能体在开始工作时都会读取相关文件。当出现问题时,修复通常包括一条新的或更新的规则。目前大约有 30 个规则文件,最古老的可追溯到 8月6日。 早期的一条规则是关于合并冲突的。在每天几百次合并的情况下,我们的冲突几乎都不在实际逻辑中。它们出现在每个分支都会追加一行的文件中:注册表、列表、生成的文件,以及带有“最后更新”行的文档。我们没有要求人们更小心地解决冲突,而是更改了这些文件: - 注册表改为每个条目一个文件,而不是一个长列表 - 生成的文件在合并后重新生成,绝不手动合并 - 每次拉取请求都会变化的值,如日期和计数,不再提交 ## 经验教训 5:为并行工作塑造代码库 智能体还编写了仓库中大部分最初的 Rust 代码,它们把东西放在最容易的地方,通常意味着最大的 crate。有一次,主运行时二进制文件大约有 290,000 行,将其拆分花费了很长时间。 8月20日,我们评估了 Rust 工作区的状况: - 有 86 个 crate,依赖链深达 13 层 - 对共享基础 crate 的更改迫使 63 个 crate 中的 43 个重新构建 - API crate 已增长到 134,000 行,而且也是所有东西连接在一起的地方 - 约 12,000 行业务逻辑位于二进制文件本身中,因为实现 crate 之间的循环依赖没有其他地方可以放置它 对于一个并行运行许多智能体的团队来说,这种结构是个问题。大多数变更触及相同的少数 crate,重新构建工作区的大部分,并相互冲突。 我们通过关于角色的严格规则修复了它。每个 crate 都是四种类型之一: - **契约** (类型和接口) - **套件** (无内部依赖的共享辅助工具) - **实现** - **二进制** 实现可以依赖契约和套件,但不能依赖其他实现。只有二进制依赖实现,且二进制只包含连接代码。当一个实现需要另一个实现的某些东西时,该依赖通过契约进行。 新代码没有例外,也没有允许列表。我们过去授予的每一个例外都被用来为下一个例外辩护。 清理工作作为一个包含 401 个工作项、项之间有 2,225 个依赖关系的项目运行,在高峰时,一次有 40 到 44 个项就绪。我们将工作分组为从不触及相同文件的批次,这样并行智能体就不会冲突。我们甚至将门禁自己的临时例外列表拆分为每个 crate 一个文件。否则,一波约 20 个智能体都会编辑同一个文件。拥有该项目的工程师在设定的检查点做出决策,智能体完成了大部分实现。 CI 门禁起初是一份报告。然后它变成了一个棘轮,允许的例外数量只能下降。结果: - 例外列表上的一个实现到另一个实现的依赖从 8月22日 的 216 降到 10月7日 的零。 - 门禁允许的最大依赖深度从 10 降到三。 - crate 的数量从 86 降到约 176。  图 C。Rust 工作区在 (8月20日) 之前和 (10月7日) 之后。之前:一条深链,业务逻辑在二进制中,实现 crate 相互依赖。之后:二进制只连接东西,实现只依赖契约和套件。 构建时间的收益比我们预测的要小。我们的编译缓存在拉取请求构建上已经命中约 99% 的时间,因此构建时间的运行间差异大于改进本身。我们能衡量的是结构性的。在对基础 crate 进行更改后,必须按顺序重新构建的最长 crate 链从 11 降到 3。现在这被用来推动进一步的速度改进,并防止编译时间随代码库规模线性增长。 ## 经验教训 6:为自动化循环设定目标和限制 今年大部分时间,循环是手动的:一个人提示智能体,阅读结果,并决定下一步做什么。现在我们更多的工作以自动化循环运行。一个测试框架获得一个可衡量的目标,并持续工作直到达到目标或用尽尝试次数。人们选择目标,决定什么绝不能变差,并对任何进入生产的东西保留最终决定权。 ### 测试速度 最清晰的例子是一个以 CI 运行测试所花时间为目标的测试框架。它打开两种拉取请求: - 使慢测试变快的更改 - 删除行为已被另一个测试覆盖的测试 它的拉取请求只有在 CI 自己的计时数据显示所触及的测试至少快 5% 时才会合并。审查步骤还会将每个断言与原始版本进行比较,因此更快的测试不能是检查更少的测试。自 mid-September 以来,这些 PRs 中约有 300 个已合并。在这些合并中,所触及的测试文件从大约 CI 时间的 17,700 seconds 降到约 1,700。 ### 文档质量 我们运行了一系列轮次,每一轮都更改生成文档的技能。每一轮都通过在固定任务集上将其输出与上一版本进行比较来评判。只有以下三项全部成立,一轮才会被保留: - 它赢得的比较多于输掉的比较 - 得分最低的 10% 没有变差 - 没有硬性检查开始失败 如果其中一项标准未满足,就会回退。10 轮中有三轮被保留。有些轮在正面比较中获胜,但仍被拒绝,因为它们最弱的输出变差了。由一个人做出发布的最终决定。 ### 工厂:生产信号输入,经测量的合并输出 我们最新的测试框架,我们称之为工厂,将工程工作的长尾作为输入:一个版本引入的错误集群、指向某个工具的反馈、一小时前损坏的主构建、一个超出其大小预算的文件。每一个都很小。它们一起到达的速度超过人们能够处理的速度。确定性规则将这些信号转化为工作项,因此没有人提交工单,我们也不信任模型来发出警报。该测试框架不需要访问客户内容来改进系统;它使用日志和用户反馈作为上下文来重现问题。 每个项都从一个生产数字开始——例如,688 个对话中 2,148 个被拒绝的载荷——并包括一个测试框架自行运行的验收检查,以及一个在变更前会失败的测试。然后该项经过一个人会遵循的阶段:研究、设计、构建和拉取请求。每个重要的阶段都有一个批评者运行在与它所检查的智能体不同的模型家族上。 在拉取请求打开之前,测试框架会回退修复并再次运行新测试。如果测试在没有修复的情况下仍然通过,它就不被视为证据,变更也不会发布。这与我们为检查写下的规则相同,应用于测试框架所做的每一项变更。 合并也不是终点线。测试框架跟踪每个合并的提交进入版本和第一个生产环,然后检查该信号在包含修复的构建上是否确实下降。如果问题再次出现,它就会成为一个新的工作项。 在十月 12 那一周,4,581 个拉取请求合并到主分支,每天约 650 个,其中越来越大的份额来自工厂。 工厂的所有状态都存在于一个数据库中,因此任何机器都可以接手任何运行,崩溃丢失的永远是一个进程,而不是一个事实。 我们运行的每个循环在开始前都决定了四件事: - 一条书面规则,规定何时保留变更、何时回退 - 一个不允许变差的衡量指标 - 尝试次数限制(将拉取请求推进到合并的智能体在 20 轮后或连续三次遇到相同的 CI 失败后停止,以先发生者为准) - 一份仍需人工执行的操作清单:部署到生产环境和更改配置 ## 案例研究:将运行时从 Python 迁移到 Rust Python 让我们快速达到 GA,我认为用其他任何东西都无法赶上六月的日期。然而,在我们之后达到的规模下,它导致了三个问题: - **事件循环停顿:** 每个 Python 进程有一个事件循环。在负载下,我们测得长达 11.9 seconds 的停顿,在此期间该进程中的所有东西都在等待,包括锁续期,因此有些轮次在过程中丢失了锁。 - **一个隐藏的超时:** 我们发现 16.7% 的会话建立恰好花费 5.000 seconds。结果证明是超时触发,而不是真正的工作。 - **内存:** 当系统内存耗尽时,被终止的进程几乎总是 Python 那个:在一个集群上,一周内发生了 539 次。 我们首先尝试将阻塞工作移出事件循环。这有帮助,并表明底层限制是单个循环本身,而不是任何单个阻塞调用。 早先的每一项更改都在迁移中发挥了作用: - 在任何流量移动之前,切换就有书面的运行手册和设计记录 - 智能体并行移植子系统,Rust 提交从七月的约 200 个增加到九月的约 3,900 个 - 扁平化的 crate 图意味着这些移植大多不会冲突 但很难知道移植是否正确。将 Rust 与 Python 并排阅读无法扩展,因此我们改为记录 Python 做了什么。 对于我们移植的每个行为,一个小型生成器在一组输入上运行真实的 Python 代码并记录结果:响应体、状态码、日志行和存储写入。该输出是一个我们称为 fixture 的 JSON 文件。Rust 测试重放该 fixture,并且必须完全匹配。 没有人手动编辑 fixture。每个生成器还可以在检查模式下运行,如果 fixture 不再匹配 Python 就会失败。我们大约有 430 个 fixture。 这是一个小例子,针对决定一个轮次是否仍在运行的函数:
// fixture: written by running the Python, never edited by hand { "cases": [ { "has_pending_redelivery": false, "signal_routes_in_flight": 0, "awaiting_up_request_ids": [], "running_turn_request_id": "", "last_terminal_request_id": "", "expected": true }, ... 47 more ] } #[test] fn every_python_case_is_reproduced() { let fixture: Fixture = serde_json::from_str(FIXTURE).expect("fixture parses"); assert_eq!(fixture.cases.len(), 48, "the generator's full grid"); for case in fixture.cases { assert_eq!(turn_running(&case.inputs()), case.expected); } } ```
测试在比较之前检查用例数量,因为对零个用例的重放也会通过。
当 Python 被删除时,它最后的 fixture 留在仓库中,作为 Rust 代码的规格说明。在允许 Rust 行为不同的地方,差异会进入一个只能变短的已知差距列表。
我们一次一个集群地移动流量,后面有可以切换回去的标志。我们在 10月10日 完成——即我们删除 Python 的第二天。
| 日期 | 步骤 |
|---|---|
| 9月23日 | 首次内部尝试,已回退 |
| 9月24日 | Rust 处理了其第一批生产轮次 |
| 10月9日 | Python 运行时已删除:约 8,000 个文件和 4.9 百万行,包括测试和生成的代码 |
| 10月10日 | 最后一个集群已迁移 |
会话第一轮的首令牌时间大幅下降。这些数字比较了九月下旬同一个 12-hour 时间窗口内的一个 Rust 集群和一个 Python 集群:
| 语言 | p50 | p90 |
|---|---|---|
| Python | 7.9 s | 14.5 s |
| Rust 语言 | 2.7 s | 6.2 s |
并非所有好处都来自 Rust。同一项变更将智能体循环从启动缓慢的容器中移出,放入 Rust 服务。我认为这一举措占了收益的很大一部分。
事件循环停顿和 5-second 建立都不复存在。我们尚未正确测量每轮成本,因此我们不对成本做出任何断言。
有两件事出了问题:
- 事件排序: Python 一直在按顺序传递某些事件,但从未有人明确指定过,而最初的 Rust 版本没有这样做。没有 fixture 捕获到这一点,因为没有人想到要记录顺序。
- 一个缓慢的前门: 最初的 Rust 前门确认新消息所需时间大约长六倍,因为它在回复前等待整个拾取过程。
我们修复了这两个问题。
六条经验教训加在一起意味着什么
经验教训不是要追求我们的 PR 数量。而是要当代码更快到达时,将人的注意力转移到更重要的决策和控制上。我们自己现在的大部分时间都花在四个决策上:
- 构建什么
- 在哪里划定边界
- 哪个测试证明变更是有效的
- 一个通过的检查是否意味着它表面上意味着的东西
Python-to-Rust 迁移一起检验了这些做法。我们能够快速行动,因为我们已经规定了工作、记录了用于对照检查的行为,并以可逆转的步骤移动流量。
其中一些做法在你能够定义“完成”意味着什么、变更到达的速度超过人们审查的速度,或者你可以将替代系统与仍在运行的系统进行比较时特别有用。但更广泛的经验教训适用于这些条件之外:工程师并没有退出流程。他们花了更多时间决定边界是什么、哪里的人工审查是必不可少的,以及什么已经准备好投入生产。我们正在探索智能体如何能够处理更多例行的合并工作流,同时为更高风险的变更保留明确的人工检查点。
自己试试
你不需要从几十个智能体开始。
从你的团队信任的一项检查开始:让它故意失败,并确认它确实失败。然后选择一个有界任务,写下成功意味着什么以及绝不能破坏什么,并看看当智能体在这些限制内工作时会发生什么变化。
文章 九名工程师,一天内 1,211 个拉取请求:构建 Copilot Cowork 的经验教训 首发于 命令行(Command Line)。
这条更新,对你有用吗
分享你的判断
评论
仅支持文字,审核通过后展示。0/4000 字符
正在读取评论…