同一批 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 AIpromptfoo
出品官网写的是 "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 AIpromptfoo
一条样本Sample(id, input, target, metadata)tests 里的一条:vars(可带自己的 assert)
样本从哪来直接传 list,或 json_dataset / csv_dataset内联、文件,或 Python 生成器(本章用 file://promptfoo_tests.py:generate_tests)
怎么得到输出solver(本章就是 generate())调 modelprompt 模板渲染后交给 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 里,两个框架都调它:

  1. report_schema:输出是 JSON,并且符合 report_schema.json。verdict 是 bug 时,module / title / steps / expected / actual / severity 都要有,steps 至少一步。
  2. hits_injected_bug:标准答案是某个注入 Bug 时,verdict 必须是 bug,并且 title、steps、actual 里包含它的全部关键词;标准答案是 NONE 时,必须报 no_bug,否则算误报。
关键词匹配是很粗的规则,只为了让评分可复现;真实 BugHunt 里"这份报告对应哪个注入 Bug"要靠 Judge 加人工校准(第 6 周)。本章关心的是框架怎么组织评分,不是这条规则好不好。

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%,没有任何报错。

测开视角:先断言用例数。评测结果的第一条检查应该是"跑了几条",和数据集条数 × provider 数对不上就判失败。本章的 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,按断言均分)

同一份逐条判定,至少有三种汇总:

口径谁默认报v1v2
每个评分器各自的通过率Inspect(每个 scorer 一行 accuracy);promptfoo 的 namedScores0.8 / 0.60.9 / 0.8
一条用例的断言全部通过promptfoo 的 pass(不设 threshold 时)5 / 107 / 10
每条用例的断言加权平均(再对用例求和)promptfoo 的 score7.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 passv2 pass
默认(全部断言通过)5 / 107 / 10
hits_injected_bug 的 weight 设 08 / 109 / 10
threshold 0.5(两条过一条就算)9 / 1010 / 10
hits 的 weight 设 2,threshold 0.56 / 108 / 10
忘了 disableVarExpansion10 / 1813 / 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 分数,本文推导可得它等于

$$\mathrm{SE} = \sqrt{\frac{\hat p(1-\hat p)}{n-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 区间:

通过数 / 10Inspect stderr±1.96·SEWilson 95%
50.1667[0.173, 0.827][0.237, 0.763]
60.1633[0.280, 0.920][0.313, 0.832]
70.1528[0.401, 0.999][0.397, 0.892]
80.1333[0.539, 1.061][0.490, 0.943]
90.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、数组展开),数字都是实时算的;默认设置和各个变体的结果已经和真实运行的输出逐个核对过。点表格里的一行可以看原始报告。

Agent
数组 vars
threshold
Inspect · report_schema
Inspect · hits_injected_bug
promptfoo 用例数
promptfoo pass
promptfoo score 合计
两版合计(CLI 底部那行)
选中行的原始报告(provider 回放的输出)与判定理由

✎练习

题 1(口径):promptfoo 一条用例有两个断言,没有设 threshold,权重都是默认值。is-json 通过,python 断言失败。这条用例算什么?
本章实测:agent_v1 的 s02(schema 过、命中没过)算 fail,所以 v1 是 5 pass 而不是 6;score 按两个断言均分记 0.5,v1 合计 7.0。要让"过一半就算",得显式设 threshold: 0.5,这时 v1 变成 9 pass。
题 2(算):把 hits_injected_bug 的 weight 设成 2,report_schema 保持 1。一条用例 schema 通过、命中失败,它的 score 是多少?
加权平均:(1 × 1 + 2 × 0) / (1 + 2) = 1/3 ≈ 0.33。再设 threshold 0.5,这条就不过;只有命中的用例(分数 ≥ 2/3)能过,所以实测 v1 是 6 pass,正好等于它的命中数。
题 3(算):Inspect 的 stderr 用样本标准差(ddof=1)除以 √n。10 条样本里 9 条 CORRECT,stderr 是多少?
0/1 数据时 SE = √(p̂(1 − p̂)/(n − 1)) = √(0.9 × 0.1 / 9) = √0.01 = 0.1,和 agent_v2 的 report_schema 输出一致。0.9 ± 1.96 × 0.1 的上限是 1.096,超过 1,所以小样本要用 Wilson 区间:[0.596, 0.982]。
题 4(排查):数据集 10 条、2 个 provider,promptfoo 却报了 36 条结果,通过率还变高了。最可能的原因?
promptfoo 默认把数组型 vars 展开(每个元素一条)。本章的 keywords 有两个元素的 8 条样本各变 2 条,加上 2 条空数组样本,每个 provider 18 条。修法是 defaultTest.options.disableVarExpansion: true;更根本的防线是断言"结果条数 = 样本数 × provider 数"。
题 5(工程):团队准备从 promptfoo 迁到 Inspect。哪种做法最稳?
B 的问题:两个框架默认口径不同(本章 0.6 对 0.5),"差不多"既可能是逻辑不一致被口径差掩盖,也可能是一致的逻辑被口径差放大。逐条对账能把这两件事分开。C 丢掉了历史基线,回归就没法比。

11面试要点

一句话讲清楚

评测框架做的是数据集、调模型、评分、汇总、日志这些杂活。Inspect 是 Python 写 Task(dataset、solver、scorer),适合 agent 和需要细看 transcript 的评测;promptfoo 是 YAML 配置 prompt × provider × 断言,适合做矩阵对比和 CI 门禁。我把评分逻辑放在框架外面,两边都调同一个函数,逐条对账 40 个判定 0 个不一致;但两边默认的汇总口径不同,一个报每个评分器的通过率,一个要求全部断言通过,所以总分不能直接比。

追问准备

  1. 离线怎么测评测本身?Inspect 用 mockllm 加 custom_outputs(列表、生成器或函数);promptfoo 写一个 Python provider 回放录制。评分函数单独写单元测试。
  2. 两个框架分数对不上,先查什么?先查条数(数组展开、过滤、重复跑),再逐条比判定,最后比汇总口径(全部通过、按评分器、加权平均、threshold)。
  3. Inspect 的 stderr 能直接当置信区间用吗?±1.96·SE 是正态近似,n 小、比例接近 0 或 1 时会出界(0.9 时上限 1.096),换 Wilson;比较两个版本用配对方法。
  4. LLM 评审在两个框架里怎么接?Inspect 用 model_graded_qa(model=...),promptfoo 用 llm-rubric 加 options.provider。评审模型本身要按第 6 周的方法校准。

常见错误说法

❌ "换了评测框架,分数变了,说明模型变了":本章同一批报告,按评分器算是 0.6,按全部断言通过算是 0.5。
❌ "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 源码,交互部分不受影响。