强化学习:GRPO 与 PPO

阶段 8 · MiniMind 训练路线图最后一站——从"听话"到"优秀"

DPO 的天花板

你已经用 SFT + DPO 训练了一个能听懂指令的模型。但你发现了一个问题——

DPO 只能做"选择题"。

它给模型一个"好回答"和一个"差回答",让模型学会区分。但现实世界中,好的回答不止一种。模型需要自己去探索、试错,找到更好的表达方式,而不只是在两个选项之间做判断。

强化学习(RL)引入了一个新角色:Reward Model(奖励模型)。它给每次回答打分,模型通过试错,真正"学会什么是好"。

这个比喻只是为了帮你建立直观感受。在代码层面,Reward Model 是一个经过训练的神经网络,输入一段对话文本,输出一个浮点数(通常在 -3 到 +3 之间),代表对回答质量的评估。

训练三阶段

图:大模型训练三阶段与偏好对齐的两条路径

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右的流程。浅蓝色框"预训练 Pretrain"内含"读万卷书,学会语言规律";浅橙色框"SFT 微调"内含"学会听指令,按格式回答";浅粉色框"偏好对齐"内含两条路径:上路径"DPO: 二选一"和下路径"RL: 试错优化",两条路径汇入绿色框"高质量 AI 助手" - 各元素关系:预训练 → SFT → 偏好对齐,用"能说话""能回答"标注过渡箭头,DPO和RL两条路径并行 - 关键标注:RL路径标注"本章重点",绿色终点框高亮 - 配色建议:浅灰背景,预训练浅蓝,SFT浅橙,对齐浅粉,终点绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

DPO 是"告诉你哪个更好",RL 是"让你自己试,做对了奖励,做错了惩罚"。后者的探索空间大得多。

三种训练范式

阶段学习方式需要的数据能学会什么
SFT模仿标准答案问答对怎么回答
DPO在好/差之间选偏好对哪个更好
RL试错 + 奖励只需 Prompt如何更好

注意 RL 的数据需求:只需要 Prompt(问题),不需要标准答案。模型自己生成回答,奖励模型打分,用分数引导更新。这意味着你可以用 RL 来优化那些"没有唯一正确答案"的任务。

Part 1: 核心概念

在写代码之前,先搞懂三个核心词:策略、奖励、优势。它们会在后面的代码里反复出现。

这一节是整个 RL 训练的"字典"。后面每个代码片段都会用到这三个概念——策略决定"怎么回答",奖励告诉你"回答好不好",优势衡量"比平均水平好多少"。先把它们搞清楚,后面读代码就不会卡壳。

  • 策略(Policy):就是模型本身,不同的权重 = 不同的回答风格。我们会看到训练代码中如何区分"当前策略"和"冻结的参考策略"
  • 奖励(Reward):一个标量分数,衡量回答质量。MiniMind 用长度、思考、重复率、RM 评分四种信号叠加而成
  • 优势(Advantage):"比平均好多少"的相对指标,是策略更新的核心信号。GRPO 用组内标准化、PPO 用 GAE 来计算

策略 Policy

定义

策略是一个条件概率分布。给定 Prompt x,它输出回答 y 的概率 P(y|x)。

白话

说白了,策略就是模型本身。它决定了"看到这个问题,我会怎么回答"。不同的模型权重 = 不同的策略 = 不同的回答风格。

代码

# train_grpo.py: 策略就是我们的 model
model, tokenizer = init_model(lm_config, base_weight, device=args.device)

# Reference Model 是训练前的原始策略,冻结不动
ref_model, _ = init_model(lm_config, base_weight, device=args.device)
ref_model = ref_model.eval().requires_grad_(False)

ref_model 是"参考模型",冻结不参与训练。它的作用是约束策略更新幅度——防止模型"学得太野,忘了自己是谁"。后面计算 KL 散度时会用到它。

奖励 Reward

定义

奖励是一个标量分数,衡量回答质量。越高表示越好。

白话

就像老师给作文打分。分数高的回答,模型以后多生成;分数低的,少生成。RL 训练的目标就是让模型生成的回答获得的奖励越来越高。

这个比喻只是为了帮你建立直观感受。在代码中,奖励信号是一个数值标量,用于在策略梯度更新中缩放每个 token 的对数概率梯度。

代码

# train_grpo.py: calculate_rewards() —— 四种信号叠加
rewards = torch.zeros(len(responses), device=args.device)
rewards[i] += 0.5 if 20 <= len(response.strip()) <= 800 else -0.5  # 长度
rewards[i] -= rep_penalty(answer)                                 # 重复惩罚
score = reward_model.get_score(messages, answer)                  # RM 评分
rewards += reward_model_scores                                    # 汇总

优势函数

定义

优势衡量"这个回答比平均水平好多少"。它是策略更新的核心信号。

白话

班上平均分 70,你考了 90,优势就是 +20。优势为正,说明这个行为值得鼓励;为负,就该抑制。关键在于"比平均好多少",而不是绝对分数。

GRPO 的计算方式

# train_grpo.py: 同一 Prompt 生成 6 个回答,组内标准化
grouped_rewards = rewards.view(-1, args.num_generations)  # [B, 6]
mean_r = grouped_rewards.mean(dim=1).repeat_interleave(args.num_generations)
std_r = grouped_rewards.std(dim=1, unbiased=False).repeat_interleave(args.num_generations)
advantages = (rewards - mean_r) / (std_r + 1e-4)  # 标准化

GRPO 的优势是"组内相对排名"——不需要额外模型,直接用同一组的均值和标准差来归一化。PPO 的优势则用 Critic 模型 + GAE 来估计,我们后面会详细讲。

Part 2: 训练循环

不管 GRPO 还是 PPO,训练循环都遵循同样的四步。理解这个循环,就理解了 RL 训练的骨架。

这一节把 RL 训练拆解成一个通用的四步循环:生成、评分、算优势、更新策略。无论后面学 GRPO 还是 PPO,底层都是这个骨架在跑。搞清楚每一步的输入输出,后面学具体算法就是往骨架里填细节。

  • 四步循环全景:生成 Rollout → 评分 Scoring → 优势计算 → 策略更新,第 3 步是 GRPO 和 PPO 的核心分歧点
  • Rollout 生成:从数据集取 Prompt,模型自己生成回答,同时记录每个 token 的对数概率 old_logp
  • 数据准备:RL 只需要 Prompt,不需要标准答案。RLAIFDataset 会随机决定是否开启思考模式

四步循环

图:RL 训练的四步循环

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从上到下四个彩色区域组成循环。第一区蓝色"1. 生成 Rollout"内含"取出Prompt → 模型生成回答 → 记录old_logp";第二区橙色"2. 评分 Scoring"内含"长度+思考+重复 → Reward Model评分 → 汇总为总Reward";第三区紫色"3. 优势计算"内含两条路径"GRPO: 组内标准化"和"PPO: GAE估计";第四区红色"4. 策略更新"内含"计算新logp → Clipped Loss + KL惩罚 → backward更新参数" - 各元素关系:四个区域从上到下依次用箭头连接,形成循环 - 关键标注:第3步标注"GRPO和PPO的核心分歧点" - 配色建议:浅灰背景,步骤1蓝色,步骤2橙色,步骤3紫色,步骤4红色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

这四步循环在每个 training step 中重复执行。第 3 步是 GRPO 和 PPO 的核心分歧点。

Step 1: 生成

从数据集取出 Prompt,让模型自己生成回答。同时记录下每个 token 的对数概率(old_logp)。

# train_grpo.py: rollout 阶段
rollout_result = rollout_engine.rollout(
    prompt_ids=prompt_inputs["input_ids"],
    attention_mask=prompt_inputs["attention_mask"],
    num_generations=args.num_generations,  # GRPO: 6, PPO: 1
    max_new_tokens=args.max_gen_len,
    temperature=0.8,  # 有一定随机性,让每次生成不同
)
outputs = rollout_result.output_ids         # [B*G, P+R] 完整序列
completions = rollout_result.completions    # List[str] 解码后的文本
old_per_token_logps = rollout_result.per_token_logps  # 旧策略的 log 概率

old_per_token_logps 是在 rollout 时记录的"旧策略"下每个 token 的 log 概率。后面计算 loss 时,会用 ratio = exp(new_logp - old_logp) 来衡量策略变化幅度。这就是 PPO 名字中"Proximal"(近端)的来源。

数据准备

RL 训练的数据只需要 Prompt,不需要标准答案。RLAIFDataset 会把对话渲染为模型输入格式,但 answer 字段留空——让模型自己生成。

# dataset/lm_dataset.py: RLAIFDataset
class RLAIFDataset(Dataset):
    def __init__(self, jsonl_path, tokenizer, max_length=1024, thinking_ratio=0.5):
        self.thinking_ratio = thinking_ratio  # 按概率开启 thinking
        self.samples = load_dataset('json', data_files=jsonl_path, split='train')

    def create_chat_prompt(self, conversations):
        conversations = pre_processing_chat(conversations)
        use_thinking = random.random() < self.thinking_ratio
        return self.tokenizer.apply_chat_template(
            conversations[:-1],         # 去掉最后一条(assistant 的回答)
            tokenize=False,
            open_thinking=use_thinking,  # 随机决定是否开启思考模式
            add_generation_prompt=True   # 添加 assistant 开头标记
        )

thinking_ratio=0.9 表示 90% 的 Prompt 会开启 <think>...</think> 思考模式,让模型同时学会"先想再答"和"直接回答"两种模式。

Part 3: 奖励信号

MiniMind 的奖励函数是四个信号的组合。前三个是规则性的辅助信号,第四个(Reward Model)是最核心的语义评分。

奖励函数决定了"模型往哪个方向学"。设计得不好,模型就会走歪路——比如学会凑字数而不是真正答好。这一节逐个拆解五个信号的设计动机和数值范围,帮你理解为什么需要每一项、它们各自鼓励或惩罚什么行为。

  • 奖励组成全景:长度奖励(鼓励适度回答)、思考奖励(鼓励 CoT 推理)、格式奖励(鼓励规范标签)、重复惩罚(惩罚车轱辘话)、RM 评分(语义质量,权重最大)
  • 长度与思考奖励:用简单阈值规则引导基本行为——不要太短敷衍,也不要太长啰嗦;思考过程要适度且格式规范
  • 重复惩罚与 RM:3-gram 重复率检测车轱辘话,RM 是唯一"理解内容"的信号,来自外部训练好的奖励模型
  • 奖励数值示例:通过一个具体例子看五个信号如何叠加成总 Reward,理解"绝对值不重要,相对排名才重要"

奖励组成全景

图:RL 奖励信号的五个组成部分

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左侧一个"模型回答"节点,向右分出五条路径:长度奖励(+0.5/-0.5)、思考奖励(+1.0/-0.5)、格式奖励(+0.25/-0.25)、重复惩罚(-rep_penalty)、蓝色高亮"Reward Model(+rm_score)",五条路径汇入右侧绿色框"总 Reward" - 各元素关系:五个信号并行从模型回答出发,全部汇入总Reward节点 - 关键标注:Reward Model用蓝色高亮并标注"最核心的语义信号",其余四个标注"辅助规则信号" - 配色建议:浅灰背景,RM蓝色高亮,总Reward绿色,其余白色/浅色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

五个信号叠加:长度(鼓励适度)、思考(鼓励 CoT)、格式(鼓励规范的 </think> 标签)、重复(惩罚车轱辘话)、RM 评分(语义质量,权重最大)。RM 是唯一一个"理解内容"的信号。

长度奖励

鼓励适度回答,惩罚敷衍或冗余:

# calculate_rewards() 中的长度奖励
rewards[i] += 0.5 if 20 <= len(response.strip()) <= 800 else -0.5

适度回答 +0.5

长度在 20~800 字符之间。不太短(敷衍),也不太长(啰嗦)。

敷衍/冗余 -0.5

过短说明模型在偷懒,过长说明它在凑字数。两者都扣分。

这是一个简单的阈值规则。它不是完美的——有些问题本来就该短回答——但作为辅助信号已经够用。真正的内容质量由 RM 评分来把关。

思考奖励

如果回答包含思考过程(</think> 标签),额外奖励格式规范的思考:

# calculate_rewards() 中的思考奖励
if '</think>' in response:
    thinking_content, answer_content = response.split('</think>', 1)
    # 思考内容长度 20~300 字符 → +1.0,否则 -0.5
    rewards[i] += 1.0 if 20 <= len(thinking_content.strip()) <= 300 else -0.5
    # </think> 标签恰好 1 个 → +0.25,否则 -0.25
    rewards[i] += 0.25 if response.count('</think>') == 1 else -0.25
    answer = answer_content.strip()  # 后续用 answer 去算 RM 分数

两个信号叠加:鼓励"适度思考 + 格式规范",惩罚"思考太短/太长 + 标签乱写"。思考过程太长可能是模型在"胡思乱想",太短则没有真正推理。

注意:如果回答不包含 </think> 标签,思考奖励和格式奖励都不触发。模型可以自由选择是否开启思考模式——这由 thinking_ratio 参数在数据准备阶段控制。

重复惩罚与 RM

# 重复惩罚:用 3-gram 重复率衡量,上限 0.5
def rep_penalty(text, n=3, cap=0.5):
    toks = re.findall(r"\w+|[^\w\s]", text.lower())
    grams = [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
    return min(cap, (len(grams) - len(set(grams))) * cap * 2 / len(grams)) if grams else 0.0

# Reward Model 评分:最核心的语义信号
score = reward_model.get_score(messages, answer)
reward_model_scores.append(score)
rewards += reward_model_scores  # 直接加到总奖励上

重复惩罚

统计 3-gram(三个连续词组)的重复比例。重复越多,扣分越多。上限 0.5。

Reward Model

外部训练好的奖励模型(InternLM2-1.8B-Reward)。输入对话 + 回答,输出语义质量分数。这是唯一"理解内容"的信号。

奖励数值示例

假设模型生成了一个包含思考过程的回答:

信号条件得分
长度奖励回答长度 350 字符(20~800 内)+0.5
思考奖励思考内容 120 字符(20~300 内)+1.0
格式奖励</think> 恰好 1 个+0.25
重复惩罚3-gram 重复率 8%-0.08
RM 评分语义质量较高+2.30
总 Reward+3.97

一个差的回答(太短 + 没有思考 + 重复 + RM 低分)可能得到 -1.5 左右的总 Reward。奖励的绝对值不重要,重要的是相对排名——这正是优势函数要做的。

Part 4: GRPO 算法

Group Relative Policy Optimization。MiniMind 的默认 RL 算法。它摒弃了 PPO 中复杂的 Critic 模型,用组内相对排名替代。

GRPO 是目前最主流的开源 RL 对齐算法之一。它的核心直觉很简单:同一个问题让模型写 6 个答案,分高的相对平均有正优势,分低的有负优势。不需要额外的 Critic 模型,6 个回答的均值就是基准线。这一节从思想到代码,完整拆解 GRPO 的每一步。

  • 核心思想与组内优势:同一 Prompt 生成 6 个回答,用组内均值和方差做标准化,无需额外价值模型
  • 四步更新流程:重新计算 new_logp → 参考模型 ref_logp → 组内标准化优势 → 裁剪损失(CISPO/GRPO 两种变体)
  • KL 散度惩罚:用 exp(Δ) - Δ - 1 约束策略不要偏离参考模型太远,beta 参数控制惩罚力度
  • 掩码机制 completion_mask:确保 loss 只计算有效 token,忽略 padding 和 EOS 之后的内容
  • 完整流程图:数据集、Rollout 引擎、Reward Model、策略模型、参考模型之间的完整交互时序

GRPO 核心思想

定义

GRPO 通过在同一 Prompt 下生成多个回答,用组内相对排名计算优势,无需训练额外的价值模型(Critic)。

白话

同一个问题让模型写 6 个答案,老师分别打分。分高的相对于平均有正优势,分低的有负优势。不需要额外的"Critic 模型"来预测"预期分数"——6 个回答的均值就是基准线。

代码

# train_grpo.py: rollout 阶段 —— 每个 Prompt 生成 6 个回答
rollout_result = rollout_engine.rollout(
    prompt_ids=prompt_inputs["input_ids"],
    attention_mask=prompt_inputs["attention_mask"],
    num_generations=args.num_generations,  # 默认 6
    max_new_tokens=args.max_gen_len,
    temperature=0.8,
)

组内相对优势

图:GRPO 的组内相对优势计算过程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:三个阶段从左到右。第一阶段"生成阶段":Prompt分出6个回答(回答1~回答6);第二阶段"打分":6个回答分别得到奖励(r1=2.1, r2=0.8, r3=3.5绿色高亮, r4=-0.2红色高亮, r5=1.5, r6=0.3);第三阶段"标准化 Adv=(r-mean)/std":计算mean=1.33, std=1.31,绿色高亮"Adv3=+1.65"和红色高亮"Adv4=-1.17" - 各元素关系:三个阶段从左到右依次连接 - 关键标注:最高分用绿色标注正优势,最低分用红色标注负优势 - 配色建议:浅灰背景,正优势绿色系,负优势红色系 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

关键点:优势是组内相对的。同一个回答在不同的组里,优势值可能完全不同。这就像你的考试成绩好不好,取决于班级平均水平。

GRPO 更新策略

GRPO 的一次前向传播分为四步。在写代码之前,先理解每一步在做什么、为什么需要它:

图:GRPO 策略更新的四步流程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从上到下四个步骤框,每个步骤右侧有"为什么?"说明。步骤1蓝色"用当前模型重新计算每个token的log概率(new_logp)"旁注"策略权重已变,需最新概率";步骤2紫色"用冻结的参考模型计算ref_logp,用于KL惩罚"旁注"防止偏离太远,遗忘知识";步骤3橙色"把同一Prompt的6个回答做组内标准化,得到优势"旁注"绝对分数无意义,相对排名才是信号";步骤4红色"用new_logp、old_logp和优势计算裁剪损失"旁注"限制更新幅度,避免一步走太远" - 各元素关系:四个步骤从上到下依次连接 - 关键标注:每个步骤用不同颜色,右侧"为什么"用灰色斜体 - 配色建议:浅灰背景,步骤1蓝,步骤2紫,步骤3橙,步骤4红 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

GRPO 代码片段

对应上页流程图的四步,每步只保留核心代码:

# 步骤 1: 重新计算 new_logp(策略已经 rollout 后更新过)
per_token_logps = F.log_softmax(res.logits[:, :-1, :], dim=-1).gather(
    2, outputs[:, 1:].unsqueeze(-1)).squeeze(-1).gather(1, logp_pos)

# 步骤 2: 参考模型的 logp(冻结,用于计算 KL 散度)
with torch.no_grad():
    ref_per_token_logps = F.log_softmax(
        ref_model(outputs, ...).logits[:, :-1, :], dim=-1).gather(...)

# 步骤 3: 组内标准化得到优势
advantages = (rewards - mean_r) / (std_r + 1e-4)

# 步骤 4: ratio = 新旧概率之比,裁剪后计算损失
ratio = torch.exp(per_token_logps - old_per_token_logps)
clamped_ratio = torch.clamp(ratio, max=args.epsilon_high).detach()

步骤 1 和 2 都是在做"取 log 概率",只是分别来自当前策略和参考模型。步骤 3 是 GRPO 区别于 PPO 的核心——不需要 Critic。步骤 4 的裁剪细节会在后面的损失函数一页展开。

KL 散度惩罚

定义

KL 散度衡量两个概率分布之间的"距离"。在 RL 训练中,它约束新策略不要偏离参考模型太远。

白话

想象你在学开车。教练(参考模型)说"方向盘应该这样握",你可以调整握法,但不能完全另起炉灶——否则就是危险驾驶。KL 惩罚就是那个"教练在盯着你"的机制。

这个比喻只是为了帮你建立直观感受。在代码中,KL 散度是通过 token 级别的 log 概率差来计算的。

代码

# train_grpo.py: per-token KL 散度
kl_div = ref_per_token_logps - per_token_logps
per_token_kl = torch.exp(kl_div) - kl_div - 1  # [B*G, R]

公式是 exp(Δ) - Δ - 1,其中 Δ = ref_logp - new_logp。这个公式保证 KL ≥ 0,且 Δ=0 时 KL=0(策略没变时惩罚为零)。args.beta(默认 0.1)控制惩罚力度。

GRPO 损失函数

损失 = 策略损失 + KL 惩罚。策略损失通过 Clipped Objective 限制更新幅度。

# train_grpo.py: 两种变体
ratio = torch.exp(per_token_logps - old_per_token_logps)  # 新旧策略的比率

if args.loss_type == "cispo":  # 默认:CISPO 单裁剪(只裁上界)
    clamped_ratio = torch.clamp(ratio, max=args.epsilon_high).detach()
    per_token_loss = -(clamped_ratio * advantages.unsqueeze(1) * per_token_logps
                       - args.beta * per_token_kl)
else:  # 标准 GRPO:双裁剪(上下界都裁)
    clipped_ratio = torch.clamp(ratio, 1 - args.epsilon, 1 + args.epsilon)
    per_token_loss1 = ratio * advantages.unsqueeze(1)
    per_token_loss2 = clipped_ratio * advantages.unsqueeze(1)
    per_token_loss = -(torch.min(per_token_loss1, per_token_loss2)
                       - args.beta * per_token_kl)

最后对有效 token 求平均:

policy_loss = ((per_token_loss * completion_mask).sum(dim=1)
               / completion_mask.sum(dim=1).clamp(min=1)).mean()
loss = (policy_loss + aux_loss) / args.accumulation_steps

CISPO 变体

MiniMind 默认使用 CISPO(Clipped Importance Sampling Policy Optimization)。和标准 GRPO 的区别在于裁剪策略:

GRPO 双裁剪

# 上下界都裁剪
clipped = torch.clamp(ratio,
    1 - 0.2, 1 + 0.2)  # [0.8, 1.2]

ratio 被限制在 [0.8, 1.2]。策略变化超过 20% 的部分被截断。

CISPO 单裁剪

# 只裁上界
clamped = torch.clamp(ratio,
    max=5.0).detach()

ratio 下界不限,上界裁到 5.0。允许策略更大胆地向好的方向更新。

CISPO 更激进——不限制 ratio 下界,允许策略更大胆地向好的方向探索。用 .detach() 截断梯度流,让裁剪不影响反向传播的数值稳定性。实践中 CISPO 收敛更快,但也需要更小的学习率来配合。

GRPO 完整流程

图:GRPO 训练的完整交互时序

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:水平时序图,五个参与者从左到右:RLAIFDataset、RolloutEngine、RewardModel(蓝色)、Policy Model(橙色)、Reference Model(紫色)。流程:Data提供Prompt → Rollout生成6个回答 → 返回output_ids和old_logp给Policy → Rollout把completions文本给RM → RM计算长度+思考+重复+RM评分 → 返回rewards给Policy → Policy计算new_logp → Ref计算ref_logp → Policy内部计算Adv=(rewards-mean)/std、ratio=exp(new-old)、KL=exp(ref-new)-(ref-new)-1、CISPO clipped loss → backward更新参数 - 各元素关系:消息箭头从左到右表示调用,从右到左表示返回 - 关键标注:Policy旁标注核心计算公式,RM旁标注"外部不训练" - 配色建议:浅灰背景,RM蓝色,Policy橙色,Reference紫色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

掩码机制

GRPO 中有一个容易被忽略但非常重要的机制:completion_mask。它确保 loss 只计算有效 token,忽略 padding 和 EOS 之后的内容。

# 核心逻辑:找到 EOS 位置,只保留 EOS 之前且非 padding 的 token
is_eos = (completion_ids == tokenizer.eos_token_id) & completion_pad_mask
eos_idx = ...  # 每个 sample 中 EOS 的位置
completion_mask = (torch.arange(...) <= eos_idx.unsqueeze(1)) & completion_pad_mask

completion_mask 在 loss 计算中用于加权——有效 token 参与 loss,padding 位置贡献为零。

如果去掉 completion_mask,padding 位置的零值会参与 loss 平均,导致梯度信号被稀释,训练效率大幅下降。

Part 5: PPO 算法

Proximal Policy Optimization,经典的 RL 算法。比 GRPO 多了一个 Critic 模型,优势估计更精细,但也更复杂、更吃显存。

PPO 是 DeepSeek、ChatGPT 等大模型背后的经典对齐算法。和 GRPO 的最大区别是多了一个"Critic 预言家"——它预测每个位置能拿多少分,让优势估计更精细。代价是多一个模型要训练,显存和调参难度都更高。这一节带你从 Critic 架构到 GAE 数学,把 PPO 彻底搞明白。

  • Critic 模型:与 Actor 共享 Transformer 基座,但用 value_head 输出标量价值 V(s),而不是词表分布。初始化时 strict=False 是关键
  • GAE 优势估计:从后往前反向传递 TD 误差,lambda 控制远近信号的衰减。先用数值例子建立直觉,再看代码实现
  • 两个损失函数:Actor 的裁剪策略损失(限制更新幅度)+ Critic 的裁剪价值损失(防止 V(s) 一步跳太远)
  • 多轮更新与 KL 早停:PPO 对同一批数据更新多轮,但 KL 超过阈值就停下来(loss 乘 0 而不是 break,避免 DDP 死锁)

Critic 模型

定义

Critic 模型用于估计"在当前状态下,未来能获得的期望总回报",输出每个位置的价值 V(s)。

白话

Critic 是一个"预言家"。它不生成文字,只输出一个分数——"从当前这个位置看,最终能拿多少分"。有了这个预言家,我们就知道某个回答是"比预期好"还是"比预期差"。

代码

# train_ppo.py: Critic 继承 MiniMind,换掉最后的 lm_head
class CriticModel(MiniMindForCausalLM):
    def __init__(self, params):
        super().__init__(params)
        # 把语言模型头替换为价值头:输出单一标量
        self.value_head = nn.Linear(params.hidden_size, 1)

    def forward(self, input_ids=None, attention_mask=None, **kwargs):
        outputs = self.model(input_ids=input_ids, attention_mask=attention_mask, **kwargs)
        hidden_states = self.model.norm(outputs[0])
        values = self.value_head(hidden_states).squeeze(-1)  # [B, seq_len]
        return values

Critic 和 Actor 共享 Transformer 基座权重,只是最后的"头"不同。Actor 用 lm_head 输出词表分布,Critic 用 value_head 输出标量价值。

Critic 初始化

Critic 从 Actor 的 SFT 权重初始化,加载预训练权重时 strict=False(因为 Critic 没有 lm_head,多了 value_head):

# train_ppo.py: Critic 初始化
ckp = f'{args.save_dir}/{base_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
state_dict = torch.load(ckp, map_location=args.device)

critic_model = CriticModel(lm_config)
critic_model.load_state_dict(state_dict, strict=False)  # 允许键不完全匹配
critic_model = critic_model.to(args.device)

strict=False 是关键。因为 state_dict 里包含 lm_head 的权重(Critic 没有),而 CriticModel 有 value_head 的权重(不在 state_dict 里)。跳过不匹配的键,Critic 的 value_head 使用随机初始化。

Actor-Critic

图:Actor-Critic 共享基座的模型架构

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:底部是一个大的灰色方框标注"共享 Transformer 基座" - 基座上方分出两个分支:左边分支标注"Actor 头(lm_head)",输出箭头指向"logits → 生成 token";右边分支标注"Critic 头(value_head)",输出箭头指向"V(s) → 单一标量价值" - 输入从左侧进入:"input_ids, attention_mask"箭头指向共享基座 - 关键标注:Critic 头旁标注"nn.Linear(hidden_size, 1)"和"输出期望回报" - 右下角小字标注"初始化时 strict=False,value_head 随机初始化" - 配色建议:浅灰背景,共享基座浅灰大框,Actor 头蓝色,Critic 头橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

GAE 直觉理解

GAE(Generalized Advantage Estimation)回答的问题是:生成序列中每个 token,究竟对最终奖励贡献了多少?

假设模型生成了 3 个 token,Critic 给出了每个位置的价值预测:

位置即时奖励 rCritic 预测 V(s)
t=00.01.0
t=10.51.5
t=2(末尾)2.01.2

从后往前算 TD 误差 delta = r + gamma*V(next) - V(current),其中末尾没有 V(next),取 0:

位置delta累积优势 A(lambda=0.95)
t=22.0 + 0 - 1.2 = 0.8A2 = 0.8
t=10.5 + 1.2 - 1.5 = 0.2A1 = 0.2 + 0.95*0.8 = 0.96
t=00.0 + 1.0 - 1.0 = 0.0A0 = 0.0 + 0.95*0.96 = 0.912

直觉:t=2 位置实际拿到 2.0 分,但 Critic 只预测了 1.2,说明这个 token "超出预期"(delta=0.8)。这个好消息通过 lambda=0.95 衰减后传递给前面的 token。这就是 GAE 的精髓——让每个 token 不仅看自己的 TD 误差,还能吸收后面 token 的误差信号。

GAE 代码实现

理解了上面的数值例子后,代码只是在做同样的事——从后往前迭代:

# train_ppo.py: GAE 反向迭代
lastgaelam = torch.zeros(B, device=args.device)
advs_rev = []
for t in reversed(range(gen_len)):
    nv = old_resp_values[:, t + 1] if t < gen_len - 1 else 0.0
    delta = token_rewards[:, t] + args.gamma * nv - old_resp_values[:, t]
    lastgaelam = delta + args.gamma * args.lam * lastgaelam
    advs_rev.append(lastgaelam)
advantages = torch.stack(advs_rev[::-1], dim=1)  # [B, R]
returns = advantages + old_resp_values  # Return = Advantage + Value

对照上页的数值例子:delta 就是 TD 误差,lastgaelam 就是累积优势。每一步把当前的 delta 加上衰减后的上一步优势,从末尾一路传递到开头。

深度边界:GAE 涉及 Bellman 方程和 TD-lambda 理论。初次学习只需理解"GAE 是比组内标准化更精细的优势估计方法,它利用了 Critic 的价值预测和时序信息"。GRPO 不需要 GAE,这也是它更简单的原因之一。

GAE 反向过程

图:GAE 广义优势估计的反向迭代过程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个横向时间轴,从左到右标注 token t=0, t=1, ..., t=T-1 - 每个时间点上方有 Critic 输出的 V(s_t) 数值(蓝色小框),下方有即时奖励 r_t(绿色小框) - 时间轴下方展示"反向计算"过程:从右到左用橙色箭头串联 - 关键公式标注:delta_t = r_t + gamma * V(s_{t+1}) - V(s_t);A_t = delta_t + gamma * lambda * A_{t+1} - 最后一个时间点旁标注"终点:delta = r - V(s)" - lambda=0.95 旁标注"越远的未来贡献越小" - 配色建议:浅灰背景,V(s) 蓝色,r_t 绿色,delta/A 橙色,反向箭头深橙 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

PPO 两个损失

PPO 同时训练两个模型,各有一个损失函数。它们的目标完全不同:

Actor:PPO 如何裁剪

策略损失训练 Actor(生成模型),让高优势的动作概率更大、低优势的更小,同时用裁剪限制更新幅度:

ratio = torch.exp(new_logp - old_logp)
clipped = torch.clamp(ratio,
    1-0.2, 1+0.2)
# 取 max 确保损失为正
policy_loss = torch.max(
    -advantages * ratio,
    -advantages * clipped)
# 加上 KL ref 惩罚
policy_loss += kl_coef * kl_penalty

clip_epsilon=0.2,策略变化超过 20% 时梯度被截断。这就是 PPO 名字中"Proximal"(近端)的含义。

Critic:Critic 学什么

价值损失训练 Critic(价值模型),让它的预测 V(s) 更接近实际回报 Returns。同样有裁剪保护:

value_loss = 0.5 * torch.max(
    # 普通 MSE
    (values - returns) ** 2,
    # 裁剪 MSE:限制 V 变化幅度
    (clamp(values,
     old_values - 0.2,
     old_values + 0.2)
     - returns) ** 2
)

取两者的 max,确保 Critic 不会一步更新太猛。

总损失 = 策略损失 + vf_coef x 价值损失 + aux_loss。两个损失分别回传到 Actor 和 Critic 的参数。

PPO 早停机制

PPO 会对同一批数据更新多轮(ppo_update_iters=2)。但如果策略偏离太远,就要停下来:

# train_ppo.py: 早停判断
log_ratio = mb_resp_logp - old_resp_logp[inds]
approx_kl = (0.5 * (log_ratio ** 2) * resp_policy_mask[inds]).sum() \
         / resp_policy_mask[inds].sum().clamp(min=1)

if approx_kl_val > args.early_stop_kl:  # 默认 0.25
    stop_ppo = True
早停时不能直接 break!

DDP 要求所有进程同步 forward-backward。某张卡 break 了,其他卡还在等它,就会死锁。所以早停时 loss 乘 0.0,让梯度为零但不中断通信:

# 早停时截断 loss 但不中断 DDP 通信
if stop_ppo:
    loss = (policy_loss + args.vf_coef * value_loss + aux_loss) * 0.0
else:
    loss = (policy_loss + args.vf_coef * value_loss + aux_loss) / args.accumulation_steps
loss.backward()

PPO 多轮更新

PPO 对同一批 rollout 数据进行多轮 mini-batch 更新。这是 PPO 和 GRPO 的重要区别:

mb_size = max(1, min(args.mini_batch_size, B))
for ppo_epoch in range(args.ppo_update_iters):  # 默认 2 轮
    if stop_ppo: break
    b_inds = torch.randperm(B, device=args.device)  # 打乱顺序
    for i in range(0, B, mb_size):
        inds = b_inds[i:i + mb_size]
        # 在 mini-batch 上计算 loss 并 backward
        ...
        if grad_accum_step % args.accumulation_steps == 0:
            clip_grad_norm_(actor_model.parameters(), args.grad_clip)
            clip_grad_norm_(critic_model.parameters(), args.grad_clip)
            actor_optimizer.step()
            critic_optimizer.step()

GRPO 每批数据只更新一次。PPO 更新多次(默认 2 次),充分利用采集的数据。但多轮更新的风险是策略偏离过大——这正是 KL 早停机制存在的意义。

PPO 完整流程

图:PPO 训练的完整交互时序

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:水平时序图,六个参与者从左到右:RLAIFDataset、RolloutEngine、RewardModel(蓝色)、Actor Model(橙色)、Critic Model(绿色)、Reference Model(紫色)。流程:Data提供Prompt → Rollout生成1个回答 → 返回output_ids和old_logp给Actor,output_ids给Critic → Rollout把completions给RM → RM评分返回rewards → Critic前向得到V(s) → Actor计算GAE Advantage → Returns=Adv+V → 循环PPO更新2轮(每轮遍历mini-batch:Actor算new_logp、Critic算new_values、Ref算ref_logp → KL检查早停? → 联合backward) - 各元素关系:消息箭头从左到右表示调用,从右到左表示返回。PPO循环用灰色背景框标注 - 关键标注:Critic旁标注"独有组件",PPO循环旁标注"多轮更新,KL早停保护" - 配色建议:浅灰背景,RM蓝色,Actor橙色,Critic绿色,Reference紫色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

GRPO vs PPO

两种算法解决同一个问题(RL 对齐),但路线不同。现在你已经分别学完了两者,让我们做个全面对比。

这一节不是新知识,而是一个"对照表"。把前面学过的 GRPO 和 PPO 放在一起,从模型数量、显存开销、优势估计方式、更新策略等维度逐项对比,帮你理清什么场景该用哪个。

  • 模型数量对比:GRPO 2 个模型(Policy + Ref),PPO 3 个(Actor + Critic + Ref),多一个 Critic 意味着多约 25% 显存
  • 全维度对比表:训练模型数、每 Prompt 生成数、优势估计方法、更新次数、损失变体、早停机制、显存开销等 9 个维度的详细对比
  • 如何选择:推荐从 GRPO(CISPO)开始,快速迭代验证奖励信号;资源充足且追求极致性能时再升级 PPO

模型数量对比

GRPO: 2 个模型

# train_grpo.py
model, tokenizer = init_model(...)       # Policy
ref_model, _ = init_model(...)           # Reference
reward_model = LMForRewardModel(...)     # 外部 RM(不训练)

只有 Policy 和 Reference 两个模型参与训练。RM 是外部工具,不参与梯度计算。

PPO: 3 个模型

# train_ppo.py
actor_model, tokenizer = init_model(...) # Actor
ref_model, _ = init_model(...)           # Reference
critic_model = CriticModel(...)          # Critic(额外)
reward_model = LMForRewardModel(...)     # 外部 RM(不训练)

多一个 Critic 模型。显存多占约 25%,还有额外的 Critic 优化器和学习率调度器。

全维度对比

维度GRPOPPO
训练模型数2(Policy + Ref)3(Actor + Critic + Ref)
每 Prompt 生成数61
优势估计组内均值/方差标准化GAE(需 Critic)
每批更新次数1 次2 次(可配置)
损失变体GRPO / CISPO标准 PPO 裁剪
早停机制无有(KL > 0.25 时触发)
显存开销较低较高(+Critic + 2 个优化器)
超参数数量较少较多(gamma, lam, vf_coef...)
推荐场景资源受限、快速迭代追求极致性能

架构对比图

图:GRPO 与 PPO 的模型架构对比

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左右两栏并排对比。左栏标题"GRPO(2个模型)",右栏标题"PPO(3个模型)" - 左栏 GRPO:展示 Policy Model(蓝色大框)和 Reference Model(灰色虚线框),中间有 KL 散度箭头。下方展示"组内 6 个回答 → 组标准化 → Advantage" - 右栏 PPO:展示 Actor(蓝色大框)、Critic(橙色大框,标注"独有")、Reference Model(灰色虚线框)。下方展示"GAE 递推 → Advantage" - 关键标注:PPO 栏的 Critic 用橙色高亮标注"+1 个模型,+25% 显存" - 配色建议:浅灰背景,Policy/Actor 蓝色,Critic 橙色,Reference 灰色虚线 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

如何选择

推荐从 GRPO 开始

不需要 Critic,显存更低,超参数更少。CISPO 变体收敛快,适合大多数场景。MiniMind 默认使用 GRPO。

PPO 适合追求极致

资源充足、追求最高性能时使用。GAE 的优势估计更精细,多轮更新充分利用数据。但调参空间更大,训练更不稳定。

实践中可以都试

先用 GRPO 快速验证奖励信号和超参是否合理,确认没有问题后再尝试 PPO 微调。两者可以接力训练。

Part 7: Rollout 引擎

Rollout 引擎负责"生成回答"这一步。看似简单——不就是调 model.generate() 吗?但实际工程中,这层抽象解决了一个关键问题。

这一节聚焦 RL 训练中容易被忽视的工程细节:生成和训练的资源竞争。小模型直接在训练代码里调 generate() 没问题,但大模型需要把"生成"抽离成独立引擎。这一节看完,你会理解为什么需要一个抽象层,以及 MiniMind 提供的两种引擎各自适合什么场景。

  • 设计动机:训练和推理争抢 GPU 资源。解决思路是把"生成"做成可插拔引擎,一行参数切换本地推理和独立推理服务
  • 两种引擎对比:Torch 引擎零配置开箱即用(适合小模型调试),SGLang 引擎独占 GPU 加速推理(适合大模型多卡训练)
  • 核心实现细节:repeat_interleave 复制 Prompt 实现"一次 generate 多个不同回答",logits_to_keep 优化只算生成部分的 logits

为什么需要引擎

训练小模型时,直接在训练代码里调用 model.generate() 就够了。但当模型变大、需要多卡训练时,问题出现了:

训练和推理的资源竞争。

生成阶段(rollout)需要 GPU 做推理,更新阶段需要 GPU 做训练。如果都在同一块卡上,推理时训练停摆,训练时推理闲置。大模型的生成耗时更长,浪费更严重。

解决思路:把"生成"这件事抽离出来,做成可插拔的引擎。小模型用本地引擎(Torch),大模型用独立推理服务(SGLang),训练代码一行不改。

# 切换引擎只需改一个参数
python train_grpo.py --rollout_engine torch   # 本地推理
python train_grpo.py --rollout_engine sglang  # 独立推理服务

这就是抽象层存在的意义——让算法工程师专注算法,不用关心底层用什么方式推理。

引擎特性对比

特性Torch 引擎SGLang 引擎
推理方式model.generate() 直接调用HTTP 调用独立推理服务
显存与训练共享 GPU独占一块 GPU
推理速度较慢(无加速)快(RadixAttention 加速)
权重同步直接引用(零开销)保存磁盘 + HTTP 通知热加载
部署复杂度零配置开箱即用需额外启动 SGLang 服务
适用场景小模型、单卡调试大模型、多卡分布式训练

对于 MiniMind 这样的小模型,Torch 引擎完全够用。SGLang 主要为未来扩展到更大模型预留。

Torch 引擎代码

TorchRolloutEngine 是默认引擎,核心逻辑只有三步:

# rollout_engine.py: TorchRolloutEngine 核心逻辑
class TorchRolloutEngine(RolloutEngine):
    def rollout(self, prompt_ids, attention_mask,
                num_generations, max_new_tokens, temperature=0.8):
        with torch.no_grad():
            # 关键:repeat_interleave 把每个 Prompt 复制 G 份
            output_ids = model.generate(
                input_ids=prompt_ids.repeat_interleave(num_generations, dim=0),
                max_new_tokens=max_new_tokens,
                do_sample=True, temperature=temperature,
            )
            per_token_logps = compute_per_token_logps(self.policy_model, output_ids, ...)
        return RolloutResult(output_ids, completion_ids, per_token_logps, completions, ...)

repeat_interleave(num_generations) 是关键操作。它把每个 Prompt 复制 G 份(GRPO G=6, PPO G=1),这样一次 generate() 调用就能产出同一 Prompt 的多个不同回答。由于 temperature=0.8 有随机性,每个副本会生成不同内容。

LogProb 计算

两个引擎都需要计算每个 token 的对数概率。这个函数是 Rollout 的核心工具:

# rollout_engine.py: 只计算最后 n_keep 个 token 的 logprob,节省计算
def compute_per_token_logps(model, input_ids, n_keep, attention_mask=None):
    unwrapped = model.module if isinstance(model, DistributedDataParallel) else model
    logits = unwrapped(input_ids, attention_mask=attention_mask,
                       logits_to_keep=n_keep + 1).logits[:, :-1, :]
    per_token_logps = []
    for logits_row, ids_row in zip(logits, input_ids[:, -n_keep:]):
        per_token_logps.append(
            torch.gather(logits_row.log_softmax(dim=-1), 1, ids_row.unsqueeze(1)).squeeze(1)
        )
    return torch.stack(per_token_logps)  # [B, n_keep]

logits_to_keep 参数是优化点:不需要对所有位置计算 logits,只算生成部分即可。这能显著减少前向计算的显存和计算量。

Part 8: 动手实践

理论讲完了,让我们把代码跑起来。先从 GRPO 训练命令开始。

这一节把前面所有的理论知识落地到实际的训练命令、日志解读、调参技巧和排错方法上。学完之后,你应该能自己启动 GRPO/PPO 训练,看懂日志输出,遇到问题知道往哪个方向排查。

  • 训练命令:GRPO 和 PPO 的完整启动命令、关键超参数含义、日志指标解读(关注 Reward 上升趋势和 KL_ref 范围)
  • SGLang 加速:独立推理服务的启动方式、权重同步的异步性、性能与一致性的权衡
  • 超参数速查:GRPO 和 PPO 共 14 个超参数的默认值和含义,调参时随时翻阅
  • 练习与排错:三个动手练习(读代码、改超参、PPO 排错)+ 常见错误诊断(Reward 负数、Loss NaN、死锁、显存溢出等)

GRPO 训练

# 基础 GRPO 训练(CISPO 变体,默认推荐)
python trainer/train_grpo.py \
    --from_weight full_sft \
    --data_path ../dataset/rlaif.jsonl \
    --num_generations 6 \
    --batch_size 2 \
    --learning_rate 3e-7 \
    --loss_type cispo \
    --beta 0.1 \
    --epochs 1 \
    --thinking_ratio 0.9

训练开始后,日志会输出每个 step 的 Reward、KL_ref、Advantage 统计等关键指标:

Epoch:[1/1](1/50), Reward: 1.2341, KL_ref: 0.0087,
Adv Std: 1.1234, Adv Mean: 0.0000, Actor Loss: 0.4156,
Avg Response Len: 234.50, Learning Rate: 0.00000030

关注 Reward 是否在逐步上升,KL_ref 是否在合理范围(0.01~0.1)。如果 KL_ref 爆涨,说明策略偏离太远,需要增大 --beta。

PPO 训练

python trainer/train_ppo.py \
    --from_weight full_sft \
    --data_path ../dataset/rlaif.jsonl \
    --batch_size 2 \
    --learning_rate 3e-7 \
    --critic_learning_rate 5e-7 \
    --ppo_update_iters 2 \
    --clip_epsilon 0.2 \
    --gamma 1.0 \
    --lam 0.95 \
    --early_stop_kl 0.25

PPO 多了几个关键超参数:

参数默认值含义
--critic_learning_rate5e-7Critic 的独立学习率(通常比 Actor 稍大)
--gamma1.0折扣因子(1.0 = 不折扣,重视长期回报)
--lam0.95GAE 的 lambda(越大越重视远期)
--ppo_update_iters2每批数据的更新轮数
--early_stop_kl0.25KL 早停阈值

SGLang 加速

当模型较大或需要多卡训练时,可以用 SGLang 加速 rollout:

# 第一步:启动 SGLang 推理服务(独立终端)
python -m sglang.launch_server \
    --model-path ./minimind-model \
    --attention-backend triton \
    --host 0.0.0.0 --port 8998

# 第二步:用 SGLang 引擎训练(另一个终端)
python trainer/train_grpo.py \
    --from_weight full_sft \
    --rollout_engine sglang \
    --sglang_base_url http://localhost:8998 \
    --sglang_model_path ./minimind-model \
    --sglang_shared_path ./sglang_ckpt_grpo
SGLang 模式下,权重同步是异步的。每 save_interval 步才同步一次策略到 SGLang 服务。这意味着 rollout 用的可能不是最新的策略——这是性能和一致性的权衡。同步间隔越小,一致性越好,但训练速度越慢。

超参数速查

参数默认值说明算法
--num_generations6GRPO 每 Prompt 生成数GRPO
--beta0.1KL 惩罚系数,越大越保守GRPO
--epsilon0.2GRPO 裁剪范围GRPO
--epsilon_high5.0CISPO 上界GRPO
--loss_typecispoGRPO / CISPO 选择GRPO
--clip_epsilon0.2PPO 裁剪参数PPO
--vf_coef0.5价值损失权重PPO
--kl_coef0.02PPO KL 惩罚系数PPO
--gamma1.0PPO 折扣因子PPO
--lam0.95PPO GAE lambdaPPO
--early_stop_kl0.25PPO 早停 KL 阈值PPO
--thinking_ratio0.9开启思考模式概率共通
--learning_rate3e-7Actor 学习率共通
--grad_clip1.0梯度裁剪阈值共通

调试模式

两个训练脚本都支持 --debug_mode,定期打印 Prompt 和生成内容,帮助排查问题:

python trainer/train_grpo.py \
    --from_weight full_sft \
    --debug_mode \
    --debug_interval 10  # 每 10 步打印一次

输出格式示例:

[DEBUG] step=10, sample[0]
============================== [DEBUG] sample[0] CONTEXT_BEGIN ==============================
<|im_start|>user\n请解释什么是机器学习。<|im_end|>
=============================== [DEBUG] sample[0] CONTEXT_END ===============================
============================ [DEBUG] gen[0] RESPONSE_BEGIN ============================
机器学习是人工智能的一个分支,它让计算机能够...
============================ [DEBUG] gen[0] RESPONSE_END ==============================
[DEBUG] gen[0] reward=2.3456

调试模式会打印每个 Prompt 的所有生成回答(GRPO 6 个,PPO 1 个)及其对应奖励。如果所有回答都很短或重复,说明模型生成有问题;如果奖励分布不合理,说明奖励信号有问题。

练习: 读代码

任务:打开 train_grpo.py,找到 grpo_train_epoch() 函数。回答以下问题:

  1. old_per_token_logps 是在哪一步产生的?为什么后面要 .detach()?
  2. 如果把 num_generations 从 6 改成 2,训练会出什么问题?
  3. completion_mask 的作用是什么?如果去掉它,loss 会怎么变?

参考答案

  1. old_per_token_logps 在 rollout 阶段由 compute_per_token_logps() 产生。.detach() 是因为它是"旧策略的参考值",不参与梯度计算——梯度只通过 new_logp 这条路径回传。
  2. 组内只有 2 个回答,均值和方差估计极不准确。优势可能只有 +1 和 -1 两种值,训练信号很弱。建议至少 4 个,6 个是效率和质量的最佳平衡。
  3. completion_mask 屏蔽 padding token 和 EOS 之后的内容。去掉后,这些位置的零值会参与 loss 平均,导致梯度信号被稀释,训练效率大幅下降。

练习: 改超参

任务:基于以下训练命令,修改一个超参数并预测效果:

python trainer/train_grpo.py \
    --from_weight full_sft \
    --num_generations 6 \
    --beta 0.1 \
    --learning_rate 3e-7 \
    --loss_type cispo
  1. 把 --beta 从 0.1 改成 0.0 会怎样?
  2. 把 --learning_rate 从 3e-7 改成 3e-5 会怎样?
  3. 把 --loss_type 从 cispo 改成 grpo 会怎样?

参考答案

  1. beta=0 意味着没有 KL 惩罚,策略可能偏离 ref_model 太远。后果是 reward hacking——模型找到 RM 的漏洞,生成看似高分但无意义的内容。
  2. 学习率增大 100 倍,训练几乎必然崩溃——loss 爆炸或 NaN。RL 的学习率通常比 SFT 小 1-2 个数量级(3e-7 vs 3e-5)。
  3. CISPO → GRPO 会把裁剪策略从"单裁上界"变为"上下界都裁"。训练会更保守,收敛更慢,但也更稳定。

练习: PPO 排错

任务:阅读以下 PPO 日志片段,诊断问题:

Epoch:[1/1](5/50), Reward: -1.2341, KL_ref: 0.0001,
Approx KL: 0.0001, ClipFrac: 0.0000,
Critic Loss: 12.3456, Avg Response Len: 5.00
  1. Reward 为负说明什么?
  2. Avg Response Len 只有 5 说明什么?
  3. ClipFrac 为 0 说明策略有在更新吗?

参考答案

  1. Reward 为负说明模型生成的回答质量很差。结合长度只有 5 个 token,大概率是模型在敷衍——生成极短的回答,触发了长度惩罚。
  2. Avg Response Len = 5 说明模型几乎不生成内容就停了。可能是 temperature 太低、模型太小还没学会生成、或者 SFT 阶段没有充分训练。
  3. ClipFrac = 0 意味着所有 token 的 ratio 都在 [0.8, 1.2] 内,没有触发裁剪。结合 KL 很低,说明策略几乎没变化——模型可能处于梯度消失或学习率太小的状态。

常见错误诊断

Reward 全是负数

症状:日志显示 Reward 一直在 -1.0 左右,不收敛。

原因:Reward Model 没有正确加载,或者 RM 评分范围与规则奖励不匹配。

修复:检查 --reward_model_path 路径是否正确。用 --debug_mode 打印单个样本的 RM 分数,确认 RM 能正常返回分数。

Loss 变成 NaN

症状:训练几步后 loss 变成 NaN。

原因:学习率太大,或 std_r 接近 0 导致除零。

修复:降低 --learning_rate(试试 1e-7)。检查代码中 std_r + 1e-4 是否存在。如果用了 fp16,尝试切换到 bf16。

PPO 多卡死锁

症状:PPO 多卡训练卡住不动。

原因:某张卡在 KL 超过阈值后直接 break,导致 DDP 通信不同步。

修复:确保早停后 loss 乘 0.0 继续完成 forward-backward 闭环,不要直接 break。MiniMind 的实现已经正确处理了这个问题。

生成内容重复

症状:模型反复生成相同的内容。

原因:temperature 太低(接近 greedy),或模型太小还没学会多样性。

修复:检查 temperature=0.8 是否设置正确。确认 rep_penalty 函数被调用。如果模型是 512 维的小模型,可能需要先做更多 SFT。

进阶排错

Critic Loss 不下降

症状:PPO 训练中 Critic Loss 一直在高位震荡。

原因:Critic 学习率不合适,或 Critic 初始化不好。

修复:尝试调大 --critic_learning_rate(试试 1e-6)。确认 Critic 用了 SFT 权重初始化,而不是随机初始化。

KL 散度爆涨

症状:KL_ref 从 0.01 快速涨到 1.0 以上。

原因:策略偏离参考模型太远。可能是学习率太大或 KL 惩罚太小。

修复:增大 --beta(GRPO)或 --kl_coef(PPO)。降低学习率。如果持续爆涨,检查奖励信号是否合理——reward hacking 会导致策略走偏。

显存不足 OOM

症状:训练时 CUDA out of memory。

原因:num_generations 太大或 max_gen_len 太长。

修复:减少 --num_generations(试试 4)。缩短 --max_gen_len。开启梯度累积 --accumulation_steps 4。PPO 比 GRPO 多一个 Critic,显存压力更大。

SGLang 连接失败

症状:ConnectionRefusedError 或超时。

原因:SGLang 服务没有启动,或端口不对。

修复:先在浏览器访问 http://localhost:8998/health 确认服务运行。检查 --sglang_base_url 是否正确。

常见问题 FAQ

选 GRPO 还是 PPO?

推荐从 GRPO(CISPO)开始。无需 Critic,显存更低,超参数更少。PPO 在资源充足且追求极致性能时使用。

为何每 Prompt 生成 6 个?

组内相对优势需要足够样本才能准确估计。6 个是质量和效率的平衡点。太少(2-3 个)估计不准,太多(16+)推理成本太高。

KL 散度惩罚干什么的?

防止策略偏离 ref_model 太远。没有 KL 约束,模型可能找到 RM 漏洞(reward hacking),生成看似高分但无意义的回答。beta 越大,约束越强。

PPO 为何要多轮更新?

一次 rollout 后多轮更新,充分利用采集的数据。但过多更新导致策略偏离过大,需要 KL 早停保护。默认 2 轮是经验值。

为什么 RL 学习率这么小?

RL 的学习率(3e-7)比 SFT(5e-4)小 1000 倍以上。因为 RL 的梯度信号更"嘈杂"——每次更新都基于采样数据,不是精确梯度。大学习率会导致训练崩溃。

RL 训练需要多长时间?

取决于数据量和硬件。MiniMind 512 模型在单卡 3090 上,1 epoch(50 步)约需 20-30 分钟。768 模型约需 1 小时。PPO 比 GRPO 慢约 30%(额外的 Critic 前向 + 多轮更新)。

学完自测

问题 1

DPO 的局限性是什么?RL 引入了什么新角色来解决?

问题 2

GRPO 的优势是怎么计算的?PPO 的优势呢?两者的根本区别是什么?

问题 3

CISPO 和标准 GRPO 的裁剪策略有什么不同?为什么 CISPO 更激进?

问题 4

PPO 的 Critic 模型输出什么?它和 Actor 共享了什么?

问题 5

PPO 的早停机制在什么条件下触发?为什么不能直接 break?

问题 6

Torch 和 SGLang 两种 Rollout 引擎各自适合什么场景?

阶段 8 完成

GRPO 和 PPO——两种 RL 算法,让模型通过试错不断进步。

GRPO 简单高效,PPO 精细强大。从 GRPO 开始,需要时再升级到 PPO。

下一章:阶段 9 · Agent RL——教会模型使用外部工具

1 / X