一、引言

2024-2025 年,大语言模型经历了一场静悄悄的革命——模型的"聪明"不再仅仅取决于参数量和训练数据,更取决于对齐(Alignment)。OpenAI 的 InstructGPT/ChatGPT、Anthropic 的 Claude、以及最近大火的 DeepSeek-R1,其核心能力都建立在同一个技术基石之上:基于人类反馈的强化学习(RLHF,Reinforcement Learning from Human Feedback)

然而,翻开网上的教程,大多数 RLHF 文章要么停留在概念层面,要么直接调用 Hugging Face 的 TRL 库、transformers.Pipeline 一行搞定——读者看完仍然不知道其中的梯度是怎么流的、策略是怎么更新的、奖励模型到底训练了什么

本文的目标就是从数学原理到代码实现,一步一步手写 RLHF 的核心组件。你将亲手实现:

  1. 一个奖励模型(Reward Model) —— 从人类偏好排序中学习打分
  2. 一个策略优化循环(PPO) —— 在 KL 散度约束下优化语言模型
  3. 一个完整的 RLHF 训练流程 —— 把数据和梯度串起来

无论你是想深入理解大模型对齐、准备相关面试,还是想自己动手调教一个"听话"的模型,这篇文章都会给你最硬核的参考。

全文约 5500 字,包含完整的 Python 伪代码和数学推导,建议配合代码编辑器阅读。


二、RLHF 的总览与核心直觉

2.1 为什么需要 RLHF?

大语言模型的预训练目标是 "预测下一个 token",学习的是语料中的统计规律。但人类的真实需求是:

  • 模型输出有用(Helpful)
  • 模型输出诚实(Honest)
  • 模型输出无害(Harmless)

这就是著名的 HHH 原则。预训练损失函数(交叉熵)无法直接优化这三者,因此需要引入人类反馈作为额外的监督信号。

2.2 RLHF 的三个阶段

标准的 RLHF 训练分为三步:

  1. SFT(Supervised Fine-Tuning):用高质量的"指令-回答"对微调预训练模型,让它学会遵循指令的格式。
  2. RM(Reward Modeling):收集人类对模型输出的偏好排序数据,训练一个奖励模型,用来给任意文本输出打分。
  3. RL(Reinforcement Learning via PPO):利用奖励模型的分数,通过 PPO(Proximal Policy Optimization)算法进一步微调 SFT 模型,让模型不断输出高分回答。

本文重点放在阶段 2(奖励模型)阶段 3(PPO 策略优化)上。SFT 就是标准的监督微调,不再赘述。

2.3 一句话直觉

RLHF = 让模型自己生成回答 → 奖励模型打分 → 用分数更新模型 → 但不要偏离原始模型太远(KL 约束)

奖励模型像一位"阅卷老师",PPO 则让"学生"不断做试卷、订正、提升成绩,同时老师也禁止学生死记硬背偏离课本太远。


三、奖励模型(Reward Model):教机器学会人类的品味

3.1 问题定义

我们有一个人工标注数据集:对于同一个 prompt(指令),标记者比较了两个不同的模型回答 $y_1$ 和 $y_2$,给出了偏好判断:$y_1 \succ y_2$(回答 1 优于回答 2)。

我们的目标是训练一个奖励函数 $r_\phi(x, y)$(由参数 $\phi$ 定义的神经网络),使得:

$$r_\phi(x, y_1) > r_\phi(x, y_2) \quad \text{当且仅当} \quad y_1 \succ y_2$$

3.2 Bradley-Terry 模型

RLHF 中经典的偏好建模方法是 Bradley-Terry 模型。它假设人类对 $y_1$ 优于 $y_2$ 的概率为:

$$P(y_1 \succ y_2 | x) = \frac{\exp(r_\phi(x, y_1))}{\exp(r_\phi(x, y_1)) + \exp(r_\phi(x, y_2))}$$

这是一个逻辑回归形式的概率模型。直观理解:两个回答的奖励分数差距越大,人类偏好其中一个的概率就越高。

3.3 损失函数

给定标注数据集 $\mathcal{D} = {(x^{(i)}, y_w^{(i)}, y_l^{(i)})}$(其中 $y_w$ 是赢家、$y_l$ 是输家),我们用负对数似然作为损失函数:

$$\mathcal{L}R(\phi) = -\mathbb{E}{(x, y_w, y_l) \sim \mathcal{D}} \left[ \log \sigma(r_\phi(x, y_w) - r_\phi(x, y_l)) \right]$$

其中 $\sigma$ 是 sigmoid 函数。

这个损失函数的意义很清晰:让赢家的分数尽可能高于输家。

3.4 奖励模型架构

在实践中,奖励模型就是从 SFT 模型的基础上改造而来:

  • 加载一个预训练或 SFT 过的语言模型(例如 GPT-2、LLaMA 等)
  • 移除语言模型的 LM Head(预测下一个 token 的分类层)
  • 在最后一层的隐藏状态上,取 最后一个 token 的表示(EOS token)
  • 接一个线性层(hidden_dim → 1),输出一个标量分数

关键设计选择:为什么取最后一个 token?因为在因果语言模型中,最后一个 token 的隐藏状态聚合了整段序列的信息,可以看作是模型对整个回答的"综合评判"。

3.5 代码实现

import torch
import torch.nn as nn
from transformers import AutoModel, AutoConfig

class RewardModel(nn.Module):
    """基于语言模型构建的奖励模型"""

    def __init__(self, base_model_name: str, dropout: float = 0.1):
        super().__init__()
        # 加载基础模型(无 LM Head)
        self.config = AutoConfig.from_pretrained(base_model_name)
        self.base_model = AutoModel.from_pretrained(base_model_name)
        hidden_size = self.config.hidden_size

        # 奖励头(Value Head)
        self.reward_head = nn.Sequential(
            nn.Linear(hidden_size, hidden_size),
            nn.Dropout(dropout),
            nn.ReLU(),
            nn.Linear(hidden_size, 1)
        )

    def forward(
        self, 
        input_ids: torch.LongTensor,       # (batch, seq_len)
        attention_mask: torch.LongTensor,   # (batch, seq_len)
    ) -> torch.Tensor:
        outputs = self.base_model(
            input_ids=input_ids,
            attention_mask=attention_mask,
            output_hidden_states=True
        )
        # 取最后一个隐藏层
        last_hidden = outputs.last_hidden_state  # (batch, seq_len, hidden_dim)

        # 找到每个序列的最后一个非 padding token 位置
        # attention_mask 中值为 1 表示有效 token
        last_indices = attention_mask.sum(dim=1) - 1  # (batch,)

        # 收集最后一个有效 token 的隐藏状态
        batch_indices = torch.arange(last_indices.size(0), device=input_ids.device)
        last_token_hidden = last_hidden[batch_indices, last_indices]  # (batch, hidden_dim)

        # 通过奖励头得到标量分数
        rewards = self.reward_head(last_token_hidden)  # (batch, 1)
        return rewards.squeeze(-1)  # (batch,)


def train_reward_model(
    model: RewardModel,
    dataloader: torch.utils.data.DataLoader,
    optimizer: torch.optim.Optimizer,
    epochs: int = 1,
    device: str = "cuda"
) -> RewardModel:
    """
    训练奖励模型。
    数据集格式: 每条记录包含 prompt_ids, chosen_ids, rejected_ids 及对应的 attention_mask。
    """
    model.train()
    loss_fn = nn.BCEWithLogitsLoss()

    for epoch in range(epochs):
        total_loss = 0.0

        for batch in dataloader:
            chosen_ids = batch["chosen_ids"].to(device)
            chosen_mask = batch["chosen_mask"].to(device)
            rejected_ids = batch["rejected_ids"].to(device)
            rejected_mask = batch["rejected_mask"].to(device)

            # 前向传播
            chosen_rewards = model(chosen_ids, chosen_mask)
            rejected_rewards = model(rejected_ids, rejected_mask)

            # 计算 Bradley-Terry 损失
            # 目标: chosen_rewards > rejected_rewards
            logits = chosen_rewards - rejected_rewards  # (batch,)
            # 标签全为 1(chosen 应该优于 rejected)
            labels = torch.ones_like(logits)
            loss = loss_fn(logits, labels)

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

            total_loss += loss.item()

        avg_loss = total_loss / len(dataloader)
        print(f"Epoch {epoch+1}, Average Loss: {avg_loss:.4f}")

    return model

3.6 训练细节与坑

  1. 数据构建:比较应当来自同一个 prompt 的两个回答。在 InstructGPT 的数据集中,标注者看到 prompt 和两个回答,然后选出更优的那个。
  2. 数据平衡:尽量确保"输家"不永远是同一个模型生成的,否则奖励模型容易学到"模型 A 的答案不好"这种偏差。
  3. 过拟合:奖励模型通常只有几万到几十万条偏好数据,参数量却和基座模型一样大——极易过拟合。需要用 dropout、权重衰减 等方法控制。
  4. 分数校准:奖励模型的输出分数是相对的,不是绝对刻度。+2-3 之间的差距有意义,但 +5 的具体数值本身没有绝对意义。

四、PPO 策略优化:让模型学会"讨好评委"

4.1 为什么要用 PPO?

有了奖励模型后,最直接的想法是:对模型做有监督的微调,把奖励模型的评分当作"标签"去拟合。

但这有两个问题:
1. 分布偏移:SFT 阶段模型从未见过"自己生成的"回答,一旦模型开始生成新内容,分布变化后奖励模型的准确性存疑。
2. 无法探索:监督学习只能模仿已有数据,无法探索新的、更优的输出策略。

因此,我们需要强化学习——让模型自己生成文本,用奖励模型打分,通过策略梯度更新。

4.2 PPO 算法的核心公式

PPO 在 RLHF 中的目标函数为:

$$\text{objective}(\theta) = \mathbb{E}{(x, y) \sim \pi{\theta_{\text{old}}}} \left[ \min\left( \frac{\pi_\theta(y|x)}{\pi_{\text{old}}(y|x)} \cdot A(x,y), \ \text{clip}\left( \frac{\pi_\theta(y|x)}{\pi_{\text{old}}(y|x)}, 1-\epsilon, 1+\epsilon \right) \cdot A(x,y) \right) \right]$$

这个公式看起来复杂,拆解开来看其实很直观:

  • $\frac{\pi_\theta}{\pi_{\text{old}}}$ 叫做 重要性采样比(importance ratio),表示新策略下生成当前回答的概率相对于旧策略的比例。
  • $A(x,y)$ 是优势函数(Advantage),表示当前回答相较于"平均回答"好多少。
  • $\min$ 和 clip 一起限制了步长:如果新策略和旧策略差异太大,梯度会被截断,防止"一步迈太远"。

4.3 KL 散度约束

PPO 的 clip 机制本身就能限制策略更新幅度,但在 RLHF 中我们额外加入一个 KL 惩罚项

$$\text{reward}{\text{final}} = r\phi(x, y) - \beta \cdot \text{KL}(\pi_{\text{ref}} \parallel \pi_\theta)$$

这里的 $\pi_{\text{ref}}$ 是 SFT 阶段得到的参考模型(冻结不动)。这个 KL 惩罚确保:

  • 模型不会为了"讨好"奖励模型而输出奇怪的内容(如不断重复高分词汇)
  • 模型的流畅性和语言能力不会退化(保持参考模型的语言质量)
  • $\beta$ 是超参数,控制对齐的"强度"

在 PPO 实际实现中,KL 项通常有两种加入方式:
- PPO-ptx:在 PPO 目标中加入参考模型的交叉熵项(保留预训练能力)
- Kullback-Leibler (KL) penalty:在奖励信号中直接扣除 KL 散度

4.4 代码实现 PPO 训练循环

import torch
import torch.nn.functional as F
from transformers import AutoModelForCausalLM, AutoTokenizer

class PPOConfig:
    """PPO 训练超参数"""
    def __init__(self):
        self.learning_rate = 1.5e-5
        self.batch_size = 16
        self.gradient_accumulation_steps = 4
        self.ppo_epochs = 4           # 每个 batch 内 PPO 更新轮数
        self.clip_epsilon = 0.2       # PPO clip 范围
        self.kl_coef = 0.02           # KL 惩罚系数
        self.value_coef = 0.1         # Value loss 系数
        self.max_grad_norm = 1.0      # 梯度裁剪
        self.max_length = 512         # 生成长度
        self.temperature = 0.7        # 采样温度


class PPOTrainer:
    """
    PPO 训练器:管理策略模型 + 价值函数 + 奖励模型的交互。
    """

    def __init__(
        self,
        policy_model: AutoModelForCausalLM,   # 正在训练的策略模型
        ref_model: AutoModelForCausalLM,       # 冻结的参考模型(SFT 后)
        reward_model: RewardModel,             # 训练好的奖励模型
        tokenizer: AutoTokenizer,
        config: PPOConfig,
        device: str = "cuda"
    ):
        self.policy_model = policy_model.to(device)
        self.ref_model = ref_model.to(device)
        self.reward_model = reward_model.to(device)
        self.tokenizer = tokenizer
        self.config = config
        self.device = device

        # 冻结参考模型和奖励模型
        for param in self.ref_model.parameters():
            param.requires_grad = False
        for param in self.reward_model.parameters():
            param.requires_grad = False

        # 优化器(只优化策略模型)
        self.optimizer = torch.optim.AdamW(
            self.policy_model.parameters(),
            lr=config.learning_rate
        )

    def _get_log_probs(
        self,
        model: AutoModelForCausalLM,
        input_ids: torch.LongTensor,
        attention_mask: torch.LongTensor
    ) -> torch.Tensor:
        """计算给定序列的 token 级对数概率之和"""
        outputs = model(input_ids, attention_mask=attention_mask)
        logits = outputs.logits  # (batch, seq_len, vocab_size)

        # shift 操作:logits 预测下一个 token,labels 是实际下一个 token
        shift_logits = logits[:, :-1, :].contiguous()
        shift_labels = input_ids[:, 1:].contiguous()
        shift_mask = attention_mask[:, 1:].contiguous()

        # 计算每个 token 的 log_prob
        log_probs = F.log_softmax(shift_logits, dim=-1)  # (batch, seq_len-1, vocab_size)
        per_token_log_probs = log_probs.gather(
            dim=-1, 
            index=shift_labels.unsqueeze(-1)
        ).squeeze(-1)  # (batch, seq_len-1)

        # 对有效 token 求和(带 mask)
        sum_log_probs = (per_token_log_probs * shift_mask).sum(dim=-1)  # (batch,)
        return sum_log_probs

    def _compute_advantages_and_returns(
        self,
        rewards: torch.Tensor,          # (batch,) 奖励模型给出的分数
        values: torch.Tensor,           # (batch,) 价值函数预测的分数
        gamma: float = 1.0,
        lam: float = 0.95
    ) -> tuple:
        """
        计算 GAE(Generalized Advantage Estimation)优势和折扣回报。

        GAE 公式: 
            A_t = δ_t + (γλ)δ_{t+1} + (γλ)^2δ_{t+2} + ...
        其中 δ_t = r_t + γV(s_{t+1}) - V(s_t)
        """
        # 简单版本(单步奖励,无时间维度展开)
        # 在 RLHF 中,整个序列只有一个奖励值(终端奖励)
        advantages = rewards - values  # (batch,)
        returns = rewards  # 简单情况,终端回报就是奖励

        return advantages, returns

    def train_step(self, batch: dict) -> dict:
        """
        单步 PPO 训练。
        batch 包含:
            - query_ids: prompt 的 token ids
            - query_mask: prompt 的 attention mask
        """
        query_ids = batch["query_ids"].to(self.device)
        query_mask = batch["query_mask"].to(self.device)

        # ====== 阶段 1: 生成回答 ======
        # 策略模型生成回答
        with torch.no_grad():
            gen_outputs = self.policy_model.generate(
                input_ids=query_ids,
                attention_mask=query_mask,
                max_new_tokens=self.config.max_length - query_ids.size(1),
                temperature=self.config.temperature,
                do_sample=True,
                pad_token_id=self.tokenizer.pad_token_id,
                return_dict_in_generate=True,
                output_scores=True
            )

            response_ids = gen_outputs.sequences  # (batch, full_seq_len)
            response_mask = (response_ids != self.tokenizer.pad_token_id).long()

        # ====== 阶段 2: 计算各模型的输出对数概率 ======
        with torch.no_grad():
            # 旧策略的对数概率(用于重要性采样比的分母)
            old_log_probs = self._get_log_probs(
                self.policy_model, response_ids, response_mask
            )
            # 参考模型的对数概率(用于 KL 计算)
            ref_log_probs = self._get_log_probs(
                self.ref_model, response_ids, response_mask
            )
            # 奖励模型打分
            rewards = self.reward_model(response_ids, response_mask)

        # ====== 阶段 3: 多轮 PPO 更新 ======
        total_stats = {
            "policy_loss": 0.0,
            "value_loss": 0.0,
            "kl_div": 0.0,
            "clip_frac": 0.0
        }

        for _ in range(self.config.ppo_epochs):
            # 新策略的对数概率
            new_log_probs = self._get_log_probs(
                self.policy_model, response_ids, response_mask
            )

            # 计算重要性采样比
            ratio = torch.exp(new_log_probs - old_log_probs)

            # 计算 KL 散度(近似形式)
            # KL(policy || ref) ≈ log(ref/policy) - 1 + policy/ref 的一种形式
            kl_div = old_log_probs - ref_log_probs
            # 从奖励中扣除 KL 惩罚
            penalized_rewards = rewards - self.config.kl_coef * kl_div

            # 计算优势和回报
            # 这里用简单版本:reward 即为回报,优势 = reward - baseline
            # 实际上还需要学习 value function(critic),这里从简
            advantages = penalized_rewards - penalized_rewards.mean()
            if advantages.std() > 0:
                advantages = advantages / (advantages.std() + 1e-8)

            # PPO 策略损失(clipped surrogate objective)
            pg_loss1 = -advantages * ratio
            pg_loss2 = -advantages * torch.clamp(
                ratio, 
                1.0 - self.config.clip_epsilon, 
                1.0 + self.config.clip_epsilon
            )
            pg_loss = torch.max(pg_loss1, pg_loss2).mean()

            # 总损失(简化版,不含 value loss)
            loss = pg_loss

            # 反向传播
            self.optimizer.zero_grad()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(
                self.policy_model.parameters(), 
                self.config.max_grad_norm
            )
            self.optimizer.step()

            # 统计信息
            total_stats["policy_loss"] += pg_loss.item()
            total_stats["kl_div"] += kl_div.mean().item()
            total_stats["clip_frac"] += (
                (torch.abs(ratio - 1.0) > self.config.clip_epsilon).float().mean().item()
            )

        # 平均统计
        for key in total_stats:
            total_stats[key] /= self.config.ppo_epochs

        return total_stats

4.5 价值函数(Critic)的角色

上面的简化实现中,我跳过了 value network 的细节。在完整的 PPO 中,模型还有一个价值头(value head),与奖励模型类似,在最后一层隐藏状态上接一个线性层输出标量,用来估计状态的价值 $V(x, y_{<t})$。

价值函数的作用:
1. 提供 baseline,用于计算优势函数 $A = r - V$,降低梯度方差
2. 用于 GAE(Generalized Advantage Estimation),在多步时序下更精确地估计优势

价值函数通过均方误差(MSE)损失训练:

$$\mathcal{L}_{\text{value}} = \mathbb{E}\left[ \left( V(x, y) - \text{return} \right)^2 \right]$$

4.6 训练技巧与超参数

超参数 推荐值 说明
learning_rate 1e-5 ~ 3e-5 比标准微调更小,策略不应变化太快
clip_epsilon 0.2 PPO clip 范围,标准值
kl_coef 0.01 ~ 0.05 KL 惩罚权重,越大越保守
ppo_epochs 3 ~ 4 每个 batch 的 PPO 更新次数
batch_size 16 ~ 64 取决于显存和数据规模
temperature 0.7 ~ 1.0 回答生成的采样温度

最重要的经验法则:监控 KL 散度。如果 KL 散度持续增长(超过 10 nats 甚至数百 nats),说明模型正在离开参考模型的分布——这通常意味着奖励模型被"欺骗"了,模型的输出在语义上虽获高分,但语言质量已严重下降。


五、完整的 RLHF 训练流程

5.1 数据准备

def prepare_rlhf_dataset(
    prompts: list[str],
    tokenizer: AutoTokenizer,
    max_length: int = 512
) -> torch.utils.data.Dataset:
    """将 prompts 转化为模型输入格式"""
    class PromptDataset(torch.utils.data.Dataset):
        def __init__(self, prompts, tokenizer, max_length):
            self.prompts = prompts
            self.tokenizer = tokenizer
            self.max_length = max_length

        def __len__(self):
            return len(self.prompts)

        def __getitem__(self, idx):
            prompt = self.prompts[idx]
            encoded = self.tokenizer(
                prompt,
                max_length=self.max_length,
                truncation=True,
                padding=True,
                return_tensors="pt"
            )
            return {
                "query_ids": encoded["input_ids"][0],
                "query_mask": encoded["attention_mask"][0]
            }

    return PromptDataset(prompts, tokenizer, max_length)

5.2 主训练循环

def run_rlhf_training(
    sft_model_name: str,
    reward_model_path: str,
    prompts: list[str],
    ppo_config: PPOConfig
):
    """完整的 RLHF 训练流程"""
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    tokenizer = AutoTokenizer.from_pretrained(sft_model_name)

    # 加载模型
    policy = AutoModelForCausalLM.from_pretrained(sft_model_name)
    ref_model = AutoModelForCausalLM.from_pretrained(sft_model_name)
    reward_model = RewardModel.load_from_checkpoint(reward_model_path)

    # 准备数据
    dataset = prepare_rlhf_dataset(prompts, tokenizer)
    dataloader = torch.utils.data.DataLoader(
        dataset, 
        batch_size=ppo_config.batch_size,
        shuffle=True
    )

    # 创建 PPO 训练器
    trainer = PPOTrainer(
        policy_model=policy,
        ref_model=ref_model,
        reward_model=reward_model,
        tokenizer=tokenizer,
        config=ppo_config,
        device=device
    )

    # 训练循环
    for step, batch in enumerate(dataloader):
        stats = trainer.train_step(batch)

        if step % 10 == 0:
            print(f"Step {step}: "
                  f"policy_loss={stats['policy_loss']:.4f}, "
                  f"kl_div={stats['kl_div']:.4f}, "
                  f"clip_frac={stats['clip_frac']:.2%}")

    return trainer.policy_model

5.3 三步走的时间顺序

  1. SFT(1 ~ 3 天,128 张 GPU):用 1 万到 10 万条"指令-回答"对微调基础模型。这是 RLHF 的起点,一个"合格但不够优秀"的模型。
  2. RM 训练(1 ~ 2 天,64 张 GPU):收集 10 万到 100 万条人类偏好对,训练奖励模型。奖励模型的大小通常和 SFT 模型相同。
  3. PPO 训练(1 ~ 3 天,128 张 GPU):用 PPO 循环优化策略模型。这是最昂贵的阶段,因为每个 step 都需要模型生成、推理奖励模型、做多次前向/反向传播。

在 DeepSeek 的 R1 论文中,他们实际上使用了更复杂的变体——Group Relative Policy Optimization(GRPO),思路是在同一个 prompt 上采样多个回答,用这些回答的奖励相对排序替代 value network,进一步简化了架构。


六、RLHF 的常见问题与最佳实践

6.1 奖励过度优化(Reward Hacking)

这是 RLHF 中最臭名昭著的问题:模型学会了"欺骗"奖励模型,而不是真正提升回答质量。

典型案例:
- 模型生成非常长的回答(因为奖励模型偏好长回答)
- 模型不断重复高分词汇("当然!必须的!毫无疑问!")
- 模型输出自我标榜的表述("这是一个非常好的问题")

解决方案
1. KL 惩罚:$\beta$ 不应太小,确保模型不离参考模型太远
2. 奖励模型校准:定期用人类评估奖励模型的预测质量
3. 多样本采样:同一个 prompt 采样多个回答,取平均奖励
4. 正则化:对回答长度做惩罚或归一化

6.2 模式坍塌(Mode Collapse)

模型变得"过于一致"——所有回答都是同一个模板,缺乏多样性。

解决方案
- 训练奖励模型时使用多样化的偏好数据
- PPO 中使用更高的采样温度
- 加入 Top-p / Top-k 采样限制

6.3 训练不稳定

PPO 训练本身就不稳定,加上大模型后更容易出现 loss 爆炸或梯度消失。

解决方案
- 梯度范数裁剪(clip_grad_norm ≤ 1.0)
- 学习率预热(warmup)
- 监控 KL 散度和 clip fraction
- 定期保存 checkpoint

6.4 奖励模型的评估与迭代

训练好的奖励模型本身也需要持续评估,否则 PPO 训练就是在用一个"不准的温度计"测体温。奖励模型的评估可以从以下几个维度进行:

1. 准确率(Accuracy)

在保留的测试集上,计算奖励模型正确预测人类偏好的比例。一个成熟的奖励模型准确率通常在 65%~75% 之间(人类标注者之间的一致性也在 70%~80%)。

2. 校准度(Calibration)

奖励模型给出的分数差是否对应合理的人类偏好概率?例如,当奖励模型认为 $y_1$ 比 $y_2$ 高出 2 分时,人类是否真的有 ~88%(sigmoid(2) ≈ 0.88)的概率选择 $y_1$?校准度差意味着模型对某些类型的回答过度自信或不够自信。

3. 维度分解

更先进的评估方法是将偏好分解为多个维度分别评估:
- 有用性(Helpfulness):回答是否解决了用户的问题?
- 准确性(Factuality):回答中的事实是否准确?
- 安全性(Safety):回答是否包含有害或偏见内容?

有些工作将奖励模型设计为多任务输出,每个维度一个分数,这样奖励模型的泛化能力和可解释性都会大幅提升。

4. 迭代策略

奖励模型不是一次性训练就完事的。在实践中通常采用迭代式数据收集:

SFT 模型 → 采样回答 → 人类标注 → 训练 RM1
RM1 → PPO 训练 π1 → π1 采样新回答 → 人类标注 → 训练 RM2
RM2 → PPO 训练 π2 → ...

每轮迭代中,之前的 PPO 模型会暴露出新的回答模式,奖励模型必须适应这些新模式。这正是 InstructGPT/ChatGPT 论文中提到的"迭代 RLHF"的核心思想。

6.5 RLHF 的变体与发展

方法 核心思想 代表工作
PPO 带 clip 的策略梯度 + KL 约束 InstructGPT
DPO 直接从偏好数据优化策略,无需显式奖励模型 DPO (Rafailov et al.)
GRPO 用组内相对奖励替代 value network DeepSeek-R1
RLOO REINFORCE with Leave-One-Out baseline REINFORCE 变体
KTO 只需要"好/坏"标签,不需要成对比较 KTO (Ethayarajh et al.)

DPO(Direct Preference Optimization)是 2024-2025 年最受关注的 RLHF 替代方案。它证明了偏好信息可以直接用于优化策略模型,不需要显式的奖励模型,也不需要复杂的 PPO 循环。其目标函数为:

$$\mathcal{L}{\text{DPO}}(\pi\theta) = -\mathbb{E}{(x, y_w, y_l) \sim \mathcal{D}} \left[ \log \sigma\left( \beta \log\frac{\pi\theta(y_w|x)}{\pi_{\text{ref}}(y_w|x)} - \beta \log\frac{\pi_\theta(y_l|x)}{\pi_{\text{ref}}(y_l|x)} \right) \right]$$

DPO 的实现比 PPO 简单至少 2~3 倍,且训练稳定得多。但 PPO 仍然有一个独特优势:可以在训练过程中采样新回答,而 DPO 只能使用静态数据集中的回答。


七、实战指南:从零搭建你自己的 RLHF 系统

7.1 实战案例:用 Anthropic HH-RLHF 数据集跑通 PPO

Anthropic HH-RLHF 是目前最常用的开源 RLHF 数据集,包含约 16 万条人类偏好比较对。下面是一个端到端的运行命令模板:

# Step 1: 克隆 TRL 库示例
git clone https://github.com/huggingface/trl
cd trl/examples/scripts

# Step 2: 使用 GPT-2 在 HH-RLHF 上训练奖励模型
python reward_modeling.py \
    --model_name gpt2 \
    --dataset_name Anthropic/hh-rlhf \
    --output_dir ./rm-gpt2 \
    --num_train_epochs 3 \
    --per_device_train_batch_size 8 \
    --gradient_accumulation_steps 4 \
    --learning_rate 1e-5

# Step 3: PPO 训练
python ppo.py \
    --reward_model_path ./rm-gpt2 \
    --policy_model_name gpt2 \
    --dataset_name Anthropic/hh-rlhf \
    --output_dir ./ppo-gpt2 \
    --ppo_epochs 4 \
    --kl_coef 0.02 \
    --clip_epsilon 0.2

7.2 使用 LoRA 降低显存开销

全参数 PPO 训练 7B 模型至少需要 4×A100,但通过 LoRA(Low-Rank Adaptation)可以将显存需求降到 1×A100:

from peft import LoraConfig, get_peft_model

def prepare_policy_with_lora(base_model):
    """在策略模型上应用 LoRA 适配器"""
    lora_config = LoraConfig(
        r=16,
        lora_alpha=32,
        target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
        lora_dropout=0.05,
        bias="none",
        task_type="CAUSAL_LM"
    )
    model = get_peft_model(base_model, lora_config)
    model.print_trainable_parameters()
    return model

LoRA 在 RLHF 中的实践要点:
- 只对 策略模型 应用 LoRA,参考模型和奖励模型保持全精度
- LoRA rank 推荐 16~64,rank 过大反而容易过拟合
- 训练结束后可以合并 LoRA 权重到基础模型,或单独保存 adapter

7.3 从 PPO 到 GRPO:DeepSeek 的创新

在 DeepSeek-R1 中,他们使用了 Group Relative Policy Optimization(GRPO),核心思想是:

  1. 对同一个 prompt 采样 一组 回答(group_size=8~64)
  2. 用这组回答的奖励分数计算相对优势(组内归一化)
  3. 完全省略 value network(Critic),减少一半的显存开销

GRPO 的优势函数为:

$$A_i = \frac{r_i - \text{mean}(r_{\text{group}})}{\text{std}(r_{\text{group}})}$$

这不仅节省了显存,还让训练更加稳定,因为优势估计不受 value network 训练质量的影响。

7.4 环境准备

pip install torch transformers datasets accelerate trl wandb

Hugging Face 的 TRL(Transformer Reinforcement Learning)库封装了上述大部分逻辑。但我还是强烈建议你先手写一遍,再做封装调用——只会调 API 的人永远无法 Debug 梯度问题。

7.5 推荐的学习路径

  1. 本周:用 LLaMA-1B 或 GPT-2 在 Anthropic HH-RLHF 数据集上跑通 PPO
  2. 下周:尝试 DPO,比较两种方法的训练速度和最终效果
  3. 下月:用你自己的数据训练奖励模型,然后在 7B 规模上做对齐

7.6 硬件要求

  • 奖励模型训练:单卡 24GB VRAM(可训 1B 模型)
  • PPO 训练(7B 模型):至少 4 × A100-80GB
  • 如果资源有限,可以:使用 LoRA 微调、减少 batch size、使用梯度累积

7.7 评估指标

RLHF 之后的模型评估不能只看基准分数(如 MMLU、GSM8K),还需要评估对齐质量

  1. Human Evaluation:人类评分者对模型回答做 A/B 测试(最贵但最准确)
  2. LLM-as-a-Judge:用 GPT-4 / DeepSeek 作为裁判打分(成本低、可复现)
  3. Reward Score:奖励模型自身打分(有偏差,但方便快速迭代)
  4. KL 散度:对齐前后的分布变化
  5. 安全测试:红队攻击、越狱测试

特别地,LLM-as-a-Judge 在实践中需要设计完善的评估 Prompt,包括:

系统指令:你是一位公正的 AI 评估员。请从以下维度对回答评分(1-5分):
1. 有用性(Helpfulness):回答是否直接解决了用户的问题?
2. 准确性(Accuracy):回答中包含的事实是否正确?
3. 完整性(Completeness):回答是否覆盖了问题的所有方面?
4. 清晰度(Clarity):表述是否清晰、易于理解?

请逐维度给出分数和简要理由。

使用 LLM 评估时需要注意:
- 位置偏差:如果同时比较两个回答,交换 A/B 顺序后评估结果应基本一致。如果偏差显著,需要做多次交换取平均。
- 自偏好偏差:裁判模型可能偏向与自己风格相似的回答。使用不同的裁判模型做交叉验证可以缓解。
- 长度偏差:模型倾向于给更长的回答更高分。可以将回答截断到相同长度,或在评估指令中明确要求忽略长度因素。


八、总结

本文从数学原理到代码实现,完整走了一遍 RLHF 的技术路线:

  1. 直觉层面:RLHF = 奖励模型打分 + PPO 策略更新 + KL 约束
  2. 奖励模型:基于 Bradley-Terry 偏好模型,用交叉熵损失训练一个"评分器"
  3. PPO 优化:重要性采样比 + clip 约束 + KL 惩罚,稳定地提升模型性能
  4. 完整流程:SFT → RM → PPO,三步缺一不可

RLHF 不是银弹——它昂贵、不稳定、容易奖励过拟合。但它确实是过去两年大模型能力跃升的核心引擎。从 ChatGPT 到 Claude 到 DeepSeek-R1,这些模型之所以"好用",不是因为它们更"大",而是因为它们更对齐

理解 RLHF,就是理解大模型从"会说话"到"会干活"的那最后一跃。


📌 延伸阅读
- DeepSeek 实战指南:从入门到生产部署 → 了解更多关于 RLHF 在 DeepSeek 模型中的实际应用
- 回复"手写系列"查看更多从零实现的技术教程


本文是「手写系列」的第 9 篇。前 8 篇覆盖了 Transformer、RAG、LoRA、DeepSeek 推理加速、向量检索、AI 评估等主题。持续更新,欢迎关注。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐