第 32 章
第 7 周:同一批 Bug 报告,Inspect 和 promptfoo 打的分一样吗?
评测框架 Inspect AI(英国 AI Security Institute)和 promptfoo:用同一份合成数据、同一个评分模块,在本机离线跑两个框架给测试 Agent 的 Bug 报告打分,逐条对账;看清两边默认汇总口径的差别、promptfoo 数组变量被展开的坑,以及 10 条样本的 stderr 能说明什么。一段讲解视频,一个可以改汇总规则的互动演示。
上一讲「把工具响应录下来回放,评测就公平了吗?」把工具响应录成 cassette,让 Agent 在固定的环境上重跑;这一讲更进一步,直接把 Agent 提交的报告录下来(合成),只关心怎么给它打分:读数据集、调模型、跑评分、汇总、存日志、比较两个版本。第 6 周的实验都是手写脚本,样本和版本一多,这些杂活就该交给评测框架。这一讲用两个开源框架做同一件事:英国 AI Security Institute 参与开发的 Inspect AI,和用 YAML 配置的 promptfoo。两个都在本机装好、离线跑过,被测"模型"只回放录好的报告,不调任何付费 API。
结论先说:两个框架调同一个评分函数,40 个逐条判定 0 个不一致;但默认的汇总口径不同,同一份判定,Inspect 的 hits_injected_bug 评分器报 0.6,promptfoo 按"全部断言通过"报 0.5。另外记录一个真实踩到的坑:promptfoo 把数组变量展开成多条用例,用例数从 20 变成 36,通过率从 60.00% 变成 63.89%,全程没有报错。本章数据都是合成数据。
讲解视频
互动演示
实验里的 10 条样本和两个版本的录制报告都在页面里。页面用 JavaScript 重写了两条评分规则和 promptfoo 的汇总规则:切换 Agent 版本,打开或关掉数组展开,调两个断言的 weight,设或不设 threshold,看 promptfoo 的用例数、pass 数和 score 怎么变,同时对照 Inspect 视角下每个评分器的通过率和 Wilson 区间。默认设置和几个变体的数字都和真实运行的输出逐个核对过。点表格里的一行可以看原始报告和判定理由。页面底部有自动判分的练习。
评测框架替你做了什么
| 环节 | 手写脚本要自己做 | 框架提供的 |
|---|---|---|
| 数据集 | 读 jsonl、拼 prompt | 样本对象(输入、标准答案、元数据),从文件加载 |
| 生成 | 调模型、并发、重试、限流 | 统一的模型 / provider 接口,并发和重试 |
| 评分 | 写判定函数 | 内置评分器(字符串匹配、JSON 校验、LLM 评审),也能挂自定义函数 |
| 汇总 | 算通过率、区间 | 按评分器 / 断言汇总的指标 |
| 日志与查看 | 自己存 trace、写查看页面 | 完整日志加一个 Web 查看器(inspect view / promptfoo view) |
| 对比 | 两份结果手工对齐 | 同一任务跑多个模型,结果并排 |
两个框架各是什么
| 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 周。
两边的概念可以一一对上:
| 概念 | 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 安装包,model 参数可以传一个列表,reducer 默认是 "majority"。
实验:同一批报告、同一份评分规则
BugHunt-Bench 的被测 Web 应用里注入了 8 个 Bug,BUG-001~BUG-004 沿用第 2 周 MCP 示例里的清单,后 4 个是新加的;另有 help、about 两个模块没有注入 Bug。测试 Agent 每个模块提交一份 JSON 报告。agent_v1、agent_v2 两个版本的报告是手写的合成录制,刻意覆盖几种情况:
| 样本 | 模块 | 标准答案 | agent_v1 | agent_v2 |
|---|---|---|---|---|
| s01 | cart | BUG-001 数量为 0 仍可下单 | 命中 | 命中 |
| s02 | cart | BUG-002 优惠券可重复叠加 | 报了别的现象(没对上) | 命中 |
| s03 | login | BUG-003 错 5 次未锁定 | 命中,但漏了 expected 字段 | 命中 |
| s04 | search | BUG-004 关键词含 % 返回 500 | 命中 | 命中 |
| s05 | profile | BUG-005 头像超 2MB 白屏 | 漏报(no_bug) | 命中 |
| s06 | checkout | BUG-006 邮编为空也能提交 | 命中 | 命中,但 steps 是空列表 |
| s07 | orders | BUG-007 第 2 页重复 | 输出是一段话,不是 JSON | 命中 |
| s08 | settings | BUG-008 切语言时间格式不变 | 命中 | 漏报(no_bug) |
| s09 | help | NONE | 误报 | 正确地报 no_bug |
| s10 | about | NONE | 正确地报 no_bug | 误报 |
评分规则写在一个不 import 任何框架的 grading.py 里,两个框架都调它:
def check_schema(output: str) -> tuple[bool, str]:
report = parse_report(output)
if report is None:
return False, "输出不是 JSON 对象"
try:
jsonschema.validate(report, SCHEMA) # data/report_schema.json
except jsonschema.ValidationError as e:
where = "/".join(str(p) for p in e.absolute_path) or "(根)"
return False, f"schema 不符:{where}: {e.message}"
return True, "格式合格"
def check_hit(output: str, target: str, keywords: list[str]) -> tuple[bool, str]:
report = parse_report(output)
if report is None:
return False, "输出不是 JSON 对象,无法判定"
verdict = report.get("verdict")
if target == NONE:
if verdict == "no_bug":
return True, "该模块没有注入 Bug,Agent 也没报"
return False, f"误报:该模块没有注入 Bug,Agent 报了「{report.get('title', '')}」"
if verdict != "bug":
return False, f"漏报:应发现 {target},Agent 报告 no_bug"
steps = report.get("steps") or []
text = " ".join([str(report.get("title", "")), *map(str, steps), str(report.get("actual", ""))])
missing = [k for k in keywords if k not in text]
if missing:
return False, f"没对上 {target}:缺关键词 {missing}"
return True, f"命中 {target}"
report_schema.json 用 JSON Schema 的 if / then:verdict 是 bug 时,module / title / steps / expected / actual / severity 都要有,steps 至少一步。关键词匹配是很粗的规则,只为了让评分可复现;真实 BugHunt 里"这份报告对应哪个注入 Bug"要靠 Judge 加人工校准,见第 6 周。本章关心的是框架怎么组织评分,不是这条规则好不好。
Inspect 版
Inspect 自带一个测试用的 mockllm provider,默认每次都回一句固定文本 Default output from mockllm/model。它的构造参数 custom_outputs 可以是 ModelOutput 的列表、生成器,或者一个函数;是函数时,每次 generate 都用 (input, tools, tool_choice, config) 调它(0.3.273 安装包 inspect_ai/model/_providers/mockllm.py:15-23 的类注释,:85-93 是调用处)。这里用函数按任务编号回放录好的报告:
PROMPT = ("任务 {id}:你是测试 Agent。请探索被测应用的 {module} 模块,按 JSON 提交一份 Bug 报告;"
"没发现 Bug 就提交 {{\"verdict\": \"no_bug\"}}。")
TASK_ID = re.compile(r"任务 (s\d+):")
def bughunt_dataset() -> list[Sample]:
return [
Sample(id=s["id"], input=PROMPT.format(**s), target=s["target"],
metadata={"module": s["module"], "keywords": s["keywords"]})
for s in grading.load_samples()
]
def replay_model(recording: str):
"""mockllm 的 custom_outputs 可以是一个函数:每次 generate 都用 (input, tools, tool_choice, config) 调它。"""
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 report_schema():
async def score(state: TaskState, target: Target) -> Score:
ok, why = grading.check_schema(state.output.completion)
return Score(value=CORRECT if ok else INCORRECT, explanation=why)
return score
@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()],
)
run_inspect.py 把两个回放模型传给同一次 eval:eval(bughunt_reports(), model=[replay_model("agent_v1"), replay_model("agent_v2")], log_dir=..., display="none")。输出:
== 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
s01 schema=C hit=C 命中 BUG-001
s02 schema=C hit=C 命中 BUG-002
s03 schema=C hit=C 命中 BUG-003
s04 schema=C hit=C 命中 BUG-004
s05 schema=C hit=C 命中 BUG-005
s06 schema=I hit=C 命中 BUG-006
s07 schema=C hit=C 命中 BUG-007
s08 schema=C hit=I 漏报:应发现 BUG-008,Agent 报告 no_bug
s09 schema=C hit=C 该模块没有注入 Bug,Agent 也没报
s10 schema=C hit=I 误报:该模块没有注入 Bug,Agent 报了「关于页版本号与发布说明不一致」
汇总写到 logs/inspect_summary.json
只用命令行、不给 custom_outputs 时,每条输出都是那句默认文本,两个 scorer 都是 0:
$ inspect eval inspect_bughunt.py@bughunt_reports --model mockllm/model --display plain
...
bughunt_reports (10 samples): mockllm/model
total time: 0:00:00
mockllm/model 810 tokens [I: 480, O: 330]
report_schema hits_injected_bug
accuracy 0.000 accuracy 0.000
stderr 0.000 stderr 0.000
这条命令适合检查任务能不能跑通。回放录制得走 Python API:命令行的 -M 只能传字面值,传不了一个 Python 函数。
promptfoo 版
同一份数据、同一份评分规则,写成 YAML:
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:
# keywords 是数组。不关掉的话,promptfoo 会把数组型 vars 展开成多条用例(每个关键词一条)
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
provider、断言和用例生成器都是几行 Python,全部委托给 grading.py:
# promptfoo_provider.py:每个 (prompt, provider, test) 组合调一次
def call_api(prompt, options, context):
recording = options["config"]["recording"]
sample_id = context["vars"]["id"]
reports = grading.load_recording(recording)
if sample_id not in reports:
return {"error": f"{recording} 里没有任务 {sample_id} 的录制"}
return {"output": reports[sample_id]}
# promptfoo_assert.py:和 Inspect 的 hits_injected_bug 用同一条规则
def get_assert(output, context):
v = context["vars"]
ok, why = grading.check_hit(output, v["target"], v["keywords"])
return {"pass": ok, "score": 1.0 if ok else 0.0, "reason": why}
# promptfoo_tests.py:从 data/samples.jsonl 读,和 Inspect 同一份数据
def generate_tests(config=None):
return [
{"description": f"{s['id']} {s['module']} → {s['target']}",
"vars": {"id": s["id"], "module": s["module"], "target": s["target"], "keywords": s["keywords"]}}
for s in grading.load_samples()
]
PROMPTFOO_PYTHON=.venv/bin/python npx promptfoo@0.123.1 eval --no-cache --no-progress-bar --no-table -o logs/promptfoo_results.json 的输出:
Cache is disabled.
Starting evaluation eval-X07-2026-10-01T11:13:40
Running 20 test cases (up to 4 at a time)...
✓ Eval complete (ID: eval-X07-2026-10-01T11:13:40)
» View results: promptfoo view
» Share with your team: https://promptfoo.app
» Feedback: https://promptfoo.dev/feedback
Results:
✓ 12 passed (60.00%)
✗ 8 failed (40.00%)
0 errors (0%)
Duration: 1s (concurrency: 4)
Writing output to logs/promptfoo_results.json
导出的 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,其他错误是 1(官方 command-line 文档,可用 PROMPTFOO_FAILED_TEST_EXIT_CODE 改)。CI 可以直接拿它挡合并;自己的 shell 脚本里开了 set -e 时要记得处理,本章的 promptfoo_variants.sh 就因为这个第一次跑时中途退出。
坑:数组变量被展开成多条用例
第一次跑时配置里没有 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”,要原样传数组得设 disableVarExpansion: true。本例 8 条有两个关键词的样本各变成 2 条,2 条空数组的样本各 1 条,每个 provider 18 条。
更麻烦的是判定也变了。展开后 keywords 是单个字符串,比如 "叠加";Python 里 for k in "叠加" 按字符迭代,规则就变成"包含’叠’和’加’"。agent_v1 的 s02 本来没命中(缺"叠加"),展开出来的 "优惠券" 那一条却判成了命中,导出的 JSON 里它的理由是 All assertions passed。"叠加" 那一条按字符检查,「加」出现在复现步骤「加入任意商品」里,只缺「叠」,理由是 没对上 BUG-002:缺关键词 ['叠']。分母变了,判定也悄悄变宽,没有任何报错。test_grading.py 里专门有一个测试把这个行为固定下来。
测开视角:先断言用例数。评测结果的第一条检查应该是"跑了几条",和"样本数 × provider 数"对不上就判失败。本章的 compare_frameworks.py 也是先数两边的判定个数,再逐条比较。
对账与口径
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 文档(expected-outputs 页)对汇总规则的说明:用例的 score 是全部断言分数的加权平均,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(有任何断言失败就是 false),设了 threshold 才改成 score >= this.threshold;weight 为 0 的断言在 :5790 被强制改成通过。本章的运行结果也是这样(agent_v1 的 s02 一过一挂,判 FAIL)。导出 JSON 里每个 provider 的 score 是这些用例分数的合计,不是平均。
promptfoo_variants.sh 只改这几行配置,其余不动,实测:
hit_weight0 agent_v1 用例 10 pass 8 score 8.0000
hit_weight0 agent_v2 用例 10 pass 9 score 9.0000
threshold05 agent_v1 用例 10 pass 9 score 7.0000
threshold05 agent_v2 用例 10 pass 10 score 8.5000
hit_weight2_threshold05 agent_v1 用例 10 pass 6 score 6.6667
hit_weight2_threshold05 agent_v2 用例 10 pass 8 score 8.3333
var_expansion_on agent_v1 用例 18 pass 10 score 13.0000
var_expansion_on agent_v2 用例 18 pass 13 score 15.5000
threshold 设成 0.5,agent_v2 就"10 / 10 全部通过"了,可它有一条漏报、一条误报、一条缺步骤。hits 的 weight 设 2、threshold 0.5 时,只有命中的用例能过(schema 过、命中挂的用例分数是 1/3),pass 数正好等于命中数。汇总规则是评测设计的一部分,要写进评测报告;换框架时,先对齐逐条判定,再对齐汇总口径,最后才比总分。
10 条样本能说明什么
Inspect 的 stderr() 是样本标准差(ddof=1)除以 \(\sqrt{n}\)(0.3.273 安装包 inspect_ai/scorer/_metrics/std.py:146-163)。对 0/1 分数,设通过率为 \(\hat p\),样本方差(ddof=1)是 \(\frac{n}{n-1}\hat p(1-\hat p)\),于是(本文推导)
\(\hat p = 0.9,\ n = 10\) 时 SE = 0.1,和 agent_v2 的 report_schema 输出一致;单元测试对四个 scorer 都断言了这个等式。如果拿 \(\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] |
agent_v1 的命中率 0.6 和 agent_v2 的 0.8,两个 Wilson 区间大面积重叠。两版跑的是同一批样本,比较它们要用配对方法,本周「换个模型上线,离线回归和线上 A/B 各管什么?」那一讲会专门讲。在这个合成设定下能说的只有一句:10 条样本的评测适合当回归冒烟,不足以下"v2 更好"的结论。promptfoo 本次的汇总输出里只有通过数和通过率,没有标准误或区间,要自己从导出的 JSON 算。
0.3.273 里还有一个 ci() 指标(同一文件第 171 行起),docstring 写的默认算法是 \(\hat p \pm t\cdot\mathrm{SE}\)(自由度 \(n-1\) 的 t 分布);它和正态近似一样是对称区间,在 \(\hat p = 0.9,\ n = 10\) 时实测给出 [0.674, 1.126],上限同样超过 1。本章没有把它挂到 scorer 上。
怎么选(笔者分析)
| 更适合 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.py7 个测试:schema 的合格 / 不合格、关键词必须全中、误报和漏报、“keywords 变成字符串会判错”,以及跑一遍 Inspect、断言四个 accuracy 和 stderr 的值。
动手实验
代码在学习目录的 week07_回放评测与模型对比/code/eval_frameworks/:
data/:samples.jsonl(10 条样本)、report_schema.json、recordings/agent_v1.json和agent_v2.json(由make_recordings.py生成的合成录制)grading.py:评分规则,只依赖 jsonschemainspect_bughunt.py、run_inspect.py:Inspect 任务和运行脚本promptfooconfig.yaml、promptfoo_provider.py、promptfoo_assert.py、promptfoo_tests.py:promptfoo 配置compare_frameworks.py:逐条对账;promptfoo_variants.sh:weight / threshold / 数组展开的变体test_grading.py:单元测试;run_all.sh:一次跑完,输出存到logs/
环境:python3.13 -m venv .venv && .venv/bin/pip install inspect-ai==0.3.273 pytest(实际装到的依赖里 jsonschema 是 4.26.0),promptfoo 用 npx promptfoo@0.123.1 临时下载,不全局安装。PROMPTFOO_PYTHON 指向 venv 里的 Python,promptfoo 调 Python 文件时才找得到 jsonschema。运行时设了 PROMPTFOO_DISABLE_TELEMETRY=1、PROMPTFOO_DISABLE_UPDATE=1。promptfoo 会在 ~/.promptfoo/ 下写本地数据库,promptfoo view 从那里读。
.venv/bin/python -m pytest -q test_grading.py:
....... [100%]
7 passed in 2.29s
常见错误说法
- “换了评测框架,分数变了,说明模型变了”:本章同一批报告、同一个评分函数,按评分器算是 0.6,按全部断言通过算是 0.5。先对齐判定和口径。
- “promptfoo 的 pass 率就是准确率”:不设 threshold 时,它是"全部断言都通过"的用例比例,和单个评分器的通过率不是一回事;设了 threshold 又是另一回事。
- “Inspect 报了 stderr,±2 倍就是区间”:小样本会出界,0.9 ± 1.96 × 0.1 上限是 1.096。用 Wilson;比较两个版本用配对方法。
- “评测跑完没报错,结果就可信”:数组展开让用例从 20 条变成 36 条,通过率从 60.00% 变成 63.89%,全程没有报错。先断言条数。
- “用了框架就不用写测试”:评分函数是评测里最容易错的代码,要有自己的单元测试;框架的版本也要钉死。
- “Inspect 只能评问答,promptfoo 只能比 prompt”:Inspect 有 agent、工具和沙箱;promptfoo 的 provider 可以是任意脚本或服务。本章两边都在评一个"Agent 提交的报告",用的都是回放的假模型。
下一章「七个配置选哪个?成本、延迟、质量的帕累托前沿」:成本、延迟和质量放在一起看。