跳转到正文

16.1 GRPO 核心机制

上一章我们深入了 DPO 的理论与实践,看到它可以直接从固定的偏好数据里学习:同一个 prompt 下,chosen 应该比 rejected 更可能出现。现在我们回到在线训练:模型不再只读别人已经标好的偏好对,而是在训练过程中自己生成回答、自己得到反馈、再用反馈更新自己。

GRPO 的入口是同题多答。给定同一道题,模型一次生成多个回答;奖励函数分别给这些回答打分;然后只在这一组回答内部比较谁更好。它表面上像"让模型多试几次",真正解决的问题是:

没有 Critic 的时候,模型怎么判断某个回答是比预期好,还是比预期差?

一个直观答案是:拿它和同一道题的其他回答比。GRPO 就是沿着这个思路,把同题多答变成可以训练的策略优化方法。

本节沿着一次完整的 GRPO 训练轨迹来讲:先看同题多答怎样产生组内比较,再解释为什么"和同题其他回答比"可以替代 Critic,接着写出优势、概率比值和裁剪目标,最后回到手写代码和 GSM8K 训练实验。

Mermaid diagram

这张图先表达一个最基本的训练信号:同一道题多答几次,每个回答都有分数;高于同组平均分的回答以后更容易出现,低于同组平均分的回答以后更少出现

GRPO 的入口

一个带数字的微缩例子

用一个具体例子走一遍。假设题目是:

小明有 3 个苹果,又买了 2 个,现在一共有几个?

模型对同一道题一次写出 4 个回答,规则打分如下:

回答模型写了什么分数
1"3 + 2 = 5,所以答案是 5。"1.5
2"答案是 5。"1.0
3"应该是 6。"0.0
4"不确定,可能是 4。"0.0

这 4 个分数的平均分是:

于是模型会这样理解这组回答:

回答和平均分比较之后怎么学
1明显比平均好,以后多生成它
2也比平均好,稍微多生成它
3比平均差,以后少生成它
4比平均差,以后少生成它

这里的"比平均分高多少、低多少",后面会被正式叫做优势。在这个例子里,优势就是"这份回答在同题四个回答里表现得比平均好还是差"。

把语言模型放进强化学习框架

为了用 RL 语言讲清楚 GRPO,先把对应关系列出来:

强化学习概念在数学推理模型里是什么
状态 题目 prompt 加上已经写出的推理步骤,也就是
动作 下一步生成的 token,也就是
轨迹 一整段推理过程和最终答案
奖励 答案是否正确、格式是否符合要求
策略 当前正在训练的语言模型

对一道题 来说,模型生成完整回答 就相当于走完一条轨迹。被训练的对象仍然是语言模型策略

需要澄清一个常见误会:GRPO 不是一个新的模型,也不只是"组内归一化"这个公式。GRPO 是一种在线训练策略模型的方法。 训练方式是:对同一个 prompt 一次生成多个回答,把这些回答放在同一组里打分,并比较:这个回答在同组里是否高于平均水平? 最后更新策略时仍然使用 PPO-style 的 ratio + clip,避免新策略离旧策略太远。

用一句话概括:

GRPO = 在线组采样 + 规则/奖励打分 + 组内相对优势 + PPO-style 裁剪更新。

把开头的苹果题翻译成这句话:同一道题一次生成 4 个回答,这是在线组采样;用答案正确性和格式给分,这是规则/奖励打分;用 这样的差值判断好坏,这是组内相对优势;最后让好回答概率上升、差回答概率下降,但每次只小步调整,这就是 PPO-style 裁剪更新

PPO Critic 的痛点

要理解 GRPO 为什么这样设计,先看它要替代的 Critic 有什么问题。

Critic 是什么

在 PPO 这类 Actor-Critic 方法里,Actor 是负责生成回答的策略模型,Critic 则像一个"价值评估器":它不直接生成回答,而是估计"当前已经写到这里,后面大概能拿到多少总奖励"。用公式写就是价值函数:

其中 是当前状态——对语言模型来说可以粗略理解为"prompt 加上已经生成的前几个 token"; 是 Critic 自己的参数。Critic 的作用是给策略更新提供一个基线:如果某个回答的真实奖励比 Critic 预估的更高,就说明这个回答比预期好,应该提高概率;如果比预期低,就应该降低概率。

如果照 PPO 的路线走,优势大致写成:

这句话的意思是:不要只看奖励高不高,要看它有没有比 Critic 的预期更好。这在传统强化学习里很自然,但在 LLM 数学推理里就很重。

Critic 在 LLM 训练中的三大问题

1. 吃显存:Critic 与 Actor 同等规模,PPO 需要同时装下 Actor + Critic + Reference + RM 四个模型。

2. 训练不稳定:价值函数 需要从"部分生成的文本"预测"最终得分",但 LLM 序列很长(500+ tokens),监督信号只在末尾才有,方差极大。

3. 工程复杂:四个模型各有一套优化器、学习率、梯度裁剪配置,调参难度指数级增长。

回顾第 6 章基线分析第 7 章优势函数,Critic 的核心作用是提供基线来降低方差。如果不需要单独训练网络就能得到基线,Critic 就可以退休了——这就是 GRPO 的出发点。

GRPO 的核心 与 组内归一化替代 Critic

GRPO 的想法出奇地简单:不再单独训练 Critic,而是用同一个 prompt 下多个回答的平均分临时充当基线。DeepSeekMath 论文提出 GRPO 时,明确说它 "foregoes the critic model",并用组内分数来估计基线。

GRPO 从 PPO 中替换 Critic 基线

因此,GRPO 与 PPO 的关系可以概括为:

  • PPO 问:这个回答比 Critic 预估的平均水平好吗?
  • GRPO 问:这个回答比同一道题的其他回答好吗?
  • PPO 和 GRPO 都还会用概率比值和裁剪,避免一次更新过大。

论文脉络:GRPO 来自 DeepSeekMath 论文 DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models。它不是完全抛弃 PPO,而是在 PPO 框架里去掉 Critic,用组内相对奖励构造优势。

GRPO 把 PPO 里的 Critic 基线换成"同一道题的一组回答的平均分":原来问"这个回答比 Critic 预期好吗",现在问"这个回答比同题其他回答好吗"。这就是"组内相对优势"的直觉:同一道题里,谁比平均好,就多学谁;谁比平均差,就少生成谁

代码地图

下面是一份最小手写 GRPO 代码地图。它不是 trl 的工程源码,而是把 GRPO 的数学结构摊开给你看:每个公式后面都能回到这份代码里的某几行。

  5# [A] 组采样:每个 prompt 生成 group_size 个回答
  6def sample_groups(model, tokenizer, prompts, group_size=8, max_new_tokens=256):
  7    expanded_prompts = [
  8        prompt
  9        for prompt in prompts
 10        for _ in range(group_size)
 11    ]
 12    inputs = tokenizer(expanded_prompts, padding=True, return_tensors="pt")
 13    inputs = {key: value.to(model.device) for key, value in inputs.items()}
 14 
 15    with torch.no_grad():
 16        output_ids = model.generate(
 17            **inputs,
 18            do_sample=True,
 19            temperature=0.8,
 20            max_new_tokens=max_new_tokens,
 21        )
 22 
 23    responses = tokenizer.batch_decode(output_ids, skip_special_tokens=True)
 24    group_ids = torch.arange(len(prompts), device=model.device).repeat_interleave(group_size)
 25    return responses, group_ids
 49# [C] 组内优势:用同题目的回答均值替代 Critic 基线
 50def group_advantages(rewards, group_size=8, eps=1e-8):
 51    grouped_rewards = rewards.view(-1, group_size)
 52    group_mean = grouped_rewards.mean(dim=1, keepdim=True)
 53    group_std = grouped_rewards.std(dim=1, keepdim=True)
 54 
 55    advantages = (grouped_rewards - group_mean) / (group_std + eps)
 56    advantages = torch.where(
 57        group_std < eps,
 58        torch.zeros_like(advantages),
 59        advantages,
 60    )
 61    return advantages.reshape(-1)
 81# [E] GRPO 更新:PPO-style ratio + clip,但优势来自组内比较
 82def grpo_loss(policy_model, ref_model, batch, old_logprobs, advantages,
 83              clip_eps=0.2, kl_coef=0.04):
 84    new_logprobs = sequence_logprob(
 85        policy_model,
 86        batch["input_ids"],
 87        batch["attention_mask"],
 88        batch["labels"],
 89    )
 90 
 91    with torch.no_grad():
 92        ref_logprobs = sequence_logprob(
 93            ref_model,
 94            batch["input_ids"],
 95            batch["attention_mask"],
 96            batch["labels"],
 97        )
 98 
 99    ratio = torch.exp(new_logprobs - old_logprobs)
100    surr1 = ratio * advantages
101    clipped_ratio = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps)
102    surr2 = clipped_ratio * advantages
103    policy_loss = -torch.min(surr1, surr2).mean()
104 
105    # [F] KL 惩罚:防止 Policy 离 Reference 太远
106    log_ratio_ref = ref_logprobs - new_logprobs
107    approx_kl = (torch.exp(log_ratio_ref) - log_ratio_ref - 1.0).mean()
108    loss = policy_loss + kl_coef * approx_kl
109 
110    metrics = {
111        "loss": loss.detach(),
112        "policy_loss": policy_loss.detach(),
113        "approx_kl": approx_kl.detach(),
114    }
115    return loss, metrics
118# [G] 训练步骤:采样、打分、组内归一化、再反向传播
119def train_step(policy_model, ref_model, optimizer, tokenizer, prompts, ground_truths,
120               group_size=8):
121    responses, _ = sample_groups(policy_model, tokenizer, prompts, group_size)
122    rewards = score_responses(responses, ground_truths, group_size, policy_model.device)
123    advantages = group_advantages(rewards, group_size)
124 
125    batch = tokenizer(responses, padding=True, return_tensors="pt")
126    batch = {key: value.to(policy_model.device) for key, value in batch.items()}
127    batch["labels"] = batch["input_ids"].clone()
128 
129    with torch.no_grad():
130        old_logprobs = sequence_logprob(
131            policy_model,
132            batch["input_ids"],
133            batch["attention_mask"],
134            batch["labels"],
135        )
136 
137    loss, metrics = grpo_loss(policy_model, ref_model, batch, old_logprobs, advantages)
138    optimizer.zero_grad()
139    loss.backward()
140    optimizer.step()
141    return metrics
142 
143 
144# [H] GRPO 训练循环:每轮都在线生成新回答
145def train_grpo(policy_model, ref_model, optimizer, tokenizer, dataloader):
146    ref_model.eval()
147    for prompts, ground_truths in dataloader:
148        metrics = train_step(
149            policy_model,
150            ref_model,
151            optimizer,
152            tokenizer,
153            prompts,
154            ground_truths,
155        )
156        print("loss=", float(metrics["loss"]), "kl=", float(metrics["approx_kl"]))

这份代码可以分成八块:

标记代码部分后文会解释什么
[A]sample_groups为什么每个 prompt 要生成多个回答
[B]rule_reward / score_responses奖励从哪里来,为什么数学题不需要 RM
[C]group_advantages组内均值如何替代 Critic 基线
[D]sequence_logprob如何给一整段回答算
[E]grpo_loss 前半段ratioclip 和 PPO-style 策略更新
[F]approx_kl为什么还要限制 Policy 偏离 Reference
[G]train_step采样、打分、优势、loss、反向传播如何接起来
[H]train_grpo为什么 GRPO 是在线训练,每轮都生成新回答

从 PPO 改到 GRPO 与 到底替换了哪几行

如果不改成 GRPO,而是继续按 PPO / RLHF 的方式训练,代码直觉通常是这样:

python
# PPO / RLHF 与 在线生成,然后让 Critic 估计基线
responses = policy_old.generate(prompts)
logps_old = sequence_logprob(policy_old, prompts, responses).detach()

rewards = reward_model(prompts, responses)
values = critic(prompts, responses)
advantages = rewards - values

logps_new = sequence_logprob(policy, prompts, responses)
ratio = torch.exp(logps_new - logps_old)
ppo_loss = -torch.min(
    ratio * advantages,
    torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * advantages,
).mean()

这里的 critic 就是前面说的价值模型。它的工作不是生成答案,而是估计一个基线:这个 prompt 和当前回答前缀,大概应该拿多少分。然后 PPO 用 rewards - values 得到优势,判断某个回答是"比预期好"还是"比预期差"。

GRPO 的改法很集中:保留在线生成、概率比值和裁剪,但不再训练 Critic;优势改成从同一个 prompt 的一组回答里算出来

python
# 同一个 prompt 生成 G 个回答,然后做组内比较
responses = generate_many(policy_old, prompts, num_generations=G)
logps_old = sequence_logprob(policy_old, prompts, responses).detach()

rewards = reward_fn(prompts, responses)
rewards_by_group = rewards.view(batch_size, G)

group_mean = rewards_by_group.mean(dim=1, keepdim=True)
group_std = rewards_by_group.std(dim=1, keepdim=True)
advantages = ((rewards_by_group - group_mean) / (group_std + 1e-4)).view(-1)

logps_new = sequence_logprob(policy, prompts, responses)
ratio = torch.exp(logps_new - logps_old)
grpo_loss = -torch.min(
    ratio * advantages,
    torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * advantages,
).mean()

把真正变化的几行单独拎出来,就是:

diff
  responses = policy_old.generate(prompts)
  rewards = reward_model_or_rule(prompts, responses)
- values = critic(prompts, responses)
- advantages = rewards - values

+ rewards_by_group = rewards.view(batch_size, G)
+ group_mean = rewards_by_group.mean(dim=1, keepdim=True)
+ group_std = rewards_by_group.std(dim=1, keepdim=True)
+ advantages = ((rewards_by_group - group_mean) / (group_std + 1e-4)).view(-1)

  loss = ppo_style_clipped_loss(logps_new, logps_old, advantages)

所以 GRPO 不是"把 PPO 全删掉",也不是"只剩一个组内归一化公式"。更准确地说,GRPO 把 PPO 里的 Critic 基线换成了组内平均基线

TRL 的真实源码也正是这个结构。2026-05-01 查看 Hugging Face TRL main 分支时,可以在 GRPOTrainer 里看到这些对应关系:

  1. GRPOTrainer 的初始化参数里有 reward_funcs,它可以是奖励模型,也可以是普通 Python 函数。也就是说,数学题这类任务可以直接用规则函数打分,不一定要先训练 RM。
  2. self.num_generations = args.num_generations 对应公式里的 ,也就是每个 prompt 生成几个回答
  3. 源码会把 rewards reshape 成 (-1, num_generations),计算 mean_grouped_rewards 和组内 std_rewards,再得到 advantages = rewards - mean_grouped_rewards,必要时除以标准差。
  4. 损失部分仍然计算 coef_1 = exp(log_ratio),再用 torch.clamp 得到 coef_2,最后对 coef_1 * advantagescoef_2 * advantagesmin。这就是 PPO-style 裁剪目标。

对照 PPOTrainer,差别就更清楚:PPOTrainer 需要 reward_modelvalue_model,并用 value_model 产生优势估计;GRPOTrainer 不需要单独的 value_model,它把同题多答的组内相对分数直接变成优势。

GRPO 的完整公式

前面用直觉和代码 diff 看过 GRPO 怎么工作。这一节把所有公式正式写一遍,要证明的核心命题是:用组内归一化替代 Critic 之后,GRPO 的策略梯度方向和 PPO 完全一致——只是基线换了来源

核心命题:对任意同题多答样本 ,组内归一化优势 满足:

  1. 梯度方向正确,即优势的正负和"是否好于组平均"完全对齐;
  2. 不引入额外偏差,组内期望为零,与 Critic 基线的性质一致;
  3. 难度归一,不同难度的题目梯度尺度一致。

下面四小节依次给出样本结构、组内优势(命题 1+2+3 的兑现)、PPO Clip、KL 惩罚,最后用一张数据流图收尾。

样本结构与组采样

GRPO 的训练样本不是"一个 prompt 配一个回答",而是一个 prompt 配一组回答。假设一个 batch 里有多个题目,用 表示第几个题目,用 表示这个题目下第几个回答:

每个字母的意思是:

  • :第 个 prompt,也就是一道题或一个问题。
  • :group size,每个 prompt 生成几个回答。代码里的 num_generations=8 就是
  • :第 个 prompt 下生成的第 个回答。
  • :生成这批回答时使用的旧策略。它负责采样数据。
  • :正在被更新的新策略。它负责学习,让好回答更可能出现。

采样过程可以写成:

符号 表示"从某个分布中采样"。每个回答生成后,都要得到一个奖励:

这里 是奖励函数, 是一个标量。数学题里, 可以很简单:答案对就加分,格式规范也加分。GRPO 的关键不是"奖励函数一定很复杂",而是:同一道题下的多个回答会放在一起比较

代码里对应的是 [A] 组采样

  5# [A] 组采样:每个 prompt 生成 group_size 个回答
  6def sample_groups(model, tokenizer, prompts, group_size=8, max_new_tokens=256):
  7    expanded_prompts = [
  8        prompt
  9        for prompt in prompts
 10        for _ in range(group_size)
 11    ]
 12    inputs = tokenizer(expanded_prompts, padding=True, return_tensors="pt")
 13    inputs = {key: value.to(model.device) for key, value in inputs.items()}
 14 
 15    with torch.no_grad():
 16        output_ids = model.generate(
 17            **inputs,
 18            do_sample=True,
 19            temperature=0.8,
 20            max_new_tokens=max_new_tokens,
 21        )
 22 
 23    responses = tokenizer.batch_decode(output_ids, skip_special_tokens=True)
 24    group_ids = torch.arange(len(prompts), device=model.device).repeat_interleave(group_size)
 25    return responses, group_ids

替代 Critic 的基线

GRPO 的核心思路在这里兑现:对同一个问题 ,先采样 个回答得到 个奖励 ,再做两步处理——减均值替代 Critic,除标准差归一化尺度。两步合起来得到组内优势:

其中 是组内均值, 是组内标准差, 是一个很小的数(如 ),防止标准差为 0 时除以 0。下面两步推导把这个公式拆开看为什么是这个形式。

第一步:减均值替代 Critic。回顾 PPO 优势 ,本质是"奖励减基线"。Critic 学的 就是对"在这个 prompt 下平均能拿多少分"的估计。而组内均值 是这个估计的直接样本版本——同一道题的 个回答就是 次蒙特卡洛采样,平均起来就是无偏估计。代换:

这一步保证了: 的正负和"是否好于组平均"完全对齐;组内期望 ,与 Critic 基线的性质一致。

第二步:除标准差归一化尺度。不同题目的奖励尺度差异巨大——简单题组内奖励可能在 之间波动(, ),难题组内可能在 之间波动(, )。如果只减均值不除标准差,简单题和难题的梯度尺度相同——但简单题已经掌握了,不应该再主导梯度。除以 把所有题目的优势尺度拉到接近 1:

在统计学里这个变换叫 z-score 标准化,几何含义是把每组奖励平移到原点、缩放到单位方差,让不同分布可以在同一坐标轴上比较。

两步合起来读: 表示这个回答比同组平均好,应该提高概率; 表示比平均差,应该降低概率; 表示和平均差不多,不需要太强更新。这种"控制变量"式的组内比较也比跨样本的绝对评分更稳定——同一组内的回答共享相同的 prompt,唯一差异是模型生成的随机性。它也和人类偏好的本质对齐:判断本来就是"A 比 B 好"这种比较式的,不是"A 得 87 分"这种绝对的。

边界情形与代码对应

如果同一组回答奖励全都一样, 会接近 0,代码会把优势设成 0。这表示这道题暂时没有可学习的差异:大家都对,或者大家都错,模型不知道该更偏向哪一个回答。 的作用是避免 的数值问题。

代码里对应的是 [C] 组内优势

 49# [C] 组内优势:用同题目的回答均值替代 Critic 基线
 50def group_advantages(rewards, group_size=8, eps=1e-8):
 51    grouped_rewards = rewards.view(-1, group_size)
 52    group_mean = grouped_rewards.mean(dim=1, keepdim=True)
 53    group_std = grouped_rewards.std(dim=1, keepdim=True)
 54 
 55    advantages = (grouped_rewards - group_mean) / (group_std + eps)
 56    advantages = torch.where(
 57        group_std < eps,
 58        torch.zeros_like(advantages),
 59        advantages,
 60    )
 61    return advantages.reshape(-1)

代码对应关系:

  • grouped_rewards = rewards.view(-1, group_size):把一维奖励列表重新排成"每行一个 prompt、每行 个回答"的形状。
  • group_mean = grouped_rewards.mean(dim=1, keepdim=True):计算每个 prompt 的
  • group_std = grouped_rewards.std(dim=1, keepdim=True):计算每个 prompt 的
  • advantages = (grouped_rewards - group_mean) / (group_std + eps):实现
  • torch.where(group_std < eps, 0, advantages):如果一组回答没有差异,就不给这组样本训练信号。

一句话总结:GRPO = PPO 的裁剪机制 + 用组内排名替代 Critic。下面两小节就把"PPO 的裁剪机制"完整展开。

策略比值与 PPO Clip

先定义新旧策略的概率比值:

实际代码里不会直接除两个很小的概率,而是先算 log probability,再相减取指数:

如果 ,新旧策略对这条回答的概率一样;如果 ,新策略把它的概率提高了 20%;如果 ,新策略把它的概率压低了 20%。

有了比值和组内优势,GRPO 的裁剪目标可以写成:

每个符号的意思是:

  • :对 batch 里的所有 prompt 和所有组内回答取平均。
  • :刚才算出的组内优势。
  • :裁剪范围,常见值是 0.2。
  • :把概率比值限制在一个区间内。例如 时, 会被限制在
  • :选择更保守的那个目标,避免一次更新太大。

为什么要裁剪?因为这批回答是 生成的。如果训练几步后 已经离 很远,那么这批数据就不再能可靠代表新策略的行为。裁剪的作用就是:允许模型学习,但不允许它因为同一批数据一下子改得太猛。这部分和第 5 章 PPO 裁剪机制完全一致,详细推导见策略更新的约束机制

 81# [E] GRPO 更新:PPO-style ratio + clip,但优势来自组内比较
 82def grpo_loss(policy_model, ref_model, batch, old_logprobs, advantages,
 83              clip_eps=0.2, kl_coef=0.04):
 84    new_logprobs = sequence_logprob(
 85        policy_model,
 86        batch["input_ids"],
 87        batch["attention_mask"],
 88        batch["labels"],
 89    )
 90 
 91    with torch.no_grad():
 92        ref_logprobs = sequence_logprob(
 93            ref_model,
 94            batch["input_ids"],
 95            batch["attention_mask"],
 96            batch["labels"],
 97        )
 98 
 99    ratio = torch.exp(new_logprobs - old_logprobs)
100    surr1 = ratio * advantages
101    clipped_ratio = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps)
102    surr2 = clipped_ratio * advantages
103    policy_loss = -torch.min(surr1, surr2).mean()

在代码里,new_logprobsold_logprobs。所以:

  • ratio = torch.exp(new_logprobs - old_logprobs):实现
  • surr1 = ratio * advantages:不裁剪时的策略目标。
  • clipped_ratio = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps):把 限制在
  • surr2 = clipped_ratio * advantages:裁剪后的策略目标。
  • policy_loss = -torch.min(surr1, surr2).mean():取保守目标,并加负号变成要最小化的 loss。

注意上面的 是"想最大化"的目标;代码里的优化器默认最小化 loss,所以会写成:

这就是为什么代码中有一个负号。

KL 惩罚 与 不要离 Reference 太远

GRPO 通常还会保留一个 KL 惩罚,让 Policy 不要离 Reference 太远。手写代码里使用的是一个常见的近似 KL:

这个形式不是凭空选的,它满足三个关键性质。

性质一:逐样本非负。令 ,则 。求导 ,所以 是凸函数,在 (即 )处取最小值 任何 都给出正值——这避免了朴素估计 在单样本上可能为负的麻烦。

性质二:是 的无偏估计。注意 ,所以 ;而 。代回去:

性质三:在小偏差处退化为二次型。把 处 Taylor 展开:,所以

几何含义 作为 的函数是一条 形曲线,最低点在 (Policy = Reference),开口由 主导。这正是"越偏离惩罚越大"在数学上的写照——而二次型主导意味着梯度在偏离小时温和、偏离大时变陡,避免一次性把策略推得太远。

最后总损失可以写成:

这里 是 KL 惩罚的权重,对应代码里的 kl_coef。它越大,模型越保守;它越小,模型越愿意离开 Reference 去探索高奖励回答。

 99    ratio = torch.exp(new_logprobs - old_logprobs)
100    surr1 = ratio * advantages
101    clipped_ratio = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps)
102    surr2 = clipped_ratio * advantages
103    policy_loss = -torch.min(surr1, surr2).mean()
104 
105    # [F] KL 惩罚:防止 Policy 离 Reference 太远
106    log_ratio_ref = ref_logprobs - new_logprobs
107    approx_kl = (torch.exp(log_ratio_ref) - log_ratio_ref - 1.0).mean()
108    loss = policy_loss + kl_coef * approx_kl
109 
110    metrics = {
111        "loss": loss.detach(),
112        "policy_loss": policy_loss.detach(),
113        "approx_kl": approx_kl.detach(),
114    }
115    return loss, metrics

代码对应关系:

  • log_ratio_ref = ref_logprobs - new_logprobs:实现
  • approx_kl = (torch.exp(log_ratio_ref) - log_ratio_ref - 1.0).mean():实现
  • loss = policy_loss + kl_coef * approx_kl:实现总损失

一次完整训练的七步

把所有步骤连起来,GRPO 的一次训练就是:

  1. 对每个 prompt 采样 个回答。
  2. 用规则或奖励函数给每个回答打分。
  3. 在同一个 prompt 的组内计算
  4. 用新旧策略 log probability 算
  5. 用 PPO-style clip 控制更新幅度。
  6. 加上 Reference KL 惩罚。
  7. 反向传播,只更新 Policy。

完整的 GRPO 数据流如下图:

Mermaid diagram

GRPO 训练实验 与 GSM8K + 规则奖励

公式讲完后,看一次真实的 GRPO 训练。本节用一个最小可跑的实验:在 GSM8K 上用规则奖励训练 Qwen2.5-1.5B。

为什么不需要 RM

GSM8K 是一个包含 8500 道小学数学应用题的数据集,每道题都有明确的数值答案。这恰好是一个有"客观正确答案"的场景——不需要 RM,直接用规则判断答案是否正确:

  • 答案正确:
  • 格式规范(有清晰的推理步骤):
  • 答案错误:
python
# 1. 规则奖励函数(不需要 RM!)
import re

def rule_based_reward(prompt: str, response: str, ground_truth: str) -> float:
    reward = 0.0
    # 格式分:检查 \boxed{...}
    if re.search(r'\\boxed\{[^}]+\}', response):
        reward += 0.5
    # 答案分:提取最终答案并比较
    answer_match = re.search(r'\\boxed\{([^}]+)\}', response)
    if answer_match:
        model_answer = answer_match.group(1).strip()
        try:
            if abs(float(model_answer) - float(ground_truth)) < 0.01:
                reward += 1.0
        except ValueError:
            if model_answer == ground_truth:
                reward += 1.0
    return reward

# 测试
prompt = "Janet 的鸡蛋盒子每天能装 16 个鸡蛋。她每天早上吃 3 个,下午用 4 个烤松饼。她每周能卖多少个鸡蛋?"
good = "首先计算每天剩余的鸡蛋数:16 - 3 - 4 = 9 个\n每周有 7 天,所以每周能卖:9 × 7 = 63 个\n\\boxed{63}"
bad = "我觉得大概能卖 50 个左右吧。\\boxed{50}"
print(rule_based_reward(prompt, good, '63'))  # 1.5
print(rule_based_reward(prompt, bad, '63'))   # 0.5

注意这里的关键区别:不需要训练任何 RM,规则就是裁判。数学题有标准答案,直接比较就行。这种"可验证奖励"正是 RLVR 的核心思想。

在手写代码地图中,奖励函数对应的是 [B]。它只接收回答和标准答案,返回一个标量奖励:

 28# [B] 规则奖励:数学答案正确、格式规范就给分
 29def rule_reward(response, ground_truth):
 30    reward = 0.0
 31    boxed = re.search(r"\\boxed\{([^}]+)\}", response)
 32 
 33    if boxed:
 34        reward += 0.5
 35        if boxed.group(1).strip() == str(ground_truth).strip():
 36            reward += 1.0
 37 
 38    return reward
 39 
 40 
 41def score_responses(responses, ground_truths, group_size=8, device="cpu"):
 42    rewards = []
 43    for i, response in enumerate(responses):
 44        prompt_id = i // group_size
 45        rewards.append(rule_reward(response, ground_truths[prompt_id]))
 46    return torch.tensor(rewards, dtype=torch.float32, device=device)

运行 GRPO 训练

我们使用 trl 库提供的 GRPO 实现。和 PPO 相比,GRPO 不需要 Critic 模型:

python
# 2. GRPO 训练代码(简化示意)
from trl import GRPOTrainer, GRPOConfig
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset

model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-1.5B-Instruct")
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-1.5B-Instruct")
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "left"

config = GRPOConfig(
    output_dir="./grpo_gsm8k",
    num_generations=8,        # 每个问题生成 k=8 个回答(组大小)
    per_device_train_batch_size=4,
    learning_rate=5e-6,
    num_train_epochs=1,
    # 不需要 Critic!这是 GRPO 的核心创新
)

gsm8k = load_dataset("openai/gsm8k", "main")
trainer = GRPOTrainer(
    model=model,
    args=config,
    train_dataset=gsm8k["train"],
    reward_funcs=[rule_based_reward],  # 直接传入规则奖励函数
    processing_class=tokenizer,
)

trainer.train()  # 开始训练——不需要 Critic,不需要 RM
trainer.save_model("./grpo_gsm8k/final_model")

如果把 GRPOTrainer 内部最关键的训练步骤摊开,就是"先组采样,再打分,再算优势,再更新策略":

118# [G] 训练步骤:采样、打分、组内归一化、再反向传播
119def train_step(policy_model, ref_model, optimizer, tokenizer, prompts, ground_truths,
120               group_size=8):
121    responses, _ = sample_groups(policy_model, tokenizer, prompts, group_size)
122    rewards = score_responses(responses, ground_truths, group_size, policy_model.device)
123    advantages = group_advantages(rewards, group_size)
124 
125    batch = tokenizer(responses, padding=True, return_tensors="pt")
126    batch = {key: value.to(policy_model.device) for key, value in batch.items()}
127    batch["labels"] = batch["input_ids"].clone()
128 
129    with torch.no_grad():
130        old_logprobs = sequence_logprob(
131            policy_model,
132            batch["input_ids"],
133            batch["attention_mask"],
134            batch["labels"],
135        )
136 
137    loss, metrics = grpo_loss(policy_model, ref_model, batch, old_logprobs, advantages)
138    optimizer.zero_grad()
139    loss.backward()
140    optimizer.step()
141    return metrics
142 
143 
144# [H] GRPO 训练循环:每轮都在线生成新回答
145def train_grpo(policy_model, ref_model, optimizer, tokenizer, dataloader):
146    ref_model.eval()
147    for prompts, ground_truths in dataloader:
148        metrics = train_step(
149            policy_model,
150            ref_model,
151            optimizer,
152            tokenizer,
153            prompts,
154            ground_truths,
155        )
156        print("loss=", float(metrics["loss"]), "kl=", float(metrics["approx_kl"]))

推理步骤的变化

GRPO 训练最令人兴奋的观察是模型推理方式的变化:

训练前(直接猜答案):

题目:小明有 15 个苹果,给了小红 3 个,又给了小刚 5 个,还剩多少个?
回答:我觉得还剩 7 个。\boxed{7}

训练后(展示推理过程):

题目:小明有 15 个苹果,给了小红 3 个,又给了小刚 5 个,还剩多少个?
回答:
让我一步一步算:
- 小明一开始有 15 个苹果
- 给了小红 3 个:15 - 3 = 12
- 又给了小刚 5 个:12 - 5 = 7
- 所以还剩 7 个
\boxed{7}

模型从"直接猜答案"变成了"先列算式再计算"——这不是我们教它的,而是模型在 GRPO 训练过程中自己"领悟"出来的。因为展示推理步骤能提高答案正确率(拿到更高的规则奖励),所以 GRPO 的优化压力自然地选择了这条路径。

Mermaid diagram

实验对比与参数调优

显存占用对比

模型大小PPO 显存(4 模型)GRPO 显存(2 模型)节省比例
1.5B~24 GB~14 GB~42%
7B~80 GB~48 GB~40%
14B~160 GB~96 GB~40%
70B~640 GB~384 GB~40%

GRPO 省掉了 Critic(和 Actor 同等规模)和 RM 两个模型,通常能减少 30-40% 的显存占用。在实际工程中,这意味着原本需要 8 张 A100 的训练任务,现在 5 张就够了。

组内方差的演化

GRPO 的核心创新是用组内归一化替代 Critic。在训练初期,同一个问题的 8 个回答质量差异很大(方差高)。随着训练推进,组内回答质量趋于一致(方差降低),大部分回答都能答对。

训练初期(Episode 10):
  问题 "15 - 3 - 5 = ?" 的 8 个回答:[3, 7, 12, 7, 15, 7, 8, 10]
  组内方差:高(答案五花八门)
  归一化优势:[−1.2, +0.1, +0.8, +0.1, +1.5, +0.1, −0.3, +0.6]

训练中期(Episode 100):
  同一问题的 8 个回答:[7, 7, 7, 8, 7, 7, 7, 7]
  组内方差:低(大部分答对了)
  归一化优势:[0, 0, 0, −0.5, 0, 0, 0, 0]

训练后期(Episode 300):
  同一问题的 8 个回答:[7, 7, 7, 7, 7, 7, 7, 7]
  组内方差:接近零(全部答对)
  归一化优势:全部接近零 → 无梯度信号

当组内方差降为零时,优势全部为零,没有梯度信号了——模型在这个问题上"毕业"了。这正是我们想要的行为:训练信号自然地转移到还没掌握的题目上。

k 值的选择

k(组大小)是 GRPO 最关键的超参数,它直接影响组内归一化的质量:

k 值采样成本归一化质量适用场景
2低(每个问题只采 2 次)差(均值和标准差不稳定)快速验证
4中等一般资源有限时
8较高良好默认推荐
16很好(统计量更稳定)追求上限
64很高极好大规模训练
python
# GRPO 组内归一化的简单实现
import numpy as np

def grpo_group_normalize(rewards: list[float]) -> list[float]:
    rewards = np.array(rewards, dtype=float)
    mean, std = rewards.mean(), rewards.std()
    if std < 1e-8:
        return np.zeros_like(rewards)
    return (rewards - mean) / std

# 8 个回答的奖励
rewards = [1.5, 0.0, 1.5, 0.0, 1.0, 1.5, 0.5, 1.5]
advantages = grpo_group_normalize(rewards)
# 归一化优势: [ 0.89 -1.48  0.89 -1.48  0.10  0.89 -0.69  0.89]
# 均值: 0.9375, 标准差: 0.634
思考题:GRPO 的组内归一化在什么情况下会失效?
  1. k 太小 时均值和标准差极不稳定,统计量不可靠。
  2. 奖励分布偏斜:大部分回答得零分时,少数高分回答主导梯度信号。
  3. 所有回答质量相同:方差为零,优势全部为零,无梯度信号——即训练后期"毕业"现象。
  4. 奖励信号不连续:只有 0/1 两个值时,归一化后的优势分布是离散的,梯度信号不够精细。

GRPO 通过 DAPO 的"动态采样"改进来缓解这些问题——过滤掉模型已经答对的题目,只保留有梯度信号的样本。

GRPO 与 PPO 全面对比

组件PPOGRPO
基线(Critic)独立的 网络组内均值
优势计算 或 GAE
模型数量4 个(Actor + Critic + Ref + RM)2 个(Actor + Ref)
裁剪机制PPO Clip同样的 PPO Clip
采样方式在线交互组采样(每个 prompt 采 k 个)
显存低 30-40%
基线质量依赖 Critic 训练质量依赖组大小
基线更新速度需要重新训练 Critic自动随 batch 更新

值得注意的是,GRPO 继承了 PPO 的裁剪机制,但没有继承 GAE。原因是 GRPO 的奖励通常只在序列末尾给出一个信号(答对/答错),而不是每个 token 都有奖励。在这种情况下,GAE 的多步 TD 退化为单步,和直接用最终奖励减去均值没有本质区别。

GRPO 通过组内归一化优雅地解决了 Critic 的问题。但这只是第一步——在策略端,DeepSeek-R1-Zero 证明了不需要 SFT 也能做纯 RL 训练,DAPO 进一步优化了 GRPO 的工程效率。让我们看看这些前沿进展——DeepSeek-R1 与 DAPO

现代强化学习实战课程

个人非商业离线镜像 · 内容版权归 WalkingLabs 所有,依 CC BY-NC-SA 4.0 授权 · 查看原文