跳到主要内容

LoRA/QLoRA 参数高效微调实战:低秩假设、NF4 双重量化与多 Adapter 生产部署

· 阅读需 8 分钟

全参微调(full fine-tuning)的成本账很吓人:训练时除了权重本身,还要存梯度,以及 Adam 优化器的一阶矩、二阶矩两个状态。一个 7B 模型,权重 bf16 约 14GB,梯度 14GB,Adam 状态约 28GB,再加上激活和中间结果,全参微调 7B 要 60GB 以上显存,70B 直接破 600GB。这不是普通工程师玩得起的。

于是有了参数高效微调(PEFT)三件套:Adapter、Prefix Tuning、LoRA。其中 LoRA(Low-Rank Adaptation,微软 2021)因为"训练省显存、推理时可合并、能热插拔多任务"成为事实标准;QLoRA(2023)进一步把底座量化到 4bit,让 65B 模型在单张 48GB 消费卡上做微调成为可能。本文不重复上一篇多 Agent 编排(多 Agent 协作架构),专注把 LoRA/QLoRA 这条线讲透到能直接上手。

一、LoRA 的核心:低秩假设​

LoRA 的出发点是两个经验观察叠加。第一,模型适配下游任务时,权重的更新量 ΔW 是低秩的——虽然 W 是一个 d×d 的大矩阵,但 ΔW 可以用两个小矩阵的乘积来近似:

ΔW = B · A,其中 A 形状为 r×d,B 形状为 d×r,r 远小于 d。

前向计算变成:

h = W0·x + ΔW·x = W0·x + (α/r)·B·A·x

其中 W0 冻结不更新,只训练 A 和 B。A 用 kaiming 均匀初始化,B 初始化为全 0,这样训练开始那一刻 ΔW = 0,输出等价于原始模型,不会破坏预训练好的底座。

第二,为什么低秩假设成立?作者的解释是:预训练语言模型适配下游任务时,实际只需要一个很小的"内在维度"(intrinsic dimensionality)。更早的研究已经发现,把 GPT 权重投影到几百维的子空间里训练,也能达到不错的精度——LoRA 把这个经验事实工程化了。

可训练参数量的对比:以 GPT-3 175B 为例,只对 Wq、Wv 加 r=4 的 LoRA,可训练参数从 175B 降到几百万。论文给出的结论是可训练参数降低约 1 万倍、GPU 显存需求降低约 3 倍,而效果与全参微调持平甚至更好。

二、最小可运行实现​

用 PyTorch 手写一个 LoRA 线性层,理解背后数学比任何封装都重要:

import math
import torch
import torch.nn as nn

class LoRALinear(nn.Module):
"""把 nn.Linear 替换成带低秩旁路的版本,底座冻结。"""
def __init__(self, in_features, out_features, r=8, alpha=16, dropout=0.0):
super().__init__()
self.base = nn.Linear(in_features, out_features, bias=False)
self.base.weight.requires_grad_(False) # 冻结底座
self.lora_A = nn.Parameter(torch.empty(r, in_features))
self.lora_B = nn.Parameter(torch.zeros(out_features, r)) # B 必须 0 初始化
nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
self.scaling = alpha / r
self.dropout = nn.Dropout(dropout)

def forward(self, x):
base_out = self.base(x)
# (B, L, in) -> (B, L, r) -> (B, L, out)
lora_out = self.dropout(x) @ self.lora_A.T @ self.lora_B.T
return base_out + self.scaling * lora_out

三个关键细节:

  1. B 必须 0 初始化:保证第一步输出等于底座原输出,A 的 kaiming 初始化只负责提供梯度方向,训练启动时不会产生震荡。
  2. scaling = α/r:α 是缩放系数,常见取 α = 2r(即 scaling = 2)或 α = r(scaling = 1)。α 和 r 共同决定 ΔW 的幅度,调 LoRA 实际调的就是这个比值。
  3. 只训 A、B:反传时只有这两个小矩阵有梯度,底座权重直接 requires_grad_(False),优化器状态和梯度都只针对这几百万参数。

三、QLoRA:把底座压到 4bit​

LoRA 解决的是"优化器状态 + 梯度"的显存,但底座权重本身(7B 的 bf16 就要 14GB)加载进 GPU 依然很重。QLoRA 的思路很朴素:底座反正不训练,那为什么不用量化权重存? 用 4bit 存底座,前向时反量化到计算精度(bf16)再算,梯度只流向 LoRA 的 A、B。

QLoRA 有三个关键工程创新:

  1. NF4(NormalFloat4)量化:普通 int4 是均匀量化,但神经网络权重近似正态分布,均匀量化浪费了大量区间。NF4 是信息论上对正态分布数据最优的 4bit 格式,实测显著优于 int4 和 fp4。
  2. 双重量化(Double Quantization):量化会产生量化常数(scale 和 zero-point),这些常数若用 fp32 存也很占空间。双重量化把这些常数再做一次 8bit 量化,平均每个参数再省约 0.37 bit。
  3. 分页优化器(Paged Optimizer):借用 NVIDIA 统一内存技术,把优化器状态从 GPU 换页到 CPU 内存,显存峰值时自动切换,避免 OOM。

效果是教科书级的:65B 模型微调从"需要超过 780GB 显存"降到"单卡 48GB",Guanaco 65B 在 Vicuna benchmark 上达到 ChatGPT 的 99.3%,只用了 24 小时单卡微调。

四、生产级配置(peft + bitsandbytes)​

手写是为了理解原理,生产直接用 HuggingFace 生态。下面是一套能跑通的 QLoRA 微调配置:

import torch
from transformers import (AutoModelForCausalLM,
BitsAndBytesConfig)
from peft import (LoraConfig, get_peft_model,
prepare_model_for_kbit_training)

bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # 用 NF4 而不是 fp4/int4
bnb_4bit_compute_dtype=torch.bfloat16, # 反量化后的计算精度
bnb_4bit_use_double_quant=True, # 双重量化
)

model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
quantization_config=bnb,
device_map="auto",
)
model = prepare_model_for_kbit_training(model) # 关键:适配 4bit 底座训练

lora = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora)
model.print_trainable_parameters()
# trainable params: 41,943,040 || all params: 7,023,411,200 || trainable%: 0.6

7B 模型只有约 4200 万参数在训练,占比 0.6%。

五、生产部署:合并还是热插拔?​

训练完的 LoRA 有两条路进生产,选择取决于你的模型规模:

路线 A:merge 回底座(merge_and_unload)

merged = model.merge_and_unload() # ΔW 加到 W0 上,变回普通模型
merged.save_pretrained("./merged-7b")

优点:推理零额外开销,任何推理框架都能直接跑。缺点:失去"一个底座 + 多个 adapter"的复用性;而且如果底座是 4bit、merge 后变回 fp16,模型体积从 3.5GB 涨回 14GB。

路线 B:多 Adapter 热插拔(Multi-LoRA serving)

一个底座挂 N 个任务的 LoRA,不同请求路由到不同 adapter。核心思想是 S-LoRA / LoRAX / vLLM 的多 LoRA 支持:底座权重和 KV cache 全共享,只有每个 adapter 的小矩阵乘法是独立的。单个 GPU 上可以同时服务几十上百个定制模型,边际成本接近一个模型。

这条路线的坑在 batch 调度:同一个 batch 里混了不同 adapter,要么按 adapter 分组分别算投影,要么逐个算,都会破坏 kernel 效率;adapter 数量一多,切换开销会吃掉收益。实践中通常按 adapter 分桶,同 adapter 的请求合并成一个 batch。

六、踩坑记录(实操)​

  1. target_modules 别只挂 q、v:老教程只挂 q_proj + v_proj,那是 GPT-3 时代的结论。对 LLaMA 类模型,挂全 q/k/v/o 甚至加 MLP 的 gate/up/down,效果通常更好,参数量也就多几千万,不值一提。RoPE(旋转位置编码)别挂——它不是线性层。

  2. 无 BF16 硬件的卡上 fp16 会溢出:V100、T4 没有 BF16 指令。如果 bnb_4bit_compute_dtype 用 bf16 而卡不支持,反量化后的计算会退化甚至溢出。T4 上要么用 fp32 compute(慢但稳),要么换卡。这是 QLoRA 在旧卡上最常见的失败点。

  3. 长样本被截断,loss 算到 padding 上:数据里 prompt 长短不一,如果用固定 max_length 直接截断且没正确处理 attention mask,loss 会算到 padding token 上,训练发散。要么用 packing(多个样本拼到一条序列,用 mask 区分),要么确保 labels 里 padding 位置置 -100。

  4. r 不是越大越好:r=4 到 16 对大多数任务够用。r 拉大不仅显存涨,小数据量下更容易过拟合。真正要调的是 α/r 这个比值,而不是 r 本身。

  5. merge 后别再用 adapter 推理:merge 后模型里已经带上了 ΔW,再挂原 adapter 会双重叠加,输出直接坏掉。要么 merge、要么热插拔,二选一。

  6. 梯度检查点是标配:4bit 底座 + LoRA 已经省了优化器和权重显存,激活显存成为新的瓶颈。gradient_checkpointing=True 用计算换显存,长序列训练几乎必开。

结语​

LoRA/QLoRA 的本质是抓住两个事实:模型适配的更新量是低秩的、底座在前向中不需要高精度存储。前者省优化器和梯度,后者省权重,合起来把"微调一个 65B 模型"从 8 卡 A100 的任务变成单张 48GB 消费卡的任务。对后端工程师来说,理解这套机制后,剩下的就是在数据质量和评测闭环上花时间——那才是真正决定效果的地方。