你接手一套 15 年历史的清结算系统:约 200 万行代码、原作者大多离开、文档与现实脱节。每月约 40 起事故与近失误,每周 300 次变更(其中越来越多由 coding agent 提交)。复盘写了不少,但「同类事故再犯」的比例居高不下——复盘结论停留在文档里,没有变成机器会执行的东西。
graph LR
I["事故 / 近失误
复盘"] --> G["AI 生成器
候选检测 + 复现脚本"]
G --> O{"独立 oracle
红绿双证"}
O -- "旧版本红 新版本绿" --> L[("裁判库
append-only
内容寻址 + 签名")]
O -- "不能复现" --> X["退回人工"]
L --> P["公开分区
失败细节可见"]
L --> H["留出分区
只回一个比特"]
C["变更
人 / agent"] --> GATE{"CI 门控"}
P --> GATE
H --> GATE
GATE -- "通过" --> M["合入"]
M -. "新事故" .-> I
H -. "每季度轮换
耗尽的留出转公开" .-> P
生成器(LLM)读复盘、日志与 diff,产出候选检测与最小复现脚本,量大但不可信。独立 oracle 做准入:候选必须在事故版本上失败、修复版本上通过,由 CI 机械执行,不让 LLM 判。裁判库只追加,分公开与留出两个分区:公开分区把失败细节返回给作者以便调试;留出分区只告诉门控「过/不过」。轮换让被频繁查询而「泄漏」了的留出用例转公开,新事故补进留出。
一句话 trade-off:给作者看越多失败细节,调试越快,但门控越容易被「针对性通过」。
Goodhart 定律在 CI 里:作者看得见的信号就是被优化的信号。能看到完整断言、反复试提交的作者(尤其是不嫌累的 agent)会找到让这条检测通过但没修好根因的改法——给特定输入加 if、吞掉异常、改日志格式让正则不再匹配。解法来自机器学习的老规矩:训练信号与验收信号分离。公开分区服务调试,留出分区服务验收;留出用例的内容、名字乃至失败输入都不暴露给作者,只回一个聚合比特。
| 方案 | 换来什么 | 牺牲什么 |
|---|---|---|
| 全公开 | 失败即可定位,开发体验最好 | 门控逐渐变成「被拟合的考卷」,通过率虚高 |
| 全隐藏 | 最难被针对 | 失败无法定位,作者只能猜;信任崩塌后有人推动关门控 |
| 分层(推荐) | 公开分区给细节,留出只回「过/不过」 | 留出失败时需要一个有权限的第三方(on-call 或 owner 团队)介入排查,增加人力路径 |
# 门控:公开分区回细节,留出分区只回一个比特(pseudo-code)
def gate(change):
pub = [d.run(change) for d in library.partition("public")]
hold = [d.run(change) for d in library.partition("holdout")]
report = {"public_failures": [r.detail for r in pub if not r.ok]}
hold_ok = all(r.ok for r in hold)
holdout_budget.charge(change.author) # 每次查询都在泄漏信息
if not hold_ok:
report["holdout"] = "FAIL" # 不给名字、不给输入
page_owner_team(change, [r for r in hold if not r.ok]) # 第三方看细节
return report, (not report["public_failures"]) and hold_ok
一句话 trade-off:用库的单调膨胀和 flaky 维护成本,换「没人能通过改裁判来通过考试」。
被评对象最便宜的捷径永远是改评估器,而不是改自己。在工程组织里它的样子很朴素:「这条测试 flaky,先注释掉」「断言太严,放宽到 ±5%」——每一次都可能合理,但累积起来裁判库在悄悄退化——门控却一直是绿的。只增不改把这条路堵死:用例一旦准入,内容按哈希寻址、不可原地编辑;「修改」= 追加新版本,旧版本继续运行直到新版本在同一批历史事故版本上证明自己同样能抓到 bug。
可变测试库(常态):维护灵活,但退化不可见。严格只增:退化不可能,但 flaky 用例会让门控随机变红,开发者信任被消耗。折中是隔离区(quarantine):flaky 用例移入隔离区后仍然运行并上报,只是不阻塞合入;隔离有 SLA(比如 14 天),到期未修复则升级到 owner 团队,而删除需要双人批准 + 证据(例如对应代码路径已下线)。核心不变量:没有任何一次 PR 能同时修改被测代码和判它的用例。
// 裁判库:内容寻址、只追加;「修改」= 新版本 + 旧版本并行(pseudo-code)
func (lib *Library) Supersede(oldID string, nd Detector, by Author) error {
if lib.TouchesCodeUnderTest(by.PendingChange) {
return ErrSameChangeEditsJudge // 改代码和改裁判不能在同一个变更里
}
// 新版本必须在旧版本抓到过的所有事故版本上同样变红
for _, bad := range lib.Get(oldID).CaughtBuilds {
if nd.Run(bad).OK { return ErrWeakerThanPredecessor }
}
lib.Append(nd.WithParent(oldID)) // 旧版本保留,直到新版本稳定 N 周
return nil
}
一句话 trade-off:留出集用得越久,「通过它」的含金量越低;轮换太勤,又会失去跨期可比的基准。
留出集并不是永远干净的。每次门控返回一个「过/不过」比特,都在向作者泄漏一点信息;一个 agent 对同一变更反复提交几十次,就是在对留出集做自适应查询。Dwork 等人 2015 年在 Science 上的「reusable holdout」工作正是讨论这件事:同一份留出数据被自适应地反复查询后,其验证结论会失效,需要对查询加噪或限预算。结论是:留出集有使用寿命。所以要对每个作者限查询预算,并每季度把「查够了」的留出用例转公开、用新事故补位。
stateDiagram-v2
state "候选" as cand
state "退回" as rej
state "留出" as hold
state "公开" as pub
state "隔离" as quar
state "退役" as ret
[*] --> cand: AI 生成
cand --> rej: 红绿双证失败
cand --> hold: 准入(新事故优先进留出)
hold --> pub: 查询预算耗尽 / 季度轮换
pub --> quar: 连续 flaky
hold --> quar: 连续 flaky
quar --> pub: SLA 内修复
quar --> ret: 双人批准 + 代码路径下线
固定留出:跨期可比,但几个月后实际已变成公开集。每次全量轮换:最干净,但新留出只来自近期事故,覆盖面窄,且通过率曲线没法看趋势。滚动轮换 + 固定小锚集(推荐):保留一个极少查询、只在发版时跑的锚集,用于观察长期漂移;其余按预算滚动。
def rotate(lib, quarter):
for d in lib.partition("holdout"):
if d.queries_since_admit > QUERY_BUDGET or d.age > ONE_YEAR:
lib.move(d, "public") # 耗尽的留出 → 公开,仍然门控
fresh = [d for d in lib.admitted_in(quarter) if d.partition == "public"]
for d in sample(fresh, k=HOLDOUT_TARGET - lib.size("holdout")):
lib.move(d, "holdout") # 新事故补位:作者还没见过
# anchor 集不参与轮换,只在 release 时跑:长期漂移的参照
一句话 trade-off:让 LLM 把复盘批量变成候选检测,吞吐提升一个数量级;但准入判定必须由机械可执行的 oracle 做,否则错误会相关地叠加。
读日志、找最小复现、写断言——这正是 LLM 擅长的生成工作。但如果让同一个模型判断「这条检测是否有效」,就落入 Day 51 讲过的陷阱:生成与判定共享盲区,两层检查退化成一层。准入因此用红绿双证:检测必须在事故当时的构建上失败(证明它真能抓到这个 bug),在修复后的构建上通过(证明它不是总报警),并且在 N 次重复运行中结果稳定(排除 flaky)。三项都由 CI 机械执行,没有一处需要模型的「意见」。
| 检测形态 | 抓什么 | 代价 |
|---|---|---|
| 单元回归 | 精确复现那一个 bug | 只防完全相同的再犯;改名重构就失效 |
| 流量回放 + 差分 | 同类输入的行为变化(承 Day 52) | 需要脱敏的生产流量与归一化层;运行慢 |
| 不变量 / property | 一类 bug(如「借贷必平衡」) | 写对很难,最需要人来审;最值钱 |
生成器的目标:一次事故产出一条精确回归 + 一个候选不变量,后者由人审核。
def admit(candidate, incident, runs=5):
bad, good = incident.buggy_build, incident.fixed_build
red = [candidate.run(bad).ok for _ in range(runs)]
green = [candidate.run(good).ok for _ in range(runs)]
if any(red): return Reject("抓不到原 bug") # 必须稳定变红
if not all(green): return Reject("误报或 flaky") # 必须稳定变绿
if candidate.kind == "invariant":
require_human_review(candidate) # 不变量 = 人工席
return library.append(candidate, partition="holdout")
按影响面选择性执行:两千条用例全跑会超出 20 分钟预算,按代码依赖图与历史失败相关性只跑受影响子集,但留出分区与锚集不做裁剪(否则作者能从「跑了哪些」推断留出内容)。近失误比事故更值钱:底率高、无伤亡、细节新鲜,应作为生成器的主要燃料。度量飞轮本身:看「同类再犯率」与「检测在合入前拦下的真 bug 数」,而不是库大小——库大小是活动指标,Goodhart 第一个盯上它。
不能直接说明。门控全绿有三种解释:系统真变好了、检测已对作者「透明」(被拟合)、检测覆盖的是已经不会再犯的旧模式。区分它们需要门控之外的信号:生产中的同类再犯率、新事故中「本应被现有检测拦下却没拦」的比例(检测失守率),以及锚集上的长期通过率。若新事故多为库里没有的新类型,该投资不变量检测,而非继续堆单元回归。
二阶效应:作者把留出当噪音反复重试(加速泄漏),再施压关掉门控。对策是让留出失败有人兜底、有时限:自动分派给 owner 团队,给出「可以告诉你是哪个模块」的粗粒度提示(泄漏一点信息换可用性),并监控留出失败率——它高于公开失败率太多时,通常说明公开分区的覆盖与留出分布不一致,应该加速轮换而不是隐藏更多。
估算:40 条 × 12 月 × 30 秒 ≈ 每年新增 4 小时串行时长;64 路并行约每年 +4 分钟,五年左右就逼近 20 分钟预算。出路不是删,而是分层执行:受影响子集在 PR 上跑;全量在合入队列或夜间跑;不变量检测替代多条同类单元回归后,旧回归转「只在发版跑」。这不违反只增不改:被禁止的是让旧用例无声消失,不是分层执行。
Day 52 解决检测:用无故障参照路径做差分,让「静默错结果」变成可比对的差异。Day 54 解决可见:让错误自曝,降低人核验的成本。本期解决积累:把每次检测到、看见的失败固化为永久裁判,并防止裁判本身被腐蚀。三者合起来才是完整回路:发现 → 看见 → 记住。
如果留出用例与代码同仓,答案是守不住——「看不见」只是权限问题,而 agent 的权限往往很宽。必须把留出分区放在作者凭证无法读取的独立仓库和执行环境中,门控只通过一个窄接口返回比特。agent 对留出反复小步试探本身就是信号,应触发限流与人工审查。这和 AIDE² 的设计同构:隔离不是约定,而是结构上不可达。