flowchart TB
Q["一次改动<br/>需要被判断的事情"]
Q --> A["答案唯一,可被程序确认"]
Q --> B["答案取决于判断"]
A --> A1["这个文件放对位置了吗?<br/>依赖方向合法吗?<br/>这个字符串该硬编码吗?"]
B --> B1["这个需求要解决什么问题?<br/>两个方案怎么取舍?<br/>这个不可逆的动作该发生吗?"]
A1 --> M["交给机器<br/>快、一致、不疲劳"]
B1 --> H["留给人<br/>注意力是稀缺资源"]
style M fill:#e8f4e8
style H fill:#fdf3e3
1 两堵墙
我在这个仓库上把 Agent 的并行度推上去之后,连着撞了两堵墙。第一堵是人审 不过来,第二堵是机器也审不过来。两次我都想过同一个省事的办法 —— 让 Agent 少产出一点 —— 而两次我都没有那么做,因为那是在拿一个可以被工程化的问题, 换一笔要一直付下去的税。真正管用的解法都在另一侧:把「判定」这件事扩容、 机械化,最后再让它只做必要的那部分。
先把两堵墙各自长什么样说清楚。它们的形状其实是一样的,只是撞上去的那一刻 看起来完全不像 —— 第一次你看到的是人在抱怨,第二次你看到的是流水线在排队。
1.1 墙一:人审不过来了
最先撑不住的是人。取样的那个月里 Agent 合并了两百九十一次,平均每天有十份改动 排队等人看。这个数字本身不算离谱,离谱的是它的另一半:人的逐行阅读速度不会 因为 Agent 的出现而变快,注意力也不会随着队列变长而增加。产出这一侧翻了几倍, 消化这一侧一点没动。
审查队列一旦变长,结果只有两种。要么让代码在队列里等着,于是 Agent 的产出 变成了库存;要么在疲惫的时候快速点通过,于是审查变成了一道手续。两种都不是 质量,但它们在指标上的表现截然不同 —— 这才是麻烦的地方。
第二种值得多看一眼。它不会表现为质量下降,恰恰相反,它表现为审查通过率 上升、平均审查时长下降、队列长度回落。所有你会盯的数字都在变好,唯独没有 人真的看过代码。我在后面会反复回到这个形状:一个失效的检查,看起来和一个 通过的检查一模一样,而你从输出上区分不出来。
1.1.1 那个当时看起来很激进的决定
想清楚这一点之后,我做了一个当时看起来挺激进的决定:不再用人做机械性的代码审查。
格式、编译、测试、依赖方向、生成物来源、危险配置 —— 这些事情都有明确答案, 交给机器判定不但更快,标准还更一致。人腾出来的注意力,留给那些机器判断不了 的问题。
这个决定的关键不在”不用人”,在”有明确答案的”这五个字。它划的是这样一条线:
把第一类交给机器不是妥协,是升级。因为人在这类判断上本来就不可靠: 一个人连续看三十个 diff 之后,他对”这条依赖方向对不对”的判断准确率会掉到 什么程度,没有人愿意去测量 —— 而正因为没人测量,这个下降不会出现在任何指标上。
1.1.2 机器判定要吃算力
判定交给机器之后,紧接着的问题是机器从哪来。我开始买机器。
现在机柜里有三台虚拟化服务器、两台 NAS、九台 Mac Mini,外加一个硬盘柜。 九台 Mac Mini 里有八台组成了 CI 的执行池,而这个池子的并发数不是统一的, 是按每台机器的内存配额分档的:五台小内存的各跑三个并发,三台大内存的各跑九个, 加起来四十二路并发的 macOS CI。
这里已经埋了一个后面要用到的观察:四十二是配额之和,不是实测吞吐。这两个 数字之间的差距,就是第二堵墙所在的位置。
1.2 墙二:机器也审不过来了
买完机器没过多久,第二堵墙就来了。产出速率又一次越过了判定速率, 每条改动都跑全量检查时,流水线开始排队,改动在队列里堆积。瓶颈只是从 人的注意力换成了机时,问题的形状一点没变。
这一次我没有继续加机器。我让机器自己决定该审什么:拿到一次改动之后, 构建系统先去查反向依赖图,只为真正受影响的目标生成这一次的流水线, 测试再按每片若干个目标并行下发。没被波及的部分根本不会跑。
从表面看这只是”省算力”,但它真正改变的东西不在那里。加机器是在放大分子, 而查依赖图是在缩小分母 —— 它把测量从”整个仓库的状态”收缩到了”本次真正 变化的那个子空间”。章节 17 会说明这两者为什么不是程度差异, 而是性质差异:一个在扩大带宽,一个在减少需要被带宽承载的东西。
flowchart LR
subgraph W1["墙一:判定的执行者不够"]
direction TB
A1[Agent 产出] --> R1[人逐行审]
R1 --> Q1[(队列爆炸)]
end
subgraph W2["墙二:判定的范围没缩小"]
direction TB
A2[Agent 产出] --> R2[机器跑全量检查]
R2 --> Q2[(队列爆炸)]
end
subgraph OK["解:稀疏测量"]
direction TB
A3[Agent 产出] --> D[查反向依赖图]
D --> R3[只跑受影响的目标]
R3 --> M[合并]
end
W1 --> W2 --> OK
style Q1 fill:#f8d7da
style Q2 fill:#f8d7da
style M fill:#e8f4e8
1.3 两堵墙的位置可以粗算出来
这两堵墙不是模糊的感受,它们各自的位置能被算个大概, 而算一遍会发现它们比想象中近得多。
墙一的位置就是一个人的阅读速度。 一份不平凡的改动, 真正逐行读懂并给出有意义的意见,通常要几十分钟 —— 不是扫一眼点通过的那种读,是能发现”这条依赖方向不对”的那种读。 按每份三四十分钟算,一个专职的审查者一天能处理十份左右, 而这已经假设他一整天不做别的、不开会、不写代码。
而那个月的实际队列是每天十份。 也就是说,墙一恰好撞在”一个人全职做审查”这条线上—— 不是接近,是刚好。这个巧合并不神秘: 它说明在此之前,这个仓库的产出速率一直被审查能力隐式地限制着, 而 Agent 只是把这条限制第一次顶到了明面上。
墙二的位置更微妙,因为它不是一个吞吐数字。 机柜里那四十二路并发看起来很多,但那是配额之和,不是实测吞吐 —— 一条流水线要跑多少个目标,取决于这次改动波及了多少东西, 而在”每条改动都跑全量”的模式下,这个数和改动大小无关, 它是一个常数:整个仓库。
于是墙二的算式变成了:产出速率乘以全量检查的成本, 除以并发数。分子里有一项在涨(产出速率), 另一项是常数且很大(全量检查),而分母是固定的。 买机器改的是分母,而它的增长是线性的、有成本的、有上限的; 查依赖图改的是分子里那个常数,而它能改几个数量级。
两堵墙之所以看起来像同一个问题的两次出现 —— 它们是同一个不等式的两侧被顶到极限, 而 章节 17 会给这个不等式一个正式的名字。
1.4 两堵墙之后
这两个决定的效果在流水线数据里能直接看到。取样的三十个合入改动里, 有八个在合并前至少被拦下过一次 —— 27%。它们平均每个跑 1.7 条流水线, 从红灯到最终转绿的中位时间是 1.4 小时,最快的一次六分钟。整个过程里 没有人做逐行审查。
被拦得最狠的两个都是大改动。一个是把远端文件系统与传输队列并入基础层, 红了四次,四个多小时后才转绿;另一个是把输入法收敛到自研引擎,同样红了四次, 跨了将近二十个小时。这两条如果排队等人审,光把 diff 读一遍的时间就不止于此 —— 而且读完之后,人能发现的问题大概率还没有那四轮流水线发现的多。
1.5 为什么”让 Agent 慢下来”是错的解法
面对排队,有一个显而易见的选项我没有选:少开几个 Agent。它确实能解决排队, 而且它是唯一一个不需要任何投入就能立刻见效的办法,所以我得说清楚为什么不选它。
不是因为它无效,是因为它把一个可以被解决的问题变成了一笔永久的税。判定这一侧 的产能是可以被工程化的:机器可以买,检查可以只跑受影响的部分,判定可以下沉 到便宜的那一层。这些投入都是一次性的,而收益是持续的。降低产出速率则相反 —— 它是一笔要一直付的成本,今天少开五个 Agent,明天还得少开五个,而且这个动作 不产生任何积累。两者的区别不在于哪个当下更快,在于哪个会随时间变好。
这个判断依赖一个前提,而这个前提必须被检验:判定这一侧的产能确实是可以被 工程化的。第二堵墙的存在说明这个前提不是无条件成立的 —— 买机器到某个点之后 就不再有效了,那时候需要换一种做法而不是加大剂量。小节 17.8 会说明, 在增益(Agent 的吞吐)与延迟(判定回到手里的时间)这个框架下 (小节 17.1),“降低产出速率”其实是三条正当出路之一, 而且当另外两条都走不通时,它比失稳要好。
1.6 这两堵墙其实是同一堵
到这里可以把话说破了。墙一是修正速率上去了而判定速率没有,于是队列爆炸; 墙二是修正速率又上去了、判定速率也上去了,但判定的对象没有缩小,于是队列 又爆炸。两次的形状完全一样:一侧的速率提高,另一侧的带宽没跟上。
两次的解法之所以不同,是因为瓶颈的性质不同。第一次是带宽绝对不足 —— 一个人一天读不了十份 diff,而且第二个审查者也帮不上忙,因为他们要看的是同一批 代码。第二次是测量冗余 —— 带宽已经够了,但大部分检查测的是没有变化的东西。 第一种要换执行者,第二种要缩范围,而把这两种搞混的代价是你会在错误的地方 一直加钱。
章节 17 会给这两件事一个统一的名字。在那之前,先记住这个形状: 每当你想通过”加更多”来解决一个排队问题,先问一句排队的是修正还是判定。 如果排队的是判定,那么加修正端的产能只会让队列更长 —— 而这恰恰是引入 Agent 时最自然的那个动作。
1.7 判定这一侧到底在做什么
在进入后面的章节之前,得把”判定”这个词拆开,因为它容易被读成”跑测试”。 它至少包含四类活动,而这四类的成本、时机和失效方式都不一样。
结构判定问的是这段代码放得对不对 —— 它在正确的目录里吗?依赖方向合法吗? 这个符号出现在了不该出现的地方吗?这类判定最便宜,因为它不需要把程序跑起来。
行为判定问的是跑起来之后发生了什么。它最贵,需要起环境、起数据库、 起模拟器,而且它是唯一能发现”逻辑错了”的一类。
不变量判定问的是这条路径背后的约定守住了吗。它也很便宜,因为它只扫本次 改动新增的那些行,而不是整个仓库。
元判定问的是上面三类本身可不可信 —— 这次测试真的跑了用例吗?这条规则 自己有没有坏?这个红灯是代码的错还是环境的错?
第四类是我的重点,也是最少被讨论的一类。原因在于前三类的失败会主动表现出来: 测试红了、检查报错了、构建挂了,总有一个信号找上门。第四类的失败是静默的 —— 一个坏掉的检查器,输出的是一片绿色。
flowchart TB
C[一次改动] --> S["结构判定<br/>放得对吗"]
C --> B["行为判定<br/>跑起来对吗"]
C --> I["不变量判定<br/>约定守住了吗"]
S --> M["元判定<br/>上面三类可信吗"]
B --> M
I --> M
M --> V{{"最终结论"}}
style M fill:#fdf3e3
style V fill:#e8f4e8
1.8 第三堵墙在哪
两堵墙都是”判定跟不上产出”。按 小节 1.6 那个框架, 可以预测第三堵墙的位置。
增益还在涨(Agent 数量、并行度)。这一次, 判定这一侧还能怎么扩容?
- 换执行者:已经做了(人 → 机器)
- 缩小测量范围:已经做了(全量 → 只测受影响的)
- 加带宽:一直在做,但有物理下界
三条路都走过了之后,剩下的是什么?
小节 17.6 会给出一个答案:回路延迟里最大的一块, 可能已经不在基础设施上了,而在”Agent 读懂失败并修好”这一段。
如果这个推测成立,那么第三堵墙的形态会是:
流水线不排队了,机器也够,但每个改动要跑更多轮才能合入。
它的解法不在基础设施上,在失败信息的质量上 —— 一个带着 owner、证据和下一步的失败, 和一个只说”不允许”的失败,中间差的是好几轮迭代。
这些后面的很多内容,回头看都是在为这堵墙做准备: 小节 13.4.4 那两个字段、小节 14.7 那四步、 小节 14.3.3 那个最小重跑集合 —— 它们全都在压缩同一段延迟。
1.9 常见的误解:这两堵墙不是”规模问题”
容易被读成”仓库大了才会有这些问题”。不是的。
两堵墙的触发条件是产出速率与判定速率的比值, 和仓库大小、团队人数都没有直接关系。
一个两个人的团队,如果两个人各开三个 Agent, 每天产出十几个改动,而判定仍然靠两个人互相审 —— 它已经撞上了第一堵墙,只是墙比较近,撞得比较轻。
这个观察有一个实际推论:这两堵墙会比大部分人预期的更早出现。
它们不是”等我们做大了再考虑”的问题, 而是在引入 Agent 的那一刻,比值就已经开始变了。
区别只在于,小团队撞墙时的表现比较温和: 不是流水线排队,而是”最近合并的东西好像没人仔细看过”。
这两种表现,本质上是同一件事。
书里会反复出现”三十个改动的样本”“五百次失败的样本”这类窗口。 它们都是真实的,但样本量不大,当趋势看没问题,当结论用会偏薄。 凡是我知道样本不够的地方,都会说明。
1.10 这三个数一年后该变成什么
这一章给的三个数 —— 27% 的拦截率、1.7 条流水线、1.4 小时转绿 —— 它们的目标值是什么?
这是一个值得问的问题,因为没有目标值的指标会被当成”越高越好”或”越低越好”, 而这三个都不是。
拦截率:不该趋近于零。趋近于零意味着检查太松, 或者 Agent 已经学会了绕过。一个健康的值是稳定在某个区间, 而它的变化比它的值更有信息量。
流水线次数:应该缓慢下降 —— 随着规则的失败信息变好、 随着更多规则被前馈化 —— 也就是从「事后检查」变成 「事前就走不到错的路上去」(小节 5.4 · 小节 15.8)。 如果它在上升,那是第三堵墙的信号(小节 1.8)。
转绿时间:这个数由两部分组成,而它们该往不同方向走。 流水线耗时应该随稀疏测量的改进而下降; 而”Agent 理解并修正”的那部分取决于失败信息的质量。
三个数里,只有第二个有明确的方向。 另外两个是区间指标, 而区间指标最容易被误用 —— 因为它们没法被”优化”, 只能被观察。
这本身也是一条一般的经验:一个不知道目标值的指标, 不要放进任何人的考核里。它会被优化到一个你没想要的方向。
1.11 两堵墙之前发生了什么
有一件事容易被跳过,但它决定了你该不该读下去:在撞第一堵墙之前, 有相当长一段时间是没有这些问题的。
那时候 Agent 的产出还不多,我一个人审得过来,所有的规矩都在我脑子里 和评审的对话里。那段时间的做法是完全正确的 —— 在那个产出速率下, 建一套判定系统是不折不扣的过度工程。你会花几周建一个当时用不上的东西, 然后一直维护它。
我后面写的所有东西,都是被产出速率逼出来的,而不是因为它们在抽象意义上 “更好”。这个区别有一个很实际的推论:如果你现在没有撞墙,那么这些内容 对你的价值主要是”知道墙在哪”,而不是”现在就把墙拆了”。
小节 1.9 会说明这两堵墙会比大部分人预期的更早出现。但它还没到的时候, 提前建墙的成本是真实的,而收益是零。唯一值得提前做的是那种成本极低的 —— 比如给每条规则写下它的失败形态、比如把退出码分成三种。这些即使在撞墙之前 也不亏,因为它们不需要维护。
1.12 两堵墙的另一个用处:当筛子
我没有从理念开始讲,而是从两个具体的排队现象开始,理由有两条。
第一条是这两堵墙可以被识别,而理念不能。“我们需要确定性的基建”这句话, 没有任何一个团队能判断它现在适不适用;而”我们的审查队列在变长”可以被观察, “流水线在排队”可以被观察。所以入口是两个症状,不是一个主张。
第二条是这两堵墙决定了后面所有内容的必要性。如果你没有撞上它们,那么后面的 大部分内容对你来说都是过度工程(小节 1.11),而我宁可你现在不建, 也不希望你建一套还不需要的东西然后一直维护它。这两堵墙的实际功能是一个筛子, 它让你判断自己在不在后面那些东西的适用范围里。
1.13 两堵墙之外的第三种可能
有一种情况这一章没讲,但它在实践中很常见:
产出速率没有变,但质量下降了。
这不是这两堵墙里的任何一堵 —— 队列没有变长, 流水线没有排队,只是合并进去的东西变差了。
它的成因通常是:判定的形式还在,实质没了。
具体表现:评审仍然在做,但评审的人已经不逐行看了; 测试仍然在跑,但新加的测试没有断言; 检查仍然是绿的,但没有人问它检查了什么。
这是形状 A 在流程上的形态,而它比排队更危险, 因为排队是可见的,而这个不是。
识别它的方式:不看队列长度, 看”从提交到合并”这个过程里,有多少个环节 会因为提交者的态度不同而给出不同的结果。 那些环节就是实质正在流失的地方。
我讲的所有机制,共同的性质是: 它们的结果不取决于任何人的态度。
1.14 两堵墙都不是被”预见”到的
补一句:这两堵墙,我一堵都没有提前想到。
墙一是在队列已经堆起来、我连续几天在下班前赶着看 diff 的时候 才意识到的。意识到它的那一刻并不是一个分析的结果, 是一个体感 —— “这样下去不行”比”审查吞吐是 O(1) 而产出是 O(n)” 早到了大概两周。那两周里我做的是加班,不是设计。
墙二更隐蔽。它的表现是流水线开始变慢, 而变慢的第一反应永远是”机器不够”—— 而且这个反应是对的, 加机器确实缓解了一段时间。它错在它是一个会耗尽的解法, 而这一点在缓解生效的那几周里完全看不出来。
我把这一点写出来,是因为它对读者有一个直接的用处: 如果你现在没有撞上这两堵墙,你大概率也预见不到它们。 这不是能力问题 —— 一个系统的瓶颈在被顶到之前, 它和别的环节看起来没有任何区别。
所以这一章的实际建议不是”提前避开这两堵墙”, 是提前把那三个数量起来(小节 17.7)。 量了之后,墙一和墙二会在撞上之前几周表现为一条上升的曲线, 而那条曲线是你唯一可能提前看到的东西。
这也是我唯一能说”预见”是可能的地方: 不是预见问题的形状,是预见某个量正在接近它的极限。
1.15 压成三句话
一、两堵墙的形状是一样的:一侧的速率提高了,另一侧的带宽没跟上。 两次的解法不同 —— 一次是换判定的执行者, 一次是缩小判定的范围 —— 是因为两次的瓶颈性质不同: 一次是带宽绝对不足,一次是测量冗余(小节 1.6)。
二、“加更多”解决不了排队,先问排队的是修正还是判定。 如果排队的是判定,加修正端的产能只会让队列更长 —— 而这恰恰是引入 Agent 时最自然的动作。
三、判定的第四类(元判定)最容易被漏掉。 前三类(结构、行为、不变量)的失败会主动表现出来, 而”这些判定本身可不可信”的失败是静默的 (小节 1.7)。
1.16 先说明一件事
这些后面会反复回到这一章的三个数 (27% 拦截率、1.7 条流水线、1.4 小时转绿), 而每一次都会从一个新的角度重读它们。
这不是重复,是因为同一组数在不同的框架下说的不是同一件事:
| 在哪章 | 这三个数说明什么 |
|---|---|
| 本章 | 判定在工作 |
| 小节 21.1.1 | 它们全是过程指标,没有一个是结果指标 |
| 小节 17.6 | 1.4 小时里大部分不是在等机器 |
| 小节 17.7 | 它们的趋势比值更有信息量 |
四种读法,而第二种是最重要的那个 —— 因为它指出了我这条证据链上最大的一处缺失。