← 返回首页

LLM微调实战:在单卡3090上微调7B模型的完整记录

2023-07-25 | 模型微调 LoRA QLoRA
公司想要一个专门回答内部技术文档问题的模型。买不起A100,只有几张3090(24GB显存)。我花了两周时间,尝试了Full Fine-tuning、LoRA、QLoRA三种方案,最终在单卡3090上成功微调了Llama2-7B。这篇文章记录完整过程。

背景:为什么要微调

我们有一套内部的技术知识库,包含:

直接用GPT-4+RAG的方案效果还行,但有两个问题:

  1. 成本高:每月API费用约$2000
  2. 数据安全:部分文档涉及敏感信息,不能发到外网

所以决定自己微调一个模型私有化部署。

数据准备:这一步花了最多时间

数据格式

微调数据需要是instruction格式:

{
  "instruction": "如何排查服务OOM问题?",
  "input": "",
  "output": "排查OOM问题的步骤:\n1. 查看日志确认OOM时间点\n2. 使用jmap导出heap dump\n3. 用MAT分析大对象\n4. ..."
}

数据构造

从技术文档中生成QA对,我用了两个方法:

import openai
from typing import List, Dict

def generate_qa_from_doc(doc_content: str, num_qa: int = 5) -> List[Dict]:
    """
    用GPT-4从文档生成QA对
    """
    prompt = f"""请根据以下技术文档,生成{num_qa}个问答对。

要求:
1. 问题要具体,不要太宽泛
2. 答案要完整,包含关键步骤
3. 覆盖文档的主要内容

文档内容:
{doc_content}

请以JSON数组格式输出,每个元素包含question和answer字段:
"""
    
    response = openai.ChatCompletion.create(
        model="gpt-4",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.7
    )
    
    # 解析JSON
    qa_list = json.loads(response.choices[0].message.content)
    
    return [
        {
            "instruction": qa["question"],
            "input": "",
            "output": qa["answer"]
        }
        for qa in qa_list
    ]


def generate_qa_from_search_logs(search_logs: List[Dict]) -> List[Dict]:
    """
    从真实搜索日志生成训练数据
    更接近用户的真实问法
    """
    qa_pairs = []
    
    for log in search_logs:
        query = log["query"]
        clicked_doc = log["clicked_doc"]  # 用户点击的文档
        
        # 用点击的文档内容作为答案
        qa_pairs.append({
            "instruction": query,
            "input": "",
            "output": extract_answer_from_doc(clicked_doc, query)
        })
    
    return qa_pairs

最终数据集

来源 数量 备注
GPT-4生成 15,000条 覆盖所有文档
搜索日志 3,200条 真实用户问题
人工标注 500条 高质量验证集
合计 18,700条 -
⚠️ 数据质量比数量重要: 我一开始用GPT-3.5生成了5万条数据,训练效果很差。后来改用GPT-4生成1.5万条,效果反而更好。垃圾数据会污染模型。

方案一:Full Fine-tuning(失败)

理论计算

# Llama2-7B显存需求估算
# 模型参数: 7B * 2 bytes (fp16) = 14GB
# 梯度: 7B * 2 bytes = 14GB  
# 优化器状态 (AdamW): 7B * 8 bytes = 56GB
# 激活值: 取决于batch size和sequence length

# 总计: 14 + 14 + 56 + 激活值 ≈ 100GB+
# 单卡3090(24GB)完全不够

尝试梯度检查点

from transformers import TrainingArguments

training_args = TrainingArguments(
    gradient_checkpointing=True,  # 用时间换空间
    gradient_accumulation_steps=16,  # 累积梯度
    per_device_train_batch_size=1,
    fp16=True,
    # ...
)

结果:还是OOM。gradient_checkpointing能省一些激活值的显存,但优化器状态太大了。

尝试DeepSpeed ZeRO-3

ZeRO-3可以把优化器状态分片到多卡,但我只有一张卡...

结论:Full Fine-tuning在消费级显卡上不可行,放弃。

方案二:LoRA(成功)

LoRA原理

LoRA的核心思想:不修改原模型参数,只训练一个低秩的"增量"。

# 原始: h = Wx
# LoRA: h = Wx + BAx
# 其中 W ∈ R^(d×d), B ∈ R^(d×r), A ∈ R^(r×d)
# r << d, 所以BA的参数量远小于W

# 例如 d=4096, r=8
# W的参数: 4096 * 4096 = 16.7M
# BA的参数: 4096 * 8 + 8 * 4096 = 65K
# 压缩比: 256倍!

完整训练代码

import torch
from transformers import (
    AutoModelForCausalLM, 
    AutoTokenizer,
    TrainingArguments,
    Trainer
)
from peft import (
    LoraConfig, 
    get_peft_model, 
    TaskType,
    prepare_model_for_kbit_training
)
from datasets import load_dataset

# 1. 加载模型
model_name = "meta-llama/Llama-2-7b-hf"
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.float16,
    device_map="auto"
)

# 2. 配置LoRA
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=8,                          # 秩,越大效果越好但参数越多
    lora_alpha=32,                # 缩放系数
    lora_dropout=0.05,
    target_modules=[              # 要加LoRA的模块
        "q_proj", "k_proj", "v_proj", "o_proj",  # attention
        "gate_proj", "up_proj", "down_proj"       # FFN
    ],
    bias="none"
)

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

# 3. 数据处理
def format_instruction(sample):
    """将数据转换为instruction格式"""
    if sample["input"]:
        text = f"""### Instruction:
{sample["instruction"]}

### Input:
{sample["input"]}

### Response:
{sample["output"]}"""
    else:
        text = f"""### Instruction:
{sample["instruction"]}

### Response:
{sample["output"]}"""
    return text

def tokenize(sample):
    text = format_instruction(sample)
    result = tokenizer(
        text,
        truncation=True,
        max_length=2048,
        padding="max_length"
    )
    result["labels"] = result["input_ids"].copy()
    return result

dataset = load_dataset("json", data_files="train_data.json")
tokenized_dataset = dataset.map(tokenize, remove_columns=dataset["train"].column_names)

# 4. 训练配置
training_args = TrainingArguments(
    output_dir="./lora-llama2-7b",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,  # 有效batch size = 4 * 4 = 16
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    save_strategy="epoch",
    fp16=True,
    optim="adamw_torch",
    report_to="tensorboard"
)

# 5. 开始训练
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset["train"],
    tokenizer=tokenizer
)

trainer.train()

# 6. 保存
model.save_pretrained("./lora-llama2-7b-final")

训练监控

# 监控显存使用
import nvidia_smi
nvidia_smi.nvmlInit()
handle = nvidia_smi.nvmlDeviceGetHandleByIndex(0)

def log_gpu_memory():
    info = nvidia_smi.nvmlDeviceGetMemoryInfo(handle)
    used = info.used / 1024**3
    total = info.total / 1024**3
    print(f"GPU Memory: {used:.1f}GB / {total:.1f}GB ({used/total*100:.1f}%)")

# 训练过程中定期调用
# 实测: 峰值显存约18GB, 3090完全hold住

LoRA训练结果

指标 数值
训练时长 8小时(3 epochs)
峰值显存 18GB
LoRA参数量 4.2M
checkpoint大小 16MB
最终loss 0.82

方案三:QLoRA(更省显存)

QLoRA原理

QLoRA = Quantized LoRA,在LoRA基础上把base model量化到4-bit:

# 显存对比:
# Llama2-7B fp16: 14GB
# Llama2-7B 4-bit: 3.5GB
# 节省了75%的模型显存!

QLoRA训练代码

from transformers import BitsAndBytesConfig

# 4-bit量化配置
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",        # Normal Float 4
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_use_double_quant=True    # 嵌套量化,进一步压缩
)

# 加载量化模型
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto"
)

# 准备模型进行训练
model = prepare_model_for_kbit_training(model)

# 后续LoRA配置和训练代码与上面相同
model = get_peft_model(model, lora_config)
# ...

QLoRA vs LoRA对比

指标 LoRA QLoRA
峰值显存 18GB 10GB
训练速度 1x 0.7x(慢30%)
最终loss 0.82 0.87
推理效果(主观) ★★★★☆ ★★★☆☆
⚠️ QLoRA的坑: 4-bit量化会损失一些精度,我们的任务上QLoRA效果比LoRA差约5%。但如果你只有16GB甚至12GB显存,QLoRA是唯一选择。

LoRA参数调优

r(秩)的影响

r 参数量 显存 验证Loss
4 2.1M 16GB 0.91
8 4.2M 18GB 0.82
16 8.4M 20GB 0.79
32 16.8M 22GB 0.78
64 33.6M OOM -

r=8是个不错的起点。r=16或32能带来一些提升,但收益递减。

target_modules的选择

# 方案1: 只加attention
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj"]
# 参数量少,但效果一般

# 方案2: attention + FFN (推荐)
target_modules = [
    "q_proj", "k_proj", "v_proj", "o_proj",
    "gate_proj", "up_proj", "down_proj"
]
# 参数量多一点,效果更好

# 方案3: 全部线性层
target_modules = ["all-linear"]
# PEFT新版本支持,等价于方案2

学习率很关键

# 测试不同学习率
lrs = [1e-5, 5e-5, 1e-4, 2e-4, 5e-4, 1e-3]
results = {}

for lr in lrs:
    trainer = train_with_lr(lr)
    results[lr] = evaluate(trainer)

# 结果:
# 1e-5: loss=1.12 (学不动)
# 5e-5: loss=0.95 (太慢)
# 1e-4: loss=0.85 (不错)
# 2e-4: loss=0.82 (最佳)
# 5e-4: loss=0.88 (开始过拟合)
# 1e-3: loss=1.23 (训练不稳定)

LoRA的学习率一般比Full FT大10-100倍,我们用2e-4效果最好。

模型合并与部署

合并LoRA权重

from peft import PeftModel

# 加载base model
base_model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    torch_dtype=torch.float16
)

# 加载LoRA
model = PeftModel.from_pretrained(base_model, "./lora-llama2-7b-final")

# 合并
merged_model = model.merge_and_unload()

# 保存
merged_model.save_pretrained("./llama2-7b-merged")
tokenizer.save_pretrained("./llama2-7b-merged")

vLLM部署

from vllm import LLM, SamplingParams

# 加载合并后的模型
llm = LLM(
    model="./llama2-7b-merged",
    tensor_parallel_size=1,
    gpu_memory_utilization=0.9
)

# 推理
sampling_params = SamplingParams(
    temperature=0.7,
    max_tokens=512,
    stop=["### Instruction:"]
)

def generate(instruction: str) -> str:
    prompt = f"""### Instruction:
{instruction}

### Response:
"""
    outputs = llm.generate([prompt], sampling_params)
    return outputs[0].outputs[0].text

# 测试
response = generate("如何排查服务OOM问题?")
print(response)

效果评测

评测方法

用GPT-4评分,满分5分:

def evaluate_response(question: str, answer: str, reference: str) -> float:
    prompt = f"""请评估这个回答的质量。

问题: {question}
参考答案: {reference}
模型回答: {answer}

评分标准(1-5分):
5分: 完全正确,表述清晰
4分: 基本正确,可能有小问题
3分: 部分正确,有遗漏或错误
2分: 大部分错误
1分: 完全错误或无关

请只输出一个数字(1-5):"""
    
    score = call_gpt4(prompt)
    return float(score)

评测结果

模型 平均分 4分以上比例 成本(月)
GPT-4 + RAG 4.3 82% $2000+
Llama2-7B (原版) 2.1 15% 电费
Llama2-7B + LoRA 3.8 68% 电费
Llama2-7B + LoRA + RAG 4.1 76% 电费

结论:LoRA微调+RAG的组合,效果接近GPT-4,但成本几乎为零(除了电费和一次性硬件投入)。

踩过的坑

坑1:忘记设置pad_token

# ❌ 错误
tokenizer = AutoTokenizer.from_pretrained(model_name)
# Llama的tokenizer没有pad_token,会报错

# ✅ 正确
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token

坑2:梯度累积计算错误

# 有效batch size = per_device_batch * gradient_accumulation * num_gpus
# 单卡: 4 * 4 * 1 = 16
# 不要把batch size设太小,否则训练不稳定

坑3:推理时不加LoRA

# 训练完直接加载base model推理,当然效果差
# 要么合并LoRA权重,要么推理时也加载LoRA

# 方式1: 合并 (推荐)
merged_model = model.merge_and_unload()

# 方式2: 推理时加载
model = PeftModel.from_pretrained(base_model, lora_path)

参考资料

总结

在消费级显卡上微调7B模型的经验:

  1. LoRA是首选:显存友好,效果接近Full FT
  2. QLoRA是备选:显存更省,但效果略差
  3. 数据质量比数量重要:宁可少而精
  4. r=8是好起点:够用了,再大收益递减
  5. 学习率2e-4左右:比Full FT大一个数量级
  6. 结合RAG效果更好:知识时效性问题靠RAG补充

更新记录:
2023-07-25: 初版发布
2023-09-10: 补充QLoRA对比数据
2024-01-15: 更新PEFT到最新版本的用法
2024-04-20: 补充vLLM部署章节