LoRA 与 QLoRA 微调:用 6GB 显驯服大模型 本节摘要:全参数微调一个 7B 模型要 56GB 显存,你多半没有,多数公司也没有。LoRA(低秩适应,Low-Rank Adaptation)让你只训练不到 1% 的参数,在 6GB 显存里微调同一个模型,而且不是妥协——多数任务上它逼近全微调质量。整个开源微调生态都跑在这一招上。本节带你吃透 LoRA 的数学:冻结原权重 W,加一个低秩分解 BA,把 4096×4096 矩阵的 1677 万参数压成 13 万(0.
本节摘要:全参数微调一个 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)。
阅读完本节,你应当能够:
你有个底座模型 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),模型变擅长你的任务,变差于其他一切。
你需要一种方法:训练更少参数、用更少显存、不破坏模型已有知识。
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:
你训练 0.78% 的参数,拿到 95~100% 的质量。
A 用随机高斯初始化,B 初始化为零。这意味着 LoRA 贡献从零开始——模型从原始行为起步,逐步学到适应。
LoRA 引入缩放因子 alpha,控制低秩更新对输出的影响:
y = Wx + (alpha / r) * BAx
alpha = r 时缩放 1 倍,alpha = 2r(常见默认)时缩放 2 倍。这个超参独立于基础学习率,控制 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 的显存优势。
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 的原因。
| 框架 | 是什么 | 何时选 |
|---|---|---|
| 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)。两条路:
保持分离:加载底座,在其上加载适配器。不同任务切换适配器——这就是如何从一个底座服务多个微调变体。
永久合并:计算 W' = W + (alpha/r)·BA,存成新的完整模型。合并后模型大小与原始相同,无推理开销,无适配器要管。
要服务多任务(客服适配器、代码适配器、翻译适配器),保持分离;要部署单个专用模型,合并。
进阶合并多个适配器的技术:
微调是第三选择,不是第一选择。
第一:提示工程。 写更好的系统提示,加少样本示例,用思维链。零成本、几分钟。如果提示能拿到 80 分,你大概不需要微调。
第二:RAG。 模型需要知道你的特定数据(文档、知识库、产品目录)时,检索比烘焙进权重更便宜、更好维护(见第 06 节)。
第三:微调。 用在:需要模型习得提示做不到的特定风格/格式/推理模式;需要一致的结构化输出;需要把大模型蒸馏进小模型;延迟敏感、付不起少样本的额外 token 时。
我们用纯 PyTorch 从零实现 LoRA,不用库、不用魔法。你将构建 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 从零起步,模型从原始行为开始训练。
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 可训练。
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 可训练。
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, }
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 层消失,模型大小与原始相同,适应烘焙进权重,无推理开销。
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。
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,即可投产。
秩消融实验:用秩 2、4、8、16、32、64 跑 demo,画出最终损失 vs 秩,找出收益递减点(损失不再随秩翻倍而减半)。对 256 维特征的简单分类,这通常在 r=8~16。
目标模块对比:修改 inject_lora 只挂第 0 层、只挂第 2 层、只挂第 4 层、三层都挂,各训 20 epoch,对比收敛速度与最终损失。这对应实战中选 q_proj 还是 v_proj 还是全线性层的决策。
量化误差分析:取训练后模型的权重矩阵,过 quantize_to_nf4/dequantize_from_nf4,计算均方误差、最大绝对误差、原始与重建权重的相关性。试 block_size 为 32、64、128、256。
多适配器服务:在数据不同子集(偶数索引 vs 奇数索引)上训两个 LoRA 适配器,都保存。底座加载一次,切换适配器验证同一输入产出不同输出——这就是生产里从一个底座服务多个微调模型。
合并 vs 未合并推理:对比合并前后 LoRA 模型在同样 100 个输入上的输出,验证一致(浮点容差 1e-5 内)。再基准测试推理速度——合并后应略快,因为是单次矩阵乘而非两次。
下一节,我们转向函数调用与工具使用——让模型不再只是「说」,而是能调你的 API、查你的数据库、执行真实动作,这是 Agent 时代的地基。