LLM微调实战:在单卡3090上微调7B模型的完整记录
公司想要一个专门回答内部技术文档问题的模型。买不起A100,只有几张3090(24GB显存)。我花了两周时间,尝试了Full Fine-tuning、LoRA、QLoRA三种方案,最终在单卡3090上成功微调了Llama2-7B。这篇文章记录完整过程。
背景:为什么要微调
我们有一套内部的技术知识库,包含:
- API文档:约5000篇
- 故障处理手册:约800篇
- 最佳实践:约300篇
直接用GPT-4+RAG的方案效果还行,但有两个问题:
- 成本高:每月API费用约$2000
- 数据安全:部分文档涉及敏感信息,不能发到外网
所以决定自己微调一个模型私有化部署。
数据准备:这一步花了最多时间
数据格式
微调数据需要是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)
参考资料
- LoRA: Low-Rank Adaptation of Large Language Models
- QLoRA: Efficient Finetuning of Quantized LLMs
- PEFT Documentation
- QLoRA Official Repo
总结
在消费级显卡上微调7B模型的经验:
- LoRA是首选:显存友好,效果接近Full FT
- QLoRA是备选:显存更省,但效果略差
- 数据质量比数量重要:宁可少而精
- r=8是好起点:够用了,再大收益递减
- 学习率2e-4左右:比Full FT大一个数量级
- 结合RAG效果更好:知识时效性问题靠RAG补充
更新记录:
2023-07-25: 初版发布
2023-09-10: 补充QLoRA对比数据
2024-01-15: 更新PEFT到最新版本的用法
2024-04-20: 补充vLLM部署章节