· 参数高效微调
LLM 微调理论:SFT、DPO、LoRA、QLoRA 的统一视角
按统一的四段式拆每个技术:一句话定义它是什么、核心机制、逐步实现路径、最容易错的地方——把「原理懂了但代码跑不通」那段落差补齐。
这份笔记按统一的四段式组织每个技术:一句话定义它是什么、拆开它的核心机制、给出逐步实现路径、标出实现时最容易错的地方。
之所以要这么写,是因为微调这件事的困难不在"想通原理",也不在"写出代码",而在两者之间的落差——原理懂了但不知道代码里那行 shift 在干什么,代码跑了但不知道 loss 不降该往哪个方向调。把中间这段补齐,才算真正掌握。
01先定位:微调在技术栈里的位置
有一个判断值得先建立起来:能用 prompt 或 RAG 解决的,就不要微调。这个建议听起来保守,实际是成本结构决定的。
Prompt engineering 改的是输入侧——把更好的指令塞进上下文,模型权重一克不动。RAG 改的是可见信息——提问前把资料检索出来拼进 prompt,模型参数同样不变。这两者的共同点是可撤销、可审计、可即时回滚:效果不好删掉那段 prompt 就恢复,出了问题能逐字排查是哪句话导致的。
微调改的是模型参数本身,也就是它的条件分布。一次训练完成后,它改变的不是"这次回答什么",而是"以后遇到这类输入时倾向于怎么回答"。这个过程不可撤销,而且排查困难——模型答错了,你没法指出是哪个训练样本导致的。
RAG 改的是条件(给模型看什么),微调改的是条件分布(模型怎么看待看到的东西)。前者是外部设备,随时插拔;后者是重新长出来的肌肉记忆。
所以顺序永远是:prompt → RAG → SFT → 偏好对齐。每往上一级,成本上一个数量级,可逆性下一级。
但微调有它不可替代的三类场景,而 RAG 绝对做不到:
输出格式的硬约束。要求模型每次都以严格 JSON schema 输出,字段名、嵌套层级、枚举值都不能错。这件事可以在 prompt 里反复强调,但模型会以一定概率违反;SFT 过几千条样本后,违反概率会降一个数量级。原因很直白——你在把"格式"变成分布里概率极高的模式,而不是依赖模型推理时"记得遵守"。
领域术语与业务逻辑内化。RAG 能让模型看到术语表,但不能让它会用。比如医疗场景"该指标轻度升高但未达干预阈值"这种表述习惯,写在 prompt 里模型会生硬引用,训进权重后才会自然输出。
推理风格与语气。客服要求"共情 → 确认问题 → 给方案 → 确认满意"的固定节奏,很难用 prompt 稳定约束,但几百条优质对话做 SFT 就能固化。
反过来,什么时候不该微调?一个简单判据:如果需求可以表述成"让模型知道某些事实",用 RAG;表述成"让模型以某种方式做事",才考虑微调。另外一条硬约束——如果知识会频繁更新(价格、库存、政策条款),微调是错误选择,因为每次更新都要重训,而 RAG 只需更新索引。
02技术地图:先看清每个技术是什么
微调领域的技术名字很密,但它们其实分布在两个正交的维度上。搞混维度是理解混乱的主要来源——比如"DPO 和 LoRA 有什么区别"这个问题本身就不成立,它们不在同一个轴上。
维度 A 回答"学什么",由训练目标和数据形态决定。维度 B 回答"改多少参数",由参数更新方式决定。两者可以任意组合:SFT+LoRA、DPO+QLoRA、PPO+LoRA 全都成立。
| 技术 | 一句话是什么 | 数据形态 | 位置 |
|---|---|---|---|
| SFT | 用"输入 + 标准答案"做有监督学习,提高标准答案的条件概率 | (x, y) | 维度 A |
| DPO | 用"好答案 vs 差答案"的偏好对直接训练,无需奖励模型 | (x, y_w, y_l) | 维度 A |
| RLHF / PPO | 先训奖励模型,再用强化学习让模型自己采样、打分、改进 | x + 偏好标注 | 维度 A |
| GRPO | PPO 的去 Critic 版,用同题多答的组内相对分做优势 | x + 可验证奖励 | 维度 A |
| LoRA | 冻结原权重,用低秩增量 W₀ + BA 表达微调改变 | — | 维度 B |
| QLoRA | LoRA + 把冻结的底座压成 4-bit,进一步降显存 | — | 维度 B |
| ZeRO | 把数据并行里每张卡的冗余状态切开分散存 | — | 分布式 |
先定维度 A:我的数据长什么样?如果是"问题 + 唯一正确答案",用 SFT;如果是"同一问题的两条回答,人能分出高下",用 DPO;如果只有问题和一个人能打分的标准(答案对不对、测试过不过),用 GRPO。
再定维度 B:我的显卡有多少显存?24 GB → QLoRA;48–80 GB → LoRA;多卡 8×80G → 可以全参。这一步跟任务无关,纯资源决策。
下面按上面这个顺序逐个展开:先讲维度 A 的 SFT 和 DPO(它们是理解成本最高、也最容易做错的两个),再讲维度 B 的 LoRA 和 QLoRA,最后讲显存工程和具体踩坑。
03SFT:是什么,怎么一步步实现
SFT(Supervised Fine-Tuning,监督微调)是在"输入 + 标准答案"的成对数据上做有监督学习,通过最大化标准答案在给定输入下的条件概率,让模型学会以特定格式、风格、逻辑回答某类问题。
它不教模型新知识,而是把模型已有的能力"调"到期望的输出形态上。这是它与预训练最本质的区别——预训练学"语言是什么样",SFT 学"该怎么回答"。
3.1 核心机制:为什么必须做掩码
先看预训练在做什么。它的目标是建模整个序列的联合概率:
SFT 要的是另一个东西——给定 prompt 之后,回答的条件分布,只在回答部分求和:
两个式子只差求和范围,但含义差很多。第二个显式地以 x 为条件,把 prompt 当作已知前提而不是学习目标。这正对应推理时的真实场景:用户输入是给定的,模型只需生成后续。
不做掩码的后果有两层。第一层是容量浪费:一条 2500 token 的样本里用户提问可能占 1500,60% 的梯度预算花在学"用户会怎么说话"——而这项能力推理时完全用不到。第二层更隐蔽:模型是共享参数的,学"提问分布"和"回答分布"会竞争同一批参数;当提问部分模式更规律、loss 更容易降时,模型会把容量优先分配给它,结果是训出一个"用户句式生成器"。
"不应该对用户输入 prompt 部分的 token 计算损失,只能对 assistant 回答部分计算损失。这是 SFT 和预训练过程中一个最本质的区别。"
更准确的说法是:answer mask 不是优化技巧,而是把目标函数从联合分布改成条件分布。不做掩码的 SFT,等价于拿指令数据做了一次低质量预训练。
3.2 七步实现
下面是从零手写 SFT 的完整路径。每一步都说明"做什么、为什么、容易错在哪"。
- 第一步 · 数据整理成 messages 结构
统一成 OpenAI 风格的对话列表。多轮对话就是一个列表里交替放 user 和 assistant:
messages = [ {"role": "user", "content": "什么是习惯?"}, {"role": "assistant", "content": "习惯是……"}, {"role": "user", "content": "怎么培养?"}, {"role": "assistant", "content": "21 天法……"}, ]易错点:数据里如果出现连续两条 assistant(比如工具调用场景),后面按奇偶位置分轮的逻辑会全部错位。必须在预处理阶段检查角色是否严格交替。
- 第二步 · 应用 chat template 转成文本
模型不认识"角色",只认 token。所以要用 tokenizer 自带的模板序列化:
input_ids = tokenizer.apply_chat_template( messages, tokenize=True, add_generation_prompt=False, # 训练时不加"该你说了"的提示 truncation=True, max_length=2500)以 Qwen 的 ChatML 为例,结果长这样:
<|im_start|>user 什么是习惯?<|im_end|> <|im_start|>assistant 习惯是指在一定时间内重复执行的行为。<|im_end|>
关键细节:
add_generation_prompt在训练和推理时必须不同——训练时数据里已有完整回答,不需要提示;推理时要设True,让模板在末尾补上<|im_start|>assistant\n,模型才知道该开始生成。 - 第三步 · 构造 answer mask
目标:生成一个与
input_ids等长的 0/1 向量,回答位置为 1,其余为 0。有三条实现路线,推荐度从低到高:路线 C(最不稳,但最能说明原理)——数特殊 token 位置后硬编码偏移:
eos_id = tokenizer.encode("<|im_end|>")[0] eos_positions = torch.where(ids == eos_id)[0].tolist() user_ends = eos_positions[::2] # 奇数位 assistant_ends = eos_positions[1::2] # 偶数位 for ue, ae in zip(user_ends, assistant_ends): mask[ue + 5 : ae + 1] = 1 # +5 跳过 im_end/换行/im_start/assistant/换行
这个
+5依赖三个前提同时成立:<|im_end|>是单 token、assistant是单 token、换行是单 token。任何一个不成立就静默错位,而且不会报错。见第 08 节坑 2。路线 A(推荐)——用 tokenizer 内置能力:
enc = tokenizer.apply_chat_template( messages, tokenize=True, return_assistant_tokens_mask=True) mask = enc["assistant_masks"] # 直接就是要的东西它内部按模板结构精确判断,换模型、换模板都不用改代码。
- 第四步 · 错位对齐(最容易错的一步)
因果语言模型在位置 t 读入
input[t]、预测input[t+1]。所以训练时要把序列拆成"输入"和"标签"两部分,标签整体右移一位:ids = batch_input_tensor inputs = ids[:, :-1] # 位置 0..T-2,作为模型输入 labels = ids[:, 1:] # 位置 1..T-1,作为预测目标
掩码也必须跟着 shift,否则就错位了。正确写法是先在完整序列上算掩码,再切:
full_mask = build_mask(ids) # 长度 T loss_mask = full_mask[:, 1:] # 长度 T-1,与 labels 对齐
如果写成
build_mask(inputs)(在已经截断的 inputs 上算),掩码就和 labels 差了一位:每个回答段的第一个 token 被漏算,段末还会多算一个属于下一轮 prompt 的 token。loss 曲线依然平滑,但模型对"回答怎么起头"学得偏弱。 - 第五步 · padding 与 padding mask
一个 batch 内长度不一,要补齐。训练用右侧 padding(配合因果掩码不会污染真实 token),并且要单独生成一个 mask 标出哪些是 pad:
pad_id = tokenizer.pad_token_id padded = F.pad(torch.tensor(ids), (0, max_len - len(ids)), value=pad_id) padding_mask = (labels != pad_id).long() # pad 位置置 0 final_mask = loss_mask & padding_mask # 两个 mask 取交集
注意:Qwen 系列 tokenizer 默认
padding_side="left"(推理时左侧补齐方便对齐末尾)。训练脚本开头必须显式改成"right",否则行为和预期不符。 - 第六步 · 前向 + 计算损失
三个动作:把 logits 转成对数概率、取出目标 token 对应的那一个值、乘掩码后求平均。
logits = model(inputs).logits # [B, T-1, V] logp = torch.log_softmax(logits, dim=-1) # 数值稳定,避免下溢 picked = torch.gather(logp, dim=-1, index=labels.unsqueeze(-1)).squeeze(-1) token_loss = -picked * final_mask # 掩码位置归零 loss = token_loss.sum() / final_mask.sum() # 有效 token 平均
为什么第一步要用
log_softmax而不是softmax再log?因为概率极小时会下溢成 0,取 log 得到负无穷。log_softmax内部做了 log-sum-exp 技巧,全程在对数域计算,数值稳定。为什么用
gather?因为 softmax 之后我们有的是"整个词表上的分布",但我们只关心一件事——模型给正确答案那个 token 分配了多大对数概率。gather 就是按labels里记的 token id,把对应位置的那一个数值取出来。 - 第七步 · 训练循环
标准五件套:取 batch → 前向 → 算 loss → 反向 → 更新。加一个保护:如果整批都没有有效 answer token(全被截断成只有 prompt),跳过。
for step in range(total_steps): batch = data[step*bs : (step+1)*bs] # ... 组装 inputs / labels / final_mask ... if final_mask.sum(dim=-1).min() == 0: skipped += 1; continue # 整批无有效 token,跳过 loss = compute_loss(model(inputs).logits, labels, final_mask) loss.backward() optimizer.step() optimizer.zero_grad()
易错点:
total_steps在 skip 之前算好了,而余弦退火按 step 索引推进。若大量 skip,实际更新次数少于计划,学习率会提前衰减到很低。用独立的effective_step计数(只在真正更新时递增)更稳妥。
3.3 为什么 SFT 一定会遗忘
常见解释是"轮次过多、学习率过高",然后给三个解法:控制轮次配早停、掺通用数据、准备双测评集。这些手段有效,但只是治标——遗忘是目标函数的结构性缺陷。
看 3.1 的损失函数:ℒ = −Σ log P(y_t | x, y_<t)。每一项都在说"把正确回答的概率往上推",没有任何一项在说"别把别的东西压下去"。梯度方向完全由目标任务数据决定。
后果是:随着训练推进,πθ 与原始分布 πref 之间的 KL 距离单调增大,而且没有上界。当漂移量足够大,原本概率较低的通用能力区域就被覆盖了。所以遗忘的机制不是"新知识挤掉旧知识",而是优化过程根本没保护旧知识。
RLHF 和 DPO 的目标函数里都有 −β · KL(πθ ‖ πref) 这一项,作用就是给漂移装刹车——显式惩罚"离原始模型太远"。
理解这点,第 04 节 DPO 的推导才有动机:那个 β 和那个参考模型不是拍脑袋加的,是补 SFT 缺的那一项。
顺带一个实践含义:LoRA 天然缓解遗忘。因为 ΔW = AB 被约束在低秩子空间,能表达的变化量本身就受限,等于一个隐式容量刹车。所以小数据集上 LoRA 往往比全参更不容易训崩——不是因为它更聪明,而是因为它"改不动那么多"。
04DPO:是什么,怎么一步步实现
DPO(Direct Preference Optimization,直接偏好优化)是直接用"同一问题下的好坏答案对"训练模型,让模型提高好答案、压低差答案的相对概率。它不需要单独训练奖励模型,也不需要强化学习循环——整个训练就是一个监督学习式的损失函数。
它的巧妙之处在于:把一个强化学习问题(带 KL 约束的奖励最大化)数学等价地转换成了一个分类问题(判断这个偏好对有没有被满足)。
4.1 推导:这个损失是怎么来的
DPO 的损失不是设计出来的,是推出来的。四步链条:写出 RLHF 目标 → 求解析最优解 → 反解出奖励 → 代回 Bradley-Terry 模型,配分函数恰好抵消。
第一步:写出带 KL 约束的 RLHF 目标
r(x,y) 是奖励函数,在 RLHF 里就是训好的奖励模型。y ∼ π(·|x) 表示回答由当前策略实时生成——注意这是在线采样,不是从固定数据集取。第二项 KL 衡量当前策略与参考策略的差异,πref 通常是 SFT 后的 checkpoint,全程冻结。
这个目标的形式很直白:既要拿高分,又别跑太远。"跑太远"的代价在 3.3 讲过——会遗忘。β 就是在"探索新行为"和"保持原能力"之间调平衡的旋钮。
第二步:这个目标有解析解
把 KL 展开成期望形式、对每个 y 分别求导置零,就得到这个闭式解。它的读法是:最优策略 = 参考策略 × 奖励的指数加权。奖励高的回答概率被放大,奖励低的被压低;β 越小放大越剧烈(温度越低),β→∞ 时退化成参考策略本身。
第三步:把奖励反解出来
意义很大:它说明奖励函数不需要单独学,可以从策略里读出来。只要你有 π* 和 πref,就能反算隐式奖励。这就是"DPO 里语言模型本身就是奖励模型"的出处——奖励藏在概率比里。
第四步:代回 Bradley-Terry,Z 恰好抵消
奖励模型的标准假设是 Bradley-Terry:人偏好 yw(chosen)而非 yl(rejected)的概率为 σ(rw − rl)。代入第三步:
Z 为什么消失?因为它只依赖 x,不依赖 y;而差值是在同一个 x 下对两条回答做减法,同一个 Z(x) 自然相消。
这也顺便解释了参考模型为什么不可省:它是让 Z 能消掉的必要条件。没有 πref,这个式子不对应任何有效奖励函数。
用 πθ 参数化 π*,套上负对数似然:
按括号分组读最清楚:当前模型对 chosen 的偏好,要比参考模型强多少。为正且大 → sigmoid 接近 1,loss 接近 0,没什么可学;为负 → 模型还更偏 rejected,梯度强力修正。
4.2 八步实现
- 第一步 · 准备三元组数据
每条样本是
(prompt, chosen, rejected)。三者都是完整文本,chosen和rejected是同一 prompt 下质量不同的两个回答。{"prompt": "解释一下什么是过拟合", "chosen": "过拟合指模型在训练集上表现好、在新数据上表现差……", "rejected": "过拟合就是模型学得太好了。"}选数据的要点:偏好差异要明显且可解释。如果 chosen 和 rejected 质量相近,模型学不到有效信号;如果两者差异只是长度(一个长一个短),模型可能学成"越长越好"。
- 第二步 · 拼成两条完整序列
把 prompt 和回答分别用 chat template 拼接,得到 chosen 序列和 rejected 序列。注意:两者共享同一个 prompt 前缀,只有回答部分不同。
chosen_ids = template(prompt + chosen) rejected_ids = template(prompt + rejected)
因为长度不同(chosen 通常更长),要分别 padding,得到两套
inputs / labels / mask。 - 第三步 · 构造回答部分的掩码
和 SFT 一样——只对回答部分算 log 概率,prompt 部分不计入。这里尤其重要,因为如果不做掩码,chosen 和 rejected 的 prompt 部分完全相同,会相互抵消,但因为长度不同又抵消不干净,反而引入噪声。
chosen_mask = assistant_mask(chosen_ids) rejected_mask = assistant_mask(rejected_ids)
- 第四步 · 四个前向
DPO 一次迭代要跑四个前向:策略模型过 chosen、策略模型过 rejected、参考模型过 chosen、参考模型过 rejected。
pi_chosen = policy(chosen_inputs).logits pi_rejected = policy(rejected_inputs).logits with torch.no_grad(): # ← 必须有,否则显存翻倍 ref_chosen = ref_model(chosen_inputs).logits ref_rejected = ref_model(rejected_inputs).logits为什么参考模型必须包 no_gradref_model.eval()只关掉 dropout,不阻止 PyTorch 构建计算图。不加no_grad,参考模型的前向会保留全部中间激活等反向传播(虽然永远等不到),DPO 的激活显存接近翻倍。 - 第五步 · 计算每条序列的 log 概率
把 logits 转成 log 概率,gather 出目标 token 的值,乘掩码后聚合。这里有个关键的归一化选择:
def seq_logprob(logits, labels, mask, average=False): logp = torch.log_softmax(logits, dim=-1) picked = torch.gather(logp, -1, labels.unsqueeze(-1)).squeeze(-1) picked = picked * mask if average: # per-token 平均:消除长度影响 return picked.sum(-1) / mask.sum(-1) return picked.sum(-1) # 序列总和:原论文口径
该选哪个?原论文用
sum(不除长度),HuggingFace TRL 默认average_log_prob=False也是 sum。用average会让长短回答的 log 概率被拉到同一尺度,等于抹掉了"长度"这个偏好维度——如果你的偏好数据里长度是个重要因素(比如"啰嗦但安全" vs "简洁但可能遗漏"),用 average 会学不到这个信号。 - 第六步 · 组合成 DPO 损失
把四个 log 概率按公式组装:
delta = (pi_chosen_lp - pi_rejected_lp) - (ref_chosen_lp - ref_rejected_lp) loss = -F.logsigmoid(beta * delta).mean()
用
logsigmoid而不是log(sigmoid(x)),是因为前者在 x 很负时数值稳定(后者会先算出接近 0 的 sigmoid 再取 log,得到 -inf)。β 的典型取值:0.1 是论文默认值,实践中 0.01–0.5 都有人用。判断标准——β 太大时 loss 降得很慢、模型几乎不动(策略被钉在参考模型附近);β 太小时训练前几十步 loss 断崖式下降、然后模型输出变得重复单调(过度集中到少数高分回答)。
- 第七步 · 只更新策略模型
优化器只传
policy.parameters(),参考模型的参数不要放进优化器,也不要有requires_grad=True。optimizer = torch.optim.AdamW(policy.parameters(), lr=5e-7) # DPO 学习率通常比 SFT 小一个量级
学习率为什么要小?因为 DPO 的梯度尺度被 β 放缩过,而且策略已经接近最优,需要小步微调。常见的失败模式是学习率用了 SFT 的 2e-5,训练几十步后模型输出完全崩坏。
- 第八步 · 优化:预计算参考 log 概率
参考模型不更新,所以它对每个样本的输出是固定不变的。可以在训练开始前跑一遍全量数据,把
ref_chosen_lp和ref_rejected_lp存下来(每个样本两个 float)。收益:训练时只需跑两个前向而不是四个,显存减半、速度约翻倍。代价:多存一份缓存(几万条样本也就几百 KB),以及如果数据集被动态采样就要重新算。
4.3 为什么 DPO 在推理任务上打不过 GRPO
这个结论重要,它解释了当前一个普遍现象:在数学、代码这类可验证奖励的任务上,DPO 明显不如 GRPO / PPO 系。
差别在数据流。DPO 从数据集取 (x, y_w, y_l),算两个 log 概率,反向传播——模型只做前向,从不生成。GRPO 让当前策略对同一个 x 生成 G 条回答(G 通常 8–64),用奖励函数打分,组内相对分算优势——每一轮都在生成新数据。
后果是:DPO 的学习信号被冻结在数据集的分布里。如果 rejected 都是"格式错误、态度差"这类低级问题,DPO 学会的就是"别犯低级错误",它永远学不到"推理第二步用错公式"这种高阶错误——因为数据里没有。
GRPO 则是每一轮用当前水平生成,错误模式会随能力提升而变化。当模型已经不会犯低级错误时,采样出来的自然就是高阶错误,学习信号自动升级。这个机制叫在线探索,是 DPO 结构上缺失的。
有大量人类偏好标注(客服、内容创作),任务开放式、无客观正确答案 → DPO。便宜、稳定、不需要奖励模型。
有可验证的奖励函数(数学答案对不对、代码过不过测试、SQL 结果是否一致)→ GRPO / PPO。在线探索能持续挖掘更高阶的错误。
4.4 梯度的形状:自动难例加权
对损失求梯度会得到一个很有用的形式:
重点看系数 σ(−βΔ),它是随训练自动变化的样本权重:Δ 大(已经学会了)时趋近 0,梯度几乎消失;Δ 为负(搞反了)时趋近 1,梯度最强。
这就形成了自动难例挖掘——不用人工挑 hard negative,损失函数自己会给难例加权。
顺带解释一个训练现象:DPO 的 loss 通常快速下降后进入平台,且平台期 loss 不接近 0。原因就在这——大部分样本的 Δ 已经足够大、权重趋近 0,只剩少数难样本在贡献梯度。看到 loss 卡在 0.5 左右不动时别急着调学习率,可能只是模型已经学会了大部分样本。
05显存:账怎么算,瓶颈怎么定位
显存问题是微调最常撞的墙,而多数"显存不够"的困惑来自对构成的不完整认识——训练时的显存由五部分组成,其中第四部分经常被漏算。
模型参数。权重本身。7B 模型用 BF16 存,每参数 2 字节,共 14 GB。必须常驻,前向反向都要用。
梯度。反向传播时每个可训练参数算出一个梯度,形状与参数相同,精度通常一致(BF16),也是 14 GB。只有 requires_grad=True 的参数才有梯度——这是 LoRA 省显存的第一个来源。
优化器状态。AdamW 为每个参数维护一阶矩(梯度移动平均)和二阶矩(梯度平方移动平均)。为保证数值稳定,两者必须 FP32——每参数 8 字节。7B 要 56 GB,是最大的一块。这是 LoRA 省显存的第二个、也是最大的来源。
FP32 主权重。最容易被漏掉的一项。混合精度的机制是:前向反向用 BF16 算得快,但参数更新必须在 FP32 下做——否则每次更新都被 BF16 的低精度截断,累积起来参数就"走不动"了。所以框架额外保存一份 FP32 副本,7B 就是 28 GB。
激活值。前向时每层输出都要留着给反向用。它与 batch size、序列长度、隐藏维度、层数成正比,是唯一随输入变化的一项——所以显存不够时先降 batch 和 max_length 就是降它。
| 组成 | 公式 | 7B 实算 | 为什么是这个量 |
|---|---|---|---|
| 模型参数 | N × 2 | 14 GB | BF16,必须常驻 |
| 梯度 | N × 2 | 14 GB | 与参数同 shape 同精度 |
| 优化器状态 | 2 × N × 4 | 56 GB | AdamW 的 m 和 v,必须 FP32 |
| FP32 主权重 常被漏掉 | N × 4 | 28 GB | 参数更新必须在 FP32 下做 |
| 静态合计 | — | 112 GB | 还没算激活值 |
| 激活值 | b·s·h·l·2·k | 数十 GB | k 为经验补偿因子 10–30 |
112 GB 这个数字解释了两件事。第一,7B 全参微调必须多卡——单张 80G A100 装不下静态状态,这不是"优化一下更好",是硬约束。第二,LoRA 能把门槛降到消费级——冻结权重仍要 14 GB,但 56 GB 优化器状态和 28 GB 主权重几乎全部消失,整卡回落到 16–20 GB。这不是省一点,是省一个数量级。
5.1 怎么定位瓶颈:三步判断
显存不够时不要盲目试手段,先花两分钟做判断:
- 先算静态账
按上表把「参数 + 梯度 + 优化器状态 + 主权重」算出来。如果这个数字已经接近或超过单卡容量,那问题是静态的,降 batch、开检查点都没用——必须换 PEFT 或上多卡。
例:7B 全参静态 112 GB,48 GB 卡无论怎么调 batch 都装不下。
- 静态够,但一跑就 OOM
说明瓶颈在激活值。这时按性价比依次试:梯度检查点(激活降到 30–40%,速度慢 20–30%)→ 降 max_length(效果最直接,激活与序列长度近线性)→ 降 batch → 梯度累计维持等效 batch。
- 显存够但速度慢
这不是显存问题。检查:混合精度有没有开(BF16 比 FP32 快一倍以上)、FlashAttention 有没有启用、数据加载有没有卡住(DataLoader 的
num_workers是否为 0)、是否在跑无谓的 eval。
5.2 四个单卡手段,各自的作用对象
梯度检查点降激活值。原理是:前向时不保存所有中间结果,只存少数检查点;反向需要某段中间值时,从最近的检查点重新前向算一遍。以时间换空间,激活降到 30–40%,速度慢 20–30%。激活瓶颈时第一个该开的就是它。
混合精度(BF16)把参数、梯度、激活的存储和计算宽度从 32 位降到 16 位。注意不是"全用 16 位"——参数更新仍在 FP32 下做,主权重副本保留。BF16 相比 FP16 的优势是数值范围与 FP32 相同(8 位指数位),几乎不溢出,训练更稳;代价是需要 Ampere 及以上架构。
梯度累计——这里有个常见误解:它不降低峰值显存。做法是跑 k 个 micro-batch,每个只累积梯度不更新,等 k 个跑完一起更新。你获得了等效 k 倍 batch 的梯度质量,但每个 micro-batch 的峰值显存与 k=1 相同。所以:OOM 时开它没用,该开检查点;想让训练更稳但显存有余量时,才该开它。
CPU 卸载把优化器状态或激活临时挪到 CPU,用到再搬回。确实省显存,但走 PCIe 的开销很大,速度通常掉一半以上。只在"显存差一点点、又不想换卡"时用。
5.3 多卡:四种并行各自切什么
数据并行(DP)切数据。每张卡放一份完整模型副本,各处理不同数据子集,算完梯度 AllReduce 求平均保证一致。解决吞吐量——训练更快,但单卡显存一点没降。所以 DP 不能用来跑"单卡装不下"的模型。
流水线并行(PP)切层。32 层切成 4 段放 4 张卡,前向时第 1 张算完把激活传给第 2 张,接力。解决单卡装不下。代价是"气泡"——后面的卡等前面的算完时是空闲的。缓解办法是把 batch 再切小,让不同卡在不同 micro-batch 上同时工作。
张量并行(TP)切单层内的矩阵。4096×4096 切成 4 份 4096×1024,每卡一份,前向时各算部分结果再聚合。解决单层都装不下。代价是通信极频繁——每一层都要通信,必须在同一台机内(NVLink),跨机会崩。
专家并行(EP)只对 MoE 有意义,把不同专家(每个是独立 FFN)放不同卡。专家间无共享参数,天然适合切分。
实际训大模型时三种组合使用:TP 在机内切单层,PP 跨机切层,DP 复制副本,写成 TP×PP×DP。
ZeRO 的定位不同——它不是第五种并行,而是对数据并行的去冗余改造。标准 DP 下 8 张卡各存一份完整状态,冗余度等于卡数。ZeRO 把参数/梯度/优化器状态依次分片(Stage 1/2/3),用时按需 all-gather。Stage 越高省得越多、通信越贵,这是它提供可调权衡点的意义。实践上 ZeRO-1 最常用——只分片优化器状态(那块最大),通信开销增加最少。
06LoRA:是什么,怎么从零实现
LoRA(Low-Rank Adaptation,低秩适配)是一种参数高效微调方法。它冻结原模型全部权重,只在特定线性层旁挂两个小矩阵 A 和 B,用它们的乘积 BA 来表示微调带来的权重变化。
关键性质:训练时只需优化极少参数,推理时可以把 BA 加回原权重,延迟零增加。
6.1 核心机制:秩是什么,为什么能用低秩近似
先建立秩的直觉。矩阵的秩是它线性无关的行(或列)的最大数量,通俗说就是"这个矩阵里真正独立的信息有几个维度"。
例子:矩阵 [[1,2],[2,4]] 的第二列是第一列的 2 倍,两列线性相关,所以秩是 1 而不是 2。虽然占 2×2 的存储,但实际信息量只有一个维度——知道第一列,第二列就确定了。
一个 d×k 的矩阵秩最大为 min(d,k)。若秩 r 远小于 min(d,k),就叫低秩矩阵,它可以精确分解成两个小矩阵乘积:W = A·B,A 是 d×r,B 是 r×k。存储收益直观:原本存 d×k 个数,现在只存 d×r + r×k 个。
LoRA 的假设是:全参微调的增量 ΔW 具备低秩结构,所以可以 W = W₀ + AB 近似。以 d=k=4096、r=8 为例,全参需要 16,777,216 个参数,LoRA 只需 4096×8 + 8×4096 = 65,536,约为 0.39%。
LoRA 原始论文的表述是"作者观察到 ΔW 往往具有低秩结构"——"观察到"这个措辞是准确的,但常被误读成"ΔW 就是低秩的"。
真相是:这是经验假设加事后验证,不是定理。理论动机来自更早的"内在维度"研究:预训练模型做下游适配时,存在一个远低于参数量的低维子空间就够用。但要注意,"存在一个够用的低维子空间"和"梯度更新本身是低秩的"并不是同一件事——前者说解空间的维度,后者说优化轨迹的形状。LoRA 是这两者之间的工程赌注,而且赌赢了。
实践含义:r 不是越大越好。r 增大时标准 LoRA 的 α/r 缩放会让每单位增量的有效梯度变小,需要相应调大 α 补偿(rsLoRA 改用 α/√r 来修正)。另外任务与预训练分布越接近,需要的 r 越小——只想换输出风格,r=8 足够;要学新语言或新推理范式,才需要 r=64 以上。
6.2 七步从零实现
- 第一步 · 定义 LoRA 线性层
本质是把原 Linear 包一层:保留原权重(冻结),额外加 A 和 B。
class LoRALinear(nn.Module): def __init__(self, base: nn.Linear, r=8, alpha=16): super().__init__() self.base = base for p in self.base.parameters(): p.requires_grad = False # 冻结原权重 d, k = base.out_features, base.in_features self.A = nn.Parameter(torch.empty(d, r)) self.B = nn.Parameter(torch.zeros(r, k)) self.scale = alpha / r
注意
B初始化为全零,A用随机初始化。这样初始时AB = 0,微调起点与原始模型完全一致——这是 LoRA 能稳定训练的关键设计。如果两者都用随机初始化,训练初期会有随机扰动,容易破坏原有能力。 - 第二步 · 实现前向
输出 = 原层输出 + 低秩增量输出,增量要乘缩放系数:
def forward(self, x): return self.base(x) + self.scale * (x @ self.A.T @ self.B.T)
为什么要有
scale = alpha / r?因为改 r 时 AB 的数值尺度会变,不缩放的话改 r 等于偷偷改了有效学习率,实验没法对比。除以 r 让不同 r 之间可比。经验上 α = r 时缩放为 1(最常用),想更激进可设 α = 2r。 - 第三步 · 注入到目标模块
遍历模型,把名字匹配的 Linear 层替换成 LoRALinear。默认目标是注意力层的
q_proj和v_proj:def inject_lora(model, targets=("q_proj", "v_proj"), r=8, alpha=16): for name, module in model.named_modules(): for child_name, child in module.named_children(): if child_name in targets and isinstance(child, nn.Linear): setattr(module, child_name, LoRALinear(child, r, alpha)) return model
为什么选 q 和 v:Query 决定"关注什么",Value 决定"取出什么信息",这两处对任务语义最敏感;而在一众线性层里,只插这两处已能接近全参效果,参数开销又最小。若任务与预训练分布差得远(新语言、新模态),可扩展到 k_proj、o_proj 甚至 FFN,但成本线性上升——FFN 的参数通常是注意力的几倍。
- 第四步 · 验证参数占比
注入后必须检查两件事:可训练参数占比是否符合预期、是否只注入了目标层。
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) total = sum(p.numel() for p in model.parameters()) print(f"可训练 {trainable/1e6:.2f}M / 总计 {total/1e6:.1f}M = {100*trainable/total:.2f}%")
7B 模型 r=16 只插 q/v,典型输出是
可训练 6.8M / 总计 6738M = 0.10%。如果这个数字明显偏大(比如 5%),说明注入范围过宽;如果接近 0,说明注入失败。 - 第五步 · 训练(只有 A、B 有梯度)
优化器只接收 requires_grad 的参数。其他训练配置(loss、掩码、学习率调度)与全参微调完全一致。
optimizer = AdamW([p for p in model.parameters() if p.requires_grad], lr=2e-4)
学习率要放大:LoRA 的典型学习率是 1e-4 ~ 3e-4,比全参微调的 1e-5 ~ 2e-5 大一个量级。原因很直白——A、B 是从零开始学的全新参数,起点没有信息,需要更快的步子;而全参微调是在已有好权重上小幅调整。
- 第六步 · 可选:加 dropout 防过拟合
LoRA 的可训练参数很少,但数据集也小(几百条)时过拟合风险其实不低。对 LoRA 层的输入加 dropout 是必要的正则:
def forward(self, x): if self.training and self.dropout > 0: x = F.dropout(x, self.dropout) return self.base(x) + self.scale * (x @ self.A.T @ self.B.T)
典型值 0.05–0.1。注意只在训练时生效,推理必须关掉。
- 第七步 · 合并权重(推理前)
训练完成后,把 BA 加回原权重,得到一个普通模型,推理延迟零增加:
with torch.no_grad(): merged = base.weight + scale * (A.T @ B.T) # 注意转置方向要对齐 base.weight.copy_(merged)
合并后可以丢弃 A、B,模型体积回到原来大小。注意:合并后的权重不能再训练,也无法"撤销"——如果还要继续实验,务必先保存一份 LoRA 适配器(只有几十 MB)。
另一个选择是不合并、运行时动态加。好处是可以给同一个底座挂多个适配器(不同任务一套),切换成本很低;代价是推理时多一次小矩阵乘。
07QLoRA:是什么,怎么一步步实现
QLoRA(Quantized LoRA)是 LoRA 的量化增强版。它保留 LoRA 的全部结构,额外把冻结的底座权重从 BF16 压到 4-bit,让显存需求再降一个台阶。
效果量级:7B 模型的权重从 14 GB 压到约 3.5 GB,整卡占用回落到 6–10 GB,单张 24 GB 消费级显卡可以微调 7B。
7.1 三个关键技术,各解决一个问题
NF4 量化。先看它要治什么病。传统 4-bit 量化是:用最大绝对值归一化到 [-1,1],均匀切 16 个等距格点,每个权重映射到最近格点。问题在于大模型权重近似正态分布——绝大部分值集中在 0 附近,两端很稀疏。均匀切格点导致:0 附近格点太稀、量化误差大;两端格点大量浪费(几乎没有值落在那里)。
NF4 的做法是按累积概率而非数值区间分配格点:把标准正态分布的累积概率均分成 15 个等概率区间,每区间取中位数作为代表值。这样格点在 0 附近密集、两端稀疏,与权重的实际分布匹配,量化误差显著降低。
双重量化。量化时每 64 个权重共享一个 FP32 缩放因子(absmax)。7B 模型约 1.1 亿个缩放因子,每个 4 字节,合计 0.4 GB——相比 3.5 GB 的权重不算小。双重量化把这些缩放因子再按 256 个一组做 8-bit 量化,开销压到原来的 1/4 左右。
分页优化器。即使做了上面两步,优化器状态在长序列训练时仍可能瞬时撑爆显存。分页优化器借鉴操作系统虚拟内存:把优化器状态分页,只在需要更新某层时把对应页换入显存,用完换出到 CPU。峰值显存只需容纳"当前正在用的那部分"。
7.2 六步实现
- 第一步 · 配置量化参数
核心是四个参数,每个都有它存在的原因:
from transformers import BitsAndBytesConfig bnb = BitsAndBytesConfig( load_in_4bit=True, # 底座权重压成 4-bit bnb_4bit_quant_type="nf4", # 用 NF4 而非 fp4 bnb_4bit_use_double_quant=True, # 缩放因子二次量化 bnb_4bit_compute_dtype=torch.bfloat16, # 计算时的精度 )
compute_dtype这一项容易被忽略但很重要:它决定反量化之后参与矩阵乘的精度。设成 bf16 是平衡速度和精度的常规选择;设成 fp32 更准但慢一倍。 - 第二步 · 用这个配置加载模型
model = AutoModelForCausalLM.from_pretrained( path, quantization_config=bnb, device_map="auto", torch_dtype=torch.bfloat16)加载后所有底座权重都是 4-bit 且不可训练。这时模型的输出质量应该与非量化版本基本一致——如果明显变差,检查
compute_dtype是否被设成了 fp16 或 fp32。 - 第三步 · 准备 k-bit 训练
这一步做三件事,缺一不可:
model = prepare_model_for_kbit_training( model, use_gradient_checkpointing=True, # 开检查点,QLoRA 必开 )它会把 LayerNorm 等非量化层转成 FP32(4-bit 下 LN 用低精度会不稳定)、设置
use_cache=False(与梯度检查点冲突)、并对输入 embedding 的梯度做正确处理。 - 第四步 · 挂 LoRA 适配器
注意
target_modules在 4-bit 加载后必须用完整模块名,不能只写 q_proj——因为量化后模块结构被包了一层:from peft import LoraConfig, get_peft_model cfg = LoraConfig( r=16, lora_alpha=32, lora_dropout=0.05, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], bias="none", task_type="CAUSAL_LM", ) model = get_peft_model(model, cfg) model.print_trainable_parameters()
QLoRA 场景下建议把 q/k/v/o 全插上——因为底座被量化后表达能力有损,多插几处能补偿。
- 第五步 · 训练
其余配置与 LoRA 一致。但有两个 QLoRA 特有的注意点:
TrainingArguments( per_device_train_batch_size=1, # 4-bit 下 batch 通常只能开很小 gradient_accumulation_steps=16, # 用累计补回等效 batch gradient_checkpointing=True, optim="paged_adamw_8bit", # 分页优化器,QLoRA 专用 learning_rate=2e-4, bf16=True, )optim="paged_adamw_8bit"是 QLoRA 的标准配置——它同时实现了分页和 8-bit 优化器状态,这两项加起来能再省下几 GB。 - 第六步 · 合并权重时必须先反量化
这是 QLoRA 最容易出错的一步。底座权重是 4-bit 的,不能直接和 LoRA 增量相加,必须先反量化回 bf16:
model = model.merge_and_unload() # 内部会先反量化再合并 model.save_pretrained(out_dir, safe_serialization=True)
合并后的模型是完整的 bf16 模型(约 14 GB),不再是 4-bit。如果你希望保持 4-bit 部署,就不要合并,而是分别保存底座和适配器,推理时再加载回去——这样显存优势才保留。
08实现里的六个坑
以下是手写实现时容易忽略的六处问题。前两个会实际影响训练正确性(但不会报错,只会静默训偏),后四个影响可维护性和资源效率。每条都给出触发条件和修法。
坑 1 · 掩码与标签错位了一个 token 影响正确性
先看代码:
model_inputs = ids[:, :-1] # 位置 0 .. T-2 target_labels = ids[:, 1:] # 位置 1 .. T-1 mask = build_mask(model_inputs) ← 在截断后的 input 上算掩码
推理一下坐标。位置 t 模型读入 input[t],预测 target[t] = input[t+1]。而掩码是在 model_inputs 上算的,所以 mask[t]=1 表示"input[t] 是回答 token"。
但 loss 在位置 t 算的是 −log P(input[t+1])。掩码说"这里该算",实际算的却是"预测下一个 token"的 loss。差了一位,造成两个后果:
- 每个回答段第一个 token 被漏算——预测它的位置是
answer_start−1,那里 mask 还是 0(属于 prompt 段)。 - 每段末尾多算一个 token——位置
assistant_end的 mask 是 1,但它预测的是下一轮的<|im_start|>或换行,不该学。
单轮影响约 1 个 token,看似微不足道。但它不是随机噪声而是系统性偏差:模型对"回答第一个 token"学得偏弱,而那个位置恰好决定起手式风格(直接给答案,还是先说"这是个好问题")。多轮对话会累积。
让掩码在完整序列上算,再 shift 对齐:
full_mask = build_mask(ids) # 长度 T loss_mask = full_mask[:, 1:] & padding_mask
这样 loss_mask[t]=1 就准确表示"target[t] 是回答 token"。
坑 2 · +5 硬编码偏移 影响正确性
掩码定位回答起点时常用 answer_start = user_end + 5。这个 5 是人工数出来的,同时依赖三个前提:
<|im_end|>是单 token(有些 tokenizer 会拆开)- 角色名
assistant是单 token(若拆成 "assist"+"ant" 就变 6) - 换行符
\n是单 token(有些模板用\r\n或带额外空格)
换模型、换模板版本,或开了 thinking 前缀(会在 assistant 后插额外标记),这个 5 立刻失效。最麻烦的是不报错——掩码偏移到错误位置,loss 曲线依然平滑下降,看起来一切正常。这类 bug 可能藏几个月,发现时你根本不会怀疑掩码。
enc = tokenizer.apply_chat_template(
messages, tokenize=True, return_assistant_tokens_mask=True)内部按模板结构精确判断,换模型换模板都不用改代码。手写版本适合理解原理,上生产该用这个。
坑 3 · SFT 与 DPO 的归一化口径不一致 设计问题
SFT 的 loss 分母是 |A|(batch 内有效 token 总数),而 DPO 里算 log 概率时常用每个样本各自除以自己的 token 数:
average_log_prob = masked_log_probs.sum(-1) / mask.sum(-1)
这跟 DPO 原论文不一致:论文用序列级 log 概率(sum,不除长度)。HuggingFace TRL 的开关是 average_log_prob,默认 False。
后果不难推:除以长度后长短回答的 log 概率被拉到同一尺度,长度不再影响偏好差的大小。而人类偏好里"啰嗦但安全"和"简洁但可能遗漏"往往有明确取向——用 per-token 平均,这个长度维度的信号就被抹掉了。想复现论文结果,用 sum。
坑 4 · 只设了 pad token 没设 padding_side 可维护性
训练时右侧 padding 是对的——配合因果掩码,右侧 pad 不影响真实 token 计算。但生成时右侧 padding 出问题:模型会接着 pad 往下写,输出无意义内容。
Qwen 系列 tokenizer 默认 padding_side="left"(推理时左侧补齐方便对齐末尾)。训练脚本没显式改回 right,行为就和注释描述不符。建议开头直接写:tokenizer.padding_side = "right"。
坑 5 · 参考模型只 eval() 没包 no_grad() 显存浪费
DPO 有四个前向:策略过 chosen、策略过 rejected、参考模型过 chosen、参考模型过 rejected。参考模型的两次完全不需要梯度。
但 ref_model.eval() 只关掉 dropout 和 batchnorm 的训练行为,不阻止 PyTorch 构建计算图。参考模型的前向仍会保存每层中间激活等反向(虽然永远等不到)。结果 DPO 的激活值显存接近翻倍,明明只用一半却被撑爆。
with torch.no_grad():
ref_chosen = ref_model(chosen_inputs).logits
ref_rejected = ref_model(rejected_inputs).logits
更好的优化:参考模型 log 概率在同一 batch 内固定(因为它不更新),可以训练前全量预计算并缓存。这样训练时只需两个前向而非四个,显存时间都省一半;代价是多存一份缓存(每样本两个标量,可忽略)。
坑 6 · skip batch 时学习率计划与实际训练量脱节 细节
SFT 代码常有保护:某个 batch 里没有有效 answer token(整批被截断成只有 prompt)就 continue 跳过。保护本身是对的。
但 total_steps 是 skip 之前算的,而余弦退火按 step 索引推进。若跳过 100 个 batch,实际只做 900 步更新,但学习率计划以为走了 1000 步——实际训练量比计划少 10%,学习率却按 1000 步衰减,训练提前进入低学习率阶段,收敛不充分。
修法:用独立 effective_step 计数器,只在真正执行更新时递增,学习率按它算。
09总结
SFT 通过 answer mask 把目标函数从联合分布收窄成条件分布——这是它与预训练的本质区别。代价是这个目标里没有任何项约束它别跑远,KL 漂移无上界增长,灾难性遗忘成为结构性必然而非调参失误。
DPO 补上了这一项。它把"带 KL 惩罚的奖励最大化"求解析解,得到 π* ∝ πref·exp(r/β),反解出 r 代回 Bradley-Terry,配分函数 Z(x) 因只依赖 x 而恰好相消——奖励模型被彻底消掉,只剩一个 sigmoid 损失。但它是离线的:数据固定、从不采样,学不到数据分布之外的行为,所以在可验证奖励任务上打不过 GRPO。
RLHF / GRPO 用在线采样换回探索能力,代价是回到"需要奖励模型 + 高方差 + 难调"。选型分水岭很清楚:你有的是人类偏好标注(选 DPO),还是可验证的奖励函数(选 GRPO)。
LoRA 是另一条正交的轴。它不改变学什么,只赌"权重改变量本身低秩",从而把优化器状态和主权重这两块最大开销整个消掉,成本降一个数量级。QLoRA 再往前一步,把冻结底座压成 4-bit,让消费级显卡能碰到 7B。
而所有这些技术的实现,最终都落在几个具体动作上:掩码要与标签对齐、参考模型要 no_grad、β 要当温度理解、学习率要按参数量级放大。原理决定你能选对方法,这些细节决定你能不能跑出结果。