针对搜索/爬虫/提取类 API 的基准测试框架设计,将任务定义与测试代码解耦,通过 corpus versioning 和统一 pass 标准解决「测一次容易、测两次难」的问题。
对 Web 访问类 API 进行基准测试——搜索、爬取、抽取、文档解析、浏览器操作——做一次很容易,做两次却很难。目标页面会变。提供商发布更新。你的测试工具增加了重试机制。三个月后数字变了,而输出中没有任何信息说明是三者中的哪一个导致的。
这里的可复现性并不是指第二次运行得到完全相同的数字;在真实的 Web 环境下,你也做不到这一点。它意味着每一个公开发布的数字都携带有足够的结构信息,能够说明测量的是什么、在什么群体上、什么日期测的。五个层次共同产生这种属性。
比较的单位是任务,而不是一次调用。将语料库作为独立数据与测试工具代码分离:每个任务携带一个稳定的 task_id、它所锻炼的能力、它的输入——一个查询、一条 URL、一个目标 schema——以及判断响应是否可用的通过标准。每个被测系统接收的都是同一份列表。
两条规则让这份列表可信。在同一次运行中跨系统保持固定:把一个爬虫与使用了简单页面的另一个爬虫比较,或者与使用了有商业级爬虫防御的页面的另一个爬虫比较,这样比的是样本,不是工具。并且要给它打版本——当你新增、删除或修改了一个任务时,提升语料库版本并重新运行所有测试,因为在 v1 和 v2 下产生的数字是不同测量,只是共享了一个指标名称。
将语料库版本与运行标识符分开;前者说明问的是什么,后者说明什么时候问的。围绕容易出问题的内容来构建语料库:扫描的 PDF、拒绝数据中心 IP 的站点、需要跨源合成的查询。简单的任务会把所有系统压缩到相近的分数。
在聚合任何数据之前,先记录每一次尝试的一行数据。如果一个测试工具在请求循环内部计算均值,就失去了回答任何未被预见到的问题的能力。
一个可用的尝试记录包含 run_id、corpus_version、task_id、system、capability、observed_at、latency_ms、billed_usd、outcome 和 outcome_reason。将 observed_at 写作带明确偏移量的 RFC 3339 日期时间;一个不带时区信息的本地时间戳一旦跨机器或跨区域运行就会变得模糊。
outcome 值得用一个小规模封闭词表——usable、unusable、error——因为重要的区别不是 HTTP 状态码。返回 200 OK 但携带空抽取结果是一次失败的任务和一次计费的调用。把这个结果混入成功率是最常见的让基准测试自我拔高的方式。
OpenTelemetry 的指标数据模型值得借鉴,即使你从不发送 OTLP 数据点。它要求每个数据点携带它所覆盖的时间窗口,并声明是增量还是累积聚合,而不是让读者去猜测。基准测试行应该遵循同样的纪律。
每个比率都有一个分母,在 Web 工具的基准测试中,分母在同一张表内就会不同。延迟中位数覆盖的是产生了计时的尝试。错误率覆盖的是所有尝试。可用结果的单次成本只覆盖可用结果,而失败但已计费的调用仍然留在分子中。
所以要把分母作为一个字段而不是脚注来发布。这个标准化器将合成的尝试观测转换为长格式的指标行,每行都携带自己分母的名称和大小。这些值展示的是形状;它们不是任何真实服务的测量。
from collections import Counter, defaultdict
from statistics import median
RUN_ID = "2026-08-11-synthetic"
CORPUS_VERSION = "demo-v1"
OUTCOMES = ("usable", "unusable", "error")
# Synthetic. Not measurements of any real service.
OBSERVATIONS = [
{"task": "t1", "system": "alpha", "capability": "search", "outcome": "usable",
"latency_ms": 620, "billed_usd": 0.002},
{"task": "t2", "system": "alpha", "capability": "search", "outcome": "unusable",
"latency_ms": 700, "billed_usd": 0.002},
{"task": "t3", "system": "alpha", "capability": "search", "outcome": "error",
"latency_ms": None, "billed_usd": 0.002},
{"task": "t1", "system": "beta", "capability": "search", "outcome": "usable",
"latency_ms": 910, "billed_usd": 0.003},
{"task": "t2", "system": "beta", "capability": "search", "outcome": "usable",
"latency_ms": 990, "billed_usd": 0.003},
]
def metric_rows(observations):
groups = defaultdict(list)
for obs in observations:
assert obs["outcome"] in OUTCOMES, obs["outcome"]
groups[(obs["system"], obs["capability"])].append(obs)
rows = []
for (system, capability), group in sorted(groups.items()):
counts = Counter(o["outcome"] for o in group)
attempts = len(group)
assert sum(counts.values()) == attempts # no attempt escapes an outcome bucket
timed = [o["latency_ms"] for o in group if o["latency_ms"] is not None]
billed = sum(o["billed_usd"] for o in group)
usable = counts["usable"]
for key, value, denominator, size in [
("error_rate", counts["error"] / attempts, "attempts", attempts),
("latency_p50_ms", median(timed) if timed else None, "timed_attempts", len(timed)),
("cost_per_usable_usd", billed / usable if usable else None, "usable_results", usable),
]:
rows.append({
"run_id": RUN_ID, "corpus_version": CORPUS_VERSION,
"system": system, "capability": capability,
"metric_key": key, "value": value,
"denominator": denominator, "denominator_size": size,
"attempts": attempts,
# every key present, so absent never reads as zero
"outcome_counts": {name: counts[name] for name in OUTCOMES},
})
return rows
if __name__ == "__main__":
rows = metric_rows(OBSERVATIONS)
indexed = {(r["system"], r["metric_key"]): r for r in rows}
latency = indexed[("alpha", "latency_p50_ms")]
assert (latency["denominator_size"], latency["attempts"]) == (2, 3)
assert round(indexed[("alpha", "cost_per_usable_usd")]["value"], 6) == 0.006
assert round(indexed[("beta", "cost_per_usable_usd")]["value"], 6) == 0.003
print(f"{len(rows)} rows, every denominator named")
脚本输出 6 行,每个分母都具名了。延迟断言将 alpha 的中位数锁定为两次计时尝试对应三次尝试:出错的调用没有产生计时,所以它无法进入延迟群体。成本断言显示 alpha 每次尝试计费少于 beta,但每个可用结果计费多于 beta,因为一次付费失败留在分子中,而不是分母中。
长格式——每个系统、能力、指标一行——用它的冗长度换来了价值。指标集合因能力不同而不同,所以宽表会退化为稀疏的列并集。
一个无法追溯到输入数据的结果集是断言,不是测量。
对语料库文件和原始观测文件做哈希,并将两个摘要都记录在发布的输出中。让序列化器具备确定性:固定的字段顺序、稳定的排序、 artifact 内部不包含墙上时钟时间戳。从相同的输入重建就会产生字节级完全相同的输出,两次运行之间的 diff 只显示什么改变了。
在每一行上重复溯源字段,而不是只在头信息中出现一次。行会被过滤、关联、并粘贴到其他人的笔记本中;一个携带自身 run_id 和 corpus_version 的切片能够在这番操作后存活下来,而没有这些信息的切片就变成了孤儿数字。
W3C 的 Web 数据最佳实践涵盖了发布规范:提供机器可读的元数据、声明溯源和许可、供应版本信息和版本历史,以及让被取代的版本仍然可访问而不是原地重写数字。有人引用过旧的数字。
最后一层是展示层面的,也是最常被省略的一层。
首先点名运营者。第一方基准测试——由对结果有商业利益的一方运行的——并不因这一事实而被否决,但读者有权权衡它。一个具体的例子是 NativePort 运营的 Web 访问服务的已发布第一方基准测试方法:它按能力保持一份带版本的语料库,在每个评分表上标注运行日期,标记每个成本分母,在每个合成指标下发布原始的每指标值,并将其运行者不观察的内容——正常运行时间、SLA 合规性、吞吐量上限——作为它因此不做出的声明列出。
三个习惯能廉价地赢得信任。发布弱结果,包括不好看的那些。将缺失视为缺失,永远不要视为零,因为"未测量"和"测量为零"是两个相反的事实,空值转零的强制转换把它们混为一谈了。在所有地方都把运行日期放在数字旁边,因为提供商行为和站点防御会在你脚下变化。
Hugging Face 的数据集卡片文档示范了这应该放在哪里:卡片随数据一起发布,在头部携带机器可读的元数据,并预留专门区域用于采集过程和局限性。注意事项属于 artifact 本身,而不是放在你的表格的消费者永远不会打开的一篇帖子里。
这些都不需要一个大型测试工具。它只需要一个决策,在第一次运行之前就做出:输出是一个有契约的数据集,不是一张幻灯片里的表格。
Dataset Cards — Hugging Face Hub 文档,介绍如何随数据一起发布卡片,包含机器可读的元数据以及用于采集过程和局限性的专门区域。
Metrics Data Model — OpenTelemetry 规范,要求数据点携带其时间窗口并显式声明聚合时序性。
Data on the Web Best Practices — W3C 推荐标准,涵盖已发布数据集的元数据、溯源、许可、版本控制和版本历史。
RFC 3339 — 用于 observed_at 字段的互联网日期/时间格式,包含明确的 UTC 偏移量。
我在 NativePort 工作,上述引用其公开方法作为第一方基准测试披露的一个例子。AI 辅助起草;人类审核了文本、代码示例和每个引用来源。本文展示的所有值都是合成的。