跳到主要内容

数据截至 (上游 commit 8b292c9f1b14)

第 4 章 执行生命周期 —— 一次 Rollout 的 open/step/close

本章讲什么: 一次 rollout 从「开盒子」到「落账拆盒子」的完整流程;四段时间和四种预算怎么算;钩子(@stop/@intercept)按类型边界路由的规则;以及打分何时会被跳过、何时不会。

1. 全景:三段式

Rollout(verifiers/v1/rollout.py:54)管一次执行的生死,对外就三个动作:

open() step(messages?) ×N close()
───────────── ────────────────── ─────────────────
起 runtime 每段 = harness 程序 关 harness session
装 task + harness 跑到让出控制权 收工具/拦截服务器
拉拦截槽位 + 工具服务器 ← 用户回合喂 messages task.finalize
落执行期网络策略 ← 预算按段扣、钩子按段查 task.score + harness.score
→ false 表示交换该停 拆 runtime(自有的才拆)

每段的边界在 trace 上有独立计时(boot/setup/agent/finalize/scoring 五段 Timing,trace.py:75),评测报告里能直接看出时间花在哪一段。

2. open:把世界搭到「能跑」

open()(rollout.py:175-338)依次:

  1. 起盒子:rollout 自有则 make_runtime + start();借来的盒子已停就抛「生命周期 bug」(rollout.py:182-191——借来的 runtime 被属主中途拆掉,这错不能记在 rollout 头上);
  2. prompt 前置校验:任务既没 prompt 也没有用户来开场,直接 TaskError(:203-208)——宁可早炸,不让 rollout 空转;
  3. setup 共享一个截止线:task.setup 和 harness.setup 合用一个 setup 超时(:215-230),谁慢拖累谁都看得见;
  4. 拉起服务:serve_interception(拦截槽位,见第 3 章)+ serve_tools(工具服务器),然后 prepare_execution 落网络策略但保留框架路由(:231-264);
  5. 过一遍请求拦截器再交给 harness:若任务注册了请求改写器,初始 prompt 先被改写一次,改写留痕 trace.request_rewrites(:265-305)。

setup 阶段任何异常都被 fail() 捕获记上 trace,返回 False(rollout.py:326-328);被取消(cancellation)则走 abort() 把起了半截的资源释放掉再抛(:329-334)。

3. step:一段一段推进,预算按段扣

step()(rollout.py:340-408)跑一段,返回「交换还能不能继续」。三样东西决定停不停:

① agent 时间预算——只在「自己跑」时计费。 timeouts.agent 是一条累积预算:每段开始时把剩余额度换成绝对截止线(:354-358),段末把花掉的扣掉(:396-401)。等用户(包括等另一个穿插的 agent)不烧预算(:136-139 的注释)。超时判 agent 失败,不是干净停止(:372-385)——花时间花光是一种「答错了」,不是「答完了」。

② RolloutLimits:turn/token 四条预算。 max_turns / max_input_tokens / max_output_tokens / max_total_tokens(session.py:61-94),每个直接读 trace 的同名聚合属性(第 3 章)。两个细节:

  • 回合间检查:跨过线的那一轮仍会跑完——token 预算是软一格的(:66-68);
  • 触线 = 该回合被拒,效果和 @stop 叫停走同一条机制,记为 trace 的 stop_condition(:62-68 注释)。

③ 零进展段。 一段跑完 num_turns 没动,说明它不可能是在等用户——直接结束交换(rollout.py:405-408),否则会把一个从未前进的对话拿去问用户,永远循环。

4. 钩子路由:按参数类型归边界

任务作者写钩子不用注册到具体位置——看注解的参数类型就知道挂在哪(hook_boundary,session.py:39-53):

钩子参数注解挂在哪
@interceptRequest请求改写器(发模型前)
@interceptResponse响应改写器
@stopRequest / Response该边界前查一次要不要停
@stopTrace每回合前看整条 trace 决定

写错形态(两个边界参数 / 一个都没有)直接 TypeError(:48-53)。拦截器挂不上 trace(防在改写器里偷看全账),stop 才能看。

5. close:打分、拆盒子,以及「失败不打分」的例外

close()(rollout.py:428-522)的收尾顺序很讲究:

  1. 关 harness session——关闭失败只警告不丢弃轨迹,生成已经完成,传输层拆除失败不该报销一条可评分的轨迹(:439-449);
  2. task.finalize + 打分:task.score 和 harness.score 并发跑(:467-475);
  3. 最后才拆 runtime——因为接下来 env 层的跨 trace 评判(第 5 章 finalize)只需要 traces,不需要活盒子(:505-508 注释)。

打分跳过规则(:429-432 注释):

  • 失败的 run 不打分(finalize/scoring 整体跳过)——半截错误轨迹打出的分没有含义;
  • 被 @stop 叫停的 run 照常打分——它完整,只是被合法终止,部分轨迹是可评的。

ok 的最终判定在 :485-486:trace.ok = not self._failed。日志一行收尾:rollout done: id=… reward=… turns=… stop=…(:515-522)。

6. 打分聚合:seed/unseed 的占位记账

第 1 章说过 Task.score 先把信号名 seed 进 trace 再执行。补齐 harness 这一侧:Harness.score 跑 harness 自己的 @metric(比如「工具调用成功率」这类只有 harness 知道的指标),同样 seed → 执行 → 记录(harness.py:192-206)。

三路信号——task 的 @reward/@metric、harness 的 @metric、config 插入的 judge——最后都汇到同一条 trace 的 rewards/metrics 两个字典里。trace.reward 是 rewards 的加权和(trace.py:410 一带)。占位 seed 的价值:哪个信号因为异常没回来,报告里那个名字还在、值是空,一眼看出缺测,而不是静默少一行。