GRPO:比PPO更简单的RLHF算法

15 阅读14分钟

DeepSeek的创新:不需要Critic,用组内对比代替


📚 目录

  1. GRPO是什么:PPO的简化版
  2. PPO的问题:为什么需要改进
  3. GRPO的核心创新:组内对比
  4. 详细机制:从公式到代码
  5. 对比PPO:优势与权衡
  6. 代码实现

📌 前置概念:从PPO到GRPO

GRPO在RLHF中的位置

大模型对齐(Alignment)
└─ RLHF方法
    └─ 阶段3: RL微调
        ├─ PPO(2017年,OpenAI)← 主流方法
        │   组件:Actor + Critic + RM + Reference
        │   问题:需要训练Critic,计算开销大
        │
        └─ GRPO(2024年,DeepSeek)← 本文重点
            组件:Actor + RM + Reference(不需要Critic!)
            创新:用组内对比代替Critic

一句话总结

GRPO = PPO - Critic + 组内对比

PPO:
  需要4个组件(Actor、Critic、RM、Reference)
  Critic用来计算Advantage(优势函数)

GRPO:
  只需要3个组件(Actor、RM、Reference)
  用组内对比计算Advantage,不需要Critic

结果:
  ✓ 简单:少训练一个模型(Critic)
  ✓ 快速:减少50%的前向传播
  ✓ 有效:效果和PPO差不多

🤔 Part 1: PPO的问题 —— 为什么需要改进

1.1 回顾PPO的流程

PPO训练一次的流程:

1. Actor生成回答
   "什么是黑洞?" → "黑洞是引力极强的天体..."

2. RM打分
   reward = 8.5分

3. Critic预测
   value = 8.0分

4. 计算Advantage
   advantage = reward - value = 8.5 - 8.0 = 0.5

5. 用Advantage更新Actor
   增加这个回答的概率(因为advantage>0)

1.2 Critic的问题

问题1:需要额外训练一个大模型

Critic是什么?
- 和Actor同样大小的模型(比如7B参数)
- Base模型 + Value Head

开销:
- 显存:需要加载两个7B模型(Actor + Critic)
- 计算:每次前向都要跑两个模型
- 训练:Critic也要训练(MSE loss)

例子:
Actor (7B) + Critic (7B) = 14B参数
→ 需要至少28GB显存(FP16)

问题2:Critic预测不准

Critic的任务:
预测"这个回答能得多少分"

问题:
训练初期Critic很不准
→ value预测偏差大
→ advantage = reward - value 不可靠
→ Actor得到错误的训练信号

例子:
实际reward = 8.5分
Critic预测 = 6.0分(预测太低)
advantage = 8.5 - 6.0 = 2.5(被夸大了)
→ Actor过度增加这个回答的概率

问题3:两个模型要同步训练

PPO需要同时训练:
- Actor:根据advantage更新
- Critic:根据预测误差更新

问题:
- 训练不稳定(两个模型互相影响)
- 超参数多(两个学习率、两个优化器)
- 调试困难(不知道是哪个模型的问题)

1.3 核心问题

Critic的本质作用:提供一个"baseline"

Advantage = reward - baseline

作用:
- 降低方差(variance reduction)
- 让训练更稳定

但代价:
- 需要训练一个大模型
- 预测不准反而有害
- 增加计算开销

思考:
能不能用更简单的方式提供baseline?
→ GRPO的答案:用组内对比!

💡 Part 2: GRPO的核心创新 —— 组内对比

2.1 核心思想

不用Critic,而是让多个回答互相对比

PPO的方式(绝对评分):
────────────────────────────
问题:"什么是黑洞?"
回答A:"黑洞是引力极强的天体..."

RM打分:8.5分
Critic预测:8.0分
Advantage = 8.5 - 8.0 = 0.5

问题:需要Critic


GRPO的方式(相对对比):
────────────────────────────
问题:"什么是黑洞?"

让Actor生成4个不同的回答:
回答A:"黑洞是引力极强的天体..." → RM打分:8.5
回答B:"黑洞是一种天体" → RM打分:7.0
回答C:"不知道" → RM打分:3.0
回答D:"黑洞是时空的弯曲..." → RM打分:8.0

计算平均分(baseline):
avg_reward = (8.5 + 7.0 + 3.0 + 8.0) / 4 = 6.625

计算Advantage(和组内平均比):
Advantage_A = 8.5 - 6.625 = +1.875(好于平均)
Advantage_B = 7.0 - 6.625 = +0.375(略好)
Advantage_C = 3.0 - 6.625 = -3.625(很差)
Advantage_D = 8.0 - 6.625 = +1.375(好)

更新Actor:
- 增加A和D的概率(advantage>0)
- 降低C的概率(advantage<0)

关键:baseline来自组内平均,不需要Critic!

2.2 类比:考试成绩的评价

PPO的方式(需要老师预测):

小明考了85分

老师预测:"这道题平均能考80分"(Critic)
小明表现 = 85 - 80 = +5分(好于预期)

问题:
- 需要一个"老师"(Critic模型)
- 老师的预测可能不准

GRPO的方式(用同学对比):

小明考了85分
让小明再考3次(同一道题):
第1次:85分
第2次:70分
第3次:60分
第4次:80分

班级平均(小明自己的4次)= (85+70+60+80)/4 = 73.75分
小明第1次表现 = 85 - 73.75 = +11.25(好于自己的平均)

好处:
- 不需要"老师"
- 用自己和自己比,更客观

2.3 为什么组内对比有效?

理论基础:Self-Baseline

关键洞察:
Actor当前的平均表现 = 很好的baseline

原因:
1. 来自Actor自己的分布
   - 不是外部预测(Critic)
   - 是Actor的真实能力

2. 自动归一化
   - 好的回答 → advantage > 0
   - 差的回答 → advantage < 0
   - 平均advantage = 0(数学保证)

3. 降低方差
   - 和Critic效果一样
   - 但不需要训练额外模型

数学表达:

PPO:
  Advantage = R(s,a) - V(s)
  其中V(s)由Critic预测

GRPO:
  Advantage = R(s,a) - mean(R(s, a₁), R(s, a₂), ..., R(s, aₙ))
  其中a₁, a₂, ..., aₙ是Actor对同一个s生成的多个回答

区别:
  PPO的baseline = Critic预测的value
  GRPO的baseline = 组内平均reward

🔧 Part 3: GRPO详细机制

3.1 组(Group)的概念

什么是"组"?
────────────────────────────────
对同一个prompt,生成多个不同的回答

例子:
Prompt: "什么是黑洞?"
Group size = 4(生成4个回答)

Group成员:
1. "黑洞是引力极强的天体..."
2. "黑洞是一种天体"
3. "不知道"
4. "黑洞是时空的弯曲..."4个回答组成一个"组"

Group Size的选择:

Group Size = 1:
- 退化成没有baseline
- 方差大,训练不稳定

Group Size = 4(常用):
- 平衡计算开销和效果
- DeepSeek论文中用的

Group Size = 8:
- baseline更准确
- 但计算开销翻倍

Group Size = ∞(理论上):
- 相当于Critic(期望值)
- 但不现实

3.2 完整训练流程

# 伪代码展示GRPO的一次迭代

# ========== Step 1: 准备Prompts ==========
prompts = ["什么是黑洞?", "如何学习Python?", ...]  # 256个
group_size = 4  # 每个prompt生成4个回答

# ========== Step 2: Actor生成(关键!)==========
all_responses = []
all_log_probs = []

for prompt in prompts:
    group_responses = []
    group_log_probs = []

    # 对同一个prompt,生成多个回答
    for _ in range(group_size):
        response = actor.generate(prompt, do_sample=True)  # 采样生成
        log_prob = actor.get_log_prob(prompt, response)

        group_responses.append(response)
        group_log_probs.append(log_prob)

    all_responses.append(group_responses)
    all_log_probs.append(group_log_probs)

# 结果:
# prompt: "什么是黑洞?"
# responses: [回答1, 回答2, 回答3, 回答4]

# ========== Step 3: RM打分 ==========
all_rewards = []

for prompt, group_responses in zip(prompts, all_responses):
    group_rewards = []

    for response in group_responses:
        reward = reward_model(prompt, response)
        group_rewards.append(reward)

    all_rewards.append(group_rewards)

# 结果:
# group_rewards = [8.5, 7.0, 3.0, 8.0]

# ========== Step 4: Reference计算KL ==========
# (和PPO一样,略)

# ========== Step 5: 计算组内Advantage(核心!)==========
all_advantages = []

for group_rewards in all_rewards:
    # 组内平均作为baseline
    baseline = sum(group_rewards) / len(group_rewards)

    # 每个回答的advantage
    group_advantages = []
    for reward in group_rewards:
        advantage = reward - baseline
        group_advantages.append(advantage)

    all_advantages.append(group_advantages)

# 结果:
# baseline = 6.625
# advantages = [+1.875, +0.375, -3.625, +1.375]
# 注意:sum(advantages) = 0(自动归一化)

# ========== Step 6: 训练Actor(和PPO类似)==========
for epoch in range(ppo_epochs):
    for prompt, group_responses, old_log_probs, advantages in data:
        for response, old_lp, adv in zip(group_responses, old_log_probs, advantages):
            # PPO更新(和之前一样)
            new_lp = actor(prompt, response)
            ratio = torch.exp(new_lp - old_lp)
            ratio_clipped = torch.clamp(ratio, 1-ε, 1+ε)

            loss = -torch.min(ratio * adv, ratio_clipped * adv).mean()
            loss.backward()
            optimizer.step()

# 关键:不需要训练Critic!

3.3 可视化对比

PPO的数据流:

Prompt: "什么是黑洞?"
    ↓
Actor生成1个回答
    ↓
RM打分:8.5
    ↓
Critic预测:8.0  ← 需要Critic模型
    ↓
Advantage = 8.5 - 8.0 = 0.5
    ↓
更新Actor

GRPO的数据流:

Prompt: "什么是黑洞?"
    ↓
Actor生成4个回答
    ↓
RM打分:[8.5, 7.0, 3.0, 8.0]
    ↓
组内平均:6.625  ← 不需要Critic!
    ↓
Advantages = [+1.875, +0.375, -3.625, +1.375]
    ↓
更新Actor(用4个样本)

⚖️ Part 4: GRPO vs PPO

4.1 组件对比

组件PPOGRPO说明
Actor都需要
CriticGRPO不需要
RM都需要
Reference都需要
总数4个3个GRPO少25%

4.2 计算开销对比

假设:7B参数模型,FP16精度

PPO(单个样本):
────────────────────────────
1. Actor生成:7B × 2 bytes = 14GB
2. RM打分:7B × 2 bytes = 14GB
3. Reference:7B × 2 bytes = 14GB
4. Critic预测:7B × 2 bytes = 14GB

峰值显存:~28GB(Actor + Critic同时加载)
前向次数:4次


GRPO(单个样本,group_size=4):
────────────────────────────
1. Actor生成4次:7B × 2 bytes = 14GB
2. RM打分4次:7B × 2 bytes = 14GB
3. Reference 4次:7B × 2 bytes = 14GB
4. 不需要Critic!

峰值显存:~14GB(只需Actor)
前向次数:12次(Actor生成4次 + RM 4次 + Ref 4次)


对比:
────────────────────────────
显存:GRPO节省50%(不需要同时加载Critic)
计算:GRPO多200%(但可以并行)
训练复杂度:GRPO更简单(只训练Actor)

4.3 优势对比

GRPO的优势:

✅ 简单
- 少一个模型(不需要Critic)
- 少一个损失函数(不需要Value loss)
- 少一个优化器

✅ 节省显存
- 不需要同时加载Actor和Critic
- 单机可以训练更大的模型

✅ 训练稳定
- 不需要平衡Actor和Critic的学习率
- 不会因为Critic预测不准导致问题

✅ 自动归一化
- Advantage自动满足:sum(adv) = 0
- 不需要额外的归一化步骤

PPO的优势:

✅ 样本效率更高
- 一个prompt生成1个回答
- GRPO需要生成多个回答

✅ 理论更成熟
- 已有大量研究和实践经验
- GRPO相对较新(2024年)

✅ Critic能学到长期价值
- Critic预测累积奖励
- 理论上能提供更好的baseline

4.4 效果对比

根据DeepSeek的论文:

实验设置:
- 模型:7B参数
- 数据:相同的prompts
- 评价:人类偏好评测

结果:
────────────────────────────
                PPO    GRPO
胜率            48%    52%
训练时间        10h    8h
峰值显存        28GB   14GB
实现复杂度      高     中

结论:
GRPO效果略好,速度更快,显存更少

4.5 何时用GRPO?

推荐用GRPO:
✓ 显存有限(单机训练大模型)
✓ 想要简单实现(不想调Critic)
✓ Critic训练不稳定
✓ 资源有限(不能同时加载两个大模型)

继续用PPO:
✓ 已有成熟的PPO pipeline
✓ 样本效率很重要(数据有限)
✓ 需要非常精确的baseline
✓ 有充足的计算资源

💻 Part 5: 代码实现

5.1 核心代码

import torch
import torch.nn as nn
import torch.nn.functional as F

# ========== GRPO训练函数 ==========

def grpo_train_step(
    actor,
    reward_model,
    reference_model,
    prompts,
    group_size=4,
    epsilon=0.2
):
    """
    GRPO的一次训练步骤

    Args:
        actor: Actor模型
        reward_model: 奖励模型(固定)
        reference_model: 参考模型(固定)
        prompts: 输入的prompts列表
        group_size: 每个prompt生成几个回答
        epsilon: PPO的裁剪阈值
    """

    # ━━━━━ Step 1: 生成多个回答(关键!)━━━━━
    all_responses = []
    all_old_log_probs = []

    for prompt in prompts:
        group_responses = []
        group_log_probs = []

        # 对同一个prompt生成多个回答
        for _ in range(group_size):
            with torch.no_grad():
                # 采样生成(do_sample=True)
                response = actor.generate(
                    prompt,
                    do_sample=True,
                    temperature=1.0
                )
                log_prob = actor.get_log_prob(prompt, response)

            group_responses.append(response)
            group_log_probs.append(log_prob)

        all_responses.append(group_responses)
        all_old_log_probs.append(group_log_probs)

    # ━━━━━ Step 2: RM打分 ━━━━━
    all_rewards = []

    with torch.no_grad():
        for prompt, group_responses in zip(prompts, all_responses):
            group_rewards = []

            for response in group_responses:
                reward = reward_model(prompt, response)
                group_rewards.append(reward)

            all_rewards.append(group_rewards)

    # ━━━━━ Step 3: Reference计算KL ━━━━━
    all_kl_penalties = []
    beta = 0.1  # KL系数

    with torch.no_grad():
        for prompt, group_responses, group_log_probs in zip(
            prompts, all_responses, all_old_log_probs
        ):
            group_kl = []

            for response, log_prob in zip(group_responses, group_log_probs):
                ref_log_prob = reference_model(prompt, response)
                kl = (log_prob - ref_log_prob).sum()
                kl_penalty = beta * kl
                group_kl.append(kl_penalty)

            all_kl_penalties.append(group_kl)

    # ━━━━━ Step 4: 计算总奖励 ━━━━━
    all_total_rewards = []

    for group_rewards, group_kl in zip(all_rewards, all_kl_penalties):
        group_total_rewards = []

        for reward, kl in zip(group_rewards, group_kl):
            total_reward = reward - kl
            group_total_rewards.append(total_reward)

        all_total_rewards.append(group_total_rewards)

    # ━━━━━ Step 5: 计算组内Advantage(核心创新!)━━━━━
    all_advantages = []

    for group_rewards in all_total_rewards:
        # 组内平均作为baseline
        baseline = sum(group_rewards) / len(group_rewards)

        # 计算每个回答的advantage
        group_advantages = []
        for reward in group_rewards:
            advantage = reward - baseline
            group_advantages.append(advantage)

        all_advantages.append(group_advantages)

    # 验证:组内advantage的和应该接近0
    for advantages in all_advantages:
        assert abs(sum(advantages)) < 1e-6, "Advantage should sum to 0"

    # ━━━━━ Step 6: PPO更新Actor ━━━━━
    actor.train()
    total_loss = 0

    for prompt, group_responses, old_log_probs, advantages in zip(
        prompts, all_responses, all_old_log_probs, all_advantages
    ):
        for response, old_lp, adv in zip(group_responses, old_log_probs, advantages):
            # 新策略的log概率
            new_lp = actor.get_log_prob(prompt, response)

            # PPO clip
            ratio = torch.exp(new_lp - old_lp)
            ratio_clipped = torch.clamp(ratio, 1 - epsilon, 1 + epsilon)

            # 损失
            surr1 = ratio * adv
            surr2 = ratio_clipped * adv
            loss = -torch.min(surr1, surr2).mean()

            total_loss += loss

    # 反向传播
    optimizer.zero_grad()
    total_loss.backward()
    optimizer.step()

    return total_loss.item()


# ========== 完整训练循环 ==========

def train_grpo(
    actor,
    reward_model,
    reference_model,
    prompts_dataset,
    num_iterations=1000,
    group_size=4
):
    """
    完整的GRPO训练循环
    """
    optimizer = torch.optim.Adam(actor.parameters(), lr=1e-5)

    for iteration in range(num_iterations):
        # 采样prompts
        prompts = sample_prompts(prompts_dataset, batch_size=64)

        # GRPO训练步骤
        loss = grpo_train_step(
            actor,
            reward_model,
            reference_model,
            prompts,
            group_size=group_size
        )

        if iteration % 10 == 0:
            print(f"Iteration {iteration}: Loss = {loss:.4f}")

        # 保存checkpoint
        if iteration % 100 == 0:
            torch.save(actor.state_dict(), f'actor_iter{iteration}.pt')

    return actor

5.2 关键点解释

# 1. 生成多个回答(最重要的区别)
for _ in range(group_size):
    response = actor.generate(prompt, do_sample=True)  # 采样生成
    # 不能用greedy,否则每次生成一样的

# 2. 组内平均作为baseline
baseline = sum(group_rewards) / len(group_rewards)
advantages = [r - baseline for r in group_rewards]
# 不需要Critic!

# 3. 验证Advantage归一化
assert sum(advantages) ≈ 0  # 数学保证

# 4. 更新和PPO一样
ratio = exp(new_log_prob - old_log_prob)
ratio_clipped = clip(ratio, 1-ε, 1+ε)
loss = -min(ratio * adv, ratio_clipped * adv)

5.3 GRPO vs PPO代码对比

# PPO训练步骤
def ppo_train_step(actor, critic, reward_model, ref, prompts):
    # 1. 生成1个回答
    response = actor.generate(prompt)

    # 2. RM打分
    reward = reward_model(prompt, response)

    # 3. Critic预测(需要Critic!)
    value = critic(prompt, response)

    # 4. 计算Advantage
    advantage = reward - value

    # 5. 更新Actor
    # ... PPO更新 ...

    # 6. 更新Critic(额外的训练步骤)
    critic_loss = (value - reward) ** 2
    critic_loss.backward()


# GRPO训练步骤
def grpo_train_step(actor, reward_model, ref, prompts, group_size):
    # 1. 生成多个回答
    responses = [actor.generate(prompt) for _ in range(group_size)]

    # 2. RM打分
    rewards = [reward_model(prompt, r) for r in responses]

    # 3. 组内平均(不需要Critic!)
    baseline = sum(rewards) / len(rewards)

    # 4. 计算Advantage
    advantages = [r - baseline for r in rewards]

    # 5. 更新Actor
    # ... PPO更新(用多个样本)...

    # 不需要更新Critic!


关键区别:
1. GRPO生成多个回答
2. GRPO不需要Critic
3. GRPO用组内平均作为baseline
4. GRPO不需要训练Critic

🎓 Part 6: 总结

6.1 核心要点

GRPO的创新:

问题:
PPO需要Critic预测baseline
→ 需要额外训练一个大模型
→ 预测可能不准
→ 增加计算开销

GRPO的解决方案:
用组内平均作为baseline
→ 不需要Critic
→ 自动归一化
→ 节省显存和训练时间

公式:
PPO:  Advantage = R(s,a) - V_critic(s)
GRPO: Advantage = R(s,a) - mean(R(s, a₁), ..., R(s, aₙ))

一句话总结:

GRPO = PPO - Critic + 组内对比

6.2 优缺点对比

维度PPOGRPO胜者
模型数量4个3个GRPO
显存占用高(需要Critic)GRPO
训练复杂度复杂(两个模型)简单GRPO
样本效率高(1个回答)低(多个回答)PPO
理论成熟度成熟(2017)较新(2024)PPO
实际效果略好GRPO

6.3 选择建议

用GRPO如果:

✓ 显存有限(比如单张A100训练7B模型)
✓ 想要简单实现
✓ PPO的Critic训练不稳定
✓ 初次尝试RLHF

用PPO如果:

✓ 有成熟的PPO代码库
✓ 样本效率很重要(prompts有限)
✓ 需要非常精确的value估计
✓ 有充足的计算资源

6.4 发展趋势

2017: PPO(OpenAI)
├─ 核心:Clip机制
└─ 问题:需要Critic

2024: GRPO(DeepSeek)
├─ 核心:组内对比
└─ 改进:去掉Critic

未来:
├─ 更简单的方法?
├─ 不需要RM?(DPO、IPO)
└─ 完全离线?(Offline RL)

6.5 快速记忆

记住GRPO的三个关键词:

  1. Group(组)

    • 对同一个prompt生成多个回答
  2. Relative(相对)

    • 用组内对比,不用绝对评分
  3. No Critic(不需要Critic)

    • 用组内平均作为baseline

记住GRPO的核心公式:

# 组内平均
baseline = mean(rewards)

# Advantage
advantages = [r - baseline for r in rewards]

# 自动归一化
assert sum(advantages) == 0

🤔 Part 7: 常见问题

Q1: Group Size选多大?

Group Size = 1:
- 退化成没有baseline
- 方差大 ❌

Group Size = 2:
- baseline不够稳定
- 效果一般

Group Size = 4(推荐):
- DeepSeek论文用的
- 平衡效果和开销 ✅

Group Size = 8:
- baseline更稳定
- 但计算开销翻倍
- 如果资源充足可以用

Group Size太大:
- 计算开销线性增长
- 收益递减

Q2: GRPO比PPO快吗?

显存:
GRPO更少(不需要Critic)
单张A100可以训练更大的模型

计算量:
GRPO更多(生成多个回答)
但可以并行(batch inference)

实际训练时间:
取决于瓶颈是显存还是计算
- 显存瓶颈:GRPO更快(可以更大batch)
- 计算瓶颈:差不多(并行抵消开销)

总体:GRPO通常更快(因为显存是瓶颈)

Q3: GRPO的baseline准确吗?

理论分析:

PPO的Critic:
- 优点:学习到的期望值
- 缺点:预测可能不准,需要训练

GRPO的组内平均:
- 优点:真实的采样平均
- 缺点:只有N个样本(N=group_size)

当N足够大时:
GRPO的baseline ≈ PPO的Critic预测值

实践中:
N=4就足够好(DeepSeek实验证明)

Q4: 能不能每个prompt生成不同数量的回答?

可以,但不推荐

固定Group Size(推荐):
- 实现简单
- 批处理效率高
- 每个prompt的baseline质量一致

动态Group Size:
- 实现复杂
- 难以批处理
- 不同prompt的advantage不可比
- 收益不大

Q5: GRPO和DPO有什么区别?

DPO(Direct Preference Optimization):
- 完全不用RL
- 直接优化偏好对比
- 不需要RM
- 更简单,但效果可能略差

GRPO(Group Relative Policy Optimization):
- 还是RL(PPO框架)
- 需要RM
- 去掉Critic
- 效果和PPO接近

选择:
- 想最简单 → DPO
- 想效果好 → GRPO或PPO
- 有大量计算资源 → PPO