一条 PR 的全部测试跑完了,合并队列却还在等一个写缓存的任务。

这不是段子。Linear 在 9 月 21 日公开了自己的 CI 改造记录。他们的测试套件从年初以来几乎翻倍,PR 等待时间却从六分多钟压到五分多钟,单次测试消耗的 runner 时间大约减半。

五十多秒,听起来不够性感。可如果团队已经让 Agent 承担大部分测试编写,这五十多秒就是开发反馈回路里最硬的一堵墙。

我对这篇复盘最有感的地方,不是某个换成 Rust 的工具,也不是八分片比四分片快,而是它把一个常被忽略的问题摆到了台面上。AI coding 没有消灭工程瓶颈,它只是把瓶颈从写代码,推到了验证代码。

这篇文章想聊的不是如何照抄 Linear 的 TypeScript 配置。Java、Spring Boot、GitHub Actions、GitLab CI 都可以从里面拿走同一套判断框架。你该先看哪里,哪些缓存该砍,哪些并行会让账单更难看,还有为什么 Agent 时代 CI 要从一条流水线,变成可被设计的反馈系统。

代码生成变快,验证不会自动跟上

以前,一个开发者手写一个功能,写半天,跑一次 CI 等五分钟,很多团队也就忍了。CI 慢当然不舒服,但它被长时间的编码工作稀释掉了。

Agent 改变的是提交节奏。

一个需求拆成几个小任务,Agent 可以连续给出多份 diff。人类不再只等自己敲完代码,而是在审核、补充上下文、修正方向,再让 Agent 继续跑。PR 的数量上去了,验证频率也上去了。更麻烦的是,Agent 往往倾向于补足测试,测试文件和依赖路径于是也一起膨胀。

Linear 的描述很直白,Agent 让交付代码指数级加快,但 CI 的验证速度并没有等比例提升。每个 PR 仍然要经过同一道门,于是等待时间和基础设施成本开始一起抬头。

这有点像把餐厅前台换成了自助点单机。订单来得更快,不代表后厨突然多了灶台。前台越顺滑,后厨排队越明显。

不少团队此时会本能地加 runner。这个选择可以理解,尤其是在队列已经堆起来时。但如果你没有先区分排队、初始化、真正执行测试和尾部失败重试,加机器可能只是在更快地烧钱。CI 里有很多工作根本不该被运行,有些工作也不该处在阻塞合并的关键路径上。

所以问题不是「怎样把每一个 job 跑得更快」。

问题是「一条变更从提交到得到可信结论,到底经过了哪些不可避免的等待」。

先盯住关键路径,别被平均耗时骗了

Linear 把优化目标定成两个量,PR 等待多久,以及每条测试要消耗多少 runner 时间。这两个量经常被混在一起,但它们不是一回事。

把一组测试拆成更多 shard,可能让开发者更早看到结果,却让总 runner 分钟数上升。把十个检查合并到一个大 job,可能让账单下降,却可能把一个不相关的失败拖到更晚才显现。没有单一指标能替你做决定。

比较实用的做法,是给一次 CI 执行画一张很粗的时间图。

  • 排队多久才拿到 runner
  • checkout、依赖安装、镜像拉取各花多久
  • 哪些 job 是后续 job 的前置条件
  • 最慢的 shard 是谁
  • 测试已通过之后,合并到底还在等什么

这里最容易漏掉的是小 job。Linear 的八个 API test shard 要等待变更检测和缓存判断完成才能启动。单看这两个 gate 任务,它们不重。但它们站在所有并行任务前面,慢二十秒就会让整条路径慢二十秒。

他们先把这类任务的 fetch depth 限下来。最慢的 gate 从 94 秒降到 20 秒。那些根本不需要工作区的 job,则直接移除了 checkout,耗时从 27 秒降到 7 秒。对于确实要比较路径的 push 和 merge queue 事件,稀疏、无 blob、有限历史的 checkout 又省下了十来秒。

这个细节特别值得 Java 项目借鉴。很多 Maven 或 Gradle job 明明只是读取提交信息、检查模块路径、计算版本,依旧习惯性 checkout 全仓库。单仓库小时候这么干没关系,发展成大 monorepo 后,每一次习惯性的全量拉取都会进入账单和关键路径。

你可以先做一个很不优雅但有效的实验。把 pipeline 中每个 job 是否真的需要源码、是否需要完整 Git 历史、是否需要全部依赖,写成三列清单。别急着改。团队通常会先发现一些连存在理由都说不清的 checkout。

缓存不是信仰,命中也可能比重建慢

缓存是 CI 里最容易获得掌声的东西。看到 install 慢,第一反应就是缓存。看到编译慢,再加一层缓存。后来缓存目录多了、key 复杂了、restore 偶发超时了,大家还是很难承认它不划算。

Linear 在 pnpm workspace 中撞上了一个很典型的反例。API 测试实际只依赖 API 包,但工作流过去会安装整个 workspace。他们改成只安装目标包和依赖后,安装从 44 到 73 秒变成 16 到 18 秒。

接着他们测了依赖缓存。lockfile 变化频繁,缓存即便命中也要约 28 秒才能恢复,而一次过滤后的安装只需约 7.5 秒。这个缓存不仅没带来收益,还额外增加了保存时间和波动。

于是他们把它删了。

这种决定看着有点反直觉,但很工程。缓存不是抽象的「快」,它是一段有传输、解压、校验、失效和维护成本的程序。真正要比较的是端到端的中位数和 p95,不是缓存命中时的理论速度。

放到 Java 项目里,也一样。.m2 或 Gradle cache 很大时,跨网络 restore 可能比下载一小撮真正需要的依赖还慢。Spring Boot 的集成测试如果只动了一个模块,也没必要每次把整个多模块工程都准备好。

我更愿意把缓存按输入稳定性分成三类。

  • 几乎不变的工具链和系统依赖,适合放到基础镜像
  • 变化不频繁、重建代价高的构建产物,适合用明确 key 缓存
  • 高频变化又能快速重建的东西,先测再决定,很多时候不缓存更干净

Linear 也把 Postgres 客户端和原生构建 headers 放进了小型 CI base image。每个 API shard 以前都要重复 apt install 七到八秒,偶尔下载 headers 还会把长尾拉得很长。把稳定的公共前置条件放到镜像里,不只省均值,也是在砍 p95。

CI 的体感往往由 p95 决定。九十次顺滑、十次卡住,不会让人觉得它可靠。

别让无关工作坐在合并的门口

Linear 有一个细节很有意思。他们之前在最终 merge check 中写 cache marker。测试已经通过了,PR 还要等这一次写入结束才能进入 merge queue。后来把写入挪到一个测试 shard 完成后执行、但不 gate 任何东西的 job,每一条 API PR 和 merge queue 项目都少等了 42 秒。

这里没有变快任何测试。

只是把一个不影响正确性的动作,从关键路径上拿走了。

很多 CI 都藏着这样的任务,上传诊断信息、生成报告、预热缓存、同步制品、通知外部系统。它们很有用,但「有用」和「必须在合并前完成」完全是两回事。

这块需要注意一下,千万别把所有后置工作都扔到异步。安全扫描、制品签名、数据库迁移兼容性检查,如果你的发布流程真的依赖它们,就必须保留 gate。关键是把门槛和善后分开,明确每个 job 阻塞合并的原因。

给每一个 required check 问一句话就够了。

如果它失败了,是否应该阻止这段代码进入 main。

回答不是「是」的,就不该堵在门口。

并行不是多开几个 runner

测试并行化常见的误区,是盯着 shard 数量。四个不够就八个,八个不够就十六个。可 shard 越多,每个 shard 启动 runner、checkout、安装依赖、启动数据库的固定成本也越多。测试本身还没跑,钱和时间已经先花掉了。

Linear 的 API shard 一度每个要花 110 到 140 秒做 setup。这个条件下直接上八分片,光 setup 就会累计 15 到 19 分钟 runner 时间,可能比测试执行本身还夸张。优化完依赖、镜像和数据库准备后,setup 大约变成 40 秒。此时从四片扩到八片才变得划算。

顺序不能反。

他们还发现 Vitest 按文件而不是按单个测试时长分配工作。少数超大的测试文件会霸住一个 shard,其他 shard 已经闲下来,整套测试仍在等最后一个慢文件。把大文件拆得更聚焦,再把 shard 从四个调到八个,关键 job 在初始基准中快了约 19%,也便宜约 19%。一周后,最慢 shard 从 5.25 分钟降到 4.33 分钟。

这不是某个 Vitest 技巧,而是负载均衡的老问题。JUnit、Surefire、Gradle Test、pytest 都会遇到。你需要的不是平均测试耗时,而是每个分片的最长耗时分布。平均值很漂亮,尾部一根刺,开发者照样得等。

对于 Spring Boot 项目,我会先从历史 CI 日志拿到 test class 级别的耗时。把特别慢的类拆开,或者让分片依据历史时长而不是文件数量切分。只有当固定开销已经降下来,再加并行度。这个顺序很朴素,效果却常常比换一套 CI 平台更大。

更快也会带来正确性风险

性能改造最危险的部分,不是删缓存,是共享状态。

Vitest 默认隔离每个测试文件,Linear 的 entity、GraphQL 和 decorator graph 会在每个 worker 里重复构建。他们新建了一个 isolate: false 的 opt-in project,让确定安全的文件在 worker 内复用 module registry。收益很大,最慢 shard 从大约 300 到 379 秒降到约 195 秒,API shard 的总 runner 时间从每次约 32.8 分钟降到 22 分钟。这是他们最大的单项改进,按其体量计算约占月度节省的 17%。

但他们没有把所有测试粗暴塞进去。

每个符合条件的文件要显式写 opt-in 注释,还要补齐共享状态的 teardown。假 timer 或状态关系理不清的测试继续留在隔离项目里。更有意思的是,团队把这条性能约束同步写进了 Agent skills,因为当前大多数测试已经由 Agent 编写。

这才是 Agent 时代应该有的工程边界。你不可能指望模型天然理解每个测试框架里共享状态的雷区。让 Agent 直接生成更快、但偶尔污染后续用例的测试,表面吞吐提升了,排查 flake 的成本会在几周后找上门。

我自己的判断是,性能 opt-in 比全局开关更适合这类场景。它不够漂亮,也会让作者多写一行注释,但它把风险暴露在 code review 中。工程不是把最快路径默认打开,而是把最危险的路径默认关着。

给 Agent 的不是一个按钮,而是一份验证契约

如果你的 Agent 只能执行一条笼统的「运行测试」命令,它会做两件不太好的事。要么每次全量跑,把快速迭代拖回人类时代。要么为了快跳过关键检查,把风险留给合并队列和线上。

比较好的做法是,把验证拆成由变更范围驱动的契约。

当 Agent 改动 Java Controller 和 Service 时,先跑对应模块的编译和单测。当它改动 Flyway 或 Liquibase migration 时,额外启动数据库兼容性检查。当它改了公共 starter、权限模块或 build logic,再提升到跨模块回归。当它只改文档和前端文案,根本没必要拉起后端容器。

这里不需要神秘的 Agent 平台。Git path filter、模块依赖图、测试标签、变更风险规则,已经能搭出第一版。LangGraph 这类编排框架则适合把「分析 diff、选择验证集、运行、失败后归因、提出修复建议」明确做成状态机,而不是藏进一个超长 prompt。

一个简单的状态可以是这样。

1
2
3
4
5
6
读取 diff
-> 判断受影响模块
-> 选择最小验证集
-> 执行并收集日志
-> 失败时区分环境问题和代码问题
-> 升级验证范围或请求人工判断

重点不在于让 Agent 更自动,而是让它每次都能说明自己为什么选了这套验证。没有可解释的选择,所谓智能路由最后很容易退化成随机跳过。

反过来,CI 也应当把结果以 Agent 能消费的形式返回。不要只留一团截断日志。输出失败阶段、关键命令、可复现入口、受影响模块和最近一次相似失败,Agent 才能接住反馈继续修,而不是再开一个全量 job 猜答案。

一张可以明天就开始用的改造清单

别一上来重写 CI。先用一周观察,再选一个短板打穿。

  • 记录 PR 的 queue time、关键路径时长、runner minutes 和 p95
  • 标出每个 required check 的阻塞理由,移走不影响合并正确性的工作
  • 审核所有 checkout,只保留真正需要的历史、文件和 blob
  • 对缓存做 restore 对重建的实测,删掉慢缓存
  • 把反复安装的稳定依赖做进基础镜像
  • 依据历史耗时而不是文件数量平衡测试分片
  • 共享状态、跳过验证和智能路由都做显式 opt-in,并写清回退路径
  • 给 Agent 提供变更范围到验证集的映射,把验证理由和结果结构化输出

其中任何一条都不新鲜。真正难的是持续把 CI 当产品做,而不是一份一年没碰过的 YAML。

Linear 的案例也提醒了我,AI coding 时代最稀缺的未必是生成更多代码的能力。代码来得越快,团队越需要一条快、稳、可解释的验证通道。否则 Agent 只是在前面踩下油门,所有人一起堵在 CI 的收费站。

五十多秒不是故事的结尾。

它是团队重新把验证速度当成产品能力之后,才刚腾出来的一点路。

资料来源