Skip to content

7.4 动手:GRPO 训练与核心机制

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

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

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

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

本节沿着一次完整的 GRPO 训练轨迹来讲:先看同题多答怎样产生组内比较,再解释 Critic 和基线,接着写出优势、概率比值和裁剪目标,最后回到手写代码和 TRL 的 GRPOTrainer

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

用一个带数字的小例子走一遍。假设题目是:

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

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

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

这 4 个分数的平均分是:

1.5+1.0+0.0+0.04=0.625\frac{1.5+1.0+0.0+0.0}{4}=0.625

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

回答和平均分比较之后怎么学
11.50.625=+0.8751.5-0.625=+0.875明显比平均好,以后多生成它
21.00.625=+0.3751.0-0.625=+0.375也比平均好,稍微多生成它
30.00.625=0.6250.0-0.625=-0.625比平均差,以后少生成它
40.00.625=0.6250.0-0.625=-0.625比平均差,以后少生成它

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

接下来先定义 Critic,再看为什么“和同题其他回答比一比”可以替代 Critic。

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

Vϕ(st)V_\phi(s_t)

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

问题在于,LLM 场景里的 Critic 往往很贵也很难训。它通常也是一个大模型,需要额外显存;而且它要从一段还没写完的文本预测最终奖励,监督信号又常常只在回答末尾出现,所以训练噪声很大。GRPO 想做的事情就是:不再单独训练这个 Critic,而是用同一个 prompt 下多个回答的平均分,临时充当“基线”。这就是后面“组内归一化能替代 Critic”的核心来源。

同题多答:把平均分当临时基线

GRPO 的训练从同题多答开始。给定同一个 prompt,模型一次生成多个回答;奖励函数分别给这些回答打分;然后把这一组回答的平均分当作临时基线。高于平均分的回答会被鼓励,低于平均分的回答会被抑制。

为了把这个过程写成强化学习问题,先对应几个基本术语:

强化学习概念在数学推理模型里是什么
状态 sts_t题目 prompt 加上已经写出的推理步骤,也就是 (x,y<t)(x,y_{<t})
动作 ata_t下一步生成的 token,也就是 yty_t
轨迹 τ\tau一整段推理过程和最终答案
奖励 RR答案是否正确、格式是否符合要求
策略 π\pi当前正在训练的语言模型

如果照 PPO 的路线走,模型会生成回答,奖励函数给最终答案打分,Critic 再估计一个基线 Vϕ(st)V_\phi(s_t),优势大致写成:

AtRVϕ(st)A_t \approx R - V_\phi(s_t)

这句话的意思是:不要只看奖励高不高,要看它有没有比 Critic 的预期更好。这在传统强化学习里很自然,但在 LLM 数学推理里就很重:Critic 也是大模型,还要从未完成的推理文本预测最终得分。

GRPO 的想法是保留 PPO 最有用的部分,也就是 ratio + clip,但把 Critic 基线换成“同一道题的一组回答的平均分”。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 不是一个新的模型,也不只是“组内归一化”这个公式。GRPO 是一种在线训练策略模型的方法。

在 GRPO 里,被训练的对象仍然是语言模型策略:

πθ(yx)\pi_\theta(y \mid x)

这里 xx 是题目或 prompt,yy 是模型生成的回答。GRPO 的训练方式是:对同一个 prompt 一次生成多个回答,把这些回答放在同一组里打分,并比较:这个回答在同组里是否高于平均水平?

如果某个回答比同组平均水平好,它的优势 AiA_i 就是正的,训练会提高它的概率;如果比同组平均水平差,优势就是负的,训练会降低它的概率。最后更新策略时,GRPO 仍然会使用 PPO-style 的 ratio + clip,避免新策略离旧策略太远。

所以 GRPO 可以先理解成:

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

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

这就是“组内相对优势”的直觉:同一道题里,谁比平均好,就多学谁;谁比平均差,就少生成谁

下面是一份最小手写 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如何给一整段回答算 logπθ(yx)\log \pi_\theta(y\mid x)
[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
# GRPO:同一个 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 基线换成了组内平均基线:原来问“这个回答比 Critic 预期好吗”,现在问“这个回答比同题其他回答好吗”。

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

  1. GRPOTrainer 的初始化参数里有 reward_funcs,它可以是奖励模型,也可以是普通 Python 函数。也就是说,数学题这类任务可以直接用规则函数打分,不一定要先训练 RM。
  2. self.num_generations = args.num_generations 对应公式里的 GG,也就是每个 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 的一组样本长什么样

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

xj{yj,1,yj,2,,yj,G}x_j \quad\longrightarrow\quad \{y_{j,1},y_{j,2},\ldots,y_{j,G}\}

这里每个字母的意思是:

  • xjx_j:第 jj 个 prompt,也就是一道题或一个问题。
  • GG:group size,每个 prompt 生成几个回答。代码里的 num_generations=8 就是 G=8G=8
  • yj,iy_{j,i}:第 jj 个 prompt 下生成的第 ii 个回答。
  • πold\pi_{\text{old}}:生成这批回答时使用的旧策略。它负责采样数据。
  • πθ\pi_\theta:正在被更新的新策略。它负责学习,让好回答更可能出现。

采样过程可以写成:

yj,iπold(xj),i=1,,Gy_{j,i} \sim \pi_{\text{old}}(\cdot \mid x_j), \qquad i=1,\ldots,G

符号 \sim 表示“从某个分布中采样”。πold(xj)\pi_{\text{old}}(\cdot \mid x_j) 里的点号 \cdot 表示“所有可能回答的位置”,意思是:给定 prompt xjx_j,旧策略会给所有可能回答分配概率,然后我们从这个分布里抽出 GG 个回答。

代码里对应的是 [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

每个回答生成后,都要得到一个奖励:

rj,i=R(xj,yj,i)r_{j,i} = R(x_j,y_{j,i})

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

7.4.1 GRPO 训练实验

实验设置:GSM8K + 规则奖励

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

  • 答案正确:+1.0+1.0
  • 格式规范(有清晰的推理步骤):+0.5+0.5
  • 答案错误:00
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 的核心思想(8.3 节会深入讨论)。

在手写代码地图中,奖励函数对应的是 [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")

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],  # 直接传入规则奖励函数
    tokenizer=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 的优化压力自然地选择了这条路径。

7.4.2 组内归一化:为什么能替代 Critic

上面我们看到了"省掉 Critic"的实际效果——显存减少 30-40%,推理步骤从"猜答案"变成"列算式"。但一个核心问题还没有回答:组内归一化为什么能替代 Critic 的工作?

PPO Critic 的三大问题

在回答"为什么能替代"之前,先说清楚"为什么要替代"。PPO 的 Critic 在 LLM 训练中面临三个严重问题:

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

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

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

回顾第 5 章,Critic 的核心作用是提供基线来降低方差。如果不需要单独训练网络就能得到基线,Critic 就可以退休了。

GRPO 的核心思路

GRPO 的想法出奇地简单。对于同一个问题 xjx_j,先采样 GG 个回答,再得到 GG 个奖励:

{rj,1,rj,2,,rj,G}\{r_{j,1},r_{j,2},\ldots,r_{j,G}\}

然后计算这组奖励的均值:

rˉj=1Gi=1Grj,i\bar r_j = \frac{1}{G} \sum_{i=1}^{G} r_{j,i}

这里:

  • rˉj\bar r_j:第 jj 个 prompt 这一组回答的平均奖励。
  • 1Gi=1G\frac{1}{G}\sum_{i=1}^G:把 GG 个回答的奖励加起来,再除以 GG
  • rj,ir_{j,i}:第 jj 个 prompt 下第 ii 个回答的奖励。

再计算这组奖励的标准差:

sj=1Gi=1G(rj,irˉj)2s_j = \sqrt{ \frac{1}{G} \sum_{i=1}^{G} (r_{j,i}-\bar r_j)^2 }

这里:

  • sjs_j:第 jj 组奖励的标准差,表示这一组回答分数差异有多大。
  • (rj,irˉj)2(r_{j,i}-\bar r_j)^2:第 ii 个回答离平均水平有多远,平方是为了不让正负抵消。
  • \sqrt{\cdot}:开方,把尺度变回奖励原来的尺度附近。

最后得到第 ii 个回答的组内优势:

A^j,i=rj,irˉjsj+ϵ\hat A_{j,i} = \frac{ r_{j,i}-\bar r_j }{ s_j+\epsilon }

这就是 GRPO 的核心公式。它读起来很朴素:

  • rj,irˉjr_{j,i}-\bar r_j:这个回答比同题平均水平高多少。
  • 除以 sj+ϵs_j+\epsilon:把不同题目的奖励尺度拉到相近范围。
  • ϵ\epsilon:一个很小的数,防止标准差为 0 时除以 0。
  • A^j,i>0\hat A_{j,i}>0:这个回答比同组平均好,应该提高概率。
  • A^j,i<0\hat A_{j,i}<0:这个回答比同组平均差,应该降低概率。
  • A^j,i0\hat A_{j,i}\approx 0:这个回答和平均水平差不多,不需要太强更新。

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

这个公式做的事情和 Critic 很像——减去均值就是“比平均好了多少”。只不过 Critic 用一个单独的神经网络来预测基线 V(s)V(s),而 GRPO 直接用同一组回答的实际得分均值 rˉj\bar r_j 当基线。

代码里对应的是 [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、每行 GG 个回答”的形状。
  • group_mean = grouped_rewards.mean(dim=1, keepdim=True):计算每个 prompt 的 rˉj\bar r_j
  • group_std = grouped_rewards.std(dim=1, keepdim=True):计算每个 prompt 的 sjs_j
  • advantages = (grouped_rewards - group_mean) / (group_std + eps):实现 A^j,i\hat A_{j,i}
  • torch.where(group_std < eps, 0, advantages):如果一组回答没有差异,就不给这组样本训练信号。

组内归一化有效的原因有三个:

难度归一化:不同题目的难度不同。简单题所有回答都正确(奖励均值很高),难题大部分回答都错误(奖励均值很低)。如果用绝对奖励,简单题的回答会获得更高的梯度信号,模型会把大部分精力花在简单题上。组内归一化消除了这种偏差——它只关注"这道题内部谁更好",不受题目绝对难度的影响。

相对比较更稳定:人类偏好本质上也是比较式的("A 比 B 好"),不是绝对的("A 得 87 分")。GRPO 的组内比较和人类的判断方式天然一致。

方差更低:同一组内的回答共享相同的 prompt,唯一的差异是模型生成的随机性。这种"控制变量"式的比较比跨样本的绝对评分更稳定。

一句话总结:GRPO = PPO 的裁剪机制 + 用组内排名替代 Critic

“PPO 的裁剪机制”在代码里仍然是 ratioclampmin,只不过这里的 advantages 来自组内比较。

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

ρj,i(θ)=πθ(yj,ixj)πold(yj,ixj)\rho_{j,i}(\theta) = \frac{ \pi_\theta(y_{j,i}\mid x_j) }{ \pi_{\text{old}}(y_{j,i}\mid x_j) }

这里:

  • πold(yj,ixj)\pi_{\text{old}}(y_{j,i}\mid x_j):采样这条回答时,旧策略给它的概率。
  • πθ(yj,ixj)\pi_\theta(y_{j,i}\mid x_j):当前正在更新的新策略给它的概率。
  • ρj,i(θ)\rho_{j,i}(\theta):新策略相对旧策略,把这条回答的概率放大或缩小了多少倍。

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

ρj,i(θ)=exp(logπθ(yj,ixj)logπold(yj,ixj))\rho_{j,i}(\theta) = \exp \left( \log \pi_\theta(y_{j,i}\mid x_j) - \log \pi_{\text{old}}(y_{j,i}\mid x_j) \right)

如果 ρ=1\rho=1,说明新旧策略对这条回答的概率一样;如果 ρ=1.2\rho=1.2,说明新策略把它的概率提高了 20%;如果 ρ=0.8\rho=0.8,说明新策略把它的概率压低了 20%。

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

JGRPOclip(θ)=Ej,i[min(ρj,i(θ)A^j,i,clip(ρj,i(θ),1ϵclip,1+ϵclip)A^j,i)]\mathcal{J}_{\text{GRPO}}^{\text{clip}}(\theta) = \mathbb{E}_{j,i} \left[ \min \left( \rho_{j,i}(\theta)\hat A_{j,i}, \operatorname{clip} (\rho_{j,i}(\theta),1-\epsilon_{\text{clip}},1+\epsilon_{\text{clip}}) \hat A_{j,i} \right) \right]

每个符号的意思是:

  • Ej,i\mathbb{E}_{j,i}:对 batch 里的所有 prompt 和所有组内回答取平均。
  • A^j,i\hat A_{j,i}:刚才算出的组内优势。
  • ϵclip\epsilon_{\text{clip}}:裁剪范围,常见值是 0.2。
  • clip(ρ,1ϵclip,1+ϵclip)\operatorname{clip}(\rho,1-\epsilon_{\text{clip}},1+\epsilon_{\text{clip}}):把概率比值限制在一个区间内。例如 ϵclip=0.2\epsilon_{\text{clip}}=0.2 时,ρ\rho 会被限制在 [0.8,1.2][0.8,1.2]
  • min(,)\min(\cdot,\cdot):选择更保守的那个目标,避免一次更新太大。

为什么要裁剪?因为这批回答是 πold\pi_{\text{old}} 生成的。如果训练几步后 πθ\pi_\theta 已经离 πold\pi_{\text{old}} 很远,那么这批数据就不再能可靠代表新策略的行为。裁剪的作用就是:允许模型学习,但不允许它因为同一批数据一下子改得太猛

 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_logprobslogπθ(yj,ixj)\log \pi_\theta(y_{j,i}\mid x_j)old_logprobslogπold(yj,ixj)\log \pi_{\text{old}}(y_{j,i}\mid x_j)。所以:

  • ratio = torch.exp(new_logprobs - old_logprobs):实现 ρj,i(θ)\rho_{j,i}(\theta)
  • surr1 = ratio * advantages:不裁剪时的策略目标。
  • clipped_ratio = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps):把 ρ\rho 限制在 [1ϵclip,1+ϵclip][1-\epsilon_{\text{clip}},1+\epsilon_{\text{clip}}]
  • surr2 = clipped_ratio * advantages:裁剪后的策略目标。
  • policy_loss = -torch.min(surr1, surr2).mean():取保守目标,并加负号变成要最小化的 loss。

注意上面的 JGRPOclip\mathcal{J}_{\text{GRPO}}^{\text{clip}} 是“想最大化”的目标;代码里的优化器默认最小化 loss,所以会写成:

policy_loss=JGRPOclip(θ)\text{policy\_loss} = - \mathcal{J}_{\text{GRPO}}^{\text{clip}}(\theta)

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

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

D^KL=exp(Δ)Δ1,Δ=logπref(yx)logπθ(yx)\widehat D_{\text{KL}} = \exp(\Delta) - \Delta - 1, \qquad \Delta = \log \pi_{\text{ref}}(y\mid x) - \log \pi_\theta(y\mid x)

这个近似有一个好性质:当 Policy 和 Reference 给出的 log probability 一样时,Δ=0\Delta=0,于是

exp(0)01=0\exp(0)-0-1=0

也就是说,两个模型越接近,KL 惩罚越小;偏离越大,惩罚越大。

最后总损失可以写成:

LGRPO=JGRPOclip(θ)+βKLD^KL\mathcal{L}_{\text{GRPO}} = - \mathcal{J}_{\text{GRPO}}^{\text{clip}}(\theta) + \beta_{\text{KL}} \widehat D_{\text{KL}}

这里 βKL\beta_{\text{KL}} 是 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:实现 Δ\Delta
  • approx_kl = (torch.exp(log_ratio_ref) - log_ratio_ref - 1.0).mean():实现 D^KL\widehat D_{\text{KL}}
  • loss = policy_loss + kl_coef * approx_kl:实现总损失 LGRPO\mathcal{L}_{\text{GRPO}}

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

  1. 对每个 prompt 采样 GG 个回答。
  2. 用规则或奖励函数给每个回答打分。
  3. 在同一个 prompt 的组内计算 rˉj\bar r_jsjs_jA^j,i\hat A_{j,i}
  4. 用新旧策略 log probability 算 ρj,i(θ)\rho_{j,i}(\theta)
  5. 用 PPO-style clip 控制更新幅度。
  6. 加上 Reference KL 惩罚。
  7. 反向传播,只更新 Policy。

7.4.3 实验对比与参数调优

显存占用对比

模型大小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.82 -1.23  0.82 -1.23  0.12  0.82 -0.53  0.82]
# 均值: 0.875, 标准差: 0.637
思考题:GRPO 的组内归一化在什么情况下会失效?
  1. k 太小k=2k=2 时均值和标准差极不稳定,统计量不可靠。
  2. 奖励分布偏斜:大部分回答得零分时,少数高分回答主导梯度信号。
  3. 所有回答质量相同:方差为零,优势全部为零,无梯度信号——即训练后期"毕业"现象。
  4. 奖励信号不连续:只有 0/1 两个值时,归一化后的优势分布是离散的,梯度信号不够精细。

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

GRPO 与 PPO 全面对比

组件PPOGRPO
基线(Critic)独立的 V(s)V(s) 网络组内均值 rˉ\bar{r}
优势计算A=RV(s)A = R - V(s) 或 GAEAi=(rirˉ)/σrA_i = (r_i - \bar{r}) / \sigma_r
模型数量4 个(Actor + Critic + Ref + RM)2 个(Actor + Ref)
裁剪机制PPO Clip同样的 PPO Clip
采样方式在线交互组采样(每个 prompt 采 k 个)
显存低 30-40%
基线质量依赖 Critic 训练质量依赖组大小 kk
基线更新速度需要重新训练 Critic自动随 batch 更新

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

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

Built for reusable bilingual course delivery