一、问题背景:为什么需要微调

先说结论:通用大模型在垂直领域的表现,往往“看起来能用,实际不能用”。

我最近在做医疗问答方向的项目。直接调用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轮基本收敛。

七、总结

几点真实感受:

  1. LoRA/QLoRA在7B模型上确实好用,单卡4090就能跑,成本可控。0.5%的可训练参数就能带来30%+的领域准确率提升。
  2. 数据质量比数量重要。我一开始用20000条爬来的数据,效果还不如精洗的8000条。医疗领域尤其如此,错误答案会污染模型。
  3. 超参不要照搬。r=16、alpha=32、lr=2e-4这套配置在我的任务上work,但你的任务可能需要调。建议先用小规模数据跑一遍找lr。
  4. merge_and_unload()必做。推理时不合并LoRA,延迟会增加15-20ms,合并后基本无损。

后续计划:尝试用DPO做偏好对齐,把“回答风格”再往专家方向推一步。另外打算试试r=32看有没有进一步提升空间。

代码已开源在个人仓库,有需要的可以自取。有问题评论区聊。