评测主循环:从 eval() 到一条样本打完分
30 秒导读: 你调一次
eval(task, model=...),Inspect 会把这个"任务"逐层拆开——先拆成"任务 × 模型"若干执行单元,再拆成"数据集 × epoch"若干条样本,最后每条样本独立走一遍:setup → 一串 solver → 一串 scorer → 写日志。本章就讲清这条从顶层入口到"一条样本打完分"的主线,它是理解其余各章的地基。
本章 是 Inspect AI 全景 下的骨架章。读完你应该能在脑子里画出:一次 eval() 到底流经哪几个函数、每一层负责切分什么、一条样本从生到死经历哪些步骤。至于每一步内部怎么实现——solver 协议(→02)、模型怎么生成(→03)、scorer 怎么聚合成指标(→05)——留给后续各章,本章只把主干打通。
1. 一句话骨架:一个四层漏斗
先给最顶层的心智模型。整个评测就是一个漏斗:上面是你写的一个 Task,下面是成百上千次"一条样本的完整评测",中间靠四层函数逐层拆分和调度。
eval() 同步入口 — 启动 anyio 事件循环,其余全是 async
└─ eval_async() 解析 model / tasks / 各种 config,建好日志 recorder
└─ eval_run() 编排一批任务:装 logger、起 sandbox、按并发调度
└─ task_run() 单个任务:切数据集、装 plan+scorer、按 (epoch × 样本) 铺开
└─ task_run_sample() 单条样本的一生:setup→solver→scorer→写日志
四层各自"拆掉一个维度",职责清清楚楚:
| 层 | 函数(符号) | 拆掉的维度 | 产出 |
|---|---|---|---|
| L1 同步壳 | eval | 无(只负责起事件循环) | list[EvalLog] |
| L2 解析装配 | eval_async | 把 tasks × model 解析成一组 ResolvedTask | 交给 L3 编排 |
| L3 编排调度 | eval_run → run_multiple | 任务 × 模型 → 并发执行单元 | 每个单元一份 EvalLog |
| L4 单任务 | task_run | 数据集 × epoch → 一条条样本 | 一份 EvalLog(含所有样本 + 指标) |
| L5 单样本 | task_run_sample | 无(最内核) | 一条样本的分数 dict[str, SampleScore] |
依据:src/inspect_ai/_eval/eval.py:eval(109)、eval_async(392);src/inspect_ai/_eval/run.py:eval_run(101)、run_multiple(501);src/inspect_ai/_eval/task/run.py:task_run(324)、task_run_sample(1037)。
记住这张漏斗图,后面每一节都是在放大其中一层。
2. Task:一次评测的完整定义
漏斗最上面那个东西,是一个 Task。它不是"跑一次"的动作,而是"要跑什么"的完整声明——数据、怎么解、怎么打分,全打包在一个对象里。
2.1 Task 里装了什么
Task.__init__ 的参数很多,但可以归成四组。理解这四组,就理解了一次评测需要你交代清楚的四件事。
| 组 | 关键字段 | 白话 |
|---|---|---|
| 数据 | dataset | 要评的样本集(见 2.3) |
| 怎么解 | setup / solver | solver 是主求解链(默认 generate(),即"就调一次模型");setup 是即使替换了 solver 也照跑的前置步骤 |
| 怎么打分 | scorer / metrics / epochs | scorer 给每条样本打分,epochs 决定每条样本重复几遍、分数怎么归并 |
| 护栏与环境 | model / sandbox / fail_on_error / 各种 *_limit | 默认模型、沙箱、容错阈值、每样本的消息/令牌/时间等上限 |
构造函数把这些逐一存成实例属性,途中做一些规整(resolve):数据序列包成 Dataset、solver 列表串成一条 chain、epochs 整数升格成 Epochs 对象。
# 示意,非源码 —— Task 构造时对入参的规整
self.dataset = resolve_dataset(dataset) # list[Sample] → MemoryDataset
self.solver = resolve_solver(solver) # list[Solver] → chain(...);Agent → as_solver
self.scorer = resolve_scorer_metrics(...) # 把自定义 metrics 挂到 scorer 的注册信息上
epochs = resolve_epochs(epochs) # int → Epochs(int)
真实实现:src/inspect_ai/_eval/task/task.py:Task.__init__(67),属性赋值集中在 179-209 行;规整用的辅助函数 resolve_dataset(461)、resolve_solver(474)、resolve_epochs(453) 都在同文件下方。重点看:Task 只是把配置存起来,不跑任何东西——真正跑是 L4/L5 的事。
附带一提:
task_with(...)(task.py:246)能就地改写一个已有 Task 的某些字段并返回它,常用于给同一个任务生成多个变体。