拿到一台双卡机器后,我第一件事不是跑 benchmark,而是一步步复现一个 R1 风格的推理模型。因为这类模型最大的特点是:它在给出答案之前,会先生成一大段思考过程,这跟普通指令微调模型完全不一样,训练方式和公式也随之发生很大变化。这篇文章记录我这次完整搭建的路线:训练数据怎么构造、GRPO 公式每一行代表什么、训练曲线该怎么看、可视化为什么不能只盯着一行 loss,以及我踩过的几个大坑。
如果你正准备用强化学习把普通对话模型调成“推理型”,或者只是好奇这类模型训练时到底发生了什么,这篇文章可以直接当操作手册用。我尽量把每步的“为什么”也讲清楚,让你不仅会跑,还能在出问题时知道去哪里排查。
1. 先把整体路线想清楚:R1 到底复现了什么
1.1 从“答得快”到“想得久”
以前我们微调模型,追求的是用户问完问题后模型立刻给答案,多了不要。但推理型模型的训练目标恰恰相反:我们希望模型在正式回复前,先输出一段内部思考过程,哪怕这段话很长、试探性很强,甚至看起来像自言自语。
R1 这类模型的核心行为就是:问题进来,先产生一段自由推理文本,再给出最终答案。这个行为不是靠几千条 SFT 数据“教”出来的,而是大量强化学习信号逼出来的。理解了这一点,你就明白为什么直接拿聊天模型去推理数学题会很难看:它的训练目标里根本没有“允许先想五分钟”这个选项,模型只会强行输出一个似是而非的答案。
我在复现时用的基座是 7B 级别。这个规模在单机双卡上还能跑,跟原版的千亿参数当然没法比,但足以观察到“思考链变长、自我修正出现”这些标志性行为。1.5B 也可以,适合先把流程跑通,缺点是推理质量上限低一些,容易在训练中提前收敛到“伪思考”。
1.2 多阶段训练路线:SFT → RL → 拒绝采样 → 再 RL
原版 R1 的训练流程不是一步到位的,拆开看大致是这样:
- 冷启动 SFT:先用少量(几千条)人工精心整理的推理数据,让模型学会“带思考过程回答”的基本格式。
- 面向推理的 RL:在数学、代码等有确定性答案的任务上做强化学习,奖励就是“答案对不对”“格式对不对”。
- 拒绝采样与微调:把 RL 阶段表现好的样本收集起来,再做一轮 SFT,相当于把所有学到的经验沉淀成模型参数。
- 全场景 RL:在更多任务上继续强化学习,让模型在通用场景也能保持推理习惯。
我在本地复现时做了简化:第一轮 SFT 用 1 万条左右的推理数据,RL 阶段用几千条数学题,跑完这两个阶段就已经能看到明显的“思考”行为。拒绝采样那步对于数据质量提升确实有价值,但如果你只是想验证训练链路,可以放到模型收敛后再补。
这里多说一句拒绝采样。它的操作逻辑很像“优中选优”:用训练中的模型对大量问题生成多个答案,再用同样的规则奖励筛选出正确的、思考链完整的样本,作为下一轮 SFT 数据。这样做的好处是让模型反复看到自己历史上表现最好的输出模式,比直接增加 RL 步数更容易稳定提升。缺点是数据规模膨胀很快,要控制好筛选阈值,否则低质量样本会把格式带歪。
1.3 为什么大家都用 GRPO 而不是 PPO
PPO 是经典的强化学习算法,但它需要一个 critic 模型(价值模型)来估计每个 token 的优势值。critic 和策略模型一样大,训练成本直接翻倍,而且 critic 训得不好会让策略更新方向很飘。
GRPO 的思路更简单粗暴:对同一个问题让模型生成一组答案,用这组答案内部的表现去计算优势,完全不需要 critic。组里的答案互相作为对照,答得好的获得正优势,答得差的获得负优势,策略朝着“比组内平均水平好”的方向更新。
我把两者的差异整理成了表格:
| 对比项 | PPO | GRPO |
|---|---|---|
| 是否需要额外价值模型 | 需要 | 不需要 |
| 优势计算依据 | critic 分数 | 组内奖励归一化 |
| 显存占用 | 高 | 少一个模型规模 |
| 公式复杂度 | 高 | 相对简单 |
| 适用场景 | 通用 RL | 奖励可计算的推理任务 |
实际跑下来,GRPO 对奖励函数的稳定性要求更高,因为所有优势都是从组内相对关系里算出来的,如果奖励全是 0 或全是 1,组内方差为 0,更新就会失灵。这也是为什么很多复现项目里,格式奖励和正确性奖励一定要分开写,避免因为变量太少导致训练直接雪崩。
2. 核心公式拆解:GRPO 的每一行到底在算什么
2.1 优势函数怎么算:手把手带一组数字走一遍
GRPO 的第一步是对同一个问题采样多个输出。假设问题 q 采样出了 G 个回复,用奖励函数 R 给每个回复打分,得到 r_1 到 r_G。然后做标准归一化:
advantage_i = (r_i - mean(r)) / (std(r) + eps)
这里的 eps 是一个很小的数,防止分母为 0。我来举一个具体例子。假设一组 4 个回复的奖励是 [0, 1, 1, 0],那么平均数是 0.5,标准差约 0.5。代入公式后,4 个优势分别是 -1、1、1、-1。奖励为 1 的两个回复会被策略鼓励,为 0 的两个会被抑制。
这个公式的含义很直白:如果这个回复比同组平均分高,优势就是正的,策略网络会提高生成这条回复的概率;反之降低。注意这里是“比同组平均高”,不是一个绝对标准。所以哪怕整组回答都不太行,只要某个回答相对更好,它也会被鼓励。这在训练早期很有用,因为模型的初始奖励通常都很低,绝对阈值很难定,组内归一化自动解决了标准漂移问题。
2.2 策略损失与重要性采样裁剪
有了优势值之后,策略更新用的目标是最大化期望优势。但因为策略一直在变,我们不能直接用当前策略去采样再算梯度,那样方差大且可能不稳定。实际操作使用重要性采样的做法:
ratio = π_θ(o | q) / π_old(o | q)
其中 π_old 是采样时的旧策略,π_θ 是待更新的新策略。然后应用策略梯度损失:
L = - E[ min(ratio * advantage, clip(ratio, 1-ε, 1+ε) * advantage) ]
这个 clip 和 PPO 里一样,作用是把单次更新幅度限制住。比如 ε 设为 0.2,那么 ratio 只能落在 0.8 到 1.2 之间,超出的部分不会继续放大梯度,避免一次更新太猛直接把模型改坏。
在 GRPO 里,因为一个 step 会同时对 G 个回复计算 loss,所以最终损失还要除以 G,再按回复长度做平均,防止长回复和短回复之间产生不公平的梯度贡献。这行代码写起来不复杂,但很多人第一次实现时容易忘记除以回复长度,导致长回复的 token 数太多,主导了梯度方向。
2.3 KL 散度惩罚:别让模型跑飞掉
强化学习有个老问题:为了拿奖励,模型会找到奖励函数里的漏洞,输出一些反常但能刷分的文本。为了限制这种漂移,GRPO 在损失函数里加了对参考模型的 KL 惩罚。
每一项的形式是 KL(π_θ || π_ref),其中 π_ref 是强化学习开始前冻结的参考模型。这里我直接解释它的作用:如果新策略开始偏离参考模型太多,损失会增加,训练会往回拉。β 控制惩罚强度,我通常从 0.03 到 0.05 起步,如果 KL 涨太快就调大。
完整一点的 GRPO 单步目标可以写成:
J = E[ 1/G * Σ_i (1 / len(o_i) * Σ_t min(ratio * A, clip(...) * A) - β * KL(π_θ || π_ref) ) ]
这个公式看起来长,但拆开看就是:鼓励模型保持在旧策略附近,同时尽量往优势高的方向动,整体更新幅度被 KL 和 clip 双重约束。实际训练时,KL 值通常通过新旧策略在生成 token 上的对数概率差来估计,不需要额外算一个完整分布,计算开销很小。
2.4 奖励设计:规则奖励比模型奖励更好用
我做这个项目最深的体会是:奖励函数决定强化学习的上限。R1 的推理阶段没有用复杂的 reward model,而是用规则验证。对数学题就是比对最终答案数值;对代码题就是跑测试用例;再加上一个格式检查,判断模型有没有按规定输出思考过程和最终答案。因为规则奖励是确定性的,训练信号远比学习出来的奖励模型干净,也不容易一开始就作弊。
奖励函数通常返回一个字典,这样训练日志里能看到每个子项:
def reward_func(prompt, completion, answer): reasoning = extract_reasoning(completion) final = extract_answer(completion) score_correct = 1.0 if normalize(final) == normalize(answer) else 0.0 score_format = 1.0 if reasoning and final else 0.0 return { "correctness": score_correct, "format": score_format, "total": score_correct + score_format }总奖励可以简单相加,也可以加权。我的经验是前几个 step 先让格式奖励占主导,等模型稳定输出“思考 + 答案”结构后,再把正确性奖励权重提上来。否则模型很容易在格式没学会时就去乱答题,回复长度忽长忽短,训练曲线看起来就像心电图。
3. 训练数据与实操流程:把公式真正跑起来
3.1 数据格式:强制让模型说话带过程
复现 R1 最关键的数据格式是特殊标记。偷懒用自然语言提示“请先思考再回答”也能跑,但后期解析答案、做格式奖励时会非常痛苦。我的模板是这样:
<|user|> 请先进行完整推理,并把思考过程写在<reasoning>标签内,最终答案写在<answer>标签内。 问题:小明有 3 个苹果,又买了 4 个,吃了 1 个,还剩几个? <|assistant|> <reasoning> 小明原来有 3 个苹果,买来 4 个后总共有 3 + 4 = 7 个。 吃掉 1 个后,剩下 7 - 1 = 6 个。 </reasoning> <answer>6</answer>用特殊标签的好处有三个:格式奖励可以直接查标签是否存在,省去大量文本解析;最终答案提取只要解析<answer>区间,评估代码非常干净;训练出来的模型输出天然结构化,方便后续对接下游任务。
真实训练时,我不会让每条问题的提示词都完全一样,而是会随机插入“请一步一步推理”“把思考过程写清楚”“最终放在 answer 里”等变体,防止模型对固定提示词过度拟合。实验下来,提示词做轻度扰动能让模型在推理时更稳,不会因为用户换一种问法就放弃思考链。
3.2 环境准备与基座选择
基座模型我建议从 1.5B 或 7B 级别开始。1.5B 适合先在单卡上把流程跑通,7B 才比较接近 R1 论文中的行为特征。如果你是第一次跑,强烈建议先用 1.5B 全流程验证一遍,再换 7B 复现完整效果,这样能省掉大量调试时间。
依赖库方面,SFT 阶段直接使用常见训练框架的 Trainer;强化学习阶段使用内置 GRPO 支持的高层训练库,配置起来很省事。推理加速建议用成熟的 vLLM 后端,几百条样本采样时速度差距很大。下面是我用的 GRPO 训练配置,关键参数做了注释:
from trl import GRPOTrainer, GRPOConfig training_args = GRPOConfig( output_dir="./r1-like-rl", learning_rate=5e-7, per_device_train_batch_size=1, gradient_accumulation_steps=16, num_generations=4, max_completion_length=1024, beta=0.04, temperature=0.7, use_vllm=True, vllm_device="cuda:1", logging_steps=1, save_steps=50, gradient_checkpointing=True, bf16=True, )这里有两个容易踩的点。第一,per_device_batch_size 要小一点,因为每个样本要生成 4 条回复,显存消耗是翻倍的。第二,强化学习阶段学习率必须压得很低,5e-7 这种量级是安全起点,调大一个数量级就可能看到 KL 值在几步内翻倍。
3.3 SFT 冷启动阶段:先学会“怎么说话”
冷启动 SFT 的目标很单一:让模型学会用“思考 + 答案”格式回答问题。数据不需要太多,我用了 1 万条带有完整推理过程的数学题,训练 2 个 epoch。这里最关键的是数据质量,尤其是思维链部分:如果思维链写得太简短,模型后期在 RL 阶段就很难自发产生足够长的思考过程。
SFT 阶段我用的是序列到序列的标准语言建模损失。输入只有问题,输出包括整个思考过程和答案。即使思考过程里有小错误,我也不在第 1 轮过分纠正,因为这一阶段的首要目标是格式稳定。训练脚本如同常规微调,唯一要注意的是max_seq_length要给够,我设为 2048,防止长思维链被截断。
这阶段我踩过一个坑:拿普通问答数据集直接套模板,结果模型学会了把“思考”写成一句废话,比如“我思考了一下”。后来我把数据里的思考过程全部换成较长的多步计算过程,效果才正常。冷启动数据里的思维链长度和质量,直接决定了 RL 阶段模型初始能触达的行为空间。
3.4 GRPO 强化学习阶段:让模型自己进化
SFT 跑完后,把训练模式切换到 GRPO。整体循环是这样的:取一批数学题 → 每个问题生成 4 条回复 → 对 4 条回复算规则奖励 → 组内归一化得到优势 → 更新策略。
这阶段我最关注三个信号:格式奖励的稳定性、正确性奖励的上升趋势、KL 散度是否平稳。前 50 步如果看到格式奖励忽高忽低,说明特殊标记学习得还不牢,先不要追求正确率。
训练中我使用的奖励函数是“正确性 + 格式”的组合。如果连续几批样本的格式奖励都达到 1,我会把正确性奖励的权重调高,让模型把精力集中在把题目做对上。这一步可以通过在奖励函数里增加一个系数实现,也可以在训练中期替换奖励函数。
代码里的奖励函数可以直接复用 2.4 节那个版本,但建议先在离线样例上测一遍解析逻辑,避免答案正常却解析为空导致奖励归零的情况。我把离线测试作为每次训练前必须执行的一步,哪怕函数没改也会跑一次,确认不是数据问题。
3.5 训练后推理与答案提取
训练结束后,推理时把 temperature 设为 0.6 左右,max_tokens 给足(我常用 2048),让模型有空间输出完整思考链。和普通模型不同,推理型模型如果 max_tokens 太小,会把思考过程截断,最终答案根本没机会出现。
答案提取脚本写起来很简单:
import re def extract_answer(text): match = re.findall(r"<answer>(.*?)</answer>", text, re.S) if not match: return None return match[-1].strip()评估时用标准化后的字符串比较,不能直接使用大小写敏感比较。比如“3.0”和“3”应该算一致,最好把所有数字转成浮点或分数形式再比对。我平时会写一个normalize_answer函数,把所有数字统一格式,再统计准确率。
3.6 训练成本估算与硬件选型
很多朋友问这个流程跑起来到底要多少资源。以 7B 基座、双卡为例,我大致算过账:SFT 阶段 1 万条数据、2 epoch,单卡 40G 的机器大约需要 2 到 3 个小时;GRPO 阶段每 step 生成 4 条回复,生成长度为 1024,大约每分钟能跑 1 到 2 个 step,跑 300 步大概需要 3 到 5 个小时。
这个时间取决于生成长度和 vLLM 的吞吐。如果发现生成成为瓶颈,优先降低max_completion_length,或者把生成和训练拆到两张卡上并行。如果显存不够,就用 LoRA 微调而不是全参数训练,效果会差一点,但 pipeline 完全一致。
4. 可视化训练过程:不能只盯着一行 loss
4.1 该记录哪些指标
强化学习阶段只看 loss 曲线毫无意义。GRPO 里真正要看的是这些指标:
| 指标 | 含义 | 我的关注点 |
|---|---|---|
| reward/mean | 组内平均奖励 | 是否逐步上升 |
| reward/std | 组内奖励标准差 | 不能持续为 0 |
| kl | 当前策略与参考模型 KL | 是否平稳或上升过快 |
| completion_length | 回复平均长度 | 思考链是否变长后收敛 |
| format_reward / correct_reward | 分项奖励 | 格式和正确率是否同步 |
我会在训练开始前就把日志系统配好,每步都记录这些指标。训练过程中直接看仪表盘,对“模型是不是开始乱答”的定位会快很多,否则等到训练完再分析,可能已经白跑了几百步。
4.2 从曲线里能读出什么
一条常见且有价值的曲线是这样发展的:
- 前 20 步:reward 波动极大,格式奖励时好时坏,KL 缓慢上升。
- 50 步左右:格式奖励先稳定,正确率开始抬头。
- 100 步以后:平均回复长度突然变长,模型开始写更多计算步骤,同时正确率明显上升。
- 后期:回复长度趋于稳定,正确率缓慢增加,KL 进入平台期。
如果正确率一直不动但 reward 涨了,几乎可以断定模型在钻格式奖励的空子,比如写出“我的答案是 3”但没有实际运算步骤。这时候需要拆开分项奖励看,哪块涨了优先排查哪块。
可视化的一个典型用途是判断训练是否还需要继续。如果 KL 已经进入平台且回复长度不再变化,即使 reward 在缓慢上涨,我也不会盲目加步数,因为继续训练可能只是在过拟合训练题集,泛化收益很有限。
4.3 样本级可视化:把思考链摊开看
除曲线外,我会每 10 步存几条原始样本,对比模型在同一个问题上的思考链变化。这个比任何指标都直观。下面是我训练中的一段对比(简化表达):
| 训练步数 | 模型输出摘要 |
|---|---|
| 0 | 直接给出答案“6”,无推理过程 |
| 40 | 输出“思路:总数-吃掉 = 6”的简短说明 |
| 120 | 先列原始数量,再计算加法、减法,最后在 给出结果 |
| 200 | 过程中有一次自我检查,重新验证“3+4=7”这一步 |
看到第 200 步时模型开始自我检查、纠正计算过程,是这次训练里最有成就感的一刻。这种“涌现”本质上就是强化学习把高奖励行为不断放大后的结果。如果你也想复现这个现象,样本级可视化是最直接的观察手段,别只看平滑后的曲线。
5. 常见问题与排查技巧实录
5.1 模型绕过思考直接给答案
这是训练初期最容易碰到的问题。原因一般是三选一:冷启动 SFT 数据里的思考过程不够充分;格式奖励没有生效;KL 惩罚过强导致模型不敢偏离 SFT 分布。
我的处理顺序是:先检查奖励函数里格式得分是否为 1,再确认数据里是否都是“短思考”范本。如果 SFT 样本本身思考链平均不到 100 字,RL 阶段也很难长出长思考链。实在不行,可以给回复加一个最低长度约束,比如完整回复少于 200 个字符直接让格式奖励打 0.2 折。
5.2 正确率不涨,奖励却在涨
这个坑非常隐蔽。模型发现只要输出特定标签组合就能拿格式分,于是每次都写“ 思考 随机数 ”。正确率一直不变,但总奖励因为格式分被拉高。对应策略是盯分项指标,一旦发现正确率停滞、格式分接近满分,就把格式奖励的权重降下来,把正确性奖励权重提上去。
另一种常见情况是模型学会了复制题目中的数字当答案。规则奖励只比对最终答案,模型如果发现猜中的概率是 30%,它可能会停止真正计算,反复输出题目中出现的数字。这种现象我通常称为“奖励 hack”,解决办法是给正确性奖励增加难度维度,比如引入更多多步计算题,让猜中率大幅下降。
5.3 KL 涨得太快或直接爆炸
KL 爆炸通常和学习率、beta 直接相关。我的处理方法是:把学习率降到 3e-7,beta 提到 0.06。如果还压不住,就降低单次更新步数,比如减少梯度累积,或者降低num_generations到 2,减小更新信号的同时也降低了显存压力。注意参考模型更新这种应急手段能不用就不用,否则 KL 约束的意义会变弱,模型飘出去之后再拉回来要花更多时间。
5.4 OOM 与生成速度太慢
GRPO 的显存开销比 SFT 大得多,因为一个 prompt 要生成多个回复。我建议把 per_device_batch_size 设成 1,num_generations 用 4 起步,再配合梯度检查点。如果训练卡显存被占用满,可以把生成任务放到另一张卡上跑,训练和采样并行,速度提升非常明显。
还有一个小技巧:把max_completion_length从 1024 暂时降到 512,先把训练跑通。等格式和奖励设计都没问题时,再拉长生成长度做正式训练。这样能省下大量调试时间,因为很多早期 bug 根本不需要长输出就能暴露。
5.5 答案提取与评估的陷阱
解析<answer>标签不出来的情况,十有八九是模型输出的标签少了一个尖括号,或者大小写不一致。我常用的做法是在系统提示里写死“必须使用半角尖括号标签”。评估时也别只比对数字,最好写一个容忍空格、全半角、小数点末尾零的标准化函数。
另外我建议训练前先跑一个奖励函数单元测试:拿 50 条人工标注的数据,逐条调用奖励函数,统计得分是否和人工判断一致。奖励函数不准,强化学习训练再努力也是白费。这个测试我每次都会做,看起来浪费十分钟,实际上能省掉至少一小时的无效训练。
6. 写在最后:几个压箱底的建议
我个人实际跑完这套流程的最大感受是:小模型也能复现出“先思考再回答”的现象,只要你把数据模板、奖励函数和 GRPO 超参调对了。训练到中途看到模型开始主动重算、纠正自己的运算步骤,那种感觉比任何 benchmark 数字都更说明问题。我不建议一开始就追求完整复刻原版论文的每一步,先把手里的 7B 或 1.5B 模型训练到“有思考痕迹、答案准确率提升”这个状态,再考虑拒绝采样、多轮迭代这些复杂操作。
如果一定要从简单里挑一个最重要的建议,我会选“降低学习率”。很多训练发散的问题,最后查下来都是学习率过于激进。强化学习阶段 5e-7 起步,不要因为 SFT 阶段用了 2e-5 就觉得强化学习也能用类似量级。
关于可视化,我最后分享一个小技巧:训练到中期时,我习惯把同一个固定问题反复喂给正在迭代的模型,看输出文本的逐步变化。这比看任何曲线都直观,能让你真实感觉到模型的推理能力在增长,也能最早发现“开始乱答”的苗头。后续我还会尝试把训练中生成的高质量思维链数据收集起来,再做一次蒸馏微调,等结果稳定了再写一篇扩展记录。