写 rollout:让模型自己生成一批答案
训练数据不是给的,是采的。这一节把「一个 prompt 采 8 条回答」写出来,并处理好 token 对齐。
学完这节你能做到
- 用 vLLM 对一批 prompt 各采 G 条回答,拿到 token id 而不是字符串
- 解释为什么必须拿 token id 回来(token-in-token-out)
- 把采样结果整理成后面能直接喂进训练的张量
这一阶段要写的东西
四节课拼出一个文件:
scripts/grpo_scratch/
├── rollout.py ← 本节:让模型生成一批答案
├── rewards.py ← 下一节:打分
├── grpo.py ← 第三节:优势、ratio、clip、KL
└── train.py ← 第四节:串起来跑
总共约 200 行。跑完你会有一个完全属于自己的 RL 训练脚本。
rollout 的输入输出,先定死
写代码前先把接口定清楚,后面三节都按这个接口对接:
输入:一批 prompt(比如 8 个数学题)
↓
输出:一个列表,每个元素是
{
prompt_ids : list[int] ← prompt 的 token
completion_ids : list[int] ← 生成部分的 token
text : str ← 生成的文本(打分和肉眼检查用)
group_id : int ← 属于哪个 prompt,算优势时要按组分
}
prompts × G 条记录。8 个 prompt、G=8 就是 64 条。
这是新手最容易埋下的一个隐患。
你可能想:拿文本就行了,训练时再 tokenize 一次。不行。 因为 decode → encode 不保证还原:
ids = [15496, 11, 1917]
text = tok.decode(ids) # "Hello, world"
ids2 = tok.encode(text) # 可能变成 [15496, 11, 995] —— 长度或内容都可能变BPE 分词在不同上下文里对同一段字符可能切法不同,空格和特殊符号尤其容易出问题。
后果很隐蔽:你算 logprob 用的是重新 encode 的 token,而实际采样出来的是另一套 token。ratio 一开始就不等于 1,训练看起来在跑,但优化的目标是错的。 这种 bug 不会报错,只会让效果莫名其妙地差。
规矩:采样返回的 token id 一路带到训练,中间不要重新 tokenize。
Miles 把它叫 TITO(Token-In-Token-Out),并写进了架构特性列表:避免 rollout 与 training 之间的 detokenize / retokenize 往返。
一个听起来像营销词的缩写,本质就是「别重新 tokenize」。等你自己踩过一次就会觉得它值得单独列一条。
写 rollout
# scripts/grpo_scratch/rollout.py
from dataclasses import dataclass
from vllm import LLM, SamplingParams
@dataclass
class Sample:
prompt_ids: list[int]
completion_ids: list[int]
text: str
group_id: int
def build_engine(model_path: str, util: float = 0.3, max_len: int = 1024) -> LLM:
return LLM(
model=model_path,
gpu_memory_utilization=util, # colocate:只给 vLLM 这么多
max_model_len=max_len,
dtype="bfloat16",
enable_sleep_mode=True, # 训练时能把显存还回来,第四节用
)
def rollout(
engine: LLM,
tokenizer,
questions: list[str],
group_size: int = 8,
max_tokens: int = 512,
temperature: float = 1.0,
) -> list[Sample]:
# 用 chat template 拼 prompt。注意 add_generation_prompt=True,
# 否则模型不知道该轮到它说话了
prompts = [
tokenizer.apply_chat_template(
[{"role": "user", "content": q}],
tokenize=False,
add_generation_prompt=True,
)
for q in questions
]
params = SamplingParams(
n=group_size,
temperature=temperature,
top_p=1.0,
max_tokens=max_tokens,
)
outputs = engine.generate(prompts, params)
samples: list[Sample] = []
for gid, out in enumerate(outputs):
for cand in out.outputs:
samples.append(
Sample(
prompt_ids=list(out.prompt_token_ids),
completion_ids=list(cand.token_ids), # ← 关键:拿 id
text=cand.text,
group_id=gid,
)
)
return samples
不到 40 行。关键点都在注释里。
采样参数怎么设
temperature:不能是 0
temperature = 0 # ❌ 绝对不行
GRPO 的基线是组内均值。temperature=0 时 8 条回答完全一样,reward 完全一样,std = 0,整组优势归零,一个梯度都没有。
推荐值:
| temperature | 效果 |
|---|---|
| 0 | ❌ 组内无差异,训练不动 |
| 0.7 ~ 0.8 | 偏保守,组内差异小,容易全对或全错 |
| 1.0 | ✅ 默认起点,多样性和质量平衡 |
| > 1.2 | 探索强但质量下滑,回答容易变成胡话 |
「优势标准差一直接近 0」这个症状,第一个该动的就是它 —— 调高 temperature 能立刻增加组内差异。
反过来如果输出质量崩了、格式全乱,先把它调回 1.0。
top_p:设 1.0
推理时常用 top_p=0.9 来提升质量。但训练时不要裁剪分布:
被 top_p 砍掉的 token 概率在采样时是 0,但训练侧算 logprob 时用的是完整分布。两边不一致,会引入偏差。
保持 top_p = 1.0,让采样分布和训练分布严格对齐。
max_tokens
直接决定 KV cache 和采样时间。数学题任务 512 起步就够。
如果大量回答的长度恰好等于 max_tokens,说明它们是被截断的,而不是自己说完的。
被截断的回答通常没写出最终答案 → reward 判 0 → 模型收到「这样答不好」的信号,但真实原因是长度不够。这是一种系统性的错误信号。
对策:调高 max_tokens,或者在 reward 里对截断的样本特殊处理(L4 会讲)。
跑一次,肉眼看看
在写任何 reward 之前,先把 8 条回答打印出来看。这一步不能跳。
from transformers import AutoTokenizer
MODEL = "./models/Qwen3-0.6B"
tok = AutoTokenizer.from_pretrained(MODEL)
engine = build_engine(MODEL, util=0.3)
q = "小明有 5 个苹果,他给了小红 2 个,又买了 7 个。他现在有几个苹果?"
samples = rollout(engine, tok, [q], group_size=8, max_tokens=512)
for i, s in enumerate(samples):
print(f"\n===== #{i} ({len(s.completion_ids)} token) =====")
print(s.text.strip())
你会看到 8 条不一样的回答,质量参差不齐。这个参差就是 GRPO 的信息来源。
- 1.肉眼确认 8 条回答确实不一样
- 2.统计长度分布,看有多少条被截断
- 3.验证 token id 没有被重新 tokenize 破坏
rollout 检查演练。跑完采样后有三件事必须确认,按顺序来。 goals 看目标,hint 要提示。
拼 batch:pad 的方向
采样出来的序列长度不一,喂进训练前要 pad 成矩阵。这里有个容易错的地方。
右 pad(训练用这个) 左 pad(生成时用)
[prompt][completion][pad][pad] [pad][pad][prompt][completion]
- 生成时用左 pad:这样所有序列的「最后一个 token」在同一列,方便取下一步的 logits。vLLM 内部自己处理,你不用管。
- 训练时用右 pad:位置索引从 0 开始连续,attention mask 和 loss mask 都好算。
同时要造两个 mask:
attention_mask # 哪些位置是真实 token(pad 之外全是 1)
completion_mask # 哪些位置要算 loss(只有 completion 部分是 1)
RL 只对模型自己生成的部分求梯度。prompt 是给定的,不该算 loss。
而且要注意错一位的问题:预测第 t 个 token 用的是第 t-1 个位置的 logits。所以 mask 和 logits 之间通常要错一位对齐。
这个 bug 不会报错,只会让效果变差。 第三节写 loss 时会给出具体的对齐检查方法。
为什么 rollout 必须返回 token id,而不是只返回文本?
GRPO 的 rollout 里 temperature 设成 0 会发生什么?
发现有 30% 的回答长度恰好等于 max_tokens,说明什么?
这节课的落点
- 接口定死:
(prompt_ids, completion_ids, text, group_id),共prompts × G条 - token id 一路带到训练,中间绝不重新 tokenize(TITO)
- temperature 必须 > 0,默认 1.0;top_p 保持 1.0 让采样与训练分布一致
- 观察「顶到 max_tokens 的比例」,超过 15% 要处理
- 生成用左 pad(vLLM 自理),训练用右 pad
- loss mask 只覆盖 completion,且注意与 logits 错一位对齐
- 写 reward 前先把 8 条回答打印出来肉眼看一遍
延伸资料
- ·Miles — SGLang + Megatron 的企业级 RL 框架 ↗
- ·rlforge 配套脚本
scripts/grpo_scratch/rollout.py