一、为什么我要在24G显存上微调7B模型

先说背景。我手头有一个垂直领域的问答需求——大约1.2万条“用户提问→标准回答”的指令数据,基座模型是Qwen2.5-7B-Instruct。直接拿基座模型去跑,回答风格太“通用”,术语不准确,甚至会把行业黑话理解错。全量微调7B?一张4090的24G显存根本不够,即使用DeepSpeed ZeRO-3,也得至少2张A100 40G起步。所以LoRA就成了最现实的选择。

LoRA(Low-Rank Adaptation)的核心思路很简单:冻结原模型权重,在Transformer的注意力层注入低秩矩阵A和B,训练时只更新这两个小矩阵。QLoRA更进一步,把基座模型用4-bit NF4量化加载,再在上面挂LoRA,显存直接砍到原来的1/4左右。我最终选的是QLoRA + LoRA混合方案:4-bit加载基座,LoRA用bf16训练。

环境版本先列清楚,避免你踩版本坑:

  • Python 3.10.13
  • PyTorch 2.3.1 + CUDA 12.1
  • transformers 4.44.2
  • peft 0.12.0
  • bitsandbytes 0.43.3
  • trl 0.9.6
  • accelerate 0.33.0
  • 硬件:RTX 4090 24G,系统内存64G

二、数据准备:1.2万条指令怎么洗

数据格式我用的是标准的Alpaca格式,每条样本包含instructioninputoutput三个字段。原始数据来自业务日志和人工标注,质量参差不齐。清洗步骤:

  1. 去重:用SimHash做近似去重,阈值设0.85,删掉约800条重复。
  2. 长度过滤:output长度小于10个token或大于1024个token的丢弃,删掉约300条。
  3. 敏感词过滤:正则匹配手机号、身份证号,替换为占位符。
  4. 格式统一:把所有output末尾的“以上” “谢谢”等废话模板去掉。

最终得到11,200条干净数据,按9:1切分训练集和验证集。数据加载我用的是datasets库,tokenize时把instruction和input拼成prompt,output作为label,并且只对output部分计算loss——这点很关键,否则模型会学会复读问题。

from datasets import load_dataset
from transformers import AutoTokenizer

model_id = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

def format_and_tokenize(example):
    prompt = f"### 指令:\n{example['instruction']}\n\n### 输入:\n{example['input']}\n\n### 回答:\n"
    answer = example['output'] + tokenizer.eos_token
    prompt_ids = tokenizer(prompt, add_special_tokens=False).input_ids
    answer_ids = tokenizer(answer, add_special_tokens=False).input_ids
    input_ids = prompt_ids + answer_ids
    labels = [-100] * len(prompt_ids) + answer_ids  # 只对answer计算loss
    return {"input_ids": input_ids, "labels": labels, "attention_mask": [1]*len(input_ids)}

dataset = load_dataset("json", data_files={"train": "train.json", "valid": "valid.json"})
tokenized = dataset.map(format_and_tokenize, remove_columns=dataset["train"].column_names)

注意:Qwen2.5的tokenizer默认没有pad_token,必须手动设成eos_token,否则训练时会报错。

三、训练配置:4-bit量化 + LoRA参数怎么选

QLoRA的配置我调了3轮才稳定。关键参数如下:

  • 量化:load_in_4bit=Truebnb_4bit_quant_type="nf4"bnb_4bit_compute_dtype=torch.bfloat16bnb_4bit_use_double_quant=True
  • LoRA:r=16lora_alpha=32lora_dropout=0.05target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"]
  • 训练:per_device_train_batch_size=4gradient_accumulation_steps=8,等效batch size=32
  • 优化器:paged_adamw_8bit,学习率2e-4,cosine调度,warmup_ratio=0.03
  • epoch=3,max_length=1024,bf16=True,gradient_checkpointing=True

这里有个坑:target_modules如果只写q_projv_proj,效果会差一截。我实测把FFN层的gate_projup_projdown_proj也加上,验证集loss能再降0.08左右。但显存会多占约1.5G,24G卡刚好扛住。

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

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(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    task_type="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()
# 输出:trainable params: 40,370,176 || all params: 7,655,986,688 || trainable%: 0.5273

可训练参数只有4037万,占比0.53%,这就是LoRA的威力。

四、训练过程与loss曲线

训练在单卡4090上跑了约4小时20分钟,3个epoch,总步数约1050步。显存峰值22.3G,基本吃满。loss曲线大致分三段:

  • 第1个epoch:loss从1.86快速降到1.12,下降很陡,模型在快速适应指令格式。
  • 第2个epoch:loss从1.12缓降到0.85,开始学领域知识。
  • 第3个epoch:loss从0.85降到0.72,趋于平缓,验证集loss最低点出现在第2.8个epoch附近,约0.78。

我保存了每个epoch的checkpoint,最后用验证集loss最低的那个。这里提醒一句:不要只看训练loss,QLoRA很容易过拟合。我第3个epoch训练loss降到0.72,但验证loss从0.76微升到0.78,说明已经有点过拟合了。所以早停策略很重要,我设的是early_stopping_patience=2

训练命令用accelerate launch,配置里mixed_precision="bf16"gradient_checkpointing=True。日志里train_loss每10步打一次,eval_loss每100步一次。

五、推理效果对比:基座 vs 微调

推理我用了两种方式:一是直接加载LoRA adapter,二是把LoRA权重merge回基座再推理。merge后推理速度更快,显存占用也更低(因为不用保留adapter的额外计算图)。

测试集是200条人工标注的领域问答,评价指标是“回答准确率”(人工判定是否包含关键术语且逻辑正确)。结果:

模型 准确率 平均响应长度 显存占用
基座Qwen2.5-7B-Instruct 43% 128 token 14.2G
+LoRA(r=16) 89% 96 token 16.1G
+LoRA(r=8) 81% 102 token 15.3G

微调后准确率翻了整整一倍。而且回答更简洁——基座模型喜欢绕圈子,微调后直接给结论。举一个实际例子:

提问:“XX材料的屈服强度标准是多少?”
基座回答:“屈服强度是材料的重要力学性能,具体标准取决于材料类型和应用场景,一般需要参考相关国家标准……”
微调后回答:“根据GB/T 228.1-2021,XX材料屈服强度标准为≥355MPa,测试温度23±5℃。”

差距一目了然。

推理代码:

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer

base = AutoModelForCausalLM.from_pretrained(
    model_id, torch_dtype=torch.bfloat16, device_map="auto", trust_remote_code=True
)
model = PeftModel.from_pretrained(base, "./lora_checkpoint")
model = model.merge_and_unload()  # merge后推理更快

tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
prompt = "### 指令:\nXX材料的屈服强度标准是多少?\n\n### 输入:\n\n### 回答:\n"
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
outputs = model.generate(**inputs, max_new_tokens=256, do_sample=False)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))

六、踩坑与优化建议

踩坑记录我列几条最典型的:

  1. loss不下降:一开始target_modules只写了q_proj,v_proj,loss卡在1.5不动。加上FFN层后立刻正常。
  2. 显存OOMper_device_train_batch_size=8直接爆显存,改成4+梯度累积8才稳。
  3. tokenizer pad_token报错:Qwen2.5必须手动设pad_token=eos_token
  4. 验证loss震荡lora_dropout=0.1时验证loss波动大,降到0.05后稳定。
  5. merge后效果变差:merge时如果dtype不一致(比如base是bf16,adapter是fp32),会丢精度。统一用bf16即可。

优化方向:如果显存更小(比如16G),可以把r降到8,max_length降到512,gradient_accumulation_steps加到16,依然能跑。如果追求更高精度,可以上DoRA(peft 0.12已支持),但训练时间会增加约30%。

七、总结

这次LoRA微调7B模型的实践,核心结论就三句话:QLoRA让24G显存微调7B成为现实;LoRA的target_modules要覆盖FFN层才有效果;验证集loss比训练loss更重要,过拟合随时会发生。最终89%的准确率对于垂直领域问答已经可用,而且整个训练成本不到5块钱电费。代码和配置我都放在GitHub上了,有需要的可以自取。如果你也在做类似的事情,欢迎交流。