一、为什么要在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

三、方案设计

整体流程分四步:

  1. 数据准备:把原始的医疗问答JSONL转成 {"instruction": ..., "input": ..., "output": ...} 格式,用 chat template 拼成单条文本。
  2. 模型加载:4bit量化加载Qwen2.5-7B-Instruct,配置LoRA。
  3. 训练:SFTTrainer,3 epoch,cosine schedule,warmup 100步。
  4. 推理对比:用同一批测试问题,对比基座模型和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模型的实践,几个核心结论:

  1. 单卡24GB完全够用。QLoRA + gradient_checkpointing + paged_adamw_8bit,稳定18.6GB,还有余量。
  2. rank=8、alpha=16、覆盖全部线性层是7B模型上比较稳的配置,参数量0.26%,效果好且不容易过拟合。
  3. 数据质量比数量重要。1.2万条高质量医疗问答,比10万条噪声数据效果好得多。
  4. packing慎用。结构化强的任务上,packing带来的吞吐收益不值得损失样本边界。
  5. 通用能力不退化是LoRA相对全量微调的最大优势,实测50条通用问题只降0.1分。

下一步我打算试试DoRA(Weight-Decomposed LoRA),论文里说在低rank下比LoRA更好,以及把rank提到16看看医疗任务上还有没有提升空间。有结果再写一篇。

完整代码已放在我的GitHub,搜索 qwen2.5-lora-medical 就能找到。有问题评论区聊。