DPO这几年在开源模型对齐领域的热度,大家有目共睹。比起RLHF那套动辄四五个模型来回拉锯的流程,DPO把偏好对齐变成了一个看起来异常朴素的目标函数,于是越来越多团队开始在自己的业务模型上尝试。但问题也随之而来:网上的教程要么把推导过程写得像数学系讲义,要么直接甩给你一段封装好的训练脚本,跑通之后根本说不清每一步在干什么。这篇博文我想换一种方式,用一套刻意保持"简陋"的教学Demo,把DPO从原理到代码完完整整摊开讲一遍,适合那种已经跑过SFT、但对偏好对齐还停留在"大概知道"阶段的同学。
先说结论:DPO真正难的不是公式,而是搞清楚损失函数里每一个符号在线性前向和反向传播时到底对应什么张量。只要这一步通了,剩下的实现就只是体力活。这个Demo我会尽量控制在能展示完整逻辑的最小规模,数据只手工构造三十条左右,模型选择一个量级很小的开源对话模型,确保普通显卡也能几分钟跑完,方便你反复改参数观察变化。
1. 为什么RLHF这么难落地,DPO直接绕过去了
1.1 RLHF三件套的"三高"问题
在进入DPO之前,有必要先说清楚我们到底在解决什么问题。传统的RLHF流程,常见做法是训练一个奖励模型,再用PPO去微调策略模型。这一套流程在工业界确实能出效果,但落地时至少有三个让人头疼的地方。
第一个是训练成本高。整个流程里你通常需要维护四个模型:被训练的演员模型、冻结的参考模型、奖励模型,以及PPO阶段用于生成rollout的采样模型。哪怕其中有些模型可以参数共享,工程上的显存和调度复杂度也比普通微调高一大截。第二个是稳定性差。PPO的clip范围、优势估计的gamma和lambda、奖励模型输出的尺度缩放,每一个超参都可能在某个随机种子下突然让loss曲线飞出天际。第三个是奖励模型的可靠性问题。奖励模型打分和人类真实偏好之间总有偏差,模型学会钻奖励模型的空子时,出现过"越训越不像人话"的案例。
1.2 DPO的一句话本质
DPO论文给出的核心观察其实非常反直觉:既然奖励模型最终是用来给策略模型提供梯度信号的,那能不能不显式训练奖励模型,而是把奖励函数"隐式地"塞进损失函数里?
就这么一句话,整个流程被砍掉了大半。DPO不需要训练奖励模型,不需要PPO那样复杂的actor-critic交互,只需要三样东西:一个待微调的策略模型、一个冻结的参考模型、一组偏好数据。参考模型一般直接用SFT阶段产出的模型副本,或者用SFT模型继续训练前的checkpoint,反正它在整个DPO训练过程中是冻结的。偏好数据的形式也很简单,就是prompt加上一个"更优回答"和一个"更差回答"。
这个三元组带来的好处是,DPO把偏好对齐变成了一个有监督学习问题,可以用标准的交叉熵训练方式去优化。我见过很多从PPO迁移过来的同学,第一次看到DPO训练循环时都有点恍惚:就这?真的没有奖励模型、没有价值网络、没有一个又一个的PPO epoch?是的,核心循环就这么简单。
1.3 DPO适合做什么类型的教学Demo
选择DPO作为教学Demo,我个人的判断是它具备"三小"特征:理论切口小、代码体量小、数据规模小。你可以在一台消费级显卡上完整跑通从数据准备到loss收敛的全部链路,并且因为模型小、步骤少,中间任何一个环节出错都能很快定位。
不过也要泼一盆冷水:DPO不是万能的。如果你的场景需要非常细粒度地控制生成风格,或者需要模型在多个维度上权衡奖励,DPO的静态偏好数据会让你调整起来很费力。此外,DPO对偏好数据的质量极其敏感,数据里如果混了噪声,模型很容易学习到某种"风格偏移"而不是真正的能力提升。这些限制我会在后面用实验现象具体展开。
2. DPO损失函数逐项拆解:每个符号都不是白写的
2.1 从Bradley-Terry模型到损失函数的推导红线
DPO的推导起点其实是一个很老的统计模型:Bradley-Terry模型。它用来描述两两比较的场景,假设每个选项都有一个潜在的"强度值",那么A胜过B的概率可以写成强度值的sigmoid函数。放到偏好对齐里,这个"强度值"就是语言模型对于一个回答的隐式奖励。
我们假设人类偏好符合Bradley-Terry模型,那么对于同一个prompt下的两个回答,人类更喜欢chosen而不是rejected的概率,可以写成两者奖励之差经过sigmoid之后的结果。DPO的关键一步,是把RLHF目标函数里那个抽象的奖励函数,通过一个带KL约束的最优策略解析式替换掉。这个解析式等于β乘以策略模型和参考模型在该回答上的对数概率比。
也就是说,隐式奖励 = β × (策略模型对数概率 - 参考模型对数概率)。这个式子太重要了,你后面看任何DPO代码,只要看到log_ratio,本质都是在算这个隐式奖励。
2.2 DPO损失公式的语义化理解
我在代码解析部分会写实现,这里先讲语义。DPO损失最终长这样:
loss = -log_sigmoid( β * ( log(πθ(chosen) / πref(chosen)) - log(πθ(rejected) / πref(rejected)) ) )为了不让符号吓到你,我们把它拆成三层理解。
第一层,$\log(\pi_\theta(chosen) / \pi_{ref}(chosen))$是什么意思?它衡量的是策略模型相对于参考模型,对chosen回答的"偏好增量"。如果训练让这个值变大,说明模型比参考模型更愿意生成chosen回答。
第二层,被减掉的另一项则是模型对rejected回答的"偏好增量"。我们当然希望这个增量越小越好,最好变成负数,即模型比参考模型更不愿意生成rejected回答。
第三层,两者相减得到的差值,就是模型对"chosen相对rejected"的偏好强度,再乘上β缩放,最后过一层sigmoid取负对数。这个负对数似然的形式,会让模型持续拉大chosen和rejected之间的概率差距,直到sigmoid输出逼近1、损失逼近0为止。
有基础的同学可能会注意到,这其实就是一个二分类交叉熵,只不过我们把"正样本"和"负样本"换成了chosen和rejected的对数概率比。想通了这一点,你甚至可以手动实现一个等价版本:先把两边的logits算出来,然后用BCEWithLogitsLoss去吃差值,效果是完全一样的。
2.3 β温度参数到底在控制什么
β在DPO里是一个既像温度又像学习率的角色。从公式来看,β直接缩放了隐式奖励的尺度。β越大,模型对于chosen和rejected之间的概率差异就越敏感,梯度信号也越强,训练起来会更快地把两者概率差距拉开。但代价是,它可能不顾参考模型原有的分布约束,导致模型输出变得激进,甚至牺牲语言流畅性来追求"更像chosen"。
反过来,如果β太小,模型感受到的偏好信号就很弱,训练半天可能loss在缓慢下降,但生成结果和初始化模型几乎没差别。我在实际操作里的经验是,β的范围通常落在0.05到0.5之间。教学Demo建议从0.1起步,观察loss变化和生成结果之后再微调。具体数值和base模型、数据规模都有关系,不存在一个放之四海皆准的"最佳β"。
2.4 参考模型为什么要冻结
代码里最容易被忽视但也最值得讲清楚的一点,是参考模型必须冻结、而且是全程不更新梯度。参考模型参数不变,它提供的是一个稳定的"初始分布基线"。我们用策略模型对比参考模型,每一次计算log-ratio都是在回答一个问题:和没做过DPO之前相比,现在这个模型有多愿意生成这段文本?
如果参考模型也被更新了,这个基线就会变成移动靶。更糟的是,策略模型和参考模型会在训练过程中逐渐趋同,log-ratio接近常数,隐式奖励的信号直接消失。我甚至见过有人把参考模型写成策略模型的深拷贝,结果每步更新完参考模型也跟着变,loss直接飞掉。这个坑很好躲,但值得在Demo代码里做明确注释:ref_model.eval()和no_grad一个都不能少。
3. Demo选型与数据准备:越简单越能看清楚机制
3.1 为什么教学Demo要把模型选得足够小
教学Demo的首要目标是让人看清楚机制,而不是刷榜单。所以我刻意选择一个参数量很小的开源对话模型作为base模型,具体量级在1亿到5亿参数之间就足够了。这个规模的好处是推理速度快,单卡训练几分钟就能看到loss变化,而且显存占用低,方便你反复试不同超参。
很多同学有个误区,觉得DPO必须在一个经过充分SFT的模型上做,base模型越大越好。如果只是为了理解原理,其实没必要。SFT质量会影响最终对齐效果,但DPO损失函数的计算方式完全不受模型规模限制。先用小模型跑通全流程,再迁移到大模型SFT checkpoint上,这是我认为最高效的学习路径。
3.2 手工构造数据的关键:prompt、chosen、rejected三元组
网上开源偏好数据集不少,但对于教学Demo,我更推荐你手工构造三十条左右的样本。不是说开源数据不好,而是当数据规模很大的时候,你很难判断loss下降到底是数据在起作用,还是训练配置出了问题。手工数据的好处是,你清楚地知道每一条数据想让模型学会什么,训练完可以直接针对这些样本做生成验证。
每条样本的结构非常固定,就是一个字典,包含三个字段。
| 字段名 | 含义 | 示例 |
|---|---|---|
| prompt | 用户输入的指令或上下文 | "帮我写一封请假邮件给导师" |
| chosen | 希望模型优先输出的回答 | 简洁礼貌、结构清晰的邮件正文 |
| rejected | 希望模型避免输出或降低倾向的回答 | 口语化、漏掉关键信息的邮件正文 |
这里的关键是chosen和rejected的差异要"方向单一"。比如同一组数据里,chosen比rejected更简洁,那就不要让rejected同时又在用词上显得更礼貌,否则模型会收到互相冲突的信号,学起来非常拧巴。我最早构造数据时犯过这个错误,结果loss波动非常大,后来把每条数据的差异维度收窄到一到两个,训练才稳定下来。
3.3 数据构造的实操细节:宁可少,不要脏
手工数据的另一个优势是可以精确控制长度。训练时,我会把prompt和回答拼在一起,统一截断到固定长度,比如256个token。构造chosen和rejected时,尽量让两边的长度不要差太悬殊。如果chosen写了200个token,rejected只写30个token,对数概率的绝对值差异会天然偏向长文本那一侧,模型可能只是在学"长度偏好"而不是内容偏好,这个信号会干扰真正的对齐目标。
此外,我建议数据里混合多种任务类型,比如写邮件、代码风格修正、聊天回复、摘要改写,比例大概各占四分之一。这样模型不会在单一任务上过拟合,你也能在训练后分别测试不同任务上的提升效果。三十条数据听起来少,对DPO这种成对学习的场景来说其实已经足够观察完整的行为变化了。
4. 代码走查:一行一行看DPO训练主流程
4.1 数据加载与tokenize:先想清楚每个batch长什么样
进入代码之前,先想清楚数据在训练循环里的形态。传统SFT的batch是[prompt + response]整段文本。DPO的batch要稍微复杂一点,每一对样本我们需要同时把chosen和rejected都做tokenize,并且分别前向一次。所以我设计了一个非常朴素的数据集类。
from torch.utils.data import Dataset class PrefDataset(Dataset): def __init__(self, raw_data, tokenizer, max_len=256): self.data = raw_data self.tokenizer = tokenizer self.max_len = max_len def _encode(self, prompt, response): text = prompt + response return self.tokenizer( text, truncation=True, max_length=self.max_len, padding=False, return_tensors="pt" )["input_ids"][0] def __getitem__(self, idx): item = self.data[idx] chosen_ids = self._encode(item["prompt"], item["chosen"]) rejected_ids = self._encode(item["prompt"], item["rejected"]) return { "chosen_ids": chosen_ids, "rejected_ids": rejected_ids, } def __len__(self): return len(self.data)这个类做的事情非常简单:把prompt和回答拼接起来,分别编码成token序列。你可能注意到我没有做padding,这是为了让每个序列在模型前向时按照自身长度计算,后面再用DataLoader的collate_fn统一补到batch内最大长度。这样做的好处是内存干净,坏处是batch内长度差异大会浪费算力。教学Demo规模小,完全够用。
4.2 模型初始化:策略模型和参考模型必须分开加载
接下来是模型初始化的部分,这里有一处非常容易出错的细节。策略模型和参考模型虽然初始权重一样,但绝不能简单地把同一个对象赋值两次。因为PyTorch的模型对象是引用语义,你如果写成ref_model = policy_model,优化器更新策略模型参数时,参考模型的参数也会跟着变,整个log-ratio的计算就失去意义了。
正确做法是分别加载两份权重到两个独立的模型对象中。
import torch from transformers import AutoModelForCausalLM, AutoTokenizer model_name = "your-small-chat-model" # 换成你本地的模型路径 policy_model = AutoModelForCausalLM.from_pretrained(model_name) ref_model = AutoModelForCausalLM.from_pretrained(model_name) # 参考模型必须冻结 for param in ref_model.parameters(): param.requires_grad = False ref_model.eval() policy_model.train()这里再强调一个细节:ref_model.eval()不只是开关Dropout和BatchNorm,更关键的是让后续所有前向计算都在no_grad语义下进行。当然,最稳妥的方式还是在每一次前向参考模型时,用torch.no_grad()包裹一次,防止任何意外的梯度传播。我自己的代码习惯是双保险,两个地方都写上。
4.3 前向计算与对数概率提取的两种写法
接下来是DPO的核心建设工程:给定一段token序列,拿到模型对它生成的"对数概率"。表面上看,只需要log_softmax然后按token位置取值。但这里有个陷阱:因果语言模型在前向时,每个位置预测的是下一个token的概率。所以对于一段完整的序列,我们计算的是模型以序列前面的token为条件、逐步生成后续token的累积对数概率。
提取的方法有两种。最直接的一种是完整前向得到所有位置的logits,然后用torch.gather去取真实token位置的对数概率。下面这段代码把这条路径写得很清楚。
import torch.nn.functional as F def sequence_logprob(model, input_ids): with torch.no_grad(): logits = model(input_ids).logits # [batch, seq_len, vocab_size] log_probs = F.log_softmax(logits, dim=-1) # 去掉最后一个位置的预测,因为我们不关心生成下一个token log_probs = log_probs[:, :-1, :] target_ids = input_ids[:, 1:] # 收集每个真实token位置的对数概率,并累加 token_log_probs = torch.gather( log_probs, dim=-1, index=target_ids.unsqueeze(-1) ).squeeze(-1) return token_log_probs.sum(dim=-1)第二种方法是利用HuggingFace的generate内部函数,但那个实现隐藏了很多细节,对教学不友好。我强烈建议第一次接触DPO的同学用上面这种显式写法,因为它让你看清每一步张量形状的变化:logits是[batch, seq_len, vocab_size],切片后变成[batch, seq_len-1, vocab_size],gather之后得到[batch, seq_len-1],最后求和变成[batch]。
4.4 一个batch的DPO损失:四次前向后的一行核心计算
一个batch的数据包含chosen_ids和rejected_ids。为了计算损失,我们需要四个数值:策略模型在chosen上的对数概率、策略模型在rejected上的对数概率、参考模型在chosen上的对数概率、参考模型在rejected上的对数概率。翻译成代码就是四次前向。
这里有一个性能小技巧:参考模型的前向不需要梯度,所以可以先把chosen和rejected两个batch拼起来,一次前向算完两组,减少调度开销。策略模型则必须保持正常的梯度计算,所以分开算。
def compute_dpo_loss(policy_model, ref_model, batch, beta=0.1): chosen_ids = batch["chosen_ids"] rejected_ids = batch["rejected_ids"] # 策略模型前向,保留计算图 policy_chosen_lp = sequence_logprob_stable(policy_model, chosen_ids) policy_rejected_lp = sequence_logprob_stable(policy_model, rejected_ids) # 参考模型前向,不保留计算图 with torch.no_grad(): ref_chosen_lp = sequence_logprob(ref_model, chosen_ids) ref_rejected_lp = sequence_logprob(ref_model, rejected_ids) # 核心DPO公式 chosen_log_ratio = policy_chosen_lp - ref_chosen_lp rejected_log_ratio = policy_rejected_lp - ref_rejected_lp logits_diff = beta * (chosen_log_ratio - rejected_log_ratio) loss = -F.logsigmoid(logits_diff).mean() return loss需要说明的是,sequence_logprob_stable和sequence_logprob的区别只在于前者不包torch.no_grad(),因为策略模型要正常回传梯度。这个函数对每个token的对数概率求和时,假设的是整段文本必须按顺序被模型"生成"。对于偏好比较来说,这种处理是合理的。
有同学可能会问:为什么不用log_softmax(chosen) - log_softmax(rejected)之间的交叉熵平滑一下?因为DPO的数学形式已经通过Bradley-Terry模型把偏好概率固定成了sigmoid形式,我们没有理由引入额外的平滑项,加了反而会偏离最优策略闭式解。
4.5 训练主循环:再普通不过的PyTorch循环
当你把损失函数封装好之后,训练主循环就和普通微调没太大区别了。唯一的重点在于:每个batch都必须重新计算chosen和rejected两侧的前向,而不能只算一侧。这个"必须"就是DPO逻辑自洽的核心。
from torch.utils.data import DataLoader def collate_fn(batch): chosen_ids = torch.nn.utils.rnn.pad_sequence( [b["chosen_ids"] for b in batch], batch_first=True, padding_value=0 ) rejected_ids = torch.nn.utils.rnn.pad_sequence( [b["rejected_ids"] for b in batch], batch_first=True, padding_value=0 ) return {"chosen_ids": chosen_ids, "rejected_ids": rejected_ids} dataset = PrefDataset(my_raw_data, tokenizer, max_len=256) dataloader = DataLoader(dataset, batch_size=4, shuffle=True, collate_fn=collate_fn) optimizer = torch.optim.AdamW(policy_model.parameters(), lr=5e-5) for epoch in range(3): for step, batch in enumerate(dataloader): loss = compute_dpo_loss(policy_model, ref_model, batch, beta=0.1) loss.backward() optimizer.step() optimizer.zero_grad() if step % 10 == 0: print(f"epoch: {epoch}, step: {step}, loss: {loss.item():.4f}")如果你已经跑过SFT,看到这个循环应该一点不陌生。DPO在工程实现上之所以比RLHF友好,不是因为它用了什么特殊黑科技,而是因为它把复杂的强化学习目标收敛到了一个标准监督学习的loss上。整个代码核心就是那行beta * (chosen_log_ratio - rejected_log_ratio),其余全是普通的前向、反向、更新。
5. 我实际跑这个Demo踩过的坑与结果解释
5.1 β取值不当的两种极端表现
我把β设为1.0跑了一轮实验,loss下降非常快,几乎在第二步就降到了0.1以内。乍一看训练成功,但用训练后的模型生成几个测试prompt,发现文本流畅性明显下降,甚至出现在句尾重复无意义词语的现象。这就是β过大的典型症状:隐式奖励差异被过度放大,模型把概率集中到chosen答案所在的狭窄区域,导致输出多样性丧失。
反过来,β设为0.01时,loss同样在下降,但下降速度慢得离谱,训练结束后对比生成结果,和初始化模型几乎没有任何可感知的差异。这也不是我们想要的效果。
最终我把β定在0.1附近,loss下降曲线平稳,生成结果在保留语言流畅性的同时,明显更贴近chosen回答的风格。这个现象和理论预期是一致的:β控制的是隐式奖励的缩放尺度,不是越大越好,而是在"对齐强度"和"保持良好分布"之间取平衡。实跑时你完全可以用一组小测试集,每跑完一个β都生成几条样本人工对比,这是最直观的选择β的方式。
5.2 loss在下降,生成质量反而变差是怎么回事
我第一次跑通DPO时,看到loss从0.69一路降到0.2,还挺兴奋,结果一测试生成,发现模型开始大量复读chosen回答里的固定句式,几乎丧失了泛化能力。排查了很久才发现问题不在loss计算,而在数据构造。
我当时构造的chosen回答和rejected回答,虽然内容上确有优劣之分,但chosen普遍用了相似的句式模板。对于一个只有一亿多参数的小模型来说,它在优化过程中很快就找到了"捷径":与其学习你的偏好逻辑,不如把所有输出都往chosen句式上靠,这样对数概率提升最快。这本质上是模型过度拟合了偏好数据中的表面特征。
解决方法是把手工数据里的句式多样性提高。同一类任务下,chosen可以有时用短句、有时用长句,有时开门见山、有时先总结后展开。让模型没法靠某个表层模板通关。这其实也揭示了一个重要原则:DPO数据的"难度"应该足够大,rejected不能被一眼看出单纯因为某个词不对而被拒绝。如果chosen和rejected的差距过于集中在用词表面,模型学到的就只是字符串层面的替换,而不是真正的行为偏好。
5.3 训练步数、学习率与模型记忆的微妙关系
DPO虽然是有监督的loss,但它仍然会过拟合。小模型在小数据集上过拟合尤其快。我试过把学习率调到1e-4,batch_size设为8,结果训练第二个epoch时loss就已经接近0,但生成测试时出现了大量和chosen几乎一字不差的记忆复现。
一个靠谱的经验是:DPO训练epoch数不必太多,教学Demo一般两到三个epoch足够,关键是每轮epoch之后停下来做生成验证。如果loss还在缓慢下降且生成结果在变好,可以继续;如果loss已经低于一个很小的阈值但生成开始复读,马上停。活在loss曲线里是训练大模型的常见毛病,在DPO这种小数据场景下更明显。
我还建议独立留出几条不参与训练的验证prompt。这些prompt的chosen和rejected同样手工构造,但绝不进入训练集。每次训练完只用验证集做对比测试,才能真正评估模型学到了偏好还是记住了数据。这个习惯非常便宜,但对判断要不要继续训、要不要调β帮助极大。
5.4 为什么我建议你从最小规模起步
最后说一个更偏方法论的建议。很多同学喜欢一上来就用数十亿参数模型和大型偏好数据集,期望一步到位解决对齐问题。但从我的经验看,DPO这个算法的"工程陷阱"远比理论描述的要琐碎,比如参考模型没有冻结、chosen和rejected长度不匹配、β尺度和模型规模不匹配、数据里存在互斥的偏好信号。这些问题在大模型、大数据下更难定位,因为每一步都跑得又慢又贵。
反过来,先把模型缩小到一个亿级别,数据缩减到几十条,跑通整个链路并且能清晰解释loss曲线和生成结果的变化原因,再去放大规模,整体效率反而更高。这也是我写这套教学Demo的初衷:不是给你一个可以上生产的完整框架,而是让你用最小成本把DPO的每一个环节变成自己的直觉。有了这个直觉,后面换模型、换数据、换分布式框架,都只是换参数的问题。