跳到主要内容

[LLM 3/10] RLHF 与 PPO:用一个无法求导的奖励去训练模型

· 阅读需 27 分钟
Kobkrit Viriyayudhakorn
CEO, iApp Technology

第 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 xx、被选中的回答 ywy_w、被拒绝的回答 yly_l

第二堵墙——就算有了分数,也无法 backprop。 假设存在一个魔法函数 r(x,y)r(x,y) 能给每个回答打分,你依然没法做 supervised 训练: 因为回答 yy 是一个 token 一个 token 采样出来的,分数在采样结束之后才到, 而导数无法逆着采样往回走——从 rr 回到权重 θ\theta 的路径恰好在那里断掉了。

想走的路撞上哪堵墙
直接给"好回答"写 loss"好"无法定义成公式,只有比较
让人打分,然后 backprop分数在 token 采样之后——梯度过不了采样这一关
让人在训练过程中实时打分人类连 rollout 的一个零头都打不过来

这就是本章标题的由来:我们要 optimize 的是一个无法求导的奖励。 能做到这件事的工具,名叫 reinforcement learning。

2. 我们要做什么(Solution)

RLHF 用两步走同时拆掉两堵墙:

  • Stage A——Reward Model: 训练一个模型 rϕ(x,y)r_\phi(x,y) 去模仿人类在偏好对上的比较(拆第一堵墙,并顶替打分打不过来的人类)
  • Stage B——PPO: 用 policy-gradient RL 把 policy 推向 rϕr_\phi 的高分方向,全程不需要对采样求导(拆第二堵墙),同时系上KL 牵引绳,不让它跑离初始模型

付出的代价是复杂度:训练期间有四个模型同时待在显存里—— policy πθ\pi_\theta(被训练的那个)、reference πref\pi_{\text{ref}}(被冻结的初始模型)、 reward model rϕr_\phi,以及一个还没登场的 value network(价值网络)VψV_\psi(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 训练 rϕr_\phi

LRM(ϕ)=E(x,yw,yl)D[logσ(rϕ(x,yw)rϕ(x,yl))]\mathcal{L}_{\text{RM}}(\phi) = -\mathbb{E}_{(x,y_w,y_l)\sim\mathcal{D}}\Big[\log\sigma\big(r_\phi(x,y_w) - r_\phi(x,y_l)\big)\Big]
  • rϕ(x,y)r_\phi(x,y) = 每段文本一个标量分数——实践中就是把语言模型的头换成单层 linear(num_labels=1
  • σ\sigma = sigmoid,把分数差变成人类会选 ywy_w 的概率(Bradley–Terry 模型)
  • 差值 rϕ(x,yw)rϕ(x,yl)r_\phi(x,y_w) - r_\phi(x,y_l) 拉得越开,loss 越低

一个常被忽视、事后吃亏的点:这个 loss 只看得见分数的差值。 把 rϕr_\phi 换成 rϕ+cr_\phi + ccc 是任意常数)——loss 纹丝不动。 也就是说 reward model 的绝对尺度没有意义,也不被训练所确定。 跑两次可能得到平均分 3.7 和 −12.4,排序却完全一致。 这就是进 PPO 之前必须先 standardize reward(减 mean 除 std)的原因——记住这一点,它会在第 7 节和第 9 节回来。

3.2 母公式:RLHF objective

如果全系列只背一个公式,就背这个:

maxθ ExD,yπθ(x)[rϕ(x,y)]    βDKL(πθ(x)πref(x))\max_\theta\ \mathbb{E}_{x\sim\mathcal{D},\,y\sim\pi_\theta(\cdot|x)}\big[r_\phi(x,y)\big] \;-\; \beta\,\mathbb{D}_{\text{KL}}\big(\pi_\theta(\cdot|x)\,\|\,\pi_{\text{ref}}(\cdot|x)\big)

用人话读一遍:"把 reward 分数拿到最多,但每离初始模型远一步,都要交罚款。"

  • πθ\pi_\theta = policy,正在训练的模型——注意 yyπθ\pi_\theta 自己采样出来的,这是它与从静态文件学习的 SFT 之间的结构性区别
  • πref\pi_{\text{ref}} = reference,初始模型(第 2 章 SFT 之后的模型),训练全程冻结
  • β\beta = 每偏离一个 nat 的价格——牵引绳的紧度
  • DKL\mathbb{D}_{\text{KL}} = policy 与 reference 之间的分布距离
为什么这是系列后半程的母公式

第 4 章(DPO)将证明这个公式存在闭式解,然后把它反过来写,让 rϕr_\phi 和 RL 循环双双消失。 第 5 章(GRPO)保留 RL 骨架,但换掉 advantage 的算法,让 VψV_\psi 消失。 两章都没有提出新的 objective——它们只是用不同的工具,解同一个公式

3.3 PPO 裁剪代理目标:Stage B 的发动机

原始的 policy gradient(REINFORCE)一批 rollout 只能更新一次就得扔,非常昂贵——因为 generate 才是瓶颈。 PPO 想把同一批 rollout 榨上好几个 epoch,就需要一个校正系数(importance sampling ratio):

ρt=πθ(atst)πθold(atst)\rho_t = \frac{\pi_\theta(a_t \mid s_t)}{\pi_{\theta_{\text{old}}}(a_t \mid s_t)}
  • sts_t = 位置 tt 的状态,即 prompt 加上已经采样出的全部 token
  • ata_t = "动作",即 rollout 时已经采样出的下一个 token
  • πθold\pi_{\theta_{\text{old}}} = rollout 那一刻的 policy snapshot——只算一次,然后冻结

再用 clip 把 ρt\rho_t 夹住:

LCLIP(θ)=Et[min(ρtA^t, clip(ρt,1ϵ,1+ϵ)A^t)]\mathcal{L}^{\text{CLIP}}(\theta) = \mathbb{E}_t\Big[\min\big(\rho_t\,\hat A_t,\ \text{clip}(\rho_t,\,1-\epsilon,\,1+\epsilon)\,\hat A_t\big)\Big]
  • A^t\hat A_t = 优势(advantage),"这个 token 比预期好多少"(下一小节定义)
  • ϵ\epsilon = trust region 的宽度(标准值 0.2)

核心在于 min + clip 合起来构成一种刻意的悲观: 如果 A^t\hat A_t 为正(好 token),把 ρt\rho_t 往上推的收益被封顶1+ϵ1+\epsilon——推过头没有任何额外收益,梯度为零。 但如果 A^t\hat A_t 为负(坏 token),min 永远会选更差的那一支——罚款没有上限。 一句话总结:收益有限,损失无限。policy 因此只会在原地附近迈小步。

别混淆:这里有两个"旧模型",而且不是同一个

公式 3.2 的 πref\pi_{\text{ref}}整个训练期间冻结,担任 KL 牵引绳。 公式 3.3 的 πθold\pi_{\theta_{\text{old}}}最近一次 rollout 的 snapshot,每轮都换,担任 trust region。 自己手写 PPO 的头号高频 bug,就是把这两个装进了同一个变量。

3.4 GAE:怎么算 advantage 才不会淹死在噪声里

advantage 由价值网络 VψV_\psi 的 TD error 构建:

δt=rt+γVψ(st+1)Vψ(st)\delta_t = r_t + \gamma V_\psi(s_{t+1}) - V_\psi(s_t) A^t=l=0(γλ)lδt+l\hat A_t = \sum_{l=0}^{\infty} (\gamma\lambda)^l\,\delta_{t+l}
  • Vψ(st)V_\psi(s_t) = 价值网络,预测"从这里走到结束,还能收多少 reward"——这就是第四个模型
  • rtr_t = 每个 token 的 reward(在我们的任务里:每个位置的 KL 罚款,外加最后一个 token 上的任务得分)
  • γ\gamma = discount factor(LLM 任务通常取 1.0)
  • λ\lambda = bias–variance 旋钮:λ=0\lambda = 0 全盘信任 VψV_\psiVψV_\psi 预测跑偏时 bias 大),λ=1\lambda = 1 完全不信、等着看真实结局(背上整条链的噪声,variance 大),常用值 0.95
把这个 V_ψ 记牢——它就是 GRPO 要干掉的那个

VψV_\psi 是一个和 policy 差不多大的模型,要用它自己的 loss 同步训练。 VψV_\psi 预测乱来,advantage 就乱来,policy 学到的就是乱来的信号——PPO 的经典崩法。 第 5 章会回答这个问题:"如果用同一个 prompt 采样出的一组回答的平均分来代替 VψV_\psi 呢?" 那就是 GRPO 的全部——用一个平均值删掉第四个模型。

3.5 PPO 的完整 loss:三项,两个模型

把所有零件拼成 optimizer 真正看到的那一个 loss(按 minimize 的写法):

LPPO=LCLIP  +  c1Et[(Vψ(st)R^t)2]    c2Et[H[πθ(st)]]\mathcal{L}_{\text{PPO}} = -\mathcal{L}^{\text{CLIP}} \;+\; c_1\,\mathbb{E}_t\Big[\big(V_\psi(s_t) - \hat R_t\big)^2\Big] \;-\; c_2\,\mathbb{E}_t\Big[\mathcal{H}\big[\pi_\theta(\cdot \mid s_t)\big]\Big]
  • 第一项 = 3.3 节的裁剪代理目标(加负号,因为我们要 maximize)
  • 第二项 = value loss,教 VψV_\psi 贴近真实 return R^t\hat R_tc1c_1 通常取 0.5
  • 第三项 = entropy bonus H\mathcal{H},防止分布过早塌缩,c2c_2 通常取 0.01
  • 至于公式 3.2 的 KL 牵引绳,实践中习惯把它塞进逐 token 的 reward:rtrtβ(logπθlogπref)r_t \leftarrow r_t - \beta\,(\log\pi_\theta - \log\pi_{\text{ref}})——第 7 节用的正是这个写法

数一数需要调的玩具:4 个模型,加上 ϵ,β,γ,λ,c1,c2\epsilon, \beta, \gamma, \lambda, c_1, c_2,再加两套 learning rate。 这就是 PPO "换个 seed 跑两遍,结果完全两回事"名声的来源, 也是第 4 章整章存在的理由。

4. 把公式画出来(Visualize)

Bradley–Terry:梯度堆在还排错的那些对上

在下面的工具里选择 Bradley-Terry 模式,然后拖动 margin 看看: reward model 已经自信排对的对(margin 很正)几乎不剩任何梯度—— Stage A 的训练预算会自动流向它还排错的那些对。

Loss family
Positive means the model already prefers the chosen response.
Bradley-Terry has no temperature term.
-6-4-20246reward margin Δlossgradient weight

Drag anywhere on the plot, or use the Δ slider with the arrow keys.

Δ2.00
Loss0.1269
Gradient weight0.119211.9% of maximum
σ(−βΔ)0.119211.92%

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

裁剪代理目标随 probability ratio 变化的双面板图,分别对应正负 advantage,并给被 clip 的区域打上阴影裁剪代理目标随 probability ratio 变化的双面板图,分别对应正负 advantage,并给被 clip 的区域打上阴影

Figure 3.1公式 3.3 在 ε = 0.2 下的直接绘制——Â 为正的一侧,收益在 1+ε 处被封顶(平坦区 = 梯度为零);Â 为负的一侧罚款没有上限,因为 min 永远选更差的那一支

请仔细看那份不对称——它就是 PPO 的全部性格: 朝好的方向挪,每次最多赚 20%;但一旦给坏 token 的概率多了,就会被全力拽回来。 图中的"平坦区"正是 trust region,它让 PPO 可以把旧 rollout 反复用上好几个 epoch 而不爆炸。

GAE 的 bias–variance 旋钮

双面板图:带末端 reward 的合成 rollout,以及 lambda 取 0、0.5、0.95、1.0 时 GAE 的 advantage 曲线双面板图:带末端 reward 的合成 rollout,以及 lambda 取 0、0.5、0.95、1.0 时 GAE 的 advantage 曲线

Figure 3.2在一段 20 步的合成 rollout 上跑 GAE(γ = 1):每步 reward 是细小噪声,真正的分数在结尾才来,而 V_ψ 被故意设成低估约 0.4——λ = 0 时信号传不到前段 token,λ = 1 时每个 token 拿到全额 credit 也背上全部噪声(机制示意插图,不是真实训练数据)

从底部那条线往上读:λ=0\lambda = 0(绿)时 advantage 几乎全程贴零—— 结尾的分数传不到前面的 token,因为一切都要经过预测跑偏的 VψV_\psi 过滤。 λ=1\lambda = 1(红)时每个 token 都从结尾拿到全额 credit,但也背上了整条链累积的噪声。 λ=0.95\lambda = 0.95(蓝)是全行业选中的中间点——信号走得远,噪声被压住。

继续之前,先亲手建立 advantage——"比预期好多少"——的直觉: 下面的工具用组内平均代替 VψV_\psi 当 baseline(这是对第 5 章的全剧透)。 按下 All correct 那组,看看当所有回答拿到相同 reward 时会发生什么:

Try a group:

Rewards r_i (G = 8)

Unchecked is the Dr.GRPO variant: it keeps the centring but drops the std, removing the bias toward low-variance groups.
r1 = 1.001.00r2 = 0.00-1.00r3 = 1.001.00r4 = 1.001.00r5 = 0.00-1.00r6 = 0.00-1.00r7 = 1.001.00r8 = 0.00-1.00Â = 0 (no update)
mean(r)0.5000
std(r)0.5000
max |Â|1.000
Formula(r − μ) / σGRPO

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 画出来

proxy reward 对 KL 的曲线图,显示 beta 0.05 的轨迹停在均衡点,而 beta 0 的轨迹冲入 reward hacking 区proxy reward 对 KL 的曲线图,显示 beta 0.05 的轨迹停在均衡点,而 beta 0 的轨迹冲入 reward hacking 区

Figure 3.3(KL, reward) 平面上的两条训练轨迹——β = 0.05 一路爬升,然后停在边际收益正好等于惩罚的那一点;β = 0 则没有停下来的理由,一直向右冲进 reward hacking 区(这是 failure mode 的机制示意插图——真正测出来的曲线见第 8 节)

绿线停下来的那个点不是猜的——它就是公式 3.2 的数学结论: 优化会恰好停在"每多偏离一个 nat 所换来的 reward 正好等于 β"的位置,再往前走就是亏本。 当 β=0\beta = 0 时,这个停止条件根本不存在——只要多偏离一点点还能换到哪怕一丁点 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 昂贵:一张图说完

水平柱状图对比完整加载四个模型的 PPO 与 policy 和 reference 共享基座权重的 LoRA 方案的显存占用水平柱状图对比完整加载四个模型的 PPO 与 policy 和 reference 共享基座权重的 LoRA 方案的显存占用

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,什么都还没开始做。

这是第 2 章的红利(第二次兑现)

我们的 policy 是 base + 第 2 章的 LoRA adapter,而 πref\pi_{\text{ref}} 就是同一个 base 关掉 adapter。 直接用 policy.disable_adapter() 调用即可——reference model 的额外显存开销是零字节。 第 4 章还会对 DPO 再用一次同样的招。这就是系列开头选择 LoRA 的架构层面理由。

6. 准备数据(Data)

Stage A——给 reward model 的偏好对

使用 iapp/dpo_thai_tutorial(100 对,Apache-2.0)—— 我为这个系列亲手制作并开放使用的泰语偏好数据集。 每行有人工挑选的 promptchosenrejected,重点关注礼貌程度和语言的自然度。

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 的 reward model

在真实系统里,Stage B 直接吃 Stage A 的产出——那正是 RLHF 的定义。 但一个从 100 对数据训出来的 RM 扛不住 PPO 的压力:没几个 update 就会被 hack, 届时我们将分不清是 PPO 循环写错了,还是 RM 太弱——实验将什么也证明不了。

rule reward 于是充当一个可核查的 RM 替身:reward 上涨时,我们能确定循环真的在工作。 但它和所有 RM 一样"不完美"——它只看最后的数字和泰文字符占比, 不看周围一切内容读不读得通。这个漏洞,正是第 8 节 β=0\beta = 0 实验要当众戳穿的。 (这种"规则可验证的 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=200do_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 里)

配置:ϵ=0.2\epsilon = 0.2β=0.05\beta = 0.05γ=1.0\gamma = 1.0λ=0.95\lambda = 0.95同一批 rollout 训 4 个 epoch,共 64 个 prompt,adapter 的 LR 为 1e-5、value head 为 1e-4。 在 T4 上一次运行约 10 分钟(notebook 会跑两次:β=0.05\beta = 0.05β=0\beta = 0)。

三行代码,错任何一行,整个实验作废

1. .exp() 之前先 .clamp(-10, 10)——fp16 里 exp(12) = 162,754,超过 fp16 的上限(65,504), 结果是 infNaN,一个 step 内传染整个 batch。

2. logp_old 必须在 rollout 时算一次、detach 存好——严禁在 epoch 循环里重算。 一旦重算,ρt=1\rho_t = 1 恒成立,clip 永远不生效,你的 PPO 就在没有任何报错的情况下悄悄退化成 REINFORCE。

3. 分数用之前先 standardize——公式 3.1 已经说了 reward 的尺度没有定义。 我们的 rule reward 确实落在 0 到 1.2 之间,但这个习惯必须带到真 RM 上——那时尺度可以任意乱来。

8. 结果(Results)

notebook 每个 update 同时量三条曲线,并写入 results.json

  1. 每个 rollout 的平均 reward——应当爬升(这是我们买到的东西)
  2. 相对 πref\pi_{\text{ref}} 的逐 token KL——应当上升然后在 β\beta 划定的天花板下饱和(这是付出的价格)
  3. 平均回答长度——病症探测器:长度异常暴涨或跳水,是 policy 开始跑偏的第一信号
绝对禁止在没有 KL 曲线陪同的情况下读 reward 曲线

不知道模型拿什么去换,上涨的 reward 什么也说明不了。 reward 涨 + KL 饱和 = 在牵引绳下学习。 reward 涨 + KL 一路狂飙 = 正在逃离语言本身,奔向裁判的漏洞。 同一条线,不同的语境,含义恰好相反——这就是图 3.3 的实测版。

当场抓获 reward hacking

notebook 的第二次运行设定 β=0\beta = 0,其他一个字都不动——没有牵引绳。 预期会看到的模式:reward 爬得一样快或更快,但 KL 没有天花板地飞升, 回答开始退化——车轱辘话、异常地短,或者变成句尾塞个数字的固定套路。 因为 rule_reward 只看得见最后的数字和泰文字符占比——它看不见的一切,都是模型可以白扔的东西。

β=0\beta = 0 运行中退化回答的实例,会由 notebook 的最后一个 cell 打印出来:

[这一格只能由 notebook 的真实运行结果来填 —— 我不会自己编造 degenerate 的例子,
因为整个系列立足的规矩就是:没有任何数字或 output 是 invent 出来的。
跑完 notebook,"hacking exhibits" 那个 cell 会展示 2-3 条真实回答和它们的 KL]
Prompt
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆ

base

Thai 18%41 tokens
The sky appears blue because of Rayleigh scattering. ท้องฟ้า is blue เพราะ light scatter ครับ. Shorter wavelengths scatter more than longer ones.

sft

Thai 99%78 tokens
ท้องฟ้าเป็นสีฟ้าเพราะแสงอาทิตย์กระทบกับโมเลกุลของอากาศแล้วเกิดการกระเจิงแบบเรย์ลี ซึ่งแสงสีน้ำเงินที่มีความยาวคลื่นสั้นกว่าจะกระเจิงได้มากกว่าแสงสีแดง เราจึงมองเห็นท้องฟ้าเป็นสีฟ้าครับ

Showing the built-in sample.

9. 对比(Comparison)

notebook 用同一套 held-out 泰语数学题(TH-MATH)测量三个系统:

模型TH-MATH acc (95% CI)结束时平均 KL平均回答长度训练耗时
起点 policy70.0%(52.1–83.3)0182 token
PPO,β = 0.0570.0%(52.1–83.3)0.011172 token5.6 分钟
PPO,β = 0(消融)~不变0.012159 token5.6 分钟
在 Colab T4 上实测 —— Stage A 奖励模型:成对排序准确率 80%(16/20,Wilson 95% CI 58.4–91.9), 通过(CI 不跨 0.5),loss 1.00 → 0.29。所有数字来自 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 无法求导的东西:先训练裁判(rϕr_\phi),再用 RL 冲着裁判的分数跑
  • Bradley–Terry 只看得见差值——reward 的绝对尺度没有定义,所以使用前永远要 standardize
  • 母公式 max E[r]βDKL\max\ \mathbb{E}[r] - \beta\,\mathbb{D}_{\text{KL}} 是唯一需要背下来的公式——第 4、5 章就是用别的方法解这个公式
  • KL 不是 regularizer——它是整个系统唯一的停止条件。什么时候拆掉它,Goodhart 什么时候上班
  • min + clip = 设计出来的悲观:收益有限、损失无限——让旧 rollout 可以反复使用的 trust region
  • GAE 是 bias–variance 旋钮,而它依赖的 VψV_\psi 是第四个模型——第 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)

  1. Schulman et al. (2017). Proximal Policy Optimization Algorithms — PPO 原始论文:第 3 节的裁剪代理目标
  2. Schulman et al. (2015). High-Dimensional Continuous Control Using Generalized Advantage Estimation — GAE:PPO 所用的优势估计
  3. Christiano et al. (2017). Deep reinforcement learning from human preferences — 开启"从人类偏好做 RL"的工作
  4. Stiennon et al. (2020). Learning to summarize from human feedback — 首次在摘要任务上真正跑通的 RLHF
  5. Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT:整条 SFT -> RM -> PPO 流水线的源头
  6. Bai et al. (2022). Training a Helpful and Harmless Assistant with Reinforcement Learning from Human Feedback — helpful/harmless 数据集及关于 KL 的经验
  7. Zheng et al. (2023). Secrets of RLHF in Large Language Models Part I: PPO — 其他论文略去的 PPO 工程细节
  8. Bradley & Terry (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons — 所有奖励模型赖以成立的 Bradley-Terry 模型

本系列的文章、代码与 notebook 均以 CC BY-NC-SA 4.0 授权 —— 可自由使用与改编,须署名、限非商业用途,并以相同方式共享。文中引用的第三方模型与数据集仍适用各自的许可证。