LLM Intro(七):用 SFT 与 LoRA 微调语言模型

一个预训练语言模型学会了续写文本,却不一定会按照我们需要的格式回答问题。假设要把设备报修描述转换成包含“设备、故障、紧急程度”的 JSON:模型可能理解描述,但漏字段、加解释,或对同一种故障使用不同标签。监督微调(Supervised Fine-Tuning,SFT)通过输入与期望输出的示例,让模型适应这样的任务。

SFT 描述的是训练目标和数据形式;LoRA 描述的是如何更新参数。两者是不同层面的问题:同一份 SFT 数据既可以用于全参数微调,也可以只训练 LoRA 适配器。先把数据和损失弄清楚,才能判断省下来的参数究竟在哪里。

一条对话怎样成为训练样本

可以先用消息列表保存任务数据:

{
  "messages": [
    {"role": "system", "content": "提取设备、故障和紧急程度,只输出 JSON。"},
    {"role": "user", "content": "会议室投影仪无法开机,明天才会用。"},
    {"role": "assistant", "content": "{\"设备\":\"投影仪\",\"故障\":\"无法开机\",\"紧急程度\":\"普通\"}"}
  ]
}

真正送入模型前,还要使用与模型匹配的 chat template,把角色、消息边界和结束标记转换成 token 序列。训练样本包含已经完成的 assistant 回答,因此不应像推理时那样,在样本末尾额外打开一个待生成的 assistant 消息。

设序列为 x1,…,xTx_1,\ldots,x_T。模型仍然用前面的 token 预测下一个 token,但我们可以只在 assistant 回答对应的位置计算损失:

L=−1∣A∣∑t∈Alog⁡pθ(xt∣x<t),\mathcal L=-\frac{1}{|\mathcal A|}\sum_{t\in\mathcal A} \log p_\theta(x_t\mid x_{<t}),

其中 A\mathcal A 是需要学习的目标位置集合。用户问题仍在输入里,提供条件;不计入损失并不意味着把它从上下文删掉。

实现时通常复制 input_ids 得到 labels,把 system、user 和 padding 对应位置改成 -100。交叉熵会忽略这些标签。assistant 结束标记是否纳入训练、哪些消息边界需要监督,应与所用模板和训练约定一致。多轮对话还要决定监督所有 assistant 回合,还是只监督最后一轮。

标签边界必须在 token 层面可靠识别。优先使用模板提供的 assistant mask 或经过验证的数据整理器;直接在拼接字符串里搜索“assistant”容易误匹配正文,也可能被分词边界打断。

下一 token 的错位发生在哪里

下面的函数假设 logits 对应完整输入,labels 已经做好上述屏蔽。位置 tt 的输出要预测位置 t+1t+1 的标签,所以两者需要错开一位:

import torch
import torch.nn.functional as F

def causal_loss(logits, labels):
    prediction = logits[:, :-1, :].contiguous()
    target = labels[:, 1:].contiguous()
    if not torch.any(target != -100):
        raise ValueError("The batch has no supervised target tokens.")
    return F.cross_entropy(
        prediction.reshape(-1, prediction.size(-1)),
        target.reshape(-1),
        ignore_index=-100,
    )

这解释了手工计算损失的过程。使用 Hugging Face 的 causal LM 并传入 labels 时,模型通常已经在内部执行这个错位,不要提前把标签再移动一次。

在正式训练前,先检查几个样本:把有效 label 解码出来,看监督的是否确实是期望回答;确认截断没有把回答全部删掉;检查 padding 没有进入损失。数据还应先按来源或任务实体划分训练集和验证集,再做切分与扩充,避免同一条内容的近似副本同时出现在两边。

全参数微调的内存花在哪里

全参数训练不仅要存模型权重,还要保存梯度、优化器状态、前向激活和临时工作区。以 Adam 为例,每个可训练参数通常还有一阶、二阶矩状态;混合精度训练也可能保留更高精度的参数副本。具体字节数依赖 dtype 和实现,不能只用权重文件大小推断训练显存。

例如 70 亿参数以每个参数 2 字节保存,权重本身约为 14 GB,尚未包括上述训练状态。序列变长或 batch 变大时,激活也会明显增长。LoRA 主要减少可训练参数及其梯度、优化器状态,基础模型仍然需要加载,许多激活也仍然存在。

用两个小矩阵表示参数更新

对线性层采用列向量记法:y=Wxy=Wx,其中 W∈Rdout×dinW\in\mathbb R^{d_{out}\times d_{in}}。全参数微调直接改变 WW;LoRA 冻结它,将更新限制为两个小矩阵的乘积:

y=Wx+αrBAx,A∈Rr×din,B∈Rdout×r.y=Wx+\frac{\alpha}{r}BAx, \qquad A\in\mathbb R^{r\times d_{in}},\quad B\in\mathbb R^{d_{out}\times r}.

rr 是秩,决定低秩分支的容量;α/r\alpha/r 控制它相对原输出的缩放。因为 BABA 的秩最多为 rr,模型只能在一个受限的更新空间内适应任务。这是一种有用的结构假设,并不保证任意任务都能由很小的 rr 完成。LoRA 论文给出了这一方法及实验依据。

若原矩阵为 4096×40964096\times4096,共有 16,777,216 个参数;取 r=8r=8,两个适配矩阵只有 8(4096+4096)=65,5368(4096+4096)=65,536 个参数,是原矩阵的 1/2561/256。这个比例只针对该层可训练矩阵,不能直接当成整次训练的显存缩减比例。

为什么一个随机初始化、另一个初始化为零

如果令 B=0B=0,则初始 BA=0BA=0,插入 LoRA 后模型仍然保持原来的输出。与此同时,把 AA 初始化为小的随机值,就能让 BB 在第一次反向传播时获得有效梯度。

第一次更新时,AA 的梯度会因为 B=0B=0 而为零;等 BB 更新后,AA 也可以开始学习。如果把两者都初始化为零,那么这两个矩阵的梯度会相互被零因子阻断,低秩分支就无法启动。

下面把一个已有的 nn.Linear 包装起来,保留它的设备、dtype 和偏置:

import math
import torch
from torch import nn

class LoRALinear(nn.Module):
    def __init__(self, base, rank=8, alpha=16):
        super().__init__()
        if rank < 1:
            raise ValueError("rank must be positive")
        self.base = base
        self.base.requires_grad_(False)
        self.scale = alpha / rank
        self.A = nn.Parameter(base.weight.new_empty(rank, base.in_features))
        self.B = nn.Parameter(base.weight.new_zeros(base.out_features, rank))
        nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))

    def forward(self, x):
        return self.base(x) + (x @ self.A.T @ self.B.T) * self.scale

base = nn.Linear(16, 12)
layer = LoRALinear(base, rank=4, alpha=8)
x = torch.randn(2, 16)
assert torch.allclose(layer(x), base(x))
loss = layer(x).square().mean()
loss.backward()
assert base.weight.grad is None
assert layer.B.grad is not None

冻结权重使用 requires_grad_(False),不要把基础分支整个包进 no_grad()。即使权重不需要梯度,梯度仍可能需要经过这次运算传给上游的可训练层;禁用计算图会切断这条路径。

在实际模型中接入 LoRA

先观察模型中线性层的名称,再选择目标模块。注意力里的 Q、K、V、输出投影和 FFN 都可以成为候选,但不同架构可能使用独立投影,也可能把 QKV 合在一个矩阵里。照抄另一种架构的名字,可能找不到模块或漏掉预期层。

以下代码接在第五篇加载 Qwen 模型之后,使用 PEFT 为其中的 q_proj、v_proj 添加适配器:

from peft import LoraConfig, get_peft_model

config = LoraConfig(
    r=8,
    lora_alpha=16,
    target_modules=["q_proj", "v_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)
model = get_peft_model(model, config)
model.print_trainable_parameters()
optimizer = torch.optim.AdamW(
    (p for p in model.parameters() if p.requires_grad),
    lr=2e-4,
)
model.train()

学习率只是实验起点,需要结合数据量、有效 batch、训练步数和验证结果调整。model.train() 也不能省略:前面推理时使用的 eval() 会关闭 dropout 等训练行为。

训练循环仍是清梯度、前向、反向、更新。每个 batch 应提供 input_ids、attention_mask 和处理好的 labels,移动到相应设备后用 model(**batch).loss 获取损失。Trainer 等工具封装的是这段调度,并不会自动替我们决定正确的标签边界。使用梯度累积时,还要让损失缩放、更新频率和学习率调度保持一致。

怎样比较效果,怎样保存结果

Llama 2 在领域任务上的基础模型、全参数微调与 LoRA 效果对比

这张图来自 Anyscale 的 Llama 2 微调实验。它说明在这些实验设置中,少量可训练参数可以获得有竞争力的效果;不能据此断言 LoRA 在任何数据集上都与全参数微调等价。比较时应同时报告任务指标、格式正确率、训练资源,以及对原有能力的影响。

PEFT 模型可以使用 model.save_pretrained("adapter") 保存适配器,并用 tokenizer.save_pretrained("adapter") 保存 tokenizer。通常保存的是适配器及其配置,而不是完整基础权重;若配置了额外可训练模块,也需要一并保存。基础模型的标识和版本应记录下来,加载时先恢复匹配的基础模型,再用 PeftModel.from_pretrained(base_model, "adapter") 接入适配器。具体接口见 PEFT 文档。

对于可合并的普通线性层,还可以将 W′=W+(α/r)BAW'=W+(\alpha/r)BA 合并成一个权重矩阵,随后移除低秩分支。合并后如果仍然运行适配器,就会把同一更新加两次。量化模型的合并还涉及反量化和重新量化,不能把普通浮点权重的处理直接套过去。


系列目录 · 上一篇:GPU 执行模型与 Triton 算子 · 下一篇:理解量化、AWQ 与 QLoRA

本文采用 CC BY-NC-SA 4.0 协议