模型排行榜通常只给出一个醒目的分数,却很少回答更重要的问题:用了哪一版数据、怎样调用模型、评分器是否变化、失败样本能否重新检查。UK AISI 与 EvalEval 对基准结果可复现性的关注,指向了一个关键转变——评测结果不应只是表格中的数字,而应是一组可以保存、审计和重放的实验记录。
由于现有摘要没有披露具体接口和内部实现,下面不会臆测 UK AISI 或 EvalEval 的产品细节,而是围绕这一主题给出一套可以直接落地的复现方法。
“可复现”不是只固定一个随机种子
对 AI 基准而言,可复现至少包含三个层次:
- 实验可追踪:知道使用了什么数据集、模型、提示词模板、推理参数和评分器。
- 评分可重放:即使模型接口已经变化,仍能利用保存的原始输出重新运行评分逻辑。
- 结果可比较:两次运行的差异能够定位到数据、模型、执行环境或评测代码,而不是只看到总分发生变化。
固定 seed 当然有用,但它无法解决所有问题。托管模型可能在相同名称下更新权重或服务栈;并发调度、采样实现和硬件差异也可能带来变化。因此,更稳妥的目标不是承诺“每个 token 永远相同”,而是保存足够完整的证据,让其他人能够解释差异。
一份合格的运行清单通常应包含:
- 数据集名称、版本和内容哈希;
- 模型标识,以及供应商提供的快照或版本号;
- system prompt、用户提示词模板及其哈希;
temperature、top_p、最大输出长度和随机种子;- 评测器名称、版本及代码哈希;
- Python、依赖、操作系统和硬件信息;
- 每道题的原始输入、原始输出和单项得分;
- 超时、拒答、解析失败与重试次数。
把运行过程拆成不可混淆的三层
评测系统最好将下面三件事分开保存。
1. 数据快照
不要只记录类似 benchmark-latest 的别名。别名可能指向新内容,应同时记录不可变版本或对规范化数据计算 SHA-256。涉及受限数据时,可以只公开哈希、样本 ID 和访问说明,而不是直接发布内容。
2. 模型原始输出
不要只保留最终分数。解析器可能存在缺陷,裁判模型也可能升级;如果保存了逐样本输出,就可以在不再次调用昂贵模型的情况下重新评分。
原始记录还应区分以下状态:
- 模型正常回答但答案错误;
- 请求超时;
- 服务端返回错误;
- 输出无法被解析;
- 安全策略拒绝回答。
把这些情况统统记成零分,会让不同系统之间的比较失真。
3. 独立评分器
评分器应像生产代码一样进行版本管理。对精确匹配、代码执行、模型裁判等不同评分方式,应明确其边界:大小写是否敏感、空白如何处理、代码运行有哪些资源限制、裁判模型使用了哪一个版本。
这样,模型执行和结果评分便可以独立进行:模型调用一次,评分器可以重放多次。
一个可运行的最小复现实验
下面是一个使用 Python 标准库的最小示例。它不是对 UK AISI 或 EvalEval 内部实现的复刻,而是展示如何生成包含数据哈希、运行器哈希、环境信息、逐题输出和汇总分数的评测产物。
将以下内容保存为 benchmark.py,使用 Python 3.9 或更高版本运行。接入真实模型时,只需替换 invoke_mock,并把供应商返回的模型快照、请求 ID 和错误信息写入记录。
import argparse
import hashlib
import json
import platform
import random
import sys
from datetime import datetime, timezone
from pathlib import Path
CASES = [
{'id': 'geo-1', 'prompt': 'Capital of France?', 'expected': 'paris'},
{'id': 'math-1', 'prompt': 'What is 2 + 2?', 'expected': '4'},
{'id': 'color-1', 'prompt': 'Color of a clear daytime sky?', 'expected': 'blue'},
]
def canonical_bytes(value):
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(',', ':'),
).encode('utf-8')
def sha256_bytes(value):
return hashlib.sha256(value).hexdigest()
def invoke_mock(prompt):
text = prompt.lower()
if 'france' in text:
return 'Paris'
if '2 + 2' in text:
return '4'
if 'daytime sky' in text:
return 'Blue'
return 'unknown'
def exact_match(response, expected):
return int(response.strip().casefold() == expected.strip().casefold())
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--model', default='mock-model-v1')
parser.add_argument('--seed', type=int, default=7)
parser.add_argument('--output', default='run.json')
args = parser.parse_args()
random.seed(args.seed)
runner_path = Path(__file__)
config = {
'model': args.model,
'seed': args.seed,
'temperature': 0,
'evaluator': 'casefold-exact-match-v1',
}
records = []
for case in CASES:
response = invoke_mock(case['prompt'])
records.append({
'case_id': case['id'],
'prompt': case['prompt'],
'prompt_sha256': sha256_bytes(case['prompt'].encode('utf-8')),
'expected': case['expected'],
'response': response,
'response_sha256': sha256_bytes(response.encode('utf-8')),
'score': exact_match(response, case['expected']),
'status': 'ok',
})
artifact = {
'schema_version': '1.0',
'created_at': datetime.now(timezone.utc).isoformat(),
'dataset_sha256': sha256_bytes(canonical_bytes(CASES)),
'runner_sha256': sha256_bytes(runner_path.read_bytes()),
'config': config,
'environment': {
'python': sys.version,
'platform': platform.platform(),
},
'summary': {
'correct': sum(item['score'] for item in records),
'total': len(records),
'accuracy': sum(item['score'] for item in records) / len(records),
},
'records': records,
}
Path(args.output).write_text(
json.dumps(artifact, ensure_ascii=False, indent=2),
encoding='utf-8',
)
print(json.dumps(artifact['summary'], indent=2))
print(f'Wrote reproducibility artifact to {args.output}')
if __name__ == '__main__':
main()
运行两次并检查关键字段:
python benchmark.py --model mock-model-v1 --seed 7 --output run-a.json
python benchmark.py --model mock-model-v1 --seed 7 --output run-b.json
python - <<'PY'
import json
a = json.load(open('run-a.json', encoding='utf-8'))
b = json.load(open('run-b.json', encoding='utf-8'))
for key in ('dataset_sha256', 'runner_sha256', 'config', 'records', 'summary'):
print(f'{key}:', 'MATCH' if a[key] == b[key] else 'DIFFERENT')
PY
不要直接期待两个 JSON 文件的整体哈希相同,因为其中包含运行时间和环境信息。真正需要比较的是数据、代码、配置、逐样本响应和评分结果。若要实现字节级一致,可以把时间等易变元数据放入单独的清单文件。
从本地脚本扩展到团队评测流水线
进入 CI 或共享评测平台后,可以将产物结构固定下来:
schema_version: '1.0'
run:
benchmark: qa-mini
dataset_digest: sha256:REPLACE_ME
model: provider/model-snapshot
seed: 7
decoding:
temperature: 0
top_p: 1
max_output_tokens: 128
scoring:
evaluator: exact-match
evaluator_version: '1.0.0'
artifacts:
raw_responses: artifacts/responses.jsonl
scores: artifacts/scores.jsonl
summary: artifacts/summary.json
实际使用时,应将 REPLACE_ME 替换为数据文件的真实哈希,并尽量使用不可变的模型快照。流水线还应保存依赖锁文件,例如 requirements.txt、uv.lock 或容器镜像摘要,而不是只记录一个容易漂移的镜像标签。
对于模型裁判,还需要额外记录裁判提示词、裁判模型版本和原始判定理由。模型裁判本身也可能不稳定,因此适合报告多次判定的一致率,或抽取一部分样本进行人工复核。
采用前检查:复现性不等于有效性
可复现的评测仍可能测错东西。一个错误评分器完全可以稳定地给出错误结论;公开测试集也可能已经进入训练语料。因此,团队在发布结果前应同时检查:
- 能否从运行清单恢复数据、配置和评测器;
- 能否只用原始输出重新计算总分;
- 数据哈希、代码哈希和模型版本是否不可变;
- 超时、拒答和解析失败是否被单独统计;
- 是否报告重复运行的方差,而不只发布最好的一次;
- 是否检查数据污染、测试集泄漏和指标局限;
- 涉及敏感数据时,复现材料是否遵守隐私和授权边界。
UK AISI 与 EvalEval 所强调的方向之所以重要,是因为它把评测从“相信这个分数”推进到了“检查这份证据”。最务实的起点并不复杂:固定输入、保存原始输出、版本化评分器,再为每次运行生成机器可读的清单。做到这四点,排行榜才开始具备工程意义。