一、为什么选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,看能不能在同样参数预算下再涨几个点。另外,把训练数据扩展到多轮对话格式,是目前模型最明显的短板。

代码和数据格式已经整理好,需要的话可以按上面的配置直接跑。如果你也在做垂直领域微调,欢迎交流踩坑经验。