系列前三篇都是图纸:决策地图回答选什么,施工图回答怎么搭,数据规格书回答建什么表。但图纸不产手感——看完还是不知道周一早上打开空目录,第一个文件写什么。这篇就是抡锤子:七步,每步给完整代码 + 我本机跑出来的真实输出 + 「你应该看到什么」。跟着敲,30 分钟后你会有一个能跑的评测系统,而且你会发现它和施工图里的 M6/M7/M8 是同一个东西的最小版本。
先声明证据档位:本文所有代码和输出都在我本机亲手跑通(inspect-ai 0.3.254,Python 3.14 venv,2026-08-09),属于本站证据分级的第一档。文中没有任何真实模型的分数——教程用 Inspect 内置的 mock 模型跑管道,你换上自己的 API key 才会得到属于你的数字。
第 0 步:先装心智模型,再装软件
Inspect AI(英国 AISI 出品)把一次评测抽象成一个三元组,整个框架都是围绕它长的:
flowchart LR
D["Dataset<br>一组带答案的题"] --> S["Solver<br>让模型作答"]
S --> O["Completion<br>模型输出"]
O --> SC["Scorer<br>判分"]
SC --> R["Score<br>C 或 I"]
R --> L["Log<br>.eval 日志与报告"]
Task = Dataset × Solver × Scorer。对照施工图:Dataset 就是 M6(评测数据集)的最小版本,Scorer 就是 M7(评分器体系),跑 Task 并落日志的引擎就是 M8(评测执行引擎)。你今天搭的不是玩具,是那十个模块里评测层的种子。
第 1 步:环境(2 分钟)
mkdir eval-lab && cd eval-lab
python3 -m venv .venv
.venv/bin/pip install inspect-ai
.venv/bin/inspect --version # 我装到的是 0.3.254
第 2 步:金集 mini 版——4 条题就够起步
建 dataset/faq.jsonl,每行一条样本。字段名刻意对齐数据规格书的样本 schema:id 是样本主键,task_class 是任务类目,source_trace_id 是血缘字段——教程里它是假的,生产里它指回真实 trace:
{"id": "smp-001", "question": "vLLM 的 PagedAttention 机制主要为了减少哪种硬件资源的浪费?直接答资源名。", "answer": "显存", "task_class": "faq_zh", "source_trace_id": "tr-demo-001"}
{"id": "smp-002", "question": "HTTP 状态码 429 通常表示客户端触发了什么机制?", "answer": "限流", "task_class": "faq_zh", "source_trace_id": "tr-demo-002"}
{"id": "smp-003", "question": "LLM 推理指标 TTFT 的英文全称是什么?", "answer": "time to first token", "task_class": "faq_zh", "source_trace_id": "tr-demo-003"}
{"id": "smp-004", "question": "KV cache 的本质是用什么换什么:显存换算力,还是算力换显存?", "answer": "显存换算力", "task_class": "faq_zh", "source_trace_id": "tr-demo-004"}
出题原则只有一条:答案里有一个必然出现的关键短语——这样第一版评分器可以用最硬的字符串包含判分,不用请 LLM 裁判(Oracle 梯度:能用便宜的验证就绝不用贵的)。
第 3 步:第一跑——零分,但管道通了
建 faq_task.py(完整文件,可直接复制):
from inspect_ai import Task, task
from inspect_ai.dataset import FieldSpec, json_dataset
from inspect_ai.scorer import includes
from inspect_ai.solver import generate, system_message
@task
def faq_zh():
return Task(
dataset=json_dataset(
"dataset/faq.jsonl",
FieldSpec(
input="question", # 你的字段名 → Inspect 的标准字段
target="answer",
id="id",
metadata=["task_class", "source_trace_id"],
),
),
solver=[
system_message("你是简洁的技术问答助手,直接给出答案,不要展开。"),
generate(),
],
scorer=includes(), # 判分:target 出现在输出里即算对
)
跑它——注意模型名是 mockllm/model,这是 Inspect 内置的假模型,不要任何 API key,永远回一句固定话。它的用途就是把管道和模型解耦,先验证管道:
.venv/bin/inspect eval faq_task.py --model mockllm/model
我跑出来的真实输出(截取关键部分):
╭──────────────────────────────────────────────────╮
│faq_zh (4 samples): mockllm/model │
╰──────────────────────────────────────────────────╯
total time: 0:00:03
mockllm/model 297 tokens [I: 165, O: 132]
includes
accuracy 0.000
stderr 0.000
Log: logs/2026-08-09T14-25-29-00-00_faq-zh_DXMQEYeEmfuLv8TkQqN3pu.eval
accuracy 0.000 就是本步的成功标志。 假模型答非所问,零分天经地义;但报告出来了、日志落盘了、四条样本每条都有判分记录——管道通了。把”管道通”和”分数好”当成两个独立的里程碑,是评测工程的第一课:以后任何一次改动(换数据集、换评分器、升级 Inspect 版本),都先用 mockllm 跑一遍管道自检,不花一分钱 API 费。
第 4 步:换真模型 = 换一个命令行参数
管道通了,换真模型只动 --model,代码一行不改:
export ANTHROPIC_API_KEY=...
.venv/bin/inspect eval faq_task.py --model anthropic/claude-sonnet-4-0
# 或 OpenAI、Google、本地 vLLM、Ollama……格式都是 provider/model-name
.venv/bin/inspect eval faq_task.py --model openai/gpt-4o-mini
更重要的是接公司网关——如果你读过施工图篇,评测引擎应该和业务走同一个 LLM 入口(M1),这样评测消耗也进成本账本。任何 OpenAI 兼容端点(LiteLLM、自建网关、vLLM serve)都用 openai-api/<服务名>/<模型名> 三段式接入:
export GATEWAY_API_KEY=sk-your-gateway-key
.venv/bin/inspect eval faq_task.py \
--model openai-api/gateway/qwen3-32b \
--model-base-url http://your-llm-gateway:4000/v1
服务名(这里是 gateway)会被 Inspect 用来找 GATEWAY_API_KEY 环境变量——这个约定我从 0.3.254 的源码里核实过。到这一步,“集成”的前半已经完成:评测引擎不再直连模型厂商,而是消费你的统一网关。
第 5 步:自定义评分器——你的第一个自研资产
内置评分器覆盖常见判分(核实自官方文档):includes / match / pattern / answer / exact / f1 / choice / math,以及两个 LLM 裁判 model_graded_qa / model_graded_fact。但业务判分逻辑早晚要自己写——开源 vs 自研分界表里,rubric 和业务评分器就在自研列。
写一个真实业务里高频的场景:结构化输出校验(模型必须输出合法 JSON 且字段齐全)。先加两条样本 dataset/extract.jsonl——注意 answer 是列表,列出必须出现的字段名:
{"id": "smp-005", "question": "从这句话抽取信息,只输出一个 JSON 对象,必须包含字段 name 和 city:小明住在杭州。", "answer": ["name", "city"], "task_class": "struct_out", "source_trace_id": "tr-demo-005"}
{"id": "smp-006", "question": "从这句话抽取信息,只输出一个 JSON 对象,必须包含字段 model 和 tokens:qwen3-32b 这次消耗了 512 个 token。", "answer": ["model", "tokens"], "task_class": "struct_out", "source_trace_id": "tr-demo-006"}
再建 extract_task.py。自定义评分器就是一个装饰器加一个异步函数,签名固定为 score(state, target):
import json
from inspect_ai import Task, task
from inspect_ai.dataset import FieldSpec, json_dataset
from inspect_ai.scorer import (
CORRECT, INCORRECT, Score, Target, accuracy, scorer, stderr,
)
from inspect_ai.solver import TaskState, generate
@scorer(metrics=[accuracy(), stderr()])
def json_fields():
"""规则评分器:输出必须是合法 JSON 且包含 target 列出的全部字段。"""
async def score(state: TaskState, target: Target) -> Score:
text = state.output.completion.strip()
try:
obj = json.loads(text)
except json.JSONDecodeError:
return Score(value=INCORRECT, answer=text[:60],
explanation="输出不是合法 JSON")
missing = [k for k in target if k not in obj]
if missing:
return Score(value=INCORRECT, answer=text[:60],
explanation=f"缺字段: {missing}")
return Score(value=CORRECT, answer=text[:60],
explanation="JSON 合法且字段齐全")
return score
@task
def struct_out():
return Task(
dataset=json_dataset(
"dataset/extract.jsonl",
FieldSpec(input="question", target="answer", id="id",
metadata=["task_class", "source_trace_id"]),
),
solver=[generate()],
scorer=json_fields(),
)
我用一个”一条答对、一条答错”的假模型验证过它的两条路径,真实输出:
smp-005 C | JSON 合法且字段齐全
smp-006 I | 输出不是合法 JSON
accuracy = 0.5
C 和 I 就是 Inspect 的 CORRECT/INCORRECT(源码里就是字符 "C" 和 "I")。注意这个评分器落在 Oracle 梯度的「规则验证」档:确定性、免费、毫秒级——它能覆盖的判分,永远不要交给 LLM 裁判。
第 6 步:双模型对比——三臂实验的雏形
评测系统的核心用法不是”给一个模型打分”,而是同一份题、同一把尺子,比较多个模型——施工图 M9 三臂对比的雏形。建 compare.py,用 mockllm 的 custom_outputs 回调造出”强/弱”两个假模型(换真模型时只改 get_model 那行):
from inspect_ai import eval as inspect_eval
from inspect_ai.model import ModelOutput, get_model
from faq_task import faq_zh
STRONG = {"PagedAttention": "显存", "429": "限流机制",
"TTFT": "Time To First Token", "KV cache": "显存换算力"}
WEAK = {"PagedAttention": "内存", "429": "服务器内部错误",
"TTFT": "首个 token 的延迟", "KV cache": "算力换显存"}
def make_mock(answers):
def respond(input, tools, tool_choice, config) -> ModelOutput:
question = input[-1].text
for key, ans in answers.items():
if key in question:
return ModelOutput.from_content("mockllm/model", ans)
return ModelOutput.from_content("mockllm/model", "不知道")
return get_model("mockllm/model", custom_outputs=respond)
for name, answers in [("strong", STRONG), ("weak", WEAK)]:
[log] = inspect_eval(faq_zh(), model=make_mock(answers), display="plain")
acc = log.results.scores[0].metrics["accuracy"].value
print(f"{name}: accuracy={acc:.2f} log={log.location}")
真实输出:
strong: accuracy=1.00 log=.../logs/2026-08-09T14-26-43-00-00_faq-zh_6X6Q....eval
weak: accuracy=0.00 log=.../logs/2026-08-09T14-26-43-00-00_faq-zh_3cF7....eval
一个实现细节值得记:custom_outputs 传的是回调(按问题内容作答)而不是答案列表——因为 Inspect 并发跑样本,列表按消费顺序出队,并发下会答错题;回调按题给答案,天然免疫乱序。这个坑我先替你踩了。
第 7 步:从日志里读出「请求级标签」+ 三个集成钩子
每次运行都落一个 .eval 日志。图形界面用 inspect view 打开(浏览器里逐样本看完整 transcript),程序化读取用 read_eval_log:
from inspect_ai.log import read_eval_log
log = read_eval_log("logs/2026-08-09T14-26-43-00-00_faq-zh_6X6Q....eval")
for s in log.samples:
print(s.id, {k: (v.value, v.answer) for k, v in s.scores.items()})
真实输出:
smp-001 {'includes': ('C', '显存')}
smp-002 {'includes': ('C', '限流机制')}
smp-003 {'includes': ('C', 'time to first token')}
smp-004 {'includes': ('C', '显存换算力')}
认出来了吗?(sample_id, model, score)——这就是数据规格书里的「请求级结果标签」,积累多了就是路由器的训练数据。你在第 6 步跑双模型对比时,已经在生产它了。
三个集成钩子,按投入从低到高:
① CI 门禁(对应门禁篇):一个 20 行的 gate.py——跑评测、读 accuracy、低于阈值就非零退出:
import sys
from inspect_ai import eval as inspect_eval
from faq_task import faq_zh
THRESHOLD = 0.75
[log] = inspect_eval(faq_zh(), model="openai-api/gateway/qwen3-32b")
acc = log.results.scores[0].metrics["accuracy"].value
print(f"accuracy={acc:.3f} threshold={THRESHOLD}")
sys.exit(0 if acc >= THRESHOLD else 1)
挂进 GitHub Actions(prompt 或路由策略改动必须过评测才许合并):
- run: pip install inspect-ai
- run: python gate.py
env:
GATEWAY_API_KEY: ${{ secrets.GATEWAY_API_KEY }}
② nightly 回归:crontab 每晚对全模型池跑一遍金集,日志按日期归档——施工图 4.3 时序图的最小实现就是一行 cron 加一个 for 循环。
③ 新模型准入:新模型上架网关前,先跑一遍全部 task,达标才进模型注册表——把第 6 步的 compare.py 里加一个候选模型就是准入脚本。
对照表:你刚搭的东西在系列图纸里叫什么
| 你刚写的文件 | 施工图模块 | 数据规格书对应物 |
|---|---|---|
dataset/*.jsonl | M6 评测数据集 | 样本 schema(id/task_class/source_trace_id/oracle) |
includes() / json_fields() | M7 评分器体系 | oracle_type 的 rule 档 |
inspect eval + logs/*.eval | M8 执行引擎 | 评测运行记录(四元组的雏形) |
compare.py | M9 基线对比 | 请求级结果标签的生产现场 |
--model-base-url 指向网关 | M1 统一网关 | 评测流量并入成本账本 |
gate.py + CI yaml | 回归门禁 | Eval 是新的 PRD |
所以”从 0 到 1 做一个评测系统”这个问题的答案是:你已经做完了。剩下的从 1 到 10 不是新概念,是规模化:4 条样本长到 200 条(按漏斗每周吸新)、评分器从 2 个长到四档齐全(judge 要配校准集)、手动跑变 nightly、单模型变全模型池。每一步在系列前三篇里都有图纸。
自检清单(合上文章,答得出算过关)
- 为什么第一跑得 0 分反而是成功?(管道与分数是两个独立里程碑)
mockllm/model的存在解决了什么工程问题?(管道自检与模型解耦,零 API 成本)- 样本里的
source_trace_id教程里没用到,生产里它连接什么?(评测样本 ↔ 线上 trace 的血缘) json_fields为什么不该换成 LLM 裁判?(Oracle 梯度:规则可判的绝不上 judge)- 双模型对比跑完,日志里的
(sample, model, score)在上一篇的术语里叫什么?(请求级结果标签) - 把评测从直连厂商改成走公司网关,要改哪两个东西?(
--model三段式名字 +--model-base-url,外加对应的 API key 环境变量) custom_outputs为什么用回调比用列表稳?(并发消费会乱序,回调按题作答)
诚实的提醒
- 本文全部代码与输出在 inspect-ai 0.3.254 上亲手跑通;
openai-api/<service>/<model>的 API key 查找约定核实自该版本源码。Inspect 迭代很快,未来版本 API 可能变化,以官方文档为准。 - 文中没有任何真实模型的分数——1.00 和 0.00 是我构造的假模型跑出来的,用途是验证管道和判分逻辑,不代表任何模型的真实能力。
- 4 条样本的 accuracy 没有统计意义:就算 4/4 全对,Wilson 95% 区间也宽到 [0.51, 1.00](此数字已脚本验算)——它只能告诉你”管道没坏”,不能告诉你”模型很强”。金集长到几百条之前,别拿 accuracy 做任何决策。
- 「先 mockllm 自检再换真模型」「custom_outputs 用回调」是我的实践建议,非官方规范。
下一步的最小动作(还是那半天实验,但现在你有工具了):把你自己最近 30 条真实 LLM 使用记录按第 2 步的格式写成 JSONL,挑两个价差大的模型跑第 6 步的对比,看一眼每成功任务的成本差——那份报告就是你的评测系统从教程变成资产的时刻。
参考来源
工程实践 / 官方文档
- Inspect AI 官方文档:Datasets ・ Scorers ・ Models ・ Log Viewer
- Inspect AI GitHub(UKGovernmentBEIS/inspect_ai)
- mockllm 行为、
CORRECT/INCORRECT取值、openai-api 命名约定:核实自 inspect-ai 0.3.254 安装包源码
本站相关旧文
- 数据规格书:四段链的字段级设计(本篇是它的实操陪读篇)
- 施工图:十模块六层架构 ・ 决策地图·评测路由篇
- 测试篇:Oracle 梯度定律 ・ 门禁篇:不可逆边界
- Eval 是新的 PRD