一、问题背景:为什么我要微调一个7B模型
先说结论:通用大模型在垂直领域经常“一本正经地胡说八道”,而全量微调7B模型对个人开发者来说显存门槛太高。我手上的任务是做一个企业内部知识问答助手,涉及大量内部术语、产品代号和固定话术。直接拿Qwen2.5-7B-Instruct做few-shot,回答准确率只有54%左右,而且经常把内部术语解释成通用含义。
全量微调7B需要至少8张A100 80G,我只有一张4090 24G。于是LoRA和QLoRA成了唯一现实的选择。这篇文章就是把我从数据准备到推理对比的完整流程写下来,包含具体参数、loss曲线和踩过的坑。
二、环境与版本
环境版本这东西,不写清楚就是耍流氓。我的配置如下:
- GPU:RTX 4090 24G(单卡)
- CUDA:12.1
- PyTorch:2.3.1+cu121
- transformers:4.44.2
- peft:0.12.0
- bitsandbytes:0.43.3
- trl:0.9.6
- accelerate:0.33.0
- Python:3.10.14
安装命令大致如下,bitsandbytes一定要和CUDA版本匹配,否则4-bit量化会报错:
pip install torch==2.3.1 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.44.2 peft==0.12.0 trl==0.9.6 accelerate==0.33.0
pip install bitsandbytes==0.43.3 datasets==2.20.0
三、方案设计:QLoRA + LoRA,r=16
核心思路是:基座模型用4-bit NF4量化加载,冻结全部参数,只在注意力层的q_proj、k_proj、v_proj、o_proj和MLP层的gate_proj、up_proj、down_proj上挂LoRA适配器。这样可训练参数只有约4200万,占7B的0.6%左右。
关键参数选择:
- LoRA rank r=16,alpha=32,dropout=0.05
- 目标模块:q/k/v/o/gate/up/down 全部挂上,实测比只挂q/v效果好3-5个点
- 量化:load_in_4bit=True,bnb_4bit_quant_type="nf4",bnb_4bit_compute_dtype=torch.bfloat16
- 学习率:2e-4,cosine调度,warmup_ratio=0.03
- batch_size:per_device=2,gradient_accumulation=8,等效batch=16
- 训练轮数:3 epoch
- 最大长度:1024
为什么用QLoRA而不是纯LoRA?因为纯LoRA加载fp16的7B模型需要约14G显存,加上激活值和优化器状态,24G很紧张。4-bit量化后模型只占约4.5G,训练峰值18.6G,留有余量。
四、核心实现
4.1 数据准备
数据格式我采用Alpaca风格的instruction/input/output,1.2万条,按9:1划分训练和验证。关键点是每条数据都套用Qwen2.5的chat template,而不是自己拼字符串。否则推理时格式对不上,效果会崩。
from datasets import load_dataset
from transformers import AutoTokenizer
model_path = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
def format_sample(sample):
messages = [
{"role": "system", "content": "你是企业内部知识助手,只根据已知信息回答。"},
{"role": "user", "content": sample["instruction"] + "\n" + sample.get("input", "")},
{"role": "assistant", "content": sample["output"]},
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
return {"text": text}
dataset = load_dataset("json", data_files="train.jsonl", split="train")
dataset = dataset.map(format_sample, remove_columns=dataset.column_names)
split = dataset.train_test_split(test_size=0.1, seed=42)
4.2 模型加载与LoRA配置
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
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_path,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()
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: 42,467,328 || all params: 7,658,.,,, || trainable%: 0.5546
4.3 训练配置
用trl的SFTTrainer最省事,它自动处理packing和loss计算。
from transformers import TrainingArguments
from trl import SFTTrainer
training_args = TrainingArguments(
output_dir="./qwen2.5-7b-lora",
per_device_train_batch_size=2,
per_device_eval_batch_size=2,
gradient_accumulation_steps=8,
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_strategy="steps",
save_steps=200,
save_total_limit=2,
bf16=True,
optim="paged_adamw_8bit",
gradient_checkpointing=True,
report_to="none",
max_grad_norm=0.3,
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=split["train"],
eval_dataset=split["test"],
dataset_text_field="text",
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.model.save_pretrained("./qwen2.5-7b-lora-final")
五、踩坑与优化
坑一:loss不下降,一直在1.8附近震荡。原因是学习率设成了1e-5,对LoRA来说太小。改成2e-4后第200步就开始明显下降。
坑二:eval loss比train loss还低。一开始以为数据泄漏,后来发现是dropout在eval时关闭导致,属于正常现象,不用慌。
坑三:4-bit量化后保存adapter,推理时加载报错。必须用PeftModel.from_pretrained加载adapter,并且基座也要用同样的bnb_config加载,否则dtype不匹配。
坑四:packing=True时loss曲线很漂亮但推理效果差。因为packing把多条短样本拼在一起,attention mask处理不当会跨样本污染。改成packing=False后效果正常。
优化点:把max_grad_norm从1.0降到0.3,训练更稳;开启double quant,显存再省0.4G;target_modules全挂比只挂q/v在验证集上高4.2个点。
六、效果数据
训练3个epoch,共约2250步,耗时3小时18分,显存峰值18.6G。loss从初始1.87降到0.62,eval loss从1.79降到0.68,没有过拟合。
推理效果对比,我用200条人工标注的测试集:
| 指标 | 基座模型 | LoRA微调后 |
|---|---|---|
| 领域问答准确率 | 54% | 89% |
| 术语解释正确率 | 61% | 93% |
| 格式合规率 | 72% | 98% |
| 平均响应长度 | 187字 | 142字 |
实际对话例子:问“X7模块的告警阈值是多少”,基座回答“通常建议设置在80%左右”(错误,通用猜测),微调后回答“X7模块默认告警阈值为CPU 75%、内存85%,可在配置文件alert.yaml中修改”(正确,来自训练数据)。
推理速度方面,4-bit基座+LoRA adapter,单条512 token输出约2.3秒,比fp16全量快约40%,因为量化权重加载更快。
七、总结
LoRA/QLoRA让单卡24G微调7B模型成为现实,成本从几万块降到几千块。关键经验就三条:数据格式必须和基座chat template一致;学习率2e-4左右,别设太小;target_modules尽量全挂。这套流程我已经复用到13B模型上,显存峰值22.1G,依然能跑。如果你也在做垂直领域微调,希望这篇记录能帮你少走几个坑。