AI 正在重塑 CI

Anthropic 的工程师如今平均每季度交付的代码量,是 2021—2025 年间的 8 倍。其中 80% 的代码由 Claude 编写,而 Claude 在 PR 的审查和批准环节中同样发挥着重要作用。

写代码已经不再是瓶颈。而当 PR 审查也被大幅加速之后,压力就开始转移到 CI 上。

CI 作业量随智能体发展变化,瓶颈逐步向下游转移
瓶颈随智能体编程的发展逐步向下游移动(原文配图)

与此同时,我们整个代码库中的测试数量增长了 10 倍,而工程师人数只增加了很少。这些因素叠加起来,使得我们的 CI 作业量在 6 个月内增长了 25 倍。(如果你正在心算这里的数字:并不是每个 PR 都会运行所有测试,后面我会解释。)

这几次险些压垮我们的测试影响分析服务。为了避免它成为下一个瓶颈,我们最终把原来的架构彻底推倒,重新思考整个服务应该如何设计。但这个过程并不顺利。一开始,我们连续做了三次快速修补,而它们分别只撑了 70 天、29 天,以及不到 1 天。

CI 作业量增长曲线,以及每次补丁争取到的缓冲时间
CI 作业量与各补丁争取到的缓冲时间(原文配图)

随着智能体持续加快代码生成和代码审查的速度,CI 扩容很可能会成为越来越多工程团队即将面对的问题。我预计,随着使用智能体的团队同时产生更多 PR 和更多测试,横向扩展的测试选择架构最终会成为行业标准。这篇文章会介绍我们如何扩展 Anthropic 的测试影响分析服务,以及我以较大代价学到的一条经验:永远要为指数级增长提前做准备。

至于具体的扩容手段——购买更大的机器、并行化进程,或者重启服务(没错,这招到现在居然还挺有效)——其实都很常见,也不是这篇文章真正想传达的重点。真正的问题在于:这些手段如今能为你争取到的时间,只有一年前的一小部分。反过来,彻底改造、重新设计一个服务所需的时间,如今也只是一年前的一小部分,因为写代码已经不再是瓶颈。你越早预判这种压力,并提前规划架构将如何随规模演进,就越不容易把时间浪费在只能暂时缓解问题的权宜之计上。

测试影响分析的架构

我认识的很多同行所在的公司,现在依然会在每一次代码变更后运行全部测试。这种方式在一定规模以内是可行的,但无法持续扩展:CI 门禁会越来越慢、越来越昂贵,也越来越不可靠。另外,人类很擅长判断“这个测试失败与我的改动无关”,但智能体需要更多明确的上下文和指引。如果给智能体一组准确、有效的相关测试,它们就能更有效地进行自我验证和迭代。

在 Anthropic,我们构建了一个确定性的测试影响分析服务,也可以称为测试选择服务。它会基于历史测试表现和软件包之间的相关性,决定每次代码变更应该运行哪些测试。这种做法并不罕见,市面上也有一类厂商专门提供类似产品。

我们的服务依赖两个必须保持同步的确定性组件:

这套机制本身效果很好,但当系统每秒都有多个 CI 作业运行时,监听器就会越来越跟不上 PR 队列。对于一个 AI 原生软件开发生命周期(AI-native SDLC) 来说,即便很短的延迟,也可能产生很大的影响。例如,监听器如果落后 20 分钟,就可能意味着数万条测试结果更新还没有同步到选择器。

这会导致几种问题:

这一整套逻辑最初运行在单个进程中,因为系统需要维护每项测试的持续历史记录,所以必须由单一写入者按顺序应用结果。这个 v0 设计也意味着,我们无法通过横向分片来扩展系统。

一路磕磕绊绊走向重构

到了去年的 10 月,这个服务已经开始显现出压力。我们甚至连续两天收到告警。

补丁 1:换一台更大的机器

第一个解决办法非常简单:把运行这个服务的 CPU 核数增加一倍。我们自己也知道,这个办法维持不了多久。

对话为重现内容,基于真实事件。
团队内部对服务前景的讨论(原文配图,对话为重现内容,基于真实事件)

即使趋势已经非常明显,服务的归属责任却并不清晰。没人愿意再接手一块基础设施。与此同时,CI 团队还有更重要的问题需要处理。

补丁 2:分片

到了这个阶段,由于监听器的积压越来越严重,我们已经频繁收到告警。为了推动一些更长期的修复,我在内部版本的 Claude Tag 中开了一个长期会话,专门用来监控这个服务。只要监听器积压超过 50,000 个作业,Claude 就会提醒我,并恢复之前的对话,继续讨论下一步应该怎么做。这个过程持续了几个月。它的一个好处是,我不需要每次都重新向 Claude 解释之前做过哪些尝试,也不用反复补充上下文。Claude 经常建议我们彻底重构,但大多数时候,我们最终还是选择了再打一个补丁。

内部版本 Claude Tag 中的真实对话,部分内容经过删减。
内部版本 Claude Tag 中的监控对话(原文配图,部分内容经过删减)

到了 2 月,CI 作业的指数式增长再次开始给服务带来压力。这一次,我们决定做并行化。后来我们发现,监听器真正需要的并不是一个负责全局排序的单一写入者。它需要的是:每个 package 各有一个单一写入者,保证代码库中各个区域对应的测试结果能够保持正确顺序。Claude 帮我们生成了相关代码,把每个 package 的状态拆成一个独立分片,并为每个分片配置自己的 worker。我们也知道这个方案不会长久。但我们没想到,它只多撑了 29 天。

补丁 3:每天重启

到了 3 月,这个进程在大多数工作日中午过后就会达到内存上限。我们再次开始寻找快速修复的方法,但是:

我们还发现,每天重启实际上会让服务逐渐落后得越来越多。当它落后超过一小时——这种情况发生过好几次——大量作业结果就没有被监听器记录下来。

需要说明的是,这并不意味着那些 PR 没有运行 CI,也不意味着未经测试的代码被直接推到了生产环境。真正发生的是:监听器漏掉了一部分测试结果,因此测试选择组件只能依据过时的数据来判断某个 PR 应该运行哪些测试、不应该运行哪些测试。实际效果通常是,我们会继续运行一些本来就极其 flaky、或者已经在整个代码库中普遍失败的测试。

监听器内存与 Pod 内存上限对比:每日重启开始,直至最后一次 OOMKill
监听器内存与 Pod 内存上限(原文配图)

重构

这时候早就已经到了必须重构的时候。于是我们采纳了 Claude 的建议:给测试选择服务加一个数据库。更准确地说,是一个内存数据存储。

这样一来,我们实际上把原本由那个单实例进程承担的大量内存处理工作转移了出去。现在,任何一个 listener worker 都可以处理任意一条测试结果,把它追加到内存数据存储中的 journal,然后立即继续处理下一条。worker 不再需要把状态保存在自身内存中。也就是说,它们变成了无状态服务,因此可以进行横向扩展。

与此同时,一个很小的独立 consumer 进程每隔几秒就会把 journal 中的数据聚合成每项测试对应的历史记录。selector 则可以快速查询所需的相关测试历史。

这种分布式架构的运行成本更高,但与那个摇摇欲坠的单实例架构相比,它要容易扩展得多,也更容易做内存分析和性能诊断。

CI 测试选择服务:重构前后的架构对比
CI 测试选择服务重构前后的架构对比(原文配图)

整个项目由一名工程师完成,只用了 3 周。如果是一年前,这个项目大概需要接近一个季度。

待处理的作业结果事件数量,以每小时最大值统计。切换架构之前:几乎每天都会形成积压,而且积压量逐周增加。切换并完成调优之后:曲线趋于平稳。

之后还有一些细节上的调整,比如 journal 的容量以及 worker 的数量。这些调优工作大部分由 Claude 自主完成,而从那以后,我们的服务一直保持稳定。

如果重新来一次,我会怎么做

如果能把我送回 2025 年 10 月,结合现在掌握的经验,我会用不同的方式处理这个项目,以及其他类似的项目。

第一个不同点,是我会把 AI 带来的指数级增长直接算进架构规划里。随着每名工程师平均使用的智能体数量增加,同时被加速的 PR 审批流程也越来越成熟,CI 作业数量会呈指数增长。

这也改变了 Anthropic 内部 PR 的形态。Claude 更倾向于创建更小、粒度更细的 PR——这也再次说明了为什么不应该让每一个 PR 都跑完整套测试。结果就是,每天产生的 CI 作业更多了。

另外,系统的最低活动水平也被抬高了,因为智能体会在夜间以及周末继续提交工作。不过负载依然呈现明显的突发性,因为大量 PR 最终仍然由人类工程师推动和批准。

我给工程团队的建议是:无论你选择自己构建还是购买现成方案,都应该假设:两个季度之后,你的架构可能需要承受现在 25 倍的负载。

“过度工程”这个概念的重要性已经开始有所下降——或者至少可以说,判断什么算过度设计的门槛正在大幅上升。只要预算允许,现在完全可以在 v0 架构设计阶段,就按照当前预估规模的 10~20 倍来提前规划。

还应该为服务做好充分的可观测性,让这些监控数据成为 Claude 的“眼睛和耳朵”。有了这些信息,Claude 就能够通过持续的小步调整进行类似“爬山式”的优化,比我们手工处理问题更快、更有效。尤其需要确保:进入系统的 CI 作业数量,应该与从系统中成功处理出去的作业数量保持一致。

从一开始,就应该把状态移出进程本身。另外,对于任何关键服务,我都会避免只运行单个实例——除非你能够充分监控它,同时也能够监控任何 canary 变更。CI 的演化速度已经太快,不能再用其他方式处理了。