LoRA 与 QLoRA 微调:用 6GB 显驯服大模型


文档摘要

LoRA 与 QLoRA 微调:用 6GB 显驯服大模型 本节摘要:全参数微调一个 7B 模型要 56GB 显存,你多半没有,多数公司也没有。LoRA(低秩适应,Low-Rank Adaptation)让你只训练不到 1% 的参数,在 6GB 显存里微调同一个模型,而且不是妥协——多数任务上它逼近全微调质量。整个开源微调生态都跑在这一招上。本节带你吃透 LoRA 的数学:冻结原权重 W,加一个低秩分解 BA,把 4096×4096 矩阵的 1677 万参数压成 13 万(0.

LoRA 与 QLoRA 微调:用 6GB 显驯服大模型

本节摘要:全参数微调一个 7B 模型要 56GB 显存,你多半没有,多数公司也没有。LoRA(低秩适应,Low-Rank Adaptation)让你只训练不到 1% 的参数,在 6GB 显存里微调同一个模型,而且不是妥协——多数任务上它逼近全微调质量。整个开源微调生态都跑在这一招上。本节带你吃透 LoRA 的数学:冻结原权重 W,加一个低秩分解 BA,把 4096×4096 矩阵的 1677 万参数压成 13 万(0.78%);缩放因子 alpha、该挂在哪些层(q_proj+v_proj 是甜蜜区)、秩 r 怎么选(8~16 最常用);QLoRA 再用 NF4 4 比特量化底座,让 7B 微调塞进单张消费级显卡;以及训练后是保留适配器多任务热切换,还是合并回底座零开销部署。最后用纯 PyTorch 从零实现 LoRA 层、注入、训练、合并。

对应原课程:Phase 11 · Lesson 08 · fine-tuning-lora(原英文 phases/11-llm-engineering/08-fine-tuning-lora/docs/en.md)。

学习目标

阅读完本节,你应当能够:

  1. 通过注入低秩适配矩阵(A、B)到预训练模型注意力层,从零实现 LoRA
  2. 计算 LoRA 相对全微调的参数节省:秩 r 配 d_model 维度,训练 2·r·d 个参数而非 d²。
  3. QLoRA(4 比特量化底座 + LoRA 适配器)把微调塞进消费级 GPU 显存。
  4. 把 LoRA 权重合并回底座用于部署,并对比有无适配器的推理速度。

一、问题与直觉

你有个底座模型 Llama 3 8B,想让它用公司的口吻回复客服工单。SFT(监督微调)是答案,但 SFT 有个成本问题。

全参数微调更新模型里每一个参数。Llama 3 8B 有 80 亿参数,fp16 下每个参数 2 字节,光加载权重就要 16GB。训练时还要梯度(16GB)、Adam 优化器状态(动量+方差 32GB)、激活值。合计:一个 8B 模型大约要 56GB 显存

一张 A100 80GB 勉强能塞。云上两张 A100 每小时 34 美元,5 万样本跑 3 个 epoch 要 610 小时,单次实验 30~40 美元。跑 10 次实验调超参,部署前已经烧掉 400 美元。放到 Llama 3 70B,光权重就 140GB,要集群,单次实验 100 美元起。

还有更深的问题:全微调改每一个权重,在客服数据上微调可能损害模型通用能力——这叫灾难性遗忘(catastrophic forgetting),模型变擅长你的任务,变差于其他一切。

你需要一种方法:训练更少参数、用更少显存、不破坏模型已有知识。

LoRA:低秩适应

Edward Hu 等人 2021 年 6 月在微软发表 LoRA。论文洞见:微调时的权重更新具有低本征秩。你不需要更新一个 4096×4096 权重矩阵里全部 1677 万个参数,更新里的有用信息可以被一个秩 16 或 32 的矩阵捕捉。

数学上,标准线性层计算:

y = Wx

W 是 d_out × d_in 矩阵。对 4096×4096 的注意力投影,那是 16777216 个参数。

LoRA 冻结 W,加一个低秩分解:

y = Wx + BAx

B 是 (d_out × r),A 是 (r × d_in),秩 r 远小于 d——通常 8、16 或 32。

对 4096×4096 层用 r=16:

  • 原始参数:4096 × 4096 = 16777216
  • LoRA 参数:(4096 × 16) + (16 × 4096) = 65536 + 65536 = 131072
  • 压缩比:131072 / 16777216 = 0.78%

你训练 0.78% 的参数,拿到 95~100% 的质量。

A 用随机高斯初始化,B 初始化为零。这意味着 LoRA 贡献从零开始——模型从原始行为起步,逐步学到适应。

缩放因子 Alpha

LoRA 引入缩放因子 alpha,控制低秩更新对输出的影响:

y = Wx + (alpha / r) * BAx

alpha = r 时缩放 1 倍,alpha = 2r(常见默认)时缩放 2 倍。这个超参独立于基础学习率,控制 LoRA 路径的学习速率。

  • alpha = 2·rank 是社区常见约定(原论文多数实验用 alpha = rank)。
  • alpha = rank 给 1 倍缩放,保守但稳定。
  • alpha 越大,每步更新越大,可能加速收敛也可能引起不稳。

该把 LoRA 挂在哪些层

Transformer 有很多线性层,不必都挂。原论文测过不同组合:

目标层 可训练参数(7B) 质量
仅 q_proj 4.7M
q_proj + v_proj 9.4M 更好
q_proj + k_proj + v_proj + o_proj 18.9M 注意力最佳
所有线性层(注意力 + MLP) 37.7M 边际提升,参数翻倍

多数任务的甜蜜区:q_proj + v_proj。这指向自注意力的查询投影与值投影——控制模型关注什么、提取什么信息。加 MLP 层对代码生成这类复杂任务有帮助,但对简单任务参数翻倍、收益递减。

秩的选择

秩 r 控制适应的表达力:

每层可训练参数 最适合
4 32768 简单分类、情感
8 65536 单域问答、摘要
16 131072 多域任务、指令遵循
32 262144 复杂推理、代码生成
64 524288 多数任务收益递减
128 1048576 极少有正当理由

Hu 等人证明 r=4 已能捕捉简单任务的大部分适应。**r=8 和 r=16 是实践里最常见的**。超过 r=64 很少提升质量,反而开始丢掉 LoRA 的显存优势。

QLoRA:4 比特量化 + LoRA

Tim Dettmers 等人 2023 年 5 月在华盛顿大学发表 QLoRA。思路:把冻结的底座量化到 4 比特精度,再在上面挂 fp16 的 LoRA 适配器。

这彻底改写了显存账本:

方法 权重显存(7B) 训练显存(7B) 所需 GPU
全微调(fp16) 14GB ~56GB 1×A100 80GB
LoRA(fp16 底座) 14GB ~18GB 1×A100 40GB
QLoRA(4 比特底座) 3.5GB ~6GB 1×RTX 3090 24GB

QLoRA 有三项技术贡献:

NF4(Normal Float 4 比特):专为神经网络权重设计的新数据类型。神经网络权重大致服从正态分布,NF4 把它的 16 个量化级放在标准正态分布的分位数上,这对正态分布数据是信息论最优的,比均匀 4 比特(INT4)或标准 Float4 丢更少信息。

双重量化:量化常数本身也占显存。每 64 个权重一块,需要 fp32 缩放因子(4 字节),7B 模型合计多 0.4GB。双重量化把这些常数再量化到 fp8,开销降到 0.1GB。小,但积少成多。

分页优化器:训练时优化器状态(Adam 的动量与方差)在长序列上可能超 GPU 显存。分页优化器用 NVIDIA 统一内存,在显存耗尽时自动把优化器状态换页到 CPU 内存,需要时再换回,以一定吞吐为代价防止 OOM 崩溃。

质量问题

减参数或量化底座会损害质量吗?多篇论文结果:

方法 MMLU(5-shot) MT-Bench HumanEval
全微调(Llama 2 7B) 48.3 6.72 14.6
LoRA r=16 47.9 6.68 14.0
QLoRA r=16(NF4) 47.5 6.61 13.4
QLoRA r=64(NF4) 48.1 6.70 14.2

r=16 的 LoRA 在多数基准上与全微调差距在 1% 以内;r=16 的 QLoRA 再掉零点几个百分点;r=64 的 QLoRA **基本追平全微调,同时省 90% 显存**。

真实成本

在 5 万样本上对 Llama 3 8B 微调(3 epoch):

方法 GPU 时间 成本
全微调 2×A100 80GB 8 小时 ~32 美元
LoRA r=16 1×A100 40GB 4 小时 ~8 美元
QLoRA r=16 1×RTX 4090 24GB 6 小时 ~5 美元
QLoRA r=16(Unsloth) 1×RTX 4090 24GB 2.5 小时 ~2 美元
QLoRA r=16 1×T4 16GB 12 小时 ~4 美元

单张消费级显卡上跑 QLoRA 比一顿午饭还便宜。这就是 2023 年开源微调社区爆发的原因,也是 2026 年每个训练框架默认带 QLoRA 的原因。

2026 PEFT 工具栈

框架 是什么 何时选
Hugging Face PEFT LoRA/QLoRA/DoRA/IA3 的规范库 要原始控制,训练循环已在 transformers.Trainer
TRL HF 的反馈训练器(SFT、DPO、GRPO、PPO、ORPO) SFT 后要做 DPO/GRPO;构建在 PEFT 之上
Unsloth 用 Triton 内核重写前向/反向 要 2~5 倍加速 + 减半显存且不损精度;Llama/Mistral/Qwen 家族
Axolotl PEFT+TRL+DeepSpeed+Unsloth 的 YAML 配置封装 要可复现、版本化的训练运行
LLaMA-Factory PEFT+TRL 的 GUI/CLI/API 封装 要零代码微调;支持 100+ 模型家族
torchtune 原生 PyTorch 配方,不依赖 transformers 要最小依赖,组织已标准化在 PyTorch

经验法则:研究或一次性实验 → PEFT;可复现的生产管线 → Axolotl 配 Unsloth 内核;丢弃式原型 → LLaMA-Factory。

合并适配器

训练后你有两样东西:冻结的底座、一个小的 LoRA 适配器(通常 10~100MB)。两条路:

  1. 保持分离:加载底座,在其上加载适配器。不同任务切换适配器——这就是如何从一个底座服务多个微调变体。

  2. 永久合并:计算 W' = W + (alpha/r)·BA,存成新的完整模型。合并后模型大小与原始相同,无推理开销,无适配器要管。

要服务多任务(客服适配器、代码适配器、翻译适配器),保持分离;要部署单个专用模型,合并。

进阶合并多个适配器的技术:

  • TIES-Merging(Yadav 等 2023):修剪小幅度参数、解决符号冲突、再合并,减少适配器间干扰。
  • DARE(Yu 等 2023):合并前随机丢弃适配器参数并重缩放其余,组合能力出奇地有效。
  • 任务算术:直接加减适配器权重。加一个「代码」适配器和一个「数学」适配器,常得到两者都擅长的模型。

什么时候不要微调

微调是第三选择,不是第一选择。

第一:提示工程。 写更好的系统提示,加少样本示例,用思维链。零成本、几分钟。如果提示能拿到 80 分,你大概不需要微调。

第二:RAG。 模型需要知道你的特定数据(文档、知识库、产品目录)时,检索比烘焙进权重更便宜、更好维护(见第 06 节)。

第三:微调。 用在:需要模型习得提示做不到的特定风格/格式/推理模式;需要一致的结构化输出;需要把大模型蒸馏进小模型;延迟敏感、付不起少样本的额外 token 时。

二、从零实现

我们用纯 PyTorch 从零实现 LoRA,不用库、不用魔法。你将构建 LoRA 层、注入模型、训练、合并权重。

步骤 1:LoRA 层

import torch import torch.nn as nn import math class LoRALayer(nn.Module): def __init__(self, in_features, out_features, rank=8, alpha=16): super().__init__() self.rank = rank self.alpha = alpha self.scaling = alpha / rank # A 用缩放随机值初始化,B 初始化为零 → BA 起点为零 self.A = nn.Parameter(torch.randn(in_features, rank) * (1 / math.sqrt(rank))) self.B = nn.Parameter(torch.zeros(rank, out_features)) def forward(self, x): return (x @ self.A @ self.B) * self.scaling

A 用缩放随机值,B 初始化为零,乘积 BA 从零起步,模型从原始行为开始训练。

步骤 2:LoRA 包装的线性层

class LinearWithLoRA(nn.Module): def __init__(self, linear, rank=8, alpha=16): super().__init__() self.linear = linear self.lora = LoRALayer(linear.in_features, linear.out_features, rank, alpha) for p in self.linear.parameters(): p.requires_grad = False # 冻结原线性层 def forward(self, x): return self.linear(x) + self.lora(x)

原线性层冻结,只有 LoRA 的 A、B 可训练。

步骤 3:把 LoRA 注入模型

def inject_lora(model, target_modules, rank=8, alpha=16): # 先冻结所有参数 for p in model.parameters(): p.requires_grad = False lora_layers = {} for name, module in model.named_modules(): if isinstance(module, nn.Linear) and any(t in name for t in target_modules): parent_name = ".".join(name.split(".")[:-1]) child_name = name.split(".")[-1] parent = dict(model.named_modules())[parent_name] lora_linear = LinearWithLoRA(module, rank, alpha) setattr(parent, child_name, lora_linear) # 替换原层 lora_layers[name] = lora_linear return lora_layers

先冻结全部参数,再遍历模型树,找到名字匹配目标的线性层,替换成 LoRA 包装版。整个模型里只有 LoRA 的 A、B 可训练。

步骤 4:数参数

def count_parameters(model): total = sum(p.numel() for p in model.parameters()) trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) return { "total": total, "trainable": trainable, "frozen": total - trainable, "trainable_pct": 100 * trainable / total if total > 0 else 0, }

步骤 5:合并权重回底座

def merge_lora_weights(model): for name, module in model.named_modules(): if isinstance(module, LinearWithLoRA): with torch.no_grad(): merged = (module.lora.A @ module.lora.B) * module.lora.scaling module.linear.weight.data += merged.T # W' = W + (alpha/r)·BA parent_name = ".".join(name.split(".")[:-1]) child_name = name.split(".")[-1] parent = dict(model.named_modules())[parent_name] if parent_name else model setattr(parent, child_name, module.linear) # 换回普通线性层

合并后 LoRA 层消失,模型大小与原始相同,适应烘焙进权重,无推理开销。

步骤 6:模拟 QLoRA 量化

def quantize_to_nf4(tensor, block_size=64): blocks = tensor.reshape(-1, block_size) scales = blocks.abs().max(dim=1, keepdim=True).values / 7.0 scales = torch.clamp(scales, min=1e-8) quantized = torch.round(blocks / scales).clamp(-8, 7).to(torch.int8) # 模拟 16 级 return quantized, scales def dequantize_from_nf4(quantized, scales, original_shape): return (quantized.float() * scales).reshape(original_shape)

这模拟 4 比特量化:把权重按 64 一块映射到 16 个离散级。生产 QLoRA 用 bitsandbytes 库在 GPU 上做真正的 NF4。

步骤 7:训练循环

def train_lora(model, data, epochs=5, lr=1e-3, batch_size=4): optimizer = torch.optim.AdamW( [p for p in model.parameters() if p.requires_grad], lr=lr) criterion = nn.MSELoss() losses = [] for epoch in range(epochs): epoch_loss, n = 0.0, 0 idx = torch.randperm(len(data["inputs"])) for i in range(0, len(idx), batch_size): b = idx[i:i+batch_size] x, y = data["inputs"][b], data["targets"][b] out = model(x) loss = criterion(out, y) optimizer.zero_grad(); loss.backward(); optimizer.step() epoch_loss += loss.item(); n += 1 losses.append(epoch_loss / n) return losses

注意优化器只收 requires_grad=True 的参数——也就是 LoRA 的 A、B,底座不参与更新。

💡 设计要点:inject_lora + merge_lora_weights 是一对可逆操作。训练时挂上适配器省显存,部署时合并回去零开销。这正是 LoRA「训练省、部署也省」的两全之美。

三、框架对比

用 Hugging Face 生态,LoRA 在真实模型上大约 20 行:

# from transformers import AutoModelForCausalLM, AutoTokenizer # from peft import LoraConfig, get_peft_model, TaskType # model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B") # tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B") # lora_config = LoraConfig( # task_type=TaskType.CAUSAL_LM, r=16, lora_alpha=32, # lora_dropout=0.05, target_modules=["q_proj","v_proj"]) # model = get_peft_model(model, lora_config) # model.print_trainable_parameters() # 打印 "trainable: 9.4M || all 8B || 0.12%"

QLoRA 加上 bitsandbytes 量化:

# from transformers import BitsAndBytesConfig # bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", # bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True) # model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B", # quantization_config=bnb, device_map="auto") # model = get_peft_model(model, lora_config)

训练循环、数据管线不变,底座住进 4 比特,LoRA 适配器以 fp16 训练,整套塞进 6GB。

用 HF Trainer 跑 SFT:

# from transformers import TrainingArguments, Trainer # from datasets import load_dataset # dataset = load_dataset("tatsu-lab/alpaca", split="train[:5000]") # args = TrainingArguments(output_dir="./lora-llama", num_train_epochs=3, # per_device_train_batch_size=4, gradient_accumulation_steps=4, # learning_rate=2e-4, fp16=True, logging_steps=10, # save_strategy="epoch", optim="paged_adamw_8bit") # trainer = Trainer(model=model, args=args, train_dataset=dataset) # trainer.train() # model.save_pretrained("./lora-adapter") # 只存 10~100MB 适配器

存的适配器只有 10~100MB,底座原封不动,可在 HF Hub 分享适配器而不重分发完整模型。Unsloth 在此基础上用融合内核把吞吐翻倍、显存减半;Axolotl 用 YAML 把这套封装成可复现的配置;本节的从零实现则让你看清「注入-冻结-训练-合并」每一步在做什么——框架把这些细节藏起来,但你需要知道旋钮在哪。

四、可复用产物

本节产出两个可复用文件(位于原课程 outputs/):

  • prompt-lora-advisor.md:一个元提示,为你的具体任务决定 LoRA 秩、目标模块、超参。喂给它任务类型、数据量、显存预算,它给出 r、alpha、target_modules、学习率的建议。
  • skill-fine-tuning-guide.md:一个技能文件,教 Agent 何时以及如何微调的决策树。

Python 代码(code/lora_from_scratch.py)是独立的从零实现,把 demo 里的玩具模型换成真实 transformers 模型、用 PEFT 配 QLoRA,即可投产。

五、练习

  1. 秩消融实验:用秩 2、4、8、16、32、64 跑 demo,画出最终损失 vs 秩,找出收益递减点(损失不再随秩翻倍而减半)。对 256 维特征的简单分类,这通常在 r=8~16。

  2. 目标模块对比:修改 inject_lora 只挂第 0 层、只挂第 2 层、只挂第 4 层、三层都挂,各训 20 epoch,对比收敛速度与最终损失。这对应实战中选 q_proj 还是 v_proj 还是全线性层的决策。

  3. 量化误差分析:取训练后模型的权重矩阵,过 quantize_to_nf4/dequantize_from_nf4,计算均方误差、最大绝对误差、原始与重建权重的相关性。试 block_size 为 32、64、128、256。

  4. 多适配器服务:在数据不同子集(偶数索引 vs 奇数索引)上训两个 LoRA 适配器,都保存。底座加载一次,切换适配器验证同一输入产出不同输出——这就是生产里从一个底座服务多个微调模型。

  5. 合并 vs 未合并推理:对比合并前后 LoRA 模型在同样 100 个输入上的输出,验证一致(浮点容差 1e-5 内)。再基准测试推理速度——合并后应略快,因为是单次矩阵乘而非两次。

本节要点回顾

  1. 全微调太贵:7B 模型要 56GB 显存,改每个权重还导致灾难性遗忘。
  2. LoRA 是低秩分解:冻结 W,加 BA,r=16 时只训练 0.78% 参数,质量逼近全微调。
  3. A 随机、B 为零:BA 从零起步,模型从原始行为开始逐步适应。
  4. alpha/r 控制缩放:alpha=2·rank 是社区默认,alpha=rank 保守稳定。
  5. 挂哪层有讲究:q_proj+v_proj 是甜蜜区,全线性层只在复杂任务边际提升。
  6. 秩 8~16 最常用:简单任务 r=4 够,代码生成 r=32,超 r=64 多数任务收益递减。
  7. QLoRA = NF4 底座 + fp16 适配器:7B 微调塞进 6GB,NF4 按正态分位数放置量化级,信息论最优。
  8. 三项 QLoRA 技术:NF4、双重量化(常数再量化到 fp8)、分页优化器(状态换页到 CPU 防 OOM)。
  9. 合并 vs 分离:多任务热切换保持分离,单专用模型部署合并,合并后零推理开销。
  10. 微调是第三选择:先提示工程(零成本),再 RAG(知识接地),最后 LoRA/QLoRA(风格/格式/推理模式)。

下一节,我们转向函数调用与工具使用——让模型不再只是「说」,而是能调你的 API、查你的数据库、执行真实动作,这是 Agent 时代的地基。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U