一、为什么选LoRA而不是全量微调
先说结论:不是全量微调做不起,而是LoRA更有性价比。
我这次的任务是让一个通用7B模型具备医疗问答领域的专业表达能力。手头只有一张RTX 4090 24GB,全量微调7B模型即使开bf16+gradient checkpointing,权重+优化器状态+梯度也轻松超过60GB,根本放不下。多卡A100当然可以,但成本不划算。
LoRA的核心思路是冻结原模型权重,只在Attention的q_proj、k_proj、v_proj、o_proj以及FFN层注入低秩矩阵。可训练参数从7B降到约2000万,显存占用压到10GB以内。QLoRA更进一步,把基座模型用4bit NF4量化加载,进一步省显存,代价是训练速度略慢、精度有轻微损失。
我的选择是QLoRA + bf16计算,理由是:24GB卡上能开更大的batch size和更长的序列长度,训练稳定性比纯LoRA更好。
二、环境与版本
环境不一致是复现失败的头号原因,先把版本钉死:
Python 3.11.8
torch 2.4.0 + cu121
transformers 4.46.2
peft 0.13.2
bitsandbytes 0.44.1
trl 0.12.1
datasets 3.1.0
accelerate 1.1.1
硬件:单卡RTX 4090 24GB,CPU为Ryzen 9 7950X,内存64GB。
注意bitsandbytes在Windows上支持较差,我用的是Ubuntu 22.04。如果你在Windows,建议WSL2或直接上Linux。
三、数据准备
原始数据是约12000条医疗问答对,格式是instruction/input/output三段式。清洗后保留8500条,按9:1划分训练集和验证集。
数据格式我统一成Alpaca风格,然后用chat template包装。这里有个坑:Qwen2.5有自己的chat template,如果手动拼接特殊token容易出错,建议直接用tokenizer.apply_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)
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="medical_qa.jsonl", split="train")
dataset = dataset.map(format_sample, remove_columns=dataset.column_names)
dataset = dataset.train_test_split(test_size=0.1, seed=42)
# 统计token长度分布,决定max_length
lengths = [len(tokenizer(t).input_ids) for t in dataset["train"]["text"][:500]]
print(f"mean={sum(lengths)/len(lengths):.0f}, max={max(lengths)}")
统计结果:平均长度412,95分位在780,最大1120。所以我把max_length设成1024,超过的截断,避免padding浪费。
四、训练配置与核心代码
训练用TRL的SFTTrainer,配合peft的LoraConfig。关键参数如下:
- LoRA: r=16, alpha=32, dropout=0.05, target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"]
- 量化: load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16
- 训练: per_device_train_batch_size=2, gradient_accumulation_steps=8, lr=2e-4, cosine调度, warmup_ratio=0.03
- epochs=3, bf16=True, gradient_checkpointing=True, optim="paged_adamw_8bit"
有效batch size = 2 × 8 = 16。这个配置下显存峰值约19.8GB,留有余量。
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_path,
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,
target_modules=["q_proj","k_proj","v_proj","o_proj",
"gate_proj","up_proj","down_proj"],
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,801,600 || trainable%: 0.2643
training_args = SFTConfig(
output_dir="./qwen2.5-7b-med-lora",
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
learning_rate=2e-4,
num_train_epochs=3,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
eval_strategy="epoch",
bf16=True,
gradient_checkpointing=True,
optim="paged_adamw_8bit",
max_seq_length=1024,
dataset_text_field="text",
report_to="none",
save_total_limit=2,
)
trainer = SFTTrainer(
model=model, args=training_args,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
tokenizer=tokenizer,
)
trainer.train()
trainer.save_model("./qwen2.5-7b-med-lora/final")
可训练参数20.18M,占总参数0.26%。训练3个epoch,step总数约1596。总耗时4小时37分钟,平均约10.4秒/step。
五、踩坑与优化
坑1:loss一开始就NaN。 排查发现是学习率2e-4配bf16在第一个warmup阶段不稳。把warmup_ratio从0.01提到0.03后解决。如果你的数据里有很多长样本,建议warmup再大一点。
坑2:验证loss不降反升。 第2个epoch后验证loss从0.68涨到0.75,典型过拟合。我把dropout从0.05提到0.1,同时把epoch降到3以内(原本想跑5),验证loss稳住了。LoRA虽然参数少,但在8000条数据上照样会过拟合。
坑3:推理时输出重复。 微调后模型在长回答里会重复句子。原因是训练数据里有些回答本身有重复模式,加上temperature=0.7时暴露明显。解决办法:推理时加repetition_penalty=1.1,同时清洗训练数据中的重复样本。
优化点:target_modules不要只加q/v。 我对比过只加q_proj、k_proj、v_proj和加全部7个投影层。后者在医疗术语准确率上高约6个百分点,代价是训练参数从8M涨到20M,速度慢约15%。这个trade-off我认为值得。
六、效果数据
Loss曲线: 训练loss从1.87(step 10)平滑降到0.62(step 1596),验证loss最低0.71出现在第2个epoch末。曲线整体没有剧烈震荡,说明学习率和batch size匹配得不错。
推理对比: 我准备了200条医疗测试问题,人工评估三个维度。
| 指标 | 基线Qwen2.5-7B | 微调后 |
|---|---|---|
| 专业术语准确率 | 52% | 87% |
| 回答完整性 | 68% | 91% |
| 通用对话能力(MMLU抽样) | 71% | 69% |
专业领域提升明显,通用能力只掉了2个百分点,属于可接受范围。如果掉太多,说明LoRA rank太大或训练数据太窄,需要调r或混入通用数据。
推理速度:4bit量化加载后,单条512 token回答生成约2.3秒(4090),比bf16全精度慢约18%,但显存从14GB降到6.8GB,能同时跑更多并发。
推理示例代码:
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
torch_dtype=torch.bfloat16, device_map="auto",
)
model = PeftModel.from_pretrained(base, "./qwen2.5-7b-med-lora/final")
model = model.merge_and_unload() # 合并LoRA权重,推理更快
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
messages = [{"role": "user", "content": "2型糖尿病患者的一线用药是什么?"}]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=256,
temperature=0.7, top_p=0.9,
repetition_penalty=1.1)
print(tokenizer.decode(out[0][inputs.input_ids.shape[1]:], skip_special_tokens=True))
七、总结
这次微调最核心的三点经验:第一,QLoRA在24GB卡上微调7B模型完全可行,可训练参数只有0.26%,效果却足够好;第二,数据质量比数据量重要,我清洗掉的3500条低质样本对最终效果贡献很大;第三,验证loss一定要盯,LoRA也会过拟合,别以为参数少就没事。
下一步我打算试试DoRA和rsLoRA,看能不能在同样参数预算下再涨几个点。另外,把训练数据扩展到多轮对话格式,是目前模型最明显的短板。
代码和数据格式已经整理好,需要的话可以按上面的配置直接跑。如果你也在做垂直领域微调,欢迎交流踩坑经验。