智能体改变了瓶颈所在
AnyRouter 的大部分改动现在由编程智能体编写、检查和发布。它们每天推送和部署很多次。写代码变快之后,围绕它的流水线成了大家等待的对象。
我们在 GitHub Actions 上运行 CI 五个月。然后在一个月里,从 10 月 4 日到 11 月 3 日,我们四次改变了 CI/CD 的设计。每次改动解决一个瓶颈,并让我们看到下一个。本文按顺序介绍各个阶段,附上日期,以及促成每次改动的提交或 PR。
这里所有数据都是在 2026 年 11 月 3 日,从 git 历史、GitHub、我们的部署日志和一个交付时长脚本中测得的。数据有缺口或需要说明的地方,我们都在旁边注明。
阶段 1:GitHub Actions 和预览部署
2026 年 5 月 23 日,我们搭建了常见的流水线(#321)。每个 pull request 在 GitHub Actions 上运行 CI,并得到一个预览部署。合并到 main 后从 GitHub Actions 部署。
这对一个人类团队很合适,对一群智能体则不合适。智能体推送,等 CI,修复失败的部分,再推送。每一次推送都会运行完整流水线。我们当时没有测量队列,但等待是显而易见的。
它还要花钱。我们的仓库是私有的。GitHub 文档说明,私有仓库上的 Actions 每月包含一定的使用量,超出后按分钟付费。标准托管运行器只对公开仓库免费。智能体整天都在推送,所以我们预计大部分分钟数都要付费。我们没有测量这笔成本。
阶段 2:专用机器上的本地 CI
10 月 4 日,我们关闭了 GitHub Actions 的 CI(#4452)。那里只剩发布工作流还在运行。同样的检查现在通过 pnpm local-ci 在一台专用机器上运行,这次运行会在 PR 上签署一个提交状态。智能体也在这台机器上开发和部署。
一台机器有它自己的限制。它有 15 GB 内存,很多智能体共用。三项改动让它能运转。
- 按 Worker 部署(10 月 10 日)。AnyRouter 运行在 19 个 Cloudflare Worker 上。现在一次部署只发布自己上次部署以来代码有变化的 Worker。每个 Worker 版本都标有构建它所用的提交,所以下一次部署知道从哪里做 diff。
- 重量级锁(10 月 12 日,#5026)。安装、构建、类型检查和测试运行共用一把锁,范围只限于它们的重量级阶段。在此之前,并行构建会耗尽机器内存,内存不足处理程序会杀掉智能体会话。
- 一个合并脚本(10 月 14 日,#5492)。
merge-pr.sh成为 lane 的 PR 合入的唯一方式,每次都运行同样的检查。
我们在之前的文章里写过按 Worker 部署,以及如何让本地 CI 更快。本文讲的是围绕它们的流程。
阶段 3:评审关卡,然后是更少更大的 PR
本地 CI 去掉了成本,但没有去掉等待。所以 10 月 28 日我们改了合并关卡(#6840)。一个 PR 凭一次独立评审合并,即 review:approved 标签加一个已签署的状态,不再为每个 PR 运行 CI。测试在一批合并之后在 main 上运行一次,失败向前修复。超过 2 小时没有评审的 PR 会被标记为过期。
然后数量追上了我们。在 10 月 26 日这一周,合并了 821 个 PR。每个 PR 都有自己的评审智能体、自己的 worktree 和自己的依赖安装。只看到一个小 PR 的评审者,看不出它和旁边那个 PR 如何相互影响。在那样的数量下,逐个 PR 评审没有意义。
交付时长的数据显示了代价。那一周,PR 从打开到上线的中位数是 1.4 小时,但最慢的 10% 用了 22.2 小时或更久。这条长尾大部分出现在合并之前,而不是在合并和部署之间。交付时长图在下面的数据部分。
11 月 3 日,manager 会话同时运行着 8 个 lane。它们在重量级锁上排在彼此的测试运行后面,等了 15 分钟或更久。两个 lane 修改了相同的文件,不得不互相等对方的 PR。所以我们改了规则(#7798)。
- 同一时间最多 2 个实现 lane。其他的都在队列里等待。
- lane 按代码区域固定,例如 catalog 和 pricing,或模型页面 UI。新请求加入拥有该区域的 lane。
- 一个 lane 把排队的改动堆叠成一个更大的改动,只做一次评审。
这个想法部分来自 Anthropic 的文章 Building agents: when to fold subagents into your main agent loop。它的建议是,只在并行的新上下文工作、已测得的上下文膨胀,或者一个智能体在工具或指令上吃力时,才保留子智能体。否则就折叠回主循环。智能体之间的交接会丢失细节,每多一个智能体都会增加延迟。这两点我们都遇到了。
阶段 4:不要 PR,直接在 main 上提交
大 PR 仍然有一个来回。11 月 3 日,一行的测试修复仍然需要一个 worktree、一次安装、一个 PR、一个评审者和一次合并,才能轮到它所阻塞的那次部署。所以我们去掉了 PR(78675034b)。
现在智能体在 main 上工作,或者在推送到 main 的 lane worktree 里工作。它在本地评审和检查,把每个可用的步骤作为检查点提交,然后 rebase 并推送。推送是备份,也是机器之间的共享状态。部署从本地 main 运行。
评审并没有消失。一段范围落地之后,评审者读取 git diff <last-reviewed>..<head> 并列出问题。对小改动,这个评审者就是主会话。Billing、pricing、auth、secrets、migration 和 routing 在推送之前仍然由一个独立评审者检查。每个修复都作为新提交落地。我们从不 amend 或 force-push 已评审的提交,所以评审记录保持可读。
release-please 的 PR 是剩下的唯一一种 PR。这些由负责人手动合并。
途中发现的更多问题
测量每个阶段,发现了没有任何检查报告的问题。每项检查都通过了,而这些问题中的每一个都让我们耗费了时间,或者放过了一个错误。
- 一个自己喂养自己的部署循环。部署检出里零散的未跟踪文件,让每次部署都把它的 Worker 版本标记为
+dirty(manager 的诊断数出了 5 个这样的文件)。部署无法从 dirty 标记做 diff,所以下一次部署重建了整个 Worker 集群,并且因为这些文件还在,又标记了+dirty。自 11 月 1 日以来的全部 51 次部署都是全集群部署。零散的未跟踪文件现在不会再把一次部署标记为 dirty(2ee30c20f)。 - 一个从不命中的缓存。如果同一个提交已经通过,部署会跳过类型检查。这个缓存把零散的未跟踪文件当成 dirty 的树,所以 51 次里命中了 0 次。每次部署的类型检查平均用 197 秒。它和上面的循环是同一个根本原因。现在这个缓存会忽略 TypeScript 从不读取的未跟踪文件(fbe45259b)。
- 部署后过早运行的冒烟测试。11 月 3 日(实测),部署后立刻运行的第一次生产冒烟测试,115 项检查中有 52 项失败,错误是
503 database_unavailable。几分钟后重跑,没有被跳过的 72 项检查全部通过。这些失败来自刚部署的版本,而不是某个 bug。现在我们在部署后的冒烟测试之前先等待。 - Vitest 不检查类型。Vitest 会去掉类型再运行代码。一个 lane 的检查只运行了 Vitest,一个有类型错误的测试进入了
main。它在部署时才暴露,而部署在第一个类型错误处就停止。现在每个 lane 在推送前都运行根目录的pnpm typecheck,并读取它真实的退出码。 main上只允许 fast-forward 的守卫。10 月 29 日,一个 lane 的 worktree 在 lane 还在使用时被删除了。它的下一条命令串联了一个失败的cd,所以一条git push --force-with-lease从一个过期的main检出里运行了。main倒退了大约一分钟(#7089)。该 lane 恢复了它,没有丢失任何提交。当我们允许直接推送到main时,我们保留了这个教训。pre-push 钩子允许 fast-forward 推送,拒绝任何改写或删除main的推送(62d85ea2b)。- 提交哈希就是身份。Cloudflare 上的每个 Worker 版本都带有
anyr-sha=<commit>。这一个字段驱动按 Worker 部署的 diff、防止过期检出让生产回滚的回滚检查,以及本文中的交付时长数据。以 PR 编号为键的指标,会在你不再开 PR 的那天失效。以提交为键的指标不会。 - 部署速度,进行中。在那 51 次运行中,一次部署的中位数是 602 秒。上面的两个修复已在 11 月 3 日落地,所以按 Worker 部署和类型检查缓存可以重新发挥作用。我们还在做一个能在部署之间保留的构建缓存,并在评估 tsgo,用 Go 编写的 TypeScript 编译器。这些我们都还没有测得结果。
我们给智能体的规则
这是当前的约定,改编自我们 AGENTS.md 里的 Main-only flow 和 Few lanes 两节。你可以复制它,并把命令改成你自己仓库的。
# How we land changes (main-only flow)
- No pull requests. Work on main, or in a lane worktree that pushes to main.
- Keep each commit small and on one topic. Stage exact paths. Never git add -A.
- Check locally before every commit:
- read the whole diff
- targeted vitest: scripts/heavy.sh light -- npx vitest run <files> --maxWorkers=2
- biome on the changed files
- root pnpm typecheck for TypeScript changes; read its real exit code
(vitest does not type-check)
- Billing, pricing, auth, secrets, migrations, routing: one independent
reviewer reads the diff before you push.
- git commit, git pull --rebase origin main, git push origin main.
Never force-push main.
- A review of the pushed range lands its fixes as new commits.
Never amend, rebase or force-push reviewed commits.
# Few lanes
- At most 2 implementation lanes at once. Queue the rest.
- Lanes are fixed by code area. A new request joins the lane that owns
its area. It does not get a new worktree.
- Fold before you fan out. Spawn a subagent only for parallel
fresh-context work, a black-box review, or measured context bloat.
- One heavy command at a time per machine (install, vitest, tsc, build).数据
这些图使用 git 历史、GitHub 和我们的部署日志。部署日志只覆盖一台机器,而且缺了几周。这些周从部署图里省略了,没有画成 0。所以部署次数是下限。11 月 2 日这一周不完整,只到 11 月 3 日。
8 月,从计数看,main 上有很多提交不是来自已合并的 PR。从 9 月下旬起,几乎每个提交都是一个 squash 合并的 PR,两条线都在 10 月 26 日这一周达到峰值,分别是 810 个提交和 821 个 PR。改为只用 main 之后,PR 这条线应该会下降,而提交会继续。
每周部署次数
这台机器日志中的部署。Sep 7、Sep 14 和 Oct 19 这几周没有记录,已省略。
每周部署时间中位数
这台机器日志中成功部署的 p50。缺失的周相同。
部署时间在 10 月上升了。部署变成了对一个不断增长的 19 个 Worker 集群的完整构建,外加一道类型检查关卡。上面说的 dirty 标记让自 11 月 1 日以来的每次部署都是完整部署。
部署时间花在哪里
自 Nov 1 以来 51 次成功部署中每个步骤的平均秒数。总计 p50 602 s,p90 810 s。
部署 spoke Worker 和类型检查加在一起,约占各步骤平均值总和的一半。这正是 dirty 标记破坏的两件事。干净的标记能让部署跳过没有变化的 Worker,也能跳过已经通过的类型检查。
PR 流程的交付时长,从开始到上线
从 PR 打开,到第一个标有该提交或更晚提交的 Worker 版本。每周的 p50 和 p90。
我们的脚本 scripts/delivery-metrics.ts 按提交测量交付时长。一个提交从它的 PR 打开时开始,如果没有 PR,则从它被编写时开始。当标有该提交或更晚提交的 Worker 版本被部署时,它就上线了。这张表把 PR 流程分成它的两半。
| PR 流程,所在周 | 提交 | 从开始到合入 p50 / p90 | 从合入到上线 p50 / p90 | 从开始到上线 p50 / p90 |
|---|---|---|---|---|
| Oct 12 | 412 | 0.6 h / 5.6 h | 0.8 h / 5.0 h | 2.1 h / 10.7 h |
| Oct 19 | 344 | 3.0 h / 12.6 h | 0.8 h / 2.7 h | 4.6 h / 14.3 h |
| Oct 26 | 819 | 0.2 h / 21.7 h | 0.5 h / 3.4 h | 1.4 h / 22.2 h |
| Nov 2(不完整) | 83(80 已上线) | 0.1 h / 0.4 h | 0.2 h / 0.8 h | 0.3 h / 1.6 h |
在 10 月 26 日这一周,p90 的长尾几乎完全出现在合并之前,为 21.7 小时。等待发生在合并之前,而不是在合并和部署之间。11 月 2 日这个不完整的一周快得多,但它只涵盖 PR 流程最后几天的 83 个提交。它不是只用 main 流程的结果。
我们还不知道的事
只用 main 的流程才一天。我们测量时,main 上有 6 个提交,其中还没有一个被部署。我们没有它的改动后数据,所以不宣称它更快。
| main 流程,所在周 | 提交 | 从开始到合入 p50 / p90 | 从开始到上线 p50 / p90 |
|---|---|---|---|
| Oct 12 | 6(6 已上线) | 0.0 h / 0.0 h | 1.0 h / 2.5 h |
| Oct 26 | 1(1 已上线) | 0.0 h / 0.0 h | 1.7 h / 1.7 h |
| Nov 2 | 6(0 已上线) | 0.2 h / 0.7 h | 暂无 |
较早的 main 行是在 PR 时期做的少量直接提交。它们太少,无法与任何东西比较。
新流程和这两个部署修复运行几天之后,我们会重新运行 npx tsx scripts/delivery-metrics.ts --since 2026-10-12。我们会用这些数据更新本文,包括那些不好看的数据。
相关文章
How we sped up our local CI without dropping a check
Coding agents push to AnyRouter all day, so CI is where they wait. We split 50 runs into queue, setup and run time, found the slow part, and cut warm typecheck from 162 to 79 seconds on the same commit, mostly by removing a check that ran twice. This post shows the audit, the changes, the numbers, and a prompt you can give your own agent.
How agents deploy AnyRouter: one Worker at a time
AnyRouter runs as a fleet of Cloudflare Workers, and agents deploy it about once an hour from more than one machine. Each Worker now records the commit it runs, a deploy ships only the Workers whose code changed since that commit, and a checkout that is older than production cannot replace it. This post shows how that works and what it still costs.
How agents ship AnyRouter: the merge and deploy loop
Coding agents write, review, merge and deploy most AnyRouter changes. In the week of 19 to 25 October 2026 that loop merged 346 pull requests. This first post in the series shows the loop: a signed local CI gate, one reviewer per commit, one merge script, and gated deploys about once an hour.