【训练与微调篇04】LoRA与QLoRA原理实战:单卡微调大模型的全套方案
🪡 LoRA与QLoRA原理实战:单卡微调大模型的全套方案
2024 年,微调 70B 模型需要 16 张 A100,花费 $100,000+。2026 年,一张 RTX 5090 就能 QLoRA 微调 70B,一天搞定,成本不到 $50。这场革命的背后,是 LoRA、QLoRA、DoRA 等一系列 PEFT 技术的成熟。
📑 目录
从全量到高效:PEFT的进化
LoRA核心原理:低秩分解的数学之美
QLoRA:量化+LoRA的双重降维打击
DoRA:权重分解学习
2026年LoRA变体全家桶
LoRA地雷阵:常见误区与解决方案
实战:用LoRA微调7B模型(单卡)
实战:用QLoRA微调70B模型(单卡)
实战:多LoRA融合与切换
LoRA的数学框架与选择指南
总结
一、从全量到高效:PEFT的进化
1.1 为什么需要 PEFT?
全量微调的问题:
一卡装不下 → 分布式昂贵 → 成本高、迭代慢
7B 模型全量微调: 8×A100 (80GB) = ~$30,000/月
70B 模型全量微调: 32×A100 (80GB) = ~$120,000/月
PEFT 的核心思路:冻结 99%+ 的原始权重,只训练少量新增参数。
年份 里程碑 可训练参数 对比全量
2021 Adapter 3-5% ~20x 节省
2021 Prefix Tuning 0.1% ~100x
2021 LoRA (原始) 0.1-1% 100-1000x
2023 QLoRA 0.1-1% 1000x+ (量化)
2024 DoRA 0.1-1% 100-1000x (更好效果)
2025 LoRA-XS, LoRA+, rsLoRA 0.01-1% 优化收敛
1.2 2026年各方法显存对比
方法 7B模型 14B模型 70B模型 单卡可行?
全量微调 FP16 112GB 210GB 1TB+ ❌
LoRA (r=16) 28GB 52GB 160GB ⚠️ 70B需多卡
QLoRA (4-bit + LoRA) 7GB 14GB 48GB ✅ 全部单卡
DoRA (r=16) 28GB 52GB 160GB ⚠️ 同LoRA
QDoRA (4-bit) 7GB 14GB 48GB ✅
GaLore 12GB 22GB 70GB ✅
关键数据:2026 年,一张 RTX 5090(48GB)× QLoRA 就可以微调 70B 模型 [1]。这在两年前需要 16 张 A100。
二、LoRA核心原理:低秩分解的数学之美
2.1 核心洞察
LoRA(Low-Rank Adaptation)基于一个重要的发现 [2]:
预训练模型的本征维度(Intrinsic Dimension)很低。也就是说,模型虽然有几亿个参数,但真正有效的变化只需要一个低维子空间。
全量微调: W’ = W + ΔW ← ΔW 是一个满秩矩阵,参数量 ≈ d×k
LoRA: W’ = W + B·A ← B·A 是低秩分解,参数量 = d×r + r×k
其中:
W: d×k 原始权重(冻结)
B: d×r 低秩矩阵(可训练)
A: r×k 低秩矩阵(可训练)
r: 秩(通常 r=8~64, r << min(d,k))
2.2 参数量节省
假设 Transformer 的 hidden_dim = 4096 (7B模型):
全量微调 QKV 投影:W_q = 4096×4096 → 16.8M 参数 ← 全量更新
LoRA (r=16):B=4096×16, A=16×4096 → 131K 参数 ← 仅 0.78%
总参数量对比(7B模型):
全量微调: 6.7B 参数 ← 全部更新
LoRA: 6.7B (冻结) + 42M (可训练) = 0.15% 可训练
QLoRA: 6.7B (4-bit量化冻结) + 42M (可训练) = 0.15% 可训练,显存减半!
2.3 数学推导
“”“LoRA 的前向传播与梯度”“”
import torch
import torch.nn as nn
import math
class LoRALayer(nn.Module):
“”"
LoRA 低秩适配层
W’ = W + B·A
前向: y = x·W^T + (x·A^T)·B^T
"""
def __init__(self, in_features: int, out_features: int, rank: int = 8, alpha: int = 16):
super().__init__()
# 原始权重(冻结)
self.register_buffer('weight', torch.randn(out_features, in_features))
# LoRA 低秩矩阵(可训练)
self.lora_A = nn.Parameter(torch.randn(rank, in_features) * 0.01)
self.lora_B = nn.Parameter(torch.zeros(out_features, rank))
self.scaling = alpha / rank # 缩放因子
self.rank = rank
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
x: (batch, seq_len, in_features)
返回: (batch, seq_len, out_features)
"""
# 原始路径(冻结)
base_out = nn.functional.linear(x, self.weight)
# LoRA 路径(可训练 + 缩放)
# y = x·W^T + (x·A^T)·B^T · α/r
lora_out = (x @ self.lora_A.T) @ self.lora_B.T * self.scaling
return base_out + lora_out
class LoRALinear(nn.Module):
“”“替换 nn.Linear 的 LoRA 版本”“”
def init(self, original_linear: nn.Linear, rank: int = 8, alpha: int = 16):
super().init()
# 冻结原始权重
self.weight = original_linear.weight
self.bias = original_linear.bias
self.weight.requires_grad = False
if self.bias is not None:
self.bias.requires_grad = False
self.in_features = original_linear.in_features
self.out_features = original_linear.out_features
# LoRA A/B
self.lora_A = nn.Parameter(
torch.randn(rank, self.in_features) * 0.01
)
self.lora_B = nn.Parameter(
torch.zeros(self.out_features, rank)
)
self.scaling = alpha / rank
def forward(self, x: torch.Tensor) -> torch.Tensor:
result = nn.functional.linear(x, self.weight, self.bias)
result += (x @ self.lora_A.T) @ self.lora_B.T * self.scaling
return result
2.4 LoRA 的梯度行为
LoRA 的梯度有一个有趣的特点:
Loss 对 lora_B 的梯度: ∂L/∂B = (∂L/∂y) · (x·AT)T
Loss 对 lora_A 的梯度: ∂L/∂A = B^T · (∂L/∂y) · x
关键:
- lora_A 接收从 lora_B 传回的梯度(受 B 影响)
- lora_B 直接从输出误差学习
- B 的初始化=0 → 初始时 LoRA 路径为 0 → 不影响原始输出
为什么 B 初始化为 0?
答:确保一开始 LoRA 不影响模型输出,训练过程是"平滑介入"的。
如果 A、B 都随机初始化,第一轮训练就会偏离原始模型太远,loss 会飙升。
2.5 秩 r 的选择
r 的选择对 LoRA 效果的影响:
r → 表达能力 ↑ 可训练参数 ↑ 过拟合风险 ↑
r → 表达能力 ↓ 可训练参数 ↓ 欠拟合风险 ↑
经验法则:
Q/K/V 层: r=8~16 (注意力头可以大一点)
O 投影: r=8 (IO 少一点)
FFN 层: r=16~32 (FFN 需要更多容量)
2026 最佳实践 [3]:
通用 SFT: r=16
领域适配: r=32~64
多任务: r=8(防止任务间干扰)
💡 面试加分点:秩 r 不等于"表达能力"。r=8 时 ΔW = B·A 的秩最多为 8,但 B·A 的"有效维度"比满秩矩阵小。这其实是 LoRA 的正则化效果——限制模型的变化空间,反而在数据量小时泛化更好。
三、QLoRA:量化+LoRA的双重降维打击
3.1 QLoRA 的架构
QLoRA = 4-bit NormalFloat (NF4) 量化 + LoRA + 双重量化 + 分页优化器 [4]
QLoRA 的组成(以微调 70B 为例):
- 原始模型 → NF4 量化(4-bit)
70B × 0.5 bytes = 35GB ← 比 FP16 节省 75% 显存
- LoRA 适配器(保留 FP16/BF16)
42M × 2 bytes = 84MB ← 极小的显存增量
- 双重量化(Double Quantization)
对量化常数再做一次量化 → 额外节省 ~0.5 bits/param
- 分页优化器(Paged Optimizer)
优化器状态溢出时自动卸载到 CPU → 避免 OOM
总计:~35GB + 0.1GB LoRA + 少量激活 ≈ 48GB
→ 一张 RTX 5090 (48GB) 刚好!✅
3.2 NF4 量化详解
QLoRA 的核心创新是 NF4(NormalFloat4),一种信息论最优的 4-bit 数据类型:
“”“NF4 量化:NormalFloat 的实现”“”
import torch
import numpy as np
class NF4Quantizer:
“”"
NormalFloat4 量化
针对 0-1 正态分布的统计最优量化
“”"
# 预计算的 NF4 量化级别(正态分布的分位数)
# 将 N(0,1) 的 CDF 等分为 16 个区间
NF4_LEVELS = torch.tensor([
-1.0, -0.6961928009986877, -0.5250730514526367,
-0.39491748809814453, -0.28444138169288635,
-0.18477343022823334, -0.09105003625154495,
0.0, 0.07958029955625534, 0.16093020141124725,
0.24621161818504333, 0.33791524171829224,
0.44067683815956116, 0.5626170039176941,
0.7229568362236023, 1.0
])
@staticmethod
def quantize(tensor: torch.Tensor) -> torch.Tensor:
"""
将 FP32/FP16 张量量化为 NF4
返回: int8 张量(每个元素存 2 个 NF4 值)
"""
# 1. 缩放到 [-1, 1]
absmax = tensor.abs().max()
scaled = tensor / absmax # 现在在 [-1, 1]
# 2. 找到最接近的 NF4 级别
# 展平 + 二分查找
flat = scaled.view(-1)
indices = torch.bucketize(flat, NF4Quantizer.NF4_LEVELS) - 1
indices = indices.clamp(0, 15).to(torch.uint8)
# 3. 打包两个 4-bit 到一个 byte
packed = indices[::2] | (indices[1::2] << 4)
return packed, absmax # 返回量化值和缩放因子
@staticmethod
def dequantize(packed: torch.Tensor, absmax: float, shape: tuple) -> torch.Tensor:
"""反量化"""
# 解包
low = packed & 0x0F
high = (packed >> 4) & 0x0F
indices = torch.stack([low, high], dim=-1).flatten()
indices = indices[:np.prod(shape)]
# 映射到实际的 NF4 级别
values = NF4Quantizer.NF4_LEVELS[indices.long()]
# 缩放回原始范围
return values.reshape(shape) * absmax
===== 使用 BitsAndBytes 加载 4-bit 模型 =====
from transformers import BitsAndBytesConfig
def load_4bit_model(model_name: str):
“”“加载 4-bit 量化模型”“”
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16, # 计算用 BF16
bnb_4bit_use_double_quant=True, # 双重量化
bnb_4bit_quant_type="nf4", # NF4 类型
)
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=quantization_config,
device_map="auto", # 自动分配
trust_remote_code=True,
)
return model
3.3 QLoRA 的显存魔法
QLoRA vs LoRA vs Full Fine-tune 的显存对比(70B 模型):
Full Fine-tune LoRA (FP16) QLoRA (NF4)
参数: 140 GB 140 GB 35 GB
LoRA 适配器: - 84 MB 84 MB
优化器状态: 280 GB (Adam) 84 MB 84 MB
梯度: 140 GB 84 MB 84 MB
激活: ~20 GB ~20 GB ~12 GB
─────────────────────────────────────────────────────
总计: ~580 GB ~160 GB ~48 GB
↑
一张 RTX 5090 就够了!
3.4 分页优化器
QLoRA 的另一个工程创新——分页优化器(Paged Optimizer):
“”“分页优化器核心逻辑”“”
class PagedAdamW(torch.optim.Optimizer):
“”"
分页 AdamW:优化器状态可以在 CPU/GPU 间换入换出
当 GPU 显存不足时,自动将优化器状态换出到 CPU
“”"
def __init__(self, params, lr=1e-4, **kwargs):
super().__init__(params, {"lr": lr, **kwargs})
self.page_size = 1024 * 1024 # 1M 参数一页
self.cpu_pages = {} # CPU 上的页
self.gpu_pages = {} # GPU 上的页
@torch.no_grad()
def step(self, closure=None):
"""优化步:自动管理 GPU 内存"""
for group in self.param_groups:
for p in group["params"]:
if p.grad is None:
continue
state = self.state[p]
# 如果 GPU 显存不足,换出到 CPU
if torch.cuda.memory_allocated() > 0.9 * torch.cuda.get_device_properties(0).total_memory:
self._evict_to_cpu(state)
# Adam update
self._adam_update(p, state, group)
return None
def _evict_to_cpu(self, state):
"""将优化器状态换出到 CPU"""
for key in ["exp_avg", "exp_avg_sq"]:
if key in state and state[key].is_cuda:
state[key] = state[key].cpu()
def _adam_update(self, p, state, group):
"""Adam 更新(标准实现)"""
# ... 标准 AdamW 更新逻辑
pass
四、DoRA:权重分解学习
4.1 DoRA 的洞察
DoRA(Weight-Decomposed Low-Rank Adaptation)[5] 发现:
全量微调的权重更新可以分解为方向(Direction)和幅度(Magnitude)两个部分。LoRA 默认同时更新两者,但方向更新更重要。
全量微调的权重变化:ΔW = ||W_new|| · (W_new / ||W_new||) - ||W|| · (W / ||W||)
DoRA:将学习分解为两个独立任务
- 幅度学习:用可学习的向量 m 控制 ||W||
- 方向学习:用 LoRA 控制 W/||W||(归一化后的方向)
4.2 DoRA 的数学形式
“”“DoRA 的核心实现”“”
import torch
import torch.nn as nn
import torch.nn.functional as F
class DoRALayer(nn.Module):
“”"
DoRA: Weight-Decomposed Low-Rank Adaptation
W' = m · (W + ΔW) / ||W + ΔW||
其中:
- m: 可学习的幅度向量(magnitude)
- ΔW: LoRA 学习的更新(direction)
- ||·||: 列归一化
"""
def __init__(self, in_features: int, out_features: int, rank: int = 8, alpha: int = 16):
super().__init__()
# 原始权重(冻结)
self.register_buffer('weight', torch.randn(out_features, in_features))
# 幅度向量 m(可训练)
self.m = nn.Parameter(torch.ones(out_features)) # 初始化为1
# LoRA 方向更新(可训练)
self.lora_A = nn.Parameter(torch.randn(rank, in_features) * 0.01)
self.lora_B = nn.Parameter(torch.zeros(out_features, rank))
self.scaling = alpha / rank
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
DoRA 前向传播:
1. 计算权重更新方向: W_updated = W + B·A·α/r
2. 列归一化: W_normed = W_updated / ||W_updated||
3. 乘幅度: W_doRA = m · W_normed
"""
# 原始权重
W = self.weight
# 1. 计算方向更新
delta_W = (self.lora_B @ self.lora_A) * self.scaling
# 2. 合并权重
W_combined = W + delta_W
# 3. 列归一化(每列分开归一化)
W_norm = torch.norm(W_combined, p=2, dim=1, keepdim=True)
W_normalized = W_combined / W_norm
# 4. 幅度缩放
W_doRA = self.m.unsqueeze(1) * W_normalized
# 5. 线性变换
return F.linear(x, W_doRA)
4.3 DoRA vs LoRA 效果对比
指标 LoRA (r=8) DoRA (r=8) 对比
Commonsense QA 74.3% 76.5% +2.2%
MMLU 63.8% 64.7% +0.9%
GSM8K 30.4% 32.5% +2.1%
参数量 42M 42M+7K 仅多 7K
推理速度 1x 0.97x 几乎无影响
DoRA 的改进不需要额外的推理成本——部署时可以将 m·W_normalized 合并到权重中,推理时与原生模型速度完全一致。
五、2026年LoRA变体全家桶
5.1 变体对比
方法 年份 核心创新 适用场景 优缺点
LoRA 2021 低秩分解 ΔW = BA 通用 SFT 基准方法
QLoRA 2023 NF4量化 + LoRA 显存受限 ✅ 单卡70B, ❌ 精度略降
DoRA 2024 幅度/方向分离 通用 SFT ✅ 效果更好, ⚠️ 略慢
LoRA+ 2024 不同LR(B大A小) 加速收敛 ✅ 收敛快2x, 无需改架构
rsLoRA 2024 秩缩放 大 r 场景 ✅ r=256也稳定
PiSSA 2025 SVD初始化 知识注入 ✅ 新知识更好
LoRA-XS 2025 共享A矩阵 多任务 ✅ 极省显存, ⚠️ 表达力
VeRA 2024 共享B/A+可学习d 超小显存 ✅ 最省参数
Delta-LoRA 2024 参数差值更新 联邦学习 ✅ 隐私友好
AdaLoRA 2023 自适应秩分配 自动化 ✅ 自动调r
5.2 rsLoRA:解决大 r 时的不稳定性
“”“rsLoRA:Rank-Stabilized LoRA”“”
class RsLoRALayer(nn.Module):
“”"
rsLoRA: 用 r/√r 替代 r 的缩放
标准 LoRA: scaling = α/r
rsLoRA: scaling = α/√r
为什么?
当 r 增大时,B·A 的期望范数按 √r 增长
标准 LoRA 的 α/r 缩放太强 → 大 r 时学习率变成无效
rsLoRA 的 α/√r 与 r 无关 → 大 r 也稳定
"""
def __init__(self, in_features, out_features, rank=8, alpha=16):
super().__init__()
self.lora_A = nn.Parameter(torch.randn(rank, in_features) * 0.01)
self.lora_B = nn.Parameter(torch.zeros(out_features, rank))
# 关键区别:用 1/√r 替代 1/r
self.scaling = alpha / math.sqrt(rank) # rsLoRA!
def forward(self, x):
delta = (x @ self.lora_A.T) @ self.lora_B.T * self.scaling
return F.linear(x, self.weight) + delta
5.3 PiSSA:用 SVD 初始化 LoRA
“”“PiSSA:Principal Singular values and Singular vectors Adaptation”“”
class PiSSALayer(nn.Module):
“”"
PiSSA: 用原始权重的 SVD 分解来初始化 LoRA
标准 LoRA: A 随机初始化(0.01), B 初始化(0)
PiSSA: A = U[:r] · √S[:r], B = √S[:r] · V[:r]^T
效果:PiSSA 保留了原始权重中最重要的方向,收敛更快,效果更好
"""
@staticmethod
def from_linear(linear: nn.Linear, rank: int = 8):
"""用 SVD 从原始权重初始化 PiSSA"""
W = linear.weight.data.float()
# SVD 分解
U, S, Vh = torch.linalg.svd(W, full_matrices=False)
# 取前 r 个奇异值
U_r = U[:, :rank] # (out, r)
S_r = S[:rank] # (r,)
Vh_r = Vh[:rank, :] # (r, in)
layer = PiSSALayer(
linear.in_features,
linear.out_features,
rank=rank
)
# 初始化为 SVD 分解
layer.lora_B.data = U_r * torch.sqrt(S_r).unsqueeze(0)
layer.lora_A.data = torch.sqrt(S_r).unsqueeze(1) * Vh_r
return layer
六、LoRA地雷阵:常见误区与解决方案
6.1 误区一:r 越大越好
❌ 误区: 秩 r 越大,表达能力越强,效果越好
✅ 真相: r 过大反而容易过拟合(尤其数据量少时)
经验:
数据量 < 1K: r=4~8
数据量 1K~10K: r=8~16
数据量 > 10K: r=16~64
6.2 误区二:所有层都用一样的 r
def apply_lora_with_varied_rank(
model: nn.Module,
default_rank: int = 8,
attention_rank: int = 16, # 注意力层更多容量
ffn_rank: int = 32, # FFN 层学习更复杂的映射
output_rank: int = 4, # 输出层少一点
):
“”“按层类型分配不同的秩”“”
lora_config = {
"q_proj": attention_rank,
"k_proj": attention_rank,
"v_proj": attention_rank,
"o_proj": default_rank,
"gate_proj": ffn_rank,
"up_proj": ffn_rank,
"down_proj": output_rank,
}
config = LoraConfig(
r=default_rank, # 默认秩
target_modules=list(lora_config.keys()),
lora_alpha=16,
lora_dropout=0.05,
modules_to_save=None,
# 注意:transformers 不支持每层不同 r
# 需要手动构建 LoRA 层
)
return config
6.3 误区三:LoRA 不需要 dropout
“”“LoRA Dropout 实验”“”
def lora_dropout_experiment():
“”"
LoRA 是否需要 dropout 取决于数据量:
数据量 | 最优 dropout | 说明
< 500 | 0.1-0.3 | 防过拟合,重要!
500-5K | 0.05-0.1 | 适中
> 5K | 0.0-0.05 | 数据够多不需要
QLoRA 场景:推荐 dropout=0.05(默认)
"""
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05, # 不要设为 0!
target_modules=["q_proj", "v_proj"],
)
6.4 误区四:LoRA 只能微调 attention
❌ 误区: LoRA 只对 attention 的 QKV 层有效
✅ 真相: FFN 层(gate/up/down)同样重要,且需要更高秩
2026 最佳实践:
模型大小 | 推荐配置
7B | q+v+o+gate+up+down, r=16
14B | 同上, r=8
70B | 同上, r=8
七、实战:用LoRA微调7B模型(单卡)
7.1 完整脚本
“”“Qwen3-7B + LoRA 微调完整脚本(单卡 RTX 5090)”“”
import os
import torch
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
BitsAndBytesConfig,
)
from peft import (
LoraConfig,
get_peft_model,
prepare_model_for_kbit_training,
TaskType,
)
from trl import SFTTrainer
import wandb
===== 0. 配置 =====
MODEL_NAME = “Qwen/Qwen3-7B”
DATA_PATH = “sft_data.jsonl”
OUTPUT_DIR = “qwen3-7b-lora”
===== 1. 加载模型(BF16,不量化) =====
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype=torch.bfloat16,
trust_remote_code=True,
attn_implementation=“flash_attention_2”,
device_map=“auto”,
)
tokenizer = AutoTokenizer.from_pretrained(
MODEL_NAME,
trust_remote_code=True,
padding_side=“right”,
)
tokenizer.pad_token = tokenizer.eos_token
===== 2. 配置 LoRA =====
lora_config = LoraConfig(
r=16, # 秩
lora_alpha=32, # 缩放因子
target_modules=[
“q_proj”, “k_proj”, “v_proj”, “o_proj”,
“gate_proj”, “up_proj”, “down_proj”,
], # 应用到所有线性层
lora_dropout=0.05, # 防止过拟合
bias=“none”,
task_type=TaskType.CAUSAL_LM,
)
应用 LoRA
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
输出: trainable params: 33,554,432 || all params: 6,734,123,520 ||
trainable%: 0.4984
===== 3. 准备数据 =====
dataset = load_dataset(“json”, data_files=DATA_PATH, split=“train”)
def format_chat(example):
text = tokenizer.apply_chat_template(
example[“messages”],
tokenize=False,
add_generation_prompt=False,
)
return {“text”: text}
dataset = dataset.map(format_chat)
===== 4. 训练参数 =====
training_args = TrainingArguments(
output_dir=OUTPUT_DIR,
per_device_train_batch_size=4, # 7B LoRA 能吃下 batch=4
gradient_accumulation_steps=4, # 等效 batch = 16
num_train_epochs=3,
learning_rate=2e-4, # LoRA 的 LR 通常比全量大 2-4x
warmup_ratio=0.03,
lr_scheduler_type=“cosine”,
bf16=True,
logging_steps=10,
save_strategy=“epoch”,
save_total_limit=3,
optim=“adamw_torch_fused”,
max_grad_norm=0.3, # LoRA 的梯度裁剪更严格
report_to=[“wandb”],
remove_unused_columns=False,
dataloader_num_workers=4,
)
===== 5. SFT Trainer =====
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset,
tokenizer=tokenizer,
max_seq_length=4096,
dataset_text_field=“text”,
packing=False,
)
===== 6. 训练 =====
trainer.train()
===== 7. 保存 =====
只保存 LoRA 适配器(约 84MB)
trainer.save_model()
也可以合并到原模型(可选)
merged_model = model.merge_and_unload()
merged_model.save_pretrained(os.path.join(OUTPUT_DIR, “merged”))
tokenizer.save_pretrained(os.path.join(OUTPUT_DIR, “merged”))
7.2 训练监控要点
LoRA 训练的正常信号:
- 初始 loss: 与原始模型相近(因为 LoRA 初始化为0)
- loss 下降: 比全量微调慢,但更稳定
- 梯度范数: 维持在 0.01-0.1 量级
异常信号: - loss 不降: 检查 lr(LoRA 需要更大的 lr: 1e-4 ~ 5e-4)
- loss 飙升: 检查缩放因子 α/r 是否过大
- 过拟合: loss 降但 eval loss 升 → 增加 dropout 或减少 r
7.3 超参数速查
模型 量化 batch_size lr r 显存 单卡
Qwen3-7B BF16 4 2e-4 16 24GB RTX 5090
LLaMA 4-8B BF16 4 2e-4 16 28GB RTX 5090
Qwen3-14B BF16 2 1e-4 8 40GB RTX 5090
Qwen3-32B NF4 2 1e-4 8 24GB RTX 5090
Qwen3-72B NF4 1 1e-4 8 40GB RTX 5090
LLaMA 4-109B NF4 1 5e-5 8 48GB RTX 5090 ✅
八、实战:用QLoRA微调70B模型(单卡)
8.1 QLoRA 完整脚本
“”“Qwen3-72B + QLoRA 微调(单卡 RTX 5090 48GB)”“”
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
BitsAndBytesConfig,
)
from peft import LoraConfig, prepare_model_for_kbit_training
from trl import SFTTrainer
===== 1. 4-bit 量化加载 =====
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type=“nf4”,
)
model = AutoModelForCausalLM.from_pretrained(
“Qwen/Qwen3-72B”,
quantization_config=bnb_config,
device_map=“auto”,
trust_remote_code=True,
attn_implementation=“flash_attention_2”,
)
===== 2. QLoRA 专有准备 =====
为 k-bit 量化训练做准备
model = prepare_model_for_kbit_training(
model,
use_gradient_checkpointing=True,
gradient_checkpointing_kwargs={“use_reentrant”: True},
)
===== 3. LoRA 配置(QLoRA 建议用更低秩) =====
lora_config = LoraConfig(
r=8, # 70B 模型用更小的 r
lora_alpha=16,
target_modules=[
“q_proj”, “k_proj”, “v_proj”, “o_proj”,
], # 只 QKV+O 就够
lora_dropout=0.05,
bias=“none”,
task_type=TaskType.CAUSAL_LM,
)
model = get_peft_model(model, lora_config)
===== 4. 训练参数(QLoRA 特有) =====
training_args = TrainingArguments(
output_dir=“qwen3-72b-qlora”,
per_device_train_batch_size=1, # 70B 只能 batch=1
gradient_accumulation_steps=8, # 等效 batch=8
num_train_epochs=3,
learning_rate=2e-4,
warmup_ratio=0.03,
bf16=True,
logging_steps=5,
save_strategy=“steps”,
save_steps=100,
save_total_limit=2,
# QLoRA 推荐配置
optim="paged_adamw_8bit", # 分页优化器!避免 OOM
max_grad_norm=0.3,
gradient_checkpointing=True,
# 节省显存
ddp_find_unused_parameters=False,
dataloader_num_workers=2,
group_by_length=True, # 按长度分组,减少padding浪费
length_column_batching=True,
)
===== 5. 训练 =====
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset,
tokenizer=tokenizer,
max_seq_length=2048, # QLoRA 用较短上下文
dataset_text_field=“text”,
packing=True, # packing 提高效率
)
trainer.train()
===== 6. 保存 =====
保存 LoRA 适配器(~21MB)
model.save_pretrained(“qwen3-72b-qlora-adapter”)
tokenizer.save_pretrained(“qwen3-72b-qlora-adapter”)
8.2 推理时合并/分离
“”“QLoRA 适配器的加载与合并”“”
from peft import PeftModel
方式一:动态加载 LoRA(不修改原模型)
base_model = AutoModelForCausalLM.from_pretrained(
“Qwen/Qwen3-72B”,
quantization_config=bnb_config,
device_map=“auto”,
)
lora_model = PeftModel.from_pretrained(
base_model,
“qwen3-72b-qlora-adapter”,
)
推理
inputs = tokenizer(“你好”, return_tensors=“pt”).to(“cuda”)
outputs = lora_model.generate(**inputs, max_new_tokens=100)
方式二:合并到原模型(推理更快,不依赖 peft)
merged_model = lora_model.merge_and_unload()
方式三:同时加载多个 LoRA(多任务切换)
base_model = AutoModelForCausalLM.from_pretrained(…)
lora_coding = PeftModel.from_pretrained(base_model, “coding-lora”)
lora_chat = PeftModel.from_pretrained(base_model, “chat-lora”)
lora_medical = PeftModel.from_pretrained(base_model, “medical-lora”)
使用时切换
def switch_lora(task: str):
if task == “coding”:
lora_coding.model.set_adapter(“coding”)
elif task == “chat”:
lora_coding.model.set_adapter(“chat”)
# … 注意:set_adapter 需要在同一个 PeftModel 上
九、实战:多LoRA融合与切换
9.1 LoRA 的算术操作
LoRA 的一个独特优势是可以做算术运算:
“”“LoRA 的加、减、合并操作”“”
import torch
class LoRAArithmetic:
“”“LoRA 权重算术操作”“”
@staticmethod
def add(lora_a: dict, lora_b: dict, alpha: float = 0.5) -> dict:
"""LoRA 权重融合: C = α·A + (1-α)·B
可用于融合两个领域的知识
"""
merged = {}
for key in lora_a:
merged[key] = alpha * lora_a[key] + (1 - alpha) * lora_b[key]
return merged
@staticmethod
def subtract(lora_a: dict, lora_b: dict) -> dict:
"""LoRA 减法: C = A - B
可用于去除某个能力
"""
return {
key: lora_a[key] - lora_b[key]
for key in lora_a
}
@staticmethod
def task_arithmetic(
base_lora: dict,
task_specific_loras: List[Tuple[str, dict, float]],
) -> dict:
"""任务算术:base + Σ(w_i · Δlora_i)"""
merged = dict(base_lora)
for name, lora_weights, weight in task_specific_loras:
for key in merged:
merged[key] += weight * lora_weights[key]
print(f" + {name} × {weight}")
return merged
===== 多 LoRA 管理系统 =====
class LoRAManager:
“”“多 LoRA 适配器管理器”“”
def __init__(self, base_model, base_path: str = "loras"):
self.base_model = base_model
self.base_path = Path(base_path)
self.active_adapter = None
def list_adapters(self) -> List[str]:
"""列出所有可用 LoRA"""
return [
d.name for d in self.base_path.iterdir()
if d.is_dir() and (d / "adapter_config.json").exists()
]
def load_adapter(self, adapter_name: str):
"""加载特定 LoRA"""
adapter_path = self.base_path / adapter_name
self.base_model.load_adapter(
str(adapter_path),
adapter_name=adapter_name,
)
print(f" ✅ 已加载: {adapter_name}")
def switch_to(self, adapter_name: str):
"""切换 LoRA"""
from peft import set_peft_model_state_dict
self.base_model.set_adapter(adapter_name)
self.active_adapter = adapter_name
print(f" → 激活: {adapter_name}")
def merge_adapters(
self,
adapter_names: List[str],
weights: List[float],
output_name: str = "merged"
):
"""融合多个 LoRA"""
merged_state = {}
for adapter_name, weight in zip(adapter_names, weights):
adapter_path = self.base_path / adapter_name
state = torch.load(
adapter_path / "adapter_model.bin",
map_location="cpu"
)
for key, val in state.items():
if key not in merged_state:
merged_state[key] = val * weight
else:
merged_state[key] += val * weight
# 保存融合结果
output_path = self.base_path / output_name
output_path.mkdir(exist_ok=True)
torch.save(
merged_state,
output_path / "adapter_model.bin"
)
print(f" ✅ 融合完成: {adapter_names} × {weights}")
def generate(self, prompt: str, **kwargs):
"""用当前 LoRA 生成"""
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(text, return_tensors="pt").to(self.base_model.device)
outputs = self.base_model.generate(
**inputs,
max_new_tokens=512,
temperature=0.7,
do_sample=True,
**kwargs
)
return tokenizer.decode(
outputs[0][inputs.input_ids.shape[1]:],
skip_special_tokens=True
)
===== 使用示例 =====
“”"
manager = LoRAManager(base_model)
独立 LoRA
manager.load_adapter(“coding-lora”) # 代码能力
manager.load_adapter(“medical-lora”) # 医疗知识
manager.load_adapter(“chat-lora”) # 对话风格
切换
manager.switch_to(“coding-lora”)
print(manager.generate(“写一个快速排序”))
融合(60% 代码 + 40% 聊天 = 一个代码教学助手)
manager.merge_adapters(
[“coding-lora”, “chat-lora”],
[0.6, 0.4],
“coding-tutor”
)
manager.switch_to(“coding-tutor”)
“”"
9.2 LoRA 融合的实际效果
融合方案 代码 聊天 数学 适用场景
pure coding 92% 30% 65% 代码助手
pure chat 40% 85% 35% 聊天机器人
0.7 coding + 0.3 chat 85% 65% 58% 代码教学
0.5 coding + 0.5 math 75% 40% 82% 数学竞赛
0.4 coding + 0.3 chat + 0.3 math 80% 55% 75% 全能助手 ⭐
十、LoRA的数学框架与选择指南
10.1 LoRA 的信息论视角
LoRA 的信息瓶颈:
min I(Z; X_base) - β·I(Z; Y_target)
↑ ↑
不偏离原始 •适应新任务
LoRA 的秩 r 控制了信息瓶颈的宽度:
r 小 → I(Z; X_base) 小 → 保留原始能力
r 大 → I(Z; Y_target) 大 → 适应更好 → 但也更容易遗忘
最佳 r = √(I(Y_target) / I(X_base)) … 理论上
实际: r=8~16 是最佳平衡
10.2 PEFT 选择决策树
我的需求:
├── 显存充足 + GPU ≥4 卡 → 用 LoRA (BF16)
│ ├── 效果好,速度快
│ └── 70B 需要 4×H200
│
├── 显存受限 + 单卡 → 用 QLoRA (NF4)
│ ├── 单卡 RTX 5090 可微调 70B
│ └── 精度损失 ~1%
│
├── 追求最高质量 → 用 DoRA
│ ├── 比 LoRA 好 1-3%,几乎不增加参数
│ └── 推理时无额外开销
│
├── 多任务切换 → LoRA + Adapter 管理
│ ├── 每个适配器 ~20MB
│ └── 可热切换
│
└── 极致显存节省 → QLoRA + LoRA-XS
├── 7B 只需 4GB
└── 适合边缘设备
10.3 2026 LoRA 最佳实践速查
LoRA 2026 最佳配置
通用 SFT (7B-14B):
r: 16
alpha: 32
target_modules: [q, k, v, o, gate, up, down]
dropout: 0.05
lr: 2e-4
batch: 16
领域适配 (医疗/法律/金融):
r: 32-64
alpha: 64
target_modules: [all linear]
dropout: 0.1
lr: 1e-4
replay_ratio: 0.1 # 10% 通用数据
多任务 (10+ tasks):
r: 8
alpha: 16
target_modules: [q, v]
dropout: 0.0
lr: 3e-4
QLoRA 70B+:
r: 8
alpha: 16
target_modules: [q, v, o]
dropout: 0.05
lr: 2e-4
batch: 8 (grad_accum)
max_seq_length: 2048
总结
核心要点
LoRA 的本质:ΔW = B·A 的低秩分解,冻结 99.85% 参数,只训练 0.15%
QLoRA = NF4 + LoRA + Dual Quant + Paged Optimizer:一张 RTX 5090 微调 70B
DoRA = Direction + Magnitude 分离:比 LoRA 好 1-3%,几乎 0 额外参数
r 不是越大越好:r=16 是通用最优,数据多可用 r=32-64
LoRA 可算术操作:加减融合,构建混合能力的 LoRA
2026 最佳实践:通用 SFT → LoRA(r=16, 2e-4), 显存受限 → QLoRA, 质量优先 → DoRA
各方法的显存/质量权衡
质量
↑
| Full Fine-tune
| DoRA
| LoRA (FP16)
| QLoRA (NF4)
| LoRA-XS
|
±-------------------------------→ 显存效率
面试高频问题
Q: LoRA 为什么把 B 初始化为 0,A 初始化为随机?
A: 初始化 B=0 保证 LoRA 路径 ΔW=0,不影响原始模型输出,loss 从原始水平开始平滑下降。A 随机初始化保证梯度可以正常流动。如果 B 和 A 都随机,ΔW 一开始就有随机值,loss 会飙升。
Q: LoRA 的秩 r 如何选择?
A: 取决于任务和数据量。通用 SFT: r=16; 领域适配: r=32-64; 多任务: r=8; 数据量 <1K: r=4-8. r 太小学不到,r 太大容易过拟合。Q/K/V 层的 r 可以比 FFN 层小。
Q: QLoRA 相比 LoRA 的精度损失有多大?
A: NF4 量化 + LoRA 的组合在绝大多数任务上精度损失 < 1-2%。如果使用 Double Quantization + BF16 compute dtype,损失几乎不可察觉。但对于数学推理等精度敏感任务,建议用 LoRA (BF16) 而非 QLoRA。
Q: 同一个 base model 可以用多个 LoRA 适配器切换吗?
A: 可以!这是 LoRA 的独特优势。每个 LoRA 适配器只有 ~20-80MB,可以在推理时热切换。甚至可以将多个 LoRA 通过线性组合融合出新能力(LoRA arithmetic)。
下一篇预告:训练与微调篇第 5 篇——Prefix Tuning 与 Prompt Tuning。
参考资料
[1] Hu et al. LoRA: Low-Rank Adaptation of Large Language Models. ICLR 2022
[2] Aghajanyan et al. Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning. 2021
[3] Dettmers et al. QLoRA: Efficient Finetuning of Quantized Language Models. NeurIPS 2023
[4] Liu et al. DoRA: Weight-Decomposed Low-Rank Adaptation. ICML 2024
[5] Kalajdzievski. rsLoRA: A Rank-Stabilized LoRA Scaling. 2024
[6] Meng et al. PiSSA: Principal Singular Values and Singular Vectors Adaptation. 2025
[7] Biderman et al. LoRA+: Efficient Low-Rank Adaptation with Different Learning Rates. 2024
更多推荐



所有评论(0)