在做大模型后训练的时候很多读者会卡在同一个地方PPO 的整体流程能跑通但显存和算力消耗让人望而却步。一个 7B 级别的策略模型加上参考模型、奖励模型和 Critic 价值模型四份权重同时驻留显存稍微把 batch 调大一点就 OOM。本文围绕 GRPOGroup Relative Policy Optimization展开这是 DeepSeek 系列模型在强化学习阶段采用的核心算法它把 PPO 里的 Critic 网络直接去掉了用同一组采样内部的相对奖励来估计优势。零基础的同学可以把它当成强化学习微调的入门切入点有 PPO 经验的开发者可以重点关注组采样、相对优势和 KL 约束这几个工程细节。读完本文你应该能理解 GRPO 的数学动机、手写一个最小可运行的损失函数、用 TRL 跑通一次带规则奖励的训练并且知道哪些参数最容易把训练搞崩。1. GRPO 是什么从 PPO 的痛点说起1.1 大模型强化学习微调要解决什么问题监督微调SFT本质上是让模型模仿一批标准答案它能教会模型“长什么样”但很难教会模型“怎么选”。强化学习微调换了一个视角不直接告诉模型该输出什么而是让模型自己生成若干候选答案再用奖励函数给这些答案打分把得分高的答案概率往上抬把得分低的答案概率往下压。DeepSeek-R1 系列之所以能在数学和代码推理上有明显提升很大一部分收益就来自这个“自己采样、自己打分、自己改进”的循环。把这个循环拆开其实就是三件事。第一件事是采样模型针对同一个问题生成多个输出这些输出有对有错、有长有短。第二件事是评估用奖励模型或者规则函数给每个输出一个标量分数。第三件事是更新根据分数调整策略参数让高分输出的对数概率上升。理论上很简单但第三件事的“怎么调”藏着大量细节因为直接对奖励做梯度上升会让模型跑偏所以需要引入重要性采样、裁剪、KL 约束这些稳定机制这就是 PPO 那一套东西的由来。GRPO 并没有推翻前面两件事它改动的是第三件事里的优势估计方式。理解这一点很重要很多入门材料把 GRPO 讲得像一个全新范式其实它是在 PPO 的框架内做的一次减法减掉的是对 Critic 网络的依赖。1.2 PPO 的四模型结构与显存压力标准 PPO 在 RLHF 场景下通常需要四个模型同时在线。策略模型Actor负责生成和更新参考模型Reference冻结不动用来计算 KL 惩罚防止策略偏离 SFT 模型太远奖励模型Reward给整段输出打一个分数价值模型Critic则要对每个 token 位置估计一个“当前状态值”用来计算优势函数。优势函数的经典定义是 A R - V(s)也就是实际回报减去基线估计。这个基线的作用是降低方差让梯度信号更稳定。问题在于Critic 要和 Actor 同样大小一个 7B 的策略就要再配一个 7B 的 Critic而且训练时 Actor 和 Critic 都要反向传播显存和算力几乎翻倍。更麻烦的是Critic 本身的训练并不容易它需要和 Actor 同步更新一旦 Critic 估计不准优势信号就会失真训练表现为奖励震荡或者原地踏步。对于资源有限的团队这几乎是不可接受的成本。GRPO 的出发点就非常朴素能不能不用 Critic也能得到一个方差可控的优势估计1.3 GRPO 的核心思想用组内相对值替代 CriticGRPO 的答案是用采样组内部的统计量替代 Critic。具体做法是对每一个 prompt让策略采样 G 个输出G 通常取 4 到 16然后对每个输出计算奖励。接下来不做任何跨样本的基线估计直接在这 G 个奖励内部做标准化得到每个输出的相对优势。这个思路的直觉很清楚。假如同一道数学题模型生成了 8 个答案其中 3 个对、5 个错那么正确答案的奖励高于组内平均优势为正错误答案低于组内平均优势为负。我们不需要知道这道题的“绝对难度”是多少只要知道在这 8 个候选里哪个更好就够了。组的均值天然扮演了基线的角色标准差则完成了尺度归一化让不同难度题目的优势值处在可比范围。这个设计和“留一法”基线RLOO、拒绝采样微调Rejection Sampling Fine-Tuning在思想上是一脉相承的区别在于 GRPO 把组内相对优势直接塞进了带裁剪的策略梯度目标里并且保留了 PPO 的 KL 约束和重要性采样比例因此训练稳定性和 PPO 属于同一档但资源开销接近“只有 Actor 和 Reference”的水平。对比项PPOGRPO在线模型数量Actor、Reference、Reward、CriticActor、Reference、Reward优势估计来源Critic 的逐 token 价值预测组内奖励的标准化结果显存开销高中对奖励模型依赖可学习奖励可学习奖励或规则奖励实现复杂度较高中等从表格可以看出GRPO 省掉的不只是一个模型还有一整套 Critic 的训练逻辑和超参数。对初学者来说这意味着更少的调试变量对工程团队来说这意味着同等显卡下可以开更大的 batch 或者更长的序列。2. 核心原理拆解组采样、相对优势与 KL 约束2.1 组采样一个 prompt 必须生成多个输出GRPO 的第一个硬性前提是每个 prompt 必须采样多个输出且这些输出要构成一个“组”。这个组的大小通常记作 G在 TRL 这类框架里对应的参数名是num_generations。为什么必须是多个因为组内相对优势的分母和分子都来自这组样本。如果 G 等于 1标准化就退化成了“自己减自己”优势恒为 0梯度信号直接消失。所以 G 等于 1 的 GRPO 是没有意义的。实践中 G 取 4、8、16 都能工作G 越大组内统计越稳定但采样成本线性增长。这里有一个容易被忽略的工程点组内样本必须是同一个 prompt 的不同采样结果不能把不同 prompt 混在一个组里做标准化。因为不同题目的难度差异巨大混在一起标准化会把“简单题答对”和“难题答对”当成同一种信号优势估计就失去意义了。所以在数据组织上batch 的维度是[batch_size, G]标准化只在第二维进行。另一个细节是采样温度。GRPO 需要组内存在多样性如果温度太低G 个输出几乎一模一样奖励也几乎相同标准差接近 0标准化后优势会被放大成一堆噪声。所以训练时温度一般不宜设置得过低同时要保证奖励函数对“细节差异”足够敏感比如答案正确性、格式合规性这类能区分出高低的信号。2.2 组内归一化优势估计把符号写清楚。对第 i 个 prompt采样得到 G 个输出奖励分别为 r₁, r₂, …, r_G。那么第 j 个输出的优势定义为A_j (r_j - mean(r_1 ... r_G)) / (std(r_1 ... r_G) eps)其中 mean 是组内均值std 是组内标准差eps 是一个很小的数常见取 1e-4 量级用来防止除零。这个公式就是 GRPO 的优势估计核心简单到可以用三行代码写完但它的作用等价于 Critic 提供的基线。值得注意的是标准差的处理。有的实现用总体标准差除以 G有的用样本标准差除以 G-1二者在 G 较大时差异可以忽略。真正需要注意的是全对或全错的情形如果 G 个输出全对奖励全相同标准差为 0优势全为 0这一组样本对梯度没有任何贡献。这不是 bug而是设计上的自洽——一组全对的样本无法提供“哪个更好”的信息自然不该产生梯度。有些实现会在标准差过小时直接把该组的优势置零避免 eps 把微小噪声放大。如果你的训练出现奖励持续上涨但评测指标不动的情况可以先检查是不是大量组都是“全对”或“全错”导致有效训练信号严重不足。2.3 带裁剪的重要性采样目标优势算出来之后还要放回策略梯度的框架里。GRPO 沿用了 PPO 的裁剪目标形式如下ratio exp(log π_θ(o|q) - log π_old(o|q)) L_policy -E[ min( ratio * A, clip(ratio, 1-ε, 1ε) * A ) ]这里 π_θ 是当前正在更新的策略π_old 是采样时使用的策略也就是更新前的那份权重。ratio 衡量了当前策略相对采样策略的概率变化。当优势为正时我们希望 ratio 变大也就是提高该输出的概率当优势为负时我们希望 ratio 变小。裁剪项的作用是限制单次更新的幅度防止某一次梯度步把策略推得太远导致采样分布和更新分布严重不匹配。为什么这个机制必要因为重要性采样的有效性依赖于两个分布不能相差太大。如果一次更新把某个输出的概率提高了十倍那么后续用旧策略采样的数据来估计新策略的期望就完全失真了训练会剧烈震荡。裁剪相当于给每次更新加了一个“安全阀”ε 通常取 0.2也可以根据任务调到 0.1 或 0.28。GRPO 在实现上还有一个细节优势值是序列级的标量但要广播到每个 token 上参与计算。也就是说同一段输出里的所有 token 共享同一个优势值损失在 token 维度上求平均或求和后再聚合。这与 PPO 中逐 token 优势的做法不同也是 GRPO 能够去掉 Critic 的直接结果——没有逐 token 的价值预测自然只能做序列级广播。2.4 KL 惩罚的无偏估计仅有策略梯度还不够还需要一个约束把策略拴在参考模型附近。原因是奖励函数永远只是真实目标的近似模型很容易找到“骗过奖励函数”的输出比如堆砌特定格式、输出冗长但空洞的推理链。KL 惩罚的作用就是限制这种漂移。GRPO 使用的 KL 估计器通常是无偏的 k3 形式KL ≈ exp(log π_ref - log π_θ) - (log π_ref - log π_θ) - 1这个估计器的好处是方差比朴素形式更低而且在 log 比值接近 0 时数值稳定。直观理解设 x log π_ref - log π_θ当 x 0两个分布一致时exp(0) - 0 - 1 0KL 为 0当 π_θ 比 π_ref 更偏向某 token 时x 为负exp(x) - x - 1 为正产生惩罚。KL 项的系数通常记作 β取值在 0.001 到 0.1 之间0.04 是一个常见默认值。β 太大模型几乎不敢偏离 SFT 权重学习效果微弱β 太小模型会迅速漂移到奖励函数的高分区域输出质量反而下降。这个参数是 GRPO 调参中最需要关注的之一后面讲最佳实践时会再展开。3. 环境准备与版本说明3.1 硬件与软件环境GRPO 的训练门槛比 PPO 低但并不低到单卡随便跑的程度。以 0.5B 到 1.5B 的小模型做教学实验单张显存 16GB 以上的卡通常可以跑起来7B 级别模型建议至少单卡 40GB 或使用多卡。本文的示例以“可复现的最小实验”为目标不追求训练出强推理模型重点是把流程跑通、把指标看懂。软件环境方面需要 Python 3.10 及以上、PyTorch 2.x、Transformers、TRL、Accelerate、Datasets、PEFT可选用于 LoRA 训练。具体版本请以你的 CUDA 驱动和框架官方兼容表为准本文不写死版本号因为这类框架迭代较快写死反而容易误导。示例代码会尽量使用稳定的接口名若你的版本存在差异按照官方文档调整参数名即可。如果显存紧张优先考虑三个手段启用 LoRA 只训练低秩适配器、缩短max_completion_length、降低num_generations。三者对显存的影响依次递减但对训练效果的影响需要实测权衡。3.2 依赖安装建议在独立虚拟环境中安装避免与已有项目的 torch 版本冲突。python -m venv venv source venv/bin/activate # Windows 下使用 venv\Scripts\activate pip install --upgrade pip pip install torch transformers datasets accelerate pip install trl pip install peft # 可选使用 LoRA 时需要安装完成后建议做一次版本自检把关键库版本打印出来留存便于后续复现问题。# 文件路径check_env.py import torch print(torch:, torch.__version__) print(cuda available:, torch.cuda.is_available()) print(cuda version:, torch.version.cuda) print(device count:, torch.cuda.device_count()) if torch.cuda.is_available(): print(device name:, torch.cuda.get_device_name(0)) import transformers, trl, datasets print(transformers:, transformers.__version__) print(trl:, trl.__version__) print(datasets:, datasets.__version__)运行命令python check_env.py预期输出会打印出 torch 版本、CUDA 是否可用、显卡型号以及各库版本。如果cuda available为 False说明安装的是 CPU 版 torch需要卸载后按官方命令重装对应 CUDA 版本。3.3 示例项目结构为了让后文的代码有明确归属这里先给出项目结构。所有文件路径都会在代码块首行标注读者可以直接对照创建。grpo-demo/ ├── check_env.py # 环境自检 ├── data/ │ └── train.jsonl # 训练数据每行一个样本 ├── src/ │ ├── advantage.py # 组内优势计算 │ ├── loss.py # GRPO 损失函数 │ ├── rewards.py # 奖励函数定义 │ └── train_grpo.py # 训练入口 └── output/ # 训练产物目录这个结构的划分原则是“可测试的原子函数独立成文件”。优势计算和损失函数是最容易出错的部分单独抽出来可以用小张量做单元验证避免把问题埋进完整训练流程里。4. 手写一个最小可运行的 GRPO 损失函数4.1 用张量模拟一次组采样在接入真实模型之前先用纯 PyTorch 张量把逻辑走一遍这是排查问题最高效的方式。假设我们有一个 batch 包含 2 个 prompt每个 prompt 采样 4 个输出奖励用一个[2, 4]的张量表示。# 文件路径src/advantage.py import torch def compute_group_advantage(rewards: torch.Tensor, eps: float 1e-4) - torch.Tensor: 计算组内相对优势。 Args: rewards: 形状为 [batch_size, group_size] 的奖励张量。 eps: 防止标准差为 0 时除零。 Returns: 与 rewards 同形状的优势张量。 if rewards.dim() ! 2: raise ValueError(frewards 必须是二维张量 [B, G]当前维度为 {rewards.dim()}) mean rewards.mean(dim1, keepdimTrue) std rewards.std(dim1, keepdimTrue, unbiasedFalse) advantage (rewards - mean) / (std eps) return advantage if __name__ __main__: rewards torch.tensor([ [1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], ]) adv compute_group_advantage(rewards) print(rewards:\n, rewards) print(advantage:\n, adv)运行命令python src/advantage.py预期输出大致如下第一组两个正确答案的优势为正、两个错误答案的优势为负且整组优势之和约为 0第二组因为全错标准差为 0优势全部接近 0。这个结果正好验证了前面说的“全对或全错不产生有效信号”。需要特别提醒的是unbiasedFalse这个参数。如果误用默认的unbiasedTrue当 G 等于 1 时标准差会变成 NaN整个训练直接崩掉。虽然 G 等于 1 的 GRPO 本身没有意义但在调试阶段很容易手滑写成 1加上这个参数能避免一部分迷惑性的报错。4.2 把优势广播到 token 维度真实训练中奖励是序列级的损失是 token 级的中间需要一次维度对齐。下面的函数把[B, G]的优势扩展成[B*G, T]其中 T 是输出序列长度。# 文件路径src/advantage.py续 def broadcast_advantage(advantage: torch.Tensor, token_mask: torch.Tensor) - torch.Tensor: 把序列级优势广播到 token 级。 Args: advantage: [B, G] 的组内优势。 token_mask: [B, G, T] 的 token 掩码1 表示有效 token。 Returns: [B, G, T] 的 token 级优势无效位置为 0。 adv advantage.unsqueeze(-1) # [B, G, 1] adv adv.expand_as(token_mask) # [B, G, T] adv adv * token_mask.to(adv.dtype) # 屏蔽 padding 位置 return adv这里掩码的作用是把 padding 位置的梯度清零。很多训练不稳定的案例追查下来都是因为 padding token 参与了损失计算模型在这些无意义位置上学到了奇怪的偏好。GRPO 的损失函数通常不直接接收 mask而是接收“每个序列的 token 数”然后在求平均时做归一化。不同框架实现略有差异你自己写的时候务必确认这一点。4.3 完整的 GRPO Loss下面这个函数把裁剪目标、KL 惩罚和掩码归一化放在一起是本文最核心的代码块。# 文件路径src/loss.py import torch def grpo_loss( logprobs: torch.Tensor, # [B, G, T] 当前策略的 log 概率 old_logprobs: torch.Tensor, # [B, G, T] 采样时策略的 log 概率 ref_logprobs: torch.Tensor, # [B, G, T] 参考模型的 log 概率 advantages: torch.Tensor, # [B, G, T] token 级优势 loss_mask: torch.Tensor, # [B, G, T] 有效 token 掩码 clip_eps: float 0.2, kl_coef: float 0.04, ) - torch.Tensor: 计算 GRPO 损失。 返回一个标量越小越好。调用方负责反向传播。 # 1. 重要性采样比例 log_ratio logprobs - old_logprobs ratio torch.exp(log_ratio) # 2. 带裁剪的策略梯度目标 surr1 ratio * advantages surr2 torch.clamp(ratio, 1.0 - clip_eps, 1.0 clip_eps) * advantages policy_loss -torch.min(surr1, surr2) # 3. 无偏 KL 估计k3 kl torch.exp(ref_logprobs - logprobs) - (ref_logprobs - logprobs) - 1.0 # 4. 只在有效 token 上求平均 mask loss_mask.to(policy_loss.dtype) token_count mask.sum().clamp(min1.0) policy_loss (policy_loss * mask).sum() / token_count kl_loss (kl * mask).sum() / token_count return policy_loss kl_coef * kl_loss逐段解释一下。第一段算 ratio注意这里用的是logprobs - old_logprobs再取 exp比直接相除数值更稳定因为 log 概率本身是负数且绝对值可能很大直接做除法容易丢精度。第二段是标准 PPO 裁剪torch.min保证了当优势为正时不会因为 ratio 过大而获得额外收益当优势为负时也不会因为 ratio 过小而过度惩罚。第三段是 k3 KL 估计器注意符号方向是ref - policy也就是估计 KL(π_θ ‖ π_ref)如果你写成反方向惩罚的语义就变了。第四段做掩码归一化除以有效 token 总数而不是除以元素总数否则长短序列会获得不同的权重。一个常见的误区是把 KL 项写成(logprobs - ref_logprobs)的均值。这不是 KL这是负的交叉熵差虽然在某些场景下能起到类似约束的作用但量纲和期望值都不对会导致 β 需要重新调而且训练早期容易出现负的“KL”让约束失效。4.4 用一个模拟 step 验证损失行为光看代码不放心可以用随机张量做一次冒烟测试确认几个关键性质当策略与采样策略完全一致时policy loss 应该等于-mean(advantage)当策略与参考模型一致时KL 应该接近 0。# 文件路径src/loss.py续 if __name__ __main__: torch.manual_seed(0) B, G, T 2, 4, 16 logprobs torch.log_softmax(torch.randn(B, G, T), dim-1) old_logprobs logprobs.clone() # 模拟 ratio 全为 1 ref_logprobs logprobs.clone() # 模拟 KL 为 0 advantages torch.randn(B, G, T) loss_mask torch.ones(B, G, T) loss grpo_loss(logprobs, old_logprobs, ref_logprobs, advantages, loss_mask) print(loss:, loss.item()) # 预期loss 约等于 -advantages.mean() print(expect:, (-advantages.mean()).item()) # 再构造一个明显偏离参考模型的策略 ref_far torch.log_softmax(torch.randn(B, G, T) * 3, dim-1) loss_far grpo_loss(logprobs, old_logprobs, ref_far, advantages, loss_mask) print(loss with large kl:, loss_far.item())运行后如果第一个 loss 和 expect 打印的数值基本相等说明裁剪和归一化逻辑正确第二个 loss 明显更大说明 KL 惩罚生效。这一步花两分钟能省掉后面几个小时的排查。5. 基于 TRL 的 GRPO 实战数学推理任务5.1 数据集格式设计我们用一个小规模的数学题数据集演示完整流程。数据格式需要满足两个条件包含 prompt 字段包含奖励函数需要的参考答案字段。下面的 jsonl 每行一个样本。{prompt: [{role: user, content: 计算 12 乘以 13并把最终答案放在 \\boxed{} 中。}], answer: 156} {prompt: [{role: user, content: 一个长方形长 7 厘米宽 5 厘米求面积答案放在 \\boxed{} 中。}], answer: 35} {prompt: [{role: user, content: 求 1 到 100 的所有整数之和答案放在 \\boxed{} 中。}], answer: 5050}保存为data/train.jsonl。这里刻意在 prompt 里要求使用\boxed{}包裹答案是为了让规则奖励能够稳定地提取结果。真实项目中如果你的数据没有格式约束就需要写更鲁棒的答案解析逻辑或者改用可学习的奖励模型。数据规模不需要很大几百条就能观察到训练信号的变化。但要注意题目难度分布如果所有题都太简单模型第一次采样就全对优势全为 0训练等于没跑如果所有题都太难全错同样没有信号。建议混合约 30% 简单题、50% 中等题、20% 难题让组内既有正样本也有负样本。5.2 奖励函数设计规则奖励是 GRPO 在小模型实验中最好用的选择因为它确定性强、无需额外训练、调试成本低。下面的实现包含两个奖励正确性奖励和格式奖励。# 文件路径src/rewards.py import re BOXED_PATTERN re.compile(r\\boxed\{([^{}]*)\}) def extract_boxed(text: str): 从模型输出中提取 \\boxed{} 里的内容。 matches BOXED_PATTERN.findall(text) if not matches: return None return matches[-1].strip() def normalize_answer(text: str) - str: 做基础归一化去掉空格和常见标点。 if text is None: return text text.strip() text text.replace(,, ).replace( , ) text text.rstrip(。.) return text def correctness_reward(completions, answer, **kwargs): 答案正确给 2 分错误给 0 分。 rewards [] for completion, gt in zip(completions, answer): content completion[0][content] if isinstance(completion, list) else completion pred normalize_answer(extract_boxed(content)) gold normalize_answer(gt) rewards.append(2.0 if pred and pred gold else 0.0) return rewards def format_reward(completions, **kwargs): 格式合规给 0.5 分用于引导模型学会输出规范。 rewards [] for completion in completions: content completion[0][content] if isinstance(completion, list) else completion rewards.append(0.5 if extract_boxed(content) is not None else 0.0) return rewards这里有两点设计意图值得说明。一是正确性奖励给了 2 分而不是 1 分格式奖励只有 0.5 分比例是 4:1。这样设计是为了让“正确”和“格式对但答案错”之间有明显区分避免模型为了拿格式分而放弃推理。二是格式奖励单独存在是因为训练初期模型往往不会主动使用\boxed{}如果只有正确性奖励所有样本都是 0 分组内标准差为 0完全没有梯度。给一个格式分相当于提供了一个“垫脚石”让模型先学会格式再学会答对。多个奖励函数在 TRL 中会按顺序累加最终奖励是各函数返回值之和。所以上面两个函数组合起来一个完全正确的输出能拿到 2.5 分格式对但答案错拿 0.5 分什么都没有拿 0 分。这个分数分布保证了组内通常存在差异。5.3 训练脚本下面是把数据、奖励、模型串起来的完整训练脚本。# 文件路径src/train_grpo.py from datasets import load_dataset from trl import GRPOConfig, GRPOTrainer from rewards import correctness_reward, format_reward def main(): model_id Qwen/Qwen2.5-0.5B-Instruct # 可按需替换为你本地或镜像路径 train_dataset load_dataset( json, data_files../data/train.jsonl, splittrain, ) print(训练样本数:, len(train_dataset)) print(首条样本:, train_dataset[0]) training_args GRPOConfig( output_dir../output/grpo-demo, num_train_epochs1, per_device_train_batch_size4, gradient_accumulation_steps2, learning_rate1e-6, lr_scheduler_typecosine, warmup_ratio0.1, logging_steps1, save_steps200, save_total_limit2, bf16True, # GRPO 关键参数 num_generations8, max_prompt_length256, max_completion_length384, temperature1.0, beta0.04, ) trainer GRPOTrainer( modelmodel_id, argstraining_args, train_datasettrain_dataset, reward_funcs[correctness_reward, format_reward], ) trainer.train() trainer.save_model(../output/grpo-demo/final) print(训练完成模型已保存) if __name__ __main__: main()运行命令cd src python train_grpo.py如果使用多卡可以换成accelerate launch train_grpo.py但要注意per_device_train_batch_size与num_generations之间的关系。TRL 在内部会检查“全局 batch 是否能被组大小整除”因为一个组必须完整地落在同一个前向批次里否则标准化就跨组了。如果你的配置不满足这个约束通常会看到一条明确的报错信息调整per_device_train_batch_size或gradient_accumulation_steps让per_device_train_batch_size * gradient_accumulation_steps * 卡数能被num_generations整除即可。参数逐个说明一下。num_generations8是组大小显存允许的话建议从 8 起步temperature1.0保证组内多样性训练后期可以降到 0.8 左右beta0.04是 KL 系数小模型上这个值比较安全learning_rate1e-6是 GRPO 的常见量级比 SFT 小一个数量级因为强化学习的梯度噪声更大max_completion_length384限制了单条输出的长度既省显存也防止模型陷入无意义的长时间重复。5.4 训练过程与指标观察启动后日志会持续输出损失、奖励均值、奖励标准差、KL、输出长度等指标。这些指标怎么读比训练本身更重要。第一看奖励均值。正常情况下它应该呈现缓慢上升的趋势哪怕只有几百条数据也能看到。如果奖励在第一步之后就不再变化通常是奖励函数过于稀疏或者组内样本完全同质需要检查奖励设计和采样温度。第二看组内奖励标准差它直接反映有效信号强度标准差长期接近 0 意味着优势几乎全为 0训练在做无用功。第三看 KL 值它应该小幅波动并缓慢上升如果早期就飙到很高说明 β 太小或者学习率太大。第四看平均输出长度它突然暴涨往往意味着模型发现了某种“啰嗦得分”的捷径需要检查奖励函数是否存在长度偏好。# 典型的日志片段字段名以实际版本为准 {loss: 0.0123, reward: 0.625, reward_std: 0.71, kl: 0.0021, completion_length: 148.3} {loss: 0.0098, reward: 0.781, reward_std: 0.83, kl: 0.0043, completion_length: 152.7} {loss: 0.0071, reward: 0.906, reward_std: 0.62, kl: 0.0068, completion_length: 155.1}这段日志展示的是一个比较健康的走势奖励上升标准差保持在 0.6 以上KL 缓慢增长长度基本稳定。如果你的日志里reward_std从一开始就是 0先别急着调学习率回去看奖励函数是不是写错了或者数据集里的答案字段和模型输出格式对不上。5.5 效果验证训练完成后用保留的几条题目做一次推理对比是最直观的验证方式。# 文件路径src/eval_sample.py import re import torch from transformers import AutoModelForCausalLM, AutoTokenizer BOXED_PATTERN re.compile(r\\boxed\{([^{}]*)\}) def infer(model_path: str, question: str, max_new_tokens: int 384) - str: tokenizer AutoTokenizer.from_pretrained(model_path) model AutoModelForCausalLM.from_pretrained( model_path, torch_dtypetorch.bfloat16, device_mapauto, ) messages [{role: user, content: question}] text tokenizer.apply_chat_template( messages, tokenizeFalse, add_generation_promptTrue ) inputs tokenizer(text, return_tensorspt).to(model.device) with torch.no_grad(): output model.generate( **inputs, max_new_tokensmax_new_tokens, do_sampleFalse, temperatureNone, top_pNone, ) generated output[0][inputs[input_ids].shape[1]:] return tokenizer.decode(generated, skip_special_tokensTrue) if __name__ __main__: question 计算 12 乘以 13并把最终答案放在 \\boxed{} 中。 answer infer(../output/grpo-demo/final, question) print(模型输出:, answer) match BOXED_PATTERN.findall(answer) print(抽取答案:, match[-1] if match else None) print(是否包含正确答案 156:, match[-1].strip() 156 if match else False)这里用贪心解码是为了让结果可复现实际评测时可以用多次采样的通过率passk作为指标更能反映模型的真实能力。验证时建议同时对比 SFT 基座和 GRPO 训练后的模型否则你无法判断提升来自训练还是来自随机采样。6. 常见问题与排查思路GRPO 的报错和异常现象集中在一小撮地方下面这张表覆盖了大部分高频场景。问题现象常见原因解决思路90% 概率 OOM组大小过大或输出长度过长降低num_generations、缩短max_completion_length改用 LoRA奖励完全不变化奖励函数返回全 0或格式不匹配打印模型原始输出确认答案解析逻辑是否命中奖励很高但评测不变模型刷分钻了奖励函数空子检查是否存在长度偏好或格式偏好增加惩罚项组大小为 1 时不报错但无提升优势标准化退化保证num_generations 2推荐 8报错 batch 无法被组大小整除数据并行维度与组采样冲突调整 batch 或梯度累积使其整除KL 早期暴涨β 太小或学习率过大提高 β 至 0.1 量级学习率降到 5e-7输出长度持续暴涨奖励函数隐含长度偏好去掉按长度给分的逻辑或加长度惩罚loss 为 NaN标准差计算出现除零或 bf16 溢出加 eps检查是否误用 unbiased std针对其中几个最容易踩的坑展开说下排查顺序。第一个是“奖励完全不变化”。排查时不要一上来改超参数先把模型的实际输出打印出来看。很多时候模型输出的是“答案是 156”而不是\boxed{156}答案解析返回 None奖励自然全 0。这种情况改 prompt 或改解析逻辑都能解决但只要不改学习率怎么调都没用。第二个是“训练跑起来了但评测指标不动”。这通常不是训练失败而是奖励函数被利用了。比如正确性奖励之外还有一个“不超长”的奖励模型可能学会了输出简短但格式合规的空答案稳拿那个附加分。解决办法是把总奖励拆开分别打日志观察各项奖励的贡献比例一旦发现某一项在训练中占比越来越高而正确性没涨就要给这一项降权或加约束。第三个是 OOM。GRPO 的显存占用大致与per_device_train_batch_size × num_generations × max_completion_length成正比三者任一个翻倍都是灾难。优先降max_completion_length它对显存的影响最直接也最不影响算法正确性。其次降组大小但要注意组太小会让优势估计噪声变大。7. 最佳实践与工程建议7.1 奖励函数要分层设计不要一步到位奖励函数是 GRPO 的“方向盘”设计得好坏直接决定训练能不能收敛。建议按层次组织基础层是格式合规中间层是答案正确上层是推理质量。基础层用规则判断成本最低中间层也用规则判断只要任务有可验证的答案上层才考虑用奖励模型因为“推理是否清晰”这种判断很难用规则覆盖。层次之间要有明确的分数梯度但也不能让基础层占比过高。一个实用的经验是让“格式对但答案错”的分数约为“完全正确”的 1/4 到 1/5这样模型会先学会格式然后自然地把注意力转向正确性。如果两层分值接近模型会满足于拿格式分。另外奖励函数一定要可测试。为每个奖励函数准备 5 到 10 个手工样本把期望分数写死在测试里改动奖励逻辑后先跑测试再训练。这一步能拦住绝大部分“训练半天发现奖励写错”的事故。7.2 超参数优先级排序GRPO 需要调的超参数不少但影响力差别很大。按优先级从高到低排序学习率、KL 系数 β、组大小、裁剪系数、采样温度。学习率建议从 1e-6 起步如果发现奖励涨得太快同时 KL 飙升就往下降如果奖励几乎不动而标准差正常就往上升。β 的调整逻辑前面讲过观察 KL 曲线的斜率斜率为正且增长过快就调大 β。组大小在显存允许的前提下优先选 8这是效果和成本比较均衡的点。裁剪系数保持默认 0.2 通常就够除非你观察到比率大量被裁剪那说明学习率偏大。采样温度不要低于 0.7否则组内多样性不足。7.3 训练稳定性与监控强化学习训练比监督学习更容易“静悄悄地坏掉”所以监控指标要建得比 SFT 更细。建议至少记录总奖励、各奖励分量、组内奖励标准差、KL、平均输出长度、被裁剪的比率、梯度范数。其中“被裁剪的比率”是一个容易被忽略的指标它反映有多少 token 的 ratio 落在了[1-ε, 1ε]之外。这个比率长期很高比如超过 30%说明每次更新跨度太大数据利用率低应该降低学习率或减小每次的数据量。梯度范数则能反映训练是否处在数值异常边缘突然出现几个数量级的跳变要立刻检查。此外务必做定期评测不能只看训练奖励。训练奖励是被优化的对象它涨不代表能力涨。用一个固定的评测集每隔一段步数跑一次贪心解码记录准确率这才是判断训练是否有效的唯一标准。7.4 生产环境的注意事项在真实项目里用 GRPO有几个工程边界需要提前划清。第一是奖励函数的权限边界如果奖励函数调用了外部服务比如执行代码判题、调用另一个模型打分一定要加超时、重试上限和熔断否则一个卡住的请求会把整个训练拖死。第二是训练数据的脱敏与合规数学题这类公开数据风险小涉及用户内容的数据必须做清洗。第三是模型产物的管理每次训练都要记录完整的配置、数据版本、代码提交号否则复现不出来。第四是回滚策略。GRPO 训练出来的模型可能在某些维度上比 SFT 基座更差比如指令跟随能力退化、输出过于冗长。上线前一定要做多维度评测不能只看目标任务的指标。建议保留 SFT 基座作为兜底采用灰度方式放量观察真实反馈后再全量。涉及生产环境变更时遵循先测试环境验证、再小流量、最后全量的原则任何一步都要有可回退的方案。8. 学习路线与延伸方向把本文的内容串起来GRPO 的知识结构其实是三层。最底层是策略梯度的基本概念包括重要性采样、优势函数、KL 约束这部分建议系统看一遍 PPO 的推导理解了min(ratio*A, clip(ratio)*A)为什么这么设计GRPO 的改动就一目了然。中间层是组内相对优势它的本质是“用组统计量替代学习到的基线”理解这一点后你会发现 RLOO、ReMax 这些方法都在同一个思路上。最上层是工程实现包括采样、奖励、损失、监控四个环节这部分只能靠动手跑。下一步可以往三个方向走。一个方向是把奖励从规则换成可学习的奖励模型这时会引入奖励攻击reward hacking的问题需要研究奖励归一化、奖励裁剪、集成奖励等技巧。另一个方向是研究 GRPO 的变体比如带 token 级优势的改进版本、动态组大小的采样策略、以及和其他对齐方法的组合。第三个方向是把 GRPO 用在数学推理以外的任务上比如代码生成、工具调用、多轮对话这时候奖励设计会变得复杂得多也是最有工程价值的地方。如果你想验证自己是否真的掌握了可以试着做一件事不用任何框架只用 PyTorch 和 Transformers从零写一个能跑通的 GRPO 训练循环。这个练习会逼你面对所有细节——采样时怎么拿到 logprobs、怎么处理变长序列的 padding、组怎么组织进 batch、KL 怎么算、梯度怎么累积。把这些都写对一遍比读十篇文章都有用。
阅读完成 · 觉得有帮助?