一、为什么我要在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格式,每条样本包含instruction、input、output三个字段。原始数据来自业务日志和人工标注,质量参差不齐。清洗步骤:
- 去重:用SimHash做近似去重,阈值设0.85,删掉约800条重复。
- 长度过滤:output长度小于10个token或大于1024个token的丢弃,删掉约300条。
- 敏感词过滤:正则匹配手机号、身份证号,替换为占位符。
- 格式统一:把所有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=True,bnb_4bit_quant_type="nf4",bnb_4bit_compute_dtype=torch.bfloat16,bnb_4bit_use_double_quant=True - LoRA:
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"] - 训练:
per_device_train_batch_size=4,gradient_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_proj和v_proj,效果会差一截。我实测把FFN层的gate_proj、up_proj、down_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))
六、踩坑与优化建议
踩坑记录我列几条最典型的:
- loss不下降:一开始
target_modules只写了q_proj,v_proj,loss卡在1.5不动。加上FFN层后立刻正常。 - 显存OOM:
per_device_train_batch_size=8直接爆显存,改成4+梯度累积8才稳。 - tokenizer pad_token报错:Qwen2.5必须手动设
pad_token=eos_token。 - 验证loss震荡:
lora_dropout=0.1时验证loss波动大,降到0.05后稳定。 - 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上了,有需要的可以自取。如果你也在做类似的事情,欢迎交流。