跳到主要内容

v1 新架构:Taskset / Harness / Runtime / Interception

这章讲什么: 前三章讲的是经典 API(verifiers/envs/)。这一章讲 verifiers/v1/ 的新架构——它把「环境」这个单一对象拆成了几个正交的部件,并解决了经典 API 在「大规模、多沙箱、多 agent harness」场景下的痛点。

1. 为什么要重构:经典 API 的两个痛点

经典 Environment 把 dataset、交互流程、rubric 揉在一个对象里。评测简单任务很好用,但当任务变复杂时暴露两个问题:

  1. 「怎么作答」和「怎么评分」耦合了。 同一批数学题,你想换一个完全不同的 agent 框架(比如换成 Codex CLI、换成一个自定义 RLM harness)来解,就得重写整个环境。交互协议和评分本该正交。
  2. 代码在评测进程里直接跑。 判分脚本的依赖、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(远程沙箱)「考场」

关键点:这三块在评测时才组装EnvConfigverifiers/v1/env.py:90)。同一个 Taskset 可以配不同 Harness、不同 Runtime。数学题的判分逻辑写一次,用哪个 agent 去解、跑在哪,都是外部配置。

依据:EnvConfigverifiers/v1/env.py:90)持有 taskset / harness 两个子配置;Environment.__init__:231)用 load_taskset / load_harness 把它们实例化并做兼容性校验(:239-:264)。

3. Taskset:出题与判分

Tasksetverifiers/v1/taskset.py:52)是数据 + 判断的那一半。和经典 Rubric 比,它更「面向对象」:

  • load_tasks():66):产出一批 TaskTaskverifiers/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.scoreverifiers/v1/taskset.py:113)、score_group:148);GSM8KTaskset.correctenvironments/gsm8k_v1/gsm8k_v1/taskset.py);Taskverifiers/v1/task.py:51)。

4. Harness:怎么驱动对话

Harnessverifiers/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):

  1. Provider 无关 + 自动记录:无论 harness 是什么语言/框架写的,只要它发 OpenAI 兼容的 chat 请求,就被接住、转发、并把这一轮记进 Trace。harness 作者不用管「怎么把对话存下来给训练用」。
  2. 框架级限额max_turnsmax_input_tokensmax_output_tokens 由拦截服务器强制——任何 harness 都自动受限,因为「限轮数」是框架的事,不该是每个 harness 或 task 各自实现(EnvConfig.max_turns 注释,verifiers/v1/env.py:103-:106)。
  3. 多路复用省隧道:一台拦截服务器按 multiplex 服务多条 rollout(按 secret 区分),远程沙箱下一台服务器共用一条隧道——绕开「每 token 一条隧道」的上限(InterceptionPoolverifiers/v1/interception/pool.py)。

依据:verifiers/v1/interception/__init__.py(server/pool 职责说明)、InterceptionServer / RolloutLimits / RolloutSessionverifiers/v1/interception/server.py)、EnvConfigmax_turns/max_*_tokens 字段(verifiers/v1/env.py:103-:115)。

6. Rollout 与 Trace:执行与记录

Rollout:一条轨迹的生命周期管理者

Rolloutverifiers/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 的类型化记录

Traceverifiers/v1/trace.py)是 v1 版的 State:一次 rollout 的完整记录(对话、每轮 response、reward、metrics、计时、error)。区别是它是带类型的 pydantic 模型,不再是 600 行的 dict 子类。

有个训练相关的巧概念——Branchverifiers/v1/trace.py:73):一段「上下文只增不改」的线性对话。如果对话中途压缩(compact)或跑了子 agent,就会分叉成多个 branch,每个 branch 产出一条训练样本。这让「上下文管理型 agent」也能正确地转成训练数据。

Episode:分组打分在 v1 的落点

Episodeverifiers/v1/episode.py)跑同一个 task 的 n 条 Rollout,然后跑 taskset 的 @group_reward 做跨 rollout 比较。Environment.episodeverifiers/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 的 Traceverifiers/v1/legacy.py)。is_legacy 属性(:137)判断走哪条路。所以两代 API 能在同一个训练/评测管线里共存。

8. 两代对照(一眼看清演进)

关切经典 v0v1
出题+评分 vs 交互揉在 Environment 一个对象TasksetHarness 拆开
交互流程MultiTurnEnv.env_response 方法Harness 独立程序(可换任意 agent)
代码在哪跑评测进程内Runtime:subprocess/docker/prime 沙箱
模型调用Client 直连Interception 拦截层(记录+限额+多路复用)
一条记录State(dict 子类)Trace(pydantic,带类型、带 Branch)
限轮数/token环境自己实现停止条件框架在拦截层统一强制

9. 代码地图

主题文件符号
评测时的组装配置verifiers/v1/env.pyEnvConfig / Environment
出题 + 评分verifiers/v1/taskset.pyTaskset.load_tasks / score / score_group
带类型的题目verifiers/v1/task.pyTask / WireTask
驱动对话的程序verifiers/v1/harness.pyHarness.run / launch / resolve_prompt
默认 agent harnessverifiers/v1/harnesses/default/harness.pyDefaultHarness
一条轨迹的生命周期verifiers/v1/rollout.pyRollout.run / Phase
拦截层verifiers/v1/interception/server.pyInterceptionServer / RolloutLimits
拦截池(多路复用)verifiers/v1/interception/pool.pyInterceptionPool
类型化记录verifiers/v1/trace.pyTrace / Branch
分组执行verifiers/v1/episode.pyEpisode
旧环境桥接verifiers/v1/legacy.py(legacy bridge)
沙箱后端verifiers/v1/runtimes/SubprocessConfig / DockerConfig / PrimeConfig
沙箱内判分示例environments/gsm8k_v1/gsm8k_v1/taskset.pyGSM8KTaskset.correct