← 返回上一页

第9章: 微调课:从 SFT 到 LoRA/QLoRA 的理论与实战

把“通用大模型”改造成“更懂你业务/更像你风格”的专用模型

🎯 这一章你将学到什么?

  • 微调到底在调什么:从“下一词预测”到“任务/风格/行为对齐”的本质
  • 什么时候该微调:微调 vs Prompt vs RAG 的选型准则
  • 主流微调范式:SFT、PEFT(LoRA/QLoRA)、DPO/RLHF 的差异与组合方式
  • 数据如何准备:指令数据、偏好数据、对话数据、结构化输出数据
  • 训练如何落地:从环境准备 → 训练 → 评估 → 迭代 → 部署的完整闭环
  • 常见坑如何避免:过拟合、遗忘、数据泄漏、评估失真、上线不稳定

🧭 选型:Prompt / RAG / 微调,到底怎么选?

三者不是互斥关系,最佳实践通常是Prompt(控制输出)+ RAG(补充事实)+ 微调(固化能力/风格/行为)

🧩 选 Prompt 的场景

  • 需求变化快:规则/话术经常改
  • 只需要格式化输出:JSON/表格/步骤
  • 不想维护训练流程:仅靠提示词就能控制
目标:稳定 JSON 输出 方案:System Prompt + Schema + 校验

📚 选 RAG 的场景

  • 知识经常更新:价格、库存、政策、文档
  • 要可追溯:回答必须带引用来源
  • 事实为主:更像“查资料+总结”
目标:答复有证据 方案:检索 → 重排 → 约束回答

🧪 选 微调 的场景

  • 固化风格/话术:“像某个客服/专家”
  • 固化任务能力:分类/抽取/结构化输出更稳
  • 降低推理成本:更短 prompt 也能达标
  • 让模型学会流程:多步决策、工具调用偏好
目标:更像业务助手 方案:SFT/LoRA + 小 prompt

一张图理解三者关系

Prompt(控制)
把“输出形状/约束/步骤”写进提示词里
RAG(补事实)
把“最新知识/私有文档”在推理时喂给模型
Fine-tune(固化能力)
把“你希望的行为/风格/任务能力”写进参数里
推荐组合(常见): 1) Prompt 固定输出协议(JSON/引用/拒答规则) 2) RAG 提供可追溯证据(文档片段 + 引用) 3) LoRA 微调固化口吻/分类/抽取/流程偏好

🧠 理论:微调在“优化”什么?

预训练模型学到的是语言统计规律 + 世界知识的粗映射。微调则更像是在特定分布上做“再训练”, 让模型在你关心的任务/风格/约束上更可靠。

1) SFT(监督微调)

指令-回答(Instruction → Response)数据教模型“该怎么答”。 目标通常是:更听话、更结构化、更像业务助手。

输入:请把下列用户话术分类为:售前/售后/投诉/其他 输出:{"label": "售后", "reason": "涉及退货"}

🎯 生活例子:新员工培训

就像给新客服一本标准话术手册。手册里有各种问题和标准答案,新员工照着手册回答,就能保持服务一致性。 SFT 就是把这本"话术手册"写进模型参数里。

💼 具体案例:电商客服分类系统

场景:某电商平台每天收到10万+用户咨询,需要自动分类路由到不同部门
训练数据:5万条真实用户问题 + 人工标注的分类(售前/售后/投诉/其他)
微调前:通用模型经常把"退货"误判为"售前",准确率只有65%
微调后:准确率提升到92%,能稳定输出JSON格式:{"category": "售后", "confidence": 0.95}
业务价值:人工审核工作量减少70%,响应时间从5分钟降到30秒

2) PEFT(参数高效微调)

不更新全部参数,而是在模型关键层插入小的可训练模块。 典型代表:LoRA(低秩适配),QLoRA(量化+LoRA)。

优点:显存占用低、训练快、可保存多个"业务适配器" 用法:同一个底座模型 + 不同 LoRA = 不同业务版本

🎯 生活例子:技能徽章系统

想象一个万能工具箱(基础模型),你不用重新买整个工具箱,只需要添加专用徽章(LoRA适配器): 加上"客服徽章"就变成客服工具,加上"医生徽章"就变成医疗工具。 LoRA 就是这些可插拔的"技能徽章",成本低、切换快。

💼 具体案例:多租户SaaS平台

场景:一个AI客服系统服务100+不同行业的客户(电商/金融/教育/医疗)
挑战:每个客户需要不同的行业术语和话术风格
解决方案:使用同一个7B基础模型 + 100个不同的LoRA适配器
训练成本:每个LoRA只需2小时训练,显存占用8GB(vs 全量微调需48GB)
部署效果:切换客户时只需加载对应LoRA(3秒),推理延迟不变
商业价值:服务器成本降低80%,新客户上线时间从2周缩短到2天

3) 对齐(DPO/RLHF)

SFT 解决“会不会答”,对齐解决“更偏好怎么答”。 常见做法:偏好数据(chosen / rejected),用 DPO 直接优化偏好。

同一问题:答案A(更合规/更简洁)胜过答案B 训练目标:让模型更常输出 A 风格

🎯 生活例子:礼仪培训

员工已经会说话(SFT教会基本回答),但需要学会更得体的表达。 通过对比"好的回答"vs"不好的回答",让员工明白:同样意思,不同说法,效果完全不同DPO 就是让模型学会这种"说话的艺术",更懂用户喜好。

💼 具体案例:金融风控助手

场景:银行内部AI助手需要回答员工关于风险合规的问题
SFT后问题:模型能答对,但经常过于冗长、语气生硬、缺乏风险提示
偏好数据:1万组对比,人工标注"更优回答"vs"较差回答"
   • chosen:"建议您先检查客户KYC评级,再决定是否需要人工复核。如需帮助,请联系风控部门。"
   • rejected:"直接查KYC就行了。"
DPO效果:回答简洁度提升40%,合规提示覆盖率从30%提升到95%
业务价值:员工满意度提升60%,合规风险事件减少45%

微调最常见误区

  • 把“知识更新”交给微调:新政策/价格/实时信息优先用 RAG
  • 数据不干净就开训:微调会把坏数据“写进参数”
  • 只看训练 loss:训练更低不代表任务更好,必须做离线评估

🔍 三种微调方式的异同点

维度 SFT(监督微调) PEFT(LoRA/QLoRA) 对齐(DPO/RLHF)
优化目标 学会"怎么答" 低成本学会"怎么答" 学会"更偏好怎么答"
数据格式 指令-回答对 指令-回答对 偏好对比(chosen/rejected)
参数更新 全量更新 只更新适配器(1-5%) 基于SFT模型微调偏好
显存需求 高(全参数) 低(QLoRA最低) 中等(基于SFT模型)
适用场景 任务能力、结构化输出 多业务、资源受限 风格优化、安全对齐

📋 微调方式选择决策树

第一步:明确目标

  • 需要任务能力(分类/抽取/JSON)→ 走SFT路线
  • 需要风格优化(更简洁/更礼貌)→ 走DPO路线
  • 需要多业务支持(不同客户/行业)→ 走LoRA路线

第二步:评估资源

  • 显存充足(24GB+)→ 可选全量SFT
  • 显存有限(8-16GB)→ 优先QLoRA
  • 需要快速切换业务→ 必选LoRA

第三步:组合策略

  • 基础能力:SFT/LoRA → 学会任务
  • 风格优化:DPO → 提升体验
  • 多业务部署:LoRA → 降低成本

🧱 数据:微调数据长什么样?怎么做才“像工程”?

微调成败很大程度取决于数据。建议把数据当成产品资产:有规范、有版本、有评估、有回滚

1) 指令数据(SFT)常见格式

text
{
  "messages": [
    {"role": "system", "content": "你是某电商平台的售后客服,回答必须输出 JSON。"},
    {"role": "user", "content": "我想退货,包裹已经拆了还能退吗?"},
    {"role": "assistant", "content": "{\"answer\": \"可以退\", \"policy\": \"7天无理由…\", \"next_step\": \"…\"}"}
  ]
}

2) 偏好数据(DPO)常见格式

text
{
  "prompt": "用户问:如何查询订单物流?",
  "chosen": "你可以在【我的订单】→ 选择订单 → 点击【查看物流】…",
  "rejected": "自己去网上查吧。"
}

✅ 好数据的特征

  • 覆盖真实分布:来自日志/工单/真实问法
  • 标准一致:同类问题同一口径、同一结构
  • 可验证:能自动评测(分类准确率/抽取F1/JSON有效率)
  • 不泄漏:测试集与训练集严格隔离

⚠️ 坏数据的信号

  • 同问不同答:标准口径混乱
  • 奖励“瞎编”:看似流畅但事实不对
  • 格式不严:JSON 常缺字段/多字段
  • 噪声很大:复制粘贴、乱码、低质量对话

🏗️ 工程流程:从 0 到 1 做一次“可迭代”的微调

这一节把“微调”落到工程:怎么装环境、怎么下载模型、LoRA 到底在改什么、SFT/LoRA/QLoRA 怎么选。 你可以把它当成一份“从 0 到能跑”的操作手册。

Step 0 定义目标
任务边界、输出协议、成功指标
Step 1 环境准备
Python、CUDA、依赖、显存预算
Step 2 下载模型
base 模型、tokenizer、缓存目录
Step 3 准备数据
SFT/偏好数据、清洗、切分
Step 4 训练
SFT/LoRA/QLoRA + 超参
Step 5 评估
指标、回归集、对齐与安全
Step 6 上线迭代
灰度、监控、回滚、难例闭环

0) 先把“微调目标”说清楚(否则越调越乱)

目标A:更稳定的结构化输出

把“字段齐全、类型正确、拒答一致”当成硬指标(可自动评测)。

目标B:更像业务话术/风格

减少长 Prompt,靠“参数”固化口吻与表达结构。

目标C:更强的任务技能

分类/抽取/路由/意图识别:靠指标评测,不靠主观感觉。

目标D:更低的推理成本

用更短 prompt 达到同等质量,减少 token 与延迟。

生活类比:先定“验收标准”,再施工

微调像做项目交付:如果你不先说清楚“交付件是什么”,最后很容易变成“感觉好像更聪明了,但上线不稳定”。 结构化输出/分类抽取这类目标之所以适合入门,是因为你可以像做质检一样:写一组样例,一键回归对比“微调前 vs 微调后”。

1) 环境与安装:把“能训练”变成可复现

推荐环境(入门)

目标是:优先把流程跑通(能下载模型、能训练、能保存 adapter、能复现结果),再逐步放大规模。

  • 系统:Linux(最推荐) / Windows(可) / Mac(可做小规模实验,但主流训练仍以 CUDA 为主)
  • Python:3.10 或 3.11(与生态兼容性更好)
  • GPU:NVIDIA(CUDA 生态最成熟;训练/量化/加速工具最全)
  • CUDA/驱动:确保驱动与 CUDA 版本匹配,否则常见报错是“找不到 GPU / bnb 不可用 / 编译失败”
  • 内存:建议 32GB 起步(数据处理/缓存/多进程更稳)
  • 磁盘:建议至少预留 50GB+(模型文件、datasets cache、训练输出、日志)
  • 网络:首次下载模型与数据需要稳定网络(建议可预下载到本地目录)

显存分档:你大概能训练到什么程度?(粗略经验)

显存 推荐方案 适合做什么 注意事项
8GB 优先 QLoRA / 更短序列长度 跑通流程、小数据试验 更容易 OOM,需要更保守的 batch/seq_len
12GB QLoRA 或 LoRA(保守) 7B/8B 级别微调入门 建议开启混合精度、合理 seq_len
16GB LoRA(最舒适起点) 7B/8B LoRA 更好迭代 能跑更长 seq_len/更大 batch
24GB+ LoRA / 多卡 / 更大模型 更大参数、更大数据、更长上下文 评估/数据闭环同样重要,别只堆规模
生活类比:显存就像厨房台面面积。
台面越大(显存越大),你一次能同时摆的食材/锅具越多(更长上下文、更大 batch)。 台面小也能做饭,但就要更精打细算:少放点(减 batch/seq_len),或者用更省空间的工具(QLoRA/量化)。

安装(Python 依赖)

text
# 建议:创建独立环境(conda/venv 二选一即可)
# conda create -n ft python=3.10 -y
# conda activate ft

python -m venv .venv
source .venv/bin/activate

python -m pip install -U pip

# 核心库:训练与微调
pip install "torch" "transformers" "datasets" "accelerate" "peft" "trl"

# QLoRA 需要:4-bit 量化训练
pip install "bitsandbytes"
生活类比:这些依赖就像一套“工具箱”。
torch 是发动机(没它车跑不动),transformers 是方向盘+仪表盘(负责把模型装起来并开起来), peft 是改装套件(只改小部件),trl 是训练工艺流程(SFTTrainer 等), bitsandbytes 则是“轻量化材料/压缩工具”(让小显存也能上路)。

这些依赖各自是干什么的?

torch
深度学习底层引擎:张量计算、自动求导、GPU 加速。训练是否能跑起来,主要靠它。
transformers
加载/使用大模型与 tokenizer(AutoModel/AutoTokenizer),以及生成、配置等能力。
datasets
数据管道:读取 json/jsonl、缓存、切分 train/eval、map 预处理,让训练数据更工程化。
accelerate
训练加速与配置:单卡/多卡、混合精度(fp16/bf16)、分布式等,让脚本更容易在不同硬件上跑。
peft
参数高效微调库:实现 LoRA/Adapter 等,让你只训练很小一部分参数(适配器),而不是全量更新模型。
trl
训练脚手架:提供 SFTTrainer(监督微调)、DPO 等训练器,减少你手写训练循环的工作量。
bitsandbytes
量化与低精度计算支持(常见 8-bit/4-bit)。在 QLoRA 这类“低显存训练”场景里常用。

什么是量化(Quantization)?

量化可以理解为:把模型权重从常见的 FP16/BF16(16 位)压缩成更低位宽(如 8-bit / 4-bit)。 位数越低,占用的显存越少,但会引入一定数值误差。

量化的收益
显存占用显著下降,让单卡/小显存更容易加载并训练(或至少能跑通流程)。
量化的代价
精度更“粗”,可能带来稳定性/效果差异;不同硬件与版本组合也更容易出现兼容性问题。

生活类比:量化 ≈ 图片/视频“压缩”

你可以把 FP16/BF16→8-bit/4-bit 理解成:把一张高清图片从“无损/高码率”压成“更小的文件”。 文件更小(更省显存),但会带来一定细节损失/噪点。 所以 QLoRA 的工程直觉就是:先把 base “压缩着加载”(省显存),再训练那块小小的 LoRA 增量(adapter)。

QLoRA =(把 base 模型权重用 4-bit 量化加载)+(仍然只训练 LoRA 适配器) 直观结果:更省显存,但需要更多“踩坑经验”。

训练前自检(避免“装完就报错”)

python
# 检查 torch 是否能看到 GPU
python -c "import torch; print('cuda:', torch.cuda.is_available()); print('device:', torch.cuda.get_device_name(0) if torch.cuda.is_available() else None)"

# accelerate 配置(单卡也建议做)
accelerate config

如果你是 Mac(M 系列)或纯 CPU 环境,也能做小模型或少量数据的实验,但主流 LoRA/QLoRA 训练体验一般还是以 CUDA 为主。

2) 模型下载:先把 base 模型放到本地缓存

方式A:直接用 Transformers 自动下载

python
from transformers import AutoTokenizer, AutoModelForCausalLM

model_id = "your-base-model"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto")
print("loaded")

首次运行会自动下载到本机缓存目录(可通过环境变量指定缓存路径)。

方式B:命令行预下载(更可控)

text
# Hugging Face(示例)
pip install -U huggingface_hub

# 预先登录(可选)
# huggingface-cli login

# 下载到指定目录(示意)
huggingface-cli download your-base-model --local-dir ./models/your-base-model --local-dir-use-symlinks False

工程里更推荐预下载,避免线上机器跑脚本时临时拉模型。

生活类比:预下载 ≈ 旅行前先把行李打包好

训练时再临时下载模型,就像出门前不收拾行李,到了机场才发现充电器没带——一旦网络/权限/代理出问题,整个流程会卡死。 预下载到 models/base 的好处是:你把“外部不确定性”提前消化掉,训练/推理就能稳定复现。

3) LoRA 原理:LoRA 到底改了什么?

在 Transformer 里,核心是大量线性层(比如注意力中的 Q/K/V 投影)。 全量微调会更新这些层的整个权重矩阵 W。 LoRA 的思想是:不直接改 W,而是学一个低秩增量 ΔW

原始:y = W x LoRA:y = (W + ΔW) x 其中:ΔW = B A A: (r × d) B: (d × r) r 很小(低秩)

生活类比:LoRA 像“外挂配件”,不是“换整机”

  • 全参 SFT:像把相机整套机身+镜头都重新调校/重装系统——效果可很强,但成本极高、风险也大
  • LoRA:像给相机加一个可拆卸的外挂镜头/滤镜(ΔW),机身(W)不动;需要换风格就换配件
  • 你真正训练的是什么:就是这块“外挂配件”的参数(A/B),所以文件小、迭代快、好回滚

用一个 4×4 矩阵例子理解 LoRA(r=2)

真实模型里一层的 W 通常是几千维(非常大)。这里用 4×4 做一个更接近“真实结构”的例子。 LoRA 选 r=2 时:A(2×4)B(4×2),所以 ΔW = B·A 仍然是 4×4。

(1) Base 权重 W(冻结)
102-1
0102
-2110
1-101
(2) LoRA 参数 A、B(只训练它们)
A (2×4)
1010
0101
B (4×2)
10
01
-10
0-1

直觉:r=2 表示用两条“方向”(2 个通道)来表达增量。

把“B·A 怎么算出来”摊开(不需要高等代数)
你只需要记住一个最朴素的矩阵乘法规则
ΔW = B · A
ΔW[i,j] = Σ_k B[i,k] · A[k,j](k 从 1..r)
在这个例子里:B 是 4×2、A 是 2×4,所以每个 ΔW[i,j] 都是“2 项相加”
ΔW[i,j] = B[i,1]·A[1,j] + B[i,2]·A[2,j]
举 3 个元素的手算(只看“对应行×对应列”):
ΔW[1,1] = 1·1 + 0·0 = 1
ΔW[3,3] = (-1)·1 + 0·0 = -1
ΔW[4,4] = 0·0 + (-1)·1 = -1
还有一个非常重要的“更好算”的写法(也是工程里常用的计算顺序):
y = (W + B A) x = W x + B (A x)
解释:先算 u = A x(把 d 维压到 r 维),再算 v = B u(把 r 维还原回 d 维),最后 y = W x + v
一张图看懂:y = W x + B(Ax)
输入 x (d 维) Base:W x W 冻结 LoRA:u = A x d → r(降维) v = B u r → d(升维) + y (d 维) 核心:W 冻结;只训练 A/B(LoRA 分支),输出通过“加法”合成
(3) 计算 ΔW = B·A,以及更新后的 W' = W + ΔW

因为 A 是两行、B 是两列,所以: ΔW = B[:,1] ⊗ A[1,:] + B[:,2] ⊗ A[2,:] (也就是两个 rank-1 外积叠加)。这就是“低秩”的核心直觉。

ΔW (4×4)
1010
0101
-10-10
0-10-1
W' = W + ΔW (4×4)
203-1
0203
-3100
1-200
你应该看懂的点 1:只改“增量”
W 冻结不动,只训练 A/B,就能让线性层权重从 W 变成 W'(行为发生变化)。
你应该看懂的点 2:r=2 的含义
ΔW 由 2 个“方向”叠加得到(两个外积),比 r=1 更有表达力,但仍远小于全参更新的参数量。

训练时只更新 A、B,W 冻结不动,因此显存和训练开销大幅下降。

LoRA 示意:冻结 W,只训练低秩增量 ΔW Base 权重 W 冻结不更新 ΔW = B A 只训练 A、B 推理时合成 W + ΔW 关键超参怎么理解? r:容量(越大越能学,但越占显存) alpha:缩放(影响 ΔW 强度) dropout:防过拟合

把 r / alpha / dropout 用大白话讲清楚

你可以把 LoRA 理解成:在不动底座权重 W 的前提下,额外挂了一条“小支路”去学习增量 ΔWr、alpha、dropout 本质上是在控制:这条“小支路”有多宽(能学多少)、声音有多大(影响多强)、以及会不会死记硬背(过拟合)。

r
容量(Rank)= “小支路的宽度”
r 越大:ΔW 的表达能力越强(更“能学”复杂变化),但训练参数量/显存/计算都会增加。
直觉:r 就像“能写多少笔记”的空间,空间越大越能记细节,但成本更高。
建议起步:r = 8 / 16
α
缩放(alpha)= “ΔW 的音量旋钮”
LoRA 的增量不是直接叠上去,而是会有一个缩放。
alpha 越大:ΔW 的影响越明显(变化更“强”);太大可能导致训练不稳或更容易过拟合。
直觉:你把“外挂配件”装上去后,alpha 决定它到底“作用几分”。
常见经验:alpha ≈ 2 × r
dropout
防过拟合 = “别让它背题”
dropout 会在训练时随机“掐掉”一部分 LoRA 分支的连接,让模型不能只靠少量路径死记硬背。
dropout 越大:越不容易过拟合,但收敛可能更慢、效果可能变弱。
直觉:像考试前不划重点,逼你全面理解,而不是背答案。
常用范围:0.0 ~ 0.1(数据少可用 0.05)
一张小图把三者关系串起来
r:分支宽度 能力 ↑ / 显存 ↑ alpha:影响强度 变化 ↑ / 不稳风险 ↑ dropout:抗过拟合 泛化 ↑ / 收敛慢 ↑ 调参顺序(建议):先 r,再 alpha,最后用 dropout 抑制过拟合

LoRA 的关键工程点(非常重要)

  • target_modules:不是所有层都需要加 LoRA,通常选 attention 的投影层(Q/K/V/O)或 MLP 部分
  • r 不是越大越好:先从 r=8/16 起步,效果不够再加
  • 数据越少越要保守:少数据 + 大 r + 多 epoch 容易过拟合
  • 多版本共存:同一 base 模型可以挂多个 adapter(售前/售后/质检不同版本)

4) SFT / LoRA / QLoRA:区别与怎么选

方案 更新哪些参数 显存/成本 优点 风险/缺点 适用场景
SFT(全参) 更新大部分/全部参数 最高(显存与训练时间) 理论上最强,改造力度最大 成本高、易遗忘、维护多版本困难 数据量大、算力充足、需要强改造
LoRA(PEFT) 冻结 base,仅训练 LoRA 适配器 中低(工程最常用) 训练快、显存省、多版本易管理 能力上限受限于 base 与 r(但多数业务足够) 企业落地首选:话术/抽取/分类/结构化输出
QLoRA base 4-bit 量化 + 训练 LoRA 适配器 最低(最省显存) 单卡更容易跑起来,适合快速试验 量化带来数值/速度/兼容性差异,需要更多踩坑经验 显存紧张(8GB/12GB)或先试后放大

实用结论(给你一个“工程优先级”)

  • 默认从 LoRA 开始:成本低、迭代快、易回滚
  • 显存不够用 QLoRA:先把流程跑通,再考虑更大显存/多卡
  • 只有在必要时才做全参 SFT:否则维护与成本都很难扛

生活类比:三种路线像“装修/改装”的不同级别

  • SFT(全参):全屋重装(拆掉重来)——改造幅度最大,但工期长、预算高、还可能把原来的好用部分弄没(遗忘)
  • LoRA:加装可拆卸模块(例如加一套可换的柜体/软装)——不动主体结构,想换风格就换模块
  • QLoRA:像在预算/空间更紧时的方案:主体材料“更轻量”(量化加载省显存),再加装同样的模块(仍训练 LoRA)

5) 把流程串起来:从 0 → 1 跑通一次(最小可执行)

上面 1) 环境与安装2) 下载模型3) 数据4) 训练与选择 已经把原理与坑点讲清楚了。 这里把它们串成一次可执行的“验收流程”:你按顺序做完,最终能拿到 outputs/adapter 并完成推理验证。

Step 0:准备一个工作目录(推荐结构)

text
# 建议新建一个独立目录(示例名称:ft-demo)
ft-demo/
  data/
    train.jsonl
    eval.jsonl
  scripts/
    train_lora.py
    infer.py
  models/
    base/              # 预下载的 base 模型(推荐,避免训练时临时下载失败)
  outputs/
    adapter/            # 训练后产物(LoRA 适配器)
    logs/               # 训练日志(可选)

前置条件(不重复展开)

  • 依赖安装:按上面 1) 环境与安装 完成(含 LoRA 必装依赖;QLoRA 再加 bitsandbytes)
  • 模型准备:按上面 2) 下载模型 完成(建议预下载到本地路径,例如 ./models/base

先选路线(按显存):LoRA vs QLoRA

路线A:LoRA(推荐)
显存 16GB+ 更舒适。base 模型按常规方式加载,训练 LoRA adapter。
路线B:QLoRA(省显存)
显存 8GB/12GB 也能尝试。base 模型用 4-bit 量化加载,仍训练 LoRA adapter。
没有本地 GPU?可以用 AutoDL AI 算力云来“模拟”跑通流程
AutoDL 提供按量计费的 GPU 环境(常见是 Jupyter/SSH + CUDA 驱动已配好),很适合用来: 临时租一张卡把本章的 LoRA/QLoRA 流程从 0→1 跑通、复现实验、排查显存问题。
  • 怎么选配置:想走 LoRA,尽量选 16GB+;显存紧张就选 QLoRA(4-bit)路线
  • 怎么衔接本文流程:在 AutoDL 的实例里按本文安装依赖 → 预下载 base 到 models/base → 训练产出 outputs/adapter → 推理验证
  • 怎么省钱:先用小模型(例如 1.5B/3B)+ 少量数据(20 条)把流程跑通,再逐步放大

Step 1(可选但强烈推荐):把 base 模型预下载到本地

这样训练时不会“临时下载失败/断网”,也更符合工程可复现。

预下载完以后到底“怎么用”?(关键)

预下载的意义是:把模型文件落到你项目里的 models/base/。 从这一步开始,你后续训练/推理就不再需要写 HuggingFace 模型名(例如 Qwen/xxx),而是直接把 BASE_MODEL 指向本地目录:models/baseTransformers 的 from_pretrained() 支持读取“本地目录”,所以会直接从磁盘加载,不会再联网下载。

python
# 示例:先选一个能跑通流程的小模型(你也可以换成任何可用模型)
export BASE_MODEL="Qwen/Qwen2.5-1.5B-Instruct"

# 可选:把 HF 缓存放到项目目录,方便迁移与复现
export HF_HOME="$(pwd)/.hf"

pip install -U huggingface_hub

# 下载模型到 models/base
huggingface-cli download "$BASE_MODEL" --local-dir ./models/base --local-dir-use-symlinks False

# 下载完快速自检
ls -lh models/base | head
python -c "from transformers import AutoTokenizer; AutoTokenizer.from_pretrained('models/base'); print('tokenizer ok')"
text
# 训练/推理时使用“本地目录”作为 base(重点看这里)
BASE_MODEL="models/base" python scripts/train_lora.py
BASE_MODEL="models/base" python scripts/infer.py

# 这等价于代码里的:
# AutoTokenizer.from_pretrained("models/base")
# AutoModelForCausalLM.from_pretrained("models/base")

Step 2:准备数据(先做 20 条也能跑通)

data/train.jsonldata/eval.jsonl 中,每行一个 JSON:包含 messages(system/user/assistant)。

生活类比:训练数据像“示范对话脚本”。
你给模型看的每一条样本,就像给新同事一段标准话术: 用户怎么说你应该怎么回。 数据越一致、格式越稳定,模型越不容易“学歪”。
text
# data/train.jsonl(示例,至少几条就能跑通)
{"messages": [{"role": "system", "content": "你是售后助手,输出必须是 JSON"}, {"role": "user", "content": "我想退货"}, {"role": "assistant", "content": "{\"label\":\"售后\",\"next_step\":\"引导用户在订单页点击退货\"}"}]}
{"messages": [{"role": "system", "content": "你是售后助手,输出必须是 JSON"}, {"role": "user", "content": "怎么查物流"}, {"role": "assistant", "content": "{\"label\":\"查询\",\"next_step\":\"引导用户在订单页查看物流\"}"}]}
text
# data/eval.jsonl(示例,放 2-5 条也能先跑通流程)
{"messages": [{"role": "system", "content": "你是售后助手,输出必须是 JSON"}, {"role": "user", "content": "包装拆了还能退吗"}, {"role": "assistant", "content": "{\"label\":\"售后\",\"next_step\":\"解释规则并引导申请\"}"}]}

Step 3:新建训练脚本(scripts/train_lora.py)

你把下面这段完整复制到 scripts/train_lora.py,然后只需要改 model_id 即可。

python
import os
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer


def main():
    # 1) 选择 base 模型(最关键:你只需要改这里)
    # model_id 支持 2 种写法:
    # - HuggingFace 模型名(会自动下载/走缓存)
    #   示例:"Qwen/Qwen2.5-1.5B-Instruct"
    # - 本地模型目录(推荐先预下载到本地,更稳定/更可复现)
    #   示例:"./models/base"  或  "/data/models/Qwen2.5-1.5B-Instruct"
    #
    # 你可以在命令行里用环境变量覆盖:
    #   BASE_MODEL="Qwen/Qwen2.5-1.5B-Instruct" python scripts/train_lora.py
    #   BASE_MODEL="models/base" python scripts/train_lora.py
    model_id = os.environ.get("BASE_MODEL", "Qwen/Qwen2.5-1.5B-Instruct")

    # 2) 读 tokenizer
    # tokenizer 负责把文本变成 token id。
    tokenizer = AutoTokenizer.from_pretrained(model_id)
    # 一些模型没有 pad_token(尤其是类 LLaMA 系),训练/对齐 batch 时可能报错。
    # 简单做法:把 pad_token 设成 eos_token。
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token

    # 3) 读 base 模型(两条路线:LoRA / QLoRA)
    # LoRA:普通加载(显存够用时推荐)
    # device_map="auto":让 transformers 自动把模型放到可用 GPU 上。
    model = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto")

    # QLoRA:如果显存不够,改用 4-bit 加载(需 bitsandbytes)
    # 你只需要:把上面那行 LoRA 加载注释掉,然后启用下面这一行。
    # model = AutoModelForCausalLM.from_pretrained(model_id, load_in_4bit=True, device_map="auto")
    # model = AutoModelForCausalLM.from_pretrained(model_id, load_in_4bit=True, device_map="auto")

    # 4) 配置 LoRA(核心:你训练的就是这里的 A/B,不动 base 的 W)
    # target_modules:决定“哪些线性层”加 LoRA。入门阶段可以先只加 attention 的投影层。
    # r/alpha/dropout:先用默认即可;效果不够再调大 r。
    lora_config = LoraConfig(
        r=8,  # rank(秩/容量):越大越“能学”,但参数量与显存/算力也越高;入门常用 8/16
        lora_alpha=16,  # 缩放系数:控制 ΔW 的“强度”(常见取 16/32);通常与 r 搭配一起调
        lora_dropout=0.05,  # dropout:只作用在 LoRA 分支上,防过拟合;数据少时可稍微调大
        bias="none",  # 是否训练 bias:none 最常见;如果你不清楚就保持默认
        task_type="CAUSAL_LM",  # 任务类型:自回归语言模型(大多数开源 LLM 微调都是这个)
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],  # 注入 LoRA 的层:注意力投影层是最常见起点
    )
    # 把 LoRA 适配器“挂”到模型上:从此以后训练的参数主要就是 LoRA 的那部分。
    model = get_peft_model(model, lora_config)

    # 5) 读数据
    # 这里读的是 JSONL(每行一个 JSON)。示例格式:
    # {"messages": [{"role":"system","content":"..."},{"role":"user","content":"..."},{"role":"assistant","content":"..."}]}
    dataset = load_dataset(
        "json",
        data_files={"train": "data/train.jsonl", "eval": "data/eval.jsonl"},
    )

    # 6) 训练
    # SFTTrainer:把 messages 格式的数据组装成模型可训练的文本序列。
    # max_seq_length:越大越吃显存;如果 OOM,先把它降到 512/256。
    trainer = SFTTrainer(
        model=model,
        tokenizer=tokenizer,
        train_dataset=dataset["train"],
        eval_dataset=dataset["eval"],
        max_seq_length=1024,
    )

    trainer.train()

    # 7) 保存产物:LoRA adapter
    # 注意:这里保存的是 adapter(增量权重),不是完整 base。
    # 推理时要用:base + adapter 组合加载。
    os.makedirs("outputs/adapter", exist_ok=True)
    trainer.save_model("outputs/adapter")
    print("\n✅ saved adapter to: outputs/adapter")


if __name__ == "__main__":
    main()

Step 4:启动训练(你应该怎么敲命令)

text
# 方式A:直接运行(最简单)
BASE_MODEL="your-base-model" python scripts/train_lora.py

# 方式B:用 accelerate(更贴近工程,多卡/混合精度更容易扩展)
BASE_MODEL="your-base-model" accelerate launch scripts/train_lora.py
生活类比:accelerate 像“导演场记”。
你只写一份脚本(演员台词),它负责把人手/机位分配好(多卡、混合精度、分布式), 让你更接近真实工程环境。

训练完成后,你应该看到什么?产物在哪?

  • 控制台:最后会打印 saved adapter to: outputs/adapter
  • 目录:outputs/adapter/ 下会出现 adapter 权重与配置(通常包括 adapter_model / adapter_config 等文件)
  • 重要理解:这里保存的是“适配器”,不是完整 base 模型;推理时要 base + adapter 一起加载

adapter(LoRA 权重)到底是什么?为什么要这么保存?

你可以把 adapter 理解为:在不改动底座权重 W 的前提下,LoRA 学出来的那部分增量参数(也就是 A/B,间接形成 ΔW)。 所以 outputs/adapter 里只保存“增量”,体积通常远小于 base 模型。

生活类比:adapter 像手机的主题包/插件
你手机系统(base)不变,但装上一个主题包后(adapter),显示风格/行为就变了;想回滚只要卸载主题包即可。
最常见用法(推荐):base + adapter 动态加载
线上保持 base 模型不动,按业务/版本加载不同 adapter。 回滚/切换版本通常只需要替换 adapter 目录。
python
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained("models/base", device_map="auto")
tokenizer = AutoTokenizer.from_pretrained("models/base")

# 把 LoRA adapter 挂到 base 上(这一步不会改 base 文件)
model = PeftModel.from_pretrained(base, "outputs/adapter")
可选用法:合并 adapter 到 base(单模型部署)
你也可以把 ΔW 合并回 W(得到一个“合并后的模型”)。 这样部署更像单模型,但会失去“多 adapter 共存/快速回滚”的优势。
python
# ✅ 可执行方案:把 LoRA adapter 合并到 base,并导出一个“单模型目录”
# 保存为:scripts/merge_adapter.py

import os
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import PeftModel


def main():
    # 1) base 模型路径(建议用本地目录,例如 models/base)
    base_dir = os.environ.get("BASE_MODEL", "models/base")
    # 2) LoRA adapter 目录(训练产物)
    adapter_dir = os.environ.get("ADAPTER_DIR", "outputs/adapter")
    # 3) 合并后的输出目录
    out_dir = os.environ.get("MERGED_DIR", "outputs/merged_model")

    # 注意:merge 需要 base 是“正常精度”(fp16/bf16/fp32) 的权重。
    # 如果你当初是 QLoRA(4-bit)训练的:合并时仍然要用“非 4-bit”的 base 来加载并 merge。
    base = AutoModelForCausalLM.from_pretrained(
        base_dir,
        device_map="auto",
        torch_dtype=torch.float16,
    )
    tokenizer = AutoTokenizer.from_pretrained(base_dir)

    model = PeftModel.from_pretrained(base, adapter_dir)

    # 把 LoRA 增量合并回 base 权重,并卸载 LoRA 结构
    merged = model.merge_and_unload()

    os.makedirs(out_dir, exist_ok=True)
    merged.save_pretrained(out_dir)
    tokenizer.save_pretrained(out_dir)
    print(f"✅ merged model saved to: {out_dir}")


if __name__ == "__main__":
    main()
怎么运行:
python
# 在 ft-demo 目录下
BASE_MODEL="models/base" ADAPTER_DIR="outputs/adapter" MERGED_DIR="outputs/merged_model" \
python scripts/merge_adapter.py

# 之后推理就直接加载 merged_dir(不再需要 adapter)
python -c "from transformers import AutoModelForCausalLM, AutoTokenizer; m=AutoModelForCausalLM.from_pretrained('outputs/merged_model', device_map='auto'); t=AutoTokenizer.from_pretrained('outputs/merged_model'); print('ok')"
合并时的注意事项(避坑)
  • 显存更吃紧:合并时需要把 base 权重以 fp16/bf16 形式加载,显存压力通常比训练 adapter 更大
  • QLoRA 的误区:QLoRA 训练时 base 是 4-bit,但合并输出通常是 fp16/bf16 的“合并模型”(不是 4-bit 文件)
  • 要多版本就别合并:你如果要一个 base 挂多个业务版本,动态加载 adapter 更合适

Step 5:推理验证(scripts/infer.py)

把下面代码保存为 scripts/infer.py,运行后对比“加载 adapter 前/后”的输出差异。

python
import os
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import PeftModel


def generate(model, tokenizer, prompt: str):
    inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
    out = model.generate(**inputs, max_new_tokens=200)
    return tokenizer.decode(out[0], skip_special_tokens=True)


def main():
    model_id = os.environ.get("BASE_MODEL", "your-base-model")

    tokenizer = AutoTokenizer.from_pretrained(model_id)
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token

    base = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto")

    prompt = "你是售后助手,输出必须是 JSON。用户:我想退货"

    print("\n=== Base only ===")
    print(generate(base, tokenizer, prompt))

    model = PeftModel.from_pretrained(base, "outputs/adapter")
    print("\n=== Base + LoRA adapter ===")
    print(generate(model, tokenizer, prompt))


if __name__ == "__main__":
    main()
text
# 运行推理验证
BASE_MODEL="your-base-model" python scripts/infer.py

Step 6:部署上线(让其它服务能调用你的微调模型)

推理验证跑通后,下一步就是把“底座模型 + LoRA 适配器”变成一个线上可访问的推理服务。 下面给你一套按步骤可落地执行的方案:用 vLLM 起一个OpenAI 兼容接口(其它服务直接用 HTTP 调用)。

6.1 你最终要产出的两个东西

  • base 模型目录:models/base(或你线上机器的同等路径)
  • LoRA adapter 目录:outputs/adapter(训练输出)

线上部署有两条路: A)推理时加载 LoRA(最快上线、方便切换多个业务 adapter); B)把 LoRA 合并进模型权重(更像“一个完整模型目录”,迁移更直观)。

两条路线怎么选?(先按这个选,少走弯路)
  • 路线 A(合并后部署):只部署一个模型目录,最适合新手、最不容易出错;适合“一个业务一个模型”。
  • 路线 B(推理时加载 LoRA):同一个 base 挂多个 adapter,适合多租户/多行业;但对推理框架版本与参数更敏感。

6.2 路线 A:合并后部署(推荐新手)

如果你希望部署时只拿一个目录(不额外带 adapter),可以执行“合并并导出”。 合并后的目录可以直接给 vLLM/Transformers 加载。

python
# scripts/merge_lora.py
import os
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import PeftModel

BASE_MODEL = os.getenv("BASE_MODEL", "models/base")
ADAPTER = os.getenv("ADAPTER", "outputs/adapter")
OUT_DIR = os.getenv("OUT_DIR", "outputs/merged_model")

def main():
    tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
    model = AutoModelForCausalLM.from_pretrained(
        BASE_MODEL,
        torch_dtype=torch.float16,
        device_map="auto",
        trust_remote_code=True,
    )
    model = PeftModel.from_pretrained(model, ADAPTER)
    model = model.merge_and_unload()  # 把 ΔW 合并进 W

    os.makedirs(OUT_DIR, exist_ok=True)
    model.save_pretrained(OUT_DIR)
    tokenizer.save_pretrained(OUT_DIR)
    print(f"merged model saved to: {OUT_DIR}")

if __name__ == "__main__":
    main()
text
# 执行合并
BASE_MODEL="models/base" ADAPTER="outputs/adapter" OUT_DIR="outputs/merged_model" python scripts/merge_lora.py

什么是 vLLM?为什么用它?

vLLM 是一个专门为大模型推理加速设计的开源框架,核心特点是: PagedAttention(显存管理优化)+ OpenAI 兼容接口。 对外提供 /v1/chat/completions 等 OpenAI 风格接口,所以无论你部署什么模型,调用方代码几乎不用改。

  • 推理快:PagedAttention 让显存碎片更少,吞吐更高
  • 接口统一:OpenAI 兼容,SDK 通用
  • 生态好:支持主流模型、LoRA、量化、流式

不同模型的接口是否一样?在哪里查都有哪些接口?

答案:接口一样(OpenAI 兼容),但 model 字段要填你在启动时 --served-model-name 的值。 你可以用以下命令快速查看你自己的服务到底暴露了哪些接口:

text
# 1) 查服务元信息(返回 OpenAI 风格的 info)
curl http://127.0.0.1:8000/v1

# 2) 列出可用模型
curl http://127.0.0.1:8000/v1/models

# 3) 查 OpenAPI 文档(如果 vLLM 版本支持)
# 浏览器打开:http://127.0.0.1:8000/docs
curl http://127.0.0.1:8000/docs
常用 OpenAI 兼容接口一览(几乎全模型通用)
  • GET /v1/models – 列出可用模型
  • POST /v1/chat/completions – 对话(最常用)
  • POST /v1/completions – 补全(旧式)
  • POST /v1/embeddings – 嵌入(如果模型支持)
  • GET /v1 – 服务元信息

调用示例(完全兼容 OpenAI SDK)

text
# 对话接口(最常用)
curl http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "my-ft-model",
    "messages": [{"role":"user","content":"用一句话解释 LoRA"}],
    "temperature": 0.2
  }'

能用 LangChain 访问吗?如何访问?

答案:可以。LangChain 的 ChatOpenAI 类只要把 base_url 指向你的 vLLM 服务,就能像调用 OpenAI 一样调用。 关键是把 openai_api_key 设成“随便填”(因为 vLLM 默认不鉴权,除非你自己加了)。

python
# LangChain 调用示例(Python)
+from langchain_openai import ChatOpenAI

+# 把 base_url 指向你的 vLLM 服务,api_key 随便填(vLLM 默认不鉴权)
llm = ChatOpenAI(
+    model="my-ft-model",          # 对应 --served-model-name
+    base_url="http://127.0.0.1:8000/v1",
+    api_key="not-used",           # vLLM 默认不鉴权,随便填
+    temperature=0.2
+)

+# 直接用 LangChain 的链路调用
+from langchain_core.messages import HumanMessage
+response = llm.invoke([HumanMessage(content="用一句话解释 LoRA")])
+print(response.content)
LangChain 常见坑(学生最容易卡在这里)
    +
  • base_url 必须带 /v1:LangChain 会自动拼 /chat/completions,所以你只要写 http://host:port/v1
  • +
  • model 参数要匹配:LangChain 传的 model 必须等于你启动时 --served-model-name 的值。
  • +
  • 鉴权:如果 vLLM 加了鉴权(比如 Nginx 层加了 Authorization),LangChain 的 api_key 就要填对应 token。
  • +

+ 一句话总结:把 ChatOpenAIbase_url 指向你的 vLLM,就能无缝接入 LangChain 生态。

6.3 路线 A:在服务器启动推理服务(vLLM,OpenAI 兼容)

下面示例用 vLLM 启动一个 HTTP 服务(默认 8000 端口),对外提供 /v1/chat/completions 等接口。 其它服务只要会发 HTTP 请求就能调用。

text
# 1) 在服务器创建虚拟环境并安装 vLLM(建议 Python 3.10/3.11 + CUDA)
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip

# 2) 安装 vLLM(CUDA 机器上)
pip install "vllm>=0.4"
text
# 3) 启动服务(路线 A:merged_model)
vllm serve outputs/merged_model \
  --host 0.0.0.0 --port 8000 \
  --served-model-name my-ft-model
健康检查(服务器上执行)
text
# 列出模型
curl http://127.0.0.1:8000/v1/models

# 发一次对话请求(OpenAI ChatCompletions 兼容)
curl http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "my-ft-model",
    "messages": [{"role": "user", "content": "用一句话解释 LoRA"}],
    "temperature": 0.2
  }'

6.6 路线 B:不合并,推理时加载 LoRA(一个 base 挂多个 adapter)

这条路线的目标是:同一个 base 模型不动,按需加载不同的 adapter(例如不同客户/行业一套 LoRA)。 你需要确保 vLLM 版本支持 LoRA(不同版本参数名可能略有差异)。

text
# 路线 B:启动 base,并启用 LoRA
# 说明:--lora-modules 的写法是“逻辑名=adapter目录”,逻辑名用于 API 调用时的 model 字段。
vllm serve models/base \
  --host 0.0.0.0 --port 8000 \
  --served-model-name my-base \
  --enable-lora \
  --lora-modules my-ft=outputs/adapter

多个 adapter 怎么配?(一个 base 挂多个 LoRA)

核心原则只有一个:给每个 adapter 起一个唯一“逻辑名”,并把它映射到对应的 adapter 目录。 之后你在请求里通过 model 字段切换逻辑名,就能切换到不同的 adapter。

目录建议(多租户/多行业)
text
models/base/
adapters/
  ecommerce/   # 电商话术 LoRA
  finance/     # 金融风控 LoRA
  medical/     # 医疗问诊 LoRA
启动示例:注册多个 LoRA(以当前版本 vLLM 支持方式为准)
text
# ✅ 思路:my-base 作为 base;多个 adapter 用不同逻辑名
# 注意:不同 vLLM 版本对 --lora-modules 的“多值写法”可能不同
# - 有的版本支持重复传参
# - 有的版本支持逗号分隔
# 你务必以 `vllm serve --help` 输出为准。

vllm serve models/base \
  --host 0.0.0.0 --port 8000 \
  --served-model-name my-base \
  --enable-lora \
  --lora-modules ecommerce=adapters/ecommerce \
  --lora-modules finance=adapters/finance \
  --lora-modules medical=adapters/medical
路线 B 的调用方式(关键:model 字段)
text
# 调用 base
curl http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"my-base","messages":[{"role":"user","content":"用一句话解释 LoRA"}]}'

# 调用挂载了 LoRA 的版本(model=你在 --lora-modules 里写的逻辑名)
curl http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"my-ft","messages":[{"role":"user","content":"用一句话解释 LoRA"}],"temperature":0.2}'

多个 adapter 怎么调用?(切换逻辑名即可)

调用侧不需要理解 LoRA 的细节,你只要把 model 当成“版本号/客户号”即可: 同一个 URL,通过 model=ecommerce / model=finance 选择不同 adapter。

text
# 电商 adapter
curl http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"ecommerce","messages":[{"role":"user","content":"我想退货,怎么走流程?"}]}'

# 金融 adapter
curl http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"finance","messages":[{"role":"user","content":"这笔交易风险点有哪些?"}]}'

# 医疗 adapter
curl http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"medical","messages":[{"role":"user","content":"持续发烧三天应该注意什么?"}]}'

如果你的推理框架不支持“请求里切换 adapter”,怎么办?(备选方案)

现实里确实会遇到:某些版本/框架不支持在同一端口下用请求参数切换 LoRA。 这时最稳的工程解法是:一个 adapter 一个端口(或一个容器/进程),再用 Nginx/网关做路由。

text
# 启动 3 个实例(示意):每个实例固定一个 adapter
vllm serve models/base --port 8001 --served-model-name ecommerce --enable-lora --lora-modules ecommerce=adapters/ecommerce
vllm serve models/base --port 8002 --served-model-name finance   --enable-lora --lora-modules finance=adapters/finance
vllm serve models/base --port 8003 --served-model-name medical   --enable-lora --lora-modules medical=adapters/medical
css
# Nginx 路由(示意):按路径或子域名分流到不同端口
location /ecommerce/ { proxy_pass http://127.0.0.1:8001/; }
location /finance/   { proxy_pass http://127.0.0.1:8002/; }
location /medical/   { proxy_pass http://127.0.0.1:8003/; }

这种方式的缺点是实例数会变多;优点是最兼容、最容易排障、隔离性最好(一个 adapter 出问题不影响其它)。

路线 B 常见坑(学生最容易卡在这里)

  • vLLM 参数不一致:不同版本 LoRA 相关参数名可能不同;优先用 vllm --help 以当前版本为准。
  • adapter 与 base 不匹配:adapter 必须来源于同一个 base(同 tokenizer/同 hidden size/同层名)。
  • 多 adapter 内存占用:一个 base + N 个 LoRA 会占用额外显存/内存;先从 1 个 adapter 跑通再扩展。

6.4 让服务“真正上线可用”(守护进程 + 反向代理 + 鉴权)

你至少需要做三件事: (1)进程守护(systemd/pm2/supervisor), (2)Nginx 反代(统一域名与 HTTPS), (3)鉴权与限流(避免裸奔被刷爆)。

text
# systemd 示例:/etc/systemd/system/vllm.service
[Unit]
Description=vLLM Inference Server
After=network.target

[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/ft
Environment=PATH=/home/ubuntu/ft/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
ExecStart=/home/ubuntu/ft/.venv/bin/vllm serve /home/ubuntu/ft/outputs/merged_model --host 0.0.0.0 --port 8000 --served-model-name my-ft-model
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

# 启用与启动
sudo systemctl daemon-reload
sudo systemctl enable vllm
sudo systemctl start vllm
sudo systemctl status vllm
css
# Nginx 反代示例(HTTPS 建议用 certbot 配好证书)
# /etc/nginx/sites-enabled/ft-model.conf
server {
  listen 80;
  server_name ft-api.yourdomain.com;

  location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # 最低限度:加一个简单的 token(更严谨可用网关/鉴权服务)
    # if ($http_authorization != "Bearer YOUR_TOKEN") { return 401; }
  }
}

sudo nginx -t
sudo systemctl reload nginx

6.5 其它服务怎么调用(示例)

text
# 任何语言都行:只要能发 HTTP POST
curl https://ft-api.yourdomain.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "model": "my-ft-model",
    "messages": [
      {"role": "system", "content": "你是企业内部知识助手"},
      {"role": "user", "content": "把这段文本提取成 JSON:..."}
    ],
    "temperature": 0.2
  }'

上线 checklist(强烈建议按这个自检)

  • 稳定性:systemd 守护 + 日志可查(journalctl -u vllm -f
  • 安全:至少有鉴权 token;建议限制来源 IP / 加网关
  • 容量:设置并发/超时;高并发建议做队列或水平扩展
  • 回滚:保留上一版 merged_model / adapter,做到秒级回退

常见卡住点(你一旦遇到就从这里排查)

  • OOM(显存不足):减小 max_seq_length、减小 batch(SFTTrainer 默认也可能占用较大)、或改用 QLoRA 的 4-bit 加载
  • 模型下载慢/失败:先用命令行预下载到本地目录,再把 BASE_MODEL 指向本地路径
  • 输出不稳定:先保证训练数据里 JSON 结构一致,再考虑加 schema 校验与难例补齐

🧨 常见问题与排查清单(非常实用)

问题:上线后胡说八道更多了

  • 检查数据:训练集中是否鼓励“编造”
  • 检查任务边界:需要事实的部分是否应该交给 RAG
  • 检查输出约束:System Prompt/Schema 校验是否缺失

问题:格式时好时坏(JSON 不稳定)

  • 增加“格式强约束”样本:尤其是边界情况
  • 评测加入解析器:解析失败直接算错
  • 减少自由发挥:降低 temperature + 提示词收紧

问题:微调后“常识变差/数学变差”

  • 灾难性遗忘:数据过窄 + 训练过度
  • 对策:降低学习率/epoch,或混入少量通用数据
  • 工程实践:只用 LoRA 并控制适配强度

📚 本章小结

🎯 核心要点

  • 微调不是更新知识:事实更新优先用 RAG,微调用来固化能力/风格/行为
  • 先用 SFT 打基础:让模型更听话、更稳定输出
  • LoRA/QLoRA 更适合工程落地:成本低、易维护、多版本可共存
  • 评估要工程化:别只看 loss,要看任务指标、格式率、拒答率、成本
  • 微调必须闭环:上线日志 → 难例回收 → 再训练 → 再评估

📝 课程作业

作业1:做一个“结构化输出微调”方案

选择一个你熟悉的业务(电商/教育/医疗/金融任意),定义一个结构化输出任务(如:工单分类、字段抽取、风险标签)。写清:

  • 输出 JSON Schema(字段、类型、必填项)
  • 至少 30 条训练样例(手工即可)
  • 至少 20 条评测样例(与训练隔离)
  • 评估指标:JSON 解析率 + 字段正确率

作业2:写一份“Prompt/RAG/微调”选型说明

针对你上面的任务,说明为什么需要(或不需要)微调,并写出替代方案与风险:

  • 只用 Prompt 的可行性与风险
  • 加入 RAG 的收益与复杂度
  • 微调的收益、成本与维护方式(LoRA 多版本)