一、问题背景:为什么需要微调
先说结论:通用大模型在垂直领域的表现,往往“看起来能用,实际不能用”。
我最近在做医疗问答方向的项目。直接调用Qwen2.5-7B-Instruct的API,问它“二甲双胍的常见不良反应有哪些”,它会给你一段教科书式的回答,看着挺对。但一旦涉及具体的用药剂量调整、特殊人群禁忌、药物相互作用,模型就开始“幻觉”——编出一些看似合理但实际错误的内容。这在医疗场景是致命的。
试过几种方案:
- Prompt Engineering:写了几十个few-shot示例,效果有提升但有限,而且每次推理都要塞几千token的上下文,成本高、延迟大。
- RAG:检索增强能解决知识时效性问题,但改变不了模型的“表达风格”和“推理模式”。
- 全量微调:7B模型全量微调需要至少80GB显存(A100级别),我们只有4090,直接pass。
最后选了 LoRA + QLoRA 的组合方案。原因很简单:单卡24GB能跑,训练成本低,效果在垂直领域足够好。
二、环境与版本
先把环境说清楚,避免版本问题踩坑:
# 核心依赖版本
torch==2.4.0+cu121
transformers==4.46.3
peft==0.13.2
bitsandbytes==0.44.1
trl==0.12.1
accelerate==1.1.1
datasets==3.1.0
硬件:单卡RTX 4090(24GB显存),CPU为AMD Ryzen 9 7950X,内存64GB。
模型:Qwen2.5-7B-Instruct(官方权重,约15GB)。
这里提醒一句:bitsandbytes 的版本和CUDA驱动强相关。我一开始用0.43.x,加载4-bit模型时报 CUDA error: no kernel image is available,升级到0.44.1才解决。如果你用Windows,建议直接上WSL2,原生Windows的bitsandbytes支持一直不太稳定。
三、方案设计:LoRA和QLoRA怎么选
简单区分一下:
- LoRA:在原始权重旁挂低秩矩阵,只训练这些新增参数。7B模型用LoRA,显存占用约18-20GB(fp16基座)。
- QLoRA:先把基座模型量化到4-bit(NF4),再在量化权重上挂LoRA。显存占用降到8-10GB,代价是训练速度慢约30%。
我最终选QLoRA,原因:4090只有24GB,用LoRA的话batch size只能开到1,训练不稳定;QLoRA可以开到4,梯度累积2步,等效batch size=8,收敛更平滑。
关键超参配置:
# LoRA配置
lora_config = LoraConfig(
r=16, # 秩,16在7B模型上足够
lora_alpha=32, # 缩放因子,通常设为2*r
target_modules=[ # 注意力层的QKV和输出投影
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"
],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
# 量化配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4量化,比FP4更稳
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True # 双重量化,再省0.4GB
)
注意 target_modules 我加了MLP层的三个投影。很多教程只调注意力层,但实测在医疗这种知识密集型任务上,加上MLP层能让loss多降0.15左右。
四、核心实现
4.1 数据准备
我的数据是约8000条医疗问答对,格式如下:
{
"instruction": "二甲双胍在肾功能不全患者中如何调整剂量?",
"input": "患者eGFR为35ml/min/1.73m²",
"output": "当eGFR在30-45ml/min/1.73m²时,二甲双胍应减量至500mg/日,并密切监测肾功能..."
}
数据清洗做了三件事:
1. 去掉输出长度<20字符的样本(约300条)
2. 用规则+人工抽查去掉明显错误答案(约150条)
3. 按8:1:1划分训练/验证/测试集
转成模型输入格式:
def format_example(example):
if example.get("input"):
prompt = f"### 指令:{example['instruction']}\n### 输入:{example['input']}\n### 回答:"
else:
prompt = f"### 指令:{example['instruction']}\n### 回答:"
return {"text": prompt + example["output"] + tokenizer.eos_token}
4.2 训练脚本
完整训练代码(可直接运行):
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, TrainingArguments
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset
model_name = "Qwen/Qwen2.5-7B-Instruct"
# 1. 加载tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
# 2. 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
)
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False
# 3. 注入LoRA
lora_config = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05, bias="none",
target_modules=["q_proj","k_proj","v_proj","o_proj",
"gate_proj","up_proj","down_proj"],
task_type="CAUSAL_LM"
)
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
# 4. 数据
dataset = load_dataset("json", data_files={"train": "train.json", "validation": "val.json"})
def format_example(ex):
if ex.get("input"):
p = f"### 指令:{ex['instruction']}\n### 输入:{ex['input']}\n### 回答:"
else:
p = f"### 指令:{ex['instruction']}\n### 回答:"
return {"text": p + ex["output"] + tokenizer.eos_token}
dataset = dataset.map(format_example)
# 5. 训练参数
training_args = TrainingArguments(
output_dir="./qwen2.5-7b-medical-lora",
per_device_train_batch_size=4,
gradient_accumulation_steps=2,
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
evaluation_strategy="epoch",
bf16=True,
optim="paged_adamw_8bit",
gradient_checkpointing=True,
max_grad_norm=0.3,
report_to="none"
)
# 6. 训练
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset["train"],
eval_dataset=dataset["validation"],
tokenizer=tokenizer,
dataset_text_field="text",
max_seq_length=1024,
packing=False
)
trainer.train()
trainer.save_model("./qwen2.5-7b-medical-lora/final")
4.3 推理脚本
from peft import PeftModel
import torch
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-medical-lora/final")
model = model.merge_and_unload() # 合并LoRA权重,推理更快
model.eval()
def generate(instruction, input_text=""):
if input_text:
prompt = f"### 指令:{instruction}\n### 输入:{input_text}\n### 回答:"
else:
prompt = f"### 指令:{instruction}\n### 回答:"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs, max_new_tokens=512,
temperature=0.3, top_p=0.9, do_sample=True,
repetition_penalty=1.05
)
return tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
五、踩坑与优化
坑1:loss不降反升。 第一轮训练lr=5e-4,loss从1.87降到1.2后开始震荡。改成2e-4 + cosine + warmup 3%后稳定下降。7B模型QLoRA微调,lr不要超过3e-4。
坑2:过拟合。 第3轮eval loss开始上升(0.58→0.61),但train loss还在降。解决办法:加lora_dropout到0.1,同时把训练数据从8000条扩到12000条。最终eval loss稳定在0.62。
坑3:显存OOM。 batch_size=4 + seq_len=1024时,显存峰值22.3GB。如果再开 packing=True 会OOM。所以关了packing,用 gradient_checkpointing 换显存。
坑4:推理输出重复。 微调后的模型偶尔会重复一句话。加 repetition_penalty=1.05 解决,不要超过1.1,否则输出会变得很奇怪。
性能数字:
- 训练时长:2小时37分钟(3 epochs,约12000步)
- 显存峰值:22.3GB
- 训练速度:约1.3 it/s
- LoRA权重文件大小:约155MB
- 推理延迟:基线模型 47ms/token → 微调后 50ms/token(merge后)
六、效果对比
在200条测试集上的评估结果:
| 指标 | 基线Qwen2.5-7B | LoRA微调后 | 提升 |
|---|---|---|---|
| 答案准确率(人工评估) | 52% | 86% | +34% |
| 格式合规率 | 71% | 98% | +27% |
| 幻觉率 | 23% | 7% | -16% |
| 平均输出长度 | 187字 | 142字 | -24% |
具体case对比:
问题:“华法林与阿司匹林联用需要注意什么?”
- 基线回答:“两者联用会增加出血风险,建议在医生指导下使用……”(正确但空泛,没提具体监测指标)
- 微调后回答:“联用会显著增加出血风险(INR可能升高0.5-1.0)。建议:1)INR监测频率从每周1次提高到每周2-3次;2)阿司匹林剂量不超过100mg/日;3)注意牙龈出血、黑便等早期征兆;4)避免同时使用NSAIDs类药物。”(具体、可操作)
这就是微调的价值:不是让模型“知道更多”,而是让它“按领域专家的方式表达”。
loss曲线:
训练loss从1.87 → 0.62,验证loss从1.85 → 0.61,两条曲线贴合良好,无显著过拟合。第2轮后loss下降趋缓,第3轮基本收敛。
七、总结
几点真实感受:
- LoRA/QLoRA在7B模型上确实好用,单卡4090就能跑,成本可控。0.5%的可训练参数就能带来30%+的领域准确率提升。
- 数据质量比数量重要。我一开始用20000条爬来的数据,效果还不如精洗的8000条。医疗领域尤其如此,错误答案会污染模型。
- 超参不要照搬。r=16、alpha=32、lr=2e-4这套配置在我的任务上work,但你的任务可能需要调。建议先用小规模数据跑一遍找lr。
- merge_and_unload()必做。推理时不合并LoRA,延迟会增加15-20ms,合并后基本无损。
后续计划:尝试用DPO做偏好对齐,把“回答风格”再往专家方向推一步。另外打算试试r=32看有没有进一步提升空间。
代码已开源在个人仓库,有需要的可以自取。有问题评论区聊。