手写 RLHF(强化学习人类反馈):从零实现大模型对齐训练
一、引言
2024-2025 年,大语言模型经历了一场静悄悄的革命——模型的"聪明"不再仅仅取决于参数量和训练数据,更取决于对齐(Alignment)。OpenAI 的 InstructGPT/ChatGPT、Anthropic 的 Claude、以及最近大火的 DeepSeek-R1,其核心能力都建立在同一个技术基石之上:基于人类反馈的强化学习(RLHF,Reinforcement Learning from Human Feedback)。
然而,翻开网上的教程,大多数 RLHF 文章要么停留在概念层面,要么直接调用 Hugging Face 的TRL库、transformers.Pipeline一行搞定——读者看完仍然不知道其中的梯度是怎么流的、策略是怎么更新的、奖励模型到底训练了什么。
本文的目标就是从数学原理到代码实现,一步一步手写 RLHF 的核心组件。你将亲手实现:
- 一个奖励模型(Reward Model)—— 从人类偏好排序中学习打分
- 一个策略优化循环(PPO)—— 在 KL 散度约束下优化语言模型
- 一个完整的RLHF 训练流程—— 把数据和梯度串起来
无论你是想深入理解大模型对齐、准备相关面试,还是想自己动手调教一个"听话"的模型,这篇文章都会给你最硬核的参考。
全文约 5500 字,包含完整的 Python 伪代码和数学推导,建议配合代码编辑器阅读。
二、RLHF 的总览与核心直觉
2.1 为什么需要 RLHF?
大语言模型的预训练目标是"预测下一个 token",学习的是语料中的统计规律。但人类的真实需求是:
- 模型输出有用(Helpful)
- 模型输出诚实(Honest)
- 模型输出无害(Harmless)
这就是著名的HHH 原则。预训练损失函数(交叉熵)无法直接优化这三者,因此需要引入人类反馈作为额外的监督信号。
2.2 RLHF 的三个阶段
标准的 RLHF 训练分为三步:
- SFT(Supervised Fine-Tuning):用高质量的"指令-回答"对微调预训练模型,让它学会遵循指令的格式。
- RM(Reward Modeling):收集人类对模型输出的偏好排序数据,训练一个奖励模型,用来给任意文本输出打分。
- 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 model3.6 训练细节与坑
- 数据构建:比较应当来自同一个 prompt的两个回答。在 InstructGPT 的数据集中,标注者看到 prompt 和两个回答,然后选出更优的那个。
- 数据平衡:尽量确保"输家"不永远是同一个模型生成的,否则奖励模型容易学到"模型 A 的答案不好"这种偏差。
- 过拟合:奖励模型通常只有几万到几十万条偏好数据,参数量却和基座模型一样大——极易过拟合。需要用dropout、权重衰减等方法控制。
- 分数校准:奖励模型的输出分数是相对的,不是绝对刻度。
+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_stats4.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_model5.3 三步走的时间顺序
- SFT(1 ~ 3 天,128 张 GPU):用 1 万到 10 万条"指令-回答"对微调基础模型。这是 RLHF 的起点,一个"合格但不够优秀"的模型。
- RM 训练(1 ~ 2 天,64 张 GPU):收集 10 万到 100 万条人类偏好对,训练奖励模型。奖励模型的大小通常和 SFT 模型相同。
- 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.27.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 modelLoRA 在 RLHF 中的实践要点:
- 只对策略模型应用 LoRA,参考模型和奖励模型保持全精度
- LoRA rank 推荐 16~64,rank 过大反而容易过拟合
- 训练结束后可以合并 LoRA 权重到基础模型,或单独保存 adapter
7.3 从 PPO 到 GRPO:DeepSeek 的创新
在 DeepSeek-R1 中,他们使用了Group Relative Policy Optimization(GRPO),核心思想是:
- 对同一个 prompt 采样一组回答(group_size=8~64)
- 用这组回答的奖励分数计算相对优势(组内归一化)
- 完全省略 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 wandbHugging Face 的TRL(Transformer Reinforcement Learning)库封装了上述大部分逻辑。但我还是强烈建议你先手写一遍,再做封装调用——只会调 API 的人永远无法 Debug 梯度问题。
7.5 推荐的学习路径
- 本周:用 LLaMA-1B 或 GPT-2 在 Anthropic HH-RLHF 数据集上跑通 PPO
- 下周:尝试 DPO,比较两种方法的训练速度和最终效果
- 下月:用你自己的数据训练奖励模型,然后在 7B 规模上做对齐
7.6 硬件要求
- 奖励模型训练:单卡 24GB VRAM(可训 1B 模型)
- PPO 训练(7B 模型):至少 4 × A100-80GB
- 如果资源有限,可以:使用 LoRA 微调、减少 batch size、使用梯度累积
7.7 评估指标
RLHF 之后的模型评估不能只看基准分数(如 MMLU、GSM8K),还需要评估对齐质量:
- Human Evaluation:人类评分者对模型回答做 A/B 测试(最贵但最准确)
- LLM-as-a-Judge:用 GPT-4 / DeepSeek 作为裁判打分(成本低、可复现)
- Reward Score:奖励模型自身打分(有偏差,但方便快速迭代)
- KL 散度:对齐前后的分布变化
- 安全测试:红队攻击、越狱测试
特别地,LLM-as-a-Judge 在实践中需要设计完善的评估 Prompt,包括:
系统指令:你是一位公正的 AI 评估员。请从以下维度对回答评分(1-5分): 1. 有用性(Helpfulness):回答是否直接解决了用户的问题? 2. 准确性(Accuracy):回答中包含的事实是否正确? 3. 完整性(Completeness):回答是否覆盖了问题的所有方面? 4. 清晰度(Clarity):表述是否清晰、易于理解? 请逐维度给出分数和简要理由。使用 LLM 评估时需要注意:
-位置偏差:如果同时比较两个回答,交换 A/B 顺序后评估结果应基本一致。如果偏差显著,需要做多次交换取平均。
-自偏好偏差:裁判模型可能偏向与自己风格相似的回答。使用不同的裁判模型做交叉验证可以缓解。
-长度偏差:模型倾向于给更长的回答更高分。可以将回答截断到相同长度,或在评估指令中明确要求忽略长度因素。
八、总结
本文从数学原理到代码实现,完整走了一遍 RLHF 的技术路线:
- 直觉层面:RLHF = 奖励模型打分 + PPO 策略更新 + KL 约束
- 奖励模型:基于 Bradley-Terry 偏好模型,用交叉熵损失训练一个"评分器"
- PPO 优化:重要性采样比 + clip 约束 + KL 惩罚,稳定地提升模型性能
- 完整流程:SFT → RM → PPO,三步缺一不可
RLHF 不是银弹——它昂贵、不稳定、容易奖励过拟合。但它确实是过去两年大模型能力跃升的核心引擎。从 ChatGPT 到 Claude 到 DeepSeek-R1,这些模型之所以"好用",不是因为它们更"大",而是因为它们更对齐。
理解 RLHF,就是理解大模型从"会说话"到"会干活"的那最后一跃。
📌 延伸阅读:
- DeepSeek 实战指南:从入门到生产部署 → 了解更多关于 RLHF 在 DeepSeek 模型中的实际应用
- 回复"手写系列"查看更多从零实现的技术教程
本文是「手写系列」的第 9 篇。前 8 篇覆盖了 Transformer、RAG、LoRA、DeepSeek 推理加速、向量检索、AI 评估等主题。持续更新,欢迎关注。
