修复智能体编程引发的 CI 资源争用

Human Systems 2026-08-28T12:57:06.174510

上次我认真思考 CI(持续集成)吞吐量的问题,还是在 Shopify 的 CI/CD 团队工作时。我压根没想到,在一个只有一名工程师的兴趣项目上,会遇到同样类型的扩展难题。但编码代理(coding agent)把我的其中一个项目推到了大约 50 万行代码的规模,让我比预想中更早撞上了这道坎。

变更提交的速度超过了 CI 的验证速度。而且这些变更往往体积更大,在 CI 跑完一次(约 20 分钟)的时间里,可能又有好几个变更已经准备好。一旦其中一个合入,其余往往需要 rebase,然后再跑一轮 CI。等待造成任务重叠,重叠又引发 rebase,rebase 则带来更多 CI 工作。感谢阅读 Human Systems!免费订阅即可收到新文章,也欢迎大家支持我的写作。

一名工程师加上编码代理,就足以让 CI 成为瓶颈——这种问题我以前只觉得是大得多的工程团队才会遇上的。我的应对思路是:减少每次变更触发的 CI 工作量。只挑变更可能影响的测试来跑,很多变更从占用运行器 20 分钟,缩短到只花几分钟,甚至完全跳过整个测试套件。

为什么增加 CI 容量行不通

起初我靠购买更多 CI 容量来硬扛。GitHub Actions 的免费分钟数很快被我烧完,账单从每月 10 美元左右涨到 20 美元、再涨到 50 美元,而且还在继续涨。我不希望 CI 成本跟着代理产出的工作量一起水涨船高。

于是我把负载搬到了一台通用虚拟机(VM)服务商提供的专用运行器上,并且刻意只给自己一台机器。一台 VM 的总算力其实够用。20 分钟的测试套件在夜间完全没问题——那时候代理可以连续跑多个回合,不必等我等每个结果。真正的痛点在于活跃开发时那 20 分钟的反馈循环。

我本可以多加几台运行器,但在 Shopify 我已经在更大规模上见识过这套做法:更多运行器确实能提高吞吐量,可每次变更触发的工作量并没有减少。随着变更速率不断提高,所需容量也只能跟着涨。所以我最后选择了另一条路:减少每次变更触发的工作量。

只运行受变更影响的测试

这个方法我们在 Shopify 也用过:不是每次变更都跑完整测试套件,而是先判断这次改动可能影响哪些测试,只运行这些。这里我把两类信息结合起来。第一类是完整测试运行时的记录——每个测试用到了应用的哪些部分。第二类是当变更提交到 CI 时,通过 TypeScript 依赖图看改动的文件可能影响应用哪些部分。CI 把这两类信息合在一起,筛选出相关测试。

这些工作的大部分并不是每次 CI 运行都要做。定时执行的完整套件运行负责收集运行时信息,而 TypeScript 依赖图重建只需几秒钟。改动范围小时可能只筛选出几个测试;改动涉及公共模块时,仍可能选中大部分甚至全部测试。

在 Shopify 那会儿,代码库大部分是 Ruby,所以我们更依赖运行时追踪来了解每个测试接触了哪些代码。在那个规模下,收集和处理跟踪数据的成本很高,我们花了不少力气去加速。而 TypeScript 让这部分工作便宜得多,因为编译器不用运行应用就能告诉我源文件之间的依赖关系。

筛选是刻意偏保守的。如果 CI 发现某个改动文件无法可靠地对应到已有测试覆盖,它会记录这种不确定性,而不是假定改动安全。我仍然每天晚上跑一次完整测试套件,这些运行也会刷新将来做测试筛选所需的运行时信息。这样既能保持开发循环足够快,夜间的完整运行又能提供更广的覆盖,而且不阻塞我。

测试筛选并不保证被跳过的测试永远不会失败。它只是避免在 CI 已经有信息可以选择一个更小集合时,还花 20 分钟去重跑无关测试。

让测试筛选真正有用

测试筛选只有在代码库有清晰边界时才效果好。如果应用的每个部分都和别的东西相互依赖,那再精准的筛选器也会得出「一个小改动可能影响大部分测试」的结论。

在这个应用中,每个页面都有一个路由文件,每组后端操作都有一个 API 文件。应用里较大的模块也会被拆分成独立的包。这些边界为选择器提供了有价值的连接点,让它能把某个源码改动和覆盖该改动的测试对应起来。

有些改动天然会跨越这些边界。共享测试设置、全局样式、构建配置和依赖变更可能影响应用的很大一部分,因此这类改动依然会选中大部分甚至全部测试套件。选择器同时也暴露出了代码库里一些薄弱的边界:如果一次小改动总是选中测试套件的大半,说明被改动的区域被太多代码依赖了。把这些边界收紧后,被选中的测试数量就会减少。这对并发的智能体工作也有帮助,因为系统不同部分之间的改动不太容易互相重叠,也就不太需要变基(rebase)。

我还发现,CI 配置里有些看起来并行的任务,实际却是串行执行的。两个端到端(E2E)任务会争用同一个 runner,导致重复进行环境设置,总耗时却没有减少。我把它们合并成一个任务,只构建一次应用、准备一次本地数据库。在这个任务内部,会改动状态的测试仍然串行执行,而相互隔离的视觉测试可以同时运行。

现在的处理顺序是:先跳过不相关的测试,再让剩余工作变得更便宜;如果小改动仍然会选中太多测试,就改进代码边界;最后才考虑增加 runner。目标是让验证成本与改动范围相匹配。

实际变化

这些改动即使在最坏情况下也有帮助——那时候选择器依然会选中完整的拉取请求(PR)测试套件。

指标 之前 之后 减少

优化效果如下:

指标 改进前 改进后 提升幅度
CI 总耗时 ~21 分钟 ~13 分钟 ~40%
E2E(端到端)测试 ~12 分钟 ~4 分钟 ~65%
浏览器测试执行 ~10 分钟 <3 分钟 ~70%

关键路径上的争用情况

指标 优化前 优化后 降幅
关键路径耗时 约14分钟 约6分钟 约60%

选择器本身大约会增加15秒的开销。最慢的几次运行改善幅度更大。在所有启动端到端测试的拉取请求中,端到端测试的p95耗时从约23分钟降至6分钟,下降了74%。如果把排队时间也算进去,CI总耗时的p95从约7小时35分钟降至35分钟,下降了92%。

为什么争用会呈非线性增长。 当一台运行机器接近满载时,引入工作量的小幅增加可能会导致排队时间不成比例地大幅增加。(下图是排队行为的示意,并非实际项目数据。)在到达率固定的情况下,当运行机器接近满载时,测试工作量的微小减少可以带来排队时间的更大幅度缩减。

那92%的降幅主要来自排队时间的缩短,而不是测试本身跑得更快了。改动前的部分运行会花几个小时排队等待那一台机器。接近容量上限时,排队呈现出非线性特征:每项改动所需工作量只要减少一点,就能释放出足够的机器容量,从而让等待时间的降幅远超测试耗时本身的改进。

这些是优化前后的历史测量数据,不是受控基准测试。它们展示了两种不同的效果:减少不必要的工作改善了普通运行的耗时,而减少运行机器争用对最慢的运行产生了更大的影响。

接下来我会做什么。 目前,一台运行机器又重新够用了。大多数拉取请求只需为它们可能影响到的测试付费,完整的测试套件则留到夜间运行。如果未来工作量超出了单台机器的承载能力,我会在移除那些不必运行的测试之后再增加机器,否则就是在花更多钱去跑与本次改动无关的测试。

更重要的经验在于到达率,而不在于人力规模。 一名工程师配合编码智能体所产生的改动速度,足以复现出我此前在大型工程团队中遇到的CI扩展问题。随着智能体提高了改动产生的频率,验证工作必须变得更有选择性,否则其背后的基础设施就得跟着以同样的速度扩张。

查看原文