RLHF 对齐
预训练模型会预测下一个 token,但不区分该不该说。你问它「怎么做炸弹」,它照答不误——因为训练数据里确实有这些续写模式。怎么让模型学会「什么该说,什么不该说」?
这一节走完 RLHF 的完整流程:SFT 打底 → Reward Model 把偏好变成打分 → PPO 优化策略 → DPO 跳过打分直接学偏好。每步都用手算例子讲清楚。
语言模型的训练目标是「预测下一个 token」,但我们要的是「给出有帮助、诚实、无害的回答」。这两个目标之间的差距就是对齐(Alignment)要解决的问题。经典 RLHF 分三个 Stage:先用高质量对话数据做 SFT 打底,再训练一个 Reward Model 来模拟人类偏好,最后用 PPO 强化学习让模型朝高奖励方向优化。
DPO 是 2023 年提出的简化方案——通过数学变换消掉显式的 Reward Model,把偏好数据直接转化为分类 loss。
SFT、Reward Model、PPO、DPO——每个阶段的损失函数和训练目标都不一样。这一节从 3H 原则出发,先用 SFT 打底,再逐步叠加更复杂的训练目标。
1. 为什么需要对齐
预训练模型的目标通常是 next-token prediction:给定前面 N 个 token,预测第 N+1 个 token。它在海量文本上学会了语言和知识的统计模式,但不等于天然知道在每个用户场景中什么回答最有帮助、最安全。
举个例子:
用户: 我最近心情不好,觉得活着没意思。
未对齐的 Base Model(续写模 式,示例):
"她站在天台上,风吹过她的头发,她想起那个背叛她的人...
楼下围满了看热闹的人,有人拿出了手机录像..."
↑ 模型在模仿网上看到的小说!模式统计上是对的——「心情不好」
后面确实经常接这种文学描写。但这个回答完全不合适。
已对齐的模型(Helpful + Harmless,示例):
"我很抱歉你正在经历这些。请记住:你并不孤单,
有很多人愿意帮助你。你可以拨打心理援助热线。
你愿意和我聊聊发生了什么吗?"
↑ 模型识别到这是求助,给出有帮助的回应。
这个对比揭示了对齐的本质问题:预训练让模型学会「这段话后面通常跟什么」,但对齐要让模型学会「在这种情况下应该回答什么」。前者是统计规律,后者是价值判断。
对齐的 3H 原则:
- Helpful(有帮助):回答问题,提供有用信息,不跑题
- Honest(诚实):不知道就说不知道,不编造听起来合理的假话
- Harmless(无害):拒绝有害请求,不助长危险行为
三个原则有时会冲突。比如用户问「我做的炸弹为什么不炸」,诚实回答会提供危险信息,有帮助于这个用户但有害于社会。对齐需要在三者之间找到平衡——通常无害优先。
2. 对齐全景图
整条链路分为几个阶段:
Base Model(预训练完,只会续写)
│
▼ Stage 1: SFT
┌─────────────────────────┐
│ 用高质量对话数据做监督学习 │
│ 教会「对话格式」和基本指令 │
└────────────┬────────────┘
▼
SFT Model(会对话了,但不会区分好坏)
│
┌─────┴─────┐
▼ ▼
Stage 2a: RLHF Stage 2b: DPO
(经典路线) (简化路线)
│ │
① 收集偏好数据 ① 收集偏好数据
② 训练 Reward Model ② 直接优化偏好
③ PPO 强化学习 (跳过 RM + PPO)
│ │
└──────┬───────────┘
▼
已对齐的模型 ✨
下面逐个阶段手算。
3. Stage 1:SFT
3.1 SFT 做了什么
用高质量对话数据做监督训练。训练数据长这样:
<|User|> 法国的首都是什么?
<|Assistant|> 法国的首都是巴黎。
<|User|> 为什么是巴黎?
<|Assistant|> 因为巴黎是法国最大的城市和政治中心,
自中世纪起就是法国的行政首都。
SFT 本质上和预训练一样——交叉熵 loss,预测下一个 token。区别只是数据从「互联网文本」换成了「对话」。
SFT 之后模型学会了:
- 对话格式(一问一答)
- 指令跟随的基本形式
- 「好回答」的表面特征
3.2 但 SFT 有个根本局限
对于「法国的首都是?」这种有唯一答案的问题,SFT 足够。
但对于开放性问题,比如「帮我写一封辞职信」——没有唯一的「标准答案」。 两封辞职信都可以是对的,但哪封更好?SFT 的训练信号无法区分。
这就需要下一阶段:让人类标注员来告诉我们哪个回答更好。
3.3 Loss Masking:SFT 时只对回答部分算 loss
前面提到,SFT 的训练数据是一条完整的对话,格式通常是:
[system prompt] [user message] [assistant response]
模型的任务是「预测下一个 token」。如果用整条数据做训练,模型会同时学习预测 system prompt、user message 和 assistant response。但我们只想让它学会生成 response 部分——毕竟用户不会让模型去预测自己的问题。
解决方法很简单:给 prompt 部分的 token 打上特殊标签,让 loss 函数忽略它们。在 PyTorch 中,这个特殊标签是 -100(CrossEntropyLoss 的 ignore_index 默认值)。这就是 Loss Masking。
具体做法:
- prompt 部分(system + user)的 label 设为
-100,loss 不计算 - response 部分(assistant)的 label 正常设为下一个 token 的 ID
这样,交叉熵 loss 只在 response token 上累加,模型只学习「如何回答」,不会被训练去复述 prompt。
import numpy as np
# === Loss Masking 演示 ===
print("=== Loss Masking:只对回答部分算 loss ===")
print()
# 模拟一条 SFT 训练数据
# 假设 tokenizer 把对话编码成了 9 个 token
# 其中前 5 个是 prompt(system + user),后 4 个是 assistant 的回答
tokens = [1, 5, 3, 8, 2, 7, 9, 4, 6] # 完整 token 序列
prompt_len = 5 # 前 5 个 token 属于 prompt
# 标准 labels:每个位置的 label = 下一个 token(右移一位)
labels_shifted = tokens[1:] + [-1] # [5, 3, 8, 2, 7, 9, 4, 6, -1]
# Loss Masking:prompt 部分的 label 替换为 -100
labels_masked = labels_shifted.copy()
for i in range(prompt_len):
labels_masked[i] = -100 # prompt 部分忽略
print("tokens: ", tokens)
print("labels(原始):", labels_shifted)
print("labels(mask):", labels_masked)
print()
# 用简单的模拟数据演示 loss 计算
# 假设模型对每个位置输出了一个概率分布,我们用「预测是否正确」来简化
# log_probs[i] = 模型在位置 i 预测 tokens[i+1] 的 log 概率
np.random.seed(42)
log_probs = np.random.uniform(-3.0, -0.5, size=len(tokens))
log_probs = np.round(log_probs, 2)
print("--- 不用 Loss Masking ---")
total_loss = 0
count = 0
for i in range(len(tokens) - 1): # 最后一位没有 label
label = labels_shifted[i]
loss_i = -log_probs[i] # 交叉熵 = -log(p)
total_loss += loss_i
count += 1
print(f" 位置 {i}: token={tokens[i]}, label={label:>3}, "
f"log_p={log_probs[i]:.2f}, loss={loss_i:.2f}")
avg_no_mask = total_loss / count
print(f" 平均 loss = {avg_no_mask:.4f} (所有 {count} 个位置都参与)")
print()
print("--- 使用 Loss Masking ---")
total_loss = 0
count = 0
for i in range(len(tokens) - 1):
label = labels_masked[i]
if label == -100:
print(f" 位置 {i}: token={tokens[i]}, label=-100 → 跳过 (prompt)")
continue
loss_i = -log_probs[i]
total_loss += loss_i
count += 1
print(f" 位置 {i}: token={tokens[i]}, label={label:>3}, "
f"log_p={log_probs[i]:.2f}, loss={loss_i:.2f}")
avg_masked = total_loss / count
print(f" 平均 loss = {avg_masked:.4f} (只算 {count} 个 response 位置)")
print()
print("关键观察:Loss Masking 让 prompt 部分的 token 不参与梯度更新,")
print("模型只会从 response 部分学习。这样 SFT 训练信号更干净。")
4. Stage 2:Reward Model
4.1 偏好数据长什么样
对同一个 prompt,标注员看到两个回答,选一个更好的:
Prompt: 帮我写一封辞职信
Answer A (chosen 被选中的):
"尊敬的领导:因个人原因,我决定辞去当前职务。
感谢公司这两年给我的培养和机会。
我会在离职前做好所有交接工作。祝公司蒸蒸日上!"
Answer B (rejected 被拒绝的):
"辞职?写 '我不干了' 四个字就行了。
反正你走了公司也不会在乎。"
标注员选了 A → 这就是一条偏好数据:(prompt, chosen, rejected)。
4.2 Reward Model 要做什么
训练一个模型(Reward Model, RM),输入 (prompt, answer),输出一个分数 r。
训练目标很简单:让 RM 给 chosen 的分数 > 给 rejected 的分数。
相当于训练一个「自动打分器」来代替人工标注。
4.3 损失函数手算 — 这就是 Bradley-Terry 模型
import math
# === Reward Model Loss 手算 ===
print("=== Reward Model 的损失函数 ===")
print()
print("公式: L = -log( σ(r_chosen - r_rejected) )")
print(" 其中 σ(x) = 1/(1+e^(-x)) 是 sigmoid")
print()
def sigmoid(x):
return 1 / (1 + math.exp(-x))
def reward_loss(r_chosen, r_rejected):
diff = r_chosen - r_rejected
prob = sigmoid(diff)
loss = -math.log(max(prob, 1e-10))
return loss, diff, prob
# 四个场景
cases = [
("很好: chosen >> rejected", 8.0, 2.0),
("还行: chosen 略好", 6.0, 5.0),
("不好: chosen < rejected", 3.0, 7.0),
("灾难: chosen << rejected", 1.0, 9.0),
]
print(f"{'场景':<25s} {'r_c':>6s} {'r_r':>6s} {'diff':>8s} {'σ(diff)':>10s} {'loss':>10s}")
print("-" * 70)
for desc, r_c, r_r in cases:
loss, diff, prob = reward_loss(r_c, r_r)
print(f"{desc:<25s} {r_c:>6.1f} {r_r:>6.1f} {diff:>8.1f} {prob:>10.4f} {loss:>10.4f}")
print()
print("解读:")
print(" • σ(diff) 是「chosen 应该赢的概率」")
print(" • 当 r_chosen >> r_rejected: σ≈1, loss≈0 → RM 做对了,轻罚")
print(" • 当 r_chosen << r_rejected: σ≈0, loss 很大 → RM 做错了,重罚")
print()
print("这本质就是把「偏好比较」变成了一个二分类问题:")
print(" 标签=1 (chosen 更好),预测=σ(r_c - r_r),loss=cross_entropy")
=== Reward Model 的损失函数 ===
公式: L = -log( σ(r_chosen - r_rejected) )
其中 σ(x) = 1/(1+e^(-x)) 是 sigmoid
场景 r_c r_r diff σ(diff) loss
----------------------------------------------------------------------
很好: chosen >> rejected 8.0 2.0 6.0 0.9975 0.0025
还行: chosen 略好 6.0 5.0 1.0 0.7311 0.3133
不好: chosen < rejected 3.0 7.0 -4.0 0.0180 4.0181
灾难: chosen << rejected 1.0 9.0 -8.0 0.0003 8.0003
解读:
• σ(diff) 是「chosen 应该赢的概率」
• 当 r_chosen >> r_rejected: σ≈1, loss≈0 → RM 做对了,轻罚
• 当 r_chosen << r_rejected: σ≈0, loss 很大 → RM 做错了,重罚
这本质就是把「偏好比较」变成了一个二分类问题:
标签=1 (chosen 更好),预测=σ(r_c - r_r),loss=cross_entropy
5. Stage 3:PPO
有了 Reward Model,就可以用强化学习来优化 LLM 了。
5.1 PPO 训练循环
重复以下步骤:
1. 拿一批 prompt(比如 256 个)
2. LLM 为每个 prompt 生成回答
3. RM 给每个 (prompt, answer) 打分 → 得到 reward
4. 用 PPO 更新 LLM——提高高分的 token,降低低分的
5. 但不能跑太偏——加 KL 惩罚(防止模型忘光 SFT 学的)
5.2 PPO 的核心机制:Clip
PPO 最关键的设计是一个叫 clip 的机制。用一页 ppt 说清:
ratio = 新模型选这个 token 的概率 / 旧模型选这个 token 的概率
如果 ratio = 1.5 → 新模型以前选这个 token 概率 2%,现在 3%
如果 ratio = 0.8 → 新模型以前选这个 token 概率 5%,现在 4%
clip 的作用: 把 ratio 限制在 [1-ε, 1+ε] 之间(通常 ε=0.2)
→ 防止单步更新太激进
→ 像开车时方向盘限位器——防止你一下拧太多翻车
import numpy as np
# === PPO clip 机制手算 ===
print("=== PPO Clip 机制 ===")
print()
def ppo_loss(ratio, advantage, epsilon=0.2):
"""
计算 PPO surrogate loss
ratio = π_new / π_old (新旧策略的概率比)
advantage = RM_score - baseline (这个回答比平均好多少)
"""
# 不加 clip 的损失
unclipped = ratio * advantage
# 加了 clip 的损失(把 ratio 限制在 [0.8, 1.2])
clipped = np.clip(ratio, 1-epsilon, 1+epsilon) * advantage
# PPO 取更保守的那个(对 advantage>0 取 min,对 advantage<0 取 max)
# 其实就是 -min(unclipped, clipped) 作为 surrogate
return -min(unclipped, clipped)
ratios = [0.5, 0.7, 0.9, 1.0, 1.1, 1.3, 1.5, 2.0]
print("当 advantage > 0(这个回答好,想增加它的概率):")
print(f"{'ratio':>8s} {'unclipped':>12s} {'clipped':>12s} {'loss':>10s} {'说明'}")
print("-" * 60)
for r in ratios:
unclipped = r * 1.0
clipped = min(r, 1.2) * 1.0
loss = -min(unclipped, clipped)
note = ""
if r > 1.2:
note = "← clip! 不让它再大了"
print(f"{r:>8.2f} {unclipped:>12.2f} {clipped:>12.2f} {loss:>10.2f} {note}")
print()
print("当 advantage < 0(这个回答差,想降低它的概率):")
print(f"{'ratio':>8s} {'unclipped':>12s} {'clipped':>12s} {'loss':>10s} {'说明'}")
print("-" * 60)
for r in ratios:
unclipped = r * (-1.0)
clipped = max(r, 0.8) * (-1.0)
loss = -min(unclipped, clipped)
note = ""
if r < 0.8:
note = "← clip! 不让它再小了"
print(f"{r:>8.2f} {unclipped:>12.2f} {clipped:>12.2f} {loss:>10.2f} {note}")
print()
print("解读:")
print(" • advantage>0(好token): PPO 鼓励增加 ratio,但 ≤1.2")
print(" • advantage<0(坏token): PPO 鼓励降低 ratio,但 ≥0.8")
print(" • clip 作用 = 安全网 → 单步更新不会太猛,防止训练崩掉")
=== PPO Clip 机制 ===
当 advantage > 0(这个回答好,想增加它的概率):
ratio unclipped clipped loss 说明
------------------------------------------------------------
0.50 0.50 0.50 -0.50
0.70 0.70 0.70 -0.70
0.90 0.90 0.90 -0.90
1.00 1.00 1.00 -1.00
1.10 1.10 1.10 -1.10
1.30 1.30 1.20 -1.20 ← clip! 不让它再大了
1.50 1.50 1.20 -1.20 ← clip! 不让它再大了
2.00 2.00 1.20 -1.20 ← clip! 不让它再大了
当 advantage < 0(这个回答差,想降低它的概率):
ratio unclipped clipped loss 说明
------------------------------------------------------------
0.50 -0.50 -0.80 0.80 ← clip! 不让它再小了
0.70 -0.70 -0.80 0.80 ← clip! 不让它再小了
0.90 -0.90 -0.90 0.90
1.00 -1.00 -1.00 1.00
1.10 -1.10 -1.10 1.10
1.30 -1.30 -1.30 1.30
1.50 -1.50 -1.50 1.50
2.00 -2.00 -2.00 2.00
解读:
• advantage>0(好token): PPO 鼓励增加 ratio,但 ≤1.2
• advantage<0(坏token): PPO 鼓励降低 ratio,但 ≥0.8
• clip 作用 = 安全网 → 单步更新不会太猛,防止训练崩掉