大模型微调实战:从 LoRA 到部署上线的完整方案
大模型微调实战:从 LoRA 到部署上线的完整方案
本文是【AI 专题精讲】系列第 11 篇。 上一篇:Token 计算与上下文窗口管理实战 下一篇:Cursor Skill 开发:把工程经验变成 AI 可执行的技能
这篇文章你会得到什么
前十篇我们一直在”用”大模型——调 API、做 RAG、搞缓存、管 Token。但有些场景你会发现:
- Prompt 怎么调都不满意,输出风格始终不对
- RAG 检索到了正确内容,但模型回答还是不专业
- 需要模型说行业术语,它只会说”通用话术”
- 想要模型以特定格式、特定角色回答,靠 Prompt 不够稳定
这时候就该考虑**微调(Fine-tuning)**了。
很多人觉得微调是”炼丹”——需要 8 张 A100、需要几万条数据、需要 ML 博士背景。实际上,2024 年之后的微调已经非常平民化了:一张 24GB 显卡、几百条数据、半天时间就能训出一个明显优于基座模型的垂直领域模型。
今天给你:什么时候该微调、怎么准备数据、怎么低成本训练、怎么部署上线,全流程走一遍。
微调 vs RAG vs Prompt 工程:三者怎么选
这三个不是互斥的,但各有适用场景:
| 维度 | Prompt 工程 | RAG | 微调 |
|---|---|---|---|
| 解决什么 | 控制输出格式和风格 | 注入外部知识 | 改变模型内在能力 |
| 数据需求 | 0 | 文档库 | 几百~几千条标注数据 |
| 成本 | 零 | 低(向量库 + Embedding) | 中(GPU 训练 + 数据标注) |
| 生效速度 | 即时 | 即时 | 小时~天 |
| 效果上限 | 中 | 高(知识方面) | 最高(能力方面) |
决策树
问题出在哪?
├── 模型不知道某些知识(公司制度、产品文档等)
│ └── → RAG(给它检索资料)
├── 模型知道但表达方式不对(格式、风格、角色)
│ ├── Prompt 能解决吗?
│ │ ├── 是 → Prompt 工程
│ │ └── 否(不稳定/太长)→ 微调
├── 模型在特定任务上能力不够(分类准确率低、提取不全)
│ └── → 微调
└── 以上都有
└── → 微调 + RAG(最强组合)
我的经验:先用 Prompt 工程,再加 RAG,最后才考虑微调。微调是最后一张牌,但也是效果最好的那张。
微调的核心概念
全量微调 vs LoRA
全量微调:更新模型所有参数。7B 模型需要 ~56GB 显存(FP16),一般人玩不起。
LoRA(Low-Rank Adaptation):只训练一个小的”补丁”矩阵,冻结原始模型参数。
原始模型参数: W (7B 参数,冻结不动)
LoRA 补丁: ΔW = A × B (只有几 MB,可训练)
推理时: W' = W + ΔW
LoRA 的参数量只有原模型的 0.1%~1%,显存需求大幅降低。
QLoRA:在 LoRA 基础上把原模型量化到 4-bit,进一步降低显存。7B 模型用 QLoRA 只需要 ~6GB 显存。
| 方案 | 7B 模型显存需求 | 效果 |
|---|---|---|
| 全量微调 | ~56 GB | 最好 |
| LoRA (FP16) | ~16 GB | 接近全量 |
| QLoRA (4-bit) | ~6 GB | 95%+ 全量效果 |
关键参数
| 参数 | 含义 | 推荐值 |
|---|---|---|
rank (r) | LoRA 矩阵的秩,越大表达能力越强 | 8~64 |
alpha | 缩放因子,通常 = rank 或 2×rank | 16~128 |
target_modules | 对哪些层加 LoRA | q_proj, k_proj, v_proj, o_proj |
learning_rate | 学习率 | 1e-4 ~ 2e-4 |
epochs | 训练轮数 | 2~5 |
batch_size | 批次大小 | 4~16(看显存) |
数据准备:最关键的一步
微调的效果 80% 取决于数据质量,20% 取决于训练参数。 垃圾数据训出来的模型比基座模型更差。
数据格式
三种主流格式:
1. Alpaca 格式(最常用)
[
{
"instruction": "根据以下产品描述,提取关键信息",
"input": "这款蓝牙耳机续航30小时,支持主动降噪,重量仅25g",
"output": "产品类型:蓝牙耳机\n续航:30小时\n特点:主动降噪\n重量:25g"
}
]
2. ShareGPT 格式(多轮对话)
[
{
"conversations": [
{"from": "human", "value": "退货流程是什么?"},
{"from": "gpt", "value": "退货流程如下:\n1. 登录账号...\n2. 选择订单...\n3. 填写原因..."},
{"from": "human", "value": "需要多久能退款?"},
{"from": "gpt", "value": "一般3-5个工作日..."}
]
}
]
3. OpenAI JSONL 格式
{"messages": [{"role": "system", "content": "你是专业客服"}, {"role": "user", "content": "退货流程"}, {"role": "assistant", "content": "退货流程如下..."}]}
{"messages": [{"role": "system", "content": "你是专业客服"}, {"role": "user", "content": "怎么改地址"}, {"role": "assistant", "content": "修改地址步骤..."}]}
数据准备脚本
import json
from pathlib import Path
def convert_to_alpaca(
raw_data: list[dict],
instruction_key: str = "question",
output_key: str = "answer",
system_prompt: str = "",
) -> list[dict]:
dataset = []
for item in raw_data:
entry = {
"instruction": system_prompt or item.get("instruction", ""),
"input": item[instruction_key],
"output": item[output_key],
}
dataset.append(entry)
return dataset
def validate_dataset(dataset: list[dict]) -> dict:
"""验证数据集质量"""
issues = []
lengths = []
for i, item in enumerate(dataset):
output = item.get("output", "")
if not output.strip():
issues.append(f"第 {i} 条: output 为空")
if len(output) < 10:
issues.append(f"第 {i} 条: output 太短 ({len(output)} 字)")
if len(output) > 5000:
issues.append(f"第 {i} 条: output 太长 ({len(output)} 字)")
lengths.append(len(output))
import numpy as np
return {
"total": len(dataset),
"issues": issues[:20],
"avg_output_length": int(np.mean(lengths)),
"min_output_length": min(lengths),
"max_output_length": max(lengths),
}
def split_dataset(dataset: list[dict], train_ratio: float = 0.9) -> tuple:
import random
random.shuffle(dataset)
split = int(len(dataset) * train_ratio)
return dataset[:split], dataset[split:]
数据量多少够
| 场景 | 推荐数据量 | 说明 |
|---|---|---|
| 风格调整 | 50~200 条 | 改变回答语气和格式 |
| 单一任务 | 200~500 条 | 如:信息提取、分类 |
| 垂直领域 | 500~2000 条 | 如:行业客服、专业问答 |
| 通用提升 | 5000+ 条 | 全面提升某领域能力 |
关键原则:200 条高质量数据 > 5000 条低质量数据。
数据质量检查清单
- 输出一致性:同类问题的回答格式和风格是否统一
- 无错误信息:答案是否准确,有无事实错误
- 长度合理:输出不要太短(信息不足)或太长(冗余)
- 覆盖全面:各种典型场景是否都覆盖到了
- 无敏感内容:不含隐私、不当内容
实战:用 Unsloth 微调 Qwen2.5
Unsloth 是目前最受欢迎的微调框架——速度快 2~5 倍,显存省 60%。
环境准备
pip install unsloth
pip install trl datasets
微调脚本
from unsloth import FastLanguageModel
from trl import SFTTrainer
from transformers import TrainingArguments
from datasets import load_dataset
# 1. 加载模型(4-bit 量化)
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="unsloth/Qwen2.5-7B-Instruct-bnb-4bit",
max_seq_length=2048,
load_in_4bit=True,
)
# 2. 添加 LoRA
model = FastLanguageModel.get_peft_model(
model,
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"],
use_gradient_checkpointing="unsloth",
)
# 3. 准备数据
dataset = load_dataset("json", data_files="train.json", split="train")
chat_template = """{% for message in messages %}
{% if message['role'] == 'system' %}{{ message['content'] }}
{% elif message['role'] == 'user' %}### 用户:{{ message['content'] }}
{% elif message['role'] == 'assistant' %}### 助手:{{ message['content'] }}{% endif %}
{% endfor %}"""
def format_data(example):
messages = example.get("messages", [])
if not messages:
messages = [
{"role": "user", "content": example.get("input", "")},
{"role": "assistant", "content": example.get("output", "")},
]
if example.get("instruction"):
messages.insert(0, {"role": "system", "content": example["instruction"]})
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
return {"text": text}
dataset = dataset.map(format_data)
# 4. 训练
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
dataset_text_field="text",
max_seq_length=2048,
args=TrainingArguments(
output_dir="./output",
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
warmup_steps=10,
num_train_epochs=3,
learning_rate=2e-4,
fp16=True,
logging_steps=10,
save_steps=100,
save_total_limit=3,
),
)
trainer.train()
训练参数调优经验
| 参数 | 数据 < 500 条 | 数据 500~2000 条 | 数据 > 2000 条 |
|---|---|---|---|
| epochs | 3~5 | 2~3 | 1~2 |
| learning_rate | 2e-4 | 1e-4 | 5e-5 |
| rank (r) | 8~16 | 16~32 | 32~64 |
| batch_size | 4 | 8 | 16 |
过拟合信号:训练 loss 持续下降但验证集效果变差。数据少的时候特别容易过拟合,减少 epochs 或降低 learning_rate。
用 LLaMA-Factory 微调(Web UI)
如果你更喜欢图形界面,LLaMA-Factory 提供了 Web UI:
git clone https://github.com/hiyouga/LLaMA-Factory.git
cd LLaMA-Factory
pip install -e ".[torch,metrics]"
# 启动 Web UI
llamafactory-cli webui
在浏览器里选模型、上传数据、调参数、点开始训练,全程可视化。
LLaMA-Factory 配置文件
也可以用 YAML 配置:
# train_config.yaml
model_name_or_path: Qwen/Qwen2.5-7B-Instruct
stage: sft
finetuning_type: lora
lora_rank: 16
lora_alpha: 32
lora_target: q_proj,k_proj,v_proj,o_proj
dataset: my_dataset
template: qwen
output_dir: ./saves/qwen2.5-7b-lora
per_device_train_batch_size: 4
gradient_accumulation_steps: 4
learning_rate: 2.0e-4
num_train_epochs: 3
fp16: true
llamafactory-cli train train_config.yaml
模型合并与导出
合并 LoRA 权重
训练完成后,LoRA 权重和基座模型是分开的。合并成一个完整模型:
# Unsloth 方式
model.save_pretrained_merged(
"merged_model",
tokenizer,
save_method="merged_16bit",
)
# 通用方式(PEFT)
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
base_model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
lora_model = PeftModel.from_pretrained(base_model, "./output/checkpoint-xxx")
merged = lora_model.merge_and_unload()
merged.save_pretrained("./merged_model")
GGUF 量化(给 Ollama 用)
# Unsloth 直接导出 GGUF
model.save_pretrained_gguf(
"gguf_model",
tokenizer,
quantization_method="q4_k_m",
)
或者用 llama.cpp:
python llama.cpp/convert_hf_to_gguf.py ./merged_model --outfile model.gguf --outtype q4_k_m
常用量化级别:
| 量化 | 大小(7B) | 质量 | 速度 |
|---|---|---|---|
| Q8_0 | ~7.5 GB | 接近无损 | 较快 |
| Q4_K_M | ~4.5 GB | 推荐 | 快 |
| Q4_0 | ~4.0 GB | 稍有损失 | 最快 |
| Q2_K | ~3.0 GB | 明显损失 | 极快 |
部署上线
Ollama 部署(最简单)
创建 Modelfile:
FROM ./model-q4_k_m.gguf
TEMPLATE """{{ if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}<|im_start|>user
{{ .Prompt }}<|im_end|>
<|im_start|>assistant
"""
PARAMETER temperature 0.7
PARAMETER top_p 0.9
PARAMETER stop "<|im_end|>"
ollama create my-model -f Modelfile
ollama run my-model "退货流程是什么"
接入现有代码(和第 04 篇的 Ollama Embedding 一样的方式):
import httpx
def call_finetuned_model(prompt: str, system: str = "") -> str:
response = httpx.post(
"http://localhost:11434/api/chat",
json={
"model": "my-model",
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"stream": False,
},
timeout=60,
)
return response.json()["message"]["content"]
vLLM 部署(高并发生产环境)
pip install vllm
python -m vllm.entrypoints.openai.api_server \
--model ./merged_model \
--host 0.0.0.0 \
--port 8000 \
--max-model-len 4096 \
--gpu-memory-utilization 0.9
vLLM 暴露的是 OpenAI 兼容接口,现有的 openai SDK 代码零改动:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
response = client.chat.completions.create(
model="./merged_model",
messages=[{"role": "user", "content": "退货流程是什么"}],
)
print(response.choices[0].message.content)
效果评估
人工评测
准备 50~100 条测试问题,基座模型和微调模型分别生成回答,人工打分:
def generate_eval_pairs(
test_questions: list[str],
base_model_fn,
finetuned_model_fn,
) -> list[dict]:
pairs = []
for q in test_questions:
pairs.append({
"question": q,
"base_answer": base_model_fn(q),
"finetuned_answer": finetuned_model_fn(q),
})
return pairs
# 导出为 CSV 让人工打分
import csv
with open("eval_pairs.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["question", "base_answer", "finetuned_answer", "winner"])
writer.writeheader()
writer.writerows(pairs)
AI-as-Judge
用 GPT-4o 当裁判,自动化评测:
def ai_judge(question: str, answer_a: str, answer_b: str) -> dict:
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": """你是一个回答质量评估专家。比较两个回答,从以下维度打分(1-5):
- accuracy: 准确性
- completeness: 完整性
- professionalism: 专业度
- format: 格式规范性
返回 JSON:{"answer_a": {"accuracy": x, ...}, "answer_b": {"accuracy": x, ...}, "winner": "a"或"b"或"tie"}""",
},
{
"role": "user",
"content": f"问题:{question}\n\n回答A:{answer_a}\n\n回答B:{answer_b}",
},
],
response_format={"type": "json_object"},
temperature=0,
)
return json.loads(response.choices[0].message.content)
def batch_evaluate(pairs: list[dict]) -> dict:
results = {"a_wins": 0, "b_wins": 0, "ties": 0}
for pair in pairs:
result = ai_judge(pair["question"], pair["base_answer"], pair["finetuned_answer"])
winner = result.get("winner", "tie")
if winner == "a":
results["a_wins"] += 1
elif winner == "b":
results["b_wins"] += 1
else:
results["ties"] += 1
total = len(pairs)
return {
"base_model_win_rate": results["a_wins"] / total,
"finetuned_win_rate": results["b_wins"] / total,
"tie_rate": results["ties"] / total,
}
持续迭代:数据飞轮
微调不是一次性的事。模型上线后要持续改进:
用户使用
↓
收集 bad case(回答不好的样本)
↓
人工修正为正确答案
↓
加入训练数据集
↓
重新微调
↓
A/B 测试新模型
↓
效果更好 → 上线替换
class DataFlywheel:
def __init__(self, dataset_path: str):
self.dataset_path = dataset_path
self.feedback_buffer = []
def collect_feedback(self, question: str, ai_answer: str, rating: int, corrected_answer: str = ""):
self.feedback_buffer.append({
"question": question,
"ai_answer": ai_answer,
"rating": rating,
"corrected_answer": corrected_answer,
"timestamp": time.time(),
})
def export_training_data(self, min_rating: int = 4) -> list[dict]:
"""导出高分和人工修正的数据为训练格式"""
training_data = []
for item in self.feedback_buffer:
if item["corrected_answer"]:
training_data.append({
"instruction": "",
"input": item["question"],
"output": item["corrected_answer"],
})
elif item["rating"] >= min_rating:
training_data.append({
"instruction": "",
"input": item["question"],
"output": item["ai_answer"],
})
return training_data
总结
- 先试 Prompt 和 RAG,最后再微调——微调是重武器,但成本和复杂度也最高。
- 数据质量 >> 数据数量——200 条高质量数据胜过 5000 条垃圾数据。
- QLoRA 是平民首选——7B 模型只需 6GB 显存,一张消费级显卡就能训。
- Unsloth / LLaMA-Factory 降低门槛——一个脚本或 Web UI 就能完成微调。
- GGUF + Ollama 最简单部署——量化导出,一条命令启动,OpenAI 兼容接口。
- 评估不能省——AI-as-Judge + 人工抽检,量化微调效果。
- 数据飞轮持续迭代——上线不是终点,收集反馈 → 修正数据 → 重新训练。
系列回顾
| 篇 | 主题 | 核心能力 |
|---|---|---|
| 01-02 | 文件解析 + 切片 | RAG 数据准备 |
| 03 | 大文件上传 | 前后端工程化 |
| 04-05 | Embedding + 向量库 | RAG 存储层 |
| 06 | 检索优化 | RAG 质量提升 |
| 07 | 意图识别 | AI 路由层 |
| 08 | 结构化输出 | 输出稳定性 |
| 09 | AI 缓存 | 成本和速度优化 |
| 10 | Token 管理 | 上下文和成本控制 |
| 11 | 大模型微调 | 模型定制化 |
| 12 | Cursor Skill | AI 可执行的工程技能 |
| 13 | Harness Engineering | 驾驭 AI 的工程效能革命 |
讨论话题:你微调过大模型吗?用的什么基座模型、多少数据、效果怎么样?评论区聊聊你的微调经验。