这篇文章对应视频:【[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 逐步漂移)
系列导航:
关联阅读(建议顺序):
- Tool-use agent 的训练闭环(cold-start SFT + async rollout + response_mask)
- SFT 工程:交叉熵 / loss mask / scheduler
- 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 idsD(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 | # 两条不同的 token 路径,decode 后可能是同一个字符串 "<think>" |
这类例子在 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_ids与response_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 | prompt_ids = encode_initial(system_and_user_messages) # 只做一次 |
注意:这里的 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 拼接
对同一条轨迹,做两种构造:
- 最终 messages 整体 encode(常见 agent 做法)
- 每轮增量拼接
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统计来诊断
因此我的建议是:
- 从第一天就把 token-in-token-out 作为 agent loop 的底层接口,不要等 PPO 不收敛了再改。
- 把“messages 形态”降级成 UI/日志形态;训练与评测闭环都基于 token ids。
- tool output 永远当 observation,并用
response_mask排除在 policy gradient 之外。
6. 小结
这篇的核心只有一句话:
RL 训练的轨迹必须是“策略真实生成的 token ids”;任何把 token 变回文本、再变回 token 的流程,都可能让你在错误分布上优化,导致不收敛。
如果你后续继续按 veRL/verl 的路线做 multi-turn tool-use/coding agent,我建议你把这篇当作“工程底线”,比任何超参经验都更优先。

