直接偏好优化 DPO

阶段 7 · 让模型从"会回答"进化到"回答得好"

灵魂拷问

SFT 之后的模型能听懂指令、能回答问题了。但同一个问题,它可能给出两种回答:

回答 A(好)

"递归是一种函数调用自身的编程技术,通常需要一个终止条件来防止无限循环,经典例子有阶乘和斐波那契数列..."

回答 B(差)

"递归就是递归,没什么好说的。"

SFT 教会了模型"怎么回答",却没教会它"什么是好的回答"。
怎么让模型自动偏好回答 A、回避回答 B?

知识地图

为什么需要对齐

  • SFT 的局限
  • 有害输出问题
  • 对齐的定义

从 RLHF 到 DPO

  • RLHF 三步流程
  • RLHF 的痛点
  • DPO 化繁为简

偏好数据

  • chosen vs rejected
  • JSONL 格式
  • DPODataset 数据流
  • 损失掩码

数学原理

  • Bradley-Terry 模型
  • DPO 损失公式
  • logits_to_log_probs
  • dpo_loss 实现

参考模型

  • 锚点机制
  • 防退化
  • 双模型实现

训练全流程

  • batch 拼接技巧
  • 学习率极小的原因
  • 梯度累积与混合精度

实践与排错

  • 构建 DPO 数据
  • 超参数调优
  • 常见错误诊断

Part 1:为何对齐

SFT 让模型"会说话",但"说好话"是另一个维度的问题。这一节我们理解对齐的动机。

SFT 训练出来的模型能回答问题、能听懂指令,看起来已经很不错了。但它缺乏一个关键能力:区分回答的好坏。同一个问题,它可能给出一个精心组织的回答,也可能敷衍了事。这一节我们要搞清楚:为什么 SFT 还不够,以及"对齐"到底要解决什么问题。

  • 训练三阶段 —— 预训练、SFT、对齐分别干什么,DPO 处于哪个阶段
  • SFT 的局限 —— 行为克隆能学会格式,却无法区分回答质量
  • 有害输出问题 —— 未对齐的模型可能产生有害、偏见、不一致的内容
  • 对齐的定义 —— 让模型输出与人类偏好保持一致

训练三阶段

图:大模型训练的三个阶段

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右三个相连的阶段卡片。第一张浅蓝色卡片"阶段一:预训练",内含"海量文本 → 学会语言规律";第二张浅橙色卡片"阶段二:SFT",内含"指令-回答 → 学会听话";第三张浅绿色加粗边框卡片"阶段三:对齐",内含"偏好数据 → 学会说好话" - 各元素关系:三个卡片从左到右用箭头连接,第三阶段卡片边框加粗强调 - 关键标注:第三阶段旁标注"DPO 所在阶段",用醒目颜色 - 配色建议:浅灰背景,预训练浅蓝,SFT浅橙,对齐浅绿加粗边框 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

对齐的核心思想:给模型看"好回答"和"差回答",让它学会区分并偏好好的。DPO 是目前最简洁高效的对齐方法。

SFT 的局限

SFT 教会模型"模仿标准答案"。但现实世界中,同一个问题存在多种回答方式,有好有坏。

SFT 能做到SFT 做不到
学会回答问题的格式区分回答的质量好坏
学会基本的对话能力避免有害、偏见、不一致的输出
记住训练数据中的知识在多种合理回答中偏好更好的那个
SFT 本质上是"行为克隆"。模型学会了说话的方式,却没有价值观来判断什么该说什么不该说。

有害输出示例

没有对齐的 SFT 模型,可能产生以下问题:

有害内容

用户:"怎么制造危险物品?"

模型(未对齐):"以下是步骤..."

模型(已对齐):"我无法协助此类请求。"

偏见内容

用户:"XX群体的人都是坏人吗?"

模型(未对齐):"是的,他们..."

模型(已对齐):"不应该对任何群体一概而论。"

对齐就是让模型的输出与人类偏好保持一致。不产生有害内容、不带有偏见、给出有帮助的回答。

Part 2:RLHF→DPO

DPO 之前,业界标准方案是 RLHF。理解 RLHF 的痛点,才能体会 DPO 的精妙。

在 DPO 出现之前,对齐的业界标准是 RLHF(基于人类反馈的强化学习)。它效果不错,但代价很高:需要 4 个模型同时跑,训练不稳定,显存爆炸。DPO 的核心洞察是——与其先训练一个评委再让评委指导选手,不如直接告诉选手哪个好哪个差。

  • RLHF 三步流程 —— SFT → 训练奖励模型 → PPO 强化学习,复杂度层层叠加
  • RLHF 的痛点 —— 4 个模型、训练不稳定、显存爆炸、奖励黑客问题
  • DPO 的核心思想 —— 砍掉奖励模型和强化学习,直接用偏好数据优化策略模型
  • RLHF vs DPO 对比 —— 模型数量、训练稳定性、实现复杂度的全面比较

RLHF 三步流程

DPO 出现之前,业界标准方案是 RLHF。流程复杂:

图:RLHF 的三步训练流程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从上到下三个阶段框。第一框"Step 1: SFT"内含"用指令数据训练";第二框橙色边框"Step 2: 训练奖励模型"内含"收集偏好数据 → 训练打分器";第三框红色边框"Step 3: PPO 强化学习"内含"模型生成回答 → 打分器打分 → PPO 优化",其中PPO优化用虚线循环箭头回到"模型生成回答" - 各元素关系:三个框从上到下用箭头依次连接,步骤2和3分别用橙色和红色边框强调复杂度 - 关键标注:步骤2旁标注"需要额外训练奖励模型",步骤3旁标注"需要4个模型,训练不稳定" - 配色建议:浅灰背景,步骤1白色,步骤2橙色边框,步骤3红色边框 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

RLHF 的痛点

痛点说明
复杂度高需要 4 个模型:SFT、奖励、Critic、Reference
训练不稳定PPO 对超参极其敏感,调参如炼丹
显存爆炸多个模型同时加载,显存开销巨大
奖励黑客模型可能钻奖励模型的漏洞,输出垃圾但得高分
正是这些痛点,催生了 DPO——一种"砍掉奖励模型,砍掉强化学习"的极简方案。

DPO 一句话核心

定义

DPO(Direct Preference Optimization):与其训练一个奖励模型来打分、再用强化学习去优化策略,不如直接用偏好数据一步到位优化策略模型本身。

白话

RLHF 是"先培养一个评委,再让评委打分指导选手"。DPO 是"直接告诉选手哪个好哪个差,让选手自己悟"。

RLHF vs DPO

图:RLHF 与 DPO 方案对比

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左右两条并行路径。左侧灰色路径"RLHF 方案":偏好数据 → 训练奖励模型 → PPO强化学习(需4个模型) → 对齐后模型;右侧绿色加粗路径"DPO 方案":偏好数据 → 直接用DPO Loss优化策略模型 → 对齐后模型 - 各元素关系:两条路径起始相同(偏好数据),RLHF路径更长更复杂,DPO路径短而直接。两条路径之间用虚线连接表示对比 - 关键标注:DPO路径用绿色加粗边框,标注"更简单、更稳定" - 配色建议:浅灰背景,RLHF灰色路径,DPO绿色加粗路径 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

维度RLHF (PPO)DPO
模型数量4 个2 个
需要奖励模型是否
需要强化学习是 (PPO)否
训练稳定性不稳定稳定,类似 SFT
数据需求偏好 + 在线采样仅偏好数据
实现复杂度高低

Part 3:数据

DPO 的核心输入是"成对偏好数据"。理解数据的结构,是理解一切的基础。

DPO 不需要奖励模型,但它需要高质量的偏好数据。每一组数据都是一个三元组:同一个问题、一个好回答(chosen)、一个差回答(rejected)。本节从数据格式到编码流程,完整拆解偏好数据是怎么从 JSONL 文件变成训练张量的。

  • 成对偏好数据 —— 同一问题配一个好回答和一个差回答,差异体现在质量而非长度
  • JSONL 格式与三条铁律 —— chosen 和 rejected 的用户提问必须完全相同
  • DPODataset 编码流程 —— 从 chat_template 到 tokenizer 编码再到张量构建
  • 损失掩码 —— 只在 assistant 回答部分计算损失,用户提问部分不参与优化
  • 数据流全览 —— 从 JSONL 到 6 个训练张量的完整转换链路

成对偏好数据

DPO 的输入是成对的偏好数据。同一个问题,一个好回答,一个差回答:

图:DPO 的成对偏好数据结构

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:顶部一个蓝色节点"用户提问:什么是递归?",下方分出两条路径。左侧绿色框"chosen 好回答"内含"递归是一种函数调用自身的编程技术...";右侧红色框"rejected 差回答"内含"递归就是递归,没什么好说的。" - 各元素关系:从用户提问节点分别引出两条箭头到 chosen 和 rejected - 关键标注:chosen框用绿色边框和对勾标记,rejected框用红色边框和叉号标记 - 配色建议:浅灰背景,提问蓝色,chosen绿色,rejected红色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

JSONL 格式

{
  "chosen": [
    {"role": "user", "content": "什么是递归?"},
    {"role": "assistant", "content": "递归是一种函数调用自身的编程技术..."}
  ],
  "rejected": [
    {"role": "user", "content": "什么是递归?"},
    {"role": "assistant", "content": "递归就是递归,没什么好说的。"}
  ]
}

三条铁律

  • chosen 和 rejected 的用户提问完全相同
  • 只有 assistant 的回答不同
  • 差异体现在质量上,不是长度或格式

数据怎么来

数据加载与编码的核心流程

class DPODataset(Dataset):
    def __init__(self, file_path, tokenizer, max_length=4096):
        super().__init__()
        self.tokenizer = tokenizer
        self.max_length = max_length
        self.padding = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0
        self.bos_id = tokenizer(f'{tokenizer.bos_token}assistant\n',
                                add_special_tokens=False).input_ids
        self.eos_id = tokenizer(f'{tokenizer.eos_token}\n',
                                add_special_tokens=False).input_ids
        self.samples = load_dataset('json', data_files=file_path, split='train')

bos_id 和 eos_id 是模型回答的边界标记。DPO 只对模型回答部分计算损失,这两个标记就是定位边界的"标尺"。

数据编码流程

__getitem__ 将偏好数据转为训练张量

def __getitem__(self, index):
    sample = self.samples[index]
    chosen = sample['chosen']      # 好回答的对话列表
    rejected = sample['rejected']  # 差回答的对话列表

    # 用 chat_template 将对话列表转为文本
    chosen_prompt = self.tokenizer.apply_chat_template(
        chosen, tokenize=False, add_generation_prompt=False)
    rejected_prompt = self.tokenizer.apply_chat_template(
        rejected, tokenize=False, add_generation_prompt=False)

    # 编码 + 截断 + padding
    chosen_encoding = self.tokenizer(
        chosen_prompt, truncation=True, max_length=self.max_length, padding='max_length')
    rejected_encoding = self.tokenizer(
        rejected_prompt, truncation=True, max_length=self.max_length, padding='max_length')

    chosen_input_ids = chosen_encoding['input_ids']
    rejected_input_ids = rejected_encoding['input_ids']

张量构建技巧

左移一位,构建训练输入和目标

    # 生成损失掩码:只在 assistant 回答部分为 1
    chosen_loss_mask = self.generate_loss_mask(chosen_input_ids)
    rejected_loss_mask = self.generate_loss_mask(rejected_input_ids)

    # x = tokens[:-1], y = tokens[1:], mask = mask[1:]
    # 模型根据位置 i 的 token 预测位置 i+1 的 token
    x_chosen = torch.tensor(chosen_input_ids[:-1], dtype=torch.long)
    y_chosen = torch.tensor(chosen_input_ids[1:], dtype=torch.long)
    mask_chosen = torch.tensor(chosen_loss_mask[1:], dtype=torch.long)
    x_rejected = torch.tensor(rejected_input_ids[:-1], dtype=torch.long)
    y_rejected = torch.tensor(rejected_input_ids[1:], dtype=torch.long)
    mask_rejected = torch.tensor(rejected_loss_mask[1:], dtype=torch.long)

    return {
        'x_chosen': x_chosen, 'y_chosen': y_chosen, 'mask_chosen': mask_chosen,
        'x_rejected': x_rejected, 'y_rejected': y_rejected, 'mask_rejected': mask_rejected
    }

每个样本返回 6 个张量。chosen 和 rejected 各 3 个:输入 x、目标 y、掩码 mask。

数据流全览

图:DPO 数据从文件到训练张量的完整流程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从上到下的流程图。顶部"加载 dpo.jsonl" → "提取 chosen 和 rejected" → "apply_chat_template 对话列表转文本" → "tokenizer 编码 truncation+padding" → 蓝色高亮"generate_loss_mask 生成损失掩码" → 底部大框"构建6个张量:左移一位",内含 x_chosen、y_chosen、mask_chosen、x_rejected、y_rejected、mask_rejected - 各元素关系:节点之间用向下箭头依次连接 - 关键标注:generate_loss_mask节点用蓝色高亮强调其重要性 - 配色建议:浅灰背景,generate_loss_mask蓝色高亮,其余白色节点 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

损失掩码生成

只在 assistant 回答部分计算损失

def generate_loss_mask(self, input_ids):
    loss_mask = [0] * len(input_ids)
    i = 0
    while i < len(input_ids):
        # 找到 assistant 回答的开头标记
        if input_ids[i:i + len(self.bos_id)] == self.bos_id:
            start = i + len(self.bos_id)
            end = start
            # 找到回答的结尾标记 eos_id
            while end < len(input_ids):
                if input_ids[end:end + len(self.eos_id)] == self.eos_id:
                    break
                end += 1
            # bos 和 eos 之间的位置标记为 1
            for j in range(start, min(end + len(self.eos_id), self.max_length)):
                loss_mask[j] = 1
            i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
        else:
            i += 1
    return loss_mask

掩码可视化

# tokens: [system] 你好 [user] 什么是AI? [assistant\n] AI是... [eos\n]
# mask:   [   0   ]  0  [  0  ]   0    [    1      ]  1 1 1 [  0  ]
#                                                    ^^^^^^^^^^^^^^^^
#                                                 只有这部分参与损失计算

用户提问是固定的,优化它没有意义。只对模型的回答部分做偏好调整。

在 dpo_loss 中的使用:(log_probs * mask).sum(dim=1)——只有 mask=1 的位置被累加。

图:损失掩码与 token 序列对照

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:上方一行水平排列的 token 方格序列,每个格内写有 token 内容如"[system]"、"你好"、"[user]"、"什么是AI?"、"[assistant]"、"AI是..."、"[eos]"。下方对应一行 mask 值条,每个格内标注 0 或 1 - 各元素关系:上方 token 与下方 mask 一一对应,mask=0 的格灰色标注"不参与损失",mask=1 的格绿色标注"参与损失" - 关键标注:绿色连续区域用大括号标注"仅 assistant 回答参与 DPO 损失" - 配色建议:浅灰背景,mask=0 灰色,mask=1 绿色,token 格白色带边框 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

Part 4:数学

DPO 背后有一套优雅的数学。理解公式,才能正确调参和排错。

DPO 不是拍脑袋想出来的,它有扎实的数学基础。从 Bradley-Terry 偏好模型出发,到 RLHF 的闭式解,再到最终的 DPO 损失公式,每一步都有清晰的推导。本节把这些数学拆成你能消化的层次,重点理解公式每个符号在代码中的对应。

  • Bradley-Terry 模型 —— 偏好建模的概率基础,好差回答之间的奖励差决定偏好概率
  • 从 RLHF 到 DPO 的推导 —— 优化问题存在闭式解,用概率比替代奖励模型
  • DPO 损失公式 —— log-sigmoid 结构,策略偏好与参考偏好的差值驱动优化
  • logits_to_log_probs —— 将原始 logits 转为真实 token 的 log 概率,DPO 比较的基础
  • dpo_loss 实现 —— 四步对应公式四层:掩码求和、拆分 batch、计算比值、log-sigmoid

BT 模型

DPO 的理论根基

假设人类对回答 y_w(好)和 y_l(差)的偏好服从 Bradley-Terry 模型:

定义

P(y_w > y_l | x) = sigmoid(r(x, y_w) - r(x, y_l))

其中 r(x, y) 是某个隐含的"奖励函数",表示回答 y 在问题 x 下的好坏程度。

白话

好回答和差回答之间的"奖励差"越大,人类偏好好回答的概率就越高。如果奖励相同,偏好概率恰好是 50%。

RLHF→DPO

DPO 的关键数学洞察

RLHF 的优化目标:最大化奖励,同时约束不偏离参考模型:

max E[r(x,y)] - beta * KL[pi || pi_ref]

DPO 论文的核心贡献:这个优化问题存在闭式解。奖励函数可以用策略模型和参考模型的概率比来表示:

r(x, y) = beta * log(pi(y|x) / pi_ref(y|x)) + beta * log Z(x)

其中 Z(x) 是配分函数,在 chosen/rejected 对比中会消掉。

不必完全理解这个推导。关键是:DPO 用概率比替代了奖励模型,绕过了强化学习。

DPO 损失公式

L = -log(sigmoid(beta * (log pi(y_w|x) - log pi(y_l|x) - log pi_ref(y_w|x) + log pi_ref(y_l|x))))

符号含义白话
y_wchosen(好回答)人类标注的优质回答
y_lrejected(差回答)人类标注的劣质回答
pi当前策略模型正在被训练的模型
pi_ref参考模型冻结的 SFT 模型
beta温度参数控制偏离惩罚力度

公式四层分解

图:DPO 损失公式的四层分解

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:四层从上到下的分解图。第一层蓝色区域"策略模型 pi(训练中)"内含 log pi(y_w) 和 log pi(y_l) 汇入 pi_logratios;第二层紫色区域"参考模型 pi_ref(冻结)"内含 log ref(y_w) 和 log ref(y_l) 汇入 ref_logratios;第三层"两者的差值 logits = pi_logratios - ref_logratios";第四层红色加粗边框"最终损失 loss = -log(sigmoid(beta * logits))" - 各元素关系:每层向下汇聚,pi_logratios和ref_logratios同时汇入logits,logits汇入最终损失 - 关键标注:蓝色=策略模型,紫色=参考模型,橙色=差值,红色=最终损失 - 配色建议:浅灰背景,策略模型蓝色系,参考模型紫色系,最终损失红色加粗 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

图:DPO 损失公式的四层分解

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:四层从上到下分解的层级图。第一层"完整 DPO 损失公式"完整展示公式,第二层拆出"pi_logratios(蓝色)= log pi(y_w) - log pi(y_l)",第三层拆出"ref_logratios(紫色)= log ref(y_w) - log ref(y_l)",第四层"logits(橙色)= pi_logratios - ref_logratios" - 各元素关系:每层用大括号或连接线从上一层拆分出来,颜色按层级区分 - 关键标注:蓝色=策略模型部分,紫色=参考模型部分,橙色=最终差值,红色=最终损失 - 配色建议:浅灰背景,pi_logratios 蓝色系,ref_logratios 紫色系,logits 橙色系 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

极端情况分析

情况pi_ratioref_ratiologitsloss
策略比 ref 更偏好 chosen2.01.01.0低(好)
策略偏好 rejected-1.00.5-1.5高(惩罚)
策略与 ref 偏好一致1.01.00.0约 0.693

pi_ratio > ref_ratio

策略比参考模型更偏好 chosen,损失降低。这是好的方向。

pi_ratio < ref_ratio

策略比参考模型更偏好 rejected,损失升高。这是坏的方向,模型会纠偏。

logits = 0 时

策略和参考模型偏好一致,loss = -log(0.5) = 0.693。这是"未训练"的基线值。

logits 转概率

logits_to_log_probs 的作用

定义:将模型输出的 logits 转为每个位置上"真实 token"的 log 概率。

白话:模型给词汇表中每个词打了个分(logits)。这个函数问:对那个实际出现的词,模型打了多少分?

假设词汇表有 5 个词 ["我", "你", "他", "她", "它"]:

# 模型在某个位置输出的 log 概率:
# [-1.2, -0.3, -2.1, -3.5, -4.0]
#               ^^^^
# 实际下一个 token 是 "你" (index=1)
# 所以该位置的 log_prob = -0.3

logits→概率

模型输出的是 6400 维的原始分数,我们需要把它变成每个 token 的对数概率

这一步很关键:DPO 要比较"策略模型给 chosen 多少分"和"参考模型给 chosen 多少分"。但模型输出的是原始 logits,不是概率。

转换分三步:先 softmax 归一化 → 再取 log → 最后用 gather 按 label 索引挑出真实 token 对应的值。

log_probs = F.log_softmax(logits, dim=2)  # 归一化 + 取 log
log_probs_per_token = torch.gather(        # 按 label 挑出真实 token
    log_probs, dim=2, index=labels.unsqueeze(2)
).squeeze(-1)
return log_probs_per_token  # (batch, seq_len)

这个函数同时用于策略模型和参考模型。DPO 的核心比较就建立在这两组值上。

张量形状变换

图:logits_to_log_probs 的张量形状变换

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从上到下的数据流。左侧蓝色3D长方体"模型输出 logits (batch, seq_len, vocab_size)"经过"log_softmax(dim=2) 转为log概率";右侧橙色扁平板"标签 labels (batch, seq_len)"经过"unsqueeze(2) → (batch, seq_len, 1)";两者汇入中间橙色高亮框"gather(dim=2) 按标签索引提取",最终输出绿色板"结果 (batch, seq_len) 每个位置真实token的log概率" - 各元素关系:两条路径从左右两侧汇入中间的 gather 操作 - 关键标注:gather操作用橙色高亮,vocab_size维度上标出"按标签挑出对应位置" - 配色建议:浅灰背景,logits蓝色,labels橙色,gather橙色高亮,结果绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

图:logits_to_log_probs 的张量形状变换

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:三个 3D 长方体从左到右排列,用箭头连接。左侧扁宽长方体标注 "logits (batch, seq_len, vocab_size)",中间扁平长方体标注 "labels unsqueeze (batch, seq_len, 1)",右侧薄板标注 "结果 (batch, seq_len)" - 各元素关系:中间的索引提取操作用 gather 箭头表示,从 vocab_size 维度"挑出"对应位置 - 关键标注:左侧 vocab_size 维度用深色高亮一列,箭头指向右侧薄板 - 配色建议:浅灰背景,logits 蓝色渐变,labels 橙色,结果绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

dpo_loss

DPO 损失函数的完整实现

def dpo_loss(ref_log_probs, policy_log_probs, mask, beta):
    # 应用掩码,只对 assistant 回答部分求和
    ref_log_probs = (ref_log_probs * mask).sum(dim=1)
    policy_log_probs = (policy_log_probs * mask).sum(dim=1)

    batch_size = ref_log_probs.shape[0]
    # 前 N 条是 chosen,后 N 条是 rejected
    chosen_ref = ref_log_probs[:batch_size // 2]
    reject_ref = ref_log_probs[batch_size // 2:]
    chosen_policy = policy_log_probs[:batch_size // 2]
    reject_policy = policy_log_probs[batch_size // 2:]

    # 核心公式:与数学公式一一对应
    pi_logratios = chosen_policy - reject_policy      # log pi(y_w) - log pi(y_l)
    ref_logratios = chosen_ref - reject_ref            # log ref(y_w) - log ref(y_l)
    logits = pi_logratios - ref_logratios              # 两者的差
    loss = -F.logsigmoid(beta * logits)                # -log(sigmoid(beta * logits))
    return loss.mean()

逐步拆解

四步对应公式的四个层次

步骤代码含义
1. 掩码求和(log_probs * mask).sum(dim=1)只累加 assistant 回答部分
2. 拆分 batch[:batch_size//2] vs [batch_size//2:]前半 chosen,后半 rejected
3. 计算比值pi_logratios - ref_logratios策略偏好 vs 参考偏好
4. 最终损失-F.logsigmoid(beta * logits)log-sigmoid 变换

-log(sigmoid(z)) 的性质:当 z 很大时趋近 0(好),当 z 为 0 时等于 0.693(基线),当 z 很小时趋近 |z|(惩罚)。

batch 拼接

一次前向传播搞定 chosen 和 rejected

# 在 train_epoch 中:
x = torch.cat([x_chosen, x_rejected], dim=0)  # (2N, seq_len)
y = torch.cat([y_chosen, y_rejected], dim=0)
mask = torch.cat([mask_chosen, mask_rejected], dim=0)

# 一次前向传播,得到 2N 条数据的 logits
# 所以 dpo_loss 用 [:batch_size//2] 和 [batch_size//2:] 拆分

避免对 chosen 和 rejected 分别做两次前向传播。拼接后一次推理,节省约一半的计算时间。

图:batch 拼接的内存布局

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:上方两个独立矩形(绿色 labeled "chosen" + 红色 labeled "rejected"),下方一个大矩形前半部分绿色后半部分红色 - 各元素关系:箭头从上方两个矩形指向下方大矩形,标注 "torch.cat(dim=0)" - 关键标注:下方大矩形中绿色区域标注 "前 N 条 = chosen",红色区域标注 "后 N 条 = rejected" - 配色建议:浅灰背景,chosen 绿色,rejected 红色,箭头深灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

Part 5:参考模型

DPO 需要两个模型。参考模型是理解 DPO 的关键之一。

蒸馏需要教师和学生两个模型,DPO 也需要策略模型和参考模型两个模型。参考模型是 SFT 模型的一个冻结副本,它的作用是充当"锚点"——防止策略模型在优化偏好的过程中走偏,退化成什么都不说。理解参考模型的角色,才能真正理解 DPO 损失在做什么。

  • 为什么需要参考模型 —— 充当安全绳,防止模型走捷径降低所有概率来"作弊"
  • 没有 ref 会怎样 —— 模型可能退化到输出空回复或乱码
  • 双模型初始化 —— 策略模型和参考模型从完全相同的 SFT 权重初始化,参考模型立刻冻结
  • GPU 显存分析 —— 参考模型省了梯度和优化器状态,MiniMind 双模型约 100MB 完全可接受

为什么需要 ref

定义:参考模型是 SFT 模型的一个冻结副本,充当"锚点",约束策略模型不能偏离太远。

白话:没有参考模型,模型可能发现一条捷径——把所有回答的概率都降低,rejected 降得更多。结果?模型学会了"什么都不说"。

参考模型就像一根安全绳。它保证模型只在已有能力上微调偏好,不会偏离到生成空回复或乱码的地步。

卸甲条款:上面的"安全绳"比喻只是帮助你建立直觉。在代码层面,参考模型是一个冻结了参数的模型副本,在 DPO 损失函数中提供基准的对数概率分布,数学上等价于 KL 散度约束项。

无 ref 退化

图:有无参考模型的行为对比

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左右两栏对比。左侧红色区域"没有 Reference Model":策略模型有两条出路——好的方向"提高chosen概率"和危险捷径"降低所有概率",捷径导致红色底"模型退化:输出空回复";右侧绿色区域"有 Reference Model":策略模型与冻结的参考模型共同作用,只有好的方向"相对ref提高chosen",最终绿色底"保持生成能力,偏好好回答" - 各元素关系:左右两栏用虚线连接表示对比 - 关键标注:左侧"危险捷径"用红色箭头标出,右侧"安全绳机制"用绿色标注 - 配色建议:浅灰背景,无ref红色系,有ref绿色系 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

双模型初始化

# 初始化策略模型(会被训练)
model, tokenizer = init_model(
    lm_config, args.from_weight, device=args.device
)
Logger(f'策略模型总参数量:{sum(p.numel() for p in model.parameters()) / 1e6:.3f} M')

# 初始化参考模型(冻结,不参与训练)
ref_model, _ = init_model(
    lm_config, args.from_weight, device=args.device
)
ref_model.eval()
ref_model.requires_grad_(False)
Logger(f'参考模型总参数量:{sum(p.numel() for p in ref_model.parameters()) / 1e6:.3f} M')

两个模型从完全相同的 SFT 权重初始化。model 的参数会被更新,ref_model 永远不变,只用于前向推理。

GPU 显存分析

策略模型(训练中)

  • 模型参数(fp16/bf16)
  • 梯度(fp32)
  • 优化器状态(fp32,AdamW 约 2x 参数量)

参考模型(冻结)

  • 模型参数(fp16/bf16)
  • 无梯度(requires_grad=False)
  • 无优化器状态
参考模型虽然省了梯度和优化器,但仍然占用参数显存。对于 MiniMind(约 64M 参数),双模型 bfloat16 下约 256MB 显存,完全可接受。

图:DPO 双模型 GPU 显存布局

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个大矩形代表 GPU 显存,内部分为上下两部分。上方蓝色区域标注"策略模型(训练中)"内含三格:"参数"、"梯度"、"优化器状态"。下方紫色区域标注"参考模型(冻结)"内含一格:"参数" - 各元素关系:策略模型的三格大小相当,参考模型只有一格,视觉上明显节省了空间 - 关键标注:梯度格和优化器格旁标注"requires_grad_(False) 可省略",紫色区域标注"no_grad" - 配色建议:浅灰背景,策略模型蓝色系,参考模型紫色系 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

Part 6:训练

把前面的所有组件串起来。DPO 训练的核心循环和普通微调很像,只多了"双模型推理"和"dpo_loss 计算两步。

数据、公式、参考模型都搞清楚了,现在把它们组装成完整的训练流程。本节从全局架构到单步时序,逐层拆解 DPO 训练是怎么运转的。你会发现核心循环和 SFT 很像,区别在于:一次前向传播要过两个模型,损失用的是 dpo_loss 而不是交叉熵。

  • 训练全流程图 —— 从数据加载到双模型推理到损失计算到梯度更新的完整链路
  • 单步训练时序 —— DataLoader 出 batch → 拼接 chosen 和 rejected → 双模型前向 → dpo_loss → 反向传播
  • 训练循环核心代码 —— train_epoch 中取出数据、拼接 batch、动态学习率调整的实现
  • 学习率为何极低 —— 默认 4e-8 比 SFT 小约 1000 倍,防止灾难性遗忘
  • 梯度累积与混合精度 —— 双模型场景下显存优化的两个关键手段

训练全流程图

图:DPO 训练的全流程架构

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:四个区域从上到下排列。顶部"数据准备"区:dpo.jsonl → DPODataset → DataLoader;中部"双模型"区:蓝色高亮"策略模型(参数会被更新)"和紫色"参考模型(参数冻结不变)";下方"前向计算"区:cat chosen+rejected → 分别通过ref_model(no_grad)和model → 得到ref_log_probs和policy_log_probs;底部红色高亮"损失计算"区:两组log_probs汇入dpo_loss → loss=dpo_loss+aux_loss - 各元素关系:数据准备→前向计算→损失计算,双模型各自连接到前向计算区 - 关键标注:策略模型蓝色加粗,参考模型紫色,dpo_loss红色加粗 - 配色建议:浅灰背景,策略模型蓝色,参考模型紫色,损失红色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

单步训练时序

图:DPO 单步训练的时序交互流程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:水平时序图,五个参与者从左到右排列:DataLoader、train_epoch、ref_model(紫色)、model(蓝色)、optimizer。循环体内的步骤用编号标注:①DataLoader传batch给train_epoch;②拼接chosen和rejected;③ref_model前向(无梯度)得到ref_logits转为ref_log_probs;④model前向(有梯度)得到logits转为policy_log_probs;⑤计算dpo_loss+aux_loss;⑥backward → step → update - 各元素关系:消息箭头从左到右表示调用,从右到左表示返回 - 关键标注:ref_model旁标注"no_grad",model旁标注"有梯度",循环体用灰色背景框 - 配色建议:浅灰背景,ref_model紫色,model蓝色,optimizer灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

训练循环核心

train_epoch 核心逻辑

def train_epoch(epoch, loader, iters, ref_model, lm_config,
                start_step=0, wandb=None, beta=0.1):
    for step, batch in enumerate(loader, start=start_step + 1):
        # 1. 取出 chosen 和 rejected 数据,移到 GPU
        x_chosen = batch['x_chosen'].to(args.device)
        x_rejected = batch['x_rejected'].to(args.device)
        y_chosen = batch['y_chosen'].to(args.device)
        y_rejected = batch['y_rejected'].to(args.device)
        mask_chosen = batch['mask_chosen'].to(args.device)
        mask_rejected = batch['mask_rejected'].to(args.device)

        # 2. 沿 batch 维度拼接,一次前向传播搞定
        x = torch.cat([x_chosen, x_rejected], dim=0)
        y = torch.cat([y_chosen, y_rejected], dim=0)
        mask = torch.cat([mask_chosen, mask_rejected], dim=0)

损失与反向传播

前向计算和梯度更新的完整流程

        # 3. 动态学习率调整
        lr = get_lr(epoch * iters + step, args.epochs * iters,
                    args.learning_rate)
        for param_group in optimizer.param_groups:
            param_group['lr'] = lr

        with autocast_ctx:
            # 参考模型前向:不计算梯度
            with torch.no_grad():
                ref_outputs = ref_model(x)
            ref_log_probs = logits_to_log_probs(ref_outputs.logits, y)

            # 策略模型前向:计算梯度
            outputs = model(x)
            policy_log_probs = logits_to_log_probs(outputs.logits, y)

            # 计算 DPO 损失 + MoE 辅助损失
            dpo_loss_val = dpo_loss(ref_log_probs, policy_log_probs,
                                    mask, beta=beta)
            loss = dpo_loss_val + outputs.aux_loss
            loss = loss / args.accumulation_steps

        # 4. 梯度累积 + 裁剪 + 更新
        scaler.scale(loss).backward()
        if step % args.accumulation_steps == 0:
            scaler.unscale_(optimizer)
            torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
            scaler.step(optimizer)
            scaler.update()
            optimizer.zero_grad(set_to_none=True)

主循环完整逻辑

模型初始化、训练启动、检查点保存

# 初始化策略模型和参考模型
model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
ref_model, _ = init_model(lm_config, args.from_weight, device=args.device)
ref_model.eval()
ref_model.requires_grad_(False)

# 创建数据集和优化器
train_ds = DPODataset(args.data_path, tokenizer, max_length=args.max_seq_len)
optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)

# 开始训练
for epoch in range(start_epoch, args.epochs):
    setup_seed(42 + epoch)
    loader = DataLoader(train_ds, batch_sampler=..., num_workers=...)
    train_epoch(epoch, loader, len(loader), ref_model, lm_config,
                0, wandb, args.beta)

学习率为何极低

默认 4e-8,比 SFT 的 1e-5 小约 1000 倍

原因一:防止遗忘

DPO 基于 SFT 模型微调。学习率太大会破坏 SFT 阶段学到的能力。模型会"忘记"怎么好好说话。

原因二:精细调整

DPO 不是教新知识,而是微调偏好方向。只需微小的参数变化,就能改变对 chosen/rejected 的偏好倾向。

原因三:DPO 梯度更敏感

DPO 损失涉及两个模型的概率差值。微小的参数变化会放大为较大的概率变化,所以需要更小的学习率。

如果学习率超过 5e-8,模型可能快速退化。表现为:输出变短、重复、甚至完全无法生成有意义的回答。

梯度累积与精度

梯度累积

accumulation_steps 控制梯度累积步数。等效 batch size = batch_size * accumulation_steps。

# 不增加显存,模拟大 batch
if step % args.accumulation_steps == 0:
    scaler.step(optimizer)
    optimizer.zero_grad()

显存不足时,增大 accumulation_steps 而非减小 batch_size。

混合精度

默认使用 bfloat16,节省显存并加速训练。

dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
autocast_ctx = torch.cuda.amp.autocast(dtype=dtype)

双模型场景下,混合精度几乎是必须的——它能节省约 50% 的参数显存。

Part 7:排错

动手构建 DPO 数据,启动训练,以及常见错误的诊断和修复。

理论和代码都看完了,本节带你真正跑起来:怎么准备 dpo.jsonl 数据文件、怎么启动训练命令、超参数怎么调。更重要的是,DPO 训练中容易踩的坑不少——loss 不降、突然爆炸、模型变傻、OOM——每个错误都有对应的排查思路。

  • 准备 DPO 数据 —— 创建 JSONL 格式的偏好数据,数据质量是 DPO 效果的决定因素
  • 启动训练命令 —— 完整的 train_dpo.py 命令行参数,必须从 SFT 权重开始
  • 超参数调优 —— learning_rate、beta、batch_size 等参数的建议范围和调优策略
  • 错误诊断 —— loss 不降、突然爆炸、模型变傻、OOM、loss 始终 0.693、NaN 等常见问题的排查

准备 DPO 数据

创建 dataset/dpo.jsonl,每行一条偏好数据:

{"chosen": [{"role": "user", "content": "1+1等于几?"}, {"role": "assistant", "content": "1+1等于2。这是基础的加法运算。"}], "rejected": [{"role": "user", "content": "1+1等于几?"}, {"role": "assistant", "content": "1+1等于3吧,我不太确定。"}]}
{"chosen": [{"role": "user", "content": "介绍一下地球"}, {"role": "assistant", "content": "地球是太阳系第三颗行星,赤道半径约6371公里..."}], "rejected": [{"role": "user", "content": "介绍一下地球"}, {"role": "assistant", "content": "地球就是个球。"}]}

数据质量是 DPO 效果的决定因素。chosen 和 rejected 的差异要体现在回答质量上,而不是长度或格式。标注不一致会导致模型学到错误的偏好。

启动训练

# 确保已完成 SFT 训练,权重保存在 out/full_sft_*.pth
python trainer/train_dpo.py \
    --epochs 1 \
    --batch_size 4 \
    --learning_rate 4e-8 \
    --beta 0.15 \
    --from_weight full_sft \
    --data_path ../dataset/dpo.jsonl \
    --device cuda:0
必须先完成 SFT 训练!--from_weight full_sft 指定从 SFT 权重开始。DPO 不能在预训练模型上直接训练。

超参数调优

参数默认值建议范围说明
learning_rate4e-81e-8 ~ 5e-8超过 5e-8 容易灾难性遗忘
beta0.150.1 ~ 0.5越大越保守,越小越激进
batch_size42 ~ 8越大越稳定,受限于显存
max_seq_len1024512 ~ 4096根据数据长度调整
accumulation_steps11 ~ 8显存不足时增大
epochs11 ~ 3DPO 通常 1 个 epoch 足够

错误诊断一

loss 不下降

可能原因:学习率太小、beta 太大、chosen 和 rejected 差异太小。

排查:打印 pi_logratios 和 ref_logratios。如果几乎相同,说明模型没有区分能力。尝试增大学习率到 2e-8 或减小 beta。

loss 突然爆炸

可能原因:学习率太大,或 chosen/rejected 数据质量差(标注反了)。

排查:检查数据中是否有 chosen 比 rejected 更差的情况。减小学习率到 1e-8。

训练后模型变傻

可能原因:灾难性遗忘——学习率太大或训练轮数太多。

排查:降低学习率到 1e-8,减少 epoch 数,增大 beta 约束偏离。

错误诊断二

CUDA OOM

原因:双模型占用约 2 倍显存。

解决:减小 batch_size,增大 accumulation_steps,使用 --dtype bfloat16,或减小 max_seq_len。

ref_model 输出异常

原因:忘记 ref_model.eval() 或 requires_grad_(False)。

解决:确认参考模型处于 eval 模式且不计算梯度。否则 BatchNorm/Dropout 行为异常。

loss 始终约 0.693

原因:-log(sigmoid(0)) = log(2) = 0.693。logits 全为 0,策略模型和参考模型完全相同。

解决:检查模型加载是否正确,确认 from_weight 路径指向有效的 SFT 权重。如果模型没有被训练过,策略和参考完全一致。

错误诊断三

loss 出现 NaN

原因:log_probs 中出现极小值,导致数值溢出。或者 chosen/rejected 的 token 序列完全相同。

解决:检查数据中 chosen 和 rejected 是否完全相同。确保使用混合精度训练。

显存占用异常大

原因:没有及时释放中间变量。

解决:源码末尾有 del 语句清理不需要的张量。确保没有额外保留对中间结果的引用。

# train_epoch 末尾的清理代码
del x_chosen, x_rejected, y_chosen, y_rejected, mask_chosen, mask_rejected
del ref_outputs, ref_logits, ref_log_probs, outputs, logits, policy_log_probs

Part 8:练习

动手实践是最好的学习方式。通过练习巩固对 DPO 的理解。

学了这么多,该动手了。本节的练习从构建数据到调参实验到效果验证,帮你把 DPO 的每个环节都过一遍。同时准备了思考题和阶段总结,检验你是否真正理解了 SFT 与 DPO 的本质差异。

  • 练习 1:构建数据 —— 手动创建 5 条覆盖不同领域的 DPO 偏好数据,体会数据质量的要求
  • 练习 2:对比 beta —— 用 0.05、0.15、0.5 三组 beta 值分别训练,观察损失曲线和生成效果的差异
  • 练习 3:对比生成 —— 训练前后用同一 prompt 生成回答,验证模型是否偏好 chosen 风格
  • 思考题 —— 关于数据差异极小、学习率极低、loss_mask 必要性、同权重初始化的深层问题

动手练习

练习:数据

任务:创建 5 条 DPO 偏好数据,覆盖数学、常识、编程等不同领域。

要求:确保 chosen 和 rejected 的差异体现在回答质量而非长度。保存为 dataset/dpo.jsonl。

# 起始框架:创建 dpo.jsonl
import json
data = [
    {
        "chosen": [
            {"role": "user", "content": "请在此写问题"},
            {"role": "assistant", "content": "请在此写好回答"}
        ],
        "rejected": [
            {"role": "user", "content": "请在此写同样的问题"},
            {"role": "assistant", "content": "请在此写差回答"}
        ]
    }
    # 再写 4 条...
]
with open('dataset/dpo.jsonl', 'w', encoding='utf-8') as f:
    for item in data:
        f.write(json.dumps(item, ensure_ascii=False) + '\n')

提示

  1. 好回答应该准确、有信息量、有条理
  2. 差回答可以是敷衍、错误、或过于简短的
  3. 每条数据的用户提问在 chosen 和 rejected 中必须完全一致

练习:观察

练习:beta

任务:用不同 beta 值(0.05, 0.15, 0.5)分别训练,对比损失曲线。

# beta = 0.05(激进)
python trainer/train_dpo.py --beta 0.05 --save_weight dpo_005

# beta = 0.15(温和,默认)
python trainer/train_dpo.py --beta 0.15 --save_weight dpo_015

# beta = 0.5(保守)
python trainer/train_dpo.py --beta 0.5 --save_weight dpo_050

思考

  1. 哪个 beta 让 loss 下降最快?
  2. 哪个 beta 的模型生成效果最好?
  3. beta 过大时,loss 曲线有什么特征?

练习:验证效果

练习:生成

任务:训练前后分别用同一个 prompt 生成回答,对比模型是否更倾向于输出 chosen 风格的回答。

# 使用 1-inference.py 进行对比生成
# 训练前:加载 full_sft 权重
# 训练后:加载 dpo 权重

# 对比维度:
# 1. 回答的详细程度
# 2. 回答的准确性
# 3. 是否回避了 rejected 风格的敷衍回答

提示

  1. 使用训练数据中的问题作为测试 prompt
  2. 同时测试训练数据中未出现过的新问题
  3. 注意:DPO 主要改变偏好方向,不应改变模型的推理能力

思考题

问题 1

如果 chosen 和 rejected 的差异极小(比如只差一个标点),DPO 还有效吗?为什么?

问题 2

为什么 DPO 的学习率比 SFT 小约 1000 倍?如果用 SFT 的学习率训练 DPO,会发生什么?

问题 3

如果不使用 loss_mask(即对整个序列计算损失),DPO 的训练会出什么问题?

问题 4

为什么策略模型和参考模型必须从同一个 SFT 权重初始化?如果从不同权重初始化会怎样?

SFT vs DPO

图:SFT 与 DPO 的核心差异对比

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左右两栏。左侧蓝色框"SFT"内含:数据为"指令-回答对" → 模型1个 → 损失为"CrossEntropy 学会模仿";右侧绿色框"DPO"内含:数据为"chosen vs rejected" → 模型2个 → 损失为"-log_sigmoid 学会偏好" - 各元素关系:SFT到DPO有一个过渡箭头,表示DPO在SFT基础上进行 - 关键标注:SFT框标注"学会回答",DPO框标注"学会更好回答" - 配色建议:浅灰背景,SFT蓝色系,DPO绿色系 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

维度SFTDPO
模型数量12(策略 + 参考)
数据格式{conversations: [...]}{chosen: [...], rejected: [...]}
损失函数CrossEntropyLoss-log_sigmoid(beta * delta)
学习率1e-4 ~ 1e-54e-8(约小 1000 倍)
训练目标学会正确回答学会给出更好的回答
基座要求预训练模型SFT 模型

学完本阶段后

问题 1

SFT 的局限性是什么?DPO 如何解决?

问题 2

dpo_loss 中 pi_logratios 和 ref_logratios 分别计算什么?它们的差值意味着什么?

问题 3

为什么需要参考模型?不用会怎样?

问题 4

batch 拼接技巧的设计意图是什么?

问题 5

DPO 学习率为什么比 SFT 小约 1000 倍?

延伸阅读

DPO 原论文

Rafailov et al., "Direct Preference Optimization: Your Language Model is Secretly a Reward Model" (NeurIPS 2023)

DPO 的理论基础,推导了从 RLHF 到 DPO 的闭式解。适合想深入理解数学原理的读者。

Bradley-Terry 模型

经典偏好建模框架,DPO 的概率基础。理解这个模型有助于理解 DPO 损失函数的设计。

阶段 7 完成

DPO 是最简洁高效的对齐方法——无需奖励模型,直接优化偏好。

下一章:阶段 8 · 强化学习 RL——GRPO 与 PPO,让模型通过试错自我提升

1 / X