Learning AI Quality 返回 KuthorX Blog II博客首页

第 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 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 周。

两边的概念可以一一对上:

概念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 安装包,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_v1agent_v2
s01cartBUG-001 数量为 0 仍可下单命中命中
s02cartBUG-002 优惠券可重复叠加报了别的现象(没对上)命中
s03loginBUG-003 错 5 次未锁定命中,但漏了 expected 字段命中
s04searchBUG-004 关键词含 % 返回 500命中命中
s05profileBUG-005 头像超 2MB 白屏漏报(no_bug)命中
s06checkoutBUG-006 邮编为空也能提交命中命中,但 steps 是空列表
s07ordersBUG-007 第 2 页重复输出是一段话,不是 JSON命中
s08settingsBUG-008 切语言时间格式不变命中漏报(no_bug)
s09helpNONE误报正确地报 no_bug
s10aboutNONE正确地报 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,按断言均分)

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

口径谁默认报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 文档(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)\),于是(本文推导)

$$ \mathrm{SE} = \sqrt{\frac{\hat p(1-\hat p)}{n-1}} $$

\(\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 区间:

通过数 / 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]

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.py 7 个测试: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:评分规则,只依赖 jsonschema
  • inspect_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 提交的报告",用的都是回放的假模型。

下一章「七个配置选哪个?成本、延迟、质量的帕累托前沿」:成本、延迟和质量放在一起看。