1. 问题背景:7B模型微调,显存是第一道坎

最近接到一个任务:需要把Qwen2-7B-Instruct微调到某个垂直领域(法律文书摘要),要求模型能输出符合特定格式的摘要。全参数微调在单卡A100(40G)上跑没问题,但公司资源有限,能申请到的只有一张RTX 4090(24G)。跑了一次全参微调,batch size=1、seq_len=2048时显存直接爆掉——7B模型的AdamW优化器状态就要吃掉约4倍参数量大小的显存,即28GB,加上激活值根本不够。

于是转向LoRA和QLoRA。这两者本质都是“冻结原模型,只训练少量旁路参数”,区别在于QLoRA额外对原模型做了4-bit量化(NormalFloat4),显存占用更低,但会引入量化误差。这篇文章记录我完整跑通这两个方案的过程,包括数据准备、训练配置、loss曲线以及最终的推理效果对比。

2. 环境与版本

强烈建议先固定版本,LLM生态一天一个样,版本不匹配会浪费大量时间排查。我的环境如下:

组件 版本
Python 3.10.12
PyTorch 2.1.2+cu118
transformers 4.40.1
peft 0.10.0
bitsandbytes 0.43.1
datasets 2.19.0
accelerate 0.29.2
GPU NVIDIA RTX 4090 24GB

这里特别提醒:peft和transformers必须配套,pEFT 0.10.0对应transformers>=4.38.0,低于这个版本会报PEFTConfig的import错误。另外bitsandbytes在Windows下需要额外安装bitsandbytes-windows轮子包,否则load_in_4bit=True会直接报CUDA setup failed

3. 方案设计:LoRA vs QLoRA的取舍

先说结论:参数规模决定策略。7B模型全参微调需要约140GB显存(FP16),无人能及;LoRA只训练0.5%-1%的参数,显存占用在16-24G之间;QLoRA进一步把原模型压缩到4-bit,显存能降到8-10G。

我的设计如下:

  • LoRA配置r=16alpha=32target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]dropout=0.05bias="none"
  • QLoRA配置:在LoRA基础上,原模型用BitsAndBytesConfig加载,load_in_4bit=Truebnb_4bit_quant_type="nf4"bnb_4bit_compute_dtype=torch.bfloat16bnb_4bit_use_double_quant=True

关键点:目标模块必须包含所有线性层。很多教程只改q_projv_proj,但在我的实验中发现只改这两个模块,loss下降明显变慢(见第5节)。原因很简单:LoRA的可学习参数太少,拟合能力不足。

4. 核心实现:数据准备与训练代码

4.1 数据准备

法律文书摘要任务,我手动标注了2000条样本(输入为裁判文书原文,输出为300字以内的摘要)。格式为{"instruction": "...", "input": "...", "output": "..."}。用datasets库加载并做tokenize:

from datasets import load_dataset
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2-7B-Instruct", trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token  # Qwen2必须设置pad_token,否则训练报错

def preprocess(example):
    # 构建对话模板(Qwen2的chat模板)
    messages = [
        {"role": "system", "content": "你是一个法律文书摘要专家。"},
        {"role": "user", "content": f"请对以下文书进行摘要:\n{example['input']}"},
        {"role": "assistant", "content": example["output"]}
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False)
    # tokenize并截断到2048
    enc = tokenizer(text, truncation=True, max_length=2048, padding="max_length")
    enc["labels"] = enc["input_ids"].copy()
    # 将padding部分设为-100,避免计算loss
    enc["labels"] = [-100 if t == tokenizer.pad_token_id else t for t in enc["labels"]]
    return enc

dataset = load_dataset("json", data_files="legal_data.jsonl")["train"]
dataset = dataset.map(preprocess, batched=False, remove_columns=dataset.column_names)

这里有个细节:labels中的pad位置必须设为-100。如果不做这一步,模型会在padding token上计算loss,导致loss曲线异常(损失值偏高且不下降)。

4.2 训练配置(LoRA)

from transformers import TrainingArguments, Trainer
from peft import LoraConfig, get_peft_model
import torch

# 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="CAUSAL_LM"
)

# 加载基础模型(LoRA用FP16,QLoRA用4bit)
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto"
)
model = get_peft_model(model, lora_config)
print(f"可训练参数: {model.print_trainable_parameters()}")  # 约0.5%的参数

training_args = TrainingArguments(
    output_dir="./lora_checkpoints",
    num_train_epochs=3,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=4,  # 等效batch_size=8
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    save_steps=200,
    evaluation_strategy="steps",
    eval_steps=200,
    save_total_limit=2,
    remove_unused_columns=False,
    report_to="tensorboard",
    fp16=False,  # QLoRA用bf16更稳定
    bf16=True,
    gradient_checkpointing=True,  # 关键:用显存换速度
    optim="paged_adamw_8bit"  # QLoRA必须用paged优化器
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=dataset,
    eval_dataset=dataset.select(range(200)),
    tokenizer=tokenizer,
)
trainer.train()

4.3 QLoRA只需改加载方式

QLoRA的核心区别在于基础模型的加载方式,其余代码完全一致:

from transformers import BitsAndBytesConfig

bnb_config = 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(
    "Qwen/Qwen2-7B-Instruct",
    quantization_config=bnb_config,
    device_map="auto",
    torch_dtype=torch.bfloat16,
)
model = get_peft_model(model, lora_config)

注意:QLoRA必须设置optim="paged_adamw_8bit",因为4-bit量化下的参数更新需要分页优化器来处理显存碎片。另外gradient_checkpointing=True是必须的,否则24G显存也扛不住7B模型的反向传播。

5. 踩坑与优化:三个关键调整

5.1 训练损失不下降?检查target_modules

第一轮实验我只用了["q_proj", "v_proj"],跑了500步loss从1.8降到1.5后就卡住了。换全部线性层后,loss降到1.2。原因:LoRA的可学习参数只有0.1%,而QKV三个投影矩阵的权重共享了大部分信息,只调其中两个会限制模型的表示能力。

5.2 QLoRA的loss震荡

QLoRA在训练初期loss波动比LoRA大(0.1左右),这是因为4-bit量化的精度损失被梯度放大。后来我做了两个调整:

  • 学习率从2e-4降到1e-4:QLoRA对学习率更敏感,高学习率会导致量化误差累积。
  • warmup_ratio从0.03提到0.1:让模型先用低学习率适应量化权重。

调整后loss曲线明显平滑。

5.3 显存优化技巧

  • gradient_checkpointing=True:显存从24G降到13G,但训练速度慢约30%。
  • per_device_train_batch_size=2 + gradient_accumulation_steps=4:等效batch size=8,显存占用仅为batch size=8的1/4。
  • 将序列长度从2048降到1024:显存减少约40%,但模型效果下降(摘要任务长文关键信息丢失)。最终保留2048。

6. 效果数据:loss曲线与推理对比

6.1 Loss曲线对比

训练了3个epoch(约750步),两种方案的loss曲线如下:

训练步数 LoRA loss QLoRA loss
0 2.31 2.45
100 1.82 1.91
300 1.45 1.52
500 1.28 1.36
750 1.15 1.24

结论:QLoRA的loss整体比LoRA高约0.1,但在最后100步差距缩小到0.09。这说明4-bit量化引入了约5%的信息损失,但并不会导致不收敛。

6.2 推理效果对比

用50条测试集(与训练集不重叠)进行人工评测,对比三个维度:格式正确率(是否按模板输出)、关键信息覆盖率(人工打分)、流畅度(人工打分,5分制)。

模型 格式正确率 关键信息覆盖率 流畅度 推理耗时(每条)
原始Qwen2-7B 12% 0.45 4.2 0.8s
LoRA微调 92% 0.83 4.6 0.8s
QLoRA微调 88% 0.81 4.5 0.9s

关键发现:LoRA和QLoRA在格式正确率上差距不大(92% vs 88%),但在关键信息覆盖率上QLoRA比LoRA低2个百分点。量化损失主要体现在对长文细节的捕捉上。不过QLoRA的显存占用(8.2G)仅为LoRA(16.8G)的一半,并且能跑在消费级显卡上(如RTX 3060 12G就能跑)。

6.3 推理效果示例

输入:(一段3000字的民事判决书……省略)

LoRA输出

原告张三与被告李四房屋买卖合同纠纷一案,法院经审理认定合同有效,被告未按期履行付款义务构成违约,判决被告支付购房款余款人民币200万元及逾期利息,案件受理费由被告负担。

QLoRA输出

原被告房屋买卖合同纠纷,法院判决被告支付购房款200万及利息,诉讼费由被告承担。

虽然QLoRA输出更简略,但关键数字(200万)和判决结果都准确。对于摘要任务,QLoRA的简洁性反而是优势。

7. 总结与建议

方案 显存占用 训练速度(step/s) 效果(F1) 适用场景
全参数微调 >24G(无法运行) - - 需A100/H100
LoRA 16.8G 1.2 0.85 单卡24G+
QLoRA 8.2G 0.98 0.84 消费级显卡

最终建议:

  1. 如果你的显卡≥24G,优先用LoRA。虽然QLoRA显存省一半,但训练时间多18%,且效果略差。没必要为了省显存牺牲效果。
  2. 如果显卡≤16G,QLoRA是唯一选择。RTX 4090 D 16G版、RTX 4080 16G都可以跑,但batch size只能设为1,需要配合梯度累积。
  3. 学习率调整:LoRA用2e-4,QLoRA用1e-4,别偷懒直接用默认值。
  4. 永远不要全参数微调7B模型,除非你有A100且不在乎时间成本。

这次实践让我深刻体会到:在有限资源下,量化+适配器是性价比最高的微调方案。QLoRA虽然损失了约1%的效果,但换来了部署和调试的极大便利。如果后续需要部署到边缘设备,我甚至可以考虑用GPTQ量化到4-bit(推理时无需bitsandbytes),但那是另一个话题了。