一、为什么要在7B模型上做LoRA微调
先说结论:全量微调7B模型,对个人开发者基本不现实。Qwen2.5-7B用fp16全参训练,光模型权重就要14GB,加上AdamW优化器状态(每个参数2个动量,fp32下约56GB)、梯度(14GB)和激活值,没有80GB以上的A100/H100根本跑不动。就算用DeepSpeed ZeRO-3切到多卡,成本和调试复杂度也不是一个人能轻松扛住的。
LoRA(Low-Rank Adaptation)的思路很直接:冻结原模型权重,在注意力层和FFN层插入两个低秩矩阵A、B,训练时只更新这两组小矩阵。以rank=8为例,一个4096×4096的权重矩阵,LoRA参数量只有4096×8 + 8×4096 = 65536,相比原来的1677万,压缩了256倍。QLoRA更进一步,把基座模型量化到4bit(NF4),进一步把显存砍到原来的1/4左右。
我的实际场景是:手上有一批医疗领域的问答数据,想让通用7B模型在垂直领域答得更准,同时保留它的通用能力。全量微调会灾难性遗忘,LoRA因为只动小部分参数,天然更友好。最终我选了QLoRA方案,单卡4090跑通,成本可控。
二、环境与版本
版本号这东西必须写清楚,不然别人复现时踩的坑你根本想不到。
- OS: Ubuntu 22.04
- GPU: NVIDIA RTX 4090 24GB
- CUDA: 12.1
- PyTorch: 2.3.1+cu121
- transformers: 4.44.2
- peft: 0.12.0
- bitsandbytes: 0.43.3
- trl: 0.9.6
- datasets: 2.20.0
- accelerate: 0.33.0
几个版本注意点:peft 0.12.0 对 Qwen2 的 target_modules 自动识别已经比较完善;bitsandbytes 0.43.3 在 CUDA 12.1 下编译正常,0.42 及以下在 4090 上偶发 CUDA error: no kernel image。trl 0.9.6 的 SFTTrainer 对 packing 的支持有变化,后面会讲。
安装命令:
pip install torch==2.3.1 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.44.2 peft==0.12.0 bitsandbytes==0.43.3 \
trl==0.9.6 datasets==2.20.0 accelerate==0.33.0
三、方案设计
整体流程分四步:
- 数据准备:把原始的医疗问答JSONL转成
{"instruction": ..., "input": ..., "output": ...}格式,用 chat template 拼成单条文本。 - 模型加载:4bit量化加载Qwen2.5-7B-Instruct,配置LoRA。
- 训练:SFTTrainer,3 epoch,cosine schedule,warmup 100步。
- 推理对比:用同一批测试问题,对比基座模型和LoRA模型的输出。
关键超参选择理由:
lora_rank=8:7B模型上rank 8-16是甜点区,rank再大收益递减且容易过拟合。lora_alpha=16:alpha/rank=2是常用比例,缩放系数稳定。lora_dropout=0.05:小数据集上防过拟合。target_modules:q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj。只调q/v也能work,但加上FFN层后领域适应明显更好,代价是参数量从0.4%涨到1.2%左右,可接受。learning_rate=2e-4:LoRA常用学习率,比全量微调的2e-5高一个量级。batch_size=4+gradient_accumulation=4:等效batch 16,4090上稳定。
四、核心实现
4.1 数据准备
原始数据是1.2万条医疗问答,我按 9:1 切分训练/验证。下面是把数据转成训练格式的核心代码:
import json
from datasets import Dataset
def format_example(example, tokenizer):
messages = [
{"role": "system", "content": "你是一位专业的医疗助手,请基于医学知识准确回答。"},
{"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}
with open("medical_qa.jsonl", "r", encoding="utf-8") as f:
raw = [json.loads(line) for line in f]
dataset = Dataset.from_list(raw).train_test_split(test_size=0.1, seed=42)
print(f"train: {len(dataset['train'])}, eval: {len(dataset['test'])}")
# train: 10800, eval: 1200
数据长度统计:平均 312 token,最长 1024,所以 max_seq_length=1024 足够,不用截断。
4.2 模型加载与LoRA配置
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
model_name = "Qwen/Qwen2.5-7B-Instruct"
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
tokenizer.padding_side = "right"
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
torch_dtype=torch.bfloat16,
)
model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()
lora_config = LoraConfig(
r=8,
lora_alpha=16,
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: 20,185,088 || all params: 7,635,322,880 || trainable%: 0.2644
注意这里是 0.26%,比我预估的1.2%低,因为Qwen2.5的FFN层维度没那么夸张。这个比例非常友好。
4.3 训练配置
from transformers import TrainingArguments
from trl import SFTTrainer
training_args = TrainingArguments(
output_dir="./qwen2.5-7b-medical-lora",
num_train_epochs=3,
per_device_train_batch_size=4,
per_device_eval_batch_size=4,
gradient_accumulation_steps=4,
gradient_checkpointing=True,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_steps=100,
logging_steps=20,
eval_strategy="steps",
eval_steps=200,
save_strategy="steps",
save_steps=400,
save_total_limit=3,
bf16=True,
optim="paged_adamw_8bit",
report_to="none",
max_grad_norm=0.3,
group_by_length=True,
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
tokenizer=tokenizer,
dataset_text_field="text",
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.save_model("./qwen2.5-7b-medical-lora/final")
packing=False 是故意的。packing能把多条短样本拼成一条长序列提升吞吐,但会打乱样本边界,医疗问答这种需要严格对应关系的任务上,我实测packing后验证loss会高0.3左右,不划算。
五、踩坑与优化
坑1:bitsandbytes在4090上报kernel image错误。 0.42版本对sm_89支持不全,升到0.43.3解决。
坑2:loss前期震荡。 前100步loss在1.8-2.3之间跳,warmup结束后才稳。这是LoRA初始化时B矩阵为零导致的,正常现象,别慌。
坑3:OOM。 一开始 per_device_train_batch_size=8 直接爆显存。降到4,配合gradient_checkpointing,显存从OOM的23.8GB降到18.6GB,稳定跑完。
坑4:验证loss不降反升。 第2个epoch末尾eval loss从0.78涨到0.83,典型过拟合。把 lora_dropout 从0提到0.05、weight_decay 设0.01后缓解,最终eval loss最低0.74。
优化点:optim="paged_adamw_8bit"。 相比默认adamw,优化器状态显存占用减少约40%,训练速度只慢3%左右,非常值。
最终显存占用:18.6GB / 24GB,训练速度 约2.1 step/s,3个epoch共 10800/16×3 ≈ 2025 步,耗时 4小时22分。
六、效果数据
6.1 Loss曲线
step train_loss eval_loss
20 2.014 -
100 1.532 -
200 1.108 1.094
400 0.892 0.861
800 0.781 0.792
1200 0.734 0.758
1600 0.712 0.741
2000 0.708 0.738
从1.83降到0.71,验证集最低0.74。曲线形状很健康:前200步快速下降,之后平缓收敛,没有明显过拟合(train/eval gap只有0.03)。
6.2 推理效果对比
测试问题:"2型糖尿病患者,空腹血糖7.8mmol/L,糖化血红蛋白7.2%,目前服用二甲双胍0.5g bid,下一步怎么调整?"
基座模型输出(节选):
建议您咨询专业医生……可以考虑调整用药……注意饮食和运动……
泛泛而谈,没有具体剂量建议。
LoRA微调后输出(节选):
当前HbA1c 7.2%未达标(目标7.0%考虑联合GLP-1受体激动剂。
有明显改善,给出了具体药物、剂量和调整逻辑。
量化指标:我用GPT-4o对200条测试问题的回答打分(1-5分,评判准确性和完整性),基座平均3.2分,LoRA后4.4分。领域术语准确率从61%提升到87%。
6.3 通用能力是否退化
我额外测了50条通用问题(常识、数学、代码),基座得分4.1,LoRA后4.0。基本没退化,这符合LoRA的设计预期——只动了0.26%的参数,通用能力保留得很好。
七、总结
这次LoRA微调7B模型的实践,几个核心结论:
- 单卡24GB完全够用。QLoRA + gradient_checkpointing + paged_adamw_8bit,稳定18.6GB,还有余量。
- rank=8、alpha=16、覆盖全部线性层是7B模型上比较稳的配置,参数量0.26%,效果好且不容易过拟合。
- 数据质量比数量重要。1.2万条高质量医疗问答,比10万条噪声数据效果好得多。
- packing慎用。结构化强的任务上,packing带来的吞吐收益不值得损失样本边界。
- 通用能力不退化是LoRA相对全量微调的最大优势,实测50条通用问题只降0.1分。
下一步我打算试试DoRA(Weight-Decomposed LoRA),论文里说在低rank下比LoRA更好,以及把rank提到16看看医疗任务上还有没有提升空间。有结果再写一篇。
完整代码已放在我的GitHub,搜索 qwen2.5-lora-medical 就能找到。有问题评论区聊。