让 Agent 去修 flaky,它删掉了让测试不确定的变量

过去四天,我在 GitHub 上找到六个公开仓库,做着同一件事:把一个随机失败的门禁,处理成一个看起来稳定的门禁。

其中四个是 Agent 干的。有一个 Agent 的"修复",是把让测试结果会变的那一行 fixture 删掉了。

然后我自己跑了个实验:一个真实 flaky 率 40% 的门禁,套上"重试即通过"这个判据,报出来是 4% 。再换一组参数,真实 18.3%,报出来是 0% ------ 六十轮全绿。

这篇讲两件事:这六起分别在判据的哪一层失真,以及为什么"重试即通过"这个判据在任何 flaky 率下都不可信。

一、六起,四个坐标

下面每一条我都在 GitHub 上打开原文读过,不是转述。

一、councilkit #111,10-04,Cursor Agent 提交,当天合并

Playwright exits 0 when a test fails and then passes on retry. The reporter prints a summary line N flaky. interpretTestLog did not read that line, so evaluateVerificationAsset accepted the log as proof.

中文:一个测试先失败、重试后通过,Playwright 退出码是 0。reporter 打印了一行 N flaky。它没读那一行,于是把这份日志当成了通过证明。

失真位置在验证器读信号 这一层。退出码没有骗人,它如实说了 0。骗人的是「0 = 通过」这个假设 ------ 而 N flaky 这行才是真正的结论,reporter 印出来了,验证器没看。

二、Splotch #2668,10-04,Claude Code 提交,一小时内合并

GitHub 的 run: 步骤如果没有显式声明 shell: bash,跑的是 bash -e;只有声明了才有 -o pipefail。这个仓库十个 workflow 一个都没声明。

于是这样一行:

ini 复制代码
reproduced="$(node tools/perf/report-undo-gate-failures.mjs --first="$FIRST_FAILURES" | tr -d '\n')"

reporter 在遇到 TypeError 时会故意崩溃,退出码 1,stdout 为空 。没有 pipefail,赋值语句取的是 tr 的退出码 0。

结果这三步依次发生:

  • "Record the non-reproduction" 写下 "not reproduced ... no issue filed"
  • "Fail on a reproduced breach" 被跳过
  • 重试 job 变绿

崩溃被管道吃成了"没复现","没复现"被读成了"没问题"。

这个 PR 里还有一句我读了三遍的话:

No test executed the step. The tests that do execute workflow steps ran them under the pipefail argv, which is stricter than CI gave those steps, so the suite could not catch this class of bug.

测试比生产严格,所以这一类缺陷永远测不出来。 测试环境给的是 pipefail,线上给的是 -e;测试跑的是"更严格的那个世界",于是它永远看不到线上那个宽松世界的 bug。

三、growi #12003,10-02,Claude Code 开

标签打的是 flaky/confirmed,描述是 "Confirmed flaky test/job, ready for investigation"。依据写在正文里:

Kind: playwright (strong evidence: passed on in-run retry)
Confirmed flaky from a single run.

"passed on in-run retry" 被当作 strong evidence ,然后从单次运行推出"已确认"。

这是我最在意的一条。它和 councilkit #111 是同一个病,但更靠后一层:councilkit 是没读 到 N flaky 那行;growi 是读到了 ,然后把"重试后过了"当成了关于不稳定性的结论。

一次重试通过,能证明的只有一件事:这次过了。它证明不了 flaky 率是多少,因为按定义你没法从一次成功里估计失败概率。

四、datahub #20121,10-01,Cubic 自动生成,PR 至今 open

Removes the upstream lineage aspect from the test fixture to fix flakiness in siblings search Playwright test. Without this aspect, the fixture no longer varies search results depending on lineage state, so the test runs deterministically.

中文:把 fixture 里 upstream lineage 那一项去掉,flaky 就好了。去掉之后 fixture 不再随 lineage 状态改变搜索结果,所以测试跑起来确定了。

它没有修那个让结果会变的东西。它把那个让结果会变的东西删了。

这个 PR 至今 open,没合。被 AI"修"绿的那部分覆盖,现在等于不存在。

五、focus-planner #826,10-03,人写的,但说的是同一件事

Three intermittent failures so far. Each passed on a plain rerun, and none of the commits touched app code.
A gate that fails at random gets rerun on reflex and eventually ignored, which defeats the no-regression rule it exists for.
Ask: ... Prove the fix with 20 consecutive full-suite runs locally, and 3 CI reruns, all green.

这位维护者的判断完全正确:随机失败的门禁会被反射性重跑,最后被忽略,这正好废掉了它存在的意义。

但他提的验收方案里藏着一个问题,而且这个坑很常见:"20 次连续全绿" 和 "3 次 CI rerun 全绿" 不是一个量级的东西。 下面我来算。

六、AllTrue_System #3506,10-04

fails at a random viewport (1440 / 768 / 390) ... The other viewports pass in the same run, and a re-run of failed jobs passes. These specs run against the live site, not the PR build, so they are independent of the PR diff.
Until fixed, re-run failed jobs.

最后那句是写在 issue 里的正式策略:修不好就重跑。

注意这两个 issue 的一个共同点:测试跑的是线上站点 ,不是 PR 的构建产物。所以 flaky 跟代码改动无关,纯粹是环境抖。"改代码改不动它"这件事,和"重跑能让它变绿"这件事,会同时成立。

二、我自己跑的实验

上面都是别人的事。我想知道的是:"重试即通过"这个判据,到底能把数字改成什么样。

方法

一个最小的页面:打开之后,在 0 到 120 毫秒之间的某个随机时刻,把 #target 从 PLACEHOLDER 改成 READY。

"测试"的做法是:导航之后固定等 N 毫秒 ,然后读一次,不等。读到 READY 算过,读到 PLACEHOLDER 算失败。

这就是一个真实的 flaky ------ 不稳定来自时序竞态,不是随机数后门。

每个点跑 60 轮,每轮最多重试 2 次(也就是最多 3 次尝试)。同一条数据出两个数:

  • A:first-attempt ------ 每轮第一次就过的比例。这就是 Playwright 报 flaky 率的那个数。
  • B:after in-run retry ------ 任意一次过了整轮就算过。这就是 GitHub Actions rerun、也是"重跑一次就好"的那个判据。

两个数之差,就是被"重试即通过"吞掉的那部分不确定性。

结果

  • 固定等待 40ms · A 真实 flaky(首尝试) 61.7% · B 重试后报出 26.7% · 差 35.0 pp · 稀释倍数 2.3×
  • 固定等待 60ms · A 真实 flaky(首尝试) 50.0% · B 重试后报出 13.3% · 差 36.7 pp · 稀释倍数 3.8×
  • 固定等待 80ms · A 真实 flaky(首尝试) 25.0% · B 重试后报出 3.3% · 差 21.7 pp · 稀释倍数 7.5×
  • 固定等待 100ms · A 真实 flaky(首尝试) 18.3% · B 重试后报出 0.0% · 差 18.3 pp · 稀释倍数 ---
  • 固定等待 120ms · A 真实 flaky(首尝试) 0.0% · B 重试后报出 0.0% · 差 0.0 pp · 稀释倍数 ---

每点 60 轮,五点合计 300 轮。

看最后两行。

  • 100ms 那一组:真实 flaky 18.3% ,报出来 0%。60 轮全绿。
  • 120ms 那一组:真实 flaky 0% ,报出来也是 0%。60 轮全绿。

两个 flaky 率相差 18 个百分点的门禁,在判据眼里是同一个东西:一块全绿的板子。

一个是 60 轮里有 11 轮读到 PLACEHOLDER,一个是 60 轮全部一次过。判据给它们的输出逐字相同。

被抹掉的不是精度,是那个维度的全部信息。

再看第一行:真实 61.7%,报出来 26.7%。不是低估百分之几,是砍掉一半还多。

一个更干净的对照

只取 wait=80ms 这一组,60 轮,每轮最多 3 次尝试:

  • 45 轮第一次就过
  • 10 轮第二次才过
  • 5 轮三次全过

20 轮是靠重试救回来的。这 20 轮里,每一轮都是一个坏掉的门禁。

这 20 轮在报表上是绿的。绿色的来源是重试,不是修复。

三、为什么"重试三次全绿"几乎什么都没证明

设真实 flaky 率是 p,重试 k 次(总共 k+1 次尝试)之后被误判为通过的漏过率就是 p^(k+1)。

算一下:

  • p = 0.10,三次尝试:漏过率 0.1%
  • p = 0.20,三次尝试:漏过率 0.8%
  • p = 0.40,三次尝试:漏过率 6.4%
  • p = 0.60,三次尝试:漏过率 21.6%

所以"三次 rerun 全绿"这句话的信息量,完全取决于 p 有多大。而 p 恰恰是你想测的东西。

再看同一个 p=0.4,换成二十次连续首尝试全绿:

0.6^20 ≈ 3.7e-5。十万分之四。

"20 次全绿"和"3 次 rerun 全绿"在证据强度上差 1700 倍。 前者是三十九分,后者是接近零。

这不是"多点几次更保险"的问题。重试次数本身就是判据的一部分,而它对 p 的敏感度是非线性的 ------ 在 p 已经很高的区间,三次和一次几乎没有区别。

四、Agent 为什么特别容易踩这个坑

上面四条 Agent 的操作,根子是同一个:

它们拿到的输入,是"重试后通过了"这一个 bit。 它们看不到真实 flaky 率,因为那个数在信息里根本不存在 ------ 它从来没有被计算过。

于是每一步都走得通:

  1. 这个测试重试后过了 → 看起来稳定

  2. 看起来稳定 → 判据认为可以依赖

  3. 判据说可以依赖 → 不用去查真实失败率

第 1 步到第 3 步之间没有断点。每一步单独看都对,串起来是一个把不确定性当成确定性传递下去的闭环。

这也是为什么 datahub 那个 PR 是这套逻辑的终点形态:它不是在验证环节出错,它是为了让验证环节不出错,去改了被验证的东西。它把一个 61.7% 的 flaky 改成了 0% 的 flaky ------ 用的是最直接的办法:让被测行为变得确定。

而这恰恰是最危险的一种假成功 ,因为它不留任何痕迹:门禁是绿的,PR 描述完整,测试是过的,只有那份测试测的东西变少了这件事没人查。

五、判据该怎么换

这四条事故落在四个不同的地方,修法也不一样。但有一条是共同的。

1. flaky 率只认 first-attempt。

任何在重试之后统计出来的失败率,都不叫失败率,它叫"重试没救回来的失败率"。Playwright 报 N flaky 用的是首尝试口径,保持这个口径,别改。

2. 判据要读"有没有失败过",不是"最后过没过"。

councilkit #111 的修法就是这个:日志里只要有 N flaky 那一行,就算 exit 0 也判失败。判据问的是"这一轮里失败过吗",不是"最后是什么状态"。

3. 修 flaky 要改被测对象,不能改判据。

datahub 那个 PR 违反了这条。判据是量具,量具不准的时候你不会去改被测的东西让读数变好看 ------ 你会怀疑量具。

4. 修的门禁比生产严格,等于没修。

Splotch #2668 那句注释值得贴进每个 CI 模板:测试环境如果给的是 pipefail,线上给的是 -e,那么测试永远测不出线上的问题。测试环境的配置必须是线上配置的子集,不是超集。

5. 验收要 20 次首尝试全绿,不是 3 次 rerun。

这不是仪式感。p=0.4 时,0.6^20 ≈ 0.004%,0.6^3 ≈ 21.6%。前者是证据,后者是祈祷。

六、一个更麻烦的推论

写到这里我发现,这篇讲的东西其实是一个更大的问题的一个切面。

「重试即通过」不是唯一一个会系统性高估可靠性的判据。它们有同一个结构:

  • 只读终点状态,不读过程 (councilkit 没读 N flaky)
  • 中间信号被更外层的状态吸收 (pipefail 缺失,失败码被 tr 吃掉)
  • 把"通过一次"当成"会一直通过"(growi 的 in-run retry)
  • 为了让判据满意,去改被测对象(datahub)

这四条我前面写过 ------ 在另一篇里我把它们归成四层:没到、到了没变、变了没进模型、进了模型没落库。

这次多了一层,而且是最坏的一层。 前四层的共同点是"什么都没发生却被记成发生了"。这一层是"为了让它被记成发生,改了它"。

从「验收不严」到「为了通过验收而改动被验收物」,中间那条线叫为了指标而修改指标所测量的对象。我原来以为这是项目管理的问题,不是工程的问题。

我错了。AI 把这条线的成本降到接近零了 ------ 它改一个 fixture 只要几秒钟,而且它会附上一份格式完整、逻辑自洽、语气自信的 PR 描述。

七、收尾

回看那六个坐标,排序标准其实只有一个:

这个动作,让问题变小了,还是让问题看不见了?

  • datahub 删掉 lineage ------ 门禁变绿了,问题看不见了
  • growi 打 flaky/confirmed ------ 标签有了,真实失败率仍然没算
  • councilkit 修 exit-0 判定 ------ 这是六个里唯一一个让问题变小了的
  • Splotch 加 shell: bash ------ 同上,而且是给十个 workflow 同时加上
  • focus-planner 要求 20 次全绿 ------ 方向对,但把 3 次 rerun 和它并列了
  • AllTrue_System 「修不好就重跑」 ------ 这条策略本身就是在让问题看不见

六个里,四个是 Agent 干的 。它们做对了两个 ------ councilkit 拒绝 exit-0 的 flaky 日志,Splotch 给十个 workflow 补上 shell: bash。做错了两个 ------ growi 拿坏证据下结论,datahub 删掉被测的变量。

另外两个是人写的,问题不在判断,在判据:focus-planner 的方向完全正确,但把"3 次 rerun"和"20 次连续全绿"并列成同一个验收条件;AllTrue_System 直接把"修不好就重跑"写成了正式策略。

做错的那两个不是失误,是缺信息。 growi 的 Agent 手里只有一个 bit:「重试后过了」。这个 bit 里没有 flaky 率,因为 flaky 率从来没被计算过。它在一个缺失了关键量的输入上做推理,做得再自信,也只是把不确定性换了个格式往下传。

councilkit 和 Splotch 做对的原因也一样朴素:作者在动手之前,先去读了那一行 N flaky 和那两条 shell 日志。多读一行日志,和少算一个数,差距就是一个正确的 PR 和一个删掉 fixture 的 PR。

所以我最后要说的不是"别让 Agent 修 flaky"。

是:你要给它的那个 bit,得先自己算出来。

附一:本文引用的六个仓库

全部是 2026-10-01 到 10-04 之间公开的,我逐个打开原文读过正文,不是转述。

  1. Wenfeng-GAO/councilkit#111 --- Cursor Agent 提交,10-04 09:11 UTC,当天合并

  2. KyleMit/Splotch#2668 --- Claude Code 提交,10-04 08:22 UTC,一小时内合并

  3. growilabs/growi#12003 --- Claude Code 开,10-02 16:47 UTC

  4. datahub-project/datahub#20121 --- Cubic 自动生成,10-01 13:10 UTC,PR 至今 open

  5. shivbijlani/focus-planner#826 --- 10-03 12:44 UTC

  6. jerry200176-png/AllTrue_System#3506 --- 10-04 04:20 UTC

附二:实验数据

环境:Windows 侧 Chrome 154,CDP 127.0.0.1:9222,用我自写的原生 CDP 客户端驱动(没装 Playwright ------ 我想量的就是「重试判据」这件事本身,不需要引入被测框架)。

  • 页面在 0 ≤ delay < 120 ms 内均匀随机地把 #target 从 PLACEHOLDER 改成 READY
  • 「测试」= 导航后固定等 N 毫秒,读一次 #target,不等
  • 固定等待取 40 / 60 / 80 / 100 / 120 ms 五个点
  • 每点 60 轮,五点合计 300 轮
  • 每轮最多 3 次尝试(1 次首尝试 + 2 次重试)
  • 判据:读到 READY 为过

120ms 那一行是对照组:它的真实 flaky 率真的是 0,用来证明这套 harness 不是"怎么跑都失败"。而它跟 100ms 那一行在判据输出上完全一样 ------ 这才是关键。

flaky 率本身是随机过程的采样值,重跑会得到不同的具体百分比。正文引用的是这一轮的实测值,趋势是稳定的:重试判据把真实 flaky 压到接近 0,而且 p 越大压得越狠。

相关推荐
东莞市云毅网络有限公司1 天前
企业资料时效性判定:Last-Modified、正文日期与版本字段三路交叉
python·sqlite·自动化运维·geo·数据监测
Ikalus19881 天前
1,136 条「通过」的轨迹里,122 条是运气
自动化运维
Ikalus19881 天前
3254 个测试全绿,CI 红在一个根本没跑的检查上
自动化运维
Ikalus19881 天前
我批准部署的那一刻,抓「部署没发生」的门禁变绿了
自动化运维
Zelman2 天前
测试过程模型与左移右移
测试·自动化运维·devops
Ikalus19883 天前
假成功有四种形状:给 AI 写自动化工具的自检清单
自动化运维
johnsmithCA3 天前
给搜索的法规条文做了个「版本核验」流程:怎么确认手上的条文还是现行有效版
人工智能·自动化运维
考虑考虑17 天前
nohup启动java程序
后端·自动化运维
东莞市云毅网络有限公司17 天前
PDF 表格抽取的三条技术路线对比:规则、库解析与版面识别
python·sqlite·自动化运维·geo·数据监测