一个循环,六种填法:用伪代码把 LLM 后训练一次排黑

把 SFT、奖励模型、RLHF-PPO、拒绝采样、DPO、KTO、GRPO 全部写成能逐行读懂的伪代码。你会发现它们是同一个训练循环的六种填法,区别只在三个槽位:回答谁生成、信号谁给、信号怎么变成梯度。文末给出全景对比表和三条真实流水线(InstructGPT、Llama 2、DeepSeek-R1)的组装方式。

你可能背得出 DPO 的损失函数,却写不出它的训练循环;读过五篇后训练综述,还是说不清 PPO 和 GRPO 在代码层面到底差了哪几行。这不怪你——散文有歧义,公式藏细节,只有伪代码没有地方可躲:数据从哪来、几个模型在显存里、损失怎么算,每一行都必须交代。这篇把主流后训练策略全部写成伪代码,一次排黑。

一句话主线

先把地图给你:

所有后训练方法都是同一个循环的变体:采样 → 打分 → 更新。它们的全部区别,只在三个槽位怎么填——回答由谁生成?监督信号是什么形式?信号怎么变成梯度?

写成母模板就是:

while not converged:
    x, y   = get_data()          # 槽位1:回答 y 从哪来?(人写的 / 模型自己生成的)
    signal = get_signal(x, y)    # 槽位2:信号是什么?(标准答案 / 打分 / 偏好对 / 规则判定)
    loss   = to_loss(y, signal)  # 槽位3:信号怎么变成损失?
    policy.update(loss)          # 这一行永远不变:梯度下降

下面每种方法出场时,你只需要盯住这三个槽位。读完你会发现:后训练没有六种算法,只有一个循环和六种填法。

阅读约定

伪代码是教学简化版,省略了 batch、padding、分布式这些工程细节,但保留所有决定算法身份的结构。几个记号贯穿全文:

  • policy:正在训练的模型(也叫策略,πθ\pi_\theta
  • ref:冻结的参考模型(通常是 SFT 之后的快照,πref\pi_{\text{ref}}),只做前向,不更新
  • rm:奖励模型(Reward Model),给回答打标量分
  • logp(model, y | x):模型 model 生成回答 y 的对数概率(逐 token 求和或保留逐 token 形式)

前置知识只需要两句话:预训练教会模型接话,SFT 教会它照做,但示范教不会”做得更好”——这是后训练存在的理由。如果这两句话对你还不透,先读《示范教不会的东西:为什么大模型需要强化学习》,零公式。

第 0 式:SFT——一切的地板

监督微调(Supervised Fine-Tuning)是后训练的起点,也是最诚实的一段代码:

# 槽位1:y 是人类(或更强模型)写好的示范
# 槽位2:信号 = 示范本身,"这就是标准答案"
# 槽位3:交叉熵,最大化示范的似然

for x, y_human in D_demo:                    # 几万条 (指令, 优质回答)
    loss = -logp(policy, y_human | x)        # 就是预训练的 next-token loss
    policy.update(loss)

逐行要点:

  • 循环里只有一个模型。没有采样、没有打分,生成开销为零——这是所有方法里最便宜的。
  • 损失和预训练完全相同,换的只是数据:从”互联网上随便什么文本”换成”人类精心写的示范”。
  • InstructGPT 用了约 1.3 万条示范 prompt 做这一步。

天花板也写在代码里y_human 是谁写的,模型就最多学到谁的水平;而且循环里没有任何一行代码能表达”这个回答不好”——SFT 只能拉高好回答的概率,不能压低坏回答的概率。这两个缺陷,后面每一式都在补。

部件:奖励模型——把人类判断力做成函数

在进入 RL 之前,先造一个部件。人类判断”哪个回答更好”又快又准,但判断力没法直接塞进训练循环——得先把它蒸馏成一个可以无限调用的函数

# 数据:人类对同一个 x 的两个回答标出偏好
# D_pref = [(x, y_win, y_lose), ...]

rm = policy_sft.clone_with_scalar_head()     # 语言模型头换成"输出一个分数"的头

for x, y_win, y_lose in D_pref:
    s_w = rm(x, y_win)                       # 标量分
    s_l = rm(x, y_lose)
    loss = -log(sigmoid(s_w - s_l))          # Bradley-Terry:赢家分数应高于输家
    rm.update(loss)

逐行要点:

  • 损失只关心分差 s_w - s_l,不关心绝对值——人类标的是”A 比 B 好”,不是”A 值 87 分”。这个”只会比较、不会绝对打分”的出身,埋下了后面 GRPO 组内归一化的伏笔。
  • 训练完成后 rm 冻结,变成一个”人类判断力模拟器”,可以每秒调用几千次——这是 RLHF 能规模化的前提。

第 1 式:RLHF-PPO——完整但昂贵的正统

有了奖励模型,就可以让模型自己生成、被打分、按分数调整。这是 InstructGPT 的第三步,也是 ChatGPT 背后的经典配方:

# 槽位1:y 由 policy 自己在线生成(on-policy)
# 槽位2:信号 = rm 给整段回答的标量分 − 逐 token 的 KL 惩罚
# 槽位3:PPO clip 损失,优势加权,一批数据复用多个 epoch

policy = policy_sft.clone()
ref    = policy_sft.frozen()                 # 防跑偏的锚
rm     = trained_rm.frozen()                 # 裁判
critic = value_model()                       # 第 4 个模型:预测"从这往后还能拿多少分"

for iteration in range(N):
    # ---- A. 采样(最贵的一步)----
    xs = sample(D_prompts)
    ys = [policy.generate(x) for x in xs]    # rollout:完整生成回答

    # ---- B. 打分:造出每个 token 的优势 ----
    for x, y in zip(xs, ys):
        score = rm(x, y)                                 # 整段一个分,只落在最后一个 token
        for t in tokens(y):                              # 逐 token 的 KL 惩罚,防跑偏
            kl_t = logp(policy, y_t | x, y_<t) - logp(ref, y_t | x, y_<t)
            reward_t = -beta * kl_t + (score if t == last else 0)
        A_t = GAE(reward_t, critic)                      # 优势:这个 token 比平均好多少

    # ---- C. 复用这批昂贵数据,更新 K 个 epoch ----
    old = policy.frozen_snapshot()
    for epoch in range(K):
        ratio_t = exp(logp(policy, y_t) - logp(old, y_t))          # 新旧策略概率比
        loss = -mean(min(ratio_t * A_t,
                         clip(ratio_t, 1 - eps, 1 + eps) * A_t))   # PPO 的灵魂:clip
        policy.update(loss)
        critic.update(mse_loss)              # critic 同步学"预测回报"

逐行要点:

  • 数一数显存:policyrefrmcritic——四个模型同时在线,其中两个还要训练。这就是”PPO 贵”的字面意思。
  • A_t逐 token 的信号:不只知道”整段回答值多少分”,还试图分辨”哪个词贡献了分数”。这份精细由 critic 提供,也是它存在的唯一理由。
  • ratioclip 那两行在解决”采样太贵所以要复用数据,复用数据策略就会偏离数据来源”的矛盾。每个部件的完整推导在《PPO 的训练循环》,这里不重复。
  • 逐 token 的 KL 惩罚是缰绳:奖励模型是个近似品,往死里优化它必然被钻空子(为什么必然,见《目标函数即命运》)。

适用边界:效果的正统上限,工程的正统噩梦——四模型编排、超参敏感、训练不稳,是后面所有”平替”存在的理由。

第 2 式:拒绝采样微调——把 RL 退化成”筛数据 + 再 SFT”

第一个平替思路简单到令人发笑:既然 SFT 便宜稳定,那就让模型自己生成一堆回答,用奖励模型筛出最好的,再拿去 SFTLlama 2 的大部分对齐迭代用的就是它(论文里叫 rejection sampling fine-tuning),RAFT 给了这个范式一个正式名字。

# 槽位1:y 由 policy 批量生成,但离线筛选后再用
# 槽位2:信号 = rm 挑出 Best-of-K(或规则:答案对才保留)
# 槽位3:普通交叉熵——训练部分和 SFT 一模一样

for round in range(R):                       # 可以迭代多轮
    D_new = []
    for x in D_prompts:
        ys = [policy.generate(x, temperature=1.0) for _ in range(K)]   # 采 K 个
        y_best = argmax(ys, key=lambda y: rm(x, y))                    # 只留最好的
        D_new.append((x, y_best))
    policy = SFT(policy, D_new)              # 复用第 0 式,一行都不用改

逐行要点:

  • 更新阶段就是 SFT,所以继承了 SFT 的全部稳定性:没有 ratio、没有 clip、没有 critic,不会训崩。
  • 与 SFT 的唯一区别在数据来源:示范不再是人写的,而是模型自己写、裁判挑出来的。模型的上限不再是标注员水平,而是”自己 K 次尝试里的最好水平”——K 越大,筛出的样本越好。
  • 也继承了 SFT 的缺陷:y_best 之外的 K−1 个样本直接扔掉,“什么是坏”的信息依然一比特都没用上
  • Llama 2 直到 RLHF-V4 都只用这一式迭代,最后一版才叠加 PPO 收尾——工程上”先用便宜的吃掉大部分收益,再用贵的榨最后一段”是常见组合。

第 3 式:DPO——把 RL 问题变成分类问题

第二个平替更激进:连奖励模型都不训了DPO(Direct Preference Optimization)的洞察是:KL 约束下的 RLHF 最优解有闭式形式,把它反过来代入奖励模型的训练损失,奖励模型就被消掉了——偏好数据可以直接训练策略:

# 槽位1:y 是离线的偏好对,谁生成的都行(人 / 别的模型 / 旧版本自己)
# 槽位2:信号 = 成对偏好 (y_win, y_lose)
# 槽位3:一个 sigmoid 分类损失,没有采样、没有 RL

policy = policy_sft.clone()
ref    = policy_sft.frozen()

for x, y_win, y_lose in D_pref:
    # "隐式奖励":策略相对参考模型,把这个回答的概率抬高了多少(对数尺度)
    r_w = beta * (logp(policy, y_win | x) - logp(ref, y_win | x))
    r_l = beta * (logp(policy, y_lose | x) - logp(ref, y_lose | x))

    loss = -log(sigmoid(r_w - r_l))          # 和奖励模型的损失长得一模一样!
    policy.update(loss)

逐行要点:

  • 对照第 1 式数显存:四个模型 → 两个(policy + 冻结的 ref),没有 rollout、没有 critic、没有 RM。训练形态和普通微调无异,这是它横扫开源社区的原因。
  • loss 那行和奖励模型的 Bradley-Terry 损失完全同形——区别只是分数换成了 beta * log(policy/ref)。这就是论文标题《你的语言模型偷偷是个奖励模型》的字面意思。
  • 注意循环里没有 policy.generate()。这是 DPO 最大的优点,也是最大的软肋:它永远在别人(或过去的自己)生成的回答上学习,是 off-policy 的。训练越久,policy 的真实行为离偏好数据的分布越远,信号越失真——想缓解就得重新采样、重标偏好、再训一轮,“免 RL”的优势随之打折。

适用边界:有现成偏好数据、算力有限、要快速对齐——DPO 是默认起手式。要在自己的生成分布上持续爬坡——迟早要回到在线方法(第 1、5 式)。

第 4 式:KTO——连偏好对都凑不齐时

DPO 要求成对数据:同一个 prompt 必须有一好一坏两个回答。现实里更常见的是不成对的散装信号——用户对单条回答点了个赞或踩。KTO 直接吃这种数据:

# 槽位1:y 离线,单条即可,不需要成对
# 槽位2:信号 = 二元标签(好 / 坏),比如点赞点踩
# 槽位3:前景理论式的价值函数,好坏两头不对称

for batch in D_binary:                       # [(x, y, label), ...]
    z0 = mean_KL(policy, ref, batch)         # 参考点:本批数据的平均偏移量
    for x, y, label in batch:
        r = beta * (logp(policy, y | x) - logp(ref, y | x))    # 和 DPO 同款隐式奖励
        if label == GOOD:
            v = lambda_D * sigmoid(r - z0)   # 好样本:抬得比参考点高才算赚
        else:
            v = lambda_U * sigmoid(z0 - r)   # 坏样本:压得比参考点低才算赚
        loss = -v
        policy.update(loss)

逐行要点:

  • 隐式奖励 r 和 DPO 完全同款,变的是外层包装:不再比较两个回答,而是每条回答独立地和参考点 z0 比。
  • lambda_D / lambda_U 两个系数不对称——损失厌恶(人对”变坏”比对”变好”敏感)是从行为经济学前景理论里搬来的设计,也是 KTO(Kahneman-Tversky Optimization)名字的由来。
  • 实践价值一句话:偏好对贵,点赞点踩遍地都是。 KTO 论文报告在 1B–30B 规模上效果匹配甚至超过 DPO,且对数据不平衡相当鲁棒。

第 5 式:GRPO——去掉 critic 的在线 RL,推理时代的主力

回到在线 RL。PPO 四个模型里最别扭的是 critic:和策略一样大、要单独训练、还训不太准。DeepSeekMath 提出的 GRPO(Group Relative Policy Optimization)用一个统计学老技巧把它整个删掉:同一个 prompt 采一组回答,用组内相对分数当优势

# 槽位1:y 由 policy 在线生成,且同一个 x 采一组 G 个
# 槽位2:信号 = 规则奖励(答案对/错、格式合规)或 RM 分,组内归一化
# 槽位3:PPO 同款 clip 损失,但优势不来自 critic,来自组内对比

policy = base_model.clone()                  # R1-Zero 甚至直接从 base 起训
ref    = base_model.frozen()

for step in range(N):
    x, answer = sample(D_verifiable)         # 带标准答案的题(数学/代码)
    ys = [policy.generate(x) for _ in range(G)]        # 一组 G 个回答(如 16 个)

    rs = [reward(y, answer) for y in ys]     # 规则打分:答对 1 分,答错 0 分,格式加分
    A  = (rs - mean(rs)) / std(rs)           # 组内归一化 = 优势。critic 的活,均值干了

    old = policy.frozen_snapshot()
    for y_i, A_i in zip(ys, A):
        ratio_t = exp(logp(policy, y_t) - logp(old, y_t))
        loss_i = -min(ratio_t * A_i, clip(ratio_t, 1 - eps, 1 + eps) * A_i) \
                 + beta * KL(policy, ref)    # KL 直接加在损失上,不再混进奖励
    policy.update(mean(loss_i))

逐行要点:

  • 灵魂是 A = (rs - mean) / std 这一行:“这道题 16 个回答里你比平均好多少”。奖励模型(和人类偏好)本来就只会比较不会绝对打分,组内相对化恰好对齐了这个天性。critic 及其全部训练麻烦,一行均值解决。
  • reward() 若是规则函数(答案核对、单元测试),整个循环里连 RM 都没有——这就是 RLVR(可验证奖励强化学习)。DeepSeek-R1 靠”规则奖励 + GRPO”训出了长链推理,证明裁判可以是一个 if 语句。
  • 代价也写在代码里:每步要生成 G 个完整回答,采样开销比 PPO 更重;且当一组回答全对或全错时,rs 方差为零,优势全为 0,这条 prompt 的采样全部白费——著名的工程痛点,后续改进(动态采样、课程筛题)大多冲它去。
  • 对比第 1 式:clip 结构原封不动,KL 从奖励挪到损失,critic 换成组内均值。GRPO 不是新物种,是 PPO 的减法。

全景对比:一张表看清三个槽位

方法回答谁生成信号形式显存里几个模型生成开销能否”压坏”
SFT人 / 更强模型标准答案1
拒绝采样 RFT自己(离线批量)Best-of-K 筛选2(分时可 1)高(K 倍采样)
DPO任意来源(离线)成对偏好2
KTO任意来源(离线)单条好/坏标签2
RLHF-PPO自己(在线)RM 标量分4
GRPO自己(在线,成组)规则 / RM 组内相对2–3最高(G 倍采样)

两条读表线索:

  • 纵向看”生成开销”和”能否压坏”的对角线:便宜的方法(SFT、DPO)用不上模型自己的探索,贵的方法(PPO、GRPO)在自己的分布上闭环学习。天下没有免费的对齐。
  • “能否压坏”一列就是 SFT 家族和偏好/RL 家族的分水岭:前者只能拉高好的,后者能同时压低坏的——负梯度是花钱买来的能力。

组合拳:真实流水线怎么拼

没有哪个前沿模型只用一式。三条有代表性的流水线,用一行伪代码写完:

# InstructGPT (2022):三步正统
model = PPO(RM(SFT(base)))

# Llama 2-Chat (2023):便宜的先上,贵的收尾
model = SFT(base)
for v in [1, 2, 3, 4]:
    model = RFT(model, rm_v)                 # 每轮都用新偏好数据重训 RM
model = PPO(RFT(model, rm_5))                # 最后一版才叠 PPO

# DeepSeek-R1 (2025):SFT 和 RL 交替两轮
model = SFT(base, cold_start_cot)            # 几千条冷启动长思维链
model = GRPO(model, rule_rewards)            # 推理 RL:答案 + 格式 + 语言一致性
data  = rejection_sample(model, keep=correct) + general_sft_data   # 约 80 万条
model = SFT(base, data)                      # 注意:回到 base 重新 SFT
model = GRPO(model, rule_rewards + preference_rm)  # 全场景 RL 收尾

R1 那条最值得看:拒绝采样(第 2 式)在这里不是对齐算法,而是数据工厂——用 RL 训到高水平的模型给自己批量生产 SFT 数据。六式不只是并列的选项,还能互相当零件。

收官:其实只有一个算法

把六段伪代码的梯度全部展开,会发现它们是同一个形状:

L    twlogπθ(ytx,y<t)\nabla \mathcal{L} \;\approx\; -\sum_t w \cdot \nabla \log \pi_\theta(y_t \mid x, y_{<t})

全都是加权的 next-token 梯度,区别只在权重 ww 从哪来:

  • SFT:w=1w = 1,人类示范说了算
  • 拒绝采样:w=1w = 1,但只给自己的 Best-of-K
  • PPO / GRPO:w=Atw = A_t(优势),可正可负,裁判说了算
  • DPO:赢家拿正权重、输家拿负权重,权重大小由”当前模型把偏好排得多错”决定(这是 DPO 论文自己给出的梯度解读)

这个视角把整个领域压缩成一句话:后训练就是在决定”给哪些 token 的概率上调或下调、调多大力度”——所有算法之争,都是权重来源之争。 谁提供 ww,谁就是事实上的目标函数;而目标函数即命运。

想继续往下钻:PPO 每个部件的完整推导在《PPO 的训练循环》,权重(目标)设计如何塑造模型性格在《目标函数即命运》

参考来源

一手论文(arXiv):

工程实践与综述: