在做大模型后训练的这段时间里后台被问得最多的一类问题是能不能不搭 Critic 模型就把 PPO 跑起来。显存不够、训练不稳定、奖励曲线忽高忽低几乎是所有人在复现 RLHF 时都会遇到的问题。本文围绕 GRPOGroup Relative Policy Optimization这套由 DeepSeek 团队提出的强化学习算法展开从 PPO 的结构痛点讲起逐层拆解组采样、组内归一化优势、裁剪目标和 KL 约束并给出两套可以直接跑起来的实现方案一套是手写的 PyTorch 训练循环便于理解每一步在算什么另一套是基于 TRL 的工程化配置便于快速验证想法。无论你是刚接触强化学习的学生还是正在做推理模型后训练的工程师都能从本文找到对应的落地点。1. GRPO 是什么从 PPO 的结构痛点说起1.1 强化学习在大模型后训练中的位置大模型的能力形成通常分成三个阶段预训练学语言分布监督微调SFT学指令遵循强化学习RL阶段则负责把模型的行为往人类或规则期望的方向上推。前两个阶段本质上都是模仿模型在学一个已经存在的答案而强化学习阶段模型是在探索它自己生成多个候选回答再由奖励信号告诉它哪些更好。这个差别决定了一件事RL 阶段没有标准答案可以逐 token 对齐只有最终的打分。正因为如此RL 阶段的价值上限往往比 SFT 更高。SFT 的天花板是标注数据的质量而 RL 的天花板是奖励函数的设计水平——只要奖励能准确刻画什么是好回答模型就有机会自己找到比标注数据更优的解法路径。DeepSeek 系列模型在数学和代码推理上的表现很大程度上就来自这一阶段的训练。而 GRPO 正是这条链路上承担策略优化职责的核心算法。1.2 PPO 的结构与显存开销PPOProximal Policy Optimization是 RLHF 时代最主流的策略优化算法。它的标准结构里有四个模型同时存在策略模型Actor、参考模型Reference、奖励模型Reward、价值模型Critic。Actor 负责生成Reference 负责提供 KL 约束的基准Reward 负责打分Critic 负责估计每个状态的价值从而算出优势Advantage。问题就出在 Critic 上。Critic 的规模通常和 Actor 相当这意味着显存占用直接翻倍。更要命的是Critic 的输出是一个标量价值估计它需要在整个序列的每个位置上都给出合理预测训练难度并不低。在 LLM 场景下序列很长、奖励又往往是稀疏的只有句末才有Critic 的估计方差会非常大导致优势信号噪声很重。很多团队在复现 PPO 时遇到奖励涨了两步就崩根源往往是 Critic 没训好而不是 Actor 出了问题。1.3 GRPO 的核心思路用组内相对值替代 CriticGRPO 的做法非常直接既然 Critic 的价值估计又贵又不准那就不估了。对于同一个 prompt让当前策略采样出一组Group回答比如 8 条然后用这 8 条回答各自的奖励做组内归一化得到每条回答的相对优势。这个相对优势直接替代了 Critic 输出的优势值。这个设计有两层好处。第一层是工程上的少了一个和 Actor 同规模的模型显存大约省下一半训练吞吐也明显提升。第二层是信号质量上的组内归一化天然带来了基线效果——奖励高的样本得到正优势奖励低的得到负优势组内平均奖励被自动减掉。这实际上就是一种不需要学习的 baseline方差比 Critic 的估计更稳因为它直接用真实采样奖励计算不含任何模型误差。需要强调一点GRPO 并不是简化版 PPO这么简单。它改变了优势的估计方式但保留了 PPO 的裁剪重要性采样目标函数所以它在数学上依然是一个策略梯度方法只是把优势项换成了不需要额外模型的形式。理解这一点后面的公式推导才不会跑偏。2. 核心原理拆解组采样、相对优势、裁剪目标、KL 约束2.1 组采样一个 Prompt 采样 G 条回答GRPO 的第一步是对每个 prompt 采样 G 条完整回答。设当前 prompt 为 q采样得到的回答集合为 {o₁, o₂, ..., o_G}。这里的采样温度通常不会设得太低因为需要一定的多样性否则组内所有回答几乎一样归一化之后优势会全部趋近于零梯度信号就消失了。组大小 G 是一个关键超参数。G 太小组内统计量不可靠优势估计噪声大G 太大采样开销线性增长而收益递减。实践中常见的取值在 4 到 16 之间。另外要注意G 条回答必须来自同一个 prompt 才能做组内比较不同 prompt 之间的奖励量级可能差异很大混在一起归一化会引入额外偏差。还需要提醒的是采样成本。G 条回答意味着解码成本乘以 G这是 GRPO 最主要的开销来源。所以在实际工程中采样阶段往往会用推理引擎比如 vLLM 之类的高吞吐解码框架来完成训练阶段再切回训练框架两者之间通过权重同步衔接。这个采样—训练分离的架构是当前 GRPO 工程实现的主流做法。2.2 组内归一化优势最关键的公式拿到了 G 个奖励 r₁ 到 r_G 之后优势的计算是一个标准化过程A_i (r_i - mean(r)) / (std(r) eps)其中 mean 和 std 都是在这个组内计算的eps 是一个极小的常数比如 1e-4用来防止整组奖励完全相同时出现除零。这个式子看起来简单但它是 GRPO 全部省掉 Critic的底气所在。它的含义是一条回答的价值不取决于它的绝对奖励而取决于它相对于同组其他回答好多少。如果一个 prompt 本身很难所有回答都只得 0 分那组内标准差为 0优势也接近 0这条样本基本不贡献梯度——这是合理的因为模型在这个问题上没有任何可区分的行为。反之如果一个 prompt 很简单大部分回答得 1 分但有一条得 0 分那条 0 分的回答会得到明显负优势模型会被明确地告知这里做错了。这个机制还有一个隐含的副作用它会自动做难度加权。难题上模型表现出区分度时才会产生强梯度全都不会做的题不会浪费训练信号。这比手工设计课程学习策略要顺手得多。2.3 裁剪的重要性采样目标有了优势之后剩下的部分和 PPO 保持一致。定义重要性采样比ratio_i(θ) π_θ(o_i | q) / π_θold(o_i | q)其中 π_θold 是采样时用的策略旧策略π_θ 是当前正在更新的策略。如果只在采样完的数据上做一次梯度更新ratio 恒等于 1那就退化成普通的策略梯度。PPO 允许在同一个 batch 上做多次更新多个 epoch但每次更新后 ratio 会偏离 1偏离太多就会导致策略跑偏。裁剪项 clip(ratio, 1-ε, 1ε) 就是用来限制这个偏离幅度的。完整的 GRPO 目标函数最大化形式可以写成J(θ) E[ (1/G) Σ_i min( ratio_i · A_i , clip(ratio_i, 1-ε, 1ε) · A_i ) - β · KL(π_θ || π_ref) ]其中 A_i 是 2.2 节算的组内归一化优势ε 通常取 0.2β 是 KL 惩罚系数。注意 min 操作的含义当优势为正时ratio 被截断在上界防止过度提升当优势为负时截断在下界防止过度打压。这就是 PPO 里经典的悲观下界思想。很多人在实现时会漏掉按 token 加权。实际上序列级的 log 概率是 token 级 log 概率的求和所以 ratio 通常是在序列级别算的把 mask 之后的 logprob 求和再取指数而 KL 项既可以按 token 平均也可以在序列上算。两种写法在文献里都出现过本文的代码采用序列级 ratio token 级 KL 平均的写法这也是工程实现中比较常见的一种。2.4 KL 惩罚与无偏估计KL 项约束的是当前策略与参考策略通常是 SFT 之后的模型之间的分布距离。这个约束有两个作用一是防止模型为了刷奖励而彻底跑偏输出退化、重复、语言混乱的内容二是保住 SFT 阶段学到的语言能力让模型在会说话的前提下优化奖励。GRPO 论文里使用的是一个无偏的 KL 估计器通常称为 k3 估计器KL ≈ exp(ref_logp - new_logp) - (ref_logp - new_logp) - 1这个式子的好处是它总是非负的且方差比朴素的 log 差值估计更小。实现时不需要自己推导照着式子写就行。需要注意的是这个估计是逐 token 计算的最后要对有效 token 求平均padding 位置必须用 mask 排掉否则 KL 会被大量零值稀释约束变相失效。β 的取值也很讲究。β 太小KL 约束形同虚设模型会逐渐偏离参考分布β 太大模型几乎不敢动奖励涨不动。常见取值在 0.001 到 0.04 之间具体要看任务和奖励尺度。有些实现会采用自适应的 KL 系数根据实际 KL 与目标值的偏差动态调整 β这在长训练中会稳一些。2.5 与 PPO、DPO 的对比把 GRPO 放到算法谱系里看会更清楚。DPO 完全绕开了在线采样直接用偏好数据做离线优化工程成本最低但上限受限于偏好数据的质量也无法探索数据里没有的行为。PPO 是在线的能力上限高但四个模型的显存和 Critic 的训练难度让很多人望而却步。GRPO 站在中间它保留了在线采样和奖励驱动但砍掉了 Critic用一个纯统计量的优势估计来替代。维度DPOPPOGRPO是否需要在线采样否是是模型数量2策略 参考4含 Critic 和 Reward3策略 参考 奖励优势估计方式无Critic 网络组内归一化显存开销低高中奖励函数灵活度低依赖偏好对高高可用规则奖励训练稳定性高中中偏高还有一点值得说GRPO 对规则型奖励特别友好。因为奖励只需要是一个能打分的函数不需要可微、不需要训练一个奖励模型。数学题可以比对答案代码题可以跑单元测试格式题可以正则匹配。DeepSeek 在推理模型训练中采用的正是准确性奖励 格式奖励的组合这也是 GRPO 能被广泛复现的重要原因。3. 环境准备与依赖说明3.1 硬件与软件环境GRPO 的训练对显存有要求因为同时要加载策略模型、参考模型还要在采样阶段保留一组回答的计算图。以 1.5B 到 7B 级别的模型为例单卡 24GB 起步比较现实7B 以上建议用多卡或者配合 LoRA 与梯度检查点。本文的示例以单机多卡为默认场景具体卡数请按模型规模调整。软件层面需要 Python 3.10 及以上、PyTorch建议用较新的稳定版与你的 CUDA 版本匹配、Transformers、Accelerate、Datasets以及可选的 TRL 和 PEFT。这里不写死具体版本号因为这几个库迭代很快不同版本之间 API 存在差异请以你本地pip index versions查到的稳定版为准。安装时建议先确定 CUDA 驱动版本再选择对应的 PyTorch 轮子避免出现编译或算子不兼容的问题。如果你的项目需要多卡并行DeepSpeed 或 FSDP 也可以接入。本文示例不强制依赖它们但会在最佳实践一节说明如何组合使用。3.2 依赖安装命令# 建议在独立虚拟环境中安装避免污染系统环境 python -m venv venv source venv/bin/activate # 先装与本地 CUDA 匹配的 PyTorch以下命令中的版本号需按实际情况替换 pip install torch --index-url https://download.pytorch.org/whl/cu121 # 训练相关依赖 pip install transformers accelerate datasets peft pip install trl安装完成后建议跑一次环境自检import torch, transformers, trl print(torch:, torch.__version__) print(cuda available:, torch.cuda.is_available()) print(device count:, torch.cuda.device_count()) print(transformers:, transformers.__version__) print(trl:, trl.__version__)3.3 示例项目结构本文的两套实现分别放在两个目录下方便对照阅读grpo-tutorial/ ├── data/ │ └── train.jsonl # 训练数据每行一个 {prompt: ..., answer: ...} ├── manual/ │ ├── rewards.py # 奖励函数 │ ├── grpo_loss.py # 手写 GRPO loss │ └── train_manual.py # 手写训练主循环 └── trl_impl/ └── train_trl.py # 基于 TRL 的工程化实现数据文件采用 JSONL 格式每行一条样本字段名可以根据你的数据源调整但要和后面代码里读取的 key 保持一致。小规模验证时准备几百条就够跑通流程正式训练再扩到万级规模。4. 手写一个最小可运行的 GRPO 训练循环这一节的目标不是做一个生产级实现而是把 GRPO 的每一步都摊开给你看。理解了这份代码再去用任何框架都不会觉得是黑盒。4.1 数据准备与奖励函数先准备数据。为了让奖励函数好写我们用最简单的数学题格式答案是一个可以精确比对的数字{prompt: 计算 12 * 7 的结果并在最后一行用 answer 标签给出答案。, answer: 84} {prompt: 计算 108 / 9 的结果并在最后一行用 answer 标签给出答案。, answer: 12}奖励函数我们设计成两部分之和格式奖励负责约束输出结构准确性奖励负责判断答案对不对。# 文件路径manual/rewards.py import re ANSWER_PATTERN re.compile(ranswer\s*(.*?)\s*/answer, re.S) def format_reward(completion: str) - float: 检查输出是否严格包含一个 answer 标签且标签内非空。 content completion.strip() if not content: return 0.0 matches ANSWER_PATTERN.findall(content) if len(matches) ! 1: return 0.0 if not matches[0]: return 0.0 return 0.5 def extract_answer(completion: str) - str: 从输出中抽取答案文本抽取失败返回空串。 matches ANSWER_PATTERN.findall(completion) if not matches: return return matches[-1].strip() def accuracy_reward(completion: str, answer: str) - float: 比对抽取出的答案与标准答案正确给 1.0错误给 0.0。 pred extract_answer(completion) if not pred: return 0.0 pred pred.replace(,, ).replace(, ).strip() gold str(answer).strip() return 1.0 if pred gold else 0.0 def compute_reward(completion: str, answer: str) - float: 总奖励 格式分 准确性分取值范围 [0, 1.5]。 return format_reward(completion) accuracy_reward(completion, answer)这里有一个设计细节值得强调格式奖励给 0.5 分准确性奖励给 1.0 分是刻意让准确性占主导。如果格式分太高模型可能学会只写标签不写对答案就能拿高分这是规则奖励里非常常见的奖励劫持现象。分数配比本质上是在向模型传递优先级设计时要反复推敲。4.2 组采样与 log 概率计算接下来是最核心的部分对同一个 prompt 采样 G 条回答并计算新旧策略、参考策略三套 log 概率。# 文件路径manual/train_manual.py import json import torch from transformers import AutoModelForCausalLM, AutoTokenizer def load_prompts(path, tokenizer, max_prompt_len512): 读取 jsonl 数据把 prompt 编码成 input_ids。 samples [] with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue item json.loads(line) enc tokenizer( item[prompt], return_tensorspt, truncationTrue, max_lengthmax_prompt_len, ) samples.append({ input_ids: enc[input_ids][0], answer: str(item[answer]), }) return samples torch.no_grad() def sample_group(model, tokenizer, prompt_ids, group_size, max_new_tokens, temperature): 对单个 prompt 采样 group_size 条回答返回文本列表和输入张量。 device next(model.parameters()).device batch_prompt prompt_ids.unsqueeze(0).to(device) completions, full_seqs [], [] for _ in range(group_size): out model.generate( input_idsbatch_prompt, max_new_tokensmax_new_tokens, do_sampleTrue, temperaturetemperature, top_p0.95, pad_token_idtokenizer.pad_token_id, ) completion_ids out[0, batch_prompt.shape[1]:] completions.append(tokenizer.decode(completion_ids, skip_special_tokensTrue)) full_seqs.append(out[0]) return completions, full_seqs采样环节有两个坑要提醒。第一generate不要放进梯度上下文否则显存会迅速爆炸第二G 条回答的长度大概率不一致后面拼 batch 时必须做 padding 并用 attention_mask 和 completion_mask 区分有效位置。mask 写错是 GRPO 实现里最高频的 bug症状通常是 loss 看起来在降但奖励不涨。4.3 GRPO Loss 的完整实现把第 2 节的公式翻译成代码得到下面这个函数。这里的输入约定是logprobs 类张量形状为[B, G, T]T 是补全部分的长度已经是 padding 对齐后的结果。# 文件路径manual/grpo_loss.py import torch import torch.nn.functional as F def compute_group_advantages(rewards, eps1e-4): rewards: [B, G]按组做标准化返回 [B, G] 的优势。 mean_r rewards.mean(dim1, keepdimTrue) std_r rewards.std(dim1, keepdimTrue, unbiasedFalse) advantages (rewards - mean_r) / (std_r eps) return advantages def grpo_loss( new_logprobs, # [B, G, T] 当前策略的 token log 概率 old_logprobs, # [B, G, T] 采样时的 token log 概率 ref_logprobs, # [B, G, T] 参考模型的 token log 概率 rewards, # [B, G] 每条回答的标量奖励 completion_mask, # [B, G, T] 1 表示有效 token0 表示 padding clip_eps0.2, kl_coef0.04, ): # 第一步组内标准化得到优势 advantages compute_group_advantages(rewards) # [B, G] # 第二步序列级 log 概率用 mask 求和 mask_f completion_mask.float() new_seq_logp (new_logprobs * mask_f).sum(dim-1) # [B, G] old_seq_logp (old_logprobs * mask_f).sum(dim-1) # [B, G] # 第三步重要性采样比数值稳定性上做 detach 截断 log_ratio torch.clamp(new_seq_logp - old_seq_logp, min-20.0, max20.0) ratio torch.exp(log_ratio) # [B, G] # 第四步PPO 风格的裁剪目标 unclipped ratio * advantages clipped torch.clamp(ratio, 1.0 - clip_eps, 1.0 clip_eps) * advantages policy_obj torch.min(unclipped, clipped) # [B, G] # 第五步k3 估计器计算逐 token KL再按有效 token 平均 log_diff ref_logprobs - new_logprobs # [B, G, T] kl_per_token torch.exp(log_diff) - log_diff - 1.0 kl_per_token kl_per_token * mask_f token_count mask_f.sum(dim-1).clamp(min1.0) # [B, G] kl_per_seq kl_per_token.sum(dim-1) / token_count # [B, G] # 第六步组合成最终要最小化的 loss per_sample -(policy_obj - kl_coef * kl_per_seq) # [B, G] loss per_sample.mean() return loss, { ratio_mean: ratio.mean().item(), kl: kl_per_seq.mean().item(), adv_abs_mean: advantages.abs().mean().item(), }这段代码里有几个地方容易写错我逐个说明。第一advantages是[B, G]形状和ratio逐元素相乘之前不需要扩维因为两者形状一致这是 GRPO 相比 token 级优势写法更简洁的地方。第二log_ratio的 clamp 是为了防止极端情况下exp溢出成 inf虽然理论上 ratio 应该接近 1但实践中一旦某条回答太长、logprob 累加误差大就可能出现异常值。第三KL 一定要乘 mask 再平均否则 padding 位置的零值会把 KL 拉低惩罚失效。4.4 训练主循环搭建把采样、打分、前向、反向串起来# 文件路径manual/train_manual.py续 from grpo_loss import grpo_loss from rewards import compute_reward MODEL_NAME your-base-model # 替换成你本地或可访问的模型路径 GROUP_SIZE 8 MAX_NEW_TOKENS 256 TEMPERATURE 0.9 LR 1e-6 KL_COEF 0.04 CLIP_EPS 0.2 EPOCHS 1 tokenizer AutoTokenizer.from_pretrained(MODEL_NAME) if tokenizer.pad_token is None: tokenizer.pad_token tokenizer.eos_token policy AutoModelForCausalLM.from_pretrained(MODEL_NAME, torch_dtypetorch.bfloat16).cuda() ref_model AutoModelForCausalLM.from_pretrained(MODEL_NAME, torch_dtypetorch.bfloat16).cuda() ref_model.eval() for p in ref_model.parameters(): p.requires_grad_(False) optimizer torch.optim.AdamW(policy.parameters(), lrLR) prompts load_prompts(data/train.jsonl, tokenizer) def forward_logprobs(model, input_ids, completion_start, completion_len): 返回补全部分的 token log 概率形状 [B, T]。 outputs model(input_idsinput_ids) logits outputs.logits[:, completion_start - 1:-1, :] # 预测下一个 token logits logits[:, :completion_len, :].float() targets input_ids[:, completion_start:completion_start completion_len] logp torch.log_softmax(logits, dim-1) return logp.gather(-1, targets.unsqueeze(-1)).squeeze(-1) for epoch in range(EPOCHS): for step, sample in enumerate(prompts): prompt_ids sample[input_ids] completions, full_seqs sample_group( policy, tokenizer, prompt_ids, GROUP_SIZE, MAX_NEW_TOKENS, TEMPERATURE ) rewards torch.tensor( [compute_reward(c, sample[answer]) for c in completions], dtypetorch.float32, devicecuda, ).unsqueeze(0) # [1, G] # 如果整组奖励完全相同优势恒为 0跳过这条样本 if rewards.std(unbiasedFalse).item() 1e-6: continue # 构造对齐后的张量这里为简化只处理长度一致的情况 seqs torch.stack(full_seqs).cuda() # [G, L] comp_start prompt_ids.shape[0] comp_len seqs.shape[1] - comp_start with torch.no_grad(): old_logp forward_logprobs(policy, seqs, comp_start, comp_len) ref_logp forward_logprobs(ref_model, seqs, comp_start, comp_len) new_logp forward_logprobs(policy, seqs, comp_start, comp_len) mask torch.ones_like(new_logp) loss, stats grpo_loss( new_logp.unsqueeze(0), old_logp.unsqueeze(0), ref_logp.unsqueeze(0), rewards, mask.unsqueeze(0), clip_epsCLIP_EPS, kl_coefKL_COEF, ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(policy.parameters(), 1.0) optimizer.step() if step % 10 0: print(fstep{step} loss{loss.item():.4f} freward{rewards.mean().item():.4f} fkl{stats[kl]:.4f} ratio{stats[ratio_mean]:.4f})这段主循环为了可读性做了简化同一组内回答长度被假设为一致实际中需要 padding 并计算 mask并且只做了一次策略更新。真实实现里通常会对同一批采样数据做多次梯度累积更新这时候old_logp必须固定在第一次更新前的值不能跟着变否则 ratio 会失真。4.5 运行与结果观察启动训练cd grpo-tutorial python manual/train_manual.py正常情况下你会在日志里看到这样的趋势前几十步reward缓慢上升从 0.5 左右只有格式分逐步爬到 1.0 以上格式分加部分正确分kl起初很小随着训练推进缓慢增长但如果超过 0.1 就要警惕说明策略偏离参考模型太远了ratio应该稳定在 1.0 附近如果长期偏离 1 超过 0.3说明学习率过大或累积更新次数太多。需要提醒的是这个手写版本更适合教学验证不适合长跑。理由有三没有实现变长 padding、没有做梯度累积、没有接推理引擎加速采样。真正要跑规模训练建议直接上第 5 节的工程化方案。5. 使用 TRL GRPOTrainer 快速上手5.1 数据格式与训练配置TRL 提供了GRPOTrainer把组采样、奖励计算、mask 处理、分布训练这些都封装好了。你只需要提供模型、数据和奖励函数。数据格式非常宽松只要数据集里包含prompt字段即可其他字段会被透传给奖励函数。# 文件路径trl_impl/train_trl.py from datasets import load_dataset from trl import GRPOConfig, GRPOTrainer dataset load_dataset(json, data_filesdata/train.jsonl, splittrain) print(dataset[0])配置项里几个和 GRPO 直接相关的参数需要重点关注training_args GRPOConfig( output_dir./output/grpo, per_device_train_batch_size1, # 单卡每次处理的 prompt 数 gradient_accumulation_steps8, # 累积步数与上一项相乘得到有效 batch num_generations8, # 即前文的组大小 G max_prompt_length512, max_completion_length512, learning_rate1e-6, beta0.04, # KL 系数 epsilon0.2, # 裁剪范围 temperature0.9, logging_steps1, save_steps200, bf16True, gradient_checkpointingTrue, report_tonone, )num_generations和per_device_train_batch_size的关系需要特别注意组采样是在每个 prompt 上独立进行的所以实际解码的序列数等于两者相乘。如果显存吃紧优先降num_generations但不要低于 4否则组内统计量噪声太大。5.2 奖励函数注册与对接奖励函数的签名需要能接收数据集里的字段。TRL 会把completions一个字符串列表和数据集中的其他列以关键字参数形式传进来返回值必须是与completions等长的浮点数列表。# 文件路径trl_impl/train_trl.py续 from rewards import format_reward, extract_answer def reward_format(completions, **kwargs): 格式奖励命中 answer 结构给 0.5 分。 return [format_reward(c) for c in completions] def reward_accuracy(completions, answer, **kwargs): 准确性奖励答案完全匹配给 1.0 分。 scores [] for c, gold in zip(completions, answer): pred extract_answer(c) pred pred.replace(,, ).strip() scores.append(1.0 if pred str(gold).strip() else 0.0) return scores trainer GRPOTrainer( modelyour-base-model, # 与手写版保持一致 reward_funcs[reward_format, reward_accuracy], argstraining_args, train_datasetdataset, ) trainer.train() trainer.save_model(./output/grpo/final)多个奖励函数的返回值会被相加所以这里的总分是 0.5 1.0 1.5和手写版本完全一致。如果你希望对不同奖励加权最简单的做法是在函数内部就乘上权重而不是指望框架提供权重参数。启动命令accelerate launch trl_impl/train_trl.py5.3 训练过程中的重点观测指标训练日志里除了 loss还有几个指标值得盯住reward的均值反映整体表现reward_std组内标准差反映组内区分度kl反映偏离程度completions/mean_length反映输出长度变化。最后这个指标经常被忽略但它很重要——很多失败的 GRPO 训练表现为奖励在涨、长度在疯狂膨胀这通常说明模型找到了某种啰嗦换取格式分的捷径。建议在训练前期每 50 步抽几条 completion 人工看一眼。规则奖励最容易出问题的地方就是模型钻空子光看数字是看不出来的必须看实际输出。6. 常见问题与排查思路6.1 高频报错与异常现象对照表问题现象常见原因解决思路训练一开始就 OOM同时加载策略、参考、奖励模型组采样序列数过多开启 gradient_checkpointing降低 num_generations参考模型用更小的 dtype奖励完全不涨组内奖励全部相同导致优势为 0奖励函数写错打印每组奖励分布检查奖励函数是否区分度足够奖励快速涨到 1.5 后不再变化任务过于简单模型已饱和提高题目难度分布或引入更难的数据KL 急剧上升后崩溃学习率过大KL 系数过小降学习率到 1e-6 以下调大 beta 到 0.04 以上输出长度持续膨胀奖励函数偏好长输出无长度约束检查格式奖励是否变相鼓励长文本加入长度惩罚loss 为 nanlog_ratio 溢出除零对 log_ratio 做 clampadvantage 分母加 eps训练速度极慢采样与训练未分离串行执行采样接推理引擎或先用小模型验证流程6.2 优势全为零的排查方法这是 GRPO 最典型的问题也是新手最容易卡住的地方。表现是 loss 一直在小幅震荡但奖励毫无起色。根本原因是组内所有回答的奖励完全相同标准化之后分子为 0优势为 0梯度也就为 0。排查步骤可以按这个顺序来先打印每个 prompt 的 G 个奖励值确认是否有差异如果全一样说明任务对当前模型太简单或太难需要调整数据难度。再检查采样温度温度太低会导致 G 条回答高度相似奖励自然也相似可以把 temperature 从 0.7 提到 0.9 甚至 1.0。最后检查奖励函数的粒度如果只有 0 和 1 两档区分度天生就弱可以考虑增加中间档位比如部分正确的答案给 0.3 分。6.3 奖励劫持的识别与规避奖励劫持Reward Hacking指的是模型找到了一种能拿高分但不符合真实意图的行为。在规则奖励下这种情况几乎必然会发生只是早晚问题。典型表现包括答案标签里塞入多个候选值企图蒙对、输出里堆砌大量无意义推理、只输出格式不输出内容。规避的核心思路是让奖励尽量难以被低成本满足。具体做法包括格式奖励给低权重准确性奖励给高权重对输出长度设上限答案比对用严格匹配而非包含匹配必要时加入惩罚项比如拒绝重复度过高的输出。另外定期抽查模型输出是必不可少的环节数字指标无法替代人工判断。7. 最佳实践与工程建议7.1 奖励函数设计的几条经验奖励函数是整个 GRPO 训练里最需要打磨的部分我在实践中总结了几条比较通用的原则。第一奖励要能分层不要只有对错两档中间态能显著提升梯度信号的丰富度。第二奖励的计算必须廉价且确定如果每次打分都要调用外部 API训练速度和可复现性都会受影响。第三不同奖励项之间要有明确的优先级通过分值配比来体现而不是靠模型去猜。第四任何规则奖励上线前都要用一批已知错误输出做测试确认它确实会给低分。7.2 超参数配置参考下面这组参数适合作为起点实际项目请在此基础上微调# 参考配置需按模型规模与任务难度调整 group_size: 8 # 组大小建议 4~16 learning_rate: 1.0e-6 # 比 SFT 小一个量级 kl_coef: 0.04 # KL 惩罚系数 clip_eps: 0.2 # 裁剪范围 temperature: 0.9 # 采样温度过低会导致组内无差异 max_completion_length: 512 # 补全长度上限 gradient_accumulation: 8 # 梯度累积步数 num_train_epochs: 1 # RL 阶段通常只训 1 个 epoch关于 epoch 数有个经验RL 阶段的数据复用次数远低于 SFT通常 1 个 epoch 就足够训多了容易过拟合到奖励函数上。如果发现奖励还在涨但评测集表现下降基本就是过拟合信号应该提前停止。7.3 训练稳定性与显存优化显存优化有几个立竿见影的手段。开启梯度检查点可以把激活显存降下来代价是训练速度慢 20% 到 30%。参考模型用 bf16 甚至 int8 加载能省下可观的空间。如果还是不够可以考虑 LoRA只训练低秩适配器策略模型的可训练参数量大幅下降优化器状态也随之变小。稳定性方面梯度裁剪是必须的clip_grad_norm_设成 1.0 比较稳妥。另外建议在每个 batch 上做多次更新时监控 ratio 的分布一旦大量样本超出裁剪范围说明更新幅度过大应当降低学习率或减少累积轮次。7.4 评测与回归机制强化学习训练最危险的地方在于指标会骗人。训练奖励上涨不代表模型变好可能只是它学会了迎合奖励函数。因此必须有一套独立的评测集且评测集不能和训练数据同分布到完全重合。建议至少保留三类评测与训练同分布的留出集、分布外的一般能力评测、以及人工抽检。每次训练产出模型后都跑一遍固定评测并记录形成回归基线。一旦新模型在某项指标上明显下降就要回溯是不是奖励设计或超参改动导致的。这套机制看起来麻烦但能帮你避开绝大多数训着训着就废了的情况。8. 学习路线与延伸方向把本文的内容串起来其实就三条主线理解 GRPO 为什么能去掉 Critic掌握组内归一化优势和裁剪目标的数学形式能把奖励函数、采样、loss、训练循环这几块拼成一个能跑的流程。这三条掌握之后剩下的都是工程细节的打磨。接下来的延伸方向可以按兴趣选。想深入算法可以对比 GRPO 与 RLOO、ReMax 这类同样去掉 Critic 的方法看看它们在优势估计上的差异想深入工程可以研究采样与训练分离的架构以及如何用推理引擎做高吞吐的组采样想深入应用可以在自己的业务场景里设计规则奖励比如代码任务用单元测试做奖励结构化输出任务用 schema 校验做奖励。最后一个实践建议不要一上来就追求大规模训练。先用几十条数据、1.5B 级别的模型把整条链路跑通确认奖励会涨、KL 可控、输出可读再逐步放大数据和模型规模。GRPO 的调试成本主要花在定位问题上而不是算力上小规模快速迭代比一次性大跑要高效得多。