数据截至 (上游 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)依次:
- 起盒子:rollout 自有则
make_runtime+start();借来的盒子已停就抛「生命周期 bug」(rollout.py:182-191——借来的 runtime 被属主中途拆掉,这错不能记在 rollout 头上); - prompt 前置校验:任务既没 prompt 也没有用户来开场,直接
TaskError(:203-208)——宁可早炸,不让 rollout 空转; - setup 共享一个截止线:task.setup 和 harness.setup 合用一个 setup 超时(:215-230),谁慢拖累谁都看得见;
- 拉起服务:
serve_interception(拦截槽位,见第 3 章)+serve_tools(工具服务器),然后prepare_execution落网络策略但保留框架路由(:231-264); - 过一遍请求拦截器再交给 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):
| 钩子 | 参数注解 | 挂在哪 |
|---|---|---|
@intercept | Request | 请求改写器(发模型前) |
@intercept | Response | 响应改写器 |
@stop | Request / Response | 该边界前查一次要不要停 |
@stop | Trace | 每回合前看整条 trace 决定 |
写错形态(两个边界参数 / 一个都没有)直接 TypeError(:48-53)。拦截器挂不上 trace(防在改写器里偷看全账),stop 才能看。
5. close:打分、拆盒子,以及「失败不打分」的例外
close()(rollout.py:428-522)的收尾顺序很讲究:
- 关 harness session——关闭失败只警告不丢弃轨迹,生成已经完成,传输层拆除失败不该报销一条可评分的轨迹(:439-449);
task.finalize+ 打分:task.score 和 harness.score 并发跑(:467-475);- 最后才拆 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 的价值:哪个信号因为异常没回来,报告里那个名字还在、值是空,一眼看出缺测,而不是静默少一行。