技术11/10/2026·约 18 分钟

把编译请出 agent 的电脑

一台 14 核的 Mac 上开五个 worktree,就是五个 Bazel 调度器各自认领全部核心。我们把编译交给了 CI Mac mini 组成的 NativeLink 池。

让 coding agent 规模化,人们最先想到的瓶颈是模型和 token 预算。我们最先撞上的,却是跑 agent 的那台电脑。

不久之前,我们一直守着一条没写下来的经验:同一台机器上,agent 开到五六个就别再加了。再加,整台机器就会被拖慢,连 agent 自己都跟着卡。

这不是机器配置太低。我们的 agent 主机是一台 14 核、64 GB 的 Mac mini(M4 Pro),仓库是一个约 500 万行代码的 Bazel monorepo。问题出在一个更根本的地方:我们的构建系统是为人设计的,而现在用它的是 agent。

做法可以用一句话概括:让 agent 的电脑只做协调和验证。 Agent 的推理本来就跑在远端的模型上,最后一件还压在本机上的重活是编译。我们把编译交给一个由现有 CI 机器组成的远程执行池,本机只留下 agent 进程、构建分析和必须用到本地设备的测试;再用主干开发加上基于构建图的影响面分析,让一群并行的 agent 持续往同一条主干上合代码,而主干不被打红。

之前:五个 worktree 的 Bazel 各自把 14 个核当成自己的,70 个编译进程争 14 个核;之后:动作执行交给 8 台 Mac mini 组成的 NativeLink 池,本机只留下 agent 进程、构建分析和测试。

图 1:之前,每个 worktree 的 Bazel 都把整台机器当成自己的;之后,动作执行交给所有客户端共享的池子,本机只留下 agent、构建分析和测试。

为什么五六个 agent 就能拖垮一台 14 核的机器?

人写代码的节奏是串行的:改几行,编译,等结果,盯着报错想一会儿,再改。一次一个构建,构建之间隔着大段的思考时间。一台能流畅跑一个构建的电脑,就是一台好的开发机。

Agent 的节奏完全不同。“改一行、编译、跑测试、读结果、再改”是它的内循环。它的“思考”发生在远端的模型上,不占本机的 CPU;本机看到的,只是一个接一个的构建请求。它也不会累。而且我们不是只跑一个 agent:每个需求一个 agent、一个 git worktree、一个分支,几个需求同时推进。

在 Bazel + worktree 的组合下,每一条这样的内循环都要付三份成本:

  • 一个 Bazel server。 Bazel 按 workspace 路径给每个 worktree 分配独立的 server 进程,它是一个常驻的 JVM,我们这里每个约 1.5 GB 内存。
  • 一个 output base。 同样按路径分配,装着这个 worktree 的执行根和全部中间产物,从几 GB 到几十 GB。
  • 一组本地 action。 编译、链接、代码生成都在本机跑。我们的本地默认配置是 --jobs=HOST_CPUS:每个 Bazel server 都按“这台机器的 14 个核全归我”来调度。

最后一条是卡死的根源。Bazel 的本地资源调度是每个 server 各算各的,彼此看不见。 五个 worktree 同时冷构建,就是五个调度器各自往同一组 14 个核上塞 14 个编译进程:70 个 Swift、Rust、clang 进程争 14 个核和 64 GB 内存,没有任何一方知道另外四个存在。这种超卖是结构性的,调 --jobs 只能在“单个构建变慢”和“多个构建一起卡死”之间二选一。

而 agent 自己也要吃资源。写这篇文章时,这台机器上有 13 个 Claude Code 进程,合计约 5.7 GB 内存,还不算它们的 MCP server、浏览器和 iOS 模拟器。Agent 进程的工具调用对延迟敏感,一旦被编译进程饿住,整个系统的吞吐会一起塌下去。

作为后文所有数字的背景,这是我们的规模:

维度规模
代码约 504 万行、4.6 万个源文件:Swift 193 万、Rust 131 万、Go 79 万、Kotlin 30 万、TypeScript / JavaScript 约 28 万
构建图一张 Bazel 9 构建图、3,500 多个 BUILD 文件:30 个 iOS app、10 个 Android 工程、Go 后端、Rust 跨端核心、TypeScript Web 应用
测试近 3,000 个测试目标
Agent 主机1 台 Mac mini(M4 Pro,14 核,64 GB),目前挂着 94 个 git worktree
主干2026-10-01 至 11-10 的 41 天里合入 297 个 MR,其中 133 个来自 agent 的 worktree 分支

代码行数按 git 跟踪的源文件统计物理行(含空行与注释),不含 JSON / YAML / Markdown、锁文件和 vendored 的第三方代码。

本机只做协调和验证

把一个 agent 用到的计算拆开看,是五种性质完全不同的负载:

负载性质放在哪
模型推理重,但本来就不在本机模型服务
Agent 进程:调用模型、执行工具、读写文件、git轻,对延迟敏感本机
构建分析:加载 BUILD 文件、分析、生成动作图中等,以内存为主本机,每个 worktree 一个 server
动作执行:编译、链接、代码生成重、确定、高度并行,在哪台机器跑都一样远程执行池
绑定设备的测试:iOS 模拟器、Android 模拟器重,离不开本机设备本机,经主机级的产能租约排队

最重的模型推理早就在远端了。剩下的四种里,只有动作执行同时满足“重”和“在哪跑都一样”,而它恰好是本机负载的大头。把它也搬走,本机就只剩下协调(agent 进程和构建分析)和验证(必须在本地设备上跑的测试)。

能搬走它,靠的是 Bazel 的 action 模型。每个 action 都显式声明输入、输出、命令行和环境变量,缓存键就是这些东西的哈希。由此得到两个性质:只要平台匹配,它在哪台机器上执行,结果都一样;只要缓存键相同,它的结果谁都可以直接复用。前者让远程执行成立,后者让远程缓存成立。

所以这套架构由五件东西组成,缺一件都不成立:

  • Bazel:action 封闭、内容寻址,这是“能被搬走、能被共享”的前提。
  • Monorepo:所有产品在同一张图、同一个缓存键空间里,一个 agent 编过的东西,别的 agent 直接命中;影响面分析也只有在一张完整的图上才做得准。
  • Worktree:每个 agent 一份独立的工作副本、一个分支、一个 MR,彼此不踩。
  • RBE / RBC:远程执行(Remote Build Execution)把 action 交给集群,远程缓存(Remote Build Cache)让结果在所有 worktree、所有机器之间复用。我们的开发机和 agent 用 RBE,CI 用一套独立的远程缓存。
  • 主干开发(Trunk-Based Development)+ 影响面分析:分支活得短、合得勤;每个 MR 只跑它影响到的目标,主干保持常绿。

Worktree:隔离要便宜,前提是共享该共享的

在我们的工作方式里,一个 agent 对应一个 worktree、一个分支、一个 MR。Worktree 共享同一个 git 对象库,创建时只需要检出文件,不用再克隆一遍。但 git 层面便宜,不等于构建层面便宜:output base 按 workspace 路径分配,一个新 worktree 在 Bazel 眼里就是一个从没见过的新仓库,没有分析缓存,也没有任何产物。如果什么都不共享,每开一个 agent 就是一次全量冷构建。

所以这一层的原则是:工作副本和执行状态各自独立,一切能按内容寻址的东西全局共享。

资源每个 worktree 独占全局共享
工作副本与分支✓git 对象库
Bazel server、output base✓(按路径分配)
本地 action 缓存(disk cache)、外部依赖缓存(repository cache)整台主机一份,按大小和时间 GC
远程 CAS / Action Cache所有接入 RBE 的机器、所有 worktree
本地起的后端服务端口✓(按 worktree 名哈希出 0–499 的端口偏移)
iOS 模拟器主机级产能租约,排队准入

独占的那部分必须能自己回收,否则 94 个 worktree 很快就会把机器撑爆:

  • Bazel server 空闲 15 分钟自动退出(--max_idle_secs=900)。94 个 worktree,此刻常驻的 Bazel server 只有 2 个。
  • output base 由一个每 15 分钟运行一次的清扫器回收,按 worktree 的状态分级:worktree 已删除的,立刻回收;提交已合进主干的,安静 24 小时后回收;还有未合入提交的,安静 72 小时后回收;磁盘紧张时,门槛统一降到 30 分钟。它只删构建缓存,从不删 worktree,判断失误的最坏结果只是一次重建。即便如此,这台机器的 Bazel 存储还是单独放在一块 4 TB 的卷上,目前用了 1.5 TB。

另外有两条规则,是 agent 时代特有的。

第一条:一个 worktree 只有一个验证 owner。 两个 Bazel 命令撞上同一个 output base,后来的那个会等锁。对一个急着完成任务的 agent 来说,最顺手的解法是另起一个:--output_base=/tmp/build-$$。看起来是并行了,实际上凭空造出一个冷的 output base:全量重建,再吃几十 GB 磁盘,再和所有 worktree 抢一遍核。

人很少这么干,agent 会。所以这条规则不能只是“最佳实践”,而要写进 agent 会读、CI 会查的地方。仓库给 agent 的指南要求:把目标合并到一次 Bazel 调用里,让 Bazel 自己串行化不可避免的重叠。同时,一份面向 agent 的不变量清单在 Makefile 和所有脚本里禁止这几个参数,违反即 CI 失败(这类清单的来历见写给 agent 的不变式):

id = "build-output-ownership"
paths = ["Makefile", "**/*.mk", "**/*.sh", ...]
invariant = "Bazel output ownership is per worktree and the host policy is its single writer ..."
forbid = ["*--output_base*", "*--output_user_root*", "*--ignore_all_rc_files*"]

第二条:构建进行中,输入可能正在被改。 人很少在构建跑着的时候改源文件,agent 却经常一边编译一边继续编辑。Action 执行期间输入被改了,结果就是“按旧内容算出的缓存键,配上按新内容编出的产物”;一旦写进全机共享的 disk cache 或远程缓存,污染的是所有人。我们开了 --guard_against_concurrent_changes=full:执行期间任何输入发生变化,结果就不发布到缓存。Bazel 9 默认的 lite 只检查主仓库的源文件,在缓存被几十个 worktree 共享的场景下不够。

真正稀缺的物理资源,则交给主机级的仲裁者。本地起的后端服务按 worktree 名哈希出端口偏移,几十套开发环境同时运行,端口互不冲突。iOS 模拟器经由一个主机级的产能租约准入:同时跑 UI 测试的 worktree 一多,超出主机产能的那部分会排队,而不是再多拉起几台模拟器。

我们的构建里有大量 Apple 平台的 action:Swift 编译、资源编译、签名、打包。它们需要 Xcode,也就需要 macOS:远程执行池里必须是 Mac。我们选了 NativeLink:用 Rust 写成,源码公开(FSL-1.1-Apache-2.0 许可,每个版本两年后转为 Apache-2.0),实现了 Bazel 的 Remote Execution API,worker 可以作为原生进程直接跑在 macOS 上。

硬件上我们没有新买机器。8 台 Mac mini 本来就是 GitLab 的 macOS runner,每台机器上加一个以 launchd 系统服务常驻的 NativeLink worker,把 CI 机器的空闲算力借给开发机和 agent。

组件规格
调度器 + 中心 CAS / Action Cache1 台 Linux VM:8 vCPU、15 GiB;CAS 上限 1.6 TB,抽样的几天里每天新写入 38–133 GB
Worker8 台 Mac mini:5 台 10 核 16 GB,3 台 14 核 M4 Pro(48 / 64 GB),共 92 核
槽位每个 action 预留 2 核,共 46 个并发槽位,所有客户端共享
版本NativeLink 1.8.0;worker 另带两个我们提交给上游的补丁(见下一节)

客户端只需要一个按次启用的 Bazel 配置:

# rbe.bazelrc:只在 --config=rbe 时生效
common:rbe --remote_executor=grpc://<pool>:50051
# 用执行器自己的 CAS 和 Action Cache:输入传到别处,worker 读不到
common:rbe --remote_cache=
common:rbe --remote_instance_name=

# 每台 worker 都声明这些属性;cpu_count 是一个 action 预留的核数
common:rbe --remote_default_exec_properties=OSFamily=darwin
common:rbe --remote_default_exec_properties=cpu_arch=aarch64
common:rbe --remote_default_exec_properties=cpu_count=2

# 只把所请求目标的产物下载回本机,中间产物留在池子里
common:rbe --remote_download_toplevel
# 池子只缓存 worker 编出来的结果;本机执行的结果留在本机
common:rbe --remote_upload_local_results=false
common:rbe --remote_timeout=180s

# 并发上限由池子决定,而不是本机核数;超出容量时由调度器统一排队
common:rbe --jobs=64

# 测试仍在本机执行:iOS 测试要用本机模拟器,集成测试要连本机起的服务
common:rbe --strategy=TestRunner=sandboxed,local

--jobs=64 这一行,回答的正是前面那个超卖问题:并发由一个看得见所有客户端的调度器统一排队,而不是每个 worktree 各自假设自己拥有整台机器。

几个不那么显然的设计决定:

  1. 按次启用,用之前先探测。 远程执行没有本地回退,执行器连不上,构建就直接失败。所以它不是默认配置。给 agent 的指南里写明:先用 nc -z 探测池子,连不上就去掉 --config=rbe,照常本地构建。
  2. 测试留在本机。 构建上池子,测试在本机执行。iOS 测试离不开本机的模拟器和产能租约,集成测试要连本机起的服务。
  3. 只下载顶层产物。 --remote_download_minimal 什么都不下载,只适合“只看能不能编过”的检查;16 个 app 的顶层产物约 2 GB,可单个产品经千兆网下载只要 1–3 秒,为它冒“下一步缺文件”的风险不值得。
  4. Worker 不向 action 注入任何环境变量。 Apple action 需要的 DEVELOPER_DIR 和 SDKROOT,由 worker 按 action 自己携带、本来就属于缓存键的 XCODE_VERSION_OVERRIDE 等变量推导,规则和 Bazel 本地执行时一模一样;worker 上没有对应版本的 Xcode,action 就失败,而不是换一个 Xcode 编完、写进缓存。我们删掉了 worker 上原本写死的 DEVELOPER_DIR、PATH 和 DYLD_LIBRARY_PATH:三者都不在缓存键里,其中 DYLD_LIBRARY_PATH 让远端的 rustc 一直加载着另一份 libLLVM。
  5. Worker 的本地缓存只存拉过的输入。 8 台 worker 的本地 CAS 一度都顶在上限的 97–99%。分析之后发现,全池约 250 GB 里有 122 GB 是只出现在单台机器上的 blob,其中近一半是 worker 自己产出的大文件;8 台共同需要的热点输入只有 1.3 GB。于是 worker 的本地 CAS 改为只缓存自己拉取过的输入,产出只写中心 CAS。

效果是这样的(2026-10-23 至 11-09 实测;“强制”指绕过所有缓存、让每个 action 都真正在池子上执行):

场景结果
新建的 worktree:空 output base,不用本机 disk cache,池子缓存命中单个 iOS app 12 秒;16 个 iOS app 46 秒
一个 iOS app 的全量构建1,503 个 action 全部远程执行,本机零执行,180 秒
另一个 iOS app,host 工具链已热1,579 个远程 action,275 秒,关键路径 189 秒
9 个 iOS / watchOS 产品,强制远程7,062 个 action,0 失败
8 个 Android 产品,强制冷构建10,528 个 action 里 9,192 个远程执行,0 失败,19.5 分钟
1,204 个 Rust 目标,强制冷构建3,442 个 action,1 个偶发失败,重跑即过

对 agent 来说,第一行最重要:worktree 一出生就是热的。 本机的 disk cache 只装着这台机器自己编过的东西,池子的 CAS 则是所有接入 RBE 的机器、所有 worktree 编过的东西的并集。

一个 inode,好几个名字

iOS 和 Rust 先迁上了池子,Android 却迟迟上不去。它的整条 JVM 工具链,包括 desugar、资源编译与合并、R 类生成、dex 合并、Kotlin 编译,在池子上间歇性失败,本地却从来不失败。报错五花八门:

FileNotFoundException: …/nativelink/work/<另一个 operation>/…/lib/tzdb.dat
Error loading java.security file
VerifyError: Could not link verifier

线索藏在第一条报错的路径里:JVM 去读的那个目录,属于另一个 action,而那个 action 往往已经结束,目录已经删了。

根因。 NativeLink 的 worker 物化输入时,用硬链接把文件从本地 CAS 放进每个 action 的工作目录。这样不复制数据,快且省盘。但硬链接意味着同一个 inode 有很多名字:CAS 里一个,每个用到它的 action 目录里各一个。

出问题的,是那些靠“自己在哪”去找同伴的程序。HotSpot 用 dladdr 查 libjvm.dylib 的路径,再由此推出 java.home;CPython 由 libpython3.11.dylib 的位置推出标准库在哪;可执行文件经 @executable_path 找 ../lib 下的动态库。而在 macOS 上,dyld 报告一个镜像的路径时,给的是内核最近为这个 inode 记下的那个名字。它可能是 CAS 里的路径,也可能是另一个 action 的目录,而那个目录可能早已被删掉。

于是,一个 action 里的 JVM,会理直气壮地去另一个已经不存在的 action 目录里找时区数据库。

修复前:同一个 inode 在 CAS 和两个 action 目录里各有一个名字,op-A 已结束、目录已删,op-B 的 dladdr 却拿到 op-A 的路径,读 tzdb.dat 失败;修复后:clonefile 给 op-B 一个只有一个名字的独立 inode,java.home 指向自己的目录。

图 2:同一个 inode 的多个名字。dyld 报告的是内核最近记下的那个名字,它可能属于另一个已经结束的 action;clonefile 给每个 action 一份只有一个名字的独立 inode。

同一个机制,我们一层一层剥了三次。

  1. 最早暴露的是可执行文件:rustc 报 Library not loaded: @rpath/libLLVM.dylib,hermetic Python 找不到自己的 libpython。@executable_path 走的就是这条路。我们给所有 Mach-O 主可执行文件一份独立 inode;隔离复现里,失败从 2,400 次中的 1,007 次降到 0。
  2. 但 JVM 照样失败。可执行文件 java 修好了,libjvm.dylib 却是库,dladdr 查的是库自己那个 inode 的名字。于是把 libjvm.dylib 也加进独立 inode 的名单。
  3. 修完 JVM 之后的全量验收里,又有一个 Python 工具失败了一次。原来 CPython 也靠 libpython 的位置定位标准库,池上的症状是 No module named 'copyreg' 这类报错。于是把 libpython 也加进名单。

每一层都先量化,再修。 我们写了一个探针:一个临时包里放 900 个 genrule,每个只跑一次 JDK,打印 java.home 和当前工作目录,再强制它们全部在池子上执行。修复前,900 个里有 150 个(16.7%)的 java.home 指向别的 operation 的目录,8 台 worker 无一幸免。本机压测(8 并发)把规律量得更清楚:

输入的物化方式java.home 错位
全部硬链接734 / 1,200
只给主可执行文件独立 inode41 / 3,200
主可执行文件 + libjvm.dylib 都独立0 / 4,000

修复。 Worker 物化输入时,用 APFS 的 clonefile(2) 给两类文件各自一份独立的 inode:所有 Mach-O 主可执行文件(按文件头里的 MH_EXECUTE 识别,因为 Bazel 把远程输入一律标成可执行,权限位分不出来),以及配置项 private_inode_libraries 点名的库,目前是 libjvm.dylib 和 libpython3.11.dylib。克隆出的文件只有一个名字,dyld 不可能再报错路径。其余输入照旧硬链接。

为什么不全部克隆? 两个原因,都是踩出来的:

  • 代码签名校验。 新 inode 第一次被执行或加载时,macOS 会重新做一遍签名校验。在开着 SIP 的 Mac mini 上,每个新 inode 固定多出约 180–200 ms。如果把 rustc 的 librustc_driver(80 MB)和 libLLVM(137 MB)也克隆,8 并发下一次最小的 rustc 编译会从 37 ms 涨到 636 ms。
  • 头文件身份。 我们试过把整棵输入树都克隆,结果每个头文件都成了独立的 inode。clang 判断两个头文件是不是同一个,看的是 inode,于是一个通过符号链接暴露头文件的 Objective-C 库报出了 duplicate interface definition。这个方案已经撤回。

所以克隆的范围是一份白名单:会用 dyld 自定位、而且确实出过错的镜像。代价是每个 JVM action 多约 0.28 秒、每个 hermetic Python action 多约 0.2 秒的首次加载。冷的整次 Android 构建因此慢几个百分点,增量构建只涉及几个 action,可以忽略;输入里没有 JDK 和 hermetic Python 的 action 不付这笔钱。

rustc 也会用 dladdr 找 librustc_driver,偶尔报 Failed finding sysroot,属于同一类问题。但它的库为了性能保持共享,发生率在万分之一的量级,重跑即过,我们有意暂时不修。

上游。 根因在 NativeLink 的 macOS 输入物化。我们向上游报了 TraceMachina/nativelink#3137,修复提交为 #3138(撰写本文时仍是草稿)。合入之前,我们的 worker 跑的是 1.8.0 加这两个提交。配置里显式写出的 private_inode_libraries 还顺带充当了一道保险:不带补丁的 NativeLink 不认识这个字段,会拒绝启动,而不是带着共享 inode 悄无声息地跑起来。

验收。 部署后,2,700 个 JVM 探针分布在 8 台 worker 上,0 错位;Python 探针在 libpython 加进白名单前有 458 / 2,700 错位,加了之后为 0。随后的强制全量验收里,Android 9,144 个、iOS 6,950 个远程 action,唯一的失败就是一次上面说的 rustc 偶发错误,当天全池 20,327 次 rustc 调用里出现了 2 次。Android 从这一天起也交给了池子。

远程执行是一次对封闭性的全面审计。在一台机器上“恰好能用”的那些关于路径、环境变量和文件系统的假设,到了池子的规模,会以千分之几的概率随机失败。这类问题靠猜是猜不出来的:先写探针把失败率量出来,修完再用同一个探针证明它归零。

主干开发:让 CI 成本跟着改动走

构建压力的另一头在 CI。Agent 让 MR 的数量随并发数线性增长:每个 MR 都跑全量,runner 先撑不住;跑得太少,主干会被打红。而主干一红,所有正在 rebase 的 agent 都会被一起卡住,它们的流水线都会继承这次破坏。

我们的主干开发规则很简单:所有改动都从主干拉出短命的分支,在 MR 里通过自动化检查后合回主干;发布分支由发布系统统一管理,普通的推送不会在上面触发流水线。难点在于“通过哪些检查”。我们的答案是让 Bazel 自己回答:CI 的第一个 job 运行一个用 Rust 写的影响面分析器,它读出 MR 改了哪些文件,在构建图上求出受影响的目标,再生成一条只包含这些目标的子流水线。

从 worktree 到主干:本机改代码、构建、提交;CI 里做影响面分析并只跑受影响的测试,失败则修复后重新推送;两层流水线都给出绿色证据后合入;合入后主干流水线构建受影响的产品,每晚补跑全部 UI 测试。

图 3:从 worktree 到主干。每个 MR 只跑它影响到的测试,产品构建在合入之后进行,每晚补跑全部 UI 测试。

改动的文件按“它怎样改变构建图”分类,各有各的回答方式:

改动怎么求影响面
源文件源文件 → 所属 target → 反向依赖(rdeps)
BUILD / .bzl在 base 和 head 上各查一次每条 rule 的属性哈希与实现哈希(bazel query --proto:include_synthetic_attribute_hash),只有真正变了的 rule 进 rdeps
外部依赖:MODULE 片段、锁文件、go.mod、Maven 锁、补丁比较 base 与 head 上 bazel mod show_repo 解析出的外部仓库定义,只把定义变了的仓库在图里的消费者交给 rdeps
构建配置:.bazelrc、平台、toolchain整张图
分析本身出错整张图,原因写进日志

最后一行是一条原则:查不出来,不等于没影响。 分析失败只能多花机器时间,不能把漏跑伪装成跑过。

在此之上还有三条策略:

  • MR 只跑测试,不构建产品。 iOS 和 Android 的 Debug / Release 构建放在合入之后的主干流水线上。哪次合入把哪个产品构建坏了,红的就是那次合入的主干流水线,归因是精确的。
  • UI 测试按“所有权 + 哨兵”挑选。 MR 只跑它改到的那些产品的 UI 测试,外加 4 个哨兵:按“改动波及了它的依赖闭包里多少个目标”排序取前 4。其余的 UI 测试交给每晚的全量任务。夜间任务列出 UI 测试全集的那个查询必须完整,有包加载失败或一个测试都没找到,它就直接失败,而不是少跑。
  • 同一个 MR 推了新提交,旧流水线立刻取消。 只有最新提交的结果能决定能不能合。

这些规则都不是拍脑袋定的。我们统计了 9 天(2026-10-26 至 11-03)里 runner 时间都花在了哪:

浪费占比对策
MR 被新提交取代后,旧流水线仍在跑MR runner 分钟的 17%(9 万里的 1.55 万)新提交自动取消旧流水线
改一个外部依赖就判“整张图”:删一个 Go 依赖、加一个 Maven 坐标,所有产品和近 3,000 个测试全部重跑MR runner 分钟的 29%比较外部仓库定义,只重跑消费者
只因共享的基础代码被波及而跑的 UI 测试,失败几乎全是 flake 或所有产品同时出现的全局故障MR UI 测试分钟的 84%(2.8 万里的 2.34 万)自己的 UI 测试 + 4 个哨兵,其余交给夜间

BUILD 文件的处理也是同样的思路。以前改了 BUILD 就判整个包、改了 .bzl 就判所有 load 它的包。一个被每个产品都 load 的 registry.bzl,意味着登记一个新产品就要重跑 24 个产品。改成 rule 级别的哈希比较之后,只有真正变了的 rule 才会触发重跑。同一类问题在产品声明一侧的解法,见二十三个产品,一个爆炸半径。

最后一环在 agent 这边

我们给 agent 的合并流程是一个固定的技能(skill):提交,rebase 到最新的主干,推送,创建 MR,等待流水线,拿到“全绿”的证据之后才合并。

“等待流水线”这一步,我们踩过一个很有代表性的坑。我们的流水线有两层:父流水线跑静态检查,生成的子流水线跑受影响的测试。有一次,读取子流水线 job 列表的 API 瞬时返回了空,脚本把“读不到”当成了“没有 job、全部完成”,而父流水线恰好是绿的。于是它对一条后来失败了的流水线发起了合并。

修复后的规则是:绿色需要证据,从来不是默认值。 某一层这一轮没读全,就是“未知”而不是“空”;只要触发子流水线的那个 bridge job 还在跑,子层就没有结束,不管它的 job 列表此刻显示什么。

这和影响面分析里的那条原则是同一件事:在自动化系统里,“不知道”必须失败关闭(fail closed)。 人看到一个奇怪的“0/0”会起疑心,agent 不会。反过来也一样,不该从缺失的证据里造出一个红,见谁跑的测试,谁说了算。

代价,以及还没解决的问题

这套架构不是免费的。下面是我们目前清楚知道的代价和缺口:

  • 没有本地回退。 执行器连不上,--config=rbe 的构建直接失败。所以它是按次启用的;池子在办公室的局域网里,离开局域网要另走 Tailscale 这类通道。
  • 容量是共享的,还和 CI 混部。 46 个槽位由所有开发机和 agent 共享,CI 作业同时在这几台机器上本地运行。一次批量冷构建能把池子占满几分钟到几十分钟。
  • 信任边界是局域网。 池子的 gRPC 端口没有认证,任何连得上的客户端都能写 Action Cache。“本地结果不上传”目前是客户端的约定,不是服务端的强制。
  • 两套缓存还没打通。 CI 用的远程缓存(bazel-remote)和 NativeLink 的 CAS 互不相通。
  • 测试仍在本机。 编译搬走之后,iOS 模拟器成了本机最大的负载。写这篇文章时,3 台已启动的模拟器的进程合计约 36 GB 常驻内存(RSS,含共享页)。
  • 还有几个尾巴。 rustc 的偶发 sysroot 错误;一台 worker 和调度器之间的连接反复断开,原因还在排查;冷构建的关键路径被一个约 9 分钟的 Rust 构建脚本钉住;JDK 目前作为输入随每个 action 发送,前面那 0.28 秒就是它的代价。

可以带走的六条经验

  1. Agent 是负载发生器,要按“N 个并发构建”来设计。 为单人优化的构建配置,在 N 个 agent 下会结构性地超卖。只有把执行交给一个看得见所有客户端的调度器,超卖才会变成排队。
  2. 只搬走“重、且在哪跑都一样”的那部分。 Agent 进程、构建分析和绑定设备的测试留在本机,确定性的动作执行交给集群。先把负载按性质分清楚,比任何调参都有效。
  3. 封闭性是入场券,远程执行是审计。 每一个不在缓存键里的环境变量、每一个靠“自己在哪”找同伴的程序,都是一个潜伏的错误。用探针把失败率量出来,修完再用同一个探针证明它归零。
  4. 给 agent 的不变量要机器可检查。 Agent 会做局部最优,比如另开一个 output base 绕开锁。规则要写在 agent 会读的地方,也要写在 CI 会查的地方。
  5. CI 成本要跟改动成正比,而不是跟仓库成正比。 先量清楚 runner 时间花在了哪,再从最大的那块下手。我们的前两大浪费,分别占了 MR runner 时间的 17% 和 29%。
  6. 失败关闭:未知不是空。 影响面查询失败就跑整张图,UI 测试全集查不完整就让夜间任务失败,流水线状态读不全就不判绿。人会对异常起疑,agent 不会。

下一步

  • 把 CI 的远程缓存和 RBE 的 CAS 合成一份,让 CI 编过的东西 agent 直接命中,反过来也一样;
  • 接入 Linux worker,把 Go、TypeScript 和一部分 Rust 的 action 分出去(届时调度属性 OSFamily 要从优先级匹配改为精确匹配,否则拦不住 Apple 的 action 被派到 Linux 上);
  • 给池子加上认证,并按 instance 隔离写入;
  • 把调度器的配置也纳入仓库,像 worker 一样由仓库渲染;
  • 像 Xcode 一样把 JDK 预装到 worker 上,省掉每个 JVM action 的签名校验开销。

构建系统的用户画像变了

回头看,这件事最大的认知转变是:构建系统的用户画像变了。 它服务的不再是一个会等待、会思考、一次只做一件事的人,而是一群并发、不知疲倦、还会做局部最优的机器开发者。Bazel 的封闭性和内容寻址,在人的时代是“让构建更快、更可靠”的工程美德;到了 agent 的时代,它们成了“并行能不能跑起来”的基础设施前提。

现在,我们的 agent 主机只做它最该做的事:协调,以及在本地设备上验证。思考交给模型,编译交给池子,正确性交给主干上的影响面分析。“agent 开到五六个就该停手”的那条经验,正在被我们忘掉。

感谢 Trace Machina 和 NativeLink 社区。

继续阅读