一、为什么要在7B模型上折腾LoRA

先说背景。我们团队有个内部知识问答场景,数据涉及公司文档,不能走公有云API。试过直接prompt Qwen2.5-7B-Instruct,在“按固定JSON格式输出”“多轮追问时保持字段一致”这两类任务上翻车率很高,人工统计100条测试集,格式正确率只有62%。

全量微调7B?单卡4090想都别想,bf16权重就要15GB,加上优化器状态和梯度,至少需要80GB以上显存。所以LoRA基本是唯一选择。而QLoRA则是在显存更紧张(比如只有一张3090 24GB但还要留出推理空间)时的备选。

这篇文章不吹概念,只讲我实际跑通的流程和踩过的坑。

二、环境与版本

环境不一致是复现最大的敌人,先把版本钉死:

  • GPU:RTX 4090 24GB(LoRA)/ RTX 3090 24GB(QLoRA对比)
  • CUDA:12.1
  • PyTorch:2.4.0+cu121
  • transformers:4.44.2
  • peft:0.13.2
  • bitsandbytes:0.43.3(QLoRA必需)
  • LLaMA-Factory:0.9.1
  • Python:3.10.14

安装命令(建议用conda新建环境):

conda create -n lora_ft python=3.10 -y
conda activate lora_ft
pip install torch==2.4.0 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.44.2 peft==0.13.2 bitsandbytes==0.43.3
pip install llamafactory==0.9.1

注意:bitsandbytes在Windows上支持较差,本文全程在Ubuntu 22.04下完成。

三、方案设计

3.1 数据准备

自建数据集,共8620条,格式为alpaca风格:

{
  "instruction": "从下面文本中抽取公司名称和成立年份,以JSON输出",
  "input": "字节跳动成立于2012年3月...",
  "output": "{\"company\": \"字节跳动\", \"founded\": 2012}"
}

划分:train 8000条,eval 620条。这里有个坑:eval集必须和train同分布但不同模板,否则eval_loss会虚低。我特意在eval里换了三种不同的指令措辞。

数据放在 data/mydata.json,同时在LLaMA-Factory的 data/dataset_info.json 里注册:

{
  "my_instruct": {
    "file_name": "mydata.json",
    "columns": {
      "prompt": "instruction",
      "query": "input",
      "response": "output"
    }
  }
}

3.2 LoRA vs QLoRA 配置选择

LoRA:r=16,lora_alpha=32,target_modules选 q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj(全attention+FFN),lora_dropout=0.05。

QLoRA:在LoRA基础上加 quantization_bit=4,量化类型 nf4,双量化开启,计算dtype用bf16。

为什么r选16?我试过r=8和r=32,r=8在格式任务上欠拟合,r=32相比r=16提升不到1%,但显存多了1.8GB,性价比低。

四、核心实现

4.1 LoRA训练命令

LLaMA-Factory封装得很好,直接命令行:

llamafactory-cli train \
  --stage sft \
  --model_name_or_path Qwen/Qwen2.5-7B-Instruct \
  --dataset my_instruct \
  --template qwen \
  --finetuning_type lora \
  --lora_rank 16 \
  --lora_alpha 32 \
  --lora_dropout 0.05 \
  --lora_target q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj \
  --output_dir saves/qwen2.5-7b/lora/sft \
  --per_device_train_batch_size 2 \
  --gradient_accumulation_steps 8 \
  --learning_rate 1e-4 \
  --num_train_epochs 3.0 \
  --lr_scheduler_type cosine \
  --warmup_ratio 0.1 \
  --bf16 true \
  --logging_steps 10 \
  --save_steps 200 \
  --eval_steps 200 \
  --cutoff_len 1024 \
  --gradient_checkpointing true

有效batch size = 2 × 8 = 16。学习率1e-4是LoRA的甜点区,试过5e-5收敛太慢,2e-4在第2个epoch就开始震荡。

4.2 QLoRA配置差异

只需要改三处:

  --quantization_bit 4 \
  --quantization_type nf4 \
  --double_quantization true

其余保持一致,这样才能公平对比。

4.3 纯PEFT手写版(如果你不想用LLaMA-Factory)

给喜欢自己掌控的读者:

from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import BitsAndBytesConfig
import torch

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
)

model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)

lora_config = LoraConfig(
    r=16, lora_alpha=32, lora_dropout=0.05,
    target_modules=["q_proj","k_proj","v_proj","o_proj",
                    "gate_proj","up_proj","down_proj"],
    bias="none", task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 20,185,088 || all params: 7,635,801,600 || trainable%: 0.2643

可训练参数约2018万,占比0.26%,这就是LoRA香的地方。

五、踩坑与优化

坑1:loss不降反升。 一开始用了默认的 learning_rate=5e-5lr_scheduler=linear,前300步loss在1.4上下横盘。换成cosine + warmup 0.1后明显改善。

坑2:QLoRA下gradient_checkpointing报错。 bitsandbytes 0.43.3需要显式调用 model.enable_input_require_grads(),否则反向传播时输入没有梯度,直接报RuntimeError。LLaMA-Factory内部已经处理,手写版记得加。

坑3:eval_loss比train_loss还低。 一开始很慌,后来发现是dropout + eval集模板太相似导致的。换模板后eval_loss才回到合理区间(略高于train)。

坑4:合并权重后推理变差。merge_and_unload() 合并后直接加载,输出乱码。原因是合并时dtype没对齐,必须显式 torch_dtype=torch.bfloat16

六、效果数据

6.1 Loss曲线

LoRA(3 epoch,约1500 step):
- step 100:train_loss 1.38,eval_loss 1.42
- step 500:train_loss 1.05,eval_loss 1.13
- step 1000:train_loss 0.82,eval_loss 0.94
- step 1500:train_loss 0.71,eval_loss 0.87

eval_loss在step 1200后基本平了,第3个epoch收益很小,说明数据量下r=16已经接近容量上限。

QLoRA曲线几乎重合,eval_loss最终0.89,比LoRA高0.02,属于量化误差的正常范围。

6.2 显存与耗时

方案 峰值显存 单epoch耗时
LoRA (bf16) 19.2 GB 42 min
QLoRA (NF4) 11.4 GB 58 min

QLoRA省了41%显存,但慢了约38%,因为反量化有额外开销。

6.3 推理效果对比

测试集100条,人工评分:

  • 原始Qwen2.5-7B-Instruct:格式正确率62%,字段完整率71%
  • LoRA微调后:格式正确率89%,字段完整率93%
  • QLoRA微调后:格式正确率87%,字段完整率91%

举个具体例子,输入“从下面文本抽取公司名和成立年份”,原始模型会输出一段解释性文字,微调后稳定输出 {"company":"...","founded":...}

推理时LoRA适配器加载延迟约0.8s,合并权重后无额外开销。

七、总结

LoRA在7B模型上是真香,2018万可训练参数就能把格式遵循率从62%拉到89%。QLoRA适合显存吃紧的场景,代价是训练慢38%、效果略降2个点。

几条经验:
1. r=16 + alpha=32 + 全target_modules 是7B任务的稳妥起点
2. 学习率1e-4 + cosine + warmup 0.1 比默认配置稳得多
3. eval集一定要换模板,否则loss曲线会骗你
4. 合并权重时dtype必须显式指定bf16
5. 3个epoch通常够用,再多就是过拟合

代码和数据模板都整理在上面的命令里,直接改路径就能跑。有问题的欢迎评论区交流。