Skip to content

4.3 参数高效微调 PEFT:LoRA 与 QLoRA

在上一节中,我们学习了监督微调(SFT)的基本流程:准备指令数据集、选择预训练模型、配置训练参数、启动微调。这套流程在概念上非常清晰,但当你真正动手尝试时,很快会遇到一个现实的障碍——显存。对一个 7B 参数的模型做全参数微调,需要大约 56 GB 的显存(模型权重、梯度、优化器状态三者之和),这远远超出了单张消费级显卡(如 RTX 4090 的 24 GB)的承受范围。即便是 13B 模型,全参数微调的显存需求也高达百 GB 级别。

更令人沮丧的是,研究发现:微调过程中权重的实际变化量非常"小"——在数学上表现为低秩的。这意味着我们花费了巨大的计算和存储成本去更新全部参数,但真正起作用的信息可能只占极小比例。这就像为了修订一本书中的几处错误,却把整本书重新排版印刷了一遍。

这就引出了本节的核心问题:全参数微调太贵了,有没有办法只更新极少量参数,就能达到接近甚至持平的微调效果? 答案就是参数高效微调(Parameter-Efficient Fine-Tuning,PEFT),而其中最成功的代表就是 LoRA 及其量化版本 QLoRA。


4.3.1 LoRA 原理深度解析

核心类比:只改一个注脚,而非重写整本书

想象你拿到一本已经出版的百科全书,发现其中某些内容需要更新。最笨的办法是把整本书重新写一遍——这相当于全参数微调。但聪明的做法是:在相关页面贴一张小纸条(注脚),写上"此处补充如下……"。读者查阅时,先看正文,再看注脚,就能得到更新后的内容。LoRA(Low-Rank Adaptation,低秩适应)做的就是这件事:冻结预训练权重(正文不动),只训练一个小小的"注脚"矩阵(低秩分解),推理时把注脚叠加到正文上。

LoRA 的核心思想:

原始前向传播:  h = W₀ · x           (W₀ 是预训练权重,冻结)

LoRA 前向传播:  h = W₀ · x + (A·B) · x

                  ┌──────────────┐
                  │   W₀ ∈ Rᵈˣᵏ   │  ← 冻结,不更新(正文)
                  │   (大矩阵)     │
                  └──────────────┘
                        +
                  ┌──────────────┐
                  │  A ∈ Rᵈˣʳ     │  ← 可训练(小矩阵)
                  │  B ∈ Rʳˣᵏ     │  ← 可训练(小矩阵)
                  │  r ≪ d, k     │  ← 秩 r 通常设为 8~64
                  └──────────────┘

为什么是低秩? 原始权重矩阵 W₀ 的维度是 d×k(比如 4096×4096),有 1600 多万个参数。但微调时真正需要的"变化量" ΔW 不需要这么大的表达能力——研究表明它可以用一个秩为 r(r 远小于 d 和 k)的矩阵来近似,即 ΔW ≈ A·B,其中 A 是 d×r,B 是 r×k。两个小矩阵相乘就能还原出大矩阵的主要信息,这就是"低秩分解"的威力。

参数压缩比计算

假设 d = 4096, k = 4096, r = 16

原始矩阵参数量:  4096 × 4096 = 16,777,216
LoRA 矩阵参数量: 4096 × 16 + 16 × 4096 = 131,072

压缩比:131,072 / 16,777,216 ≈ 0.78%

也就是说,原本需要更新 1677 万个参数,现在只需要更新 13 万个——不到原来的 1%,却能获得接近全参数微调的效果。这就是 LoRA 的核心价值。


LoRA 的三个关键超参数

超参数含义推荐值说明
r秩(Rank)8~64越大表示能力越强,但参数也越多
lora_alpha缩放因子16~64实际学习率 = alpha/r 缩放
target_modules目标模块q_proj, v_proj应用到哪些层的哪些投影矩阵
lora_dropoutDropout 率0.05~0.1防止过拟合

其中 r 是最重要的超参数。直觉上理解:r 越大,"注脚"能承载的信息越多,模型适应新任务的能力越强,但同时参数量和过拟合风险也随之增加。对于简单的风格适配任务,r=8 通常足够;对于需要学习大量新知识的任务(如代码生成、领域专业问答),r=32 或 64 效果更好。lora_alpha 则控制注脚内容的"权重"——实际施加到原始权重上的增量会被乘以 alpha/r,所以 alpha 通常设为 r 的 2 倍左右。

target_modules 选择策略

Transformer 层中的可 LoRA 化模块:
┌─────────────────────────────────────────────────────────┐
│  Attention 模块:                                        │
│    q_proj  ← 查询投影(推荐)                              │
│    k_proj  ← 键投影(推荐)                                │
│    v_proj  ← 值投影(强烈推荐)                             │
│    o_proj  ← 输出投影(推荐)                               │
│                                                          │
│  FFN 模块:                                              │
│    gate_proj  ← 门控投影(可选)                           │
│    up_proj    ← 上投影(可选)                             │
│    down_proj  ← 下投影(可选)                             │
│                                                          │
│  经验法则:q_proj + v_proj 是最小可用配置                  │
│          全部 attention 模块效果最佳                        │
│          加入 FFN 模块可进一步提升但参数翻倍                 │
└─────────────────────────────────────────────────────────┘

选择策略的经验:先用 ["q_proj", "v_proj"] 快速实验,确认流程跑通后再逐步加入其他模块。如果效果不足,优先加 k_projo_proj,再考虑 FFN 的三个投影。每次调整后注意观察训练损失曲线和验证集表现。


4.3.2 QLoRA:把大模型塞进消费级显卡

LoRA 已经将可训练参数压缩到了 1% 以下,但还有一个问题没解决:预训练权重本身仍然需要以全精度(FP16)加载到显存中。一个 7B 模型的 FP16 权重就要 14 GB,13B 要 26 GB,70B 要 140 GB。这意味着即使你只训练 0.1% 的参数,光是"把模型读进显存"这一步就可能 OOM(内存溢出)。

QLoRA(Quantized LoRA)就是为了解决这个痛点而提出的。它在 LoRA 的基础上引入了量化技术,将冻结的预训练权重从 16 位压缩到 4 位,使得在 24 GB 显存的消费级显卡(如 RTX 4090)上微调 70B 模型成为可能。

QLoRA 的三大技术支柱:
┌──────────────────────────────────────────────────────────┐
│                                                          │
│  ① 4-bit NormalFloat (NF4) 量化                           │
│     ┌──────────────────────────────────────────────┐    │
│     │  预训练权重: FP16 (16-bit) -> NF4 (4-bit)       │    │
│     │  显存节省: 约 75%                                │    │
│     │  信息损失: 极小(NF4 针对正态分布权重优化)        │    │
│     └──────────────────────────────────────────────┘    │
│                                                          │
│  ② 双重量化 (Double Quantization)                         │
│     ┌──────────────────────────────────────────────┐    │
│     │  量化常数量: FP32 -> FP8(再次量化)              │    │
│     │  每个参数额外节省: ~0.37 bit                     │    │
│     └──────────────────────────────────────────────┘    │
│                                                          │
│  ③ 分页优化器 (Paged Optimizer)                           │
│     ┌──────────────────────────────────────────────┐    │
│     │  利用 CPU RAM 作为 GPU 内存的"交换空间"          │    │
│     │  避免 OOM(内存溢出)错误                         │    │
│     └──────────────────────────────────────────────┘    │
│                                                          │
└──────────────────────────────────────────────────────────┘

三者的协同关系:NF4 量化解决"权重占显存太大"的问题;双重量化在此基础上进一步挤出剩余的显存水分;分页优化器则作为安全网,在显存即将爆满时自动把优化器状态转移到 CPU 内存,避免训练中途崩溃。三者叠加,才让"小卡跑大模型"从理论变成现实。

不同规模模型的显存需求对比

模型大小全量微调 (FP16)LoRA (FP16)QLoRA (4-bit)
7B~56 GB~18 GB~8 GB
13B~104 GB~32 GB~12 GB
70B~560 GB~160 GB~48 GB
405B不可行~1TB~200 GB

注:以上为粗略估算,实际显存需求取决于 batch size、序列长度等因素。

从表中可以直观看到 QLoRA 的价值:同样是微调 7B 模型,全参数微调需要 56 GB(至少 3 张 A100),LoRA 需要 18 GB(勉强一张 4090),而 QLoRA 只需要 8 GB——一张 RTX 3060 就能跑


4.3.3 PEFT 方法全景对比

除了 LoRA,还有多种 PEFT 方法各有优劣。理解它们之间的差异,有助于在不同场景下做出合适的选择。

┌──────────────────────────────────────────────────────────────────┐
│                     PEFT 方法全景对比                               │
├────────────┬──────────────┬──────────────┬────────────────────────┤
│   方法      │  可训练参数    │  推理开销      │  核心思想               │
├────────────┼──────────────┼──────────────┼────────────────────────┤
│ Adapter    │  3~5%        │  有(增加层)   │ 在 Transformer 层间       │
│            │              │              │ 插入小型网络             │
├────────────┼──────────────┼──────────────┼────────────────────────┤
│ Prefix     │  <1%         │  有(占用      │ 在每层前添加可学习的       │
│ Tuning     │              │  序列长度)     │ 虚拟 Token 前缀          │
├────────────┼──────────────┼──────────────┼────────────────────────┤
│ Prompt     │  <0.1%       │  有(占用      │ 仅在输入层添加可学习       │
│ Tuning     │              │  序列长度)     │ 的软提示                 │
├────────────┼──────────────┼──────────────┼────────────────────────┤
│ LoRA       │  0.1~1%      │  无(可合并)   │ 低秩矩阵分解权重更新       │
├────────────┼──────────────┼──────────────┼────────────────────────┤
│ IA³        │  <0.01%      │  无(可合并)   │ 学习缩放向量而非矩阵       │
└────────────┴──────────────┴──────────────┴────────────────────────┘

各方法的核心差异在于"把可训练参数放在哪里":Adapter 在层与层之间插入新的小网络;Prefix Tuning 在每一层的输入前拼上一段可学习的虚拟 Token;Prompt Tuning 更极端,只在最开始的输入层拼一段软提示;LoRA 则选择在权重矩阵旁边挂一个低秩分解的"旁路";IA³ 最为精简,只学习一个缩放向量来调节已有权重。其中 LoRA 和 IA³ 有一个独特优势——推理时可以将训练得到的增量合并回原始权重,不增加任何推理开销。而 Adapter 和 Prefix/Prompt Tuning 由于引入了额外的计算路径或占用了序列长度,推理时会带来延迟。

方法选择决策树

需要推理零开销? ─── 是 ───-> LoRA / IA³




需要最少参数? ─── 是 ───-> Prompt Tuning / IA³




需要最佳效果? ─── 是 ───-> LoRA (r=16~64)




    Adapter

对于绝大多数应用场景,LoRA 是通用首选:它在效果和效率之间取得了最佳平衡,社区支持最成熟,生态工具最完善。只有当你有特殊需求(如极端参数受限、需要推理时动态切换多个任务适配器)时,才需要考虑其他方法。


4.3.4 实战:LoRA 微调 Qwen2

接下来我们用 HuggingFace 的 PEFT 库,对 Qwen2-0.5B 进行一次完整的 LoRA 微调。选择 0.5B 小模型是为了让读者在普通笔记本上也能复现。

第一步:模型加载与 tokenizer 设置

python
import torch
from transformers import (
    AutoModelForCausalLM,      # 因果语言模型的基类
    AutoTokenizer,             # 分词器,负责文本与 token ID 的转换
    TrainingArguments,          # 训练超参数容器
    Trainer,                    # HuggingFace 通用训练循环
    DataCollatorForLanguageModeling,  # 批处理数据整理器
)
from peft import (
    LoraConfig,                # LoRA 配置类
    get_peft_model,            # 将普通模型包装为 PEFT 模型
    TaskType,                  # 任务类型枚举
    prepare_model_for_kbit_training,  # 量化训练预处理
)
from datasets import load_dataset  # 数据集加载工具

# ========== 1. 模型加载 ==========
model_name = "Qwen/Qwen2-0.5B"  # 选择 0.5B 小模型,CPU 也能跑
tokenizer = AutoTokenizer.from_pretrained(
    model_name,
    trust_remote_code=True,    # Qwen 系列需要信任远程代码
)

# 设置 pad_token:很多预训练模型没有 pad_token,
# 训练时 batch 内需要对齐长度,必须有一个填充符
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token  # 借用结束符充当填充符

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.float16, # 半精度加载,节省一半显存
    device_map="auto",         # 自动分配到可用设备(GPU/CPU)
    trust_remote_code=True,
)

第二步:配置 LoRA 并包装模型

python
# ========== 2. 配置 LoRA ==========
lora_config = LoraConfig(
    r=16,                              # 秩:决定"注脚"的信息容量
    lora_alpha=32,                     # 缩放因子,通常设为 r 的 2 倍
    target_modules=[                   # 指定对哪些投影矩阵应用 LoRA
        "q_proj", "k_proj", "v_proj", "o_proj",   # Attention 的四组投影
        "gate_proj", "up_proj", "down_proj",     # FFN 的三组投影
    ],
    lora_dropout=0.1,                  # Dropout 防止过拟合
    bias="none",                       # 不训练偏置项(省参数)
    task_type=TaskType.CAUSAL_LM,      # 任务类型:因果语言建模
)

# 将普通模型包装为 PEFT 模型:冻结原始权重,注入 LoRA 旁路
model = get_peft_model(model, lora_config)

# 打印可训练参数统计:你会看到可训练参数不到 1%
model.print_trainable_parameters()
# 输出示例:trainable params: 1,234,576 || all params: 500,000,000 || trainable%: 0.25%

第三步:数据准备

python
# ========== 3. 数据准备 ==========
dataset = load_dataset("silk-road/alpaca-data-gpt4-chinese", split="train")

def format_instruction(example):
    """将 alpaca 格式的数据拼成模型可读的指令文本"""
    if example.get("input") and example["input"].strip():
        # 有额外输入的情况:指令 + 输入 + 回答
        text = f"### 指令:\n{example['instruction']}\n\n### 输入:\n{example['input']}\n\n### 回答:\n{example['output']}"
    else:
        # 无额外输入:只有指令 + 回答
        text = f"### 指令:\n{example['instruction']}\n\n### 回答:\n{example['output']}"
    return {"text": text}

dataset = dataset.map(format_instruction)

def tokenize_function(examples):
    """分词处理:将文本转为 token ID 序列"""
    result = tokenizer(
        examples["text"],
        truncation=True,        # 超长文本截断
        max_length=512,        # 最大序列长度 512
        padding=False,         # 不在此处填充,交给 DataCollator 统一处理
    )
    result["labels"] = result["input_ids"].copy()  # 标签 = 输入(自回归训练)
    return result

tokenized_dataset = dataset.map(tokenize_function, remove_columns=dataset.column_names)