🎯 目标:掌握大模型微调技术(PEFT全系列)、模型量化(8-bit/4-bit/QLoRA)、私有化部署,能够独立完成大模型的微调和上线。 📋 前置要求:阶段四(LLaMA架构理解、LangChain/RAG基础、PyTorch训练经验)
本阶段知识依赖图
flowchart TD
A["阶段四基础(大模型架构理解 + 应用开发经验)"]
A -->|"为什么需要微调?微调 vs RAG"| B[微调概述]
A --> C["PEFT参数高效微调(核心)"]
C -->|最简单:只调偏置| C1[BitFit]
C -->|软提示学习| C2[Prompt Tuning]
C -->|提示编码器| C3[P-Tuning]
C -->|前缀调优| C4[Prefix Tuning]
C -->|"低秩分解(最常用)"| C5["LoRA "]
C -->|缩放激活值| C6[IA3]
C -->|多适配器/融合| C7[PEFT进阶]
A --> D[模型量化]
D -->|"LLM.int8()"| D1[8-bit量化]
D -->|NF4/FP4| D2[4-bit量化]
D -->|量化+LoRA| D3["QLoRA "]
A --> E[私有化部署]
E -->|GPU/显存计算| E1[硬件选型]
E -->|AutoDL/云服务器| E2[云端部署]
E -->|FastAPI/vLLM| E3[接口开发]
模块一:PEFT参数高效微调
为什么需要微调?——三种适配大模型的方法
类比:让一个大学生帮你做事
| 方法 | 类比 | 特点 |
|---|---|---|
| 提示词工程 | 直接告诉他怎么做 “你是一位律师,请帮我审查这份合同” | 不需要培训,直接上手;但对公司业务不了解,可能遗漏细节 |
| RAG | 给他参考资料 “先看这份合同模板和公司政策,然后帮我审查” | 不需要培训,有资料就能做;但理解方式还是通用的,不够专业化 |
| 微调 | 给他做专业培训 用公司 1000 份历史合同和审查报告来训练他 | 需要时间和资源;但真正“学会”公司标准,推理时直接给出专业判断 |
三种方法的详细对比
| 方法 | 原理 | 数据需求 | 计算需求 | 效果 | 适用场景 |
|---|---|---|---|---|---|
| 提示词工程 | 设计好的输入格式 | 无需训练 | 极低 | 一般 | 快速原型、简单任务 |
| RAG | 检索相关知识辅助生成 | 无需训练 | 低 | 好 | 知识问答、文档查询 |
| 微调 | 用领域数据训练模型参数 | 需要数据 | 高 | 最好 | 专业领域、格式要求 |
什么时候用微调?
- 需要模型“学会”特定领域的知识(如医疗、法律、金融)
- 需要模型输出固定格式(如始终输出 JSON)
- 需要模型遵循特定行为准则(如客服话术)
- RAG 效果不够好(需要更深层的理解)
- 推理时不能有额外延迟(RAG 需要检索时间)
全量微调的问题——为什么需要PEFT?
全量微调 = 更新模型的所有参数。
| 组成 | 计算 | 显存 |
|---|---|---|
| 模型参数 | 7B × 4 bytes (float32) | 28 GB |
| 梯度 | 7B × 4 bytes | 28 GB |
| 优化器状态(Adam) | 7B × 8 bytes | 56 GB |
| 总计 | — | 约 112 GB |
结论:需要 2 张 A100 80GB 显卡,成本极高。
PEFT 的解决方案:
- 只训练 0.1%-1% 的参数,冻结其余 99%
- LLaMA-7B + LoRA:只需训练约 400 万参数
- 显存需求降到 14-18 GB → 一张 RTX 3090 就够了
BitFit——最简单的微调方法
核心思想:只调偏置,不调权重
一个 Transformer 层的参数:
- 权重参数(占 99.5%):$W_Q, W_K, W_V, W_O, W_1, W_2$
- 偏置参数(占 0.5%):$b_Q, b_K, b_V, b_O, b_1, b_2$
BitFit:冻结所有权重,只训练偏置
- 只有 0.5% 的参数参与训练
- 效果在某些任务上能达到全量微调的 90%
为什么只调偏置也有效?
- 偏置虽然少,但它控制了每个神经元的“激活阈值”
- 调整偏置 = 调整“什么时候激活,什么时候不激活”
- 对于分类等任务,调整激活阈值就足够了
from transformers import AutoModelForSequenceClassification
model = AutoModelForSequenceClassification.from_pretrained("bert-base-chinese", num_labels=2)
# 冻结所有参数
for param in model.parameters():
param.requires_grad = False
# 只解冻偏置参数
for name, param in model.named_parameters():
if "bias" in name:
param.requires_grad = True
# 查看可训练参数量
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"可训练参数:{trainable:,} / {total:,} = {trainable/total:.2%}")
# 可训练参数:约89,000 / 102,000,000 = 0.09%
Prompt Tuning——软提示学习
核心思想:不改模型,只加"前缀"
类比:在演员上台前,给他一个“提词器”。
- 原始输入:
[CLS] 今天 天气 真 好 [SEP] - Prompt Tuning:
[p1 p2 p3 p4] + [CLS] 今天 天气 真 好 [SEP]- 这 4 个向量是可学习的(不是真实的词)
- 作用是“引导”模型的注意力和行为
训练时:只更新 p1, p2, p3, p4 这 4 个向量。
推理时:不同任务用不同的前缀 → 同一个模型可以做不同任务。
Prompt Tuning的优势:
- 存储效率极高:每个任务只需保存 4-20 个向量(几 KB)
- 多任务服务:一个基础模型 + 多个 Prompt = 多个任务
- 不改变模型结构:可以随时切换任务
- 效果在大模型上很好(10B+ 参数时接近全量微调)
局限:
- 在小模型上效果一般
- 对初始化敏感(不同初始值效果差异大)
- 不如 LoRA 在大多数任务上的效果
P-Tuning——可学习的提示编码器
Prompt Tuning 的问题:p1, p2, p3 是独立的可学习向量
→ 它们之间没有“联系”,初始化敏感。
P-Tuning 的改进:用一个小的 LSTM 或 MLP 来生成这些向量
p1, p2, p3由编码器生成,有相互依赖关系- 初始化更稳定,效果更好
P-Tuning v2:在每一层都添加可训练的前缀(而不只是输入层) → 效果更接近全量微调
Prefix Tuning——前缀调优
核心思想:在每一层的K和V前面都加"前缀"
原始 Self-Attention:
- $Q = X\cdot W_Q, K = X\cdot W_K, V = X\cdot W_V$
Prefix Tuning:
- $Q = X\cdot W_Q$
- $K = [P_K; X\cdot W_K]$($P_K$ 是可学习的前缀 Key)
- $V = [P_V; X\cdot W_V]$($P_V$ 是可学习的前缀 Value)
类比:
- 原始 Attention = 学生看黑板上的内容
- Prefix Tuning = 学生同时看黑板和老师举的提示牌 → 提示牌影响了学生“关注什么”
Prefix Tuning vs Prompt Tuning:
| 对比项 | Prompt Tuning | Prefix Tuning |
|---|---|---|
| 添加位置 | 只在输入层添加前缀 | 在每一层的 K 和 V 都添加前缀 |
| 影响力 | 影响力有限 | 影响力更大 |
| 类比 | 只在教室门口放一块提示牌 | 在教室的每一面墙都放提示牌(学生处处受影响) |
LoRA——低秩适配 ⭐⭐⭐ 最重要的微调方法
核心思想——学习权重的"变化量"
类比:学画画
- 全量微调 = 从零开始画一幅新画(需要重新学所有技法,工作量巨大)
- LoRA = 在原画上做小幅修改(只需“微调”细节,比如把天空调蓝一点)
LoRA 的关键洞察:
- 微调前的权重 $W$(768×768)已经学到了大量通用知识
- 微调后的权重 $W' = W + \Delta W$
- $\Delta W$ 是“需要改的部分”,它远比 $W$ 小(低秩)
- 可以用两个小矩阵 $A$ 和 $B$ 来近似:$\Delta W \approx A \times B$
数学推导
- 原始权重 $W$:(768, 768)— 589,824 个参数
- LoRA 分解:$\Delta W = A \times B$
- $A$:(768, r),r 通常为 8 或 16
- $B$:(r, 768)
当 $r=8$ 时:
- $A$:(768, 8)= 6,144 个参数
- $B$:(8, 768)= 6,144 个参数
- 总计:12,288 个参数
- 参数减少比例:$12,288 / 589,824 = 2.1\%$ → 减少了 98%
推理时:
- $W' = W + A \times B$
- 可以把 $A \times B$ 合并回 $W$ → 推理时没有额外开销
为什么低秩假设成立?
研究发现:大模型微调时,权重的变化量 $\Delta W$ 确实具有低秩特性。
直觉理解:
- 预训练模型已经学到了“语言的通用知识”(语法、语义、世界知识)
- 微调只是教它“新的任务特定知识”(如医疗诊断、法律审查)
- 通用知识的信息量 ≫ 任务特定知识的信息量
- $\Delta W$ 的“信息量”远小于 $W$ → 低秩假设成立
实验证据:
- Aghajanyan et al. (2021) 发现:预训练模型的内在维度(intrinsic dimensionality)远小于参数量
- 一个 768 维的模型,内在维度可能只有几维
- 用几维的低秩矩阵就能有效微调
LoRA的实现
from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM
# 1. 加载预训练模型
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b")
# 2. 配置LoRA
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM, # 任务类型
r=8, # 秩(rank):越小参数越少
lora_alpha=32, # 缩放因子:通常设为r的2-4倍
lora_dropout=0.1, # Dropout:防止过拟合
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"], # 对哪些层应用LoRA
)
# 3. 创建LoRA模型
model = get_peft_model(model, lora_config)
# 4. 查看参数量
model.print_trainable_parameters()
# trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.06%
# → 只有0.06%的参数需要训练!
# 5. 正常训练(和普通模型一样)
from transformers import Trainer, TrainingArguments
trainer = Trainer(model=model, args=training_args, ...)
trainer.train()
# 6. 保存LoRA权重(只有几MB!)
model.save_pretrained("./my-lora")
LoRA的关键参数——如何选择?
- r(秩)——控制“容量”
- r=4:参数最少,适合简单任务(如情感分类)
- r=8:默认值,大多数任务够用
- r=16-64:复杂任务(如代码生成、长文本摘要)
- r=128+:接近全量微调效果
- 类比:r 就像“修改画作时用的画笔粗细”
- r=4 = 细画笔,只能做精细的小改动
- r=64 = 粗画笔,可以做大面积的修改
- lora_alpha(缩放因子)——控制“影响力”
- $alpha/r$ = LoRA 对原始权重的影响程度
- alpha=32, r=8 → 影响系数 = 4
- alpha=16, r=8 → 影响系数 = 2
- 通常设为 r 的 2-4 倍
- target_modules——应用到哪些层
- 最常见:[
"q_proj","v_proj"](只改 Q 和 V) - 更强:[
"q_proj","v_proj","k_proj","o_proj"](改所有 Attention 层) - 最强:加上 FFN 层 [
"gate_proj","up_proj","down_proj"] - 层越多 → 参数越多 → 效果越好 → 但显存需求也越大
- 最常见:[
LoRA模型融合(Merge)
# 训练完成后,可以把LoRA权重合并回原始模型
merged_model = model.merge_and_unload()
# 合并后的模型和原始模型结构完全一样
# 不需要额外的LoRA组件 → 可以像普通模型一样部署
merged_model.save_pretrained("./merged-model")
# 合并的数学原理:
# W' = W + A × B
# 把A×B的结果直接加到W上,得到新的W'
# 推理时只需要W',不需要单独存A和B
IA3——Infused Adapter by Inhibiting and Amplifying Inner Activations
IA3 的核心思想:不添加新参数,只用可学习的向量来“缩放”现有激活值。
对 K、V 和 FFN 的激活值分别乘以一个可学习的向量:
- $K' = l_k \odot K$($l_k$ 是可学习的缩放向量)
- $V' = l_v \odot V$
- $FFN' = l_{ff} \odot FFN(x)$
参数量:只需要 3 个向量(比 LoRA 还少)
- 若 $d_{model}=4096$,IA3 只需要 $3 \times 4096 = 12,288$ 个参数
效果:在某些任务上接近 LoRA。 局限:表达能力不如 LoRA(只能“缩放”,不能“变换”)。
PEFT进阶操作——多适配器管理
from peft import PeftModel
# 加载基础模型 + LoRA适配器
base_model = AutoModelForCausalLM.from_pretrained("base-model")
model = PeftModel.from_pretrained(base_model, "./my-lora-task-a")
# 切换不同适配器
model.set_adapter("lora-task-a") # 使用任务A的LoRA
model.set_adapter("lora-task-b") # 切换到任务B的LoRA
# 禁用适配器(获取原始模型输出)
with model.disable_adapter():
output = model(input) # 使用原始模型
# 合并多个适配器
model.add_weighted_adapter(
adapters=["lora-a", "lora-b"],
weights=[0.7, 0.3], # 70%任务A + 30%任务B
adapter_name="merged"
)
PEFT方法总结对比:
| 方法 | 可训练参数占比 | 原理 | 效果排名 |
|---|---|---|---|
| BitFit | 0.1% | 只调偏置 | 低 |
| Prompt Tuning | <0.01% | 输入层加可学习向量 | 中低 |
| P-Tuning v2 | 0.1-1% | 每层加可学习前缀 | 中 |
| Prefix Tuning | 0.1-1% | 每层 K/V 加前缀 | 中 |
| LoRA | 0.1-1% | 低秩分解权重变化量 | 高 ⭐ |
| IA3 | <0.01% | 缩放激活值 | 中 |
| QLoRA | 0.1-1% | 量化 + LoRA | 高 ⭐ |
实际选择:
- 大多数任务 → LoRA
- 显存非常有限 → QLoRA
- 需要快速验证 → BitFit
- 需要多任务服务 → Prompt Tuning
模块二:模型量化——让大模型在消费级GPU上运行
为什么需要量化?
类比:图片压缩
- 原始图片:10MB 的高清照片(float32 模型参数)
- 压缩后:1MB 的 JPEG 照片(int8 量化参数)
虽然 JPEG 有轻微失真,但肉眼几乎看不出来。同样,int8 量化的模型精度损失很小(通常 <1%),但体积减小 4 倍。
LLaMA-7B 的存储需求:
| 精度 | 估算占用 | 参考硬件 |
|---|---|---|
| float32 | 7B × 4 bytes = 28 GB | A100 |
| float16 | 7B × 2 bytes = 14 GB | RTX 4090 |
| int8 | 7B × 1 byte = 7 GB | RTX 3070 |
| int4 | 7B × 0.5 byte = 3.5 GB | RTX 3060 |
量化 = 降低精度 → 减少显存 → 能用更便宜的 GPU → 更多人能用大模型。
8-bit量化——LLM.int8()
核心挑战:离群值问题
- 问题:直接把 float32 转为 int8 会导致精度严重下降
- 原因:大模型的激活值中存在“离群值”(outlier)
示例:
- 正常激活值:[-0.5, 0.3, -0.2, 0.1, 0.4] → 范围小,量化误差小
- 存在离群值:[-0.5, 0.3, -0.2, 100.0, 0.4] → 为了容纳 100.0,其他值的精度严重损失
类比:
- 正常情况:一个班的成绩在 60-100 分之间
- 存在离群值:一个班的成绩在 60-100 之间,但有一个学生考了 10000 分
- 如果用同一个评分标准,其他学生的成绩差异就看不出来了
LLM.int8()的解决方案——混合精度分解
步骤:
- 找出激活值中的离群值(绝对值 > 6 的通道)
- 离群值用 float16 计算(保持精度)
- 非离群值用 int8 计算(节省显存)
- 合并两部分结果
效果:几乎无损,模型性能下降 < 1%。 显存:减半(float16 → 混合 int8/float16)。
类比:把那个考 10000 分的学生单独处理(用 float16),其他学生用正常标准评分(用 int8),所有学生的成绩都能准确表示。
from transformers import BitsAndBytesConfig, AutoModelForCausalLM
# 8-bit量化配置
bnb_config = BitsAndBytesConfig(load_in_8bit=True)
# 加载8-bit模型
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b",
quantization_config=bnb_config,
device_map="auto"
)
4-bit量化——更激进的压缩
两种 4-bit 格式:
- FP4:标准的 4-bit 浮点数
- NF4(Normal Float 4):专为正态分布设计
为什么 NF4 更好?
- 大模型的权重分布近似正态分布(大部分值在 0 附近,极少数值很大)
- NF4 的量化区间是根据正态分布设计的
- 在正态分布数据上,NF4 的量化误差最小
类比:
- FP4 = 均匀划分的尺子(每个刻度间距相同)
- NF4 = 根据数据分布调整的尺子(中间刻度密,两边刻度疏)
- 对正态分布数据,NF4 的测量精度更高
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 启用4-bit量化
bnb_4bit_quant_type="nf4", # 使用NF4格式
bnb_4bit_compute_dtype=torch.bfloat16, # 计算时用bf16
bnb_4bit_use_double_quant=True, # 二次量化(进一步压缩)
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b",
quantization_config=bnb_config,
device_map="auto"
)
QLoRA——量化 + LoRA = 最高效的微调方案 ⭐
QLoRA的核心思想:先压缩,再微调
类比:在一个压缩过的画布上做小幅修改。
步骤:
- 用 4-bit 量化加载大模型(压缩画布 → 节省空间)
- 在量化模型上添加 LoRA 适配器(准备小幅修改的工具)
- 只训练 LoRA 参数(在压缩画布上做精细修改)
显存对比(LLaMA-7B 微调):
| 方案 | 估算显存 | 参考硬件 |
|---|---|---|
| 全量微调 float32 | ~112 GB | 2 张 A100 80GB |
| 全量微调 float16 | ~56 GB | 1 张 A100 80GB |
| LoRA float16 | ~18 GB | 1 张 RTX 4090 |
| QLoRA (4-bit) | ~6 GB | 1 张 RTX 3060 |
from peft import prepare_model_for_kbit_training, LoraConfig, get_peft_model
from transformers import BitsAndBytesConfig, AutoModelForCausalLM
# 1. 4-bit量化加载
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b",
quantization_config=bnb_config,
)
# 2. 准备量化模型用于训练
model = prepare_model_for_kbit_training(model)
# 3. 添加LoRA
lora_config = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj","v_proj"])
model = get_peft_model(model, lora_config)
# 4. 正常训练
trainer = Trainer(model=model, args=training_args, train_dataset=dataset)
trainer.train()
模块三:大模型私有化部署
GPU显存需求计算——必须掌握的公式
推理显存(模型加载):
$$ \text{显存} \approx \text{参数量} \times \text{每参数字节数} $$| 模型 | 精度 | 显存需求 |
|---|---|---|
| LLaMA-7B | float16 | $7B \times 2 = 14\text{ GB}$ |
| LLaMA-13B | float16 | $13B \times 2 = 26\text{ GB}$ |
| LLaMA-70B | 4-bit | $70B \times 0.5 = 35\text{ GB}$ |
训练显存(远大于推理):
$$ \text{显存} \approx \text{参数量} \times (\text{模型精度} + \text{梯度} + \text{优化器状态}) $$$$ \approx \text{参数量} \times (2 + 2 + 4 + 4) = \text{参数量} \times 12 \text{ bytes(float16混合精度)} $$| 方案 | 显存需求 |
|---|---|
| LLaMA-7B | $7B \times 12 \approx 84\text{ GB}$ |
| LLaMA-7B + LoRA | $7B \times 2 + 4M \times 4 \approx 14\text{ GB}$ |
| LLaMA-7B + QLoRA | $\sim 6\text{ GB}$ |
选择GPU的快速参考:
| GPU | 显存 | 支持的模型和任务 |
|---|---|---|
| RTX 3060 | 12GB | 7B-4bit 推理,QLoRA 微调 7B |
| RTX 3090 | 24GB | 7B-fp16 推理,LoRA 微调 7B |
| RTX 4090 | 24GB | 同 3090 但更快 |
| A100 | 40GB | 13B-fp16 推理,LoRA 微调 13B |
| A100 | 80GB | 70B-4bit 推理 |
云端部署实践
# 使用ModelScope下载模型(国内镜像,速度快)
from modelscope import snapshot_download
model_dir = snapshot_download('LLM-Research/Meta-Llama-3-8B-Instruct')
# 或使用HuggingFace
from huggingface_hub import snapshot_download
model_dir = snapshot_download('meta-llama/Llama-2-7b-chat-hf')
对外接口开发——FastAPI
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
app = FastAPI()
# 加载模型
model = AutoModelForCausalLM.from_pretrained("./model", device_map="auto")
tokenizer = AutoTokenizer.from_pretrained("./model")
@app.post("/chat")
async def chat(prompt: str, max_tokens: int = 512):
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=max_tokens)
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
return {"response": response}
@app.post("/chat/stream")
async def chat_stream(prompt: str):
async def generate():
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
# 流式生成逻辑...
yield token
return StreamingResponse(generate(), media_type="text/event-stream")