GoGoYe

Back

第六章:rollout.py——多轮轨迹状态机#

源码位置:https://github.com/KMnO4-zx/agentic-rl-lab/blob/main/05-retool/rollout.py

6.1 先抓住整章主线#

rollout.py 是前五章第一次真正汇合的地方:

一句话概括:

rollout 把“模型生成—环境执行—观察反馈—模型续写”驱动成多轮轨迹,并保留训练所需的真实 token、旧 logprob、最终 reward 和组内 advantage。

6.2 什么是 code-interlaced trajectory#

普通单轮生成可以写成:

prompt → assistant final answer
text

ReTool 的代码交织轨迹是:

prompt
  → assistant 推理 + code tool call
  → tool observation
  → assistant 继续推理 + code tool call
  → tool observation
  → assistant final answer
text

“interlaced”强调两类 token 交替出现:

模型动作 token:assistant completion
环境反馈 token:tool observation
text

工具调用标签和其中的 Python 代码是 assistant 自己生成的,所以属于模型动作;Python stdout、traceback 和 timeout 信息由环境生成,所以属于 observation。

6.3 roundassistant turntraining step#

三个概念不要混淆。

assistant turn#

某一条轨迹的一次模型生成。每调用一次 sampler 并消费一个 sequence,该轨迹就增加一个 AssistantTurn

rollout round#

状态机的一轮同步推进:给当前所有活跃轨迹各采样一次,并并发执行这一轮出现的工具调用。不同轨迹可能在同一 round 结束或继续。

training step#

训练脚本选出一批问题,生成所有分组轨迹,计算 reward/advantage,构造 Datum,执行一次逻辑上的 PPO 参数更新。

关系大致是:

1 training step
  └── 1 rollout batch
        ├── first round
        ├── later round 1
        ├── later round 2
        └── ...
              └── 每条活跃轨迹各产生 1 assistant turn
text

6.4 RolloutConfig:三类控制参数#

@dataclass(frozen=True)
class RolloutConfig:
    group_size = 8
    max_code_calls = 4
    max_assistant_turns = 6
    max_trajectory_tokens = 8192
    max_assistant_tokens = 1024
    max_tool_response_tokens = 512
    temperature = 1.0
    top_p = 1.0
    seed = 42
python

分组参数#

group_size:同一道问题首轮采样多少条候选轨迹,用于之后的同题相对比较。

轨迹预算参数#

参数限制对象
max_code_calls一条轨迹成功接入历史的工具调用次数
max_assistant_turns一条轨迹最多记录多少次 assistant 生成
max_trajectory_tokens当前完整 prompt/轨迹的 token 总预算
max_assistant_tokens单个 assistant turn 的最大生成 token
max_tool_response_tokens单次 tool content 的最大 token

采样参数#

temperaturetop_pseed 控制采样随机性。

配置对象使用 frozen=True,整个 rollout 过程中只读取,不应被某条轨迹修改。

6.5 四个核心对象先总览#

MathExample
    ↓ 创建
Trajectory
    ↓ make_request()
SampleRequest
    ↓ sampler response
AssistantTurn 写入 Trajectory.turns
    ↓ 如果是合法工具调用
PendingExecution
    ↓ sandbox result
更新 Trajectory,继续或结束
text
对象生命周期核心职责
Trajectory整条轨迹保存完整动态状态和训练结果
SampleRequest一次模型请求描述 prompt、采样数、预算、seed 和写回位置
AssistantTurn一次模型动作保存本轮真实 prompt/completion/logprob/text
PendingExecution一次待执行工具调用把代码、轨迹引用和续接上下文带到沙箱结果写回阶段

6.6 AssistantTurn:训练最关心的动作记录#

@dataclass
class AssistantTurn:
    prompt_tokens: list[int]
    completion_tokens: list[int]
    logprobs: list[float]
    text: str
python

字段边界:

prompt_tokens     → 本轮生成前的完整模型输入 Pᵢ
completion_tokens → sampler 真实返回的模型动作 Cᵢ
logprobs          → 旧策略对 Cᵢ 每个 token 的 logprob Lᵢ
text              → 可读文本,用于协议解析、代码提取和日志
text

最重要的不变量:

len(completion_tokens) == len(logprobs)
text

completion_tokenslogprobs 是 PPO 训练事实;text 是语义解释。即使本轮输出格式非法或最终 reward 为负,实际采样过的动作仍会先写进 turns

6.7 Trajectory:一条轨迹的完整状态#

@dataclass
class Trajectory:
    example: MathExample
    group_index: int
    messages: list[dict]
    next_prompt_tokens: list[int] | None = None
    question_index: int = 0
    turns: list[AssistantTurn] = ...
    code_calls: int = 0
    final_text: str = ""
    reward: float = -1.0
    advantage: float = 0.0
    valid_format: bool = False
    correct: bool = False
    done: bool = False
python

可以按四组理解。

静态归属#

example        → 对应的题目和参考答案
question_index → 题目在当前 rollout batch 中的位置
group_index    → 当前轨迹在同题 group 中的分支编号
text

双轨历史#

messages           → 可读的 system/user/assistant/tool 历史
next_prompt_tokens → 工具返回后连续构造的真实下一轮 token prompt
turns              → 每一次真实 assistant 动作
text

状态计数#

code_calls → 已成功把 tool observation 接入对话的次数
done       → 是否停止继续采样
text

终局训练信号#

final_text   → 交给 reward 的最后 assistant 文本
reward       → ±1 结果奖励
valid_format → 是否找到完整 boxed
correct      → 数学答案是否正确
advantage    → reward 减同题组均值
text

Trajectory 必须可变,因为 rollout 会逐轮原地更新它。

6.8 三种 index 分别是什么#

假设一个 batch 有两道题,每题 group_size=3

question_index=0
├── group_index=0 → flattened trajectory_index=0
├── group_index=1 → flattened trajectory_index=1
└── group_index=2 → flattened trajectory_index=2

question_index=1
├── group_index=0 → flattened trajectory_index=3
├── group_index=1 → flattened trajectory_index=4
└── group_index=2 → flattened trajectory_index=5
text
名称是否长期稳定用途
question_index在本次 rollout 内稳定reward 分组、call ID
group_index在同题组内稳定标识候选分支、call ID
SampleRequest.trajectory_index取决于当前索引的列表把响应写回 root 或扁平轨迹

首轮 trajectory_index 指向 roots;后续则指向 trajectories。因此它不是轨迹永久 ID。

工具调用 ID:

code-{question_index}-{group_index}-{code_calls + 1}
python

例如 code-1-2-3 表示当前 batch 第 2 道题、组内第 3 条轨迹的第 3 次工具调用。

6.9 SampleRequest:一次采样任务#

@dataclass(frozen=True)
class SampleRequest:
    trajectory_index: int
    prompt_tokens: list[int]
    num_samples: int
    max_tokens: int
    seed: int
python

其字段回答五个问题:

结果写回哪条轨迹?       → trajectory_index
模型看到什么 token?      → prompt_tokens
共享 prompt 采多少候选? → num_samples
每个候选最多生成多少?   → max_tokens
本次随机种子是多少?      → seed
text

首轮每道题一个请求,num_samples=group_size;后续每条未结束轨迹一个请求,num_samples=1

6.10 PendingExecution:跨阶段保存工具调用#

@dataclass(frozen=True)
class PendingExecution:
    trajectory: Trajectory
    code: str
    call_id: str
    messages_before_assistant: list[dict]
    assistant_text: str
    prompt_tokens: list[int]
    completion_tokens: list[int]
python

它在“assistant 已生成工具调用”和“沙箱结果尚未写回”之间充当事务记录:

trajectory                 → 结果写回哪个可变对象
code                       → 沙箱执行什么
messages_before_assistant  → 当前 assistant 生成前的消息快照
assistant_text             → observation 无法接入时作为 final_text
prompt_tokens + completion → 连续拼接下一轮真实 token
text

它不重复保存 logprobs,因为 logprob 已经连同 completion 写入 AssistantTurn

6.11 顶层状态机鸟瞰#

rollout_batch_async() 的完整阶段是:

1. 每题创建一个 root Trajectory
2. 每题构造一个首轮 SampleRequest
3. 并发采样所有题的首轮 group
4. 每个 sequence 深拷贝 root,形成独立分支
5. 解析首轮 assistant 输出
6. 并发执行首轮所有工具调用
7. while 仍有未结束轨迹:
     a. 为每条活跃轨迹构造单样本请求
     b. 并发采样
     c. 解析全部输出
     d. 并发执行其中的工具调用
     e. 写回 observation 或终止
8. 所有轨迹逐条计算 reward
9. 按 question_index 计算组内 advantage
10. 返回 list[Trajectory]
text

6.12 每道题先创建一个 root#

roots = [
    Trajectory(
        example=example,
        group_index=0,
        messages=initial_messages(example.question),
        question_index=question_index,
    )
    for question_index, example in enumerate(examples)
]
python

此时一题只有一个 root:

Trajectory
├── messages = [system, user]
├── turns = []
├── code_calls = 0
├── done = False
└── reward/advantage 尚未计算
text

group_index=0 只是临时值,真正分叉后每个 branch 会重新赋成 0 ... group_size-1

6.13 make_request():prompt 与生成预算#

prompt 有两条来源:

prompt_tokens = (
    trajectory.next_prompt_tokens
    if trajectory.next_prompt_tokens is not None
    else build_prompt(tokenizer, trajectory.messages)
)
python

首轮从 [system, user] 完整渲染;工具调用后的后续轮使用连续构造好的真实 token,不重渲染历史。

本轮可生成上限:

remaining = max_trajectory_tokens - len(prompt_tokens)
max_tokens = min(max_assistant_tokens, remaining)
python

假设总预算 8192、单轮上限 1024:

当前 prompt 长度剩余总预算本轮 max_tokens
500031921024
7800392392
81920不采样,直接 done

max_tokens <= 0,函数设置 trajectory.done=True 并返回 None。这里没有截断旧历史,也没有滑动窗口。

6.14 为什么首轮一次采样整个 group#

首轮每道题只有一个共享 prompt,因此只创建一个请求:

request = make_request(
    tokenizer,
    root,
    trajectory_index=question_index,
    num_samples=config.group_size,
    seed=config.seed + question_index,
    config=config,
)
python

若有 2 道题、group_size=3

请求 Q0:prompt of question 0,num_samples=3
请求 Q1:prompt of question 1,num_samples=3
text

这比为相同 prompt 重复发 3 个单样本请求更自然,也明确表达“同题一组候选”。不同题的两个请求通过 asyncio.gather() 并发。

采样服务必须返回精确数量:

if len(response.sequences) != group_size:
    raise ValueError(...)
python

6.15 root 如何分叉为独立 branch#

对首轮返回的每个 sequence:

branch = copy.deepcopy(root)
branch.group_index = group_index
trajectories.append(branch)
begin_advance(branch, ..., sequence, ...)
python

深拷贝很重要。否则多个分支可能共享同一个 messagesturns 列表:一条轨迹追加 tool observation,会污染同题其他候选。

分叉后:

一个 root prompt
├── branch 0:独立 messages / turns / code_calls / done
├── branch 1:独立 messages / turns / code_calls / done
└── branch 2:独立 messages / turns / code_calls / done
text

MathExample 本身是 frozen 的字符串值对象,共享或复制都不会被轨迹修改。

6.16 为什么后续每条轨迹只能单独采样#

首轮之后,各分支可能:

  • 直接回答并结束;
  • 调用不同 Python 代码;
  • 得到不同 stdout 或 traceback;
  • 因输出长度不同而拥有不同 token prompt。

因此它们不再共享 prompt:

branch 0 prompt = 原 prompt + code A + observation A
branch 1 prompt = 原 prompt + code B + observation B
branch 2 已结束
text

后续循环为每条活跃轨迹创建 num_samples=1 的请求。虽然请求各自独立,但多个 sample_async 仍在同一个 round 中并发调度。

6.17 sample_requests_async():并发采样与顺序映射#

每个 request 转为:

SamplingParams(
    max_tokens=request.max_tokens,
    seed=request.seed,
    stop=stop_sequences(tokenizer),
    temperature=config.temperature,
    top_p=config.top_p,
)
python

prompt 则转成:

trio.ModelInput.from_ints(request.prompt_tokens)
python

所有 awaitable 交给:

await asyncio.gather(*tasks)
python

并发任务完成顺序可能不同,但 gather() 的返回列表保持输入任务顺序。因此:

requests[i] ↔ responses[i]
text

后面再用 zip(..., strict=True),防止列表长度不一致时静默截断。

源码没有在这一层添加 retry、单独 timeout 或并发 semaphore;相关服务限制交给 PyTRIO 客户端。任何采样基础设施异常会让整个 gather 向上抛出。

6.18 read_sequence():守住 token/logprob 对齐#

tokens = [int(token) for token in sequence.tokens]
logprobs = [float(value) for value in sequence.logprobs]
python

随后立即检查:

if len(tokens) != len(logprobs):
    raise ValueError(...)
python

文本优先使用服务返回的 sequence.text;只有它是 None 时,才从 token 解码:

tokenizer.decode(tokens, skip_special_tokens=True)
python

源码信任服务保证 sequence.textsequence.tokens 表达同一输出,没有额外逐 token 一致性校验。

这个边界很重要:

协议解析和代码执行使用 text
PPO 训练使用 tokens + logprobs
text

6.19 begin_advance():一轮输出的核心分岔#

执行顺序:

1. read_sequence() 取得 tokens/logprobs/text
2. text.strip()
3. 立即追加 AssistantTurn
4. parse_assistant(text)
5. 决定执行工具还是结束轨迹
text

“先记录再解析”意味着所有实际采样动作都会留在 turns,包括正确答案、错误答案、非法工具格式,以及达到次数或轮数上限时生成的最后动作。

6.20 can_code 的三个必要条件#

can_code = (
    parsed.kind == "tool"
    and trajectory.code_calls < config.max_code_calls
    and len(trajectory.turns) < config.max_assistant_turns
)
python

只有三项同时满足,代码才会执行并继续轨迹。

注意当前 turn 已经 append,因此 len(turns) 包含本轮。默认最多 6 轮时:

第 1~5 轮可以调用工具并等待下一轮
第 6 轮即使生成合法 tool call,也不会执行
text

这样避免第 6 轮调用工具后还必须出现第 7 轮才能给最终答案。

6.21 不继续工具时的统一终止分支#

如果 can_code=False

trajectory.messages.append({"role": "assistant", "content": text})
trajectory.final_text = text
trajectory.done = True
return None
python

以下情况都走这里:

当前输出/状态结果
普通最终回答正常结束,交给 reward
非法工具格式结束,原文本交给 reward
工具次数已满又生成工具调用不执行该代码,工具调用文本成为 final_text
已是最后允许轮次又生成工具调用不执行该代码,工具调用文本成为 final_text

reward 只看 final_text 中的 boxed,不会自动知道是哪一种终止原因。

6.22 合法工具调用如何形成 PendingExecution#

若可以执行工具:

生成 call_id
→ 保存当前 assistant 之前的 messages 快照
→ 把真实 assistant 文本追加到 trajectory.messages
→ 返回 PendingExecution
text

messages_before_assistant 必须在追加当前 assistant 前保存,因为 build_next_prompt() 使用占位 assistant 来推导 closing 和 observation 增量。

此时轨迹尚未增加 code_calls;只有沙箱结果成功接入历史后才增加。

6.23 同一 round 的工具调用如何并发#

状态机先解析本 round 所有响应,把合法工具调用收集为:

pendings: list[PendingExecution]
python

然后一次性执行:

await asyncio.gather(
    *(sandbox.arun_code(p.code) for p in pendings)
)
python

每个 arun_code() 再通过线程池运行同步子进程,sandbox 内部的 BoundedSemaphore 限制实际同时执行的进程数量。

结果列表保持 pending 输入顺序,随后严格 zip 写回。每个 pending 又直接持有目标 Trajectory 引用,所以不会因异步完成顺序把 observation 写错轨迹。

6.24 并发是 round 内并发,round 间有屏障#

一轮的时序是:

并发采样所有活跃轨迹
        ↓ 等全部采样完成
解析全部响应

并发执行所有 tool calls
        ↓ 等全部工具完成
统一写回结果

进入下一 round
text

采样阶段和工具执行阶段不会重叠,下一 round 也必须等待当前 round 最慢的任务。因此它是“round 内并发、round 间同步屏障”,不是每条轨迹完全独立地一路跑到底。

好处是状态和进度容易统一管理;代价是长尾工具调用会拖慢整个 round。

6.25 fit_tool_content():同时满足两类预算#

沙箱先把结果格式化成 content。接下来必须同时满足:

条件 1:token_count(content) <= max_tool_response_tokens
条件 2:len(next_prompt) <= max_trajectory_tokens
text

只有条件 1 通过,才用真实模板构造下一轮 prompt;这样总预算检查包含:

  • 当前真实 prompt;
  • 当前真实 completion;
  • 缺失 assistant closing;
  • tool role 和内容;
  • tool closing;
  • 下一轮 assistant generation prefix。

若任一条件不满足,就保留 tool content 尾部约 70%:

content = "[... truncated ...]\n" + content[-int(len(content) * 0.7):]
python

这是按字符迭代缩短,不是一次精确截到 N 个 token。保留尾部是因为计算结果和 traceback 关键信息通常位于末尾。

若内容已不超过 64 个字符仍放不下,返回 None,让轨迹终止。

6.26 finish_advance():observation 成功或失败写回#

成功装入预算#

trajectory.messages.append(tool_message(...))
trajectory.next_prompt_tokens = next_prompt_tokens
trajectory.code_calls += 1
python

此时 done 仍为 False,下一 round 继续采样。

无法装入预算#

trajectory.final_text = pending.assistant_text
trajectory.done = True
python

此时:

  • 不追加 tool message;
  • 不增加 code_calls
  • 但沙箱代码事实上已经执行过;
  • 当前 assistant 工具调用文本被当作 final_text 评分。

所以 code_calls 更准确地表示“成功接入对话历史的工具调用数”,不一定等于真实沙箱执行次数。

6.27 一条两轮轨迹的完整状态变化#

假设模型第一轮调用工具,第二轮回答。

初始状态#

messages = [system, user]
next_prompt_tokens = None
turns = []
code_calls = 0
done = False
text

构造首轮请求#

P₁ = build_prompt(messages)
SampleRequest(prompt=P₁, num_samples=group_size, ...)
text

第一轮 sampler 返回#

C₁ = 推理 + tool_call 的真实 token
L₁ = 每个 C₁ token 的旧 logprob
text₁ = 可读工具调用文本
text

状态先更新:

turns = [AssistantTurn(P₁, C₁, L₁, text₁)]
messages = [system, user, assistant tool_call]
text

创建 PendingExecution,并发执行代码。

沙箱返回 observation#

content₁ = stdout / stderr / timeout
D₁ = assistant closing 补全 + tool observation + 下一 assistant 前缀
P₂ = P₁ + C₁ + D₁
text

状态:

messages = [system, user, assistant tool_call, tool observation]
next_prompt_tokens = P₂
code_calls = 1
done = False
text

第二轮 sampler 返回最终答案#

C₂ = 最终推理 + \boxed{answer}
L₂ = 与 C₂ 一一对应的旧 logprob
text

状态:

turns = [
    AssistantTurn(P₁, C₁, L₁, text₁),
    AssistantTurn(P₂, C₂, L₂, text₂),
]
messages += assistant final answer
final_text = text₂
done = True
text

最后 score_trajectory() 写入 reward/correct/valid_format,再写 advantage。

6.28 这条轨迹的 token 不变量#

记:

Pᵢ:第 i 轮 prompt
Cᵢ:第 i 轮 completion
Lᵢ:第 i 轮旧 logprobs
Dᵢ:工具调用后的环境/结构增量
text

必须满足:

len(Cᵢ) = len(Lᵢ)
Pᵢ₊₁ = Pᵢ + Cᵢ + Dᵢ
text

两轮轨迹最终序列:

P₁ + C₁ + D₁ + C₂
text

训练 mask 预览:

P₁ → context,advantage 0
C₁ → assistant action,trajectory advantage
D₁ → closing/tool observation/下一轮前缀,advantage 0
C₂ → assistant action,trajectory advantage
text

这就是为什么 AssistantTurn 必须保存每轮的完整 prompt,而不是只保存本轮新 observation。

6.29 三层前缀对齐保护#

构造层#

build_next_prompt() 直接返回:

previous prompt + real completion + structural/observation delta
text

所以新 prompt 必然以前一轮 Pᵢ+Cᵢ 开头。

chat template 层#

protocol.py 用占位 assistant 检查模板能否稳定切出 closing 和 tool observation 增量。模板若改写历史会立即报错。

训练层#

train.py/build_datum() 再检查:

turn.prompt_tokens[:len(full_tokens)] == full_tokens
python

若下一轮 prompt 不是已有 token 轨迹的前缀扩展,就拒绝构造 PPO Datum。

因此是:

rollout 连续构造
→ protocol 检查模板可切分
→ train 再检查真实前缀
text

6.30 advance_round():后续 round 的批量推进器#

它分两遍处理。

第一遍:

requests/responses 严格配对
→ 找到 trajectory_index 对应轨迹
→ begin_advance()
→ 结束的立即更新 progress
→ 工具调用收集为 pendings
text

第二遍:

并发执行全部 pendings
→ finish_advance() 逐条写回
→ 因预算失败而结束的更新 progress
text

仍能继续的轨迹不更新完成进度,留到后面 round。

6.31 while 循环如何收敛#

循环条件:

while any(not trajectory.done for trajectory in trajectories):
python

每轮只为 done=False 的轨迹建请求。轨迹最终会因以下原因之一结束:

  1. 生成普通 answer;
  2. 生成 invalid 输出;
  3. 达到工具次数上限后仍生成 tool call;
  4. 达到 assistant turn 上限后仍生成 tool call;
  5. 当前 prompt 已无生成 token 预算;
  6. tool observation 即使截断也放不进预算。

只要配置为正常正值并且采样/工具调用能返回,轮数和 token 上限共同保证轨迹不会无限续写。

6.32 同步入口与异步入口#

训练代码处于同步循环,调用:

rollout_batch(...)
python

它内部执行:

asyncio.run(rollout_batch_async(...))
python

每次调用会创建并在结束后关闭一个 event loop;同一次 rollout 的所有 round 共用这个 loop。

若调用方已经位于运行中的 event loop,例如 eval.py,不能嵌套 asyncio.run(),应直接:

await rollout_batch_async(...)
python

否则会出现 asyncio.run() cannot be called from a running event loop

6.33 seed 如何分配#

首轮#

seed = base_seed + question_index
text

同一道题的整个 group 使用一个多样本请求;各分支没有分别传独立 seed,组内随机流由 sampler 的 num_samples 语义管理。

后续轮#

seed = base_seed
     + flattened trajectory_index
     + len(turns) × 10000
text

首次后续采样时已经有 1 个 turn,因此偏移是 10000;再下一轮是 20000。

seed 是位置驱动的,不使用 example.id,也不包含 training step。每个 rollout batch 的 index 会从 0 重置。固定 seed 提高可复现性,但端到端结果还依赖采样后端、模型权重、prompt、并发动态 batching 和沙箱代码的确定性。

10000 只是人工分隔轮次的间隔,不是数学上严格无碰撞的编码;默认轨迹规模远小于 10000。

6.34 所有轨迹结束后才评分#

主循环结束后:

for trajectory in trajectories:
    score_trajectory(trajectory)
assign_group_advantages(trajectories)
python

score_trajectory() 调用:

score_answer(
    trajectory.final_text,
    trajectory.example.answer,
)
python

并写回:

reward
valid_format
correct
text

工具调用过程中不产生即时 reward;整条轨迹只在终点得到一次结果奖励。

6.35 advantage 为什么按 question_index 分组#

groups.setdefault(trajectory.question_index, []).append(trajectory)
python

分组键不是 example.id,也不是 group_index

  • question_index 表示当前 batch 中同一个题目位置;
  • group_index 只是该题内部第几个候选;
  • 同一道题若因循环取样在 batch 中出现两次,会有两个不同 question_index,因而形成两个独立 group。

每组计算:

Aᵢ = Rᵢ - mean(R_group)
text

没有再除以组内标准差。

函数假设输入来自单次 rollout batch。若把多个 batch 的轨迹拼在一起后重新调用,因为每批 question_index 都从 0 开始,会把不同批次的题错误合组。

6.36 ±1 reward 下 advantage 的通式#

假设 group 大小为 G,其中 k 条正确:

正确 reward = +1
错误 reward = -1
组均值 = (k - (G-k)) / G = (2k-G)/G
text

所以:

正确轨迹 advantage = 1 - mean = 2(G-k)/G
错误轨迹 advantage = -1 - mean = -2k/G
text

例如 G=8, k=2

正确 advantage = 2×6/8 = +1.5
错误 advantage = -2×2/8 = -0.5
text

k=0k=G 时全组 advantage 为 0;group_size=1 也必然为 0。

6.37 progress_callback 在哪些地方触发#

进度单位是“完成的 trajectory”,不是 round 或 tool call。

触发位置:

  1. 首轮 begin_advance() 直接结束分支时;
  2. 首轮沙箱结果因预算失败结束分支时;
  3. 后续 make_request() 因 prompt 无剩余预算而结束时;
  4. 后续 begin_advance() 直接结束时;
  5. 后续 finish_advance() 因 observation 放不下而结束时。

正常继续的工具轨迹不会更新进度,直到之后真正结束。

回调是同步调用并运行在 event-loop 线程中;如果回调阻塞或抛异常,会拖慢或中止 rollout。

6.38 一次完整 batch 的数量例子#

假设:

questions_per_batch = 2
group_size = 3
text

首轮:

2 个 root
→ 2 个并发 sampling requests
→ 每个 response 3 sequences
→ 6 个 Trajectory branches
text

假设结果:

3 条直接回答并 done
3 条调用工具
text

首轮并发执行 3 个工具调用。后续 round 只为这 3 条活跃轨迹分别创建单样本请求。

若下一轮 2 条回答、1 条继续调用工具,则再下一 round 只剩 1 条活跃轨迹。最终仍返回 6 条轨迹,再按 question_index 分成两个各 3 条的 reward group。

6.39 rollout 输出如何被 train.py 消费#

返回值:

list[Trajectory]
python

每条轨迹最关键的训练字段:

turns[*].prompt_tokens
turns[*].completion_tokens
turns[*].logprobs
advantage
text

以两轮轨迹为例:

turn 1: prompt=P₁, completion=C₁, logprobs=L₁
turn 2: prompt=P₁+C₁+D₁, completion=C₂, logprobs=L₂
text

build_datum() 会识别:

第 1 轮新增 context = P₁
第 2 轮新增 observation = D₁
text

合成:

full_tokens = P₁ + C₁ + D₁ + C₂

old logprobs = 0...0 + L₁ + 0...0 + L₂
advantages   = 0...0 + A...A + 0...0 + A...A
text

其中 A=trajectory.advantage。第七章会继续讲右移和张量字段。

6.40 关键不变量清单#

6.41 重点边界与源码疑点#

边界 1:fit_tool_content() 可能在长度 65/66 陷入不再缩短#

截断长度近似递推:

new_len = 20 字符提示前缀 + floor(old_len × 0.7)
text

对于某些短长度,例如 65 或 66,新长度可能等于旧长度;而失败退出条件是 len(content) <= 64。如果此时 observation 仍放不进总预算,循环可能无法收敛。这是应在本地测试任务中覆盖的真实疑点。

边界 2:初始 prompt 已超预算时题目会消失#

首轮 root 的 make_request() 返回 None 后,它不会分叉,也不会加入最终 trajectories。于是返回轨迹数可能少于:

len(examples) × group_size
text

且首轮进度不会为该题补上 group_size

边界 3:恰好占满预算可能以空 final_text 评分#

fit_tool_content() 允许 len(next_prompt) == max_trajectory_tokens。它成功接入 tool message 后,下一轮 make_request() 发现剩余为 0,只设置 done=True,没有为 final_text 赋值;最终空字符串得到 -1

边界 4:采样前没有为 tool wrapper 预留空间#

assistant completion 可以用完所有剩余预算。若它恰好是工具调用,后面的 closing、tool wrapper 和 observation 必然无法装入,只能事后截断或终止。

边界 5:rollout 上限与训练上限可能不一致#

RolloutConfig.max_trajectory_tokens → 命令行可调
train.py MAX_TRAIN_CONTEXT_TOKENS   → 固定为 8192
text

若把 rollout 上限调到 8192 以上,轨迹可能生成成功,却在 build_datum() 阶段报超长错误。

边界 6:sequence.text 与 tokens 的一致性依赖服务#

源码只检查 token/logprob 等长,不检查 text 是否能从这些 token 严格得到。正常 sampler 应满足契约,但若不一致,协议执行的是一段文本,训练的却可能是另一组 token。

边界 7:基础设施异常没有局部降级#

asyncio.gather() 默认遇到任一采样、线程或进程基础设施异常就向上抛出;没有 retry 或“只让单条轨迹失败”的机制。普通 Python 语法错误和超时已经由 sandbox 包装,不属于此类异常。

边界 8:配置没有统一的正值校验#

例如 group_size=1 可运行但 advantage 恒为 0;max_assistant_turns<=0 仍会先采样一次;max_workers=0 可能导致工具执行永久等待。正常实验必须使用合理正值配置。

边界 9:空输入安全返回空列表#

examples=[] 时不会创建请求、不会进入 while,最后返回 []。训练入口已在更早处拒绝空训练数据。

6.42 本章对象与状态账本#

阶段主要对象关键变化
创建根轨迹Trajectory root写入 example、question_index、初始 messages
构造请求SampleRequest确定真实 prompt、num_samples、max_tokens、seed
消费采样AssistantTurn保存 P/C/L/text,turns 加一
判断动作ParsedAssistanttool 继续;answer/invalid 或上限则结束
等待工具PendingExecution保存代码、轨迹引用和 token 续接上下文
写回工具Trajectory追加 tool message、next prompt、code_calls 加一
轨迹终止Trajectorydone=True,保存 final_text
结果评分Trajectoryreward、correct、valid_format
组内比较Trajectoryadvantage = reward - group mean

6.43 本章小结#

用一句话概括 rollout.py

它先用共享首轮 prompt 为每道题分叉出一个采样组,再按 round 并发推进各条独立轨迹;每轮保存真实 prompt、completion 和旧 logprob,合法工具调用经沙箱变成 observation 并连续接回 token 历史,直到回答、协议错误或预算耗尽,最后统一计算 outcome reward 和同题组 advantage。

读完本章应能回答:

  1. 为什么首轮使用 num_samples=group_size,后续却是每轨迹 num_samples=1
  2. question_indexgroup_indextrajectory_index 有什么区别?
  3. AssistantTurn 为什么同时保存 token、logprob 和 text?
  4. 为什么 begin_advance() 要先写入 turn,再解析输出?
  5. 默认 6 个 assistant turns 时,为什么第 6 轮工具调用不会执行?
  6. code_calls 为什么可能小于实际沙箱执行次数?
  7. fit_tool_content() 同时检查哪两类预算?
  8. “round 内并发、round 间屏障”是什么意思?
  9. 两轮工具轨迹为什么满足 P₂=P₁+C₁+D₁
  10. rollout 返回后按什么字段分组计算 advantage?
  11. 哪些终止条件会设置 done=True
  12. 哪些边界可能导致轨迹消失、空 final_text 或截断循环不收敛?
ReTool 源码精读06
https://gogo-ye.github.io/blog/11retool%E6%BA%90%E7%A0%81%E7%B2%BE%E8%AF%BB06/06_rollout_%E5%A4%9A%E8%BD%AE%E8%BD%A8%E8%BF%B9%E7%8A%B6%E6%80%81%E6%9C%BA
Author GoGoYe
Published at 2026/08/13