写代码变快了,验证却成了瓶颈
技术说明:当写代码不再是最紧的约束,会发生什么?我们的工程代理集群里大约有 25 个代理,配合 Claude Code 和三个 Claude Ultra 账号使用。代理和人一起工作,写了主仓库里的大部分 Pull Request。一个代理可以开一个草稿 PR,GitHub 就会开始跑三万条测试。下一次推送,又可能把三万条测试全跑一遍。然后合并的时候,再来一整轮。每次全量测试在 GitHub 托管的 runner 上跑,我们要付 60 到 75 个 runner 分钟的钱。三个 Claude 账号每月花 600 美元,而十月的账单显示 GitHub Actions 花了约 1600 美元。我们在 GitHub Actions 上的开销,是写代码所用订阅费的两倍半还多。
代理推送一个修订版,就可以开始干下一个任务。PR 就停在那里,等测试变绿。十月某次统计中,合并队列的中位等待时间是 12 到 14 分钟。这是 PR 在时钟上耗掉的时间。而 GitHub 是按并行任务来计 runner 费用的。验证成了瓶颈,成本也跟着转移。在寻找更便宜的 runner 之前,我们先查了一件事:我们到底有多频繁地让 GitHub 跑同样的测试。
1. 改 YAML 之前,先审计任务尝试记录
从一个仓库、一天开始。用已登录的 GitHub CLI,并具备 Actions 的读权限,列出当天创建的运行记录,然后检查某个昂贵运行的所有任务尝试。替换示例中的仓库、UTC 日期和运行 ID。
REPO = owner/repo
DAY = 2026-10-28
gh api --paginate "repos/ $REPO /actions/runs?created= $DAY &per_page=100" \
--jq '.workflow_runs[] | [.id, .name, .event, .head_sha, .run_attempt] | @tsv'
RUN_ID = 123456789
gh api --paginate "repos/ $REPO /actions/runs/ $RUN_ID /jobs?filter=all&per_page=100" \
--jq '.jobs[] | [.id, .name, .head_sha, (.labels | join(",")), .started_at, .completed_at, .conclusion] | @tsv'
runs 接口能拿到触发方式和这次运行关联的 SHA。jobs 接口能拿到各个任务,包括用 filter=all 查到的更早尝试记录。要做完整审计,就按任务实际运行时间统计任务尝试次数,窗口开始前创建、后来重试的运行也要算进去。仓库繁忙时,把运行查询拆成更窄的时间窗口,避免触发 GitHub 对筛选搜索的 1,000 条结果上限。按触发方式和运行器类型对任务耗时分组。八个分片并行跑十分钟,在计费调整前大约是 80 运行器分钟。如果其中一个分片在临近结束时挂了,而你重跑整套测试,计算量几乎会翻倍,但第二次运行的耗时看起来并不会更慢。任务耗时能指出该查哪里。实际付费看账单数据;包含额度要和实际用量分开跟踪。
2. 给草稿加快速检查,同时保留完整的合并门禁
我们迁到 GitHub Enterprise,拿到更大的包含分钟额度,并把测试分成2. 给草稿加快速检查,同时保留完整的合并门禁
我们迁到 GitHub Enterprise,拿到更大的包含分钟额度,并把测试分成两层。草稿拉取请求只跑它新增或改动的测试。准备合并的拉取请求仍然必须通过完整测试套件。每次运行的计费分钟数从 76.4 降到 31.4,下降了 59%。起初总计费分钟数几乎没动——因为我们跑 CI 的次数变多了。³
合并队列让我们不必对已经通过测试的代码再跑一遍完整测试。队列测试的是准备合入的那个提交。合并之后,CI 会查找 main 上完全相同的提交是否有成功的队列运行记录。如果找不到,就运行完整测试套件。最后这项检查很关键:另一个提交的绿色结果不算数。
如果你要改自己的工作流,先从事件入手。草稿推送和即将合入 main 的提交,需要的检查并不相同。
| 事件 | 该跑什么 | 结果能证明什么 |
|---|---|---|
pull_request,仍是草稿 |
快速检查,加上按 diff 选出的测试 | 作者拿到反馈。这不满足完整的合并门禁。 |
从 PR 到合并队列再到 main 分支
pull_request,已准备好接受审查:这是最初策略下的完整测试套件。PR 在进入队列前已通过。但基础分支仍可能变化。
merge_group:针对队列提交运行所需的完整测试套件。合并后的变更已通过。PR 的检查不能替代这项检查。
推送到受保护分支:在最终落地的提交上寻找证明。如果找不到证明,就运行完整测试套件。要么该确切提交已通过完整检查,要么现在就测试它。
将所需的工作流绑定到 merge_group。在完整测试任务中,签出后记录 git rev-parse HEAD,这样就能知道实际运行的是哪个提交。对于合并后的快捷路径,要求满足:来自可信队列工作流的绿色运行、测试的 SHA 与推送的 SHA 一致、且每个所需完整测试套件任务都通过。如果工作流签出了不同的 ref 或跳过了必需任务,则不算数。查询超时也不算。启用快捷路径前,先测试三种情况:已知的绿色精确匹配、不同 SHA 上的绿色运行、以及失败的查询。只有第一种应该跳过测试套件。
我们仍在处理测试套件中较慢的部分。对于那些无论如何都必须运行的测试,我们核算了 runner 时间的成本。
3. 将一个工作流迁移到 Spot,并测试两种失败路径
GitHub Actions 仍然运行了该工作流。我们将符合条件的测试任务从 GitHub 托管的 runner 迁移到了 EC2 Spot,先从合并队列开始,后来也迁移了同仓库分支的 PR 任务。我们在 AWS 中使用了隔离的、短生命周期的 CI runner,每个 runner 只运行一个任务,任务结束后即丢弃。
Spot 为我们提供了更便宜的测试时间购买方式。但它也可能在运行中途收回资源。如果七个分片通过,第八个失去了 runner,重新运行整个套件意味着要为已经获得的七个结果重复付费。
我们将启动器称为 Spot Wave-Rider。我们不再向 AWS 请求特定机器,而是告诉 EC2 Fleet 任务需要什么,让它在启动器指定的区域内从匹配的实例类型中选择。我们使用了容量优化策略,它会寻找有空闲容量的池子,而不是只挑最便宜的。
如果启动时拿不到 Spot 容量,启动器可以改用 On-Demand(按需实例)。因中断而丢失的测试分片,会在 GitHub 托管的 runner 上重试一次;而测试本身失败的分片不适用这次重试。这是两条不同的失败路径。启动阶段,任务还没开始跑,只是需要一个地方运行;跑到一半被中断,你要保住的是已经通过的那些分片。以八分片为例,重试第 8 个分片时,针对的是同一个被测 commit,并且要求它的结果和原先 7 个分片的结果一起作为依据。如果你无法确认是 runner 掉线,或者重试也失败了,那么这个必需检查就一直是红的。在把某个必需工作流迁走之前,先测一测中断路径:在试点任务运行期间停掉一个 runner,确认只重试了缺失的那个分片。然后再故意让一个测试失败,确认门禁依然是红的。别把不受信任的 PR 代码放到自托管池里跑;GitHub 的 runner 安全指南解释了为什么贪便宜的 runner 可能是一笔亏本买卖。
4. 账单和队列要一起看
让试点跑在较近的基线上,仓库范围保持一致。能一次只改一个变量,就一次只改一个。除了合并数,还要看首次排队成功率和排队时长。算单个已合并 PR 的成本时,把这一范围内实际付给 GitHub 和 AWS 的钱都算进去,包括重试消耗的算力,再除以合并的 PR 数。GitHub 账单降了,可能只是掩盖了更长的等待,或者多出一份 AWS 账单。
我们测到的数据
| 指标 | 10 月 24–31 日 | 11 月 3–5 日 |
|---|---|---|
| GitHub Actions 每日毛计费(约) | 约 83 美元 | 约 27 美元 |
| 每日合并 PR 数(观测区间) | 38–82 | 47–55 |
| 每个已合并 PR 的 GitHub Actions 毛费用(日区间) | 1.19–2.52 美元 | 0.49–0.56 美元 |
抵扣额度之前,GitHub 的每日费用从约 83 美元降到 27 美元,降幅 68%,而我们每天仍然合并约 50 个 PR。我还需要把账单上的日期和迁走的具体任务对应起来,再看看 AWS 那边收了我们多少钱,然后才能说这次迁移究竟省了多少。
我们最初面对的局面是:agent 写代码的速度已经快过了我们验证代码的速度。于是我们给草稿加了一道快速检查,把完整测试套件留在合并门禁上。如果某个确切的 commit 已经通过过一次,那么代码合入之后就不再重跑整套测试。
接着,我们把剩下的部分工作迁到 Spot 实例上,并做了一套机制:某个分片(shard)跑丢了,可以只重试它,不必从头再来。我仍然希望智能体能想推多少次修订就推多少次。我真正关心的是:一个改动要多久才能通过测试、进入 main 分支,以及把它送到那里要花多少钱。写代码变快之后,瓶颈就转到了验证和测试上。我们砍掉了重复的测试运行,合并门禁(merge gate)上依然跑全量测试套件,并把剩下的部分工作搬到 Spot 上,配合只重跑丢失分片的机制。做到这一步之后,制约我们速度、迭代节奏和产出的,是需要人来判断的部分——识别正确的架构模式,做出正确的决策。
每月 600 美元是我们订阅 Claude 的费用。10 月的 Actions 数字是取整后的结果,来自内部支出追踪系统,目前还在与另一份 GitHub 账单数据核对。这个对比只涵盖这两项支出,不等于工程或验证的全部成本。
↩ 数据来源:内部合并队列追踪,2026 年 10 月 17 日。这是队列等待时间的一个快照,不代表 PR 的总交付周期,也不是智能体写代码耗时的测量。
↩ 数据来源:内部 CI 用量追踪,2026 年 10 月 16 日。59% 这个数字比较的是每次运行的计费分钟数,不是每天的总分钟数。
↩ 数据来源:GitHub Actions 用量计费 API(Actions 分钟数,为扣除包含额度前的总费用)和 GitHub 已合并 PR 搜索,均于 2026 年 11 月 6 日读取。10 月的每次合并成本区间取自 10 月 28—31 日的每日数据。这个比值是用计费范围内 Actions 的总费用除以同一观察仓库当天的合并数量,并不把费用摊到单个 PR 上。合并数量既不衡量队列延迟,也不衡量测试质量。
技术说明
当写代码不再是卡脖子的环节,会发生什么?
我们的 agent 集群里大约有 25 个工程智能体(agent),用三个 Claude Ultra 账号跑 Claude Code。主仓库里大部分拉取请求(PR)都是这些智能体写的,它们和人一起干活。智能体开一个草稿 PR,GitHub 就开始跑 3 万个测试。下一次推送,这 3 万个测试可能又得全跑一遍。等到合并,还会再触发一整轮完整运行。每次跑完这一整套……我们都要为 60 到 75 个 runner 分钟付费。