Trajectory 记录、一个精确记录采样内容的 rollout,以及一个把一组 rollout 转换为批次张量的 collate。此后每一个训练章节都以 collate 的输出为输入。
arl. 调用前面章节的函数,因此这条导入属于保存块的一部分。其余导入只用于本章的演示。
5.1 每个回合一条序列
多轮(multi-turn)回合交替包含由不同参与方写出的文本。在第 4 章的clamp 回合中,问题描述(issue)来自任务,调用来自策略,源代码清单和测试结果来自工具。为了给策略的调用打分,训练器把所有内容拼接成一条 token 序列,并对其做一次因果前向计算,使每个策略 token 都以它之前的全部内容为条件,包括更早的观测(observation)。
序列中的每个 token 都有一个生成方(owner),即产生它的参与方:
- template(模板)token 由格式化代码插入:角色头、分隔符和序列起始 token。
- prompt(提示)token 是任务输入:系统消息、问题文本和工具描述。
- policy(策略)token 是 rollout 期间从模型中采样得到的。
- tool(工具)token 是环境返回的观测。
sequence_log_probs(3.4 节)对被预测的位置 ids[:, 1:] 返回 [B, T-1] 个值,因此动作掩码的形状也是 [B, T-1],第 列回答的问题是“token 是否由策略采样?”。存储的采样对数概率同样如此。5.6 节会把这种对齐固化到数据结构中,使后面的章节都不必再记住这个偏移。
5.2 带工具的真实对话模板
生产模型看不到原始的轮次。它们看到的是对话模板的输出:对话模板是随分词器一起发布的一段程序(通常是 Jinja),把一个消息列表(以及可选的工具 schema 列表)渲染成一个带特殊 token 的字符串。要知道模型看到哪些 token、生成哪些 token,就必须运行真实的模板。我们用 Qwen3-0.6B 的模板渲染一个两轮的clamp 回合。工具 schema 采用 4.7 节中 MCP 风格的 schema,并转换为模板所期望的函数调用格式。助手消息是手工编写的:还没有任何模型生成过它们。
<tool_response> 标签中,并放在 user 角色的轮次里,因此模型把观测当作用户输入来看待。每个助手轮次先在 <think> 标签内渲染推理,然后在 <tool_call> 标签内以 JSON 形式给出调用,并以 <|im_end|> 结束。渲染结果以一个打开的助手头结尾,即生成提示(generation prompt),模型在其后写出下一轮。
<tool_call> 学成一次决策,解析器也可以按 token id 而非字符串匹配来找到工具调用。
模板的输出必须按生成方切分。分词器会报告每个 token 覆盖的字符区间(offset_mapping),因此我们先给字符打标签,再让每个 token 取其第一个字符的标签。用户内容是 prompt,工具响应内容是工具输出,而每个助手轮次从其头部之后一直到它的 <|im_end|> 都是策略输出,因为在部署中这些内容全部由模型生成,包括 <think> 和结尾的 <|im_end|>。
<think> 开始、以 <|im_end|> 结束,实线边框的是被无框模板 token 包裹的工具区间,末尾是打开的助手头。损失只使用蓝色单元格,注意力使用全部单元格。
gym 的工具是一种抽象(第 4.7 节)。生产环境中的智能体用 Read、Edit 和 Bash 完成同样的修复,自己写出替换文本,并以不调用工具直接回答来结束。用同一个模板渲染这个回合,可以看出这种抽象从 token 预算中去掉了什么。助手轮次同样是手写的。
<think> 推理;其中仅一次 Edit 调用就占 45 个。这 45 个 token 大多不是自由选择:old_string 必须逐字符精确复现源码,否则工具返回错误;而 gym 的 edit 调用只用寥寥几个 token 指定一个操作名。这就是 BugFix gym 为 TinyAgent 做出的取舍。已经能准确复制和编写代码的预训练模型可以在生产环境的工具接口上探索,因此它的强化学习信号关乎写哪一个编辑。从零训练的模型则必须先学会复制文本,而一次修复的奖励会分摊到几十个必须全部正确的 token 上。
5.3 模板对历史做了什么
模板每次都会渲染整段历史。这带来一个问题:在第 步,模型看到的某个较早轮次的文本,可能与它在那一轮生成的文本不同。Qwen3 的模型卡写道“the historical model output should only include the final output part”,意思是较早的推理不应被带入后续上下文,而它的模板也是这样实现的(Qwen3-0.6B 模型卡,2026-10-13 查阅)。我们来精确检查这在什么时候发生。 检验方法是前缀稳定性(prefix stability)。先把对话渲染到某一轮并带上生成提示,这恰好是模型生成该轮时看到的内容。然后渲染一段更长的对话,看第一次渲染是否是第二次渲染的前缀。如果是,那么对最终渲染结果做一次前向计算,就能复现每个较早轮次的上下文。<think>\n\n</think>\n\n 块。这些 token 归模板所有,因此它们属于上下文,但不属于动作掩码,尽管在思考模式下同样的 token 会归策略所有。归属取决于在这一次具体的 rollout 中是谁产生了该 token,而不取决于 token 的文本。
5.4 重分词漂移
重分词漂移(retokenization drift)是多轮强化学习基础设施中后果最严重的隐患,因为它会静默失败。rollout 采样的是 token id。环境需要文本,于是这些 id 被解码。一种常见但错误的设计只保留文本,等到训练器之后需要 token id 时,再对文本重新编码,而且往往是先通过模板重新渲染。一般而言,先解码再重新编码并不能复现采样得到的 id。 BPE 分词器对同一个字符串有许多种 token 序列。采样器可以产生其中任何一种,但编码总是返回一种规范切分。当两者不同时,训练器计算的对数概率、比率和梯度都针对一条模型从未采样过的序列。 第一种发生途径是模板。模型可能以紧凑 JSON 写出工具调用。如果系统把该调用解析成一条消息、再通过模板重新渲染,就会改变它的格式,如下面这个手工编写的例子所示。- 和 hi 作为两个 token 输出。解码后的文本包含 -hi,而分词器对该文本的规范切分是 -h 后接 i。解码出的字符串完全相同,token 序列却不同。按 token 计,漂移很少见,但一个强化学习批次包含数百万个 token,而长轨迹还会增加单个回合发生漂移的机会。更糟的是,漂移集中在不常见的切分上,而这恰恰是那些对数概率对梯度影响最大的低概率样本。
解决办法是一条实现约定,我们称之为 token-in/token-out。rollout 保留采样得到的 token id,把环境的观测以 token id 形式追加,并且从不重新编码策略产生的文本。文本只存在于边界上:解码后交给环境解析器的动作,以及环境返回的观测字符串。训练器接收的是 id、掩码和采样对数概率,从不接收文本。从这里开始我们构建的一切都遵循这条规则。
生产实践。 通过 OpenAI 风格的对话 API 提供模型服务的系统拿回的是文本,除非推理引擎也返回 token id,否则两种漂移都会受到影响。这是一条一般性的工程观察;我们没有审查具体框架如何处理它。本书的建议是:把任何重新编码生成文本的路径都视为 bug,并通过在第 0 步比较训练器重新计算的对数概率与采样器的对数概率来检测它(3.7 节)。
5.5 TinyAgent 的分词器
TinyAgent 需要一个分词器,其词表(vocabulary)覆盖 gym 可能产生的每个字符串,同时序列要保持较短。词级分词器正合适。所有 gym 文本本来就是以空白分隔的单词(read 工具返回 4.4 节的紧凑投影),因此一个 token 就是一个单词,换行是特殊单词 <nl>,解码时用空格连接单词。词级分词器对每个字符串只有一种切分,因此不会发生漂移。这个简化让小模型实验更容易检查,但 rollout 仍按 5.4 节的 token-in/token-out 约定实现,以适用于真实分词器。
read 和 test 观测、每种编辑操作之后的相同观测,再加上每个格式正确的调用、每条固定消息,以及从 -20 到 150 的整数,这些整数覆盖了测试和错误编辑会产生的数值。传入所有任务,包括训练任务和留出任务(held-out tasks),使留出任务的函数名也有自己的 id。在见到留出任务之前,这些 id 一直未被训练,这模拟了真实词表中低频 token 的情形。
clamp 的 read 观测长 23 个 token(含一个 <nl>),并能精确解码还原。一个带参数的调用是四个 token,特殊 token 的 id 固定为 0 到 7(<call> 是 5,</call> 是 6),rollout 依赖这一点。词表之外的单词映射为 <unk>。下一个代码块在每个任务上运行第 4 章的每个脚本化策略,并编码每条观测,以检查 gym 从不产生 <unk>。
fix_range_end。这样的值会被编码为 <unk>,这些位置不会作为动作目标进入损失,但 <unk> 仍会丢失观测信息;频繁出现时,需要扩充词表或调整编码。
5.6 轨迹与掩码
轨迹(trajectory)是一个回合在 token 层面的记录。它存储整条序列的 id,以及与被预测位置ids[1:] 对齐的动作掩码和采样对数概率。它还存储奖励(reward)和环境最终的 info。另有两个与 ids 对齐的字段,记录每个 token 的生成方及其所属的轮次。第 7 章和第 12 章用轮次索引来做按轮的回报归因(credit assignment)。
Episode 以 5.4 节的 token-in/token-out 方式逐步构建一条轨迹。它以 <bos> 和编码后的问题描述开头。act 按采样原样追加策略调用的 id,只在把文本交给 env.step 时才解码它们,并把编码后的观测追加在 <obs> 和 </obs> 两个模板 token 之间。它还执行两条环境并不知道的限制:rollout 的轮数上限,以及模型的上下文长度 max_len。如果下一条观测和再一次调用放不下,回合就以截断(truncated)结束,奖励为 0。最后一次调用之后的观测不会被追加,因为不再有后续动作依赖于它。
make_trajectory 把这些列表转换为张量。对齐在这里集中完成:动作掩码就是“token 的生成方是策略”,采样对数概率则丢掉第一项,因为它属于 <bos>,而 <bos> 从不被预测。
scripted_rollout 让第 4 章的文本策略走同一条路径。它产生的轨迹是监督微调(SFT,第 15 章)的有效示范数据。它们的 sample_logp 为 0:对 oracle 这样的确定性策略,这是正确的对数概率,对其他策略则只是占位值,因此不能用在重要性比率(importance ratio)中。我们编码 oracle 的 clamp 回合,并打印前十六个被预测的位置。
<call> 位于第 6 列,第一条观测从 </call> 之后开始。每一列描述的是该位置的 logits 所预测的 token,这正是 sequence_log_probs 采用的约定。oracle 的回合长 76 个 token,其中 13 个是动作:四次调用,三次各三个 token,编辑调用四个 token。
ok)到约二十个 token(源代码清单)不等。条带下方的蓝色横线即动作掩码,恰好标出了策略单元格。后续的 policy loss 都遵守这条规则:只对策略采样的 token 计算直接的预测损失。工具文本仍是上下文,会通过注意力影响后续预测及其梯度。
我们在全部四个脚本化策略上检查这一不变量以及序列长度。
5.7 采样 rollout
现在由策略来写调用。generate_call 用 3.5 节的无 KV 缓存循环逐 token 采样一次调用,在遇到 </call> 或达到 MAX_CALL_TOKENS 时停止。它记录每个 token 在采样分布下的对数概率。rollout 重复这一过程直到回合结束。两者都适用于任何把 id [B, T] 映射为 logits [B, T, V] 的模型。
error : malformed call,直到预算耗尽结束回合。我们在 64 个回合上测量同样的行为,并检查存储的采样对数概率是否等于训练器从完成的序列重新计算出的值。
collate 把每个字段右填充到最长回合的长度:id 用 pad id 填充,掩码用 0,对数概率用 0,轮次索引用 -1。attention_mask 标出真实 token。在因果模型下,右填充从不影响真实位置,因此 TinyAgent 忽略它,但损失和指标需要它。每个 [B, T-1] 字段都与 sequence_log_probs 对齐。
[8, 76],三个被预测位置字段为 [8, 75]。oracle 对应行的奖励为 1,随机模型对应行的奖励为 0。oracle 行的 sample_logp 是脚本化占位值,因此对数概率检查只使用采样行,在这些行上,对填充后批次的重新计算仍然一致。没有任何填充位置的动作掩码为 1。
5.8 实践中的设计问题
问题:上下文随轮次增长。 现象: 回合数不变,但步骤耗时和显存随训练增长,长回合后期轮次的采样很慢。 原因: 每一轮都把完整的观测追加到上下文。序列长度是所有轮次之和,注意力开销随之二次增长,而没有 KV 缓存时采样开销也是二次增长(3.5 节)。随着策略学会收集更多信息,智能体强化学习往往会使回合变长。 可选方案: (a) 限制观测长度(4.8 节),这会丢失信息。(b) 限制轮数,这会改变任务。(c) 摘要或丢弃旧观测,这会改变策略的输入分布(第 12 章)。(d) 用按 token 预算组批来承担开销(第 14 章)。 建议做法: 从一开始就记录每轮的长度,并根据实测长度而非猜测来设定上下文上限。下一个代码块展示 oracle 回合中上下文如何逐轮增长。
问题:截断策略塑造训练分布。
现象: 以 stop='truncated' 结束的回合比例不断上升,策略学会变得简短或过早提交。
原因: 被截断的回合没有结果,rollout 给它奖励 0。如果长回合被系统性地截断,强化学习就会学到长度本身是坏的,无论该回合当时在做什么。
可选方案: (a) 奖励 0,简单,但偏向短回合。(b) 从批次中丢弃截断回合,这对保留下来的回合是无偏的,但会把数据过滤得偏向短回合。(c) 在截断处用价值估计做自举(bootstrap)(第 7 章),这需要一个 critic(价值模型)。
建议做法: 把截断率列为核心监控指标,通过合理设定上下文大小使其保持在接近零的水平,并把截断率上升视为 bug。下一个代码块用一个上下文只有 48 个位置的模型强制触发截断。
问题:SFT 与强化学习之间的模板变化。
现象: 在一个成功的 SFT 阶段之后,奖励在强化学习的最初几步就崩溃,或者第 0 步的重要性比率远离 1。
原因: SFT 数据用一种模板(或一种思考模式,5.3 节)渲染,而强化学习的 rollout 用另一种,于是强化学习中的策略看到的前缀与它训练时见过的不同。看似只是表面上的模板细节,例如 JSON 空格或一个空的 <think> 块,在 token 层面都是分布偏移。
可选方案: (a) 在运行配置中固定分词器和模板的版本,成本很低。(b) 用强化学习的模板重新渲染 SFT 数据,需要过一遍数据。(c) 增加一项检查:用两条流水线渲染同一段固定对话并比较 token id。
建议做法: (a) 和 (c)。对渲染后的 id 做一行 diff,就能在任何训练开始前发现这类错误。
问题:从历史中剥离推理。 现象: 多轮运行中,第 0 步的比率对最后一轮正常、对较早轮次却不正常,或者在长对话上的学习不稳定。 原因: 删除较早推理的模板(5.3 节)使最终渲染与每一轮采样时的上下文不同。在最终渲染上做一次前向计算来给所有轮次打分,会在错误的上下文中评估较早的轮次。 可选方案: (a) 按采样时的原样存储每一轮的 token 和上下文,并针对各自的上下文给每一轮打分,代价是每轮一次前向计算。(b) 训练时在历史中保留推理,这会使训练上下文与部署不同。(c) 只在最后一轮上训练,这会丢弃数据。 建议做法: 针对每一轮采样时的上下文为其打分。在一次工具调用请求之内,Qwen3 的模板是前缀稳定的,因此在那里单次前向计算是正确的。
问题:掩码 bug 静默地在工具输出上训练。
现象: 策略开始在调用之后写出看起来像观测的文本,例如测试结果或文件内容。损失看起来正常。
原因: 动作掩码与对数概率没有对齐,通常是因为其中一个做了偏移而另一个没有。
可选方案: (a) 在一个地方根据逐 token 的归属构建掩码(如 make_trajectory 所做),成本很低。(b) 在训练器中利用归属张量断言每个被掩码选中的 token 都是采样得到的。
建议做法: 两者都做。下一个代码块展示一个未偏移的掩码会选中什么。
<call>)从损失中消失,最后一个 </call> 落到了末尾之外,而对前三次调用中的每一次,</call> 之后的那个位置现在在训练 <obs> 模板 token。程序不会崩溃,而策略被训练去预测在它自己的调用之后会开始一条观测。如果观测更长、掩码又是根据工具区间构建的,同样的差一错误就会在工具输出上训练。
问题:停止 token 的处理。 现象: 调用永不结束,回合总是触到调用长度上限,或者模型在工具调用之后继续生成。 原因: 停止 token(这里是</call>,Qwen3 是<|im_end|>)由策略生成,必须在动作掩码中。如果 rollout 在遇到它时停止却没有记录它,或者记录了但掩码为 0,策略就永远得不到关于何时停止的梯度。 可选方案: (a) 总是把采样得到的停止 token 连同其对数概率包含在轨迹中,如generate_call所做。(b) 在触到调用长度上限时把停止 token 作为模板 token 注入,这能干净地结束调用,但加入了策略并未采样的 token。 建议做法: (a)。如果上限截断了一次调用,就让它保持未终止状态,交给解析器拒绝,就像 BugFix gym 所做的那样,使策略学到未终止的调用会失败。
小结
- 一个多轮回合变成一条 token 序列,其中的 token 分别归模板、提示、策略或工具所有。动作掩码选取策略 token。它与被预测位置
ids[:, 1:]对齐,存储的采样对数概率也是如此。 - Qwen3-0.6B 的模板把工具 schema 渲染为几百个 token 的系统前导内容,把观测渲染为
<tool_response>标签中的用户轮次,把调用渲染为<tool_call>标签中的 JSON。它在一次工具调用请求之内保留推理,一旦出现新的用户消息就将其丢弃,这破坏了前缀稳定性。 - 对生成的 token 解码再重新编码可能改变它们。本书测得:8 条 128 token 的 Qwen3 样本中有 1 条发生漂移,而重新渲染一个紧凑 JSON 调用把 19 个 token 变成了 21 个。rollout 必须遵循 token-in/token-out。
- TinyAgent 的词级分词器有 316 个条目,覆盖全部 gym 文本。oracle 的回合约 70 个 token,所有脚本化策略的回合都在 200 以内。
rollout和rollout_batch记录采样的 id、采样对数概率和掩码。collate把它们填充为ids、attention_mask[B, T],action_mask、sample_logp、turn_ids[B, T-1],以及rewards[B]。未训练的 TinyAgent 只输出格式错误的调用,奖励为 0,因此强化学习需要一个有时能成功的初始策略。
练习
- 用
enable_thinking=False并去掉reasoning_content,渲染 5.2 节的回合。统计每个生成方的 token 数。如果把助手头之后的所有内容都标为策略,哪些模板 token 会被错误地纳入动作掩码? - 用 32 个样本、温度 0.6 和 1.0 重复 5.4 节的漂移实验。降低温度会减少漂移吗?从哪些 token 发生漂移的角度加以解释。
- 改变一个假设:让 rollout 在最后一次调用之后也追加观测。轨迹的哪些字段会改变?第二部分中的任何损失的值会改变吗?开销会有什么变化?
- 编写
assert_token_in_token_out(traj, tok):如果任何动作区间在解码再重新编码后与存储的 id 不同,就让它失败。为什么对 TinyAgent 的分词器它永远不会失败?对 Qwen3 你会改为检查什么? - 修改
collate,改为在左侧而非右侧填充。为了让 TinyAgent 计算出相同的对数概率,还必须改变什么(考虑位置和因果掩码)?为什么推理引擎在生成时常常偏好左填充? - 设计评审:你的推理服务通过对话 API 以文本形式返回补全结果,训练器再对其重新分词。一位同事认为漂移太少见,无关紧要。设计一个能界定其对梯度影响的测量方法,并提出能消除该问题的、对服务接口的最小改动。