手写 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 model
3.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_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 三步走的时间顺序
- 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.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),核心思想是:
- 对同一个 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 wandb
Hugging 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 评估等主题。持续更新,欢迎关注。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)