大模型微调技术对比:LoRA、QLoRA、全参微调
引言
去年冬天,我接手了一个企业知识库问答项目。客户是一家大型律所,他们希望把内部十万份合同、判例、法律意见书灌进模型,让律师用自然语言就能检索和推理。一开始我们选了最直接的路子——调用 GPT-4 的 API,配合 RAG 做检索增强。跑了两周,问题来了:律所对数据出境极其敏感,合同文本不允许发往任何第三方 API;而且他们的法律术语体系非常特殊,通用模型对"表见代理""善意取得"这类概念的回答总是差那么一口气,RAG 检索回来的片段也经常答非所问。
摆在面前的路只有一条:本地部署开源模型,并且做微调,让模型真正"学会"这家律所的语言习惯和知识分布。
但真正动手时,新的问题又来了。律所的 GPU 预算只有 2 张 A100 80G,而我们想微调的是 70B 级别的模型。全参微调?光是优化器状态就要吃掉几百 GB 显存,根本跑不起来。这时候,LoRA 和 QLoRA 进入了视野。
这篇文章,我想把这三种微调路线——全参微调(Full Fine-tuning)、LoRA、QLoRA——从原理到源码彻底讲清楚,帮你在真实项目里做出正确的技术选型。这不是一篇"Hello World"级别的教程,而是面向有 3-5 年工程经验、准备把微调真正落地到生产环境的开发者。
核心概念:三种微调路线的本质区别
一个餐厅后厨的类比
想象你经营一家餐厅,现在要培养一位新厨师。
全参微调,相当于让这位厨师从零开始重新学一遍所有菜系——川菜、粤菜、法餐、日料,全部推倒重来。学成之后,他对所有菜的理解都会变化,但代价是:需要重金聘请教务团队(优化器状态)、耗时数月(训练时间长)、而且万一学砸了,原来的手艺也回不来了(灾难性遗忘)。
LoRA,相当于不动厨师原有的手艺,只给他配一本"本店特色菜补充手册"。手册很薄,只记录这家店特有的调味偏好和摆盘风格。厨师做菜时,一边用自己原有的功底,一边翻阅这本手册做微调。手册可以随时换、随时扔,厨师本身不受影响。
QLoRA,则是在 LoRA 的基础上,先把厨师本人的记忆压缩成"精简版"(4-bit 量化),再配那本补充手册。这样厨师占用的"工位"(显存)大大减少,但做菜时的精细度会有一点点损失。
技术定义
全参微调(Full Fine-tuning):在预训练模型的基础上,用下游任务数据更新模型的全部参数。对于一个 7B 模型,需要更新的参数量就是 70 亿个,训练时每个参数还要额外存储梯度、优化器的一阶矩和二阶矩(Adam 优化器下,每个参数额外占用 8 字节),显存开销是模型本身的 3-4 倍。
LoRA(Low-Rank Adaptation):冻结预训练模型的所有原始权重 $W_0 \in \mathbb{R}^{d \times k}$,在旁路注入一对可训练的低秩矩阵 $A \in \mathbb{R}^{r \times k}$ 和 $B \in \mathbb{R}^{d \times r}$,其中秩 $r \ll \min(d, k)$。前向传播变为:
$$
h = W_0 x + \Delta W x = W_0 x + BAx
$$
训练时只更新 $A$ 和 $B$,参数量从 $d \times k$ 降到 $r \times (d + k)$。以 $d=k=4096, r=8$ 为例,参数量从 1600 万降到 6.5 万,压缩了 244 倍。
QLoRA(Quantized LoRA):在 LoRA 的基础上,把冻结的基座模型 $W_0$ 量化到 4-bit(NF4 数据类型),同时引入三项关键创新:4-bit NormalFloat 量化、双重量化(Double Quantization)、以及分页优化器(Paged Optimizers)。这让 65B 模型可以在单张 48G 显卡上微调。
源码/原理深度分析
全参微调的显存账本
先算一笔账。假设我们要微调一个 7B 模型(如 Llama-2-7B),使用 AdamW 优化器 + 混合精度训练(fp16 计算 + fp32 主权重):
| 项目 | 精度 | 显存占用 |
|---|---|---|
| 模型权重 | fp16 | 14 GB |
| 主权重副本 | fp32 | 28 GB |
| 梯度 | fp16 | 14 GB |
| Adam 一阶矩 | fp32 | 28 GB |
| Adam 二阶矩 | fp32 | 28 GB |
| 激活值 | fp16 | 视 batch size 而定 |
| 合计 | ≈ 112 GB + 激活值 |
这就是为什么全参微调 7B 模型至少需要一张 A100 80G(还得用 gradient checkpointing 省激活值),70B 模型则要好几张卡做 ZeRO-3 分片。
LoRA 的源码级剖析
我们直接看 HuggingFace peft 库中 LoRA 层的核心实现(peft/tuners/lora/layer.py,简化版):
class LoraLayer(BaseTunerLayer):
def __init__(self, in_features: int, out_features: int, **kwargs):
self.r = {}
self.lora_alpha = {}
self.scaling = {}
self.lora_dropout = nn.ModuleDict({})
self.lora_A = nn.ModuleDict({})
self.lora_B = nn.ModuleDict({})
# 冻结的原始权重
self.weight = nn.Parameter(torch.empty((out_features, in_features)))
def update_layer(self, adapter_name, r, lora_alpha, lora_dropout, init_lora_weights):
self.r[adapter_name] = r
self.lora_alpha[adapter_name] = lora_alpha
# 关键:scaling = alpha / r
self.scaling[adapter_name] = lora_alpha / r
if lora_dropout > 0.0:
lora_dropout_layer = nn.Dropout(p=lora_dropout)
else:
lora_dropout_layer = nn.Identity()
self.lora_dropout.update(nn.ModuleDict({adapter_name: lora_dropout_layer}))
# A 矩阵:高斯初始化,形状 (r, in_features)
self.lora_A[adapter_name] = nn.Linear(in_features, r, bias=False)
# B 矩阵:零初始化,形状 (out_features, r)
self.lora_B[adapter_name] = nn.Linear(r, out_features, bias=False)
# 初始化:A 用 Kaiming,B 用零,保证训练开始时 ΔW = BA = 0
nn.init.kaiming_uniform_(self.lora_A[adapter_name].weight, a=math.sqrt(5))
nn.init.zeros_(self.lora_B[adapter_name].weight)前向传播的实现(Linear 版本):
def forward(self, x: torch.Tensor, *args, **kwargs):
# 原始路径:使用冻结的 base weight
result = self.base_layer(x, *args, **kwargs)
# LoRA 旁路
for active_adapter in self.active_adapters:
if active_adapter not in self.lora_A.keys():
continue
lora_A = self.lora_A[active_adapter]
lora_B = self.lora_B[active_adapter]
dropout = self.lora_dropout[active_adapter]
scaling = self.scaling[active_adapter]
# 注意:输入先做 dropout,再经过 A、B,最后乘 scaling
x_dropped = dropout(x.to(lora_A.weight.dtype))
result += lora_B(lora_A(x_dropped)) * scaling
return result这里有几个源码级的关键细节,很多博客不会告诉你:
scaling = alpha / r的设计意图:当你调整r时,无需重新调learning_rate。alpha 固定,r 变大时 scaling 自动变小,保证有效学习率稳定。这是 LoRA 论文里一个非常工程化的巧思。
- B 矩阵零初始化:保证训练开始时 $\Delta W = BA = 0$,模型行为与原始模型完全一致,然后平滑地开始学习。这一点至关重要——如果 A、B 都随机初始化,训练初期会出现明显的性能抖动。
- dropout 的位置:注意是
dropout(x)而不是dropout(B(A(x)))。dropout 作用在输入上,与标准 Linear 层的 dropout 位置一致。
- 哪些层加 LoRA:实践中我们通常只对 attention 的
q_proj, k_proj, v_proj, o_proj加 LoRA,有时加上 MLP 的gate_proj, up_proj, down_proj。全加会显著增加参数量,收益递减。
QLoRA 的三项核心创新
QLoRA 的源码主要在 bitsandbytes 库里。我们看最关键的 NF4 量化(bitsandbytes/functional.py):
# 4-bit NormalFloat 量化:基于分位数的信息论最优量化
def create_normal_map(offset=0.9677083, use_extra_value=True):
from scipy.stats import norm
# 生成 16 个分位点,使每个量化桶的概率质量相等
if use_extra_value:
# 正负两侧各 8 个点,但为了对称性做了特殊处理
v1 = norm.ppf(torch.linspace(offset, 0.5, 9)[:-1]).tolist()
v2 = (-1 * torch.tensor(v1)).tolist()
v2.reverse()
v0 = v2 + [0] + v1
else:
v1 = norm.ppf(torch.linspace(offset, 0.5, 8)[:-1]).tolist()
v2 = (-1 * torch.tensor(v1)).tolist()
v2.reverse()
v0 = v2 + v1
return v0为什么 NF4 比普通 int4 好? 因为预训练模型的权重近似服从零均值正态分布。普通 int4 把数值均匀分成 16 个桶,导致中间密集区域精度不够、两侧稀疏区域浪费桶。NF4 按正态分布的分位数切桶,让每个桶的"概率质量"相等,等于把有限的 16 个比特位分配到了信息量最大的地方。这是信息论意义上对正态分布的最优量化。
双重量化(Double Quantization):量化本身需要存储 scale(缩放因子)。对 7B 模型,每 64 个权重一个 scale,scale 用 fp32 存储,大约额外占用 0.5 bit/参数。双重量化把这些 scale 再量化一次,将 0.5 bit 降到 0.127 bit,节省约 0.37 bit/参数。对 65B 模型来说,这就是 3 GB 的显存。
分页优化器(Paged Optimizers):利用 NVIDIA 的统一内存(Unified Memory),当显存不足时自动把优化器状态换出到 CPU 内存。这样训练长序列时,即使显存瞬时峰值超过容量也不会 OOM。
三种方案的架构对比
图中红色表示"需要更新",绿色表示"冻结但保持高精度",蓝色表示"冻结且量化"。可以看到 QLoRA 在显存节省和保持可训练性之间找到了一个非常好的平衡点。
实战代码
下面三个示例都是完整可运行的,覆盖从数据准备到训练到推理的全流程。我尽量标注了生产环境中的坑点。
示例一:全参微调(以小型模型为例,便于复现)
"""
全参微调示例:使用 HuggingFace Transformers 微调 Qwen2-0.5B
适用于:显存充足(>= 24GB)、需要模型能力有根本性改变的场景
"""
import torch
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
Trainer,
DataCollatorForSeq2Seq,
)
MODEL_NAME = "Qwen/Qwen2-0.5B-Instruct"
OUTPUT_DIR = "./full_ft_output"
# 1. 加载模型与分词器
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
# 关键坑点:很多模型没有 pad_token,需手动指定,否则 batch 训练会报错
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype=torch.bfloat16, # Ampere 及以上用 bf16,避免 fp16 溢出
device_map="auto",
)
# 2. 数据预处理:把对话数据转成训练格式
def format_and_tokenize(example):
# 使用 chat template 保证与推理时格式一致
messages = [
{"role": "user", "content": example["instruction"]},
{"role": "assistant", "content": example["output"]},
]
text = tokenizer.apply_chat_template(messages, tokenize=False)
enc = tokenizer(
text,
truncation=True,
max_length=1024,
padding=False, # 交给 collator 动态 padding,节省显存
)
# 关键:labels 与 input_ids 相同,但 padding 位置要设为 -100 不参与 loss
enc["labels"] = enc["input_ids"].copy()
return enc
# 用公开的中文指令数据集举例
raw_ds = load_dataset("shibing624/alpaca-zh", split="train[:2000]")
train_ds = raw_ds.map(format_and_tokenize, remove_columns=raw_ds.column_names)
# 3. 训练参数
args = TrainingArguments(
output_dir=OUTPUT_DIR,
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 等效 batch size = 16
gradient_checkpointing=True, # 关键:用时间换显存,激活值减半以上
learning_rate=2e-5, # 全参微调学习率通常 1e-5 ~ 5e-5
num_train_epochs=3,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
bf16=True,
optim="adamw_torch",
report_to="none",
)
trainer = Trainer(
model=model,
args=args,
train_dataset=train_ds,
data_collator=DataCollatorForSeq2Seq(
tokenizer=tokenizer, padding=True, pad_to_multiple_of=8
),
)
trainer.train()
trainer.save_model(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
print("全参微调完成,模型已保存到", OUTPUT_DIR)坑点提示:
gradient_checkpointing=True时,必须调用model.enable_input_require_grads(),否则梯度无法回传(在 PEFT 中会自动处理,全参微调时要手动加)。
- 全参微调会产生一个和原模型等大的新模型,存储成本要考虑。
- bf16 需要在 Ampere(A100/3090)及以后的架构上才能用,V100 只能用 fp16 + loss scaling。
示例二:LoRA 微调(生产环境主力方案)
"""
LoRA 微调示例:使用 peft 库微调 Qwen2-7B
显存需求:单张 24GB 显卡可跑(配合 4-bit 则 12GB 即可)
"""
import torch
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
Trainer,
DataCollatorForSeq2Seq,
)
from peft import (
LoraConfig,
get_peft_model,
TaskType,
prepare_model_for_kbit_training,
)
MODEL_NAME = "Qwen/Qwen2-7B-Instruct"
OUTPUT_DIR = "./lora_output"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
# 全精度加载(bf16),LoRA 场景下基座不量化
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype=torch.bfloat16,
device_map="auto",
)
model.gradient_checkpointing_enable()
model.enable_input_require_grads()
# ============ LoRA 配置 ============
lora_config = LoraConfig(
r=16, # 秩,越大能力越强但参数越多。8~64 是常见范围
lora_alpha=32, # 通常设为 2*r,scaling = 2
lora_dropout=0.05, # 小数据集加 dropout 防过拟合
bias="none", # 一般不动 bias
task_type=TaskType.CAUSAL_LM,
# 关键:目标模块的选择直接影响效果
# Qwen/Llama 系列推荐:q,k,v,o + gate,up,down
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出示例:trainable params: 20,185,088 || all params: 7,635,000,000 || trainable%: 0.26%
# 数据预处理(同示例一)
def format_and_tokenize(example):
messages = [
{"role": "user", "content": example["instruction"]},
{"role": "assistant", "content": example["output"]},
]
text = tokenizer.apply_chat_template(messages, tokenize=False)
enc = tokenizer(text, truncation=True, max_length=1024, padding=False)
enc["labels"] = enc["input_ids"].copy()
return enc
raw_ds = load_dataset("shibing624/alpaca-zh", split="train[:5000]")
train_ds = raw_ds.map(format_and_tokenize, remove_columns=raw_ds.column_names)
args = TrainingArguments(
output_dir=OUTPUT_DIR,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
gradient_checkpointing=True,
learning_rate=2e-4, # LoRA 的学习率通常比全参微调大 10 倍
num_train_epochs=3,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
bf16=True,
optim="adamw_torch",
report_to="none",
)
trainer = Trainer(
model=model,
args=args,
train_dataset=train_ds,
data_collator=DataCollatorForSeq2Seq(
tokenizer=tokenizer, padding=True, pad_to_multiple_of=8
),
)
trainer.train()
# 只保存 LoRA adapter(通常几 MB ~ 几十 MB)
model.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
print("LoRA adapter 已保存,大小约几 MB")关键实践:
- 学习率:LoRA 用 1e-4 ~ 3e-4,全参微调用 1e-5 ~ 5e-5,不要搞混。
target_modules:只加q,v是最省参数的方案,加全部 attention + MLP 效果最好但参数翻倍。生产上通常先用全 attention 试水。
lora_alpha:设成2*r是社区默认,但如果你发现模型欠拟合,可以调大到4*r。
- 多 adapter 复用:一个基座可以挂多个 LoRA adapter,通过
model.set_adapter("name")切换,非常适合多租户场景。
示例三:QLoRA 微调(单卡跑 70B 的救命方案)
"""
QLoRA 微调示例:4-bit 量化基座 + LoRA,单卡 48GB 可微调 65B 模型
本示例用 7B 模型演示,方便复现,但流程完全适用 70B
"""
import torch
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
TrainingArguments,
Trainer,
DataCollatorForSeq2Seq,
)
from peft import LoraConfig, get_peft_model, TaskType, prepare_model_for_kbit_training
MODEL_NAME = "Qwen/Qwen2-7B-Instruct"
OUTPUT_DIR = "./qlora_output"
# ============ 核心:4-bit 量化配置 ============
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 启用 4-bit 加载
bnb_4bit_quant_type="nf4", # 使用 NF4 数据类型(关键)
bnb_4bit_compute_dtype=torch.bfloat16, # 计算时反量化到 bf16
bnb_4bit_use_double_quant=True, # 双重量化,再省 0.37 bit/参数
)
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
# 关键:为 k-bit 训练做准备,做三件事
# 1. 冻结所有基座参数
# 2. 把 LayerNorm 转成 fp32(数值稳定性)
# 3. 启用 gradient checkpointing 的输入梯度
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=64, # QLoRA 通常用更大的 r,因为基座精度损失需要补偿
lora_alpha=128,
lora_dropout=0.05,
bias="none",
task_type=TaskType.CAUSAL_LM,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
def format_and_tokenize(example):
messages = [
{"role": "user", "content": example["instruction"]},
{"role": "assistant", "content": example["output"]},
]
text = tokenizer.apply_chat_template(messages, tokenize=False)
enc = tokenizer(text, truncation=True, max_length=2048, padding=False)
enc["labels"] = enc["input_ids"].copy()
return enc
raw_ds = load_dataset("shibing624/alpaca-zh", split="train[:5000]")
train_ds = raw_ds.map(format_and_tokenize, remove_columns=raw_ds.column_names)
args = TrainingArguments(
output_dir=OUTPUT_DIR,
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
gradient_checkpointing=True,
learning_rate=2e-4,
num_train_epochs=3,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
bf16=True,
# 关键:QLoRA 必须用 paged optimizer 才能避免长序列 OOM
optim="paged_adamw_8bit",
report_to="none",
)
trainer = Trainer(
model=model,
args=args,
train_dataset=train_ds,
data_collator=DataCollatorForSeq2Seq(
tokenizer=tokenizer, padding=True, pad_to_multiple_of=8
),
)
trainer.train()
model.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
print("QLoRA adapter 已保存")推理时的关键:QLoRA 训练出的 adapter 可以直接加载到 4-bit 基座上推理,也可以合并到 fp16 基座上。生产部署通常建议合并后量化到 GPTQ/AWQ,兼顾精度和吞吐:
# 合并 LoRA 到基座(需要 fp16 精度的基座)
from peft import PeftModel
from transformers import AutoModelForCausalLM
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2-7B-Instruct", torch_dtype=torch.float16, device_map="cpu"
)
merged = PeftModel.from_pretrained(base, "./qlora_output").merge_and_unload()
merged.save_pretrained("./merged_model")方案对比:横向评测
| 维度 | 全参微调 | LoRA | QLoRA |
|---|---|---|---|
| 可训练参数 | 100% | 0.1% ~ 2% | 0.1% ~ 2% |
| 7B 显存需求 | ~112 GB | ~20 GB | ~8 GB |
| 70B 显存需求 | ~1.2 TB(多卡) | ~160 GB | ~48 GB(单卡) |
| 训练速度 | 慢 | 快 | 中等(反量化有开销) |
| 最终精度 | 最高 | 接近全参 | 略低于 LoRA |
| 存储成本 | 一份完整模型 | 几 MB adapter | 几 MB adapter |
| 多任务切换 | 每个任务一份模型 | 挂载不同 adapter | 挂载不同 adapter |
| 灾难性遗忘 | 风险高 | 风险低 | 风险低 |
| 适用场景 | 领域大改、数据充足 | 绝大多数生产场景 | 显存受限、大模型 |
与 Prompt Tuning / P-Tuning 的对比
除了 LoRA 家族,还有一类软提示(Soft Prompt)方法,比如 Prompt Tuning、P-Tuning v2。它们不改模型权重,只在输入前面拼接可训练 embedding。参数量比 LoRA 更少(几千个),但效果在复杂任务上明显不如 LoRA。实践建议:除非你做的是简单分类任务,否则优先 LoRA。
与 Adapter 层的对比
Adapter 是在 Transformer 层之间插入小的 bottleneck 模块。它的问题是会增加推理延迟(因为串行插入),而 LoRA 因为可以合并回原权重,推理时零额外延迟。这也是 LoRA 成为主流的核心原因。
最佳实践与避坑指南
1. 数据质量 > 一切
我见过太多团队花两周调超参,最后发现是数据里有 30% 的脏数据。500 条高质量样本 > 5000 条噪声数据。具体做法:
- 用 LLM 对训练数据做质量打分,过滤低分样本;
- 保证指令多样性,不要 500 条都是"总结以下文本";
- 训练集/验证集按 9:1 切分,验证集要能反映真实分布。
2. 学习率与 batch size 的耦合
LoRA 的学习率经验值:lr ≈ 1e-4 × sqrt(batch_size / 16)。如果你 batch size 只有 4,学习率应该降到 5e-5 左右。不要直接照搬别人博客里的 2e-4。
3. 秩 r 的选择
- 简单任务(风格迁移、格式对齐):r=8 足够;
- 中等任务(领域知识注入):r=16~32;
- 复杂任务(推理能力增强):r=64~128,并配合更多数据。
r 不是越大越好。r 太大时,LoRA 会退化成近似全参微调,过拟合风险上升,显存也增加。
4. 目标模块的选择
# 保守方案(参数量最少)
target_modules = ["q_proj", "v_proj"]
# 平衡方案(推荐默认)
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj"]
# 激进方案(效果最好,参数最多)
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"]5. 常见坑点速查
- 坑 1:
padding_side设置错误。训练时应该用right或left取决于模型,推理生成时必须是left。搞错会导致输出乱码。
- 坑 2:忘记设置
tokenizer.pad_token,训练直接报错。
- 坑 3:QLoRA 训练时用了
adamw_torch而不是paged_adamw_8bit,长序列必 OOM。
- 坑 4:LoRA 权重保存后,推理时忘了
merge_and_unload(),导致推理延迟没优化。
- 坑 5:用 bf16 保存全参微调结果后,再用 fp16 加载做推理,出现溢出。统一精度,或者用 fp32 保存。
- 坑 6:多卡训练 LoRA 时,用 DDP 而不是 FSDP,导致每张卡都存完整基座,显存没省。用 FSDP +
sync_module_states才能真省。
6. 一个实用的选型决策树
总结
回到开头那个律所项目。我们最终的方案是:QLoRA 微调 Qwen2-72B,单张 A100 80G 训练了 36 小时,得到不到 100MB 的 adapter。部署时把 adapter 合并到 fp16 基座,再用 AWQ 量化到 4-bit,最终单卡 48G 就能推理,响应延迟 800ms 左右。律师们反馈,模型对法律术语的理解明显改善,RAG 检索的片段利用率也上升了。
三种技术路线的核心取舍可以概括为:
- 全参微调:能力最强、成本最高,适合有充足算力和数据、需要根本性改造模型的场景。但生产环境里,真正需要它的场景其实比你想的少。
- LoRA:性价比之王。0.1% 的参数量拿到接近全参的效果,还支持多任务 adapter 热插拔,是绝大多数生产项目的默认选择。
- QLoRA:显存救星。用一点精度损失换取 4 倍的显存节省,让大模型微调从"多卡集群"走进"单卡服务器"。数据量不大、对精度要求不是极致时,它是首选。
最后我想强调一个观点:微调不是万能药。很多团队一上来就想微调,但 80% 的场景 RAG + Prompt Engineering 就够了。微调真正的价值在于:改变模型的行为模式(语气、格式、推理风格),而不是注入事实知识(这更适合 RAG)。理解这个边界,比掌握具体技术更重要。
延伸阅读方向:如果你已经掌握 LoRA,下一步可以研究 DoRA(Weight-Decomposed Low-Rank Adaptation)、LoRA+(差异化学习率)、GaLore(梯度低秩投影),以及如何把微调模型接入 LangGraph 构建多步推理 Agent。这些是 2026 年值得投入的方向。