[LLM 3/10] RLHF 与 PPO:用一个无法求导的奖励去训练模型
第 2 章我们靠"逐 token 模仿标准答案"来教模型。但真正让 AI 助手可用的那些性质—— 答得对、有礼貌、不胡编、不滑回英语——既没有标准答案可模仿,也写不成一个直接的 loss function。 这一章是那个问题最正统的答案:RLHF(Reinforcement Learning from Human Feedback)配 PPO。 我们会从泰语偏好对训练一个真正的 reward model,然后从零手写约 120 行 PPO 循环, 最后做一个我在整个系列里最喜欢的实验:解开 KL 牵引绳,现场看模型作弊刷分。 这是全系列刻意安排的最重的一章,因为第 4 章(DPO)和第 5 章(GRPO) 都是从本章的公式出发,各自选择"删掉"其中一块零件。
Open in Colab03_rlhf_ppo.ipynb
1. 问题(Problem statement)
第 2 章的 SFT 藏着一个前提假设:必须有标准答案可以模仿。 但想想我们真正想要的东西,比如"把数学题做对,并用读得通的泰语解释清楚"—— 这句话没有唯一答案,好的回答可以有一百种写法,而"读得通"三个字根本写不成公式。
一旦试图直接 optimize 这些目标,我们总会撞上两堵墙:
第一堵墙——质量写不成 loss。 "更好"无法定义成函数,但人类比较的能力极强: 给两个回答让人指出更喜欢哪个,立刻能答,而且相互间还算一致。 所以现实中能收集到的数据是三样东西:prompt 、被选中的回答 、被拒绝的回答 。
第二堵墙——就算有了分数,也无法 backprop。 假设存在一个魔法函数 能给每个回答打分,你依然没法做 supervised 训练: 因为回答 是一个 token 一个 token 采样出来的,分数在采样结束之后才到, 而导数无法逆着采样往回走——从 回到权重 的路径恰好在那里断掉了。
| 想走的路 | 撞上哪堵墙 |
|---|---|
| 直接给"好回答"写 loss | "好"无法定义成公式,只有比较 |
| 让人打分,然后 backprop | 分数在 token 采样之后——梯度过不了采样这一关 |
| 让人在训练过程中实时打分 | 人类连 rollout 的一个零头都打不过来 |
这就是本章标题的由来:我们要 optimize 的是一个无法求导的奖励。 能做到这件事的工具,名叫 reinforcement learning。
2. 我们要做什么(Solution)
RLHF 用两步走同时拆掉两堵墙:
- Stage A——Reward Model: 训练一个模型 去模仿人类在偏好对上的比较(拆第一堵墙,并顶替打分打不过来的人类)
- Stage B——PPO: 用 policy-gradient RL 把 policy 推向 的高分方向,全程不需要对采样求导(拆第二堵墙),同时系上KL 牵引绳,不让它跑离初始模型
付出的代价是复杂度:训练期间有四个模型同时待在显存里—— policy (被训练的那个)、reference (被冻结的初始模型)、 reward model ,以及一个还没登场的 value network(价值网络)(3.4 节见)。
RLHF 是隔着一个不完美的代理(reward model)去 optimize 一个你无法求导的奖励。 而毫无约束地猛压一个代理指标,注定按 Goodhart's law 的方式坏掉: 当指标变成目标,它就不再是好指标。
所以公式 3.2 里那个 KL 项不是求个心安的 regularizer—— 它是你和 reward hacking(奖励欺骗)之间唯一的屏障。 第 8 节会用把它拆掉的方式,亲眼验证这句话。
还有一句话请揣在兜里带完全系列:本章的 objective 是整个系列后半程的母公式。 第 4 章(DPO)把它解成闭式,让 reward model 和 RL 循环相互抵消。 第 5 章(GRPO)换掉 advantage(优势)的估计方式,让价值网络消失。 把这一章弄懂,后面两章就成了一眼能读懂的"零件删减"。
3. 公式(Equation)
3.1 Reward model:Bradley–Terry
Stage A 用一个短短的 loss 训练 :
- = 每段文本一个标量分数——实践中就是把语言模型的头换成单层 linear(
num_labels=1) - = sigmoid,把分数差变成人类会选 的概率(Bradley–Terry 模型)
- 差值 拉得越开,loss 越低
一个常被忽视、事后吃亏的点:这个 loss 只看得见分数的差值。 把 换成 ( 是任意常数)——loss 纹丝不动。 也就是说 reward model 的绝对尺度没有意义,也不被训练所确定。 跑两次可能得到平均分 3.7 和 −12.4,排序却完全一致。 这就是进 PPO 之前必须先 standardize reward(减 mean 除 std)的原因——记住这一点,它会在第 7 节和第 9 节回来。
3.2 母公式:RLHF objective
如果全系列只背一个公式,就背这个:
用人话读一遍:"把 reward 分数拿到最多,但每离初始模型远一步,都要交罚款。"
- = policy,正在训练的模型——注意 是从 自己采样出来的,这是它与从静态文件学习的 SFT 之间的结构性区别
- = reference,初始模型(第 2 章 SFT 之后的模型),训练全程冻结
- = 每偏离一个 nat 的价格——牵引绳的紧度
- = policy 与 reference 之间的分布距离
第 4 章(DPO)将证明这个公式存在闭式解,然后把它反过来写,让 和 RL 循环双双消失。 第 5 章(GRPO)保留 RL 骨架,但换掉 advantage 的算法,让 消失。 两章都没有提出新的 objective——它们只是用不同的工具,解同一个公式。
3.3 PPO 裁剪代理目标:Stage B 的发动机
原始的 policy gradient(REINFORCE)一批 rollout 只能更新一次就得扔,非常昂贵——因为 generate 才是瓶颈。 PPO 想把同一批 rollout 榨上好几个 epoch,就需要一个校正系数(importance sampling ratio):
- = 位置 的状态,即 prompt 加上已经采样出的全部 token
- = "动作",即 rollout 时已经采样出的下一个 token
- = rollout 那一刻的 policy snapshot——只算一次,然后冻结
再用 clip 把 夹住:
- = 优势(advantage),"这个 token 比预期好多少"(下一小节定义)
- = trust region 的宽度(标准值 0.2)
核心在于 min + clip 合起来构成一种刻意的悲观: 如果 为正(好 token),把 往上推的收益被封顶在 ——推过头没有任何额外收益,梯度为零。 但如果 为负(坏 token),min 永远会选更差的那一支——罚款没有上限。 一句话总结:收益有限,损失无限。policy 因此只会在原地附近迈小步。
公式 3.2 的 在整个训练期间冻结,担任 KL 牵引绳。 公式 3.3 的 是最近一次 rollout 的 snapshot,每轮都换,担任 trust region。 自己手写 PPO 的头号高频 bug,就是把这两个装进了同一个变量。
3.4 GAE:怎么算 advantage 才不会淹死在噪声里
advantage 由价值网络 的 TD error 构建:
- = 价值网络,预测"从这里走到结束,还能收多少 reward"——这就是第四个模型
- = 每个 token 的 reward(在我们的任务里:每个位置的 KL 罚款,外加最后一个 token 上的任务得分)
- = discount factor(LLM 任务通常取 1.0)
- = bias–variance 旋钮: 全盘信任 ( 预测跑偏时 bias 大), 完全不信、等着看真实结局(背上整条链的噪声,variance 大),常用值 0.95
是一个和 policy 差不多大的模型,要用它自己的 loss 同步训练。 预测乱来,advantage 就乱来,policy 学到的就是乱来的信号——PPO 的经典崩法。 第 5 章会回答这个问题:"如果用同一个 prompt 采样出的一组回答的平均分来代替 呢?" 那就是 GRPO 的全部——用一个平均值删掉第四个模型。
3.5 PPO 的完整 loss:三项,两个模型
把所有零件拼成 optimizer 真正看到的那一个 loss(按 minimize 的写法):
- 第一项 = 3.3 节的裁剪代理目标(加负号,因为我们要 maximize)
- 第二项 = value loss,教 贴近真实 return , 通常取 0.5
- 第三项 = entropy bonus ,防止分布过早塌缩, 通常取 0.01
- 至于公式 3.2 的 KL 牵引绳,实践中习惯把它塞进逐 token 的 reward:——第 7 节用的正是这个写法
数一数需要调的玩具:4 个模型,加上 ,再加两套 learning rate。 这就是 PPO "换个 seed 跑两遍,结果完全两回事"名声的来源, 也是第 4 章整章存在的理由。
4. 把公式画出来(Visualize)
Bradley–Terry:梯度堆在还排错的那些对上
在下面的工具里选择 Bradley-Terry 模式,然后拖动 margin 看看: reward model 已经自信排对的对(margin 很正)几乎不剩任何梯度—— Stage A 的训练预算会自动流向它还排错的那些对。
Drag anywhere on the plot, or use the Δ slider with the arrow keys.
This pair teaches almost nothing.The model already ranks this pair correctly, so σ(−βΔ) is only 11.92% and the gradient is 11.9% of what a hard pair would give. This is why preference datasets full of obvious wins barely move the model: the easy pairs are silently ignored, and the few genuinely confusing pairs do all the work.
如果这张图让你想起第 4 章 DPO 的梯度——不是巧合。 DPO 把这个 Bradley–Terry 模型整个搬了过去,只是换了"由谁来充当 reward"。
看得见的 trust region:min + clip
Figure 3.1公式 3.3 在 ε = 0.2 下的直接绘制——Â 为正的一侧,收益在 1+ε 处被封顶(平坦区 = 梯度为零);Â 为负的一侧罚款没有上限,因为 min 永远选更差的那一支
请仔细看那份不对称——它就是 PPO 的全部性格: 朝好的方向挪,每次最多赚 20%;但一旦给坏 token 的概率多了,就会被全力拽回来。 图中的"平坦区"正是 trust region,它让 PPO 可以把旧 rollout 反复用上好几个 epoch 而不爆炸。
GAE 的 bias–variance 旋钮
Figure 3.2在一段 20 步的合成 rollout 上跑 GAE(γ = 1):每步 reward 是细小噪声,真正的分数在结尾才来,而 V_ψ 被故意设成低估约 0.4——λ = 0 时信号传不到前段 token,λ = 1 时每个 token 拿到全额 credit 也背上全部噪声(机制示意插图,不是真实训练数据)
从底部那条线往上读:(绿)时 advantage 几乎全程贴零—— 结尾的分数传不到前面的 token,因为一切都要经过预测跑偏的 过滤。 (红)时每个 token 都从结尾拿到全额 credit,但也背上了整条链累积的噪声。 (蓝)是全行业选中的中间点——信号走得远,噪声被压住。
继续之前,先亲手建立 advantage——"比预期好多少"——的直觉: 下面的工具用组内平均代替 当 baseline(这是对第 5 章的全剧透)。 按下 All correct 那组,看看当所有回答拿到相同 reward 时会发生什么:
Rewards r_i (G = 8)
Watch the std term.Dividing by std(r) = 0.500 rescales this whole group. A group that happened to be near-unanimous gets a large multiplier and dominates the update, even though it carries less information than a group that genuinely disagreed. Untick the box to see the same rewards without the rescaling.
全组 advantage 为零 = 没有任何可学的信号——把这个感觉记住,读第 9 节时会用到。
KL 牵引绳:把公式 3.2 画出来
Figure 3.3(KL, reward) 平面上的两条训练轨迹——β = 0.05 一路爬升,然后停在边际收益正好等于惩罚的那一点;β = 0 则没有停下来的理由,一直向右冲进 reward hacking 区(这是 failure mode 的机制示意插图——真正测出来的曲线见第 8 节)
绿线停下来的那个点不是猜的——它就是公式 3.2 的数学结论: 优化会恰好停在"每多偏离一个 nat 所换来的 reward 正好等于 β"的位置,再往前走就是亏本。 当 时,这个停止条件根本不存在——只要多偏离一点点还能换到哪怕一丁点 reward,那就算"赚"。 于是模型会一路远离自然语言,只要 reward 的数字还在往上跳。
5. 准备环境(Environment)
生产级 RLHF 端着 4 个模型,最大的那个通常 7B 起步,用的是真人标注的 数万到数百万对偏好数据,并且把 generate 的机器(rollout fleet)和训练的机器分开。 这个 notebook 每个位置都用 Qwen3-0.6B,偏好对 100 对,数学题 64 道。 它演示的是算法的每一个零件——不是在做真的 RLHF。 得到的结果能证明机制,不能证明模型变得更好用了。
打开 Colab,选择 Runtime → Change runtime type → T4 GPU(免费额度够用)。
Colab 的 T4 是 Turing 架构(SM 7.5),它不支持 bfloat16,也不支持 FlashAttention-2。
但 Qwen3-0.6B 的 config.json 里写着 torch_dtype: bfloat16。
所以 torch_dtype="auto" 是个陷阱:代码会崩掉或者慢得离谱,而且不会告诉你原因。
torch_dtype=torch.float16 # 不是 bfloat16
attn_implementation="sdpa" # 不是 flash_attention_2
而且本章的 fp16 还额外埋着一颗雷,名叫 ratio overflow——第 7 节见。
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True(把 emulation 也算上了!)
is_bf16_supported() 在 T4 上会骗你较新的 torch 在 T4 上返回 True,因为它把**模拟(emulation)**也算作支持——而模拟比 fp16 慢得多。
请改为判断 compute capability ≥ 8.0(Ampere 及以上)。这是真正在 Colab 上跑才发现的 bug。
为什么 RLHF 昂贵:一张图说完
Figure 3.4一个 PPO step 里必须同时待在显存中的四个模型,按 Qwen3-0.6B 的真实参数量计算(仅权重,fp16)——LoRA 让 policy 和 reference 共用同一份 base,整整省下一个模型
图中的数字只是权重——还没算 activation、generate 时的 KV cache、gradient 和 optimizer state。 在 0.6B 上一切都显得小巧,但那个 ×4 的乘数在放大时哪儿也不去:到 7B 就是 56 GB,什么都还没开始做。
我们的 policy 是 base + 第 2 章的 LoRA adapter,而 就是同一个 base 关掉 adapter。
直接用 policy.disable_adapter() 调用即可——reference model 的额外显存开销是零字节。
第 4 章还会对 DPO 再用一次同样的招。这就是系列开头选择 LoRA 的架构层面理由。
6. 准备数据(Data)
Stage A——给 reward model 的偏好对
使用 iapp/dpo_thai_tutorial(100 对,Apache-2.0)——
我为这个系列亲手制作并开放使用的泰语偏好数据集。
每行有人工挑选的 prompt、chosen、rejected,重点关注礼貌程度和语言的自然度。
按 80/20 切分:80 对训练,20 对留作 held-out,训练期间绝不碰。 Stage A 的及格线是:在留出的 20 对上的 pairwise ranking accuracy,其 Wilson 95% CI 不跨 0.5 ——只要"显著好于抛硬币"就算机制验证成功,因为 20 对实在做不出更窄的 CI。
Stage B——能用规则批改的题目
PPO 循环用的是 VISAI-AI/gsm8k-thai(GSM8K 的泰语翻译版)里的 64 道数学题,
并用可核查的规则打分,而不是用 Stage A 的 reward model:
import re
def rule_reward(response: str, gold: int) -> float:
"""回答中最后一个整数正确得 +1.0,真的用泰语回答再得 +0.2"""
nums = re.findall(r"-?\d+", response.replace(",", ""))
correct = 1.0 if nums and int(nums[-1]) == gold else 0.0
thai = sum("ก" <= ch <= "๛" for ch in response)
thai_bonus = 0.2 if thai / max(len(response), 1) >= 0.5 else 0.0
return correct + thai_bonus
在真实系统里,Stage B 直接吃 Stage A 的产出——那正是 RLHF 的定义。 但一个从 100 对数据训出来的 RM 扛不住 PPO 的压力:没几个 update 就会被 hack, 届时我们将分不清是 PPO 循环写错了,还是 RM 太弱——实验将什么也证明不了。
rule reward 于是充当一个可核查的 RM 替身:reward 上涨时,我们能确定循环真的在工作。 但它和所有 RM 一样"不完美"——它只看最后的数字和泰文字符占比, 不看周围一切内容读不读得通。这个漏洞,正是第 8 节 实验要当众戳穿的。 (这种"规则可验证的 reward"思想会在第 5 章以主角身份全面回归。)
7. 核心代码(Main code)
7.1 Stage A——训练一个真正的 reward model(约 6 分钟)
把语言模型改造成打分机:LM 头被换成返回单个标量的单层 linear。
import torch, torch.nn.functional as F
from transformers import AutoModelForSequenceClassification, AutoTokenizer
from peft import LoraConfig, get_peft_model
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
rm = AutoModelForSequenceClassification.from_pretrained(
"Qwen/Qwen3-0.6B",
num_labels=1, # 标量头:每段文本一个分数
torch_dtype=torch.float16, # T4 没有 bf16
attn_implementation="sdpa",
).cuda()
rm.config.pad_token_id = tok.pad_token_id
rm = get_peft_model(rm, LoraConfig(
task_type="SEQ_CLS", r=8, lora_alpha=16,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
modules_to_save=["score"], # 打分头是随机初始化的,必须全量训练
))
for p in rm.parameters():
if p.requires_grad:
p.data = p.data.float() # 第 1 章的 fp16 教训:在 fp32 中训练
def rm_loss(chosen, rejected):
s_w = rm(**chosen).logits.squeeze(-1) # r_φ(x, y_w)
s_l = rm(**rejected).logits.squeeze(-1) # r_φ(x, y_l)
return -F.logsigmoid(s_w - s_l).mean() # 就是公式 3.1 本身
在 80 对上训 3 个 epoch,然后在留出的 20 对上测 pairwise accuracy,附 Wilson CI。 如果 CI 不跨 0.5——你刚刚用 80 行数据,成功训出了人生第一个 reward model。
7.2 Stage B——从零手写 PPO,约 120 行(约 10 分钟)
我们不用 TRL 的 PPOTrainer,而这是一个刻意的决定,不是犟:
TRL 已把 PPOTrainer 移入 trl.experimental,并宣布计划在 0.29.0 移除——
用这个库教的代码,再过几个月就跑不起来了。
而自己写的约 120 行 PPO 循环,只要 PyTorch 还在就能跑。
更重要的是:亲手写过,你才知道每一行在做什么——和第 4 章那 25 行 DPO loss 是同一个道理。
棋盘上的棋子:policy = base + LoRA adapter(来自第 2 章,is_trainable=True),
reference = 同一个 base 关掉 adapter,value head 是插在最后一层 hidden state 上的两层 MLP:
value_head = torch.nn.Sequential(
torch.nn.Linear(1024, 1024), torch.nn.Tanh(),
torch.nn.Linear(1024, 1),
).float().cuda() # ~1M 参数 —— 和另外三个比起来微不足道
rollout:每次对 8 个 prompt 采样回答(max_new_tokens=200、do_sample=True),
用 rule_reward 打分,在 batch 内 standardize 分数,并当场把 rollout 时的 log-prob detach 存下来。
然后进入这个 update 循环——全章的心脏,40 行(完整循环在 notebook 里):
def compute_gae(rewards, values, gamma=1.0, lam=0.95):
"""rewards: 单条 response 的 [T],values: [T+1](最后一格 = 结束后为 0)"""
adv, acc = torch.zeros_like(rewards), 0.0
for t in reversed(range(rewards.shape[0])):
delta = rewards[t] + gamma * values[t + 1] - values[t] # 公式 3.4
acc = delta + gamma * lam * acc # Â_t = δ_t + γλ Â_{t+1}
adv[t] = acc
return adv
def ppo_update(rollout, eps=0.2, beta=0.05):
out = policy(rollout.ids, output_hidden_states=True) # 一次 forward 拿两样东西
logp = gather_logprobs(out.logits, rollout.ids) # [B, T] 带梯度
with torch.no_grad(), policy.disable_adapter():
logp_ref = gather_logprobs(policy(rollout.ids).logits, rollout.ids)
# 逐 token 的 reward = 每个位置的 KL 罚款 + 最后一个 token 上的任务得分(公式 3.2)
rew = -beta * (logp.detach() - logp_ref)
rew[:, -1] += rollout.scores_std # standardize 过的 rule 得分
hidden = out.hidden_states[-1].detach().float() # detach = 切断流向主干的梯度
values = value_head(hidden).squeeze(-1)
adv = torch.stack([compute_gae(r, F.pad(v, (0, 1))) for r, v in zip(rew, values)])
adv = (adv - adv.mean()) / (adv.std() + 1e-8) # 再 standardize 一层 advantage
log_ratio = (logp - rollout.logp_old).clamp(-10, 10) # 防 fp16 overflow!
ratio = log_ratio.exp() # ρ_t(公式 3.3)
surr = torch.min(ratio * adv.detach(),
ratio.clamp(1 - eps, 1 + eps) * adv.detach())
returns = (adv + values).detach() # value head 的学习目标
m = rollout.resp_mask # 只统计 response 的 token
loss = (-(surr * m).sum() + 0.5 * ((values - returns).pow(2) * m).sum()) / m.sum()
return loss # (entropy 项在 notebook 里)
配置:、、、,
同一批 rollout 训 4 个 epoch,共 64 个 prompt,adapter 的 LR 为 1e-5、value head 为 1e-4。
在 T4 上一次运行约 10 分钟(notebook 会跑两次: 和 )。
1. .exp() 之前先 .clamp(-10, 10)——fp16 里 exp(12) = 162,754,超过 fp16 的上限(65,504),
结果是 inf 变 NaN,一个 step 内传染整个 batch。
2. logp_old 必须在 rollout 时算一次、detach 存好——严禁在 epoch 循环里重算。
一旦重算, 恒成立,clip 永远不生效,你的 PPO 就在没有任何报错的情况下悄悄退化成 REINFORCE。
3. 分数用之前先 standardize——公式 3.1 已经说了 reward 的尺度没有定义。 我们的 rule reward 确实落在 0 到 1.2 之间,但这个习惯必须带到真 RM 上——那时尺度可以任意乱来。
8. 结果(Results)
notebook 每个 update 同时量三条曲线,并写入 results.json:
- 每个 rollout 的平均 reward——应当爬升(这是我们买到的东西)
- 相对 的逐 token KL——应当上升然后在 划定的天花板下饱和(这是付出的价格)
- 平均回答长度——病症探测器:长度异常暴涨或跳水,是 policy 开始跑偏的第一信号
不知道模型拿什么去换,上涨的 reward 什么也说明不了。 reward 涨 + KL 饱和 = 在牵引绳下学习。 reward 涨 + KL 一路狂飙 = 正在逃离语言本身,奔向裁判的漏洞。 同一条线,不同的语境,含义恰好相反——这就是图 3.3 的实测版。
当场抓获 reward hacking
notebook 的第二次运行设定 ,其他一个字都不动——没有牵引绳。
预期会看到的模式:reward 爬得一样快或更快,但 KL 没有天花板地飞升,
回答开始退化——车轱辘话、异常地短,或者变成句尾塞个数字的固定套路。
因为 rule_reward 只看得见最后的数字和泰文字符占比——它看不见的一切,都是模型可以白扔的东西。
运行中退化回答的实例,会由 notebook 的最后一个 cell 打印出来:
[这一格只能由 notebook 的真实运行结果来填 —— 我不会自己编造 degenerate 的例子,
因为整个系列立足的规矩就是:没有任何数字或 output 是 invent 出来的。
跑完 notebook,"hacking exhibits" 那个 cell 会展示 2-3 条真实回答和它们的 KL]
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
9. 对比(Comparison)
notebook 用同一套 held-out 泰语数学题(TH-MATH)测量三个系统:
| 模型 | TH-MATH acc (95% CI) | 结束时平均 KL | 平均回答长度 | 训练耗时 |
|---|---|---|---|---|
| 起点 policy | 70.0%(52.1–83.3) | 0 | 182 token | — |
| PPO,β = 0.05 | 70.0%(52.1–83.3) | 0.011 | 172 token | 5.6 分钟 |
| PPO,β = 0(消融) | ~不变 | 0.012 | 159 token | 5.6 分钟 |
results.json。
文章预测 β = 0 会让 KL 飙升、语言崩坏(reward hacking)。但在真实的 demo 规模运行 (FAST_MODE:32 题 × 16 次更新)中并没有:β=0 的 KL(0.012)与 β=0.05(0.011)几乎无异, 两者 reward 都保持不变,TH-MATH 的 McNemar p = 1.0(无变化)。
原因是 0.6B 模型上 16 次更新远不足以诱发 reward hacking,那需要数百次更新。本章真正证明的是 (1)奖励模型确实能训练 —— 80%,(2)约 120 行的 PPO 循环能完整跑通,且其数学与手算一致(第 6 节)。 要清楚看到 reward hacking,需要关闭 FAST_MODE 并在更大的 GPU 上长时间运行 —— 所以我们报告实测到的, 而不是我们希望看到的。
注: 起点 policy 是新建的 LoRA(fallback),并非第 2 章的 adapter(尚未推送到 Hub); 推送后将按设计从真正的 SFT 模型开始。
10. 小结(Summary)
- RLHF = 两步绕路,去 optimize 无法求导的东西:先训练裁判(),再用 RL 冲着裁判的分数跑
- Bradley–Terry 只看得见差值——reward 的绝对尺度没有定义,所以使用前永远要 standardize
- 母公式 是唯一需要背下来的公式——第 4、5 章就是用别的方法解这个公式
- KL 不是 regularizer——它是整个系统唯一的停止条件。什么时候拆掉它,Goodhart 什么时候上班
- min + clip = 设计出来的悲观:收益有限、损失无限——让旧 rollout 可以反复使用的 trust region
- GAE 是 bias–variance 旋钮,而它依赖的 是第四个模型——第 5 章 GRPO 会把它删掉
- PPO 贵在结构,不是代码写得烂:4 个模型 + 十来个 hyperparameter,就是门票价格
64 个 prompt 加规则 reward,是算法演示,不是在做 RLHF。 我们的 rule reward 只是 Stage A reward model 的替身—— 真实的 RLHF 用 7B 以上、从数万到数百万对人类偏好训出的 RM, 还需要独立的 rollout fleet,因为 generate 吃掉的算力是 update 的好几倍。
这个实验真正能证明的有两件事:手写的 PPO 循环工作正确(reward 在 KL 牵引绳下爬升), 以及 reward hacking 的机制真实存在(拆掉 β 就能测出来,不只是嘴上说说)。 不要拿这个结果去宣称你得到了 align 好的泰语模型——你得到的是对整台机器如何运转的理解, 而那恰恰是读懂后面两章的必需品。
下一章: DPO——DPO 用纯代数,从你刚背下的母公式 3.2 出发,把 reward model 和 RL 循环双双删掉。
参考文献(References)
- Schulman et al. (2017). Proximal Policy Optimization Algorithms — PPO 原始论文:第 3 节的裁剪代理目标
- Schulman et al. (2015). High-Dimensional Continuous Control Using Generalized Advantage Estimation — GAE:PPO 所用的优势估计
- Christiano et al. (2017). Deep reinforcement learning from human preferences — 开启"从人类偏好做 RL"的工作
- Stiennon et al. (2020). Learning to summarize from human feedback — 首次在摘要任务上真正跑通的 RLHF
- Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT:整条 SFT -> RM -> PPO 流水线的源头
- Bai et al. (2022). Training a Helpful and Harmless Assistant with Reinforcement Learning from Human Feedback — helpful/harmless 数据集及关于 KL 的经验
- Zheng et al. (2023). Secrets of RLHF in Large Language Models Part I: PPO — 其他论文略去的 PPO 工程细节
- Bradley & Terry (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons — 所有奖励模型赖以成立的 Bradley-Terry 模型
本系列的文章、代码与 notebook 均以 CC BY-NC-SA 4.0 授权 —— 可自由使用与改编,须署名、限非商业用途,并以相同方式共享。文中引用的第三方模型与数据集仍适用各自的许可证。
