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个tokenrejected只写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_len256): self.data raw_data self.tokenizer tokenizer self.max_len max_len def _encode(self, prompt, response): text prompt response return self.tokenizer( text, truncationTrue, max_lengthself.max_len, paddingFalse, return_tensorspt )[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, indextarget_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, beta0.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_firstTrue, padding_value0 ) rejected_ids torch.nn.utils.rnn.pad_sequence( [b[rejected_ids] for b in batch], batch_firstTrue, padding_value0 ) return {chosen_ids: chosen_ids, rejected_ids: rejected_ids} dataset PrefDataset(my_raw_data, tokenizer, max_len256) dataloader DataLoader(dataset, batch_size4, shuffleTrue, collate_fncollate_fn) optimizer torch.optim.AdamW(policy_model.parameters(), lr5e-5) for epoch in range(3): for step, batch in enumerate(dataloader): loss compute_dpo_loss(policy_model, ref_model, batch, beta0.1) loss.backward() optimizer.step() optimizer.zero_grad() if step % 10 0: print(fepoch: {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-4batch_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的每一个环节变成自己的直觉。有了这个直觉后面换模型、换数据、换分布式框架都只是换参数的问题。