跳到主要内容

数据截至 (上游 commit 8b292c9f1b14)

第 3 章 拦截与记账 —— 模型流量必经的服务器,和它为 RL 准备的 Trace

本章讲什么: verifiers 设计含量最高的一层。为什么 harness 不被允许直接调模型;拦截服务器除转发外还干哪三件事;以及它实时写出的 Trace 为什么能直接当 RL 训练样本。

1. 为什么模型流量必须过境

直觉做法是让 harness 直连 OpenAI/Anthropic,框架事后收日志。verifiers 不走这条路,原因写在架构文档里(docs/v1/architecture.md:15-23):harness 直连意味着采样参数由 harness 说了算(很多 harness 根本不暴露这些设置)、轨迹只能事后捞(捞不全)、工具返回和联网搜索结果没法管(模型可以让服务器替它作弊——reward hacking)。

于是每个 rollout 开跑前,框架都拉起一个拦截槽位(rollout.py:237-249serve_interception),harness 拿到的模型地址是 {base_url}/v1(rollout.py:250)。从这时起,模型说的每个字都先进 verifiers 的内存。

远程执行时这个服务器怎么够得着?本地 rollout 走本地端口;远端沙箱走 Prime Tunnel(docs/v1/architecture.md:15)。env 在起服务前就算好要不要隧道——任务类的 Task.toolsets 是 classmethod,不需要任务实例就能问出它要哪些工具服务器(env.py:376-384)。

2. 拦截服务器:按 secret 认领,按方言翻译

核心处理在 InterceptionServer.handle_request(verifiers/v1/interception/server.py:377-420):

  1. 认领:从请求头取 secret,查出这个请求属于哪个 rollout 的 session;查不到就 401(server.py:381-385)。一台服务器多路复用多个 rollout,全靠 secret 区分。
  2. 翻译:不同 harness 说不同的 API 方言——Codex 要 OpenAI Responses,Codex 换成 Claude Code 就是 Anthropic Messages(docs/v1/architecture.md:17)。Dialect 层把请求解析成统一内部模型,dialect.apply_overrides(body, session.ctx.model, session.ctx.sampling) 在转发前把运行方指定的模型和采样参数盖上去(server.py:393)——harness 不暴露采样设置也没关系,评测方说了算。
  3. 记账:请求/响应进 session 的 trace(下一节)。

还有一个为省连接的设计:session 的模型客户端由服务器在注册时分配,同一 endpoint 配置的多个 rollout 共享一条 keepalive 连接池,而不是每个 rollout 自开一条(session.py:111-114 的注释)。

3. 重试去重:同一回合绝不记两遍

RL 训练对轨迹的要求是「图不能分叉」。但 harness SDK 自己会重试——同一个请求体可能被发两遍。拦截层的对策(server.py:400-419 的注释与逻辑):

  • 只对带了重试标记的请求头(is_retried_request,server.py:85)做重放判定;光看请求体相同不算数——压缩(context compaction)完全可能合法地再生出一样的请求;
  • 确认是重试且和上一笔请求摘要相同 → 直接返回已录的响应,不再记一个新回合;
  • 若是同请求体的新一轮(非重试)→ 丢掉旧响应缓存,让这一轮自己的重试去合并或重跑。

判断依据是请求体摘要(_request_digest,server.py:96)加显式标记,两者缺一不重放。这是「宁可少优化、不可错记图」的保守设计。

4. Trace:评测的账,RL 的粮

每个 rollout 一条 Trace(verifiers/v1/trace.py:357),结构上三层:

装什么
一局Tracetask 信息(TraceTask:type/data/key/hash,:118)、agent 配置(AgentInfo,:107)、rewards/metrics、stop_condition、计时(Timing,:75)
一支Branch一条消息链 + 训练字段:token_ids(:224)、sampled_mask(:233)、logprobs(:265)、advantages(:271)
一次调用ModelCall一次 provider 交换:模型、采样、finish reason、usage、计时、错误(:175)

为什么 Branch 上挂着 token_ids 和 logprobs: 接 prime-rl 训练时,拦截层用 renderers 把每次调用增量渲染成 token 序列——训练要的不只是「说了什么」,而是「每个 token 的对数概率」(docs/v1/overview.md:31-33)。这就是「同一份记录既当评测分数又当训练样本」的物理基础。纯评测时这些字段留空,Episode.to_record 落盘时会剔掉原始张量(episode.py:75-81)。

Trace 上的聚合口径也都是现成属性:num_input_tokens 是喂进去的(system+user+tool)之和,num_output_tokens 是模型生成的,num_turns 是采样回合数(trace.py:418-443)——第 4 章的预算检查直接读这四个数。

5. 防作弊:改写而不只是记录

拦截层还能改写流量,典型用途是掐断 reward hacking:

  • 任务作者用 @intercept 钩子注册请求/响应改写器,按注解参数(Request/Response)自动归到对应边界(hook_boundary,session.py:39-53);
  • harness 若支持(SUPPORTS_TOOL_INTERCEPTION),工具返回也过 /tool 路由(rollout.py:306-315);
  • 文档明示的用途包括「拦截并改写工具响应或服务器侧联网搜索结果」(docs/v1/architecture.md:23)——比如模型让搜索引擎直接返回答案时,拦截层可以把结果抹掉。

被改写过的请求会在 trace 上留痕(request_rewrites,rollout.py:285-305),评测报告里能看出哪道题的输入被动过。

6. 状态与 task 查询路由

拦截服务器不止代理模型:/state/task 路由(handle_state_get/puthandle_task_get,server.py:980-1010 一带)让 runtime 里的工具服务器能回读该 rollout 的任务数据和共享状态——这是 MCP 工具服务器「知道自己在帮哪道题」的通道。工具服务器拿 state_secret 访问;state 服务有独立密钥,和模型流量的 model_secret 分开(rollout.py:237-249 返回的三元组)。