同一批 Bug 报告,Inspect 和 promptfoo 打的分一样吗?
逐条判定一样:40 个判定,0 个不一致。汇总出来的数却可以不一样:Inspect 给每个评分器各报一个通过率,promptfoo 默认要求一条用例的全部断言都通过才算 pass。框架不决定分数,评分规则和汇总口径才决定。
评测框架替你干的是杂活:读数据集、调模型、跑评分、存日志、出对比表。选哪个都行,要紧的是评分逻辑别绑死在框架里,换框架时能逐条对账。
1评测框架替你做了什么
上一讲「把工具响应录下来回放,评测就公平了吗?」把工具响应录成 cassette,让 Agent 在固定的环境上重跑;这一讲更进一步,直接把 Agent 提交的报告录下来(合成),只关心怎么给它打分。第 6 周的实验都是手写脚本:一个 for 循环读样本、调评分函数、算通过率。样本一多、版本一多,手写脚本就要补很多东西:
| 环节 | 手写脚本要自己做 | 框架提供的 |
|---|---|---|
| 数据集 | 读 jsonl、拼 prompt | 样本对象(输入、标准答案、元数据)、从文件加载 |
| 生成 | 调模型、并发、重试、限流 | 统一的模型 / provider 接口,并发和重试 |
| 评分 | 写判定函数 | 内置评分器(字符串匹配、JSON 校验、LLM 评审),也能挂自定义函数 |
| 汇总 | 算通过率、区间 | 按评分器 / 断言汇总的指标 |
| 日志与查看 | 自己存 trace、写查看页面 | 完整日志加一个 Web 查看器(inspect view / promptfoo view) |
| 对比 | 两份结果手工对齐 | 同一任务跑多个模型,结果并排 |
这一讲用两个开源框架做同一件事:Inspect AI 和 promptfoo。两个都在本机装好并离线跑过,不调任何付费 API;被测"模型"是回放录好的报告,数据是合成数据。
2两个框架各是什么
| Inspect AI | promptfoo | |
|---|---|---|
| 出品 | 官网写的是 "developed by the UK AI Security Institute and Meridian Labs" | 官网定位 "an open-source CLI and library for evaluating and red-teaming LLM apps" |
| 许可证 | MIT(GitHub 仓库 UKGovernmentBEIS/inspect_ai) | MIT(GitHub 仓库 promptfoo/promptfoo) |
| 怎么写 | Python:@task 函数返回 Task(dataset, solver, scorer) | YAML 配置:prompts、providers、tests、assert;自定义部分用 Python 或 JS 文件 |
| 核心抽象 | Dataset → Solver → Scorer | 每个 prompt × provider × 测试用例跑一次,再跑一组断言 |
| Agent 评测 | 内置 react() agent、工具,沙箱支持 Docker、Kubernetes 等 | provider 可以是任意脚本或 HTTP 服务(本章用 Python 脚本) |
| 看结果 | inspect view(Web)、VS Code 扩展、.eval 日志文件 | promptfoo view(Web)、-o 导出 json / csv / html / junit.xml 等 |
| 本章版本 | inspect-ai 0.3.273(Python 3.13.7,venv) | promptfoo 0.123.1(Node 24.13.1,npx) |
出处:inspect.aisi.org.uk 首页、promptfoo.dev/docs/intro、两个仓库的 GitHub license 字段,2026-10-01 查阅。promptfoo 的红队功能留到第 8 周。
3概念对照
| 概念 | Inspect AI | promptfoo |
|---|---|---|
| 一条样本 | Sample(id, input, target, metadata) | tests 里的一条:vars(可带自己的 assert) |
| 样本从哪来 | 直接传 list,或 json_dataset / csv_dataset | 内联、文件,或 Python 生成器(本章用 file://promptfoo_tests.py:generate_tests) |
| 怎么得到输出 | solver(本章就是 generate())调 model | prompt 模板渲染后交给 provider |
| 离线假模型 | mockllm/...,custom_outputs 可以是列表、生成器或函数 | 自定义 provider:Python 文件里的 call_api(prompt, options, context) |
| 评分 | scorer:@scorer 装饰的 async 函数,返回 Score | 断言:is-json、python、javascript、llm-rubric 等 |
| LLM 评审 | model_graded_qa / model_graded_fact,model 参数指定评审模型 | llm-rubric,用 options.provider 或断言级 provider 换评审模型 |
| 汇总 | 每个 scorer 一组 metrics(本章 accuracy、stderr) | 每条用例 pass / fail 和 score;断言上的 metric 标签汇总成 namedScores |
| 重复跑 | --epochs | --repeat |
| 结果存哪 | log_dir 下的 .eval 文件 | 本机 ~/.promptfoo/promptfoo.db,加上 -o 导出的文件 |
LLM 评审这一行本章没有跑(第 6 周已经讲过 Judge 的校准和偏差),写法核对自两边的官方文档;model_graded_qa 的签名核对自 0.3.273 安装包。
4实验:同一批报告、同一份评分规则
BugHunt-Bench 的被测 Web 应用里注入了 8 个 Bug(BUG-001~BUG-004 沿用第 2 周 MCP server 里的清单,后 4 个是新加的),另有 2 个模块没有注入 Bug。测试 Agent 每个模块提交一份 JSON 报告。两个版本 agent_v1、agent_v2 的报告已经录好(手写的合成数据),刻意覆盖了几种情况:命中、没命中、漏字段、输出不是 JSON、误报。
评分规则写在一个和框架无关的 grading.py 里,两个框架都调它:
- report_schema:输出是 JSON,并且符合
report_schema.json。verdict是bug时,module / title / steps / expected / actual / severity都要有,steps至少一步。 - hits_injected_bug:标准答案是某个注入 Bug 时,
verdict必须是bug,并且 title、steps、actual 里包含它的全部关键词;标准答案是NONE时,必须报no_bug,否则算误报。
5Inspect 版
mockllm 默认只回一句固定文本 Default output from mockllm/model。把一个函数传给 custom_outputs,每次 generate 都会用 (input, tools, tool_choice, config) 调它(0.3.273 安装包 inspect_ai/model/_providers/mockllm.py:15-23 的类注释,:85-93 是调用处)。这里用它按任务编号回放录好的报告:
def replay_model(recording: str):
reports = grading.load_recording(recording)
def respond(input, tools, tool_choice, config) -> ModelOutput:
sample_id = TASK_ID.search(input[-1].text).group(1)
return ModelOutput.from_content(model=recording, content=reports[sample_id])
return get_model(f"mockllm/{recording}", custom_outputs=respond)
@scorer(metrics=[accuracy(), stderr()])
def hits_injected_bug():
async def score(state: TaskState, target: Target) -> Score:
ok, why = grading.check_hit(state.output.completion, target.text, state.metadata["keywords"])
return Score(value=CORRECT if ok else INCORRECT, explanation=why)
return score
@task
def bughunt_reports():
return Task(dataset=bughunt_dataset(), solver=generate(),
scorer=[report_schema(), hits_injected_bug()])
# 两个回放模型一起跑
eval(bughunt_reports(), model=[replay_model("agent_v1"), replay_model("agent_v2")], log_dir="logs/inspect")
python run_inspect.py 的输出(逐条结果只列 v1):
== mockllm/agent_v1 status=success samples=10
report_schema accuracy=0.800 stderr=0.1333
hits_injected_bug accuracy=0.600 stderr=0.1633
s01 schema=C hit=C 命中 BUG-001
s02 schema=C hit=I 没对上 BUG-002:缺关键词 ['叠加']
s03 schema=I hit=C 命中 BUG-003
s04 schema=C hit=C 命中 BUG-004
s05 schema=C hit=I 漏报:应发现 BUG-005,Agent 报告 no_bug
s06 schema=C hit=C 命中 BUG-006
s07 schema=I hit=I 输出不是 JSON 对象,无法判定
s08 schema=C hit=C 命中 BUG-008
s09 schema=C hit=I 误报:该模块没有注入 Bug,Agent 报了「帮助页加载较慢」
s10 schema=C hit=C 该模块没有注入 Bug,Agent 也没报
== mockllm/agent_v2 status=success samples=10
report_schema accuracy=0.900 stderr=0.1000
hits_injected_bug accuracy=0.800 stderr=0.1333
只用命令行、不给 custom_outputs 时(inspect eval inspect_bughunt.py@bughunt_reports --model mockllm/model),每条输出都是那句默认文本,两个 scorer 都是 0.000。这条命令适合检查任务能不能跑通,回放录制得走 Python API,因为命令行的 -M 传不了函数。
6promptfoo 版
同一份数据、同一份评分规则,写成 YAML。provider 是一个 Python 文件,call_api 按 context["vars"]["id"] 回放报告;断言一条用内置的 is-json(schema 从同一个文件读),一条用 python 断言调 grading.check_hit:
prompts:
- '任务 {{id}}:你是测试 Agent。请探索被测应用的 {{module}} 模块,按 JSON 提交一份 Bug 报告;没发现 Bug 就提交 {"verdict": "no_bug"}。'
providers:
- id: file://promptfoo_provider.py
label: agent_v1
config: {recording: agent_v1}
- id: file://promptfoo_provider.py
label: agent_v2
config: {recording: agent_v2}
defaultTest:
options:
disableVarExpansion: true # 为什么要这一行,见下一节
assert:
- type: is-json
value: file://data/report_schema.json
metric: report_schema
- type: python
value: file://promptfoo_assert.py
metric: hits_injected_bug
tests: file://promptfoo_tests.py:generate_tests
PROMPTFOO_PYTHON=.venv/bin/python npx promptfoo@0.123.1 eval --no-cache -o logs/promptfoo_results.json 的汇总:
Running 20 test cases (up to 4 at a time)...
Results:
✓ 12 passed (60.00%)
✗ 8 failed (40.00%)
0 errors (0%)
导出的 JSON 里按 provider 拆开:agent_v1 是 5 pass、score 合计 7,namedScores report_schema: 8, hits_injected_bug: 6;agent_v2 是 7 pass、score 8.5,namedScores 9, 8。namedScores 和 Inspect 的 accuracy 对得上(8/10、6/10、9/10、8/10),pass 数却是 5 和 7,不是 6 和 8。
有用例失败时 promptfoo eval 的退出码是 100(官方 command-line 文档),CI 可以直接用它挡合并;脚本里用 set -e 时要记得处理。
7坑:数组变量被展开成多条用例
第一次跑时没有 disableVarExpansion,结果是:
Results:
✓ 23 passed (63.89%)
✗ 13 failed (36.11%)
0 errors (0%)
10 条样本、2 个 provider,应该是 20 条结果,实际是 36 条。原因是 keywords 是数组,promptfoo 默认把数组型 vars 展开成多条用例,每个元素一条(官方 test-cases 文档:"By default, array variables expand into multiple test cases")。8 条有两个关键词的样本各变成 2 条,2 条空数组的样本各 1 条,每个 provider 18 条。
更麻烦的是判定也变了。展开后 keywords 是单个字符串,比如 "叠加";Python 里 for k in "叠加" 按字符迭代,规则变成"包含'叠'和'加'"。agent_v1 的 s02 本来没命中(缺"叠加"),展开出来的 "优惠券" 那一条却判成了命中(理由 All assertions passed);"叠加" 那一条按字符检查,「加」出现在「加入任意商品」里,只缺「叠」(理由 缺关键词 ['叠'])。分母变了,判定也悄悄变宽,通过率从 60.00% 变成 63.89%,没有任何报错。
compare_frameworks.py 先数两边的判定个数(40 对 40),再逐条比较。8对账与口径
compare_frameworks.py 把两边的逐条判定对齐:
逐条判定:Inspect 40 个,promptfoo 40 个,不一致 0 个
口径对照(Inspect | promptfoo):
agent_v1 0.800 | 0.800 report_schema 通过率
agent_v1 0.600 | 0.600 hits_injected_bug 通过率
agent_v1 0.500 | 0.500 两条都过(promptfoo 的 pass)
agent_v1 — | 7.000 score 合计(promptfoo,按断言均分)
agent_v2 0.900 | 0.900 report_schema 通过率
agent_v2 0.800 | 0.800 hits_injected_bug 通过率
agent_v2 0.700 | 0.700 两条都过(promptfoo 的 pass)
agent_v2 — | 8.500 score 合计(promptfoo,按断言均分)
同一份逐条判定,至少有三种汇总:
| 口径 | 谁默认报 | v1 | v2 |
|---|---|---|---|
| 每个评分器各自的通过率 | Inspect(每个 scorer 一行 accuracy);promptfoo 的 namedScores | 0.8 / 0.6 | 0.9 / 0.8 |
| 一条用例的断言全部通过 | promptfoo 的 pass(不设 threshold 时) | 5 / 10 | 7 / 10 |
| 每条用例的断言加权平均(再对用例求和) | promptfoo 的 score | 7.0(均值 0.70) | 8.5(均值 0.85) |
promptfoo 还能改汇总规则:断言的 weight(默认 1,设 0 时该断言自动通过),用例的 threshold(设了以后按加权分数 ≥ threshold 判 pass)。不设 threshold 时任何一个断言失败整条用例就失败,这一点文档没直接写,源码确认:promptfoo 0.123.1 dist/src/evaluator-DlYW7Rgb.js:1359-1363,score 是 totalWeight > 0 ? totalScore / totalWeight : 0,pass 默认是 !this.failedReason,设了 threshold 才改成 score >= this.threshold;weight 0 的断言在 :5790 被强制改成通过。promptfoo_variants.sh 只改这几行,实测:
| 变体 | v1 pass | v2 pass |
|---|---|---|
| 默认(全部断言通过) | 5 / 10 | 7 / 10 |
| hits_injected_bug 的 weight 设 0 | 8 / 10 | 9 / 10 |
| threshold 0.5(两条过一条就算) | 9 / 10 | 10 / 10 |
| hits 的 weight 设 2,threshold 0.5 | 6 / 10 | 8 / 10 |
| 忘了 disableVarExpansion | 10 / 18 | 13 / 18 |
threshold 设成 0.5,v2 就"全部通过"了,可它有一条漏报、一条误报。汇总规则是评测设计的一部分,要写进报告,换框架时要先对齐。
910 条样本能说明什么
Inspect 的 stderr() 是样本标准差(ddof=1)除以 \(\sqrt{n}\)(0.3.273 安装包 inspect_ai/scorer/_metrics/std.py:146-163)。对 0/1 分数,本文推导可得它等于
\(\hat p = 0.9,\ n = 10\) 时 SE = 0.1,和输出一致(单元测试里也断言了这个等式)。拿 \(\hat p \pm 1.96\,\mathrm{SE}\) 当 95% 区间,上限是 1.096,超出了 [0, 1]。另外,第 1 周的教科书标准误是 \(\sqrt{\hat p(1-\hat p)/n}\),Inspect 的 stderr 比它大 \(\sqrt{n/(n-1)}\) 倍(n = 10 时约 1.054 倍),差别来自 ddof=1。小样本、比例接近 0 或 1 时,用第 1 周「30 次挂了 2 次,失败率在什么范围?」讲的 Wilson 区间:
| 通过数 / 10 | Inspect stderr | ±1.96·SE | Wilson 95% |
|---|---|---|---|
| 5 | 0.1667 | [0.173, 0.827] | [0.237, 0.763] |
| 6 | 0.1633 | [0.280, 0.920] | [0.313, 0.832] |
| 7 | 0.1528 | [0.401, 0.999] | [0.397, 0.892] |
| 8 | 0.1333 | [0.539, 1.061] | [0.490, 0.943] |
| 9 | 0.1000 | [0.704, 1.096] | [0.596, 0.982] |
v1 的命中率 0.6、v2 的 0.8,两个 Wilson 区间大面积重叠。两版跑的是同一批样本,比较要用配对方法,本周「换个模型上线,离线回归和线上 A/B 各管什么?」那一讲会专门讲;这里只说一句:10 条样本的评测适合当回归冒烟,不适合下"v2 更好"的结论。promptfoo 本次的汇总输出里只有通过数和通过率,没有标准误或区间,要自己从导出的 JSON 算。
0.3.273 里还有一个 ci() 指标(同一文件 :171 起),默认按 \(\hat p \pm t\cdot\mathrm{SE}\) 算 t 区间;它和正态近似一样是对称区间,\(\hat p = 0.9,\ n = 10\) 时实测给出 [0.674, 1.126],上限同样超过 1。本章没有把它挂到 scorer 上。
10怎么选(笔者分析)
| 更适合 Inspect | 更适合 promptfoo |
|---|---|
| 评测本身要写很多 Python:多轮 agent、工具调用、沙箱里执行代码 | 主要是比 prompt × 模型的矩阵,配置交给不写 Python 的同事也能改 |
| 要逐条看完整 transcript 做错误分析 | 要在 CI 里挡回归,退出码直接可用 |
| 想要每个评分器单独的指标和标准误 | 想顺手做红队扫描(第 8 周) |
两种情况都成立的一条:评分逻辑放在框架外面。本章的 grading.py 不 import 任何框架,Inspect 的 scorer 和 promptfoo 的断言都只是薄薄一层包装。换框架、升级框架时,评分规则不动,跑一遍对账脚本就知道有没有漂。
还有两条工程习惯:版本钉死(本章写 npx promptfoo@0.123.1、inspect-ai==0.3.273,@latest 下次可能就不是同一个版本);评分函数自己有单元测试(test_grading.py 7 个测试,包括"keywords 变成字符串会判错"这一条)。
▶互动演示:同一批判定,换一种汇总
下面的数据就是实验里的 10 条样本和两个版本的录制报告(合成数据)。页面用 JavaScript 重新实现了两条评分规则和 promptfoo 的汇总规则(weight、threshold、数组展开),数字都是实时算的;默认设置和各个变体的结果已经和真实运行的输出逐个核对过。点表格里的一行可以看原始报告。
✎练习
11面试要点
一句话讲清楚
评测框架做的是数据集、调模型、评分、汇总、日志这些杂活。Inspect 是 Python 写 Task(dataset、solver、scorer),适合 agent 和需要细看 transcript 的评测;promptfoo 是 YAML 配置 prompt × provider × 断言,适合做矩阵对比和 CI 门禁。我把评分逻辑放在框架外面,两边都调同一个函数,逐条对账 40 个判定 0 个不一致;但两边默认的汇总口径不同,一个报每个评分器的通过率,一个要求全部断言通过,所以总分不能直接比。
追问准备
- 离线怎么测评测本身?Inspect 用
mockllm加custom_outputs(列表、生成器或函数);promptfoo 写一个 Python provider 回放录制。评分函数单独写单元测试。 - 两个框架分数对不上,先查什么?先查条数(数组展开、过滤、重复跑),再逐条比判定,最后比汇总口径(全部通过、按评分器、加权平均、threshold)。
- Inspect 的 stderr 能直接当置信区间用吗?±1.96·SE 是正态近似,n 小、比例接近 0 或 1 时会出界(0.9 时上限 1.096),换 Wilson;比较两个版本用配对方法。
- LLM 评审在两个框架里怎么接?Inspect 用
model_graded_qa(model=...),promptfoo 用llm-rubric加options.provider。评审模型本身要按第 6 周的方法校准。
常见错误说法
❌ "promptfoo 的 pass 率就是准确率":不设 threshold 时它是"所有断言都通过"的比例,和单个评分器的通过率不是一回事。
❌ "Inspect 报了 stderr,±2 倍就是区间":小样本会出界,0.9 ± 1.96 × 0.1 上限 1.096。
❌ "评测跑完没报错,结果就可信":数组展开让用例从 20 条变成 36 条,通过率从 60.00% 变成 63.89%,全程没有报错。
❌ "用框架就不用写测试了":评分函数是评测里最容易错的代码,要有自己的单元测试。
代码在学习目录 week07_回放评测与模型对比/code/eval_frameworks/:grading.py(评分规则)、inspect_bughunt.py 与 run_inspect.py、promptfooconfig.yaml 与三个 promptfoo_*.py、compare_frameworks.py、promptfoo_variants.sh、test_grading.py,run_all.sh 一次跑完。
公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。