为什么需要对齐?

预训练后的 LLM 只是"下一个 token 预测器",它学会了互联网上的所有文本模式——包括有害内容、错误信息和不符合人类价值观的回答。

对齐(Alignment) 的目标是:让模型的行为符合人类期望。

三个阶段:

  1. SFT(监督微调):用人类标注的问答对训练
  2. 偏好学习:让模型学会区分"好回答"和"差回答"
  3. 安全对齐:拒绝有害请求

本文聚焦于第 2 阶段的三种主流方法。

RLHF:经典三阶段方法

总体架构

SFT Model
    |
    v
[奖励模型训练] ← 人类偏好数据 (chosen, rejected)
    |
    v
奖励模型 (Reward Model)
    |
    v
[PPO 强化学习] ← 奖励模型打分 + SFT 模型作为参考
    |
    v
对齐后的模型 (Aligned Policy)

阶段一:训练奖励模型

人类标注员对模型的多个回答进行排序(A > B > C),然后训练一个奖励模型 RM 来预测偏好分数:

import torch
import torch.nn as nn

class RewardModel(nn.Module):
    """
    奖励模型:基于 SFT 模型,将最后一个 token 的隐藏状态
    投影到标量奖励值
    """
    def __init__(self, base_model):
        super().__init__()
        self.base_model = base_model  # 通常是 SFT 模型
        self.value_head = nn.Linear(
            base_model.config.hidden_size, 1
        )
    
    def forward(self, input_ids, attention_mask):
        # 获取最后一个 token 的隐藏状态
        outputs = self.base_model(
            input_ids, attention_mask=attention_mask,
            output_hidden_states=True
        )
        hidden = outputs.hidden_states[-1]  # (B, L, D)
        
        # 取最后一个非 padding token
        seq_lens = attention_mask.sum(dim=1) - 1  # (B,)
        last_hidden = hidden[torch.arange(hidden.size(0)), seq_lens]
        
        # 投影到标量
        reward = self.value_head(last_hidden)  # (B, 1)
        return reward.squeeze(-1)


def reward_model_loss(rm, chosen_ids, chosen_mask, 
                      rejected_ids, rejected_mask):
    """
    Bradley-Terry 模型:chosen 的奖励应高于 rejected
    
    Loss = -log(sigmoid(r_chosen - r_rejected))
    """
    r_chosen = rm(chosen_ids, chosen_mask)
    r_rejected = rm(rejected_ids, rejected_mask)
    
    # Bradley-Terry loss
    loss = -torch.nn.functional.logsigmoid(r_chosen - r_rejected).mean()
    
    # 准确率
    acc = (r_chosen > r_rejected).float().mean()
    
    return loss, acc

阶段二:PPO 强化学习

用奖励模型作为奖励函数,通过 PPO 算法优化策略模型:

import torch.nn.functional as F

class PPOTrainer:
    def __init__(self, policy_model, ref_model, reward_model, 
                 value_model, kl_coef=0.05, clip_range=0.2):
        """
        Args:
            policy_model: 正在训练的策略模型
            ref_model: SFT 模型(冻结),用于 KL 约束
            reward_model: 奖励模型(冻结)
            value_model: 价值模型(用于 advantage 估计)
            kl_coef: KL 散度惩罚系数
            clip_range: PPO 裁剪范围
        """
        self.policy = policy_model
        self.ref = ref_model
        self.rm = reward_model
        self.value = value_model
        self.kl_coef = kl_coef
        self.clip_range = clip_range
    
    def compute_rewards(self, prompts, responses):
        """计算每个 token 的奖励"""
        # 1. 奖励模型打分(序列级别)
        full_ids = torch.cat([prompts, responses], dim=1)
        sequence_reward = self.rm(full_ids, 
                                   attention_mask=(full_ids != pad_id))
        
        # 2. KL 惩罚(token 级别)
        with torch.no_grad():
            ref_logprobs = self._get_logprobs(self.ref, prompts, responses)
        policy_logprobs = self._get_logprobs(self.policy, prompts, responses)
        
        kl_per_token = policy_logprobs - ref_logprobs
        kl_penalty = -self.kl_coef * kl_per_token
        
        # 3. 合并奖励:KL 惩罚(每个 token)+ 序列奖励(最后 token)
        token_rewards = kl_penalty.clone()
        token_rewards[:, -1] += sequence_reward
        
        return token_rewards
    
    def ppo_step(self, prompts, responses, old_logprobs, advantages):
        """PPO 单步更新"""
        # 新策略的 log prob
        new_logprobs = self._get_logprobs(self.policy, prompts, responses)
        
        # 重要性采样比率
        ratio = torch.exp(new_logprobs - old_logprobs)
        
        # PPO Clipped Objective
        surrogate1 = ratio * advantages
        surrogate2 = torch.clamp(ratio, 1 - self.clip_range, 
                                  1 + self.clip_range) * advantages
        policy_loss = -torch.min(surrogate1, surrogate2).mean()
        
        # 价值函数损失
        values = self.value(prompts, responses)
        value_loss = F.mse_loss(values, advantages + values.detach())
        
        # 总损失
        loss = policy_loss + 0.5 * value_loss
        
        return loss
    
    def _get_logprobs(self, model, prompts, responses):
        """获取 response 部分 token 的 log probability"""
        full_ids = torch.cat([prompts, responses], dim=1)
        logits = model(full_ids).logits
        
        # 只取 response 部分的 logprob
        prompt_len = prompts.shape[1]
        resp_logits = logits[:, prompt_len-1:-1, :]
        resp_logprobs = F.log_softmax(resp_logits, dim=-1)
        
        # 收集实际生成 token 的 logprob
        token_logprobs = resp_logprobs.gather(-1, responses.unsqueeze(-1))
        return token_logprobs.squeeze(-1)

RLHF 的问题

  1. 训练不稳定:四个模型同时交互,梯度容易爆炸
  2. 超参数敏感:KL 系数、学习率、PPO clip range 需要精细调整
  3. 计算成本高:需要同时加载 4 个模型(policy, ref, reward, value)
  4. 奖励黑客(Reward Hacking):策略模型可能找到奖励模型的漏洞

DPO:去掉奖励模型

核心洞察

DPO(Direct Preference Optimization, Rafailov et al., 2023)证明:最优策略和奖励函数之间存在解析关系,可以跳过奖励模型训练,直接从偏好数据优化策略。

数学推导:

# 最优策略的解析形式(从 RL 目标推导)
pi*(y|x) = (1/Z(x)) * pi_ref(y|x) * exp(r(x,y) / beta)

# 逆推奖励函数
r(x,y) = beta * log(pi*(y|x) / pi_ref(y|x)) + beta * log(Z(x))

# 代入 Bradley-Terry 偏好模型
P(chosen > rejected) = sigmoid(r(x,chosen) - r(x,rejected))

# 得到 DPO 损失
P(chosen > rejected) = sigmoid(
    beta * log(pi(chosen|x)/pi_ref(chosen|x)) 
  - beta * log(pi(rejected|x)/pi_ref(rejected|x))
)

DPO 实现

class DPOTrainer:
    def __init__(self, policy_model, ref_model, beta=0.1):
        """
        Args:
            policy_model: 正在训练的策略模型
            ref_model: 冻结的参考模型(通常是 SFT 模型)
            beta: KL 约束强度
        """
        self.policy = policy_model
        self.ref = ref_model
        self.beta = beta
    
    def compute_loss(self, batch):
        """
        DPO Loss
        
        Args:
            batch: {
                prompt_ids: (B, P),
                chosen_ids: (B, C),
                rejected_ids: (B, R),
            }
        """
        prompt = batch['prompt_ids']
        chosen = batch['chosen_ids']
        rejected = batch['rejected_ids']
        
        # 策略模型的 log prob
        policy_chosen_logp = self._get_logps(
            self.policy, prompt, chosen
        )
        policy_rejected_logp = self._get_logps(
            self.policy, prompt, rejected
        )
        
        # 参考模型的 log prob(no_grad)
        with torch.no_grad():
            ref_chosen_logp = self._get_logps(
                self.ref, prompt, chosen
            )
            ref_rejected_logp = self._get_logps(
                self.ref, prompt, rejected
            )
        
        # DPO logits
        chosen_logits = self.beta * (
            policy_chosen_logp - ref_chosen_logp
        )
        rejected_logits = self.beta * (
            policy_rejected_logp - ref_rejected_logp
        )
        
        # DPO Loss = -log(sigmoid(chosen_logits - rejected_logits))
        loss = -F.logsigmoid(chosen_logits - rejected_logits).mean()
        
        # 监控指标
        with torch.no_grad():
            chosen_reward = self.beta * (
                policy_chosen_logp - ref_chosen_logp
            )
            rejected_reward = self.beta * (
                policy_rejected_logp - ref_rejected_logp
            )
            acc = (chosen_reward > rejected_reward).float().mean()
            margin = (chosen_reward - rejected_reward).mean()
        
        return loss, {'acc': acc.item(), 'margin': margin.item()}
    
    def _get_logps(self, model, prompt, response):
        """计算 response 的平均 log probability"""
        full = torch.cat([prompt, response], dim=1)
        logits = model(full).logits
        
        prompt_len = prompt.shape[1]
        # 取对应 response token 位置的 logits
        resp_logits = logits[:, prompt_len-1:-1, :]
        logprobs = F.log_softmax(resp_logits, dim=-1)
        token_logps = logprobs.gather(-1, response.unsqueeze(-1)).squeeze(-1)
        
        # 返回序列平均 logprob
        return token_logps.sum(dim=-1) / response.shape[1]

DPO 的变体

变体改进点公式差异
IPO防止过拟合偏好数据(chosen-rejected)^2 替代 logsigmoid
KTO不需要配对数据单条数据的 Kahneman-Tversky 效用函数
SimPO去掉参考模型直接用策略模型的 logprob
ORPO融合 SFT 和偏好学习SFT loss + odds ratio loss
def simpo_loss(policy_chosen_logp, policy_rejected_logp,
               chosen_len, rejected_len, beta=2.0, gamma=1.0):
    """SimPO: 无需参考模型的 DPO 变体"""
    # 长度归一化
    chosen_logp_norm = policy_chosen_logp / chosen_len
    rejected_logp_norm = policy_rejected_logp / rejected_len
    
    # SimPO logits(无参考模型)
    chosen_logits = beta * chosen_logp_norm
    rejected_logits = beta * rejected_logp_norm
    
    # 加入 margin
    loss = -F.logsigmoid(
        chosen_logits - rejected_logits - gamma
    ).mean()
    
    return loss

GRPO:群组相对策略优化

动机

GRPO(Group Relative Policy Optimization, DeepSeek, 2024)是 PPO 的改进,去掉了价值模型,用群组采样的相对优势来估计 baseline。

核心思想

对于每个 prompt,采样 G 个回答,用组内相对奖励作为 advantage:

标准 PPO:
    advantage = reward - value_model(prompt, response)
    需要训练 value model

GRPO:
    对同一 prompt 采样 G 个回答
    advantage_i = (reward_i - mean(rewards)) / std(rewards)
    不需要 value model!
class GRPOTrainer:
    def __init__(self, policy_model, ref_model, reward_fn,
                 beta=0.04, group_size=8, clip_range=0.2):
        """
        Args:
            policy_model: 策略模型
            ref_model: 参考模型(冻结)
            reward_fn: 奖励函数(可以是规则、模型或 LLM judge)
            beta: KL 惩罚系数
            group_size: 每个 prompt 的采样数
            clip_range: PPO 裁剪范围
        """
        self.policy = policy_model
        self.ref = ref_model
        self.reward_fn = reward_fn
        self.beta = beta
        self.G = group_size
        self.clip_range = clip_range
    
    def generate_group(self, prompt, temperature=0.7):
        """为一个 prompt 生成 G 个回答"""
        responses = []
        for _ in range(self.G):
            resp = self.policy.generate(
                prompt, temperature=temperature, 
                do_sample=True, max_new_tokens=512
            )
            responses.append(resp)
        return responses
    
    def compute_advantages(self, rewards):
        """
        群组相对优势
        
        A_i = (r_i - mean(r)) / (std(r) + eps)
        """
        mean_r = rewards.mean()
        std_r = rewards.std()
        advantages = (rewards - mean_r) / (std_r + 1e-8)
        return advantages
    
    def grpo_step(self, prompt_ids, group_responses, 
                  group_logprobs_old):
        """
        GRPO 单步更新
        
        Args:
            prompt_ids: (1, P) 单个 prompt
            group_responses: (G, R) G 个回答
            group_logprobs_old: (G, R) 旧策略的 logprob
        """
        G = self.G
        
        # 1. 计算每个回答的奖励
        rewards = torch.zeros(G)
        for i in range(G):
            rewards[i] = self.reward_fn(
                prompt_ids, group_responses[i]
            )
        
        # 2. 群组相对优势
        advantages = self.compute_advantages(rewards)  # (G,)
        
        # 3. KL 惩罚
        with torch.no_grad():
            ref_logprobs = self._get_logps_batch(
                self.ref, prompt_ids.expand(G, -1), 
                group_responses
            )
        
        # 4. 新策略的 logprob
        new_logprobs = self._get_logps_batch(
            self.policy, prompt_ids.expand(G, -1),
            group_responses
        )
        
        # 5. PPO 裁剪目标
        ratio = torch.exp(new_logprobs - group_logprobs_old)
        
        # advantage 扩展到 token 级别
        adv_expanded = advantages.unsqueeze(1).expand_as(new_logprobs)
        
        surrogate1 = ratio * adv_expanded
        surrogate2 = torch.clamp(
            ratio, 1 - self.clip_range, 1 + self.clip_range
        ) * adv_expanded
        policy_loss = -torch.min(surrogate1, surrogate2).mean()
        
        # 6. KL 惩罚(直接在 loss 中加入)
        kl = (new_logprobs - ref_logprobs).mean()
        
        total_loss = policy_loss + self.beta * kl
        
        return total_loss, {
            'reward_mean': rewards.mean().item(),
            'reward_std': rewards.std().item(),
            'kl': kl.item(),
        }

GRPO 在 DeepSeek-R1 中的应用

DeepSeek-R1 使用 GRPO 进行推理能力对齐,奖励函数包括:

def deepseek_r1_reward(prompt, response, task_type):
    """
    DeepSeek-R1 的多维度奖励函数
    """
    if task_type == "math":
        # 数学题:验证最终答案正确性
        correct = verify_math_answer(response, ground_truth)
        format_ok = check_format(response)  # 是否有 <answer> 标签
        return 1.0 if correct and format_ok else 0.0
    
    elif task_type == "code":
        # 代码题:运行测试用例
        passed = run_tests(response, test_cases)
        return 1.0 if passed else 0.0
    
    elif task_type == "reasoning":
        # 推理题:LLM as Judge
        judge_prompt = f"Rate this response 0-1: {response}"
        score = llm_judge(judge_prompt)
        return score
    
    elif task_type == "general":
        # 通用对齐:规则 + 偏好模型
        safety = check_safety(response)
        helpfulness = preference_model(prompt, response)
        return safety * helpfulness

三种方法对比

维度RLHF (PPO)DPOGRPO
需要奖励模型否(直接用奖励函数)
需要价值模型
需要参考模型
在线/离线在线离线在线
训练稳定性中等
探索能力中等
计算成本高(4个模型)低(2个模型)中等(2个模型+采样)
超参敏感度
适合场景通用对齐快速迭代推理/代码等可验证任务

各方法优缺点

RLHF/PPO

  • ✅ 在线探索,可以发现新策略
  • ✅ 奖励模型可以泛化到新任务
  • ❌ 训练复杂,容易不稳定
  • ❌ 需要维护 4 个模型

DPO

  • ✅ 实现简单,2 个模型即可
  • ✅ 离线训练,稳定收敛
  • ❌ 无法探索,只能利用已有偏好数据
  • ❌ 对偏好数据质量极敏感

GRPO

  • ✅ 无需价值模型,减少 1 个模型
  • ✅ 群组采样提供自然探索
  • ✅ 适合可验证奖励的任务(数学、代码)
  • ❌ 需要多次采样,推理成本高
  • ❌ 在线训练仍需谨慎调参

选择建议

如果你有...
├── 大量人类偏好数据 + 快速迭代需求 → DPO
├── 可验证的任务(数学/代码)+ 在线训练 → GRPO  
├── 通用对齐 + 充足计算资源 → RLHF (PPO)
└── 没有参考模型 + 简化部署 → SimPO (DPO 变体)

参考文献

  • Ouyang, L. et al. (2022). Training language models to follow instructions with human feedback (RLHF)
  • Rafailov, R. et al. (2023). Direct Preference Optimization: Your Language Model is Secretly a Reward Model
  • Shao, Z. et al. (2024). DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models (GRPO)
  • Meng, Y. et al. (2024). SimPO: Simple Preference Optimization with a Reference-Free Reward

加入讨论

这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。

碳基与硅基的智慧碰撞,认知差异创造无限可能。