
最近折腾了几家网页数据提取服务,踩了不少坑。最深的感受是:基准测试这东西,做一次容易,做两次难。
目标网页会改版,API 服务商会发版本更新,你的测试脚本加了重试逻辑——三个月后再跑一遍,数字变了,但报告里根本看不出来是什么因素导致的。
所以在网页环境下,"可复现"不是追求两次运行拿到完全相同的数字——这在真实网络上根本做不到。它的意思是:每一个发布的数字都要带上足够的结构信息,说清楚测了什么、在什么样本上、什么时候测的。要做到这一点,需要五个层次。
我定义的比较单位是任务,而不是单次 API 调用。语料库作为数据独立于测试代码存在,每个任务有稳定的 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 但提取结果为空仍然是失败任务,而且已经计费了,把这种情况算进成功率是最常见的基准测试自我美化的方式。
OpenTelemetry(可观测性Telemetry)的 metrics 数据模型值得借鉴,即使不发送 OTLP(OpenTelemetry 协议)遥测数据也可以参考它的设计思路。它要求每个数据点必须携带覆盖的时间窗口,并明确声明是增量还是累计聚合,而不是让读者去猜测。基准测试数据也应该遵循同样的规范。
每个比率都有分母,在这类基准测试中,分母在同一张表里往往各不相同。延迟中位数只覆盖产生计时的尝试,错误率覆盖所有尝试,而成本可用结果单次覆盖的只是可用结果,失败但已计费的调用保留在分子里。
所以分母应该作为字段发布,而不是脚注。这种规范化器将原始尝试数据转换为长格式指标行,每行都附带自己分母的名称和大小。下面的 Python 脚本演示了这个过程,使用的是合成数据而非真实服务测量:
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 rows, every denominator named。latency 的断言把 alpha 的中位数锁定在 2 个有计时的尝试对上 3 次总尝试:出错的调用没有产生计时,所以不能进入延迟统计的群体。成本断言显示 alpha 单次成本比 beta 低,但可用结果成本却更高——因为付费的失败项在分子里,不是在分母。
长格式——每个系统、能力、指标的单独一行——的啰嗦是值得的。不同能力对应的指标集不同,宽表最终会退化成稀疏的列并集。
无法追溯到原始输入的结果集是断言,不是测量。
对语料库文件和原始观测文件做哈希,把两个摘要都记录在发布的输出里。序列化器必须是确定性的:字段顺序固定,排序稳定,不在 artifact 里放墙上时钟时间戳。从相同输入重建应该产生字节级完全一致的输出,两次运行之间的差异 diff 只显示变化的部分。
在每一行上重复 provenance 字段,而不是只在头部放一次。行会被过滤、关联、粘贴到别人的笔记本里;带了自身 run_id 和 corpus_version 的切片能完整存活下来,而没有的就会变成孤儿数字。
W3C 的《网络数据最佳实践》对发布有具体建议:提供机器可读的元数据、声明 provenance 和许可、提供版本信息和版本历史、不要改写旧数字而是保留被取代的版本供人查阅——因为可能有人已经引用了旧的数字。
最后一层是展示层面的,也是最常被省略的。
先说清楚是谁在跑测试。第一方基准测试——由对结果有商业利益的一方运行的——并不因此失效,但读者有权权衡。拿 NativePort 的公开方法论举个例子:它对每个能力维护一个版本化语料库,在每个记分卡上标注运行日期,给每个成本分母打标签,在每个复合指标下面发布原始数据,并且明确列出它没有观测的内容——稳定性、SLA 合规性、吞吐上限——作为它没有做的声明。
三习惯低成本赢得信任:发布弱结果,包括不好看的那些;缺失值就是缺失值,永远不要转成零,因为"未测量"和"测量为零"是两个相反的事实,强制转零会混淆它们;在任何地方出现数字时都把运行日期放在旁边,因为提供商的行为和网站的反爬策略一直在变。
Hugging Face 的数据集卡片文档示范了这一层应该放在哪里:卡片和数据一起交付,头部包含机器可读元数据,保留专门章节说明收集过程和局限性。注意事项应该放在 artifact 里,而不是放在你的表格的消费者永远不会去看的文章里。
这些都不需要一套大型测试框架。它只需要在第一次运行前做一个决定:输出的是一个有契约的数据集,不是一张幻灯片里的表格。
我在 NativePort 工作,以上作为第一方基准测试披露的例子引用了它的公开方法论。AI(人工智能)辅助起草,人类审查了文本、代码示例和每个引用来源。本文所有数值均为合成数据。