大模型微调:LoRA与QLoRA实战

大模型能力强但通用——想让它在特定领域(医疗问答、代码生成、客服对话)表现更好,微调是最直接的手段。全参数微调成本过高,LoRA 和 QLoRA 的出现让普通 GPU 也能跑微调。本文从原理到代码,完整走一遍用 PEFT 微调 Qwen 模型的流程。

为什么要微调

预训练大模型(如 Qwen-7B、Llama-2-7B)在通用任务上表现不错,但在特定场景下往往不够精准。常见的需求:

  • 领域适配:让模型理解医疗术语、法律条文、金融报表
  • 风格控制:让输出符合特定格式(JSON、特定模板)
  • 知识注入:让模型掌握私有知识库的内容

微调的本质就是在预训练权重上继续训练,让模型"记住"新的模式。

全参数微调的问题

一个 7B 模型(70亿参数)用 FP16 存储就要 14GB 显存,训练时梯度和优化器状态还要再乘 3-4 倍。粗略算一下:

模型参数: 7B × 2 bytes (FP16) = 14 GB
梯度:     7B × 2 bytes        = 14 GB
AdamW 优化器状态: 7B × 8 bytes = 56 GB
总计: ~84 GB

一张 A100 80GB 都不够。更别说 13B、70B 的模型了。

LoRA 原理

LoRA(Low-Rank Adaptation)的核心思想非常优雅:不修改原始权重,而是给每个目标层注入一对低秩矩阵 A 和 B

原始的线性层计算:y = Wx

加上 LoRA 后:y = Wx + BAx

其中 W 是原始权重矩阵(冻结不训练),B 和 A 是新增的低秩矩阵:

  • W 的形状:d × d(比如 4096 × 4096)
  • A 的形状:r × d(r 是秩,通常取 8、16、64)
  • B 的形状:d × r

可训练参数量从 降到 2 × d × r。当 d=4096, r=16 时,参数量从 1600万 降到 13万,减少了 99%

# LoRA 的核心实现(简化版)
import torch
import torch.nn as nn

class LoRALinear(nn.Module):
    def __init__(self, original_linear: nn.Linear, rank: int = 16, alpha: float = 32):
        super().__init__()
        self.original = original_linear
        self.original.weight.requires_grad_(False)  # 冻结原始权重

        d_in = original_linear.in_features
        d_out = original_linear.out_features

        self.lora_A = nn.Parameter(torch.randn(rank, d_in) * 0.01)
        self.lora_B = nn.Parameter(torch.zeros(d_out, rank))
        self.scaling = alpha / rank

    def forward(self, x):
        original_out = self.original(x)
        lora_out = (x @ self.lora_A.T @ self.lora_B.T) * self.scaling
        return original_out + lora_out

关键参数:

  • rank (r):秩,越大表达能力越强但参数越多。一般任务 8-16 够用,复杂任务可以到 64
  • alpha:缩放系数,通常设为 rank 的 2 倍
  • target_modules:对哪些层加 LoRA,一般是 attention 的 q_proj、v_proj

QLoRA:4-bit 量化 + LoRA

QLoRA 在 LoRA 基础上更进一步:把冻结的原始权重量化到 4-bit,大幅减少显存占用。

核心技术:

  1. NF4 量化:一种信息论最优的 4-bit 量化方式,比普通 INT4 精度更高
  2. 双重量化:连量化参数本身也量化,进一步省显存
  3. 分页优化器:利用 CPU 内存处理显存溢出

显存对比(7B 模型微调):

方法 显存需求
全参数微调 (FP16) ~84 GB
LoRA (FP16) ~16 GB
QLoRA (NF4) ~6 GB

6GB 意味着一张 RTX 3060 就能微调 7B 模型。

环境准备

pip install torch transformers datasets peft bitsandbytes accelerate trl
# bitsandbytes 提供量化支持
# peft 是 HuggingFace 的参数高效微调库
# trl 提供 SFTTrainer

数据集准备

微调数据一般是指令-回答对。常见格式:

[
    {
        "instruction": "解释什么是梯度下降",
        "input": "",
        "output": "梯度下降是一种优化算法,通过计算损失函数对参数的梯度..."
    },
    {
        "instruction": "将以下文本翻译成英文",
        "input": "今天天气不错",
        "output": "The weather is nice today."
    }
]

实际项目中,数据质量比数量重要得多。我的经验是:500-2000 条高质量数据 > 10000 条噪声数据

数据清洗要点:

  • 去除重复和矛盾的样本
  • 确保输出格式一致
  • 长度分布合理(不要全是短回答)
  • 覆盖目标场景的各种 case

将数据处理成 HuggingFace Dataset 格式:

from datasets import load_dataset

dataset = load_dataset("json", data_files="train_data.json", split="train")

def format_instruction(example):
    '''转换为模型需要的对话格式'''
    if example["input"]:
        text = (
            f"<|im_start|>user\n{example['instruction']}\n"
            f"输入:{example['input']}<|im_end|>\n"
            f"<|im_start|>assistant\n{example['output']}<|im_end|>"
        )
    else:
        text = (
            f"<|im_start|>user\n{example['instruction']}<|im_end|>\n"
            f"<|im_start|>assistant\n{example['output']}<|im_end|>"
        )
    return {"text": text}

dataset = dataset.map(format_instruction)

微调 Qwen-7B-Chat

完整的训练脚本:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset

# === 1. 量化配置(QLoRA) ===
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

# === 2. 加载模型和 tokenizer ===
model_name = "Qwen/Qwen-7B-Chat"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

# === 3. 准备模型 ===
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False  # 训练时关闭 KV cache

# === 4. LoRA 配置 ===
lora_config = LoraConfig(
    r=16,                          # 秩
    lora_alpha=32,                 # 缩放系数
    target_modules=[               # 对哪些层加 LoRA
        "c_attn",                  # Qwen 的 attention 层名
        "c_proj",
        "w1", "w2",                # FFN 层
    ],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 13,107,200 || all params: 7,734,013,952 || trainable%: 0.1695

# === 5. 加载数据集 ===
dataset = load_dataset("json", data_files="train_data.json", split="train")
dataset = dataset.map(format_instruction)

# === 6. 训练 ===
training_args = SFTConfig(
    output_dir="./qwen-7b-lora",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,   # 等效 batch_size = 16
    learning_rate=2e-4,
    weight_decay=0.01,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
    logging_steps=10,
    save_strategy="epoch",
    bf16=True,
    max_seq_length=2048,
    dataset_text_field="text",
    gradient_checkpointing=True,     # 省显存
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=dataset,
    tokenizer=tokenizer,
)

trainer.train()
trainer.save_model("./qwen-7b-lora/final")

关键训练参数说明

参数 推荐值 说明
r (rank) 8-64 任务越复杂用越大的值
lora_alpha 2×r 缩放系数,影响 LoRA 权重的强度
learning_rate 1e-4 ~ 3e-4 QLoRA 一般比全参数微调用更大的 lr
epochs 2-5 数据少可以多跑几轮,注意过拟合
batch_size 4-16 受显存限制,用 gradient_accumulation 补
max_seq_length 1024-4096 根据数据长度设定,太长会爆显存

推理使用

训练完成后加载 LoRA 权重进行推理:

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer

# 加载基座模型
base_model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen-7B-Chat",
    device_map="auto",
    trust_remote_code=True,
    torch_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen-7B-Chat", trust_remote_code=True)

# 加载 LoRA 权重
model = PeftModel.from_pretrained(base_model, "./qwen-7b-lora/final")

# 合并权重(可选,合并后推理更快但无法再切换 LoRA)
model = model.merge_and_unload()

# 推理
prompt = "<|im_start|>user\n你的问题<|im_end|>\n<|im_start|>assistant\n"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
outputs = model.generate(**inputs, max_new_tokens=512, temperature=0.7)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))

微调 Llama-2

Llama 的流程基本一样,只是 target_modules 不同:

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

不同模型的层命名不同,需要查看模型结构确定。一个简单的方法:

for name, _ in model.named_modules():
    print(name)

常见问题

Q: LoRA 和全参数微调效果差多少?
大部分任务差距在 1-3% 以内。rank 设大一些(32-64)基本可以逼近全参数微调的效果。

Q: 数据量多少合适?
取决于任务复杂度。简单的格式适配 200-500 条就够,复杂的领域知识注入可能需要 5000+。

Q: 怎么判断过拟合?
留 10% 数据做验证集,观察 eval_loss。如果 train_loss 持续下降但 eval_loss 开始上升,就是过拟合了。降低 epoch 数或加 dropout。

Q: 多个 LoRA 能叠加吗?
可以。PEFT 支持同时加载多个 LoRA adapter 并切换,适合多任务场景。

小结

LoRA/QLoRA 把大模型微调的门槛从"需要一台 DGX"降到了"一张消费级显卡"。整个流程的关键不在代码——PEFT 库已经封装得很好了——而在数据质量超参数调优。建议先从小数据集快速验证效果,再逐步扩大规模。