🎯 目标:掌握大模型微调技术(PEFT全系列)、模型量化(8-bit/4-bit/QLoRA)、私有化部署,能够独立完成大模型的微调和上线。 📋 前置要求:阶段四(LLaMA架构理解、LangChain/RAG基础、PyTorch训练经验)


本阶段知识依赖图

阶段四基础(大模型架构理解 + 应用开发经验)
    ├──→ 微调概述 ──→ 为什么需要微调?微调 vs RAG
    ├──→ PEFT参数高效微调(⭐核心)
    │       ├── BitFit ──→ 最简单:只调偏置
    │       ├── Prompt Tuning ──→ 软提示学习
    │       ├── P-Tuning ──→ 提示编码器
    │       ├── Prefix Tuning ──→ 前缀调优
    │       ├── LoRA ⭐ ──→ 低秩分解(最常用)
    │       ├── IA3 ──→ 缩放激活值
    │       └── PEFT进阶 ──→ 多适配器/融合
    ├──→ 模型量化
    │       ├── 8-bit量化 ──→ LLM.int8()
    │       ├── 4-bit量化 ──→ NF4/FP4
    │       └── QLoRA ⭐ ──→ 量化+LoRA
    └──→ 私有化部署
            ├── 硬件选型 ──→ GPU/显存计算
            ├── 云端部署 ──→ AutoDL/云服务器
            └── 接口开发 ──→ FastAPI/vLLM

模块一:PEFT参数高效微调

为什么需要微调?——三种适配大模型的方法

类比:让一个大学生帮你做事

方法1:提示词工程 = 直接告诉他怎么做
    "你是一位律师,请帮我审查这份合同"
    → 不需要培训,直接上手
    → 但他对你的公司业务不了解,可能遗漏细节

方法2:RAG = 给他参考资料
    "先看这份合同模板和公司政策,然后帮我审查"
    → 不需要培训,有资料就能做
    → 但他理解资料的方式还是通用的,不是专业化的

方法3:微调 = 给他做专业培训
    用公司1000份历史合同和审查报告来训练他
    → 需要时间和资源
    → 但他真正"学会"了公司的审查标准和风格
    → 推理时不需要额外资料,直接给出专业判断

三种方法的详细对比

方法              原理                    数据需求    计算需求    效果    适用场景
──────────────────────────────────────────────────────────────────────────────
提示词工程        设计好的输入格式         无需训练    极低        一般    快速原型、简单任务
RAG               检索相关知识辅助生成     无需训练    低          好      知识问答、文档查询
微调              用领域数据训练模型参数   需要数据    高          最好    专业领域、格式要求

什么时候用微调?
1. 需要模型"学会"特定领域的知识(如医疗、法律、金融)
2. 需要模型输出固定格式(如始终输出JSON)
3. 需要模型遵循特定行为准则(如客服话术)
4. RAG效果不够好(需要更深层的理解)
5. 推理时不能有额外延迟(RAG需要检索时间)

全量微调的问题——为什么需要PEFT?

全量微调 = 更新模型的所有参数

LLaMA-7B 的参数量:70亿
全量微调的显存需求:
    模型参数: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₁, W₂
    偏置参数(占0.5%):b_Q, b_K, b_V, b_O, b₁, b₂

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:[p₁ p₂ p₃ p₄] + [CLS] 今天 天气 真 好 [SEP]
         这4个向量是可学习的(不是真实的词!)
         它们的作用是"引导"模型的注意力和行为

训练时:只更新 p₁, p₂, p₃, p₄ 这4个向量
推理时:不同任务用不同的前缀 → 同一个模型可以做不同任务!

Prompt Tuning的优势

1. 存储效率极高:每个任务只需保存4-20个向量(几KB!)
2. 多任务服务:一个基础模型 + 多个Prompt = 多个任务
3. 不改变模型结构:可以随时切换任务
4. 效果在大模型上很好(10B+参数时接近全量微调)

局限:
- 在小模型上效果一般
- 对初始化敏感(不同的初始值效果差异大)
- 不如LoRA在大多数任务上的效果

P-Tuning——可学习的提示编码器

Prompt Tuning的问题:p₁, p₂, p₃ 是独立的可学习向量
→ 它们之间没有"联系",初始化敏感

P-Tuning的改进:用一个小的LSTM或MLP来生成这些向量
→ p₁, p₂, p₃ 由编码器生成,有相互依赖关系
→ 初始化更稳定,效果更好

P-Tuning v2:在每一层都添加可训练的前缀(而不只是输入层)
→ 效果更接近全量微调

Prefix Tuning——前缀调优

核心思想:在每一层的K和V前面都加"前缀"

原始Self-Attention:
    Q = X·W_Q, K = X·W_K, V = X·W_V

Prefix Tuning:
    Q = X·W_Q
    K = [P_K; X·W_K]    ← P_K是可学习的前缀Key
    V = [P_V; X·W_V]    ← P_V是可学习的前缀Value

类比:
原始Attention = 学生看黑板上的内容
Prefix Tuning = 学生同时看黑板和老师举的提示牌
              → 提示牌影响了学生"关注什么"

Prefix Tuning vs Prompt Tuning

Prompt Tuning:只在输入层添加前缀 → 影响力有限
Prefix Tuning:在每一层的K和V都添加前缀 → 影响力更大

类比:
Prompt Tuning = 只在教室门口放一块提示牌
Prefix Tuning = 在教室的每一面墙都放提示牌 → 学生处处受影响

LoRA——低秩适配 ⭐⭐⭐ 最重要的微调方法

核心思想——学习权重的"变化量"

类比:学画画

全量微调 = 从零开始画一幅新画
    需要重新学所有技法,工作量巨大

LoRA = 在原画上做小幅修改
    原画已经很好了,只需要"微调"一些细节
    比如把天空的颜色调蓝一点,把树叶加几笔

LoRA的关键洞察:
    微调前的权重 W(768×768)已经学到了大量通用知识
    微调后的权重 W' = W + ΔW
    ΔW 是"需要改的部分",它远比 W 小(低秩)
    → 可以用两个小矩阵 A 和 B 来近似:ΔW ≈ A × B

数学推导

原始权重 W:(768, 768) — 589,824 个参数

LoRA分解:
    ΔW = A × 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 × B
    可以把A×B合并回W → 推理时没有额外开销!

为什么低秩假设成立?

研究发现:大模型微调时,权重的变化量ΔW确实具有低秩特性

直觉理解:
    预训练模型已经学到了"语言的通用知识"(语法、语义、世界知识)
    微调只是教它"新的任务特定知识"(如医疗诊断、法律审查)
    通用知识的信息量 >> 任务特定知识的信息量
    → Δ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 → 影响系数 = 32/8 = 4
    alpha=16, r=8 → 影响系数 = 16/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 ⊙ K      (l_k是可学习的缩放向量)
    V' = l_v ⊙ V
    FFN' = l_ff ⊙ FFN(x)

参数量:只需要3个向量(比LoRA还少!)
    如果d_model=4096,IA3只需要 3 × 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()的解决方案——混合精度分解

步骤:
1. 找出激活值中的离群值(绝对值 > 6 的通道)
2. 离群值用 float16 计算(保持精度)
3. 非离群值用 int8 计算(节省显存)
4. 合并两部分结果

效果:几乎无损!模型性能下降 < 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的核心思想:先压缩,再微调

类比:在一个压缩过的画布上做小幅修改

步骤:
1. 用4-bit量化加载大模型(压缩画布 → 节省空间)
2. 在量化模型上添加LoRA适配器(准备小幅修改的工具)
3. 只训练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显存需求计算——必须掌握的公式

推理显存(模型加载):
    显存 ≈ 参数量 × 每参数字节数
    
    LLaMA-7B  (float16):7B  × 2 bytes = 14 GB
    LLaMA-13B (float16):13B × 2 bytes = 26 GB
    LLaMA-70B (4-bit):  70B × 0.5 bytes = 35 GB

训练显存(远大于推理):
    显存 ≈ 参数量 × (模型精度 + 梯度 + 优化器状态)
         ≈ 参数量 × (2 + 2 + 4 + 4) bytes  ← Adam优化器
         ≈ 参数量 × 12 bytes (float16混合精度)
    
    LLaMA-7B:7B × 12 bytes ≈ 84 GB
    LLaMA-7B + LoRA:7B × 2 + 4M × 4 ≈ 14 GB
    LLaMA-7B + QLoRA:~6 GB

选择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")

📝 自测题

  1. 微调对比:用"让大学生做事"的类比,解释提示词工程、RAG、微调的区别
  2. LoRA:用"画画修改"的类比解释LoRA的低秩假设,为什么ΔW可以用A×B来近似?
  3. LoRA参数:r、alpha、target_modules分别如何影响微调效果?
  4. QLoRA:解释QLoRA的工作流程,为什么4-bit量化后仍能微调?
  5. 量化:解释LLM.int8()的混合精度分解原理,为什么需要处理离群值?
  6. NF4:为什么NF4比FP4更适合量化大模型?
  7. 显存计算:计算LLaMA-13B使用QLoRA微调需要多少显存
  8. PEFT对比:BitFit、Prompt Tuning、Prefix Tuning、LoRA、IA3各自的优缺点
  9. 部署:设计一个大模型API服务的架构方案
  10. 综合:给定一张RTX 3090(24GB),能微调多大的模型?用什么方法?