一、问题背景:为什么我要微调一个7B模型
先说结论:通用大模型在垂直领域真的不够用。
我手头有个医疗问答场景,需要模型能准确回答“二甲双胍的禁忌症”“儿童布洛芬剂量”这类问题。直接用Qwen2.5-7B-Instruct跑,结果让人头大——它会给出看似合理但细节错误的答案,比如把成人剂量直接套给儿童,或者漏掉关键禁忌症。测试了200条专业问题,准确率只有54%,还不如查指南。
全量微调7B模型?一张24GB的4090根本扛不住,光是优化器状态就要吃掉几十GB。所以LoRA成了唯一现实的选择。它的思路很简单:冻结原模型权重,只在注意力层注入低秩矩阵,训练参数量降到原来的0.1%左右。再配合QLoRA的4-bit量化,连基座模型都能压到4GB以内。
这篇文章就记录我从零到跑通的全过程,包括数据怎么洗、参数怎么设、loss长什么样、最后效果到底行不行。
二、环境与版本:别小看版本兼容性
我用的环境如下,建议直接抄:
- 操作系统:Ubuntu 22.04
- GPU:RTX 4090 24GB
- CUDA:12.1
- PyTorch:2.3.1
- transformers:4.44.2
- peft:0.12.0
- bitsandbytes:0.43.3
- trl:0.9.6
- accelerate:0.33.0
这里有个坑:bitsandbytes 0.43.x 和 PyTorch 2.3.1 搭配才能正常做4-bit量化,用0.42会报 CUDA error: an illegal memory access。另外peft 0.12.0 对Qwen2架构支持比较稳,再老的版本加载LoRA时会丢key。
安装命令:
pip install torch==2.3.1 transformers==4.44.2 peft==0.12.0 bitsandbytes==0.43.3 trl==0.9.6 accelerate==0.33.0 datasets==2.20.0
三、方案设计:QLoRA + 指令微调
整体方案分三层:
- 基座模型:Qwen2.5-7B-Instruct,用bitsandbytes做NF4量化,计算类型用bfloat16。
- LoRA配置:目标模块选
q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj,rank=16,alpha=32,dropout=0.05。注意alpha一般设为rank的2倍,这是经验值。 - 训练策略:指令微调,数据格式统一成
[{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}],用trl的SFTTrainer,3个epoch,学习率2e-4,cosine调度,warmup比例0.03。
为什么选这些参数?我试过r=8和r=32:r=8时loss降到0.9就下不去了,欠拟合;r=32时显存多占1.5GB,但效果只比r=16好一点点,性价比不高。所以r=16是甜点。
四、核心实现:数据准备与训练代码
4.1 数据准备
我的原始数据是8k条医疗问答,存在JSONL里。清洗步骤:去掉长度超过1024 token的样本(避免截断影响),过滤掉答案里包含“建议咨询医生”这类无信息量的回复,最后剩7,842条。
格式化成Qwen的chat template:
from datasets import load_dataset
import json
def format_data(example):
messages = [
{"role": "user", "content": example["question"]},
{"role": "assistant", "content": example["answer"]}
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
return {"text": text}
dataset = load_dataset("json", data_files="medical_qa.jsonl", split="train")
dataset = dataset.map(format_data, remove_columns=dataset.column_names)
dataset = dataset.train_test_split(test_size=0.05, seed=42)
4.2 训练代码
完整可运行脚本如下,关键参数我都标了注释:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset
model_name = "Qwen/Qwen2.5-7B-Instruct"
# 4-bit量化配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
# LoRA配置
lora_config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
lora_dropout=0.05,
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,000,000 || trainable%: 0.264
# 数据加载与格式化
dataset = load_dataset("json", data_files="medical_qa.jsonl", split="train")
def format_data(example):
messages = [
{"role": "user", "content": example["question"]},
{"role": "assistant", "content": example["answer"]}
]
return {"text": tokenizer.apply_chat_template(messages, tokenize=False)}
dataset = dataset.map(format_data, remove_columns=dataset.column_names)
split = dataset.train_test_split(test_size=0.05, seed=42)
# 训练配置
training_args = SFTConfig(
output_dir="./qwen2.5-7b-lora-medical",
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
bf16=True,
optim="paged_adamw_8bit",
gradient_checkpointing=True,
max_seq_length=1024,
dataset_text_field="text",
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=split["train"],
eval_dataset=split["test"],
tokenizer=tokenizer,
)
trainer.train()
trainer.save_model("./qwen2.5-7b-lora-medical/final")
显存占用:训练时峰值约18.3GB,比全量微调省了60%以上。训练时间:3个epoch共2小时37分钟,平均每步0.82秒。
五、踩坑与优化:我遇到的三个问题
坑1:loss不下降,一直卡在1.8左右。 排查发现是学习率设成了2e-5,太小了。LoRA因为只训练低秩矩阵,学习率通常要比全量微调大10倍左右,改成2e-4后loss立刻开始降。
坑2:eval loss波动大。 原因是验证集只有392条,batch size又小。后来把per_device_eval_batch_size调到8,并且每500步评估一次,曲线就平滑了。
坑3:推理时输出重复。 微调后的模型有时会重复一句话。解决办法是在生成时加repetition_penalty=1.1,并且把temperature从0.7降到0.3。另外训练数据里如果有重复模式,也会导致这个问题,所以数据去重很重要。
优化点:开启gradient_checkpointing后显存从22GB降到18GB,代价是训练速度慢15%左右,但能跑起来比什么都强。
六、效果数据:loss曲线与推理对比
6.1 Loss曲线
训练loss从第1步的1.82稳步下降,到第3个epoch结束时为0.67。验证loss从1.75降到0.72,没有明显过拟合。具体节点:
- Epoch 1结束:train loss 1.12,eval loss 1.08
- Epoch 2结束:train loss 0.81,eval loss 0.83
- Epoch 3结束:train loss 0.67,eval loss 0.72
曲线形状是标准的平滑下降,没有剧烈震荡,说明学习率和batch size搭配合理。
6.2 推理效果对比
我用同样的200条测试问题,分别跑基座模型和LoRA微调后的模型,人工评估准确率:
| 模型 | 准确率 | 显存占用 | 推理速度 |
|---|---|---|---|
| Qwen2.5-7B-Instruct | 54% | 14.2GB | 42 token/s |
| + LoRA (r=16) | 89% | 15.1GB | 40 token/s |
显存只多了0.9GB,速度几乎没损失。举两个具体例子:
问题:“二甲双胍的绝对禁忌症有哪些?”
- 基座回答:提到了肾功能不全,但漏了“严重感染”和“代谢性酸中毒”。
- 微调回答:完整列出eGFR<30、急性代谢性酸中毒、严重感染、缺氧性疾病等,和指南一致。
问题:“3岁儿童布洛芬混悬液用量是多少?”
- 基座回答:给出“每次5-10mg/kg”,但没说明每6-8小时一次,也没提24小时不超过4次。
- 微调回答:准确给出“每次5-10mg/kg,每6-8小时一次,24小时内不超过4次,最大单次剂量400mg”。
6.3 合并与部署
训练完后,LoRA权重可以合并回基座模型,方便用vLLM部署:
from peft import PeftModel
from transformers import AutoModelForCausalLM
base_model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
torch_dtype=torch.bfloat16,
device_map="auto"
)
model = PeftModel.from_pretrained(base_model, "./qwen2.5-7b-lora-medical/final")
model = model.merge_and_unload()
model.save_pretrained("./qwen2.5-7b-medical-merged")
合并后模型大小约15GB(bf16),用vLLM启动后吞吐量能到1200 token/s(batch=8)。
七、总结
LoRA/QLoRA让单卡微调7B模型变得非常现实。我的经验是:数据质量比数量重要,8k条清洗过的数据远好过5万条脏数据;rank=16、alpha=32、lr=2e-4这组参数在7B模型上比较通用;4-bit量化几乎不损失效果,但省下一大半显存。
当然也有局限:LoRA对知识注入的效果有限,如果想让模型学会全新的事实性知识,可能还是需要全量微调或者RAG配合。但对于格式对齐、领域术语、回答风格这类任务,LoRA已经足够好了。
下一步我打算试试DoRA(Weight-Decomposed LoRA),据说在低rank下比LoRA更稳。如果大家有兴趣,我可以再写一篇对比。