1. 问题背景:为什么选择LoRA微调7B模型?

全参数微调7B模型,单卡A100 80G都无法完整加载——Llama-2-7B的FP16权重约14GB,加上优化器状态(AdamW的动量+方差)需要额外28GB,合计42GB,远超消费级显卡。而LoRA(Low-Rank Adaptation)通过冻结原始权重,仅在Transformer的注意力层插入低秩矩阵(rank=8~64),参数量仅为原始模型的0.1%~0.5%,训练时显存需求可降低至20GB以下。QLoRA进一步引入4bit NormalFloat量化(NF4),将基座权重压缩至4GB,加上LoRA模块和梯度,单卡24G即可运行。

2. 环境与版本

  • 硬件:单张NVIDIA RTX 4090 24GB + AMD Ryzen 7950X + 64GB RAM
  • 操作系统:Ubuntu 22.04 LTS,CUDA 12.1
  • Python 3.10.12,PyTorch 2.1.2
  • 核心库:
  • transformers==4.36.2(HuggingFace模型加载)
  • peft==0.7.1(LoRA/QLoRA实现)
  • bitsandbytes==0.41.3(4bit量化)
  • datasets==2.16.1(数据处理)
  • accelerate==0.25.0(分布式训练)
  • trl==0.7.10(RLHF辅助,此处仅用SFTTrainer)
  • 基座模型:NousResearch/Llama-2-7b-hf(原始Llama-2-7B,非chat版本)

3. 方案设计:数据准备与训练配置

3.1 数据集:中文医疗问答(精简版)

采用公开的qgyd2021/chinese_medical_qa,包含约2.8万条(问题-答案)对。为防止过拟合且加速训练,随机抽取3000条作为训练集,300条作为验证集。

数据格式:每条样本构造为问题:{question}\n答案:{answer},长度控制在512 tokens以内。

3.2 训练配置:QLoRA + 4bit量化

  • 量化:bitsandbytes的4bit NormalFloat,bnb_4bit_compute_dtype=torch.bfloat16bnb_4bit_use_double_quant=True
  • LoRA参数:
  • r=8(秩)
  • lora_alpha=16(缩放系数,通常为2*r)
  • target_modules=["q_proj","v_proj"](仅修改query和value投影)
  • lora_dropout=0.05
  • 训练超参数:
  • 优化器:AdamW 8bit(bitsandbytes实现),lr=2e-4,weight_decay=0.01
  • 批量大小:per_device_train_batch_size=2,gradient_accumulation_steps=8(等效batch=16)
  • 学习率调度:余弦退火,warmup_steps=100
  • 最大步数:1000步(约2个epoch)
  • 梯度裁剪:max_grad_norm=0.3

4. 核心实现:可运行代码

4.1 QLoRA模型加载与配置

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

# 1. 4bit量化配置
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

# 2. 加载模型和分词器(需确保联网或本地已缓存)
model_name = "NousResearch/Llama-2-7b-hf"
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token  # 设置padding token

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",          # 自动分配至GPU
    torch_dtype=torch.bfloat16,
    trust_remote_code=True,
)

# 3. 为kbit训练准备模型(冻结原始权重,启用梯度检查点)
model = prepare_model_for_kbit_training(model)

# 4. LoRA配置
lora_config = LoraConfig(
    r=8,
    lora_alpha=16,
    target_modules=["q_proj", "v_proj"],  # Llama的注意力投影层
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出: trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.0622
# 仅约0.06%的参数可训练

4.2 训练循环(使用transformers的Trainer)

from transformers import TrainingArguments, Trainer
from datasets import Dataset
import json

# 假设已有预处理函数:将原始json转为文本
def format_qa(example):
    return {"text": f"问题:{example['question']}\n答案:{example['answer']}"}

# 加载数据(此处示意,实际需替换为真实路径)
with open("medical_qa_subset.json", "r", encoding="utf-8") as f:
    raw_data = json.load(f)

train_dataset = Dataset.from_list(raw_data[:3000]).map(format_qa)
eval_dataset = Dataset.from_list(raw_data[3000:3300]).map(format_qa)

# 分词函数
def tokenize_function(examples):
    return tokenizer(
        examples["text"],
        truncation=True,
        max_length=512,
        padding="max_length",
        return_tensors="pt",
    )

train_dataset = train_dataset.map(tokenize_function, batched=True, remove_columns=["text"])
eval_dataset = eval_dataset.map(tokenize_function, batched=True, remove_columns=["text"])

# 训练参数
training_args = TrainingArguments(
    output_dir="./llama2-7b-lora-medical",
    per_device_train_batch_size=2,
    per_device_eval_batch_size=2,
    gradient_accumulation_steps=8,
    learning_rate=2e-4,
    weight_decay=0.01,
    num_train_epochs=2,
    max_steps=1000,
    warmup_steps=100,
    logging_steps=50,
    evaluation_strategy="steps",
    eval_steps=200,
    save_strategy="steps",
    save_steps=500,
    fp16=False,
    bf16=True,
    gradient_checkpointing=True,
    optim="adamw_8bit",           # 使用8bit AdamW
    report_to="none",             # 不启用wandb
    remove_unused_columns=False,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    tokenizer=tokenizer,
)

# 开始训练
trainer.train()

5. 踩坑与优化

坑1:梯度累积与步数计算
由于per_device_batch_size=2,gradient_accumulation_steps=8,实际等效batch=16。但若max_steps=1000,则实际处理样本数为1000×16=16000条,而训练集只有3000条,意味着会重复遍历约5.3个epoch。我实际跑下来发现loss在800步后不再下降,反而出现轻微震荡,说明过拟合。优化:将max_steps设为600(等效约3.2epoch),或增大数据集至10000条。

坑2:4bit量化下的loss NaN
第一次跑时,使用bnb_4bit_compute_dtype=torch.float16,训练到200步时loss突然变为NaN。排查发现是梯度下溢——NF4量化后权重精度不足,float16的梯度范围有限。解决:将计算精度改为torch.bfloat16(动态范围更大),同时开启gradient_checkpointing=True(虽增加10%训练时间,但稳定)。

坑3:推理时输出为空
训练后保存LoRA权重,加载时需先加载基座模型(4bit),再加载peft权重。但若未设置trust_remote_code=True,Llama的config可能不完整,导致生成时max_new_tokens被忽略。解决:加载模型时始终加上trust_remote_code=True,并显式设置generation_config

6. 效果数据:Loss曲线与推理对比

6.1 Loss曲线

训练日志显示:
- Step 50: train_loss=2.31, eval_loss=2.18
- Step 200: train_loss=1.74, eval_loss=1.69
- Step 400: train_loss=1.42, eval_loss=1.38
- Step 600: train_loss=1.21, eval_loss=1.29(过拟合拐点)
- Step 800: train_loss=1.08, eval_loss=1.41(过拟合明显)

最佳保存点为step 600,此时验证集困惑度(perplexity)最低约为3.8(相较于基座模型的5.2)。

6.2 推理效果对比(相同问题)

问题:“患者咳嗽一周,伴有低烧、乏力,可能是什么病?”
- 基座模型(未微调):“咳嗽是一种常见的症状,可能与感冒、过敏、支气管炎等有关。建议咨询医生进行详细检查。”(过于泛化,未提及低烧与乏力的组合)
- 微调后模型:“根据症状描述:咳嗽一周、低烧、乏力,常见原因包括上呼吸道感染(如流感)、肺炎支原体感染或结核病。建议血常规和胸部X光检查以鉴别。”(更具体,包含鉴别诊断)

人工评估:随机抽取50个问题,由两位医生评分(0-5分,5分最佳)。基座模型平均分2.3,微调模型平均分3.9,提升约70%。但需注意:微调模型偶尔会“幻觉”出药物名称(如“建议服用阿奇霉素”),因为训练数据中部分答案包含药物,实际部署需加约束。

7. 总结

本文用QLoRA在单卡4090上成功微调了Llama-2-7B,关键点总结如下:
- 显存:4bit量化+LoRA(r=8)使训练显存降至约18GB,峰值不超过22GB。
- 速度:1000步训练耗时约2小时(batch=16,梯度检查点开启),平均每步7.2秒。
- 效果:医疗问答质量显著提升,但存在过拟合风险,建议数据量至少1万条。
- 未来改进:可尝试r=16提高表达能力,或加入target_modules=["q_proj","v_proj","k_proj","o_proj"]全注意力层微调(显存增加约3GB,但效果可能更好)。

最后提醒:LoRA不是万能药,对于需要全新知识注入的场景(如新语言、新领域),全参数微调仍有优势。但在资源受限时,QLoRA是最具性价比的方案。

全部代码已上传至GitHub:https://github.com/yourname/llama2-lora-medical(示例链接)