v1 新架构:Taskset / Harness / Runtime / Interception
这章讲什么: 前三章讲的是经典 API(
verifiers/envs/)。这一章讲verifiers/v1/的新架构——它把「环境」这个单一对象拆成了几个正交的部件,并解决了经典 API 在「大规模、多沙箱、多 agent harness」场景下的痛点。
1. 为什么要重构:经典 API 的两个痛点
经典 Environment 把 dataset、交互流程、rubric 揉在一个对象里。评测简单任务很好用,但当任务变复杂时暴露两个问题:
- 「怎么作答」和「怎么评分」耦合了。 同一批数学题,你想换一个完全不同的 agent 框架(比如换成 Codex CLI、换成一个自定义 RLM harness)来解,就得重写整个环境。交互协议和评分本该正交。
- 代码在评测进程里直接跑。 判分脚本的依赖、agent 执行的 shell 命令,都在你的主进程里。要沙箱隔离、要跑在 Docker 或远程 GPU 沙箱上,经典 API 没有一等公民的抽象。
v1 的回答:把环境拆成三块正交的东西,谁跑在哪由配置决定。
2. 三块正交部件
EnvConfig (评测时组装)
┌───────────┼────────────┐
▼ ▼ ▼
Taskset Harness Runtime
出题 + 评分 怎么驱动对话 在哪执行
(数据+判断) (哪个 agent) (子进程/Docker/远程沙箱)
| 部件 | 职责 | 类比 |
|---|---|---|
Taskset | 产出带类型的 Task,定义 @reward/@metric 打分,可暴露工具 | 「出题老师 + 判卷标准」 |
Harness | 一个真正跑起来的程序,驱动模型多轮作答(自带 bash 工具的 chat loop、或 Codex CLI、或 RLM……) | 「考生用什么方法答题」 |
Runtime | 代码在哪执行:subprocess(本机进程)/ docker / prime(远程沙箱) | 「考场」 |
关键点:这三块在评测时才组装(EnvConfig,verifiers/v1/env.py:90)。同一个 Taskset 可以配不同 Harness、不同 Runtime。数学题的判分逻辑写一次,用哪个 agent 去解、跑在哪,都是外部配置。
依据:
EnvConfig(verifiers/v1/env.py:90)持有taskset/harness两个子配置;Environment.__init__(:231)用load_taskset/load_harness把它们实例化并做兼容性校验(:239-:264)。
3. Taskset:出题与判分
Taskset(verifiers/v1/taskset.py:52)是数据 + 判断的那一半。和经典 Rubric 比,它更「面向对象」:
load_tasks()(:66):产出一批Task。Task(verifiers/v1/task.py:51)是冻结的 pydantic 模型,子类可加带类型的字段(比如 gsm8k 加一个answer: str)——这些字段类型安全地一路流到打分函数里,取代了经典 API 里到处dict.get的写法。@vf.reward/@vf.metric装饰的方法:打分。和经典 rubric 一样按参数名注入,但能注入的对象更强——包括runtime(这条 rollout 的活沙箱)。tools(task)/user(task):可选,给任务挂工具服务器、或挂一个「用户模拟器」做多轮对话。setup/finalize/validate:生命周期钩子(准备沙箱、跑完后处理、离线校验题目本身可解)。
巧妙点:判 分脚本跑在沙箱里,依赖不污染主进程
看 gsm8k 的例子(environments/gsm8k_v1/gsm8k_v1/taskset.py)——它的正确性奖励不在评测进程里判,而是把一个 uv 脚本写进 rollout 的 runtime 里执行:
# 示意,源自 gsm8k_v1/taskset.py 的 @vf.reward correct
result = await runtime.run_uv_script(VERIFY, args=[task.answer, prediction or ""])
return float(result.stdout.strip().splitlines()[-1])
VERIFY 是一个带内联依赖声明(math-verify)的 uv 脚本。runtime.run_uv_script 让 uv 在沙箱里装依赖、跑脚本——判分器的依赖永远不碰评测进程,且在子进程/docker/远程沙箱上行为一致。这直接解决了痛点 2。
依据:
Taskset.score(verifiers/v1/taskset.py:113)、score_group(:148);GSM8KTaskset.correct(environments/gsm8k_v1/gsm8k_v1/taskset.py);Task(verifiers/v1/task.py:51)。
4. Harness:怎么驱动对话
Harness(verifiers/v1/harness.py:79)是一个真正被启动的程序,它在 runtime 里跑,驱动模型多轮作答。经典 API 里「怎么作答」写在 MultiTurnEnv.env_response;v1 里它是一个可替换的独立程序。
仓库自带多种 harness(verifiers/v1/harnesses/):
| harness | 是什么 |
|---|---|
default | 自带 bash/edit/search 工具的 coding-agent chat loop(uv 脚本,deps: openai+mcp) |
null | 纯 chat loop,无本地工具(只中转模型和远程 MCP 工具) |
codex / kimi_code / mini_swe_agent / terminus_2 | 接入外部 agent CLI/框架 |
rlm | 递归语言模型 harness(上下文丢弃、prompt builder 等) |
每个 harness 声明自己支持什么能力(class 变量,verifiers/v1/harness.py:83-:90):SUPPORTS_MCP(能不能用工具服务器)、SUPPORTS_USER_SIM(能不能驱动用户模拟器)、APPENDS_SYSTEM_PROMPT 等。Environment.__init__ 会校验 taskset 的需求和 harness 的能力匹配——比如 taskset 挂了工具但 harness 不支持 MCP,直接报错(verifiers/v1/env.py:239-:247)。
为什么 harness 是「独立程序」而不是一个方法? 因为它要能跑在远程沙箱里、能是任意语言/框架的 agent CLI。它通过 HTTP 调模型——而这些调用被下面的拦截层接住。
5. Interception:拦截层——v1 最巧妙的一环
这是理解 v1 的关键。harness 在沙箱里跑,它并不直接连模型 provider,而是连一个框架起的「拦截服务器」(interception server)。
┌─────────── runtime (沙箱) ───────────┐
│ Harness 程序 (agent chat loop) │
│ │ HTTP: chat/completions │
└─────┼────────────────────────────────┘
▼
┌─────── ───────────────────────┐
│ Interception Server │ ← 框架起的代理
│ · 转发给真实模型 provider │
│ · 记录每一轮 (存进 Trace) │
│ · 强制 max_turns / token 限额 │ ← 拒掉超限的那一轮
│ · 多路复用: 一台服务多条 rollout(按 secret 区分) │
└──────────────────────────────┘
▼ 真实模型端点
它一举解决三件事(verifiers/v1/interception/__init__.py):
- Provider 无关 + 自动记录:无论 harness 是什么语言/框架写的,只要它发 OpenAI 兼容的 chat 请求,就被接住、转发、并把这一轮记进
Trace。harness 作者不用管「怎么把对话存下来给训练用」。 - 框架级限额:
max_turns、max_input_tokens、max_output_tokens由拦截服务器强制——任何 harness 都自动受限,因为「限轮数」是框架的事,不该是每个 harness 或 task 各自实现(EnvConfig.max_turns注释,verifiers/v1/env.py:103-:106)。 - 多路复用省隧道:一台 拦截服务器按
multiplex服务多条 rollout(按 secret 区分),远程沙箱下一台服务器共用一条隧道——绕开「每 token 一条隧道」的上限(InterceptionPool,verifiers/v1/interception/pool.py)。
依据:
verifiers/v1/interception/__init__.py(server/pool 职责说明)、InterceptionServer/RolloutLimits/RolloutSession(verifiers/v1/interception/server.py)、EnvConfig的max_turns/max_*_tokens字段(verifiers/v1/env.py:103-:115)。
6. Rollout 与 Trace:执行与记录
Rollout:一条轨迹的生命周期管理者
Rollout(verifiers/v1/rollout.py:66)拥有一条轨迹从头到尾,包括它那台 runtime 的生死。run()(:138)是一个分阶段、每阶段带独立超时的状态机:
make_runtime() → runtime.start() [SETUP]
├─ taskset.setup() (setup_timeout)
├─ harness.setup()
├─ 起拦截 endpoint + 工具服务器 + 用户模拟器
├─ harness.run() (harness_timeout) [RUNNING] ← agent 在沙箱里驱动对话
├─ taskset.finalize() (finalize_timeout) [FINALIZE] ← 应用 diff / 跑构建 / 抓产物
└─ taskset.score() + harness.score() (scoring_timeout) [SCORING]
finally: runtime.stop() ← 一定拆掉沙箱
几个体现「防御式工程」的点:
- runtime 引用一创建就赋值(
:148),所以哪怕 setup 崩了,finally也一定能拆掉沙箱(:290-:295)。 - 坏 rollout 是数据不是崩溃:
RolloutError被抓住记进trace.capture_error,不让一条坏轨迹取消掉同批其他 rollout(:270-:278)。 - harness 超时 ≠ 崩溃:超时按「预算用完」处理,仍给已产出的部分打分(
:234-:235)——像经典 API 的max_turns一样。
Trace:取代 State 的类型化记录
Trace(verifiers/v1/trace.py)是 v1 版的 State:一次 rollout 的完整记录(对话、每轮 response、reward、metrics、计时、error)。区别是它是带类型的 pydantic 模型,不再是 600 行的 dict 子类。
有个训练相关的巧概念——Branch(verifiers/v1/trace.py:73):一段「上下文只增不改」的线性对话。如果对话中途压缩(compact)或跑了子 agent,就会分叉成多个 branch,每个 branch 产出一条训练样本。这让「上下文管理型 agent」也能正确地转成训练数据。
Episode:分组打分在 v1 的落点
Episode(verifiers/v1/episode.py)跑同一个 task 的 n 条 Rollout,然后跑 taskset 的 @group_reward 做跨 rollout 比较。Environment.episode(verifiers/v1/env.py:303)会检查:如果 taskset 定义了 group_reward 但 n<2,直接报错——因为「组内比较」至少要两条。
7. 兼容旧世界:legacy bridge
v1 没有抛弃经典环境。EnvConfig 里留了 id / args 字段(verifiers/v1/env.py:125-:129):设了 id(而不设 taskset)就走legacy 桥——用经典的 verifiers.load_environment(id, **args) 把 v0 环境加载进来,包一层适配成 v1 的 Trace(verifiers/v1/legacy.py)。is_legacy 属性(:137)判断走哪条路。所以两代 API 能在同一个训练/评测管线里共存。
8. 两代对照(一眼看清演进)
| 关切 | 经典 v0 | v1 |
|---|---|---|
| 出题+评分 vs 交互 | 揉在 Environment 一个对象 | Taskset 与 Harness 拆开 |
| 交互流程 | MultiTurnEnv.env_response 方法 | Harness 独立程序(可换任意 agent) |
| 代码在哪跑 | 评测进程内 | Runtime:subprocess/docker/prime 沙箱 |
| 模型调用 | Client 直连 | Interception 拦截层(记录+限额+多路复用) |
| 一条记录 | State(dict 子类) | Trace(pydantic,带类型、带 Branch) |
| 限轮数/token | 环境自己实现停止条件 | 框架在拦截层统一强制 |