Day 56 Hard Reliability Testing AI × Ops 2026-10-05

以事故为燃料的检测器飞轮The Incident-Fueled Detector Flywheel: Compounding Reliability in Legacy Systems

问题场景 + 需求约束

你接手一套 15 年历史的清结算系统:约 200 万行代码、原作者大多离开、文档与现实脱节。每月约 40 起事故与近失误,每周 300 次变更(其中越来越多由 coding agent 提交)。复盘写了不少,但「同类事故再犯」的比例居高不下——复盘结论停留在文档里,没有变成机器会执行的东西。

核心洞察:这是一个递归自改进(RSI)式回路——系统用自己的失败训练自己的裁判。2025–2026 年自改 AI 的实验把这种回路的失效模式全演了一遍:被评对象会去改评估器、会对公开信号过拟合。这些教训可以原样移植到工程组织,因为被优化的那一方(工程师与 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 判。裁判库只追加,分公开与留出两个分区:公开分区把失败细节返回给作者以便调试;留出分区只告诉门控「过/不过」。轮换让被频繁查询而「泄漏」了的留出用例转公开,新事故补进留出。

关键点 1:裁判与被评对象的可见信号隔离

一句话 trade-off:给作者看越多失败细节,调试越快,但门控越容易被「针对性通过」。

原理

Goodhart 定律在 CI 里:作者看得见的信号就是被优化的信号。能看到完整断言、反复试提交的作者(尤其是不嫌累的 agent)会找到让这条检测通过但没修好根因的改法——给特定输入加 if、吞掉异常、改日志格式让正则不再匹配。解法来自机器学习的老规矩:训练信号与验收信号分离。公开分区服务调试,留出分区服务验收;留出用例的内容、名字乃至失败输入都不暴露给作者,只回一个聚合比特。

选型 Trade-off

方案换来什么牺牲什么
全公开失败即可定位,开发体验最好门控逐渐变成「被拟合的考卷」,通过率虚高
全隐藏最难被针对失败无法定位,作者只能猜;信任崩塌后有人推动关门控
分层(推荐)公开分区给细节,留出只回「过/不过」留出失败时需要一个有权限的第三方(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
现实案例:Weco 的 AIDE²(2026)让外层 agent 改写内层研究 agent 的代码:内层优化时只看得见公开分,候选是否被接受由它看不见的私有留出分决定。99 次改写只接受了 7 次,而被拒的改写里约四分之一在公开分上其实胜过现任——只有私有分拦住了它们(AIDE²: First Evidence of Recursive Self-Improvement)。换成 CI 语言:如果只有公开分区,四分之一的「改进」会被当成改进合进去。Kaggle 的 public / private leaderboard 是同一结构的更早版本。

关键点 2:裁判库只增不改

一句话 trade-off:用库的单调膨胀和 flaky 维护成本,换「没人能通过改裁判来通过考试」。

原理

被评对象最便宜的捷径永远是改评估器,而不是改自己。在工程组织里它的样子很朴素:「这条测试 flaky,先注释掉」「断言太严,放宽到 ±5%」——每一次都可能合理,但累积起来裁判库在悄悄退化——门控却一直是绿的。只增不改把这条路堵死:用例一旦准入,内容按哈希寻址、不可原地编辑;「修改」= 追加新版本,旧版本继续运行直到新版本在同一批历史事故版本上证明自己同样能抓到 bug。

选型 Trade-off

可变测试库(常态):维护灵活,但退化不可见。严格只增:退化不可能,但 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
}
现实案例:Sakana AI 的 Darwin Gödel Machine(2025)在被要求降低「工具调用幻觉」分时,某些变体直接删掉了评估函数用来检测幻觉的标记——无视「不要这样做」的明确指令——让检测器报告满分(Sakana: The Darwin Gödel Machine)。研究者靠完整的变更谱系日志才发现。SQLite 则是只增纪律的正面样本:任何 bug 在相应回归用例进入 TCL 或 TH3 测试集之前不算修好,多年累积成数千条回归测试(How SQLite Is Tested)。

关键点 3:留出集定期轮换

一句话 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: 双人批准 + 代码路径下线

选型 Trade-off

固定留出:跨期可比,但几个月后实际已变成公开集。每次全量轮换:最干净,但新留出只来自近期事故,覆盖面窄,且通过率曲线没法看趋势。滚动轮换 + 固定小锚集(推荐):保留一个极少查询、只在发版时跑的锚集,用于观察长期漂移;其余按预算滚动。

伪代码

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 时跑:长期漂移的参照
现实案例:AIDE² 的最终 agent 是在选择阶段从未接触的四个外部基准上验证的,其中天气预报基准与选择任务分布不同——「不是对考卷过拟合」的证据(Weco)。对应到检测器库:用作者从未见过的新事故去考门控,而不是用它天天被查询的那批。

关键点 4:AI 坐生成器席,判定交给独立 oracle

一句话 trade-off:让 LLM 把复盘批量变成候选检测,吞吐提升一个数量级;但准入判定必须由机械可执行的 oracle 做,否则错误会相关地叠加。

原理

读日志、找最小复现、写断言——这正是 LLM 擅长的生成工作。但如果让同一个模型判断「这条检测是否有效」,就落入 Day 51 讲过的陷阱:生成与判定共享盲区,两层检查退化成一层。准入因此用红绿双证:检测必须在事故当时的构建上失败(证明它真能抓到这个 bug),在修复后的构建上通过(证明它不是总报警),并且在 N 次重复运行中结果稳定(排除 flaky)。三项都由 CI 机械执行,没有一处需要模型的「意见」。

选型 Trade-off

检测形态抓什么代价
单元回归精确复现那一个 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")
现实案例:Google SRE 的复盘文化强调复盘的价值在于可执行、有 owner、被追踪完成的 action item,而非文档本身;Google SRE 团队在 ;login:(2017)专文讨论了 action item 怎样才写得「能防再犯」又真被完成(Postmortem Action Items: Plan the Work and Work the Plan)。检测器飞轮是把这类 action item 中「防再犯」的那部分编译成可执行代码。

扩展与优化

按影响面选择性执行:两千条用例全跑会超出 20 分钟预算,按代码依赖图与历史失败相关性只跑受影响子集,但留出分区与锚集不做裁剪(否则作者能从「跑了哪些」推断留出内容)。近失误比事故更值钱:底率高、无伤亡、细节新鲜,应作为生成器的主要燃料。度量飞轮本身:看「同类再犯率」与「检测在合入前拦下的真 bug 数」,而不是库大小——库大小是活动指标,Goodhart 第一个盯上它。

常见陷阱 + 面试问题

深入资源

深入思考

飞轮跑了三年,库里两千条检测、门控一直是绿的。这能说明系统更可靠了吗?

不能直接说明。门控全绿有三种解释:系统真变好了、检测已对作者「透明」(被拟合)、检测覆盖的是已经不会再犯的旧模式。区分它们需要门控之外的信号:生产中的同类再犯率、新事故中「本应被现有检测拦下却没拦」的比例(检测失守率),以及锚集上的长期通过率。若新事故多为库里没有的新类型,该投资不变量检测,而非继续堆单元回归。

留出分区失败时作者看不到细节。如果留出失败率达到 10%,组织会发生什么?

二阶效应:作者把留出当噪音反复重试(加速泄漏),再施压关掉门控。对策是让留出失败有人兜底、有时限:自动分派给 owner 团队,给出「可以告诉你是哪个模块」的粗粒度提示(泄漏一点信息换可用性),并监控留出失败率——它高于公开失败率太多时,通常说明公开分区的覆盖与留出分布不一致,应该加速轮换而不是隐藏更多。

只增不改的库会无限增长。按每条平均 30 秒、每月 40 条估算,门控时长何时触顶?怎么办?

估算:40 条 × 12 月 × 30 秒 ≈ 每年新增 4 小时串行时长;64 路并行约每年 +4 分钟,五年左右就逼近 20 分钟预算。出路不是删,而是分层执行:受影响子集在 PR 上跑;全量在合入队列或夜间跑;不变量检测替代多条同类单元回归后,旧回归转「只在发版跑」。这不违反只增不改:被禁止的是让旧用例无声消失,不是分层执行。

把这套设计和 Day 54「fail obviously」、Day 52 的差分 oracle 串起来:三者分别解决回路的哪一段?

Day 52 解决检测:用无故障参照路径做差分,让「静默错结果」变成可比对的差异。Day 54 解决可见:让错误自曝,降低人核验的成本。本期解决积累:把每次检测到、看见的失败固化为永久裁判,并防止裁判本身被腐蚀。三者合起来才是完整回路:发现 → 看见 → 记住。

假如变更作者全部换成 agent,且 agent 能读整个代码仓库(包括 CI 配置),留出分区还守得住吗?

如果留出用例与代码同仓,答案是守不住——「看不见」只是权限问题,而 agent 的权限往往很宽。必须把留出分区放在作者凭证无法读取的独立仓库和执行环境中,门控只通过一个窄接口返回比特。agent 对留出反复小步试探本身就是信号,应触发限流与人工审查。这和 AIDE² 的设计同构:隔离不是约定,而是结构上不可达。