本节摘要:本节治理浏览器智能体系统里最磨人的失败形态:flake——时好时坏的用例与任务。先给定义与代价:同一输入在不同运行里结果不同,它比稳定失败更贵,因为它腐蚀信号(分不清真缺陷还是噪声,告警信任崩塌)。根源三分类:时序类(等待不足、竞态——断言跑在渲染前、双击与防抖的赛跑)、数据类(测试数据漂移、共享状态——账号互踢、租户数据被别的任务改掉)、环境类(网络抖动、资源竞争、依赖服务不稳——池过载、镜像里的字体缺失)。然后是本章的立场口号:重试不是答案而是症状——给 flake 加重试等于给发烧加退烧药,烧退了病还在;重试只对「已分类的瞬态失败」合法(第 6.2 节的分类器先工作),未分类的 flake 必须先归因再处置。度量上把稳定率升为一等指标:单用例通过率、隔离运行对照全量运行(差异即污染信号)、flake 分。治理流程五步闭环,配分层门禁挡新 flake。双语实现稳定率统计。
flake 的严格定义:同一输入、不同运行、不同结果。它比稳定失败贵三个数量级,贵在信号层:
| 受害者 | 稳定失败 | flake |
|---|---|---|
| 开发者 | 修就是了 | 先花半小小时重现——还未必重现得了 |
| CI 信号 | 红就是红 | 「再跑一次就好了」文化蔓延 |
| 告警系统 | 可信 | 信任崩塌,真警报被当噪声划掉 |
第 9.2 节提过自然语言用例的抖动率更高(示意值百分之一到百分之五),但 flake 不是 AI 用例的专利——纯 Playwright 一样有时序与数据类的抖动。治理对象是整个系统:测试、固化任务、Agent 任务,只要有时好时坏就在治理范围内。
| 类别 | 典型指纹 | 根因 | 治理方向 |
|---|---|---|---|
| 时序类 | 「再跑一次就过」概率高;日志显示断言时刻页面未就绪 | 等待不足、竞态 | 显式等待终态(第 6.1 节断言前等待);消竞态(幂等设计) |
| 数据类 | 换账号或时段就变;失败信息显示数据不符 | 测试数据漂移、共享状态 | 数据自足(第 9.2 节用例自足纪律);按任务隔离账号(第 7.1 节) |
| 环境类 | 集中在高峰时段;特定实例上高发 | 网络抖动、资源竞争、依赖不稳 | 池扩容或降载(第 10.1 节);环境对齐(第 7.2 节 shm 类环境税) |
归因的取证材料就是第 8.3 节的事件流加第 6.1 节的三件套:时间戳对齐(失败是否集中在高峰)、实例分布(是否特定实例高发)、失败瞬间的快照(页面当时到底长什么样)。flake 的第一现场往往只在证据里——这是留痕系统在稳定性工程里的又一份回报。
「重试不是答案而是症状」的完整表述:给未归因的 flake 加重试,等于用下一次的运气掩盖这一次的病因。重试的合法条件只有一条:失败已被分类器归入瞬态类(第 6.2 节),即已知根因是会自愈的(网络抖动),且重试有预算上限。除此之外的重试都在制造技术债的复利:
未归因 flake ──加重试──▶ 表面稳定 ──▶ 归因动力消失 ──▶ 病根存活 │ │ └──────────── 某天条件变化,病根以更难的形态复发 ──────────┘
工程上的替代动作:新 flake 一律先进「隔离区」(quarantine 标记,不计入门禁但可见),附归因工单;归因完成(三分类之一加具体根因)后该修修、该删删,才允许回到正式集。测试框架的自动重试功能可以保留给已分类的瞬态失败,重试通过的用例要打标——「这次是重试才过的」本身就是稳定率统计要扣分的输入。
把稳定率升为一等指标,两个基础口径:单用例通过率(近 N 次运行的通过占比)、隔离对照差(单独运行的通过率减去全量运行中的通过率——差异大即存在共享状态污染)。flake 分取少数结果占比(零最稳):
# stability_report.py —— 用例稳定率统计(写法示意) import json from collections import defaultdict from pathlib import Path RUNS_DIR = Path("ci-runs") # 每次 CI 落一份 {test, status, retried} 记录 def load_history() -> dict: stats = defaultdict(lambda: {"pass": 0, "fail": 0, "retried": 0}) for file in sorted(RUNS_DIR.glob("*.jsonl")): for line in file.read_text(encoding="utf-8").splitlines(): row = json.loads(line) stats[row["test"]][row["status"]] += 1 if row.get("retried"): stats[row["test"]]["retried"] += 1 return stats def flake_score(runs: dict) -> float: total = runs["pass"] + runs["fail"] return min(runs["pass"], runs["fail"]) / total if total else 0.0 for name, runs in sorted(load_history().items()): total = runs["pass"] + runs["fail"] rate = runs["pass"] / total if total else 0.0 print(f"{name}: 稳定率 {rate:.0%},flake 分 {flake_score(runs):.2f},重试通过 {runs['retried']}")
// stability_report.mjs —— 用例稳定率统计(写法示意) import { readFileSync, readdirSync } from "node:fs"; const RUNS_DIR = "ci-runs"; // 每次 CI 落一份 {test, status, retried} 记录 /** 汇总历史记录 @returns {Map<string, {pass: number, fail: number, retried: number}>} 统计 */ function loadHistory() { const stats = new Map(); for (const file of readdirSync(RUNS_DIR)) { for (const line of readFileSync(`${RUNS_DIR}/${file}`, "utf8").split("\n")) { if (!line.trim()) continue; const row = JSON.parse(line); const s = stats.get(row.test) ?? { pass: 0, fail: 0, retried: 0 }; s[row.status] += 1; if (row.retried) s.retried += 1; stats.set(row.test, s); } } return stats; } for (const [name, s] of loadHistory()) { const total = s.pass + s.fail; const rate = total ? s.pass / total : 0; const flake = total ? Math.min(s.pass, s.fail) / total : 0; console.log(`${name}: 稳定率 ${(rate * 100).toFixed(0)}%,flake 分 ${flake.toFixed(2)},重试通过 ${s.retried}`); }
门禁阈值示例(示意值,按团队基线定):新合入用例近二十次运行稳定率低于九成五自动进隔离区;存量用例稳定率跌破九成出工单。
五步闭环:发现(稳定率看板或隔离对照差报警)→ 归因(三分类加证据取证)→ 修复(时序改等待、数据改自足、环境改配置或容量)→ 隔离(修不了或低价值的移出正式集,显式记录原因)→ 回归(修复后观察窗口内的稳定率验证)。每一步都有产物进第 8.3 节事件流——治理本身也要可审计。
分层门禁的立场:flake 只出不进。新增用例先在预发环境跑二十次(快进快出的小任务集)再进正式集;对存量 flake 定期清账(修、删、隔离三选一,不许第四种「留着不管」)。门禁的价值在保护第 9.1 节回归任务集的信号纯度——任务集的可信度是第 11.3 节敢把它设为发布门槛的前提。
| 坑 | 现象 | 排查与处理 |
|---|---|---|
| 重试当万能药 | 抖动被掩盖、根因积累 | 症状论:先分类,重试只给瞬态 |
| 阈值一刀切 | 全线告警或全线沉默 | 按用例分层定基线,定期校准 |
| 忽略重试标记 | 「过了」其实是重试过的 | retried 字段进统计与门禁 |
| 隔离区变垃圾场 | 进去就没人管 | 隔离带归因工单与期限 |
| 只看通过率不看隔离差 | 共享状态污染漏诊 | 隔离运行对照纳入月度体检 |
稳定的基线有了,接下来让系统「坏了有人知道」:下一节给错误一套分类学与告警分级——五类 taxonomy、三级告警、成功率看板,把第 8.3 节的事件流变成值班同学看得懂的仪表盘。