· 参数高效微调

LLM 微调理论:SFT、DPO、LoRA、QLoRA 的统一视角

按统一的四段式拆每个技术:一句话定义它是什么、核心机制、逐步实现路径、最容易错的地方——把「原理懂了但代码跑不通」那段落差补齐。

2026-10-27 理论 + 实现笔记 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
GRPOPPO 的去 Critic 版,用同题多答的组内相对分做优势x + 可验证奖励维度 A
LoRA冻结原权重,用低秩增量 W₀ + BA 表达微调改变—维度 B
QLoRALoRA + 把冻结的底座压成 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 核心机制:为什么必须做掩码

先看预训练在做什么。它的目标是建模整个序列的联合概率:

ℒPT = −Σt=1m log Pθ(yt | y<t) 序列里每个 token 都贡献梯度,包括标点和格式符

SFT 要的是另一个东西——给定 prompt 之后,回答的条件分布,只在回答部分求和:

ℒSFT = −Σt ∈ A log Pθ(yt | x, y<t),   A = {assistant 回答的 token 位置} A 是掩码选出的位置集合,只在这些位置算 loss

两个式子只差求和范围,但含义差很多。第二个显式地以 x 为条件,把 prompt 当作已知前提而不是学习目标。这正对应推理时的真实场景:用户输入是给定的,模型只需生成后续。

不做掩码的后果有两层。第一层是容量浪费:一条 2500 token 的样本里用户提问可能占 1500,60% 的梯度预算花在学"用户会怎么说话"——而这项能力推理时完全用不到。第二层更隐蔽:模型是共享参数的,学"提问分布"和"回答分布"会竞争同一批参数;当提问部分模式更规律、loss 更容易降时,模型会把容量优先分配给它,结果是训出一个"用户句式生成器"。

这句话值得背下来

"不应该对用户输入 prompt 部分的 token 计算损失,只能对 assistant 回答部分计算损失。这是 SFT 和预训练过程中一个最本质的区别。"

更准确的说法是:answer mask 不是优化技巧,而是把目标函数从联合分布改成条件分布。不做掩码的 SFT,等价于拿指令数据做了一次低质量预训练。

3.2 七步实现

下面是从零手写 SFT 的完整路径。每一步都说明"做什么、为什么、容易错在哪"。

  1. 第一步 · 数据整理成 messages 结构

    统一成 OpenAI 风格的对话列表。多轮对话就是一个列表里交替放 user 和 assistant:

    messages = [
        {"role": "user",      "content": "什么是习惯?"},
        {"role": "assistant", "content": "习惯是……"},
        {"role": "user",      "content": "怎么培养?"},
        {"role": "assistant", "content": "21 天法……"},
    ]

    易错点:数据里如果出现连续两条 assistant(比如工具调用场景),后面按奇偶位置分轮的逻辑会全部错位。必须在预处理阶段检查角色是否严格交替。

  2. 第二步 · 应用 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,模型才知道该开始生成。

  3. 第三步 · 构造 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"]   # 直接就是要的东西
    

    它内部按模板结构精确判断,换模型、换模板都不用改代码。

  4. 第四步 · 错位对齐(最容易错的一步)

    因果语言模型在位置 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 曲线依然平滑,但模型对"回答怎么起头"学得偏弱。

  5. 第五步 · 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",否则行为和预期不符。

  6. 第六步 · 前向 + 计算损失

    三个动作:把 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,把对应位置的那一个数值取出来。

  7. 第七步 · 训练循环

    标准五件套:取 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 目标

maxπ   𝔼x∼𝒟, y∼π(·|x)[ r(x,y) ] − β · 𝔻KL( π(·|x) ‖ πref(·|x) ) 第一项要奖励高,第二项要不离参考模型太远

r(x,y) 是奖励函数,在 RLHF 里就是训好的奖励模型。y ∼ π(·|x) 表示回答由当前策略实时生成——注意这是在线采样,不是从固定数据集取。第二项 KL 衡量当前策略与参考策略的差异,πref 通常是 SFT 后的 checkpoint,全程冻结。

这个目标的形式很直白:既要拿高分,又别跑太远。"跑太远"的代价在 3.3 讲过——会遗忘。β 就是在"探索新行为"和"保持原能力"之间调平衡的旋钮。

第二步:这个目标有解析解

π*(y|x) = (1/Z(x)) · πref(y|x) · exp( r(x,y) / β ) Z(x) = Σy πref(y|x) exp(r(x,y)/β),只依赖 x 的归一化常数

把 KL 展开成期望形式、对每个 y 分别求导置零,就得到这个闭式解。它的读法是:最优策略 = 参考策略 × 奖励的指数加权。奖励高的回答概率被放大,奖励低的被压低;β 越小放大越剧烈(温度越低),β→∞ 时退化成参考策略本身。

第三步:把奖励反解出来

r(x,y) = β · log( π*(y|x) / πref(y|x) ) + β · log Z(x) 这一步是 DPO 全部洞察的来源:奖励可以用策略本身表达

意义很大:它说明奖励函数不需要单独学,可以从策略里读出来。只要你有 π* 和 πref,就能反算隐式奖励。这就是"DPO 里语言模型本身就是奖励模型"的出处——奖励藏在概率比里。

第四步:代回 Bradley-Terry,Z 恰好抵消

奖励模型的标准假设是 Bradley-Terry:人偏好 yw(chosen)而非 yl(rejected)的概率为 σ(rw − rl)。代入第三步:

r(yw) − r(yl) = β·[ log(π*(yw)/πref(yw)) − log(π*(yl)/πref(yl)) ] 两个 β·log Z(x) 相减归零 —— 整个推导能成立的关键就这一下

Z 为什么消失?因为它只依赖 x,不依赖 y;而差值是在同一个 x 下对两条回答做减法,同一个 Z(x) 自然相消。

这也顺便解释了参考模型为什么不可省:它是让 Z 能消掉的必要条件。没有 πref,这个式子不对应任何有效奖励函数。

用 πθ 参数化 π*,套上负对数似然:

ℒDPO = −𝔼 log σ( β [ (log πθ(yw|x) − log πθ(yl|x)) − (log πref(yw|x) − log πref(yl|x)) ] ) 前一个括号是当前模型的偏好差,后一个是参考模型的偏好差

按括号分组读最清楚:当前模型对 chosen 的偏好,要比参考模型强多少。为正且大 → sigmoid 接近 1,loss 接近 0,没什么可学;为负 → 模型还更偏 rejected,梯度强力修正。

4.2 八步实现

  1. 第一步 · 准备三元组数据

    每条样本是 (prompt, chosen, rejected)。三者都是完整文本,chosen 和 rejected 是同一 prompt 下质量不同的两个回答。

    {"prompt":   "解释一下什么是过拟合",
     "chosen":   "过拟合指模型在训练集上表现好、在新数据上表现差……",
     "rejected": "过拟合就是模型学得太好了。"}

    选数据的要点:偏好差异要明显且可解释。如果 chosen 和 rejected 质量相近,模型学不到有效信号;如果两者差异只是长度(一个长一个短),模型可能学成"越长越好"。

  2. 第二步 · 拼成两条完整序列

    把 prompt 和回答分别用 chat template 拼接,得到 chosen 序列和 rejected 序列。注意:两者共享同一个 prompt 前缀,只有回答部分不同。

    chosen_ids   = template(prompt + chosen)
    rejected_ids = template(prompt + rejected)

    因为长度不同(chosen 通常更长),要分别 padding,得到两套 inputs / labels / mask。

  3. 第三步 · 构造回答部分的掩码

    和 SFT 一样——只对回答部分算 log 概率,prompt 部分不计入。这里尤其重要,因为如果不做掩码,chosen 和 rejected 的 prompt 部分完全相同,会相互抵消,但因为长度不同又抵消不干净,反而引入噪声。

    chosen_mask   = assistant_mask(chosen_ids)
    rejected_mask = assistant_mask(rejected_ids)
  4. 第四步 · 四个前向

    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_grad

    ref_model.eval() 只关掉 dropout,不阻止 PyTorch 构建计算图。不加 no_grad,参考模型的前向会保留全部中间激活等反向传播(虽然永远等不到),DPO 的激活显存接近翻倍。

  5. 第五步 · 计算每条序列的 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 会学不到这个信号。

  6. 第六步 · 组合成 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 断崖式下降、然后模型输出变得重复单调(过度集中到少数高分回答)。

  7. 第七步 · 只更新策略模型

    优化器只传 policy.parameters(),参考模型的参数不要放进优化器,也不要有 requires_grad=True。

    optimizer = torch.optim.AdamW(policy.parameters(), lr=5e-7)  # DPO 学习率通常比 SFT 小一个量级

    学习率为什么要小?因为 DPO 的梯度尺度被 β 放缩过,而且策略已经接近最优,需要小步微调。常见的失败模式是学习率用了 SFT 的 2e-5,训练几十步后模型输出完全崩坏。

  8. 第八步 · 优化:预计算参考 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 梯度的形状:自动难例加权

对损失求梯度会得到一个很有用的形式:

∇ℒ = −β · σ( −βΔ ) · [ ∇log πθ(yw) − ∇log πθ(yl) ] Δ = 前面那个方括号(当前模型相对参考模型的偏好差)

重点看系数 σ(−βΔ),它是随训练自动变化的样本权重:Δ 大(已经学会了)时趋近 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 × 214 GBBF16,必须常驻
梯度N × 214 GB与参数同 shape 同精度
优化器状态2 × N × 456 GBAdamW 的 m 和 v,必须 FP32
FP32 主权重 常被漏掉N × 428 GB参数更新必须在 FP32 下做
静态合计—112 GB还没算激活值
激活值b·s·h·l·2·k数十 GBk 为经验补偿因子 10–30

112 GB 这个数字解释了两件事。第一,7B 全参微调必须多卡——单张 80G A100 装不下静态状态,这不是"优化一下更好",是硬约束。第二,LoRA 能把门槛降到消费级——冻结权重仍要 14 GB,但 56 GB 优化器状态和 28 GB 主权重几乎全部消失,整卡回落到 16–20 GB。这不是省一点,是省一个数量级。

5.1 怎么定位瓶颈:三步判断

显存不够时不要盲目试手段,先花两分钟做判断:

  1. 先算静态账

    按上表把「参数 + 梯度 + 优化器状态 + 主权重」算出来。如果这个数字已经接近或超过单卡容量,那问题是静态的,降 batch、开检查点都没用——必须换 PEFT 或上多卡。

    例:7B 全参静态 112 GB,48 GB 卡无论怎么调 batch 都装不下。

  2. 静态够,但一跑就 OOM

    说明瓶颈在激活值。这时按性价比依次试:梯度检查点(激活降到 30–40%,速度慢 20–30%)→ 降 max_length(效果最直接,激活与序列长度近线性)→ 降 batch → 梯度累计维持等效 batch。

  3. 显存够但速度慢

    这不是显存问题。检查:混合精度有没有开(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 七步从零实现

  1. 第一步 · 定义 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 能稳定训练的关键设计。如果两者都用随机初始化,训练初期会有随机扰动,容易破坏原有能力。

  2. 第二步 · 实现前向

    输出 = 原层输出 + 低秩增量输出,增量要乘缩放系数:

        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。

  3. 第三步 · 注入到目标模块

    遍历模型,把名字匹配的 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 的参数通常是注意力的几倍。

  4. 第四步 · 验证参数占比

    注入后必须检查两件事:可训练参数占比是否符合预期、是否只注入了目标层。

    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,说明注入失败。

  5. 第五步 · 训练(只有 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 是从零开始学的全新参数,起点没有信息,需要更快的步子;而全参微调是在已有好权重上小幅调整。

  6. 第六步 · 可选:加 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。注意只在训练时生效,推理必须关掉。

  7. 第七步 · 合并权重(推理前)

    训练完成后,把 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 六步实现

  1. 第一步 · 配置量化参数

    核心是四个参数,每个都有它存在的原因:

    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 更准但慢一倍。

  2. 第二步 · 用这个配置加载模型
    model = AutoModelForCausalLM.from_pretrained(
        path, quantization_config=bnb, device_map="auto", torch_dtype=torch.bfloat16)

    加载后所有底座权重都是 4-bit 且不可训练。这时模型的输出质量应该与非量化版本基本一致——如果明显变差,检查 compute_dtype 是否被设成了 fp16 或 fp32。

  3. 第三步 · 准备 k-bit 训练

    这一步做三件事,缺一不可:

    model = prepare_model_for_kbit_training(
        model,
        use_gradient_checkpointing=True,   # 开检查点,QLoRA 必开
    )

    它会把 LayerNorm 等非量化层转成 FP32(4-bit 下 LN 用低精度会不稳定)、设置 use_cache=False(与梯度检查点冲突)、并对输入 embedding 的梯度做正确处理。

  4. 第四步 · 挂 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 全插上——因为底座被量化后表达能力有损,多插几处能补偿。

  5. 第五步 · 训练

    其余配置与 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。

  6. 第六步 · 合并权重时必须先反量化

    这是 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、β 要当温度理解、学习率要按参数量级放大。原理决定你能选对方法,这些细节决定你能不能跑出结果。

微调理论学习笔记
2026-10-27
分享:
返回文章列表

相关文章

前向传播与反向传播概述

微调场景下的前向产物、反向传播机制,以及冻结参数与 LoRA 等不同策略对反向计算图的实际影响。