跳到主要内容

数据截至 (上游 commit 8b292c9f1b14)

第 1 章 任务模型 —— TaskData 与 Taskset

本章讲什么: verifiers 里「一道题」到底长什么样——数据和行为为什么劈成两半、打分钩子怎么注册和被覆盖、题库怎么做到「生成器当题库」还能复现采样。

1. 为什么把「题」劈成数据和行为两半

v1 的第一刀切在任务定义上:

  • TaskData——线上跑的那一半。 一个 frozen pydantic 模型(verifiers/v1/task.py:80-81),只装可序列化的题目数据:prompt、system_prompt、Docker 镜像、工作目录、网络白名单、要跨 runtime 搬运的产物清单、超时和资源需求。它会被序列化后从客户端发往 worker,也会原样记进 traces.jsonltrace.task.data(模块 docstring,task.py:1-8)。
  • Task——行为那一半。 同样是这个类实例(task.py:137),挂着生命周期钩子(setup/finalize/validate)和打分方法(@reward/@metric)。它留在出题端,worker 拿到数据卡后由 taskset 重新构造出来。

好处很直接:线上只流不可变数据,行为(打分代码)不存在序列化/反序列化的安全问题,也不存在「worker 上的版本和出题端不一致」的问题。

2. TaskData:题目卡里有什么

除了 prompt,这张卡还替执行环境把需求说全了(task.py:80-117):

字段干什么备注
prompt / system_prompt初始用户 prompt / 系统 promptprompt=None 表示由「用户」先开口(task.py:90-93)
image / workdir容器镜像 / 工作目录只对容器类任务有意义(:95-98)
network_allow / network_block该题执行时的出网白/黑名单["*"] 不动 runtime 默认;prime runtime 要 host 级条目且需 vm=true(:100-108)
artifacts要从 runtime 收回的文件路径收集时缺失 = rollout 直接判失败(:110-114)
timeout / resources分阶段超时 / CPU·内存·GPU·磁盘TaskTimeout(:65-77)、TaskResources(:50-62)

还有两个只读但关键的身份字段:hash 是题目卡内容的 SHA-256(task_key,task.py:45-47),key 默认等于 hash,但当数据里带运行期字段(比如自增 idx)时应覆盖成数据集的持久 ID——--resume 靠它认出「这道题跑过了」(:151-159,key 在 taskset 内必须唯一)。

3. Task:行为都在钩子上

Task 的四个可覆盖点(task.py:166-173):setup(trace, runtime)(开局搭环境)、finalize(trace, runtime)(收尾)、validate(runtime)(这道题当前环境能不能跑)、以及打分 score

打分不是一个大函数,而是一堆小钩子:

class AdditionTask(vf.Task[AdditionData]): # 示意,非源码(节选自官方示例)
@vf.reward
async def exact_match(self, trace: vf.Trace) -> float:
return float(trace.last_reply == str(self.data.answer))

Task.score 的执行顺序(task.py:175-243):

  1. 收集 @metric@reward 钩子,外加 config 里插进来的 judge;
  2. 没有 runtime 时自动跳过声明了必需 runtime 参数的信号(:180-210)——这让同一份打分代码既能在线(跑完就评)也能离线(只有 trace 文件)用;
  3. 先把所有信号名 seed 进 trace.metrics/trace.rewards(占位,缺测也能看出少了谁),再并发执行,逐个 record_reward(key, value, weight) 落账(:212-243)。

钩子的合并规则值得单独说(Task.hooks,task.py:258-276):config 里插进来的同名函数会替换装饰器方法,再按「优先级降序、名字升序」排序。也就是说,taskset 作者给默认打分,用户可以在 TOML/CLI 里不改代码就换评分函数——评测的可比性和可定制性各退一步又各进一步。

4. Taskset:只会一件事——出题

Taskset 是个抽象类,唯一的抽象方法是 load() -> Iterable[TaskT](verifiers/v1/taskset.py:50-52)。泛型参数把 task 类型和 config 类型钉死(Taskset[TaskT, ConfigT]),一个 taskset 只出一种 task(模块 docstring,taskset.py:1-13)。

迭代路径和加载路径是分开的(__iter__,taskset.py:54-62):读的时候先套上 config 层的 system_prompt 覆盖(with_system_prompt,task.py:161-164),再应用视图变换。

4.1 惰性视图:head / shuffle

load() 可以是生成器——题库可以无限(比如程序生成题)。配套的视图机制(taskset.py:64-95):

  • head(n):惰性截前 n 题,eval -n 5 只构造 5 道题,不是全量(:74-78);
  • shuffle():默认用全库共享的固定种子 SEED = 0(:33),所以每次 --shuffle 采到的都是同一批——可复现;无限题库必须先 head(n) 再 shuffle,否则直接抛错(:80-88);
  • view(transform):浅拷贝叠加迭代变换,多个视图可组合(:64-72)。
# 示意,非源码
tasks = MyTaskset(config).head(200).shuffle() # 先截 200 题,再可复现地打乱

4.2 一个真实任务集长什么样

参考实现是 environments/alphabet_sort/alphabet_sort/taskset.py:多轮排序题,load 是无限生成器;任务不自带 prompt(prompt=None),由 env 里一个「脚本化用户」逐轮把预生成的追加重排指令发给 assistant(模块 docstring,该文件 1-21 行)。它示范了 v1 任务的两个非显然能力:用户可以先开口,以及题库可以是生成器

工具也在任务模型里:Task.toolsets(config) / Taskset.toolsets(config) 是 classmethod,声明要拉起哪些 MCP 工具服务器(task.py:279-289taskset.py:101-111)。是 classmethod 而非实例方法,因为 env 要在任何任务实例存在之前就知道要不要为它们开隧道(env.py:376-384 的注释)。