RLforge
完整主线 路线6 / 27 · L0 开炉退出路线
原理预计 30 分钟

token 与 chat template:新手翻车最密集的地方

一半的「RL 不收敛」其实是模板拼错、EOS 没停、mask 错位。这一节把这些坑提前踩掉。

学完这节你能做到

  • 手动拼出一条 chat template 的完整 token 序列,并指出哪些位置该算 loss
  • 说清 prompt mask、completion mask、padding mask 三者的关系
  • 判断「模型不停下来」是模板问题、EOS 问题还是采样参数问题

一半的「RL 不收敛」不是算法问题

而是这三件事之一:

  1. chat template 拼错了,模型不知道该轮到它说话
  2. loss mask 错位,梯度算到了 prompt 上,或者错了一位
  3. 模型停不下来,全部回答顶到 max_tokens 被截断

这三个都不报错。训练照跑、loss 有值、日志在刷,只是效果不对。而且它们排查起来比算法 bug 更烦,因为你会本能地先怀疑算法。

这一节把它们提前踩掉。代价是 30 分钟,收益是省掉一个通宵。

tokenizer 做三件事

职责方法会坑你的地方
编码 / 解码encode / decode往返不保证还原(L2 会详述)
特殊 tokeneos_token_id、pad_token_idBase 模型可能没有 pad token
chat templateapply_chat_templateBase 与 Instruct 的模板完全不同

把模板打印出来看

不要相信任何博客里贴的模板长什么样,包括这一页。直接打印你自己那个模型的:

from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("./models/Qwen3-0.6B")

msgs = [{"role": "user", "content": "1+1=?"}]

s = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
print(repr(s))          # 用 repr!否则看不见换行和空格

repr 这一点很关键——直接 print 你看不出末尾有没有换行,而那个换行会不会存在直接影响模型的行为。

输出大致是这样(以 Qwen3 为例,实际以你打印出来的为准):

'<|im_start|>user\n1+1=?<|im_end|>\n<|im_start|>assistant\n'

拆开看:

<|im_start|>user\n     ← 用户回合开始
1+1=?                  ← 你的内容
<|im_end|>\n           ← 用户回合结束
<|im_start|>assistant\n ← 轮到模型了(这就是 add_generation_prompt 加的)
                       ← 模型从这里开始生成
×add_generation_prompt=True 不能漏

少了它,字符串末尾停在 <|im_end|>\n,模型看到的是「用户说完了」,但没有「该你说了」的信号。

后果是模型可能:自己再生成一个 <|im_start|>user 接着扮演用户、或者输出空白、或者直接输出 EOS。

症状是「模型什么都不说」或「模型自己和自己对话」。 遇到这两种现象,第一件事就是 repr 打印你的 prompt。

Base 与 Instruct 选错了会怎样

Base 模型Instruct 模型
训过对话格式吗❌✅
apply_chat_template可能没有模板,或有一个占位模板有真正训过的模板
直接问问题会续写、复述、跑题会回答
适合做 RL 起点吗⚠️ 可以,但要先 SFT 教格式✅ 直接可用

R1-Zero 证明了从 Base 直接跑 RL 是可行的,但那是在很强的大 base 模型 + 大量算力上。0.6B 上从 Base 起步,你大概率会卡在「模型连格式都不稳」这一步,采样出来一批 reward 全 0,整组优势归零,训练不动。

▣所以本路径用 Instruct

Qwen/Qwen3-0.6B 是带指令能力的版本,直接可用。

如果你想试 Base(-Base 后缀),先确认它的 chat_template 是不是 None——是的话你得自己拼 prompt,而且要先做一轮 SFT 把格式教会,否则 L2 的训练大概率跑不动。

RL 里的 loss mask

这是本节最重要的部分。

一条完整序列长这样:

位置   0    1    2    3    4    5    6    7    8    9   10   11
token  <im> user \n   1+1  =?   <end> \n  <im> asst \n   答案  是2
       └──────────── prompt ────────────────────────┘  └─ completion ─┘

attention_mask   1    1    1    1    1    1   1    1    1    1    1    1
completion_mask  0    0    0    0    0    0   0    0    0    0    1    1

三个 mask 各管一件事:

mask覆盖用来做什么
attention_mask所有真实 token(pad 之外)告诉模型哪些位置有内容
completion_mask只有模型生成的部分哪些位置算 loss
padding补齐用的位置上面两个都是 0

为什么只对 completion 算 loss:prompt 是给定的,模型没有「选择」它。RL 优化的是模型的决策,而 prompt 里没有模型的决策。

×错一位:最隐蔽的一个 bug

Transformer 在位置 t 输出的 logits,预测的是位置 t+1 的 token。

所以要拿位置 t 那个 token 的概率,得用位置 t-1 的 logits:

logits  = out.logits[:, :-1, :]     # 丢掉最后一个位置
targets = input_ids[:, 1:]          # 丢掉第一个 token
mask    = completion_mask[:, 1:]    # mask 跟着错一位

三个都要错一位,只错两个就完了。

这个 bug 不报错,只是让梯度作用在错误的 token 上,训练看起来在跑但学不到东西。L2 会给出一个能一次性抓住它的断言:ratio 初值必须恒等于 1。

三种停止方式

模型停下来有三条路,搞混了会导致「回答全部被截断」:

方式谁触发停在哪是否算完整回答
EOS token模型自己生成了 eos_token_id✅ 正常结束
stop 字符串采样器匹配到指定字符串✅ 正常结束
max_tokens采样器长度到上限❌ 被截断

判断一条回答属于哪种:

for cand in out.outputs:
    if cand.finish_reason == "stop":
        pass          # 正常结束(EOS 或 stop 字符串)
    elif cand.finish_reason == "length":
        pass          # 被 max_tokens 截断 ← 要统计比例
!截断的回答会给模型错误的信号

被截断 → 通常没写出最终答案 → 判分器抽不到答案 → reward 判 0 → 模型学到「这样答不好」。

但真实原因是长度不够,不是答得不好。这是一种系统性的错误信号。

所以 L2 的日志里有 trunc 这一项。判读标准:

截断率处置
< 10%正常
10% ~ 15%留意,看是否在上升
> 15%调高 max_tokens,或给截断样本单独的 reward 处理
> 50%训练已经在学错东西,停下来修

常见翻车现场

rl@forge目标 0/4
  1. 1.认出漏了 add_generation_prompt 的症状
  2. 2.认出回答里带模板标记的原因
  3. 3.验证 loss mask 的错一位对齐
  4. 4.统计截断率并判断是否需要处理
四个真实翻车现场。每个先看现象,再看 repr 打印出的真相。
goals 看目标,hint 要提示。
[rl@forge ~/rlforge]$
help 查看用法 · goals 看目标 · hint 要提示 · ↑↓ 翻历史
Checkpoint单选

模型采样出来 8 条里有 6 条是空的,另外 2 条在扮演用户提问。最可能是什么原因?

Checkpoint单选

loss mask 的错一位对齐,为什么「只错两个张量」这个 bug 特别难发现?

Checkpoint单选

长度分布图上,在 max_tokens 处突然出现一根孤立的柱子,说明什么?

这节课的落点

  • 一半的「RL 不收敛」是模板、mask、截断三件事之一,而且都不报错
  • 用 print(repr(...)) 打印模板,别相信任何文档里贴的样子
  • add_generation_prompt=True 不能漏,漏了症状是空回答或模型自己扮演用户
  • 0.6B 用 Instruct 起步;Base 要先 SFT 教格式,否则 reward 全 0 训不动
  • completion_mask 只覆盖生成部分;logits / targets / mask 三个都要错一位
  • 三种停止方式:EOS、stop 字符串是正常结束,max_tokens 是被截断
  • 用 finish_reason 统计截断率;「恰好等于上限」的孤立柱子是截断的特征形状