Skip to content
全部文章

编程智能体接手后,我们的 CI/CD 如何改变

编程智能体现在编写并发布 AnyRouter 的大部分改动。在 GitHub Actions 上用了五个月之后,我们在一个月里四次改变了 CI/CD 的设计。我们先从 GitHub Actions 换成一台机器上的本地 CI,再换成有评审关卡的 PR,再换成少量大 PR,现在是直接在 main 上提交。本文按阶段介绍,附上我们测得的数据、途中发现的问题,以及我们今天交给智能体的规则。


智能体改变了瓶颈所在

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 部署。

Agent pushes a branch

Pull request

GitHub Actions CI

Preview deploy

Merge

GitHub Actions deploy

阶段 1。每个智能体的每次推送都在 GitHub 上运行整条流水线。

这对一个人类团队很合适,对一群智能体则不合适。智能体推送,等 CI,修复失败的部分,再推送。每一次推送都会运行完整流水线。我们当时没有测量队列,但等待是显而易见的。

它还要花钱。我们的仓库是私有的。GitHub 文档说明,私有仓库上的 Actions 每月包含一定的使用量,超出后按分钟付费。标准托管运行器只对公开仓库免费。智能体整天都在推送,所以我们预计大部分分钟数都要付费。我们没有测量这笔成本。

阶段 2:专用机器上的本地 CI

10 月 4 日,我们关闭了 GitHub Actions 的 CI(#4452)。那里只剩发布工作流还在运行。同样的检查现在通过 pnpm local-ci 在一台专用机器上运行,这次运行会在 PR 上签署一个提交状态。智能体也在这台机器上开发和部署。

signs a status

Agent lane in a worktree

Pull request

pnpm local-ci on the dedicated machine

merge-pr.sh

pnpm ops deploy from local main

Only the Workers that changed

阶段 2。检查和部署移到智能体共用的一台机器上。

一台机器有它自己的限制。它有 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 把排队的改动堆叠成一个更大的改动,只做一次评审。

Lane: catalog and pricing

Stacked commits

Lane: model pages UI

Stacked commits

One PR

One PR

One independent review per PR

Merge, then one deploy for the batch

阶段 3。两个 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 运行。

Edit on main

Local checks: diff read, vitest, biome, typecheck

git commit as a checkpoint

git pull --rebase, then fast-forward push

Deploy from local main

Review the pushed range, fix forward with new commits

阶段 4。提交是检查点,推送是备份,评审读取已推送的范围。

评审并没有消失。一段范围落地之后,评审者读取 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 两节。你可以复制它,并把命令改成你自己仓库的。

改编自 AGENTS.md,2026 年 11 月 3 日。
# 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 日。

Commits on main vs PRs merged, per weekAug 3Aug 10Aug 17Aug 24Aug 31Sep 7Sep 14Sep 21Sep 28Oct 5Oct 12Oct 19Oct 26Nov 2*9008007006005004003002001000Count
从 8 月 3 日这一周开始,每周 main 上的提交(柱)和已合并的 PR(线)。* Nov 2 是不完整的一周。

8 月,从计数看,main 上有很多提交不是来自已合并的 PR。从 9 月下旬起,几乎每个提交都是一个 squash 合并的 PR,两条线都在 10 月 26 日这一周达到峰值,分别是 810 个提交和 821 个 PR。改为只用 main 之后,PR 这条线应该会下降,而提交会继续。

每周部署次数

这台机器日志中的部署。Sep 7、Sep 14 和 Oct 19 这几周没有记录,已省略。

Aug 312
Aug 1059
Aug 1791
Aug 2450
Aug 319
Sep 2114
Sep 28168
Oct 545
Oct 1234
Oct 2614
Nov 2 (partial)37

每周部署时间中位数

这台机器日志中成功部署的 p50。缺失的周相同。

Aug 395 s
Aug 10438 s
Aug 17538 s
Aug 2443 s
Aug 31177 s
Sep 21172 s
Sep 28107 s
Oct 5883 s
Oct 12937 s
Oct 26794 s
Nov 2 (partial)562 s

部署时间在 10 月上升了。部署变成了对一个不断增长的 19 个 Worker 集群的完整构建,外加一道类型检查关卡。上面说的 dirty 标记让自 11 月 1 日以来的每次部署都是完整部署。

部署时间花在哪里

自 Nov 1 以来 51 次成功部署中每个步骤的平均秒数。总计 p50 602 s,p90 810 s。

deploy:spokes227 s
typecheck197 s
core-uploads124 s
prerender79 s
prerender:redeploy50 s
build49 s
assets42 s
build:docs41 s
deploy:docs40 s

部署 spoke Worker 和类型检查加在一起,约占各步骤平均值总和的一半。这正是 dirty 标记破坏的两件事。干净的标记能让部署跳过没有变化的 Worker,也能跳过已经通过的类型检查。

PR 流程的交付时长,从开始到上线

从 PR 打开,到第一个标有该提交或更晚提交的 Worker 版本。每周的 p50 和 p90。

Oct 12 p502.1 h
Oct 12 p9010.7 h
Oct 19 p504.6 h
Oct 19 p9014.3 h
Oct 26 p501.4 h
Oct 26 p9022.2 h
Nov 2 p50 (partial)0.3 h
Nov 2 p90 (partial)1.6 h

我们的脚本 scripts/delivery-metrics.ts 按提交测量交付时长。一个提交从它的 PR 打开时开始,如果没有 PR,则从它被编写时开始。当标有该提交或更晚提交的 Worker 版本被部署时,它就上线了。这张表把 PR 流程分成它的两半。

PR 流程,所在周提交从开始到合入 p50 / p90从合入到上线 p50 / p90从开始到上线 p50 / p90
Oct 124120.6 h / 5.6 h0.8 h / 5.0 h2.1 h / 10.7 h
Oct 193443.0 h / 12.6 h0.8 h / 2.7 h4.6 h / 14.3 h
Oct 268190.2 h / 21.7 h0.5 h / 3.4 h1.4 h / 22.2 h
Nov 2(不完整)83(80 已上线)0.1 h / 0.4 h0.2 h / 0.8 h0.3 h / 1.6 h

在 10 月 26 日这一周,p90 的长尾几乎完全出现在合并之前,为 21.7 小时。等待发生在合并之前,而不是在合并和部署之间。11 月 2 日这个不完整的一周快得多,但它只涵盖 PR 流程最后几天的 83 个提交。它不是只用 main 流程的结果。

我们还不知道的事

只用 main 的流程才一天。我们测量时,main 上有 6 个提交,其中还没有一个被部署。我们没有它的改动后数据,所以不宣称它更快。

main 流程,所在周提交从开始到合入 p50 / p90从开始到上线 p50 / p90
Oct 126(6 已上线)0.0 h / 0.0 h1.0 h / 2.5 h
Oct 261(1 已上线)0.0 h / 0.0 h1.7 h / 1.7 h
Nov 26(0 已上线)0.2 h / 0.7 h暂无

较早的 main 行是在 PR 时期做的少量直接提交。它们太少,无法与任何东西比较。

新流程和这两个部署修复运行几天之后,我们会重新运行 npx tsx scripts/delivery-metrics.ts --since 2026-10-12。我们会用这些数据更新本文,包括那些不好看的数据。

两分钟发第一笔请求

用自己的 key 免费开始,或充值按 token 计费。Go 每月 $4 额度 — $2/月,或捐一把 provider key 免费开通。

开始使用