大多数开源的 GRPO(Group Relative Policy Optimization)实现的流程都差不多:导入 TRL,调用
GRPOTrainer
,调几个超参数,跑起来。能拿到结果但算法为什么起作用、规模上去之后会在哪里出问题、出了问题该怎么查——这些都无从知晓。
所以本文不是想重新发明轮子,因为真正了解一个系统的唯一办法就是亲手搭一遍。每个设计决策都会变成一个绕不开的问题:为什么用分组归一化而不是价值网络?rollout worker 为什么用 Trio 而不是 asyncio?代码执行为什么要上子进程沙箱?封装一个库的时候,这些问题根本不会冒出来;只有在从头搭建时才会更深入的了解
本文做了一个简单的项目 grpo-llm:一个从零实现的 GRPO,带异步 Trio rollout worker、FSDP 多 GPU 支持、沙箱化代码执行、C++ pybind11 奖励扩展——以及本文最相关的部分,在 GSM8K 数学推理任务上对 GRPO、PPO、DPO 做的一次受控对比。
三种算法各自在做什么
先讲清楚这三种算法的区别,结果才好解读。
GRPO:Group Relative Policy Optimization
GRPO 的思路很简单:不训练单独的价值网络估计基线(baseline),用组本身当基线。每个 prompt 采样 G 个 completion,打分,在组内做归一化:
def compute_advantages(self, rewards: torch.Tensor) -> torch.Tensor:
rewards_grouped = rewards.view(-1, self.config.group_size) # (batch, G)
mean = rewards_grouped.mean(dim=-1, keepdim=True)
std = rewards_grouped.std(dim=-1, keepdim=True)
return ((rewards_grouped - mean) / (std + 1e-8)).view(-1)
5 行代码,GRPO 算法就完整了。没有 critic 模型,没有价值网络训练,没有额外的反向传播,组的均值和标准差直接顶替了价值网络的位置。
同等模型规模下,GRPO 比 PPO 便宜:显存里只需要放一个模型,不用放两个。
PPO:Proximal Policy Optimization
PPO 在策略梯度上加了一个裁剪后的替代目标函数(clipped surrogate objective),防止策略在单步更新中变化太猛:
ratio = torch.exp(log_probs - old_log_probs) # importance weight
obj_unclipped = ratio * advantages
obj_clipped = torch.clamp(ratio, 1 - epsilon, 1 + epsilon) * advantages
loss = -torch.min(obj_unclipped, obj_clipped).mean()
ratio 一旦超出
[1-ε, 1+ε]
,裁剪后的目标函数就接管,限制更新幅度。这是 PPO 用来防止策略被单个坏 batch 带崩的稳定性机制。
我们这个实现中GRPO 在组归一化的 advantage 之上用了同一套裁剪目标函数;跟标准 PPO 的区别只在基线——组均值代替了学习到的价值函数。
DPO:Direct Preference Optimization
DPO 则是另一个方法,它不在线收集 rollout、计算奖励,而是离线工作:给定一对 completion,一个偏好、一个拒绝,直接优化策略让偏好的那个概率更高。
logits = beta * (
(lp_chosen.sum() - ref_chosen.sum()) -
(lp_rejected.sum() - ref_rejected.sum())
)
loss = -F.logsigmoid(logits)
没有 rollout worker,训练时不需要奖励函数,也没有 KL 惩罚项。只有一对偏好数据和一个 loss。
这次的项目中偏好对是实时生成的:生成两个 completion,用二元数学奖励打分,谁对谁错决定 chosen 和 rejected。
实验环境
设置:Qwen2.5-0.5B-Instruct,GSM8K 数学推理数据集,每种算法训练 300 次迭代,用同一批 200 道题目做留出评估,AWS g4dn.xlarge(Tesla T4 GPU)。
模型、tokenizer、学习率(1e-6)、KL 惩罚系数(β=0.01)、评估流程,全部固定;唯一的变量是算法本身。
结果:
算法最终准确率训练时间*DPO***29.5%**120 分钟GRPO28.5%188 分钟PPO25.0%191 分钟
DPO 赢了而且赢得干脆:120 分钟完成训练,GRPO 和 PPO 都跑了 3 个多小时。
一些出乎意料的发现
本以为 GRPO 会赢。这个算法就是为 LLM 推理任务设计的,DeepSeek-R1 用的正是它,还省掉了价值网络的开销,理应占尽优势。
结果DPO 赢得比想象中更明显,用时还只有一半,这可能是因为:
小规模下,稀疏二元奖励是个麻烦。这里的奖励函数是二元的:模型的最终答案要么对(奖励=1),要么错(奖励=0)。0.5B 参数的模型在训练早期大多数答案都是错的,也就是说,大多数 rollout 返回的奖励是 0。
一个组里所有 completion 的奖励都是 0 时,组归一化后的 advantage 全部趋于零,甚至数值不稳定。零 advantage 意味着零梯度,零梯度意味着策略不更新,这一步训练相当于没跑。
所以300 次迭代下来,GRPO 有相当一部分计算预算耗在了这种零梯度步骤上;PPO 情况也类似,还要加上裁剪机制本身的开销。
而DPO 完全绕开了这个坑。哪怕两个 completion 都错,DPO 依然能产生有用的更新,因为它比较的是两个 completion 之间谁更好,不是跟一个绝对正确性的门槛比。总有一个"相对更对",即便两个都不完全对,这个偏好信号永远不会归零。
DPO 总能学到东西,GRPO 和 PPO 常常什么都学不到——这是这次实验最核心的发现:小模型规模、稀疏二元奖励的情况下,偏好信号的质量比在线探索更重要。在线 RL 那种能看到真实奖励分布、能自适应调整 rollout 策略的丰富信息环境,补不回浪费在零奖励 batch 上的训练步骤。
这对过程奖励模型(process reward model)意味着什么?GRPO 和 PPO 的自然解法是把奖励做密。不只对最终答案打分,对每一步推理都打分,过程奖励模型(PRM)能在解题的每一步都给出信号,把零梯度更新的比例大幅压下去。
Math-Shepherd 和 Let's Verify Step by Step 这类论文探讨的正是这个方向。这次的结果给出了一个具体的原因:小规模、二元结果奖励的场景下,在线 RL 算法卡住的不是架构,是奖励稀疏性。把奖励密度这个问题解决掉,情况很可能会不一样。
如果有更多算力会怎么做
更多迭代次数。300 步只是个概念验证,真正的对比要跑 2000 步以上,让在线探索有时间积累优势——GRPO 相对 DPO 的优势可能会随训练时长增长。
更大的模型。7B 以上参数量的模型才有足够容量去消化在线 RL 提供的更丰富的梯度信号;0.5B 也许太小,用不上 GRPO 能给的东西。
多个随机种子。目前每种算法只跑了一次,要确立统计显著性至少需要 3 个以上的种子。这些结果算是有启发,还谈不上定论。
关注格式的奖励设计。二元奖励对"推理过程对、最终答案错"和"完全没推理"给的惩罚是一样的。如果引入格式感知的奖励,对正确的结构给部分分数,信号会密一些,各算法的排名很可能跟着变。
代码
对比实验只是这个项目的一半,另一半是让这些干净实验得以进行的基础设施,这部分花的心思更多。
Trio 做异步 rollout worker。Trio 用 nursery 实现结构化并发,worker 任务里的异常不可能被静默丢掉;长时间训练中,一次静默的 worker 失败会在毫无提示的情况下污染奖励缓冲区。asyncio 要达到同样的保证,得手动处理异常传递,Trio 默认就有。
沙箱化代码执行。代码生成类的 RL 任务要跑不可信的模型输出,
envs/code_exec.py
模块把每次执行隔离在一个子进程里,设了内存限制、超时、输出截断,接口留好了口子,以后要换成 gVisor 或 Firecracker 也不用改。
C++ pybind11 奖励扩展。
normalize_answer
这个函数训练时要被调用数百万次,pybind11 写的 C++ 扩展比纯 Python 快 1.7 倍。单次调用省下来的时间不多,一整个训练跑下来就攒起来了。
25 个测试全过,不需要 GPU。奖励函数和执行环境用 pytest 测了正确性、边界情况、超时处理、输出隔离,这些测试不用 GPU,每次推送代码都能先验证一遍,再让它去占算力。
总结
这次对比带出的问题比它回答的多。最想知道的是:加上过程奖励模型之后,在线 RL 相对 DPO 的优势会不会回来?
猜测是会的。DPO 赢的这个故事,讲的是奖励稀疏性,不是算法孰优孰劣。给 GRPO 更密的奖励,它应该能反超——当奖励信号足够丰富、能真正指引探索的时候,在线探索的价值才显得出来。
完整代码在 github.com/Uttaprexa/grpo-llm,
一切都可复现:确切的超参数在
configs/grpo_gsm8k.yaml
,完整实验脚本在
experiments/compare_algorithms.py
,结果在
experiments/results.json
。
作者:Uttapreksha