一、问题背景:为什么放弃全量微调

先说结论:7B模型全量微调,显存需求远超大多数开发者的硬件条件。以LLaMA-2-7B为例,参数量约70亿,每个参数用FP16存储需要2字节,光权重就占14GB。加上梯度(14GB)、优化器状态(AdamW需要28GB),以及激活值,训练时峰值显存轻松超过80GB。这还没算上中间变量和CUDA context的开销。

我手头只有一台单卡RTX 4090(24GB显存),显然跑不动全量微调。但任务又必须做:需要把模型在通用能力基础上,增强法律领域的专业问答能力。于是转向LoRA(Low-Rank Adaptation)方案。

LoRA的核心思想是冻结预训练模型的权重,在Transformer层中插入低秩矩阵来学习任务相关的适配参数。这样做的好处是:
1. 可训练参数量大幅减少,7B模型只需训练约0.1%的参数(约800万)
2. 显存需求主要来自激活值而非梯度/优化器状态
3. 训练速度提升明显

但LoRA在24G显存下仍然有点紧——虽然比全量微调好很多,但有时会OOM,尤其是在batch size稍大的情况下。于是我用了QLoRA(Quantized LoRA),把基座模型量化到4-bit,进一步压低显存占用。

二、环境与版本

先列一下我的环境:

- GPU: NVIDIA RTX 4090 24GB
- CUDA: 12.1
- Python: 3.10
- PyTorch: 2.1.2
- transformers: 4.38.2
- peft: 0.10.0
- bitsandbytes: 0.43.0
- datasets: 2.18.0
- accelerate: 0.27.2
- 基座模型: meta-llama/Llama-2-7b-chat-hf

这里必须强调版本兼容性。我一开始用的transformers 4.35.0,peft的QLoRA实现会报错。升级到4.38.2后问题消失。另外bitsandbytes版本也很敏感,0.43.0配合CUDA 12.1是稳定组合。

三、方案设计:LoRA vs QLoRA的选择

LoRA和QLoRA的核心区别在于基座模型的精度:

  • LoRA:基座模型保持FP16精度,插入LoRA适配器训练
  • QLoRA:基座模型用4-bit量化存储,LoRA适配器在计算时反量化到BF16

QLoRA的优势是显存占用更低,理论上可以用更小的GPU跑更大的模型。代价是4-bit计算精度略低,但实际效果差异不大——因为我们训练的是LoRA适配器,基座模型只是被冻结的前向传播。

我的选择:先用LoRA跑一版,记录显存和loss曲线;然后切到QLoRA,对比最终效果

LoRA配置:

from peft import LoraConfig, get_peft_model, TaskType

lora_config = LoraConfig(
    r=8,                    # 低秩矩阵的秩,决定可训练参数量
    lora_alpha=32,          # 缩放因子,一般设为r的2-4倍
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],  # 只适配Attention层
    lora_dropout=0.05,      # 防过拟合,如果数据量小可以加到0.1
    bias="none",
    task_type=TaskType.CAUSAL_LM,  # 因果语言模型任务
)

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-chat-hf",
    torch_dtype=torch.float16,
    device_map="auto",
)
model = get_peft_model(model, lora_config)
print(f"可训练参数: {model.num_parameters(only_trainable=True) / 1e6:.2f}M")
print(f"总参数: {model.num_parameters() / 1e9:.2f}B")

QLoRA配置(关键差异在load_in_4bit=True):

from transformers import BitsAndBytesConfig

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",      # 使用Normal Float 4-bit量化
    bnb_4bit_compute_dtype=torch.bfloat16,  # 计算时用BF16
    bnb_4bit_use_double_quant=True, # 二次量化,进一步省显存
)

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-chat-hf",
    quantization_config=bnb_config,
    device_map="auto",
)
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, lora_config)

注意:QLoRA必须调用prepare_model_for_kbit_training,它会处理量化模型前向传播时的梯度问题。

四、数据准备:法律问答数据集的清洗与格式化

任务场景是法律问答,用户提问,模型给出法律依据和结论。我用的数据集是从公开法律问答社区爬取的,共12000对问答,清洗后剩9800对有效数据。格式采用LLaMA-2的chat模板:

[INST] 用户问题 [/INST] 模型回答 

数据预处理的核心是tokenization和padding。这里有个关键点:如果直接把所有样本pad到相同长度,会浪费大量计算在pad token上。我用的是DataCollatorForSeq2Seq,它会在每个batch内动态padding到该batch的最大长度,而不是全局固定长度。

from transformers import AutoTokenizer, DataCollatorForSeq2Seq

tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-chat-hf")
tokenizer.pad_token = tokenizer.eos_token  # 关键:设置pad_token

def format_example(example):
    prompt = f"[INST] {example['question']} [/INST]"
    response = f" {example['answer']} "
    full_text = prompt + response
    # 只对answer部分计算loss,prompt部分用-100屏蔽
    prompt_len = len(tokenizer(prompt, add_special_tokens=False)["input_ids"])
    full_ids = tokenizer(full_text, add_special_tokens=False)["input_ids"]
    labels = [-100] * prompt_len + full_ids[prompt_len:]
    return {"input_ids": full_ids, "labels": labels}

dataset = dataset.map(format_example, remove_columns=["question", "answer"])
data_collator = DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True)

这里label屏蔽很重要——如果不屏蔽prompt部分,模型会学习预测用户问题而不是回答,这会让loss在训练初期居高不下,且影响最终回答质量。

五、训练配置与loss曲线

训练超参我试了多个组合,最终稳定在以下配置:

training_args = TrainingArguments(
    output_dir="./lora-legal-7b",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=8,   # 等效batch size = 4*8 = 32
    learning_rate=2e-4,              # LoRA通常用较高的LR
    warmup_steps=100,
    num_train_epochs=3,
    logging_steps=10,
    save_strategy="epoch",
    fp16=True,                       # LoRA用FP16;QLoRA用BF16
    optim="paged_adamw_8bit",       # 8-bit优化器,省显存
    gradient_checkpointing=True,     # 关键:用显存换计算
)

显存占用对比:
- LoRA(batch=4, grad_accum=8):峰值22.3GB,偶尔OOM
- QLoRA(batch=4, grad_accum=8):峰值17.1GB,稳定运行

Loss曲线方面,LoRA和QLoRA的收敛趋势非常接近。第一轮epoch loss从2.31降到1.87,第二轮降到1.52,第三轮在1.41附近震荡。没有出现严重的过拟合。以下是训练日志中截取的典型loss变化:

step 100 | loss: 2.213
step 200 | loss: 1.984
step 500 | loss: 1.721
step 1000 | loss: 1.568
step 1500 | loss: 1.493
step 2000 | loss: 1.447
step 2500 | loss: 1.418
step 3000 | loss: 1.396

一个细节:QLoRA的loss比LoRA略高(约0.02-0.05),但最终效果差异不大。如果显存不紧张,优先用LoRA;如果显存紧张,QLoRA是个很好的折中。

六、踩坑与优化

这里记录三个我踩过的最深的坑:

坑1:pad_token未设置导致显存爆炸
一开始没有设置tokenizer.pad_token = tokenizer.eos_token,导致padding时生成随机token,且长度不一致,最终batch padding后显存占用飙升,直接OOM。设置后问题解决。

坑2:梯度检查点与量化兼容问题
在QLoRA下开gradient_checkpointing=True时,需要调用model.gradient_checkpointing_enable()并确保prepare_model_for_kbit_training已经调用。否则会报RuntimeError: element 0 of tensors does not require grad。解决方案是调整调用顺序。

坑3:学习率过大导致loss崩坏
一开始用5e-4,loss在500步后突然飙升到3.8,明显是发散。降到2e-4后稳定。LoRA的LR上限一般比全量微调高,但不能太高,建议范围1e-43e-4

七、推理效果对比

我用测试集(500条法律问答)对比了三个模型的效果:
1. 原始LLaMA-2-7B-chat(未微调)
2. LoRA微调后的模型
3. QLoRA微调后的模型

评测标准:
- 准确率:回答中是否包含正确的法律条文引用
- 相关性:回答与问题的语义相关性(用BERTScore计算)
- 流畅度:人工打分(1-5分)

结果如下:

模型 法律条文引用准确率 BERTScore 人工流畅度
原始模型 54.7% 0.782 3.2
LoRA微调 82.3% 0.893 4.5
QLoRA微调 81.6% 0.889 4.4

从具体案例看差异更明显:

问题:劳动合同到期后公司不续签,需要赔偿吗?

  • 原始模型回答:比较模糊,提到“可能需要看合同约定”等泛泛之谈,没有引用具体法律条文。
  • LoRA微调回答:明确引用《劳动合同法》第四十六条,说明“除用人单位维持或者提高劳动条件续订合同而劳动者不同意续订的情形外,合同到期终止需要支付经济补偿金”,还补充了计算方式。
  • QLoRA微调回答:与LoRA基本一致,仅表述略有差异。

训练时间对比:
- 全量微调(理论估算):约12小时/epoch(在80G A100上)
- LoRA微调:约2.5小时/epoch(在4090上)
- QLoRA微调:约2.1小时/epoch(在4090上)

八、总结

最终我选择QLoRA方案部署到生产环境,因为它在显存占用和效果之间取得了最佳平衡。虽然LoRA的效果略好一点(82.3% vs 81.6%),但QLoRA的显存优势让我可以稳定训练,不用反复调整batch size。

几个核心经验:
1. LoRA/QLoRA不是二选一,而是看硬件条件。显存允许就用LoRA,不允许就用QLoRA,效果差异在1%以内。
2. 数据质量比微调方法更重要。我清洗数据花了2天,训练只花了3小时。
3. target_modules的选择影响大。我只适配了Attention层的q/k/v/o投影,没有动MLP层。如果想追求更高效果,可以尝试加入"gate_proj", "up_proj", "down_proj",但参数量会翻倍。
4. label屏蔽不可省略。这是新手最容易忽略的细节。

如果你也在微调7B模型,建议先跑通QLoRA流程,再根据效果决定是否切换到LoRA。代码已经整理好,可以把我这份配置直接作为起点。有问题欢迎在评论区交流。