这篇文章对应视频:【[veRL] tokenizer 编解码的非对称性,RL 训练崩溃到 Agent loop 中的 token in token out】(BV1b2pDzYEY2)。

我不会把它写成“视频复述”,而是把它抽象成一个你做 RL4LLM / Agentic RL / Multi-turn Tool Use 一定会遇到的工程定律:

在 RL 训练里,token_ids 才是“行为(action)”本体;把它 decode 成文本、再 encode 回去,往往已经不是同一个行为了。

一旦你在 rollout 的链路里出现 decode → encode(尤其是 multi-turn),你就可能让 PPO/GRPO 训练变成“在错误分布上算 logprob”,表现为:

  • approx_kl/clipfrac/loss 统计异常
  • reward curve 不上升,甚至彻底不收敛
  • multi-turn agent loop 越跑越乱(历史拼接后 token 逐步漂移)

系列导航:

关联阅读(建议顺序):

  1. Tool-use agent 的训练闭环(cold-start SFT + async rollout + response_mask)
  2. SFT 工程:交叉熵 / loss mask / scheduler
  3. PG/PPO loss 组件:ratio/clip/KL/entropy/聚合

配套仓库(你本地已下载)中,这篇文章会重点对齐这两个 notebook:

  • tokenizer 编解码非对称性示例与复现:
    • /Users/wangpeng/Downloads/modern_genai_bilibili-main/agentic_rl/verl/tokenizer/encode-decode.ipynb
  • agent loop 的“token in token out”原因、async rollout、prompt_ids/response_mask
    • /Users/wangpeng/Downloads/modern_genai_bilibili-main/agentic_rl/verl/agent/agent_loop_details.ipynb

0. 先把术语讲死:什么叫“非对称性”

下面用两个函数记号(不用纠结数学,目的是把问题说清楚):

  • E(text):tokenizer encode,把文本转成 token ids
  • D(token_ids):tokenizer decode,把 token ids 还原成文本

你在直觉上可能希望它们互为逆:

  • D(E(text)) == text(大多数情况下“差不多对”,但也可能有空白符/特殊 token 等细节差异)
  • E(D(token_ids)) == token_ids(这句在工程上经常是错的)

关键在第二句:LLM 生成的是 token ids 序列,但 tokenizer 的 encode 是“给定文本的规范分词”。两者不是同一个过程。

encode-decode.ipynb 里用一句话总结得很准确:

  • D(E(text)) = text(更常见)
  • E(D(token_ids)) ≠ token_ids(非常常见)

只要你理解了这点,很多“RL 不收敛”的怪现象就会变得可解释。


1. 为什么 E(D(ids)) 可能改变 ids:模型的生成路径不是“规范分词”

tokenizer(BPE / Unigram / SentencePiece 等)的 encode 往往有一个“规范性”:

  • 贪心 / 最长匹配 / 最优分词:给定同一段文本,encode 通常会选择一个“唯一或偏唯一”的 token 切分。

但模型生成是逐步选 token,它可能走出一种“非规范拼法”:

  • 词表里同时存在 <think><, think, > 这些 token;
  • 模型完全可能一步步采样出 <think>
  • 你把它 decode 成字符串 "<think>" 后,再 encode 回去,tokenizer 可能直接用 <think> 这个 token(或另一种更长切分)。

于是:

  • 原始生成 ids[ "<", "think", ">" ]
  • 回编码 ids[ "<think>" ]

这不是罕见 corner case,而是“只要词表允许,训练早晚会遇到”的常态。

1.1 repo 里的最小复现(直接对照 notebook)

encode-decode.ipynb 给了一个非常具体的例子(示意):

1
2
3
# 两条不同的 token 路径,decode 后可能是同一个字符串 "<think>"
D([13708, 766, 29]), E(D([13708, 766, 29]))
D([27, 26865, 29]), E(D([27, 26865, 29]))

这类例子在 LLM 里尤其多见,因为:

  • 你会用大量“协议 token”(XML/JSON/工具调用标签/role tag)
  • 词表里经常同时存在“整段标签”和“字符级碎片”

2. 为什么这在 serving/agent 产品里不算致命,但在 RL 训练里是致命的

先说结论:

  • Serving/agent 产品:你 decode 出来能看、能用、能继续对话,通常就没事。
  • RL 训练:你必须保证 rollout 的轨迹真的是从 policy 分布里采样出来的,否则 logprob/ratio/KL 的意义会被破坏。

PPO/GRPO 等算法依赖的最核心事实是:

你在计算的 log π_\theta(a_t|s_t),必须对应“真实执行过的 action”。

如果你把 action(token ids)换成了另一组 ids,你就变成:

  • 行为 A 产生 reward
  • 却用 行为 B 计算 logprob/ratio/KL

这等价于一种非常隐蔽的 off-policy mismatch,轻则不收敛,重则直接发散。

2.1 最常见的触发点:multi-turn 把历史存成 messages,然后每轮 apply_chat_template

几乎所有 agent 框架(LangGraph / CrewAI / LlamaIndex…)都会:

  • 把对话历史存成 messages(role/content/tool)
  • 每次生成前把整段 messages 重新拼成 prompt string
  • 再 encode 给模型

在推理产品里这很正常;但在 RL 训练里,它会引入一个关键不一致(encode-decode.ipynb 里点得很直白):

encode(messages) != (prompt_ids ⊕ response_ids)
即:对“最终 messages”做一次整体 encode,可能不等于你每轮把 prompt_idsresponse_ids 拼起来得到的 token 序列。

特别是在你经历过:

  • 模型生成了“非规范 token 切分”(上一节说的 <, think, >
  • tool output / observation 被插入到历史里(包含大量特殊字符/空白/换行)
  • chat template 自动插入 separator / BOS/EOS / role tokens

这种不一致会越来越大,最终 PPO 会表现得像“完全不在一个坐标系里训练”。


3. Token-in-Token-out:把“轨迹 = token ids”当作不可变事实

veRL/verl 的解决方案非常工程化,也非常值得你在自己的 agentic RL 系统里照搬:

输入用 token ids,输出也用 token ids;把文本当日志,而不是训练数据的主形态。

也就是所谓 token in, token out

  • in:给 LLM server 的是 prompt_ids: list[int],而不是 messages/text
  • out:拿到的是 response_ids: list[int],并把它 append 回 prompt_ids

agent_loop_details.ipynb 里,AgentLoop 甚至强调:

  • “避免 Chat Template 在多轮对话中反复 Encode/Decode 导致的不一致问题”
  • AsyncLLMServerManager.generate(prompt_ids=...)(输入是 token ids)

3.1 一个足够接近真实系统的伪代码

你可以把整个多轮过程理解成“只做 token 级拼接,不做 message 级重建”:

1
2
3
4
5
6
7
8
9
10
11
12
prompt_ids = encode_initial(system_and_user_messages)  # 只做一次

while not done:
# 生成:输入 token ids
response_ids = llm_generate(prompt_ids)

# 训练所需的轨迹:直接存 token ids
prompt_ids += response_ids

# 如果触发 tool call:把 tool output 也 encode 成 ids 并追加(但这部分不算 action)
tool_output_ids = encode_tool_output(tool_output_text)
prompt_ids += tool_output_ids

注意:这里的 encode_tool_output 不是为了“让模型训练 tool output”,而是为了让它在下一轮生成时能看到 observation。真正训练时,你会用 response_mask 把 observation token 排除掉(在 tool-use 那篇里我已详细解释)。


4. Debug 清单:你怎么判断自己踩中了这个坑

我建议你在 RL 训练(尤其 multi-turn)里把下面 3 个检查写成单元测试/日志断言:

4.1 检查 1:是否出现了不该出现的 decode → encode

任何一条路径只要出现:

  • text = tokenizer.decode(ids)
  • ids2 = tokenizer.encode(text)

你就要默认它会改变 ids,除非你证明不会。

4.2 检查 2:整体 apply_chat_template(messages) 是否等于增量 token 拼接

对同一条轨迹,做两种构造:

  1. 最终 messages 整体 encode(常见 agent 做法)
  2. 每轮增量拼接 prompt_ids ⊕ response_ids(token-in-token-out)

如果二者不一致,你就不要在 RL 训练里用 messages 方式做“训练用 token”。

4.3 检查 3:出现“不合物理直觉”的 PPO 统计量

典型症状:

  • approx_kl 很大但模型输出看起来没怎么变
  • clipfrac 长期极端(接近 0 或接近 1)
  • reward 曲线停滞但 loss 各项在剧烈抖动

这些现象当然也可能由学习率/优势刻度等引起,但 token mismatch 是你应该优先排除的一类“结构性错误”。


5. 这对你的 deep research / tool-use agent 意味着什么

deep research 类 agent 往往具备“天然放大这个问题”的特征:

  • 多轮:history 很长,累计偏差更大
  • interleaved:tool output 文本复杂、包含换行/代码/引用,chat template 更容易插入或规范化
  • 强依赖 logprob:你用 RL 训练时需要稳定的 logprob/ratio/KL/entropy 统计来诊断

因此我的建议是:

  1. 从第一天就把 token-in-token-out 作为 agent loop 的底层接口,不要等 PPO 不收敛了再改。
  2. 把“messages 形态”降级成 UI/日志形态;训练与评测闭环都基于 token ids。
  3. tool output 永远当 observation,并用 response_mask 排除在 policy gradient 之外。

6. 小结

这篇的核心只有一句话:

RL 训练的轨迹必须是“策略真实生成的 token ids”;任何把 token 变回文本、再变回 token 的流程,都可能让你在错误分布上优化,导致不收敛。

如果你后续继续按 veRL/verl 的路线做 multi-turn tool-use/coding agent,我建议你把这篇当作“工程底线”,比任何超参经验都更优先。