调用模型的测试失败通常按因聚集而非独立,用签名聚合替代逐条报告可快速定位真实根因。
当一个调用模型的测试套件变红时,往往是一团团地变红:一个速率限制、一个糟糕的部署、一个模型别名迁移,然后四十个测试同时失败。把这读成四十个问题,是每个测试报告的默认行为——但这错了,而且错得离谱,白白浪费数小时。
测试报告按运行器认识的东西组织:文件、类、测试名。这种组织方式假设失败是独立的——对于单元测试来说这是个不错的假设,但对于所有通过同一个 HTTP 客户端访问同一个提供商的测试来说,这是一个糟糕的假设。当共享依赖晃动时,每个触及它的测试都会失败,而报告把它们呈现为无关的,因为它们的名字无关。
后果不仅仅是浪费阅读时间。它扭曲了每一个下游数字:你的 flake 计数会因四十个本身没问题的测试而飙升,你的分类待办队列充斥着重复项,而同一次运行中发生的真正单测回归被淹没在噪声中。按原因分组才能让这次运行变得可读——一次事故、一行、四十个受影响的测试,任何因不同原因失败的测试立即凸显出来。
签名是一个从失败中计算出的短字符串,具有这样的特性:具有相同根本原因的两个失败产生相同的字符串,而具有不同原因的两个失败产生不同的字符串。值得纳入的字段,按优先级排序:
HTTP 状态码,如果有的话。429 和 500 是不同的事故,即使 resulting exception text 完全相同。在测试 harness 中捕获它——它不在 traceback 里。
提供商的错误类型或代码。rate_limit_exceeded、overloaded_error、context_length_exceeded 及其等价物是最具区分度的字段,而且是稳定的字符串。
异常类名。AssertionError 对比 ValidationError 对比传输超时,是大多数报告从未做出的第一次拆分。
断言位置。最内层属于你自己代码的栈帧——文件和方法名,不要行号,因为行号随每次编辑移动,会让同一分组在不同的 commit 之间碎片化。
服务的模型 ID。同一断言在两个不同模型上失败是两个问题。
必须排除的:测试名、完整消息、请求 ID 或任何时间戳。把测试名放进去,每个分组就恰好只有一个成员——又回到起点了。
需要关注的权衡是:签名可以太粗糙,也可以太细。只对异常类做哈希会把套件中所有的 AssertionError 塌缩成一个大组,这是一行毫无用处的数据。好的签名的检验标准是行为性的,而非美学性的:当你看最大的那个组时,每个成员都应该有相同的修复方案。如果两个成员需要由不同的人来修复,就加一个字段;如果两个组需要由同一变更来修复,就去掉一个。
消息既是有效区分信息所在之处,也是所有熵所在之处,所以需要在哈希之前对它做归一化。六条替换规则完成了大部分工作,而且顺序很重要——先做具体的,再做一般的。
import re
SUBS = [
(re.compile(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
r"[0-9a-f]{4}-[0-9a-f]{12}\b", re.I), "<uuid>"),
(re.compile(r"\b(req|chatcmpl|msg)_[A-Za-z0-9]{6,}\b"), "<id>"),
(re.compile(r"\b\d{4}-\d{2}-\d{2}[T ][\d:.]+Z?\b"), "<ts>"),
(re.compile(r"0x[0-9a-f]+", re.I), "<addr>"),
(re.compile(r"'[^']{40,}'"), "'<long>'"), # inlined prompts and completions
(re.compile(r"\b\d+\b"), "<n>"), # do this LAST
]
def normalise(message: str) -> str:
text = message.strip().split("\n")[0][:400]
for pattern, replacement in SUBS:
text = pattern.sub(replacement, text)
return text
其中两条值得提醒。把每个整数替换成 <n> 会把"期望 1 次工具调用,得到 0 次"和"期望 1 次工具调用,得到 3 次"塌缩成一组——这通常是你想要的,但偶尔也会掩盖真实的区别;把它放在最后执行,这样更具体的模式先抢走它们的匹配。最后截断到第一行会把模型输出从消息中丢弃,这是整个设计的要点——嵌入在断言消息中的 completion 每次都是唯一的,如果你不把它去掉,会瓦解任何分组。
每个值得使用的运行器都会输出 JUnit XML:pytest 用 --junitxml,Vitest 用 --reporter=junit --outputFile,Playwright 用它的 junit reporter。那个文件就是输入,用标准库解析它——无需额外依赖。
让每个 job 把 JUnit XML 输出到一个已知路径并上传为 artifact。即使对通过的 job 也要这样做——那些通过的行是你之后想要的所有比率的分母。
解析每个 <testcase> 并读取其 <failure> 或 <error> 子元素。如果你的运行器输出的是 rerun 方言,也要读取 <flakyFailure> 和 <rerunFailure>:那些是重试过的尝试,它们就是 flake 信号。
计算签名并分组。按最大组优先打印。
import hashlib, sys, xml.etree.ElementTree as ET
from collections import defaultdict
FAILURE_TAGS = ("failure", "error", "flakyFailure", "rerunFailure")
def signature(case, node):
parts = [
node.tag,
node.get("type") or "",
normalise(node.get("message") or ""),
case.get("classname") or "",
]
raw = "|".join(parts)
return hashlib.sha1(raw.encode()).hexdigest()[:10], raw
groups = defaultdict(list)
for path in sys.argv[1:]:
for case in ET.parse(path).iter("testcase"):
for node in case:
if node.tag in FAILURE_TAGS:
key, raw = signature(case, node)
groups[key].append((case.get("name"), raw))
for key, members in sorted(groups.items(), key=lambda kv: -len(kv[1])):
print(f"{len(members):4d} {key} {members[0][1][:110]}")
for name, _ in members[:5]:
print(f" {name}")
if len(members) > 5:
print(f" ... and {len(members) - 5} more")
这个脚本故意一次读取多个文件,因为你想要分组的单元是 CI run,而不是 job。分散在八个并行分片上的套件会写入八个 XML 文件,而击中全部八个的速率限制是一次事故;如果按文件分组,会报告成八次。把整个 artifact 目录传进去。
注意 classname 在签名里而测试名不在。包含 classname 让真正不相关的模块中的失败保持分离,同时仍然把一个模块内的二十个测试塌缩在一起;如果你的套件把所有东西都归到一个 class 里,就去掉它,因为它此时没有任何贡献。
输出改变了红色构建对你的要求。一个有四十个成员的组,带有 rate_limit_exceeded 代码,是一次行动:降低套件的并发度——本地绿、CI 红的页面涵盖了这个。一个只有一员的组,带有在你自己代码中特定函数的 AssertionError,才是你真正需要阅读的东西。
把签名持久化到每次尝试旁边,而不是只在当下计算它。一旦它成为一列,那些曾经很难的问题就变得 trivial 了:这个签名本周是新的吗?哪个签名占了套件红灯时间的大部分?周二飙升的那个组之前出现过吗?这是 flake dashboard 已经需要的表多加一个字段,而这个字段让 dashboard 值得构建。
与 rerun 相关的 JUnit 元素是来自 Surefire 的扩展,不是任何单一标准的一部分,支持程度因运行器和 CI 产品而异。在依赖 <flakyFailure> 存在之前,检查你的运行器实际输出什么。