大模型微调技术对比: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

这里有几个源码级的关键细节,很多博客不会告诉你:

  1. scaling = alpha / r 的设计意图:当你调整 r 时,无需重新调 learning_rate。alpha 固定,r 变大时 scaling 自动变小,保证有效学习率稳定。这是 LoRA 论文里一个非常工程化的巧思。
  1. B 矩阵零初始化:保证训练开始时 $\Delta W = BA = 0$,模型行为与原始模型完全一致,然后平滑地开始学习。这一点至关重要——如果 A、B 都随机初始化,训练初期会出现明显的性能抖动。
  1. dropout 的位置:注意是 dropout(x) 而不是 dropout(B(A(x)))。dropout 作用在输入上,与标准 Linear 层的 dropout 位置一致。
  1. 哪些层加 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。

三种方案的架构对比

graph TD subgraph FullFT["全参微调"] A1[输入 x] --> A2[W0 更新] A2 --> A3[输出 h] A4[梯度更新全部参数] end subgraph LoRA["LoRA"] B1[输入 x] --> B2[W0 冻结] B1 --> B3[A: r×k 可训练] B3 --> B4[B: d×r 可训练] B2 --> B5[相加] B4 --> B5 B5 --> B6[输出 h] end subgraph QLoRA["QLoRA"] C1[输入 x] --> C2[W0 4-bit NF4 冻结] C1 --> C3[A: r×k 可训练] C3 --> C4[B: d×r 可训练] C2 --> C5[反量化 + 相加] C4 --> C5 C5 --> C6[输出 h] end style A2 fill:#ffcccc style B2 fill:#ccffcc style C2 fill:#cce5ff

图中红色表示"需要更新",绿色表示"冻结但保持高精度",蓝色表示"冻结且量化"。可以看到 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. 一个实用的选型决策树

graph TD Start[开始微调] --> Q1{显存是否充足?} Q1 -->|单卡 80G+| Q2{任务是否根本性改变?} Q1 -->|多卡集群| Q2 Q1 -->|单卡 24G 以下| QLoRA[直接上 QLoRA] Q2 -->|是, 需要彻底改造| Full[全参微调 + ZeRO-3] Q2 -->|否, 领域适配/风格对齐| Q3{显存是否够 LoRA?} Q3 -->|够| LoRA[标准 LoRA] Q3 -->|不够| QLoRA Full --> Deploy1[合并权重 + GPTQ 量化部署] LoRA --> Deploy2[adapter 热插拔 或 合并部署] QLoRA --> Deploy3[合并到 fp16 基座 + AWQ 量化]

总结

回到开头那个律所项目。我们最终的方案是: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 年值得投入的方向。