DPO直接偏好优化:原理、损失函数与极简实现
发布时间:2026/10/22 9:20:30 锦皓数字建站

DPO 这个名字最近两年在模型对齐方向火得不行做 LLM 微调的人多少都会碰到。如果你已经跑通了监督微调开始接触 RLHF、人类偏好对齐这些东西那 DPODirect Preference Optimization直接偏好优化几乎就是绕不开的一道坎。它的核心卖点非常直接不需要训练奖励模型不需要复杂的强化学习采样循环只用一条偏好数据对就能把模型对齐到人类偏好上。我最初看到这个思路也觉得有点反直觉——不建奖励模型怎么优化但当你真正把代码跑起来把损失函数拆开看一遍就会发现它的设计其实非常干净。这篇博文不会去讲太多学术公式推导而是从一份极简教学 Demo 的角度把 DPO 从原理到代码实现完整捋一遍。适合两类人一是刚了解 RLHF、想知道 DPO 到底在做什么的入门者二是已经能跑通 SFT但对着 DPO 训练脚本不知道怎么改、踩了不少坑的实践者。我会把 Demo 里每一段关键代码的意图、损失函数为什么长这样、超参数怎么选连同我在实际训练中遇到的典型问题全部写出来希望能帮你少走一些弯路。1. 整体设计思路DPO 为什么值得上手1.1 传统 RLHF 的复杂链路与 DPO 的化繁为简在聊 DPO 的代码之前得先搞清楚它到底解决了什么问题。传统的 RLHF 链路是四步走先拿人类偏好数据训练一个奖励模型再冻结基座模型、加载一个待优化的策略模型然后用强化学习算法最常见的是 PPO去让策略模型在奖励模型的打分下不断提升。这个链路有两个难点让人很头疼第一奖励模型本身要单独训练、单独调参它的质量直接决定后续强化学习的上限第二PPO 的在线采样、优势估计、价值网络这些组件非常重训练不稳定是常态动不动就崩连很多做过多年 RL 的人都觉得麻烦。DPO 的核心洞察在于它把“奖励模型”和“策略优化”这两件事合并了。具体来说DPO 利用了一个数学上的等价关系——在 KL 约束下最优策略和奖励函数之间可以互相转换。也就是说我们可以不显式训练奖励模型而是直接把这个最优策略下的隐式奖励表达式代入 Bradley-Terry 偏好模型中最终得到一个只依赖策略模型和参考模型的简单损失函数。这个损失用一行代码就能写完训练过程和普通的交叉熵微调几乎一样简单。这也是为什么 DPO 发布之后很快就被大量采用——性能上能对标甚至超过传统 RLHF但工程复杂度低了一个量级。1.2 Demo 代码的定位教学优先于性能我们这份极简教学 Demo目标不是刷排行榜而是把 DPO 的核心机制完整跑通。所以它的设计会有几个明确取舍不使用分布式训练不使用大模型甚至连 LoRA 我都不打算直接套现成的训练库而是用最原始的 PyTorch 代码把训练循环写出来。这样做有好处你能看到每一个张量的形状、每一步梯度是怎么回传的、损失函数里每一项在算什么。当你真正理解了这个最简版本再去上 Hugging Face 的 TRL 库或者使用各种大模型训练框架就只是换个 API 的问题底层思想是一样的。Demo 的完整流程大概是这样准备一组偏好数据每条数据包含一个问题、一个被偏好的回答 chosen、一个不被偏好的回答 rejected加载一个预训练模型作为 policy model同时复制一份冻结参数作为 reference model然后计算两条回答在 policy model 和 reference model 下的对数概率带入 DPO 损失做常规的反向传播。整个过程不采样、不强化、不更新参考模型思路非常线性。我之前自己复现这个流程时最大的感受是DPO 的代码框架比 RLHF 简单太多但数据质量的影响会被放大很多。因为整个训练信号完全来自偏好之间的对比如果偏好数据本身标注质量差或者 chosen 和 rejected 的差异不明显模型的分布偏移就会变得很怪。这一点在后面会详细说。2. 核心细节解析DPO 损失函数的数学直觉2.1 偏好模型从 Bradley-Terry 说起要理解 DPO 的损失函数得先理解数据是怎么来的。我们搜集人类反馈时通常会拿到三元组 $x$提示、$y_w$优秀回答、$y_l$较差回答。Bradley-Terry 模型给出了一种建模方式假设存在一个底层的奖励函数 $r(x, y)$人类选择 $y_w$ 而不是 $y_l$ 的概率可以写成 sigmoid 形式$$P(y_w \succ y_l \mid x) \sigma(r(x, y_w) - r(x, y_l))$$这个式子很直观两条回答的奖励分差越大被偏好的那条胜出的概率越高。传统 RLHF 的做法是先学一个参数化的奖励模型去拟合这个概率再拿这个奖励模型去指导策略优化。但 DPO 走了一条完全不同的路——它压根不去显式学奖励函数而是直接把奖励函数进行参数化替换。突破口在于一个已经证明的结论在 KL 散度约束下最大化奖励的强化学习问题有闭式解。最优策略 $\pi^*$ 和奖励函数 $r$ 之间存在一一对应关系奖励可以表示为$$r(x, y) \beta \log \frac{\pi^*(y \mid x)}{\pi_{\mathrm{ref}}(y \mid x)} \beta \log Z(x)$$这里的 $Z(x)$ 是配分函数$\beta$ 是控制偏离参考模型程度的温度参数。DPO 的核心聪明之处就是把上面这个奖励表达式直接代回 Bradley-Terry 公式配分函数 $Z(x)$ 会在分子分母的比较中消掉。最终你得到的损失函数只和当前策略模型、参考模型的概率比有关完全不需要奖励模型。2.2 隐式奖励与参考模型的角色DPO 里虽然没有显式的奖励模型但每个策略模型其实都隐含着一个奖励函数。你可以把 $\beta \log(\pi(y \mid x) / \pi_{\mathrm{ref}}(y \mid x))$ 看作是当前策略相对于参考模型的“优势分数”。当策略模型给某条回答的概率显著高于参考模型时就等价于隐式奖励很大反之则很小。这个视角解释了 DPO 为什么能在不建模奖励的情况下对齐人类偏好。那 reference model 在这里起什么作用它是训练开始之前保存的初始策略副本全程冻结。它的存在相当于一个“锚点”防止模型在优化过程中偏离原始分布太远。如果你完全没有参考模型或者参考模型加载错了优化目标里就没有了防止分布漂移的约束模型很快就会过度优化偏好数据出现重复、退化、单一化等问题。我在实际复现中曾经偷懒跳过参考模型结果 loss 下降得非常快但生成的文本质量一塌糊涂这个教训后面会展开讲。2.3 损失实现中的关键参数DPO 的最终损失函数写出来非常简洁。定义两个概率比策略模型下 chosen 与 rejected 的对数概率差$\log \pi(y_w|x) - \log \pi(y_l|x)$以及参考模型下的同样差值。两者相减得到一个 logits再套一个负对数 sigmoid就是最终的损失$$\mathcal{L}{\mathrm{DPO}} -\log \sigma\left(\beta \left(\log \frac{\pi(y_w|x)}{\pi(y_l|x)} - \log \frac{\pi{\mathrm{ref}}(y_w|x)}{\pi_{\mathrm{ref}}(y_l|x)}\right)\right)$$这个公式的信息量很大拆开看有三层第一层如果策略模型给 chosen 的概率提升、给 rejected 的概率下降那么两个概率差会变大损失变小第二层参考模型的概率差相当于一个基线如果参考模型本身就已经偏向 chosen那么策略模型需要超过这个基线才会得到奖励这有效防止了模型把分布偏移到参考模型已经偏好过的方向上第三层$\beta$ 参数控制这个偏移的惩罚强度。在 Demo 代码中这个损失函数通常只需要五六行即可实现。如果你看到别人代码里的损失函数长得很奇怪多半是对负对数 sigmoid 做了等价变换比如用 log-sum-exp 来稳定计算核心逻辑都是一样的。3. 实操过程与核心实现逐段解析3.1 数据准备偏好数据的组织方式DPO 的数据集和 SFT 不太一样不能简单搞一个“输入-输出”的映射就完事。每一条训练样本必须同时包含三样东西prompt提示、chosen被偏好的回答、rejected被拒绝的回答。很多开源偏好数据集的格式就是这样的三元组你在加载时需要注意字段名称不同数据集叫法可能不一样有的叫 accepted/rejected有的叫 chosen/response_chosen 等。在极简 Demo 里我会建议先把数据组织成一个列表每个元素是(prompt, chosen, rejected)的字符串元组然后单独写一个预处理函数把三个字符串拼接成模型输入的 token 序列。一个非常关键的细节是chosen 和 rejected 必须使用完全相同的 prompt 前缀并且要分别独立拼接不能把两条回答放进同一个序列否则模型看到 token 顺序就会混乱。def construct_input(prompt, response): # 使用统一的对话模板拼接 prompt 和 response text template.format(promptprompt, responseresponse) return tokenizer(text, truncationTrue, max_lengthmax_seq_len)很多新手在第一步就踩坑chosen 和 rejected 的序列长度不一致于是在 batch 时需要 padding。此时要特别小心padding 的部分绝不能参与 loss 计算否则模型会去预测我们人为填充的 token导致概率计算完全失真。一种做法是把 padding 位置的标签设为 -100在计算对数概率时用掩码排除还有一种做法是构造一个loss_mask只对回答部分的 token 计算概率和损失。3.2 模型加载与参考模型冻结模型加载这块Demo 里比较标准化的流程是加载一个预训练模型作为 policy model再深拷贝一份权重作为 reference model。我建议用copy.deepcopy(model.state_dict())这种直接复制状态字典的方式然后加载到另一个模型实例里而不是在同一个模型上调用两次加载这样更干净。policy_model AutoModelForCausalLM.from_pretrained(model_name) ref_model AutoModelForCausalLM.from_pretrained(model_name) # 或者直接把 policy_model 的权重深拷贝给 ref_model ref_model.load_state_dict(copy.deepcopy(policy_model.state_dict()))reference model 加载完之后立刻设置requires_grad_(False)把所有参数冻结。这一步很容易被忽略但如果不冻结梯度会同时传到两个模型上训练目标就完全错乱了。我在代码里还会加一句检查for name, param in ref_model.named_parameters(): assert not param.requires_grad, f{name} 应该被冻结另外有个小建议如果你的显存紧张但 GPU 能装下模型可以把 reference model 和 policy model 放进同一个 device两个模型的前向计算交替进行不会占用双份激活值内存因为 same batch 下两个模型是顺序执行的。3.3 训练循环与损失计算的核心实现下面这段是整篇博文最核心的代码我会一段一段拆开说。DPO 的训练循环本身并不复杂难点在于怎么把“对数概率差”算准。def compute_logps(model, input_ids, attention_mask, labels): outputs model(input_idsinput_ids, attention_maskattention_mask) logits outputs.logits # [batch, seq_len, vocab_size] log_probs F.log_softmax(logits, dim-1) # 只取每个位置模型给真实 token 的对数概率 per_token_logps torch.gather( log_probs, dim-1, indexlabels.unsqueeze(-1) ).squeeze(-1) # 把 padding 位置排除掉 mask labels ! -100 per_token_logps per_token_logps * mask.float() return per_token_logps.sum(dim-1)这段代码做了三件事第一拿到模型对每个位置、每个词表 token 的 logits转成对数概率第二通过gather把每个位置模型给真实 token 的概率捞出来第三将标签为 -100 的位置 mask 掉然后对序列求和得到整条回答的“总对数概率”。这里有个细节值得提醒求和而不是求平均在 DPO 中是故意的。因为 DPO 的损失计算的是两条回答的概率差而概率本质上是序列级联合概率的对数。如果你求平均短回答和长回答的概率就被归一化了偏好比较的尺度就失去了意义。实际操作长回答容易获得更低的平均 logprob但不是模型生成质量差的信号这只是概率乘法路径更长导致的。所以 DPO 的原则是对齐所有 token 的联合似然用求和。接下来就是 DPO 损失函数的核心计算def dpo_loss(policy_chosen_logps, policy_rejected_logps, ref_chosen_logps, ref_rejected_logps, beta0.1): policy_log_ratios policy_chosen_logps - policy_rejected_logps ref_log_ratios ref_chosen_logps - ref_rejected_logps logits policy_log_ratios - ref_log_ratios loss -F.logsigmoid(beta * logits) return loss.mean()你会发现这个实现总共就四行但每一行都对应一个重要的概念。第一行计算策略模型对 chosen 和 rejected 的总对数概率差第二行计算参考模型的同样差值第三行相减得到“策略相对于参考模型的偏好程度变化”第四行通过logsigmoid把 logits 压成损失。训练循环本身和普通的语言模型微调没什么两样把一组 batch 的 chosen 和 rejected 输入分别前向算出四个 logps然后算 loss、反向传播、更新梯度。有一点我要特别提醒如果显存允许建议把 chosen 和 rejected 拼成一个 batch 一起前向这样两个序列都能共享一次 batch padding 逻辑但计算损失时按照索引切分否则当 chosen 和 rejected 长度差异大时batch padding 会浪费大量计算。3.4 超参数选择与训练配置DPO 的超参数里最值得花心思的是 $\beta$。它的大小直接代表了“允许模型偏离参考模型多远”。在 Demo 里我默认设成 0.1这个值是很多实验验证过的安全起点但如果你发现模型生成风格变化太快、过拟合严重可以把 $\beta$ 调大比如 0.5 甚至 1.0如果感觉模型学得不够积极、偏好提升慢则可以调小到 0.05。学习率和 batch size 的选择和 SFT 类似但因为 DPO 的梯度信号来自偏好对比对数据噪声更敏感一般建议学习率不要太大在我的测试里 1e-6 到 2e-5 之间比较稳妥LoRA 场景下可以稍微大一点。训练轮数不宜过多通常 1 到 3 个 epoch 就足够多了反而容易记忆偏好数据中的噪声。还有一个常见的训练技巧是加上梯度累积。因为偏好数据构造起来成本高通常数据量不会特别大所以小 batch 配合梯度累积既能稳定优化又能在有限显存下扩大有效 batch。不过要注意DPO 的 loss 是 pairwise 的做梯度累积时不需要额外处理正常累积即可。4. 常见问题与排查技巧实录4.1 loss 一直在下降生成质量反而变差这是我复现 DPO 时遇到的最诡异的状况训练 loss 曲线很漂亮均匀下降但生成结果却越来越重复、越来越模板化。排查到最后发现这是因为我在构建偏好数据时选了一个 quality gap 很大的数据集chosen 和 rejected 的差异太明显模型一下子就把“答案风格”也学进去了。这其实是过拟合的一种表现通过增大 $\beta$ 或减小训练轮数可以缓解。我建议你在训练过程中不定期手动看一下生成示例不要只盯 loss 曲线。DPO 的 loss 下降本身并不能保证对齐质量提升因为 loss 衡量的是偏好对比的满足程度而不是绝对生成质量。你可以额外记录一个“chosen 平均对数概率差”的指标这个差值如果一直在增高模型很可能已经开始过度优化偏好数据了。4.2 chosen 和 rejected 长度差异过大这个问题在真实数据里太常见了。有些标注员写的 rejected 回答只有一句话而 chosen 是很长的段落。求和后 chosen 的总对数概率天然比 rejected 更负因为长序列的 logprob 绝对值更大但其实每个 token 的拟合程度可能差不多。这样模型会通过“加长输出”这种偷懒手段来降低 loss而不是真正学会回答内容的好坏。处理方式有两个方向。第一个方向是过滤数据拒绝掉长度差异过大的样本第二个方向是在构造 loss mask 时只对回答部分的 token 计算概率并且不要让 loss mask 覆盖 prompt 部分。如果你确实想保留这类样本可以把求和改成按 token 数量做轻微归一化但这种情况不太推荐因为会破坏 DPO 的联合概率比较逻辑。4.3 训练显存不足DPO 理论上比 PPO 省显存因为它不需要额外的值网络、不需要存储 rollout 轨迹。但如果你一次性加载 policy model 和 reference model 两份完整参数显存压力依然存在。在不影响理解核心代码的前提下有几个非常实用的降显存手段第一用 LoRA 之类的方式训练参考模型仍然全量加载但 policy model 的可训练参数极少梯度显存大幅降低。第二把两个模型放进同一个 GPU用梯度检查点gradient checkpointing减少激活值占用。第三如果 reference model 太大可以使用和 policy model 共用底层 transformer、只保存特定层副本的方式这种技巧只适合特殊网络结构我这里不多展开。我的经验是在 Demo 阶段直接用 7B 以下的小模型或者干脆用 1B 左右的模型先跑通逻辑不要一开始就上大规模模型。DPO 的训练思路在大模型和小模型上是完全一致的把 Demo 跑通了再迁移到大模型你会轻松很多。4.4 推理时出现重复与退化训练完的模型做推理时如果发现输出和参考模型几乎没差别那通常是训练不充分、$\beta$ 太大或者学习率太小。反之如果输出变得奇怪、语法错误、重复严重多半是过拟合偏好数据了也就是前面说的 loss 虽然降了但分布崩了。退化问题在 DPO 里比 SFT 更容易出现因为训练目标只对比相对偏好没有绝对质量约束参考模型的锚定作用一旦被削弱模型就会往 score 高的方向狂奔。一个很有效的补救措施是保存训练过程中多个 checkpoint每个 checkpoint 都用固定的测试 prompt 写几段生成样本人工比较之后再决定选取哪一个。不要只看验证集上的 loss因为验证集的构建方式和训练集几乎一样模型很容易在偏好对比上作弊。5. 实操心得与后续扩展方向我在实际把 DPO 从 Demo 扩展到完整项目时有几个很深的体会。首先是“参考模型就是隐式的约束器”这句话只有真正下场调过参才会有感觉——$\beta$ 和参考模型对结果的影响比学习率还大。其次是数据质量再怎么强调都不过分DPO 把训练信号全部压缩到了一条偏好对的对比里如果这条对比本身是错或者模糊的整个模型就是在朝一个错误的方向移动而且因为训练简单这个错误方向会很快被放大。所以在做过一轮完整实验后我又回头花了大量时间清洗偏好数据效果比调参明显得多。讲到这里如果你已经理解了那份极简教学 Demo 的代码结构可以继续往这几个方向扩展把单轮偏好数据换成对话级别的多轮偏好数据把标准 DPO 换成带长度惩罚或 token 级加权的变体再加上 LoRA 和 4bit 量化做高效微调。这些扩展本质上都是在不改变核心损失函数的前提下对数据 shape 和训练策略做调整。我自己跑过一圈后最深的建议是动手前先把这个最小 Demo 从头到尾写一遍不用任何封装库你会发现 DPO 的神秘感瞬间消失了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。