【案例】Qwen 微调实战 · 合并、GPTQ 量化与 vLLM 上线

训练收敛只是拿到一张配方修改单。从 checkpoint 到能扛住并发的线上服务,还隔着合并、量化、部署这三道工序。

30″30 秒看懂这一讲

把微调训练想成一位主厨在试菜:他试了几百次,终于把口味调对了,但他手里攥着的不是一本菜谱,而是一沓写满修改意见的便签——「盐减半」「先煸后煮」「起锅前淋香油」。便签本身没法用,厨房里其他人拿到它不知道从哪下手。

要把这道菜变成每天出几千份、还不能翻车的中央厨房产品,得走三道工序:先把便签誊写进正式菜谱(合并),再把「3.1416 克盐」这种精确到离谱的刻度改成半小勺(量化),最后把「一个厨师从头炒到尾」改成流水线(部署)。这一讲讲的就是这三道工序,一道都不能跳。

图① 30 秒看懂:从配方修改单到中央厨房流水线
图① 30 秒看懂:从配方修改单到中央厨房流水线
中央厨房里的角色对应的技术概念它到底是什么
原版菜谱基座模型(Qwen2-7B-Instruct预训练权重,几十亿参数,谁都能照着做但做不出你要的味道
一沓修改便签LoRA adapter(checkpoint-xxx训练只存了低秩增量,几十 MB,离开原版菜谱就没有意义
誊写成正式菜谱权重合并(llamafactory-cli export把增量算回主干权重,产出一份能独立加载的完整模型
把克数改成勺数GPTQ 4-bit 量化用更粗的刻度记录同一个数,体积和显存大幅下降,味道基本不变
试做几道招牌菜校味校准数据(calibration data)一小批真实样本,用来确认「换刻度之后味道有没有跑偏」
哪味料最敏感的敏感度表Hessian 逆矩阵告诉算法哪些权重动一点点就毁掉输出,得留更精细的刻度
中央厨房流水线vLLM推理框架,负责让同一套灶台同时出很多份餐
备料 / 出餐prefill / decode前者一次性处理整段 prompt,后者一个 token 一个 token 往外吐
熬好的高汤KV cache已经算过的 K、V 存起来,下一个 token 不必从头再算
16 格标准餐盒 + 取餐号码牌PagedAttention 的 block 与 block table显存按固定小块按需发放,用多少取多少,不再整桌预留
同一条流水线挂多本菜谱多 LoRA(--enable-lora一个服务同时挂多个 adapter,调用时点名用哪一本
⛔ 整讲最硬的一条铁律 先合并,后量化,顺序不能颠倒,也不能混在一起做。 合并这一步只认原版 FP16 菜谱:配置里 finetuning_type 一律写 lora(哪怕你训的是 QLoRA),不要指定量化模型,也不要写 quantization_bit。量化是合并完成之后单独跑的一道工序,用的是 GPTQ,跟训练时那个 4-bit 完全是两件事。
这一讲的三道工序,对应三条命令 llamafactory-cli export merge_lora.yamlpython run_gptq.pypython -m vllm.entrypoints.openai.api_server --model ...。后面每一节都是在拆这三条命令背后发生了什么。

01概念:训练收敛之后,路才走了一半

三道工序各解决什么问题,以及它们和上一讲的 QLoRA 是什么关系

1.1 训练完之后还缺三步

训练脚本跑完、loss 曲线平了,目录里躺着一个 checkpoint-1250,这时候很容易产生一个错觉:模型已经好了,剩下的只是「部署一下」。实际上从这里到线上服务,中间隔着三个性质完全不同的问题。

工序解决的问题输入产出
① 合并
merge
产物不完整,没法独立加载基座权重 + adapter一份完整的 FP16 模型目录,可直接被任何推理框架加载
② 量化
GPTQ
体积大、显存吃紧、单卡装不下合并后的完整模型 + 校准数据4-bit 权重(safetensors),体积约为 FP16 的四分之一
③ 部署
vLLM
能跑但太慢、并发一上来就崩量化或未量化的模型目录一个 OpenAI 风格的 HTTP 服务,支持高并发与多 adapter

三件事互相独立:全量微调不需要第 ① 步;显存宽裕可以跳过第 ② 步;只做离线批量推理可以不起服务、直接用 vLLM 的离线接口。但只要走的是 LoRA / QLoRA 路线并且要对外提供服务,三步就一步都少不了。

1.2 为什么必须合并权重

回到中央厨房的比喻:便签上写的是「减半」「多加两勺」,是相对量,不是绝对量。 便签离开原版菜谱就毫无意义——「盐减半」,减的是谁的一半?

LoRA 的产物就是这样一沓相对量。训练时冻结基座、只更新两个低秩矩阵,脚本保存时也只把这两个矩阵存下来,几十 MB 而已。所以:

  • 要推理,必须同时提供基座和 adapter,让框架在加载时现场把增量叠加回去;
  • 或者提前合并一次,把增量永久算进主干权重,产出一份完整模型。之后无论谁来加载,都不需要知道 LoRA 的存在。

生产上选后者:少一个依赖、少一次加载期计算,而且量化、转格式这些后续工序基本都只接受完整模型。采用 LoRA 或 QLoRA 训练时,脚本只保存对应的 LoRA 权重,需要合并权重才能进行推理;全量参数训练无需执行此步骤。

合并不是「拼接」,是算术 合并不会让模型变大——它是把低秩增量加进原有权重矩阵,参数量与基座完全一致。所以合并后的模型体积等于基座体积,不等于「基座 + adapter」。

1.3 PTQ 与 QAT:两种量化路线的分野

量化方法主要有两大类,上一讲的 QLoRA 与这一讲的 GPTQ 正好各占一边:

对比项PTQ(Post-Training Quantization)QAT(Quantization Aware Training)
中文训练后量化量化感知训练
代表GPTQQLoRA
什么时候量化训练全部结束之后,单独跑一遍训练 / 微调过程中同时进行
要不要重训不需要,只要一小批校准数据本身就是训练流程的一部分
目的压缩推理阶段的体积与显存压缩训练阶段的显存,让大模型塞进小卡
量化谁只量化权重量化基座权重,adapter 仍是高精度
这一讲的位置合并之后的第 ② 道工序属于上一讲的训练环节

用厨房的话说:QAT 是主厨在试菜阶段就只用「勺」这种粗刻度去调味,调出来的配方天然适配粗刻度;PTQ 是菜谱已经定稿,再回头把克数翻译成勺数,翻译时要拿几道招牌菜试吃,确认味道没跑。

⛔ 别把两个 4-bit 混为一谈 QLoRA 训练时的 quantization_bit: 4 与 GPTQ 的 bits=4 都是 4-bit,但它们作用在完全不同的阶段,产物也不通用。QLoRA 训完拿到的仍是一个高精度 adapter,必须先按 1.2 节合并成 FP16 完整模型,再交给 GPTQ 重新量化。

1.4 为什么要换掉训练框架自带的推理服务

LLaMA-Factory 自带的 api server 能起服务,训练完顺手验证一下很方便。问题是它为「能跑」设计,不是为「扛住并发」设计。同一个问题、同一块卡,实测下来差距是这样的:

部署方式输出速度说明
LLaMA-Factory api server5.05 字/秒训练框架自带的推理服务,逐 token 朴素生成
LLaMA-Factory api server7.26 字/秒另一次测量记录到的数值,量级与上一行一致
vLLM api server111.92 字/秒输出 1794 字耗时 16.03 秒,同一块卡、同一个 3B 模型
两个数字都如实摆在这里 训练框架侧的实测有 5.057.26 两个记录,来自不同次运行,测试口径(问题长度、是否首次加载)并不完全一致,所以两个都保留、不做平均。要记住的是量级差异:个位数字/秒 对 百位数字/秒,大约二十倍。这个差距不是靠调采样参数能补回来的,它来自框架层的显存管理与调度策略。

vLLM 由加州大学伯克利分校团队开发,通过显存管理和调度策略上的改造,解决了传统推理框架显存利用率低、吞吐量不足、并发处理效率低的问题。它的对外特性清单里,对这个项目最要紧的是三条:

1与 HuggingFace 模型无缝集成

合并后的目录、GPTQ 量化产物都能直接 --model 指过去,不需要转格式。

2OpenAI 风格接口

起服务之后用 openai 客户端换个 base_url 就能调,上层应用代码一个字不用改。

3多 LoRA 支持

一个服务同时挂多个 adapter,请求时用 model 参数点名,省服务器也方便对比。

其余特性还有并行采样与波束搜索等多种解码算法、张量并行与流水线并行、流式输出、前缀缓存,以及对 NVIDIA GPU、AMD、Intel CPU、TPU 等多种硬件的支持。环境要求很朴素:Linux、Python 3.9–3.12、一块 GPU(V100 / T4 / RTX20xx / A100 / L4 / H100 均可),安装就是 pip install vllm

02原理:三道工序各自在做什么

合并的算术、GPTQ 的逐层逼近、KV cache 与 PagedAttention 的显存账

图② 训练之后的三道工序:合并 → 量化 → 部署,每步的输入与产物
图② 训练之后的三道工序:合并 → 量化 → 部署,每步的输入与产物

2.1 合并在做什么运算

LoRA 训练时,被冻结的原权重矩阵记作 W,训练出来的两个小矩阵记作 AB。推理时模型实际用的是 W + BA 这个组合——基座出大头,增量出微调带来的那部分差异。

不合并时,这个加法每次加载模型都要现场做一遍,而且框架必须同时知道基座在哪、adapter 在哪。合并做的事情就一句话:把 W + BA 算出来,把结果写回权重文件,从此不再有 A 和 B。

所以合并有三个直接后果,对应后面易错点里最常见的几个坑:

后果意味着什么
参数量不变合并后的模型大小等于基座大小。7B 模型 FP16 约 14 GB,合并前后都是这个量级
基座必须完全对得上增量是相对某一份具体权重训出来的。换一个基座版本再合并,等于把「盐减半」贴到另一本菜谱上,结果不可预期
合并后 adapter 失效合并产物已经含有增量,再叠一次 adapter 就是加了两遍,输出会明显走样
⛔ 合并时不要碰量化 如果拿一个已经 4-bit 的模型去做基座合并,或者在合并配置里写了 quantization_bit,等于在「刻度已经被压粗」的菜谱上誊写便签——增量精度会被量化误差吃掉一大块。正确顺序是先用 FP16 基座合并,再单独量化

2.2 GPTQ:把菜谱的克数改成勺数,还要保证味道不跑

GPTQ(Gradient-based Post-training Quantization)是一种针对大规模预训练模型的高效后量化算法。它的目标很明确:在不重新训练的前提下,把权重压到 4-bit 甚至更低,同时尽可能保住性能。它采用非对称量化,并且逐层处理——每一层独立量化完,再继续下一层。

难点在于:把「3.1416 克」写成「半小勺」,误差是必然的。GPTQ 的聪明之处在于它不平均对待每一味料

Hessian 逆矩阵:哪一味料动不得

在逐层量化时,算法首先把这一层的权重转换为 Hessian 矩阵的逆矩阵。Hessian 告诉我们模型输出对每个权重变化的敏感程度,也就是每个权重的重要性(影响程度)。

这句话的方向别记反 与 Hessian 矩阵中较小值相关的权重更为重要,因为这些权重的微小变化可能对模型性能产生重大影响。在 Hessian 矩阵的逆矩阵中,数值越低,权重越「重要」。翻译成厨房语言:盐的敏感度远高于水,同样误差半克,盐能毁掉一锅汤,水几乎无感——所以盐要留更细的刻度。

拿到敏感度信息后,GPTQ 对权重矩阵逐列操作:量化这一列的权重 → 计算由此引入的误差 → 把这份误差补偿到该层其余还没量化的权重上。处理完一列再处理下一列,一层处理完再更新所有其余权重。这就是它比「直接四舍五入」精度高得多的原因——误差不是被忽略,而是被后面的权重接住了

图③ GPTQ 逐层后量化的四个步骤
图③ GPTQ 逐层后量化的四个步骤

算法的四个步骤

GPTQ 的核心思想是通过最小化量化引入的输出误差,实现高精度低比特量化。具体到每一层的权重矩阵,它利用一小部分校准数据,最小化量化前后模型输出的差异。基本步骤如下:

1收集校准数据

从训练数据或相关数据集中抽取一小部分样本作为校准数据。对应厨房里的「先试做几道招牌菜」——不是全量重训,只是取样试吃。

2逐层处理

对模型的每一层独立量化,避免全局优化的复杂度。一层一道工序,做完封存,不回头。

3最小化输出误差

对于每一层,寻找最佳的量化权重,使得在校准数据上的输出误差最小。日志里那个 avg loss 就是这一步的度量。

4更新权重

把量化后的权重替换原始权重,同时把量化误差补偿进同层剩余权重。

「只量化权重」这四个字很关键 GPTQ 只对权重做量化,激活值(推理时流过网络的中间张量)仍然是高精度。所以它省的是显存里放权重的那部分和加载体积,而不是把整个计算都变成 4-bit 整数运算。这也解释了为什么 4-bit 量化后显存占用不会精确地变成四分之一——KV cache 那一块没被压。

2.3 分组量化:group_size 到底在分什么组

如果整个权重矩阵共用一组量化参数(缩放系数与零点),那么一个极端值就会把整个矩阵的刻度拉粗。分组量化把多个权重组合在一起进行量化:每 group_size 个权重共享一组参数,组内刻度自适应。

group_size效果代价
越小(如 32)刻度更贴合局部分布,精度更好要存的量化参数更多,模型体积变大
128(常用默认)精度与体积的平衡点
越大 / 不分组额外参数最少,体积最小一个离群权重就能把整组刻度带偏

用勺子打比方:group_size=128 相当于每 128 味配料共用一套量勺。分得太细,厨房要摆满各种规格的量勺;分得太粗,一套量勺量不准所有东西。

2.4 推理的两个阶段与 KV cache

LLM 推理过程通常分为两个阶段,vLLM 的全部优化都围绕这两个阶段展开:

prefill 预填充把整段 prompt 一次性喂给模型做 forward 计算
decode 生成根据 prefill 的结果,一个 token 一个 token 地生成 response

对应厨房:prefill 是备料——把整张订单的食材一次性处理好;decode 是出餐——一份一份端出去,前一份端出去了才知道下一份怎么配。

为什么只缓存 K 和 V,不缓存 Q

大模型计算复杂度最高的就是自注意力 QKV 的计算。如果每输出一个字符都要从头计算,成本太高,所以把中间阶段的 K 和 V 值存入缓存,这就是 KV cache。在 prefill 阶段,prompt 过一遍模型后得到的 K、V 就保存进 cache。

Q 为什么不缓存 在 decode 阶段,每一步只有当前这一个新 token需要一个 Query,去和前面所有 token 的 K、V 做注意力。历史 token 的 Q 再也不会被用到,缓存它纯属浪费;而历史 token 的 K、V 每一步都要被重新用到,缓存它们才是省算力的关键。
厨房版本:高汤(K、V)熬一次能反复取用,点单的那句话(Q)用完就作废。

传统 KV cache 的三个问题

传统做法是直接分配一段连续的物理显存给 KV cache。随着 prompt 变长、输出序列变长,这段显存不断增长,于是问题接踵而至:

问题后果
输出序列长度无法预先知道没法为 KV cache 量身定制存储空间,只能拍脑袋预留
预留多了大量显存被占着不用,能并发的请求数被硬生生压低
预留少了生成到一半空间不够,请求失败或被迫中断

还有一个批处理上的两难:业务数据是流式到达的,推理动作却是批量的。凑不满一批就开始推理,要拿 0 去填充、浪费算力;等着凑满,又不知道要等到什么时候、浪费时间。如何优化 KV cache、节省显存、提高推理吞吐量,就成了 LLM 推理框架要解决的重点问题。

2.5 PagedAttention:把显存改成「按块发放」

vLLM 通过一种名为 PagedAttention 的技术,动态地为请求分配 KV cache 显存,提升显存利用率。它的设计灵感来自操作系统中虚拟内存的分页管理技术

先回忆一下分页管理的三条基本原理:

  • 把物理内存划分为固定大小的块,每一块叫一页(page);从物理内存模拟出来的虚拟内存也按相同方式划分。
  • 对于一个进程,不需要静态加载它的全部代码和数据。用到哪部分就动态加载哪部分到虚拟内存,再由虚拟内存做物理内存的映射。
  • 一个进程在物理内存上的存储可以不连续,但在它自己的虚拟内存里是连续的。通过模拟连续内存,既解决了物理内存的碎片问题,也方便了进程的开发和运行。

把两个进程看成两本书,代码分布在书的不同页上:想读哪一页就加载哪一页,而不是把两本书整个搬进来;不想读某些页时,也能按页码把它清空。

PagedAttention 把这套机制原样搬到了 KV cache 上,四个概念一一对应:

PagedAttention操作系统里的对应物说明
请求(request)进程一次会话就是一个「进程」
逻辑内存
(logical KV blocks)
虚拟内存每个 block 类比一个 page,大小固定,vLLM 中默认为 16,即可装 16 个 token 的 K/V 值
块表(block table)虚拟内存到物理内存的映射表记录每个逻辑 block 实际落在哪个物理 block 上
物理内存
(physical KV blocks)
物理内存真正的 GPU 显存,物理块之间不要求连续
图④ PagedAttention:逻辑 KV blocks、块表与物理 KV blocks
图④ PagedAttention:逻辑 KV blocks、块表与物理 KV blocks

于是 2.4 节那三个问题一起消失了:只有在当前块写满时才申请下一块,每次只增加一块,最坏的结果不过是最后一个块没写满。这种方式下显存的利用率能达到 96%

厨房版本 从前是「每来一桌客人就预留一整张长台面,万一你们点得多呢」;现在是统一规格的 16 格餐盒,装满一盒再拿一盒,桌号与餐盒的对应关系记在取餐号码牌(block table)上。餐盒不必挨着放,客人也感觉不到——他只看到自己的餐是连着上的。

批量任务调度:不必等同一批

显存利用率提高之后,能并行的推理任务数量随之增加,调度策略也跟着变了:

1不要求同阶段

vLLM 不再要求所有并行任务处于同一阶段。别的任务正在 decode 时,只要资源充足,新任务可以随时开始 prefill,减少了任务批次之间的等待。

2超量则抢占

如果超出并行量,最后的请求会被抢占(preemption),退出所有占用资源,等待运行中的任务完成后再继续。

厨房版本:流水线上不必等「全部菜都备完料」才统一开火,哪个灶台空出来就先做哪单;灶台全占满时,最后来的单子先退回等位区,而不是把所有人的菜都拖慢。

抢占不等于失败 被抢占的请求是让出资源、排队等待,不是被丢弃。但对调用方来说,表现为这条请求的首字延迟明显变长——压测时看到长尾延迟突然抬头,多半就是并发超出了显存能支撑的并行量。

03最小代码:三条命令走完全程

先把最短的一条路跑通,再回头看每个参数为什么这么填

三道工序剥掉所有可选项之后,只剩三次调用。把它们串起来,就是从 checkpoint 到线上服务的最短路径:

① 合并llamafactory-cli export
② 量化python run_gptq.py
③ 起服务vllm ... api_server

第一步:合并,一个 YAML 一条命令

合并没有 Python 代码,全部配置写在 YAML 里,命令只负责把它读进去:

merge_lora.template.yaml —— 合并配置,改三处 TODO 即可骨架模板
### LoRA / QLoRA 权重合并模板
### 用法:llamafactory-cli export merge_lora.template.yaml
### 合并时不要使用量化模型,也不要写 quantization_bit

### model
# TODO 1: 基座模型路径或 HuggingFace 模型 ID,必须与训练时用的基座完全一致
model_name_or_path: TODO-PATH-TO-BASE-MODEL
# TODO 2: 训练产出的 adapter 目录(checkpoint-xxx 那一层)
adapter_name_or_path: TODO-PATH-TO-LORA-CHECKPOINT
# 合并 Qwen2 系列时必须为 qwen
template: qwen
# 无论训练时用的是 LoRA 还是 QLoRA,这里一律填 lora
finetuning_type: lora

### export
# TODO 3: 合并后完整权重的落盘目录
export_dir: models/qwen2-7b-sft-lora-merged
# 单个权重分片的最大体积,单位 GB
export_size: 2
# 合并在 CPU 上做,显存不够也能完成;有充裕显存可改 cuda
export_device: cpu
# 是否导出为旧版 .bin 格式,false 表示使用 safetensors
export_legacy_format: false

然后执行:

llamafactory-cli export merge_lora.template.yaml

跑完 export_dir 指向的目录里就是一份完整模型:权重分片、config.json、tokenizer 文件一应俱全,可以像任何 HuggingFace 模型一样被加载。

第二步:量化,一条命令三个路径

量化脚本要三样东西:待量化的模型、一份校准数据、一个输出目录。

python run_gptq.py \
  --model_name_or_path models/qwen2-7b-sft-lora-merged \
  --data_path data/calibration.json \
  --out_path models/qwen2-7b-sft-gptq-int4

校准数据是普通的对话 JSON,每条形如 {"conversations": [{"from": "user", "value": "..."}, {"from": "assistant", "value": "..."}]}几百条真实业务样本就够,不需要训练集那个量级——它只用来「试吃」,不用来学习。

依赖装一次即可:

pip install auto-gptq optimum -i https://mirrors.aliyun.com/pypi/simple/

第三步:起服务,一行命令

vLLM 提供了一个 OpenAI 风格的 HTTP API 服务器,启动之后就可以让客户端通过 REST API 调用模型做文本生成:

python -m vllm.entrypoints.openai.api_server \
  --model models/qwen2-7b-sft-gptq-int4 \
  --host 0.0.0.0 \
  --port 8000 \
  --trust_remote_code True
参数作用
--model模型目录。合并后的 FP16 目录、GPTQ 量化产物都可以,vLLM 会自己识别量化格式
--host 0.0.0.0监听所有网卡。只写 127.0.0.1 的话容器外、局域网内都访问不到
--port 8000服务端口。云平台上还要在控制台做一次端口映射,才能拿到对外 URL
--trust_remote_code TrueQwen 这类带自定义建模代码的模型需要它,否则加载直接报错

日志刷到服务启动完毕的提示,就可以用 OpenAI 客户端去调了:

vllm_client.py —— 用 openai 客户端访问 api server,顺手量速度
"""用 OpenAI 客户端访问 vLLM 起的 api server,并顺手量一下输出速度。

服务端地址与凭据全部来自环境变量,脚本里不写死任何 key:
    export VLLM_BASE_URL="http://127.0.0.1:8000/v1"
    export VLLM_API_KEY="本地服务随便填一个占位串"
    export VLLM_MODEL="add1"      # 指定走哪个 LoRA;留空则走基座模型

依赖:pip install openai
运行:python vllm_client.py
"""

import os
import time

from openai import OpenAI


def build_client() -> OpenAI:
    base_url = os.environ.get("VLLM_BASE_URL", "http://127.0.0.1:8000/v1")
    # vLLM 本地服务不校验 key,但 openai SDK 要求这个字段非空,
    # 所以仍然从环境变量取,绝不在源码里写死真实凭据
    api_key = os.environ.get("VLLM_API_KEY")
    if not api_key:
        raise RuntimeError("未找到 VLLM_API_KEY,请先在环境变量里配置")
    return OpenAI(api_key=api_key, base_url=base_url)


def call_server(client: OpenAI, prompt: str) -> str:
    # model 传 LoRA 的挂载名就走那个 LoRA,传空字符串则走基座模型
    model = os.environ.get("VLLM_MODEL", "")
    result = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        max_tokens=2048,
    )
    return result.choices[0].message.content


def main() -> None:
    client = build_client()
    prompt = "用一段话说明 PagedAttention 解决了什么问题。"

    start = time.time()
    answer = call_server(client, prompt)
    cost = time.time() - start

    print(answer)
    print("-" * 60)
    print(f"输出长度:{len(answer)} 字")
    print(f"耗时:{cost:.2f} 秒")
    print(f"输出速度:{len(answer) / cost:.2f} 字/秒")


if __name__ == "__main__":
    main()
凭据一律走环境变量 本地起的 vLLM 服务不校验 key,但 openai SDK 要求 api_key 字段非空。不要因此就在源码里写一个字面量顶上——这个习惯一旦养成,换到需要真实凭据的服务时就会原样把 key 写进去。统一用 os.environ.get(...) 读,取不到就报错退出。
不需要 HTTP 服务时,用离线接口更省事 跑评测、批量刷数据这类场景没必要起服务,直接用 vLLM 的 LLM 类一次投喂整批 prompt,内部照样享受 PagedAttention 与连续批处理的好处。
vllm_offline_infer.py —— 离线批量推理最小版
"""vLLM 离线批量推理:一次把整批 prompt 喂进去,拿回全部结果。

离线模式不起 HTTP 服务,适合跑评测、批量刷数据。
依赖:pip install vllm
运行:python vllm_offline_infer.py --model <模型目录>
"""

import argparse

from vllm import LLM, SamplingParams


def build_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser("vLLM offline batch inference")
    parser.add_argument("--model", type=str, required=True,
                        help="模型目录,合并后的全量权重或 GPTQ 量化产物都行")
    parser.add_argument("--temperature", type=float, default=0.8,
                        help="采样温度,越大越发散")
    parser.add_argument("--top_p", type=float, default=0.95,
                        help="核采样阈值")
    parser.add_argument("--max_tokens", type=int, default=100,
                        help="单条输出的最大 token 数")
    return parser.parse_args()


def main() -> None:
    args = build_args()

    # 一次投喂多条,vLLM 内部会自己做连续批处理,不必等凑满一批
    prompts = [
        "总结下面这段文本的摘要:随着科技的飞速发展,我们的生活方式也在悄然改变。"
        "智能手机、人工智能、物联网等科技产品的出现,为我们的日常生活带来了更多便利和舒适。",
        "用三句话说明什么是 KV cache。",
    ]

    sampling_params = SamplingParams(
        temperature=args.temperature,
        top_p=args.top_p,
        max_tokens=args.max_tokens,
    )

    # trust_remote_code=True:Qwen 等带自定义建模代码的模型需要它
    llm = LLM(model=args.model, trust_remote_code=True)

    outputs = llm.generate(prompts, sampling_params)

    for output in outputs:
        print("=" * 60)
        print("Prompt:", output.prompt)
        print("Output:", output.outputs[0].text)


if __name__ == "__main__":
    main()

04完整案例:把 Qwen 的 checkpoint 送上线

三道工序逐段拆开,每一步的配置、代码、日志和产物长什么样

前面讲的是通用方法:任何一个走 LoRA 路线的模型,都要经过合并、量化、部署。这一节落到具体项目上——把上一讲训练出来的 Qwen adapter,一路送到能对外提供服务的状态。

4.1 第一道工序:把便签誊写进菜谱

配置文件全貌如下,逐行都有注释;下面再逐个参数讲它的取值逻辑。

merge_lora.template.yaml —— LoRA / QLoRA 权重合并配置骨架模板
### LoRA / QLoRA 权重合并模板
### 用法:llamafactory-cli export merge_lora.template.yaml
### 合并时不要使用量化模型,也不要写 quantization_bit

### model
# TODO 1: 基座模型路径或 HuggingFace 模型 ID,必须与训练时用的基座完全一致
model_name_or_path: TODO-PATH-TO-BASE-MODEL
# TODO 2: 训练产出的 adapter 目录(checkpoint-xxx 那一层)
adapter_name_or_path: TODO-PATH-TO-LORA-CHECKPOINT
# 合并 Qwen2 系列时必须为 qwen
template: qwen
# 无论训练时用的是 LoRA 还是 QLoRA,这里一律填 lora
finetuning_type: lora

### export
# TODO 3: 合并后完整权重的落盘目录
export_dir: models/qwen2-7b-sft-lora-merged
# 单个权重分片的最大体积,单位 GB
export_size: 2
# 合并在 CPU 上做,显存不够也能完成;有充裕显存可改 cuda
export_device: cpu
# 是否导出为旧版 .bin 格式,false 表示使用 safetensors
export_legacy_format: false
参数说明怎么填
model_name_or_path预训练模型的名称或路径必须与训练时用的基座完全一致,包括版本。填错不会报错,只会输出走样
adapter_name_or_pathadapter 路径指到 checkpoint-xxx 那一层目录,里面有 adapter_model.safetensors
template模型模板合并 Qwen2 模型权重,务必将 template 设为 qwen
finetuning_type微调类型无论 LoRA 还是 QLoRA 训练,合并权重时 finetuning_type 均为 lora
export_dir导出路径合并产物落盘目录,建议名字里带上基座名与训练方式,便于追溯
export_size最大导出模型文件大小单位 GB,控制分片粒度。填 2 就是每个分片不超过 2 GB
export_device导出设备cpu 慢但稳,显存不够也能完成;显存充裕可改 cuda 提速
export_legacy_format是否使用旧格式导出false 表示用 safetensors;除非下游工具只认 .bin,否则保持 false

执行合并:

llamafactory-cli export merge_lora.template.yaml
⛔ 这一步的两条硬约束,写进配置里就别改合并 Qwen2 模型权重,务必将 template 设为 qwen
无论 LoRA 还是 QLoRA 训练,合并权重时 finetuning_type 均为 lora
另外,合并时不要使用量化模型,也不要写 quantization_bit——这一条在配置文件顶部就用注释标出来了。
怎么确认合并成功export_dir 目录:应该有若干 model-0000x-of-0000y.safetensors 分片、一个 model.safetensors.index.jsonconfig.json,以及 tokenizer 相关文件。总体积应当与基座相当(7B 的 FP16 约 14 GB)。如果只有几十 MB,说明导出的还是 adapter,配置没生效。

4.2 第二道工序:GPTQ 4-bit 量化

量化脚本实现了对 Qwen 系列大语言模型的 GPTQ 量化(4-bit)全流程,主要包含三个关键阶段:数据预处理 → 模型量化 → 结果保存

run_gptq.py —— GPTQ 4-bit 量化全流程,路径全部走命令行参数
"""用 AutoGPTQ 把合并后的 Qwen 模型量化到 4-bit。

三个阶段:把对话数据拼成 ChatML 校准样本 -> 逐层量化 -> 存成 safetensors。
所有路径都走命令行参数,脚本里不写死任何目录。

依赖:pip install auto-gptq optimum transformers torch
运行:python run_gptq.py --model_name_or_path <合并后的模型目录> \
                        --data_path <校准数据.json> \
                        --out_path <量化产物目录>
"""

import argparse
import json
import logging
from typing import Any, Dict, List

import torch
import transformers
from transformers import AutoTokenizer
from transformers.trainer_pt_utils import LabelSmoother
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig

# 这个常量标记「不参与 loss 计算」的位置,值为 -100
IGNORE_TOKEN_ID = LabelSmoother.ignore_index


def preprocess(
    sources: List[Dict[str, Any]],
    tokenizer: transformers.PreTrainedTokenizer,
    max_len: int,
    system_message: str = "You are a helpful assistant.",
) -> List[Dict[str, torch.Tensor]]:
    """把原始对话拼成 Qwen 的 ChatML 格式,并切成 input_ids / attention_mask。

    拼出来的文本形如:
        <|im_start|>system\n你是一个助手<|im_end|>\n
        <|im_start|>user\n什么是 AI<|im_end|>\n
        <|im_start|>assistant\nAI 是……<|im_end|>\n
    """
    # 角色前缀:ChatML 用 <|im_start|>角色 开头,<|im_end|> 收尾
    roles = {"user": "<|im_start|>user", "assistant": "<|im_start|>assistant"}

    im_start = tokenizer.im_start_id          # <|im_start|> 的 token id
    im_end = tokenizer.im_end_id              # <|im_end|> 的 token id
    nl_tokens = tokenizer("\n").input_ids     # 换行符的 token id
    _system = tokenizer("system").input_ids + nl_tokens

    data: List[Dict[str, torch.Tensor]] = []

    for source in sources:
        source = source["conversations"]
        # 首句若不是用户发言就丢掉,保证对话从 user 开始
        if roles[source[0]["from"]] != roles["user"]:
            source = source[1:]

        input_id: List[int] = []
        target: List[int] = []

        # 先铺系统消息:input 里保留全文,target 里整段屏蔽掉
        system = [im_start] + _system + tokenizer(system_message).input_ids \
            + [im_end] + nl_tokens
        input_id += system
        target += [im_start] + [IGNORE_TOKEN_ID] * (len(system) - 3) \
            + [im_end] + nl_tokens
        assert len(input_id) == len(target)

        for sentence in source:
            role = roles[sentence["from"]]
            _input_id = tokenizer(role).input_ids + nl_tokens \
                + tokenizer(sentence["value"]).input_ids + [im_end] + nl_tokens
            input_id += _input_id

            if role == "<|im_start|>user":
                # 用户说的话不参与 loss,整段填 IGNORE_TOKEN_ID
                _target = [im_start] + [IGNORE_TOKEN_ID] * (len(_input_id) - 3) \
                    + [im_end] + nl_tokens
            elif role == "<|im_start|>assistant":
                # 助手回答要学,只屏蔽掉角色前缀那几个 token
                prefix_len = len(tokenizer(role).input_ids)
                _target = [im_start] + [IGNORE_TOKEN_ID] * prefix_len \
                    + _input_id[prefix_len + 1:-2] + [im_end] + nl_tokens
            else:
                raise NotImplementedError(f"未知角色:{role}")
            target += _target

        assert len(input_id) == len(target)

        # 超长直接截断到 max_len
        ids = torch.tensor(input_id[:max_len], dtype=torch.int)
        # 非 padding 位置为 1,让注意力跳过填充位
        attention_mask = ids.ne(tokenizer.pad_token_id)
        data.append(dict(input_ids=ids, attention_mask=attention_mask))

    return data


def build_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser("Model Quantization using AutoGPTQ")
    parser.add_argument("--model_name_or_path", type=str, required=True,
                        help="待量化的模型目录(LoRA 请先合并权重)")
    parser.add_argument("--data_path", type=str, required=True,
                        help="校准数据 JSON,几百条真实对话即可")
    parser.add_argument("--out_path", type=str, required=True,
                        help="量化产物输出目录")
    parser.add_argument("--max_len", type=int, default=8192,
                        help="单条校准样本的最大 token 数")
    parser.add_argument("--bits", type=int, default=4,
                        help="量化位宽,4 表示 int4")
    parser.add_argument("--group_size", type=int, default=128,
                        help="分组量化的组大小,每 128 个权重共享一组量化参数")
    return parser.parse_args()


def main() -> None:
    args = build_args()

    logging.basicConfig(
        format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
        level=logging.INFO,
        datefmt="%Y-%m-%d %H:%M:%S",
    )

    quantize_config = BaseQuantizeConfig(
        bits=args.bits,
        group_size=args.group_size,   # 分组量化:组内共享缩放系数,组越小精度越好、体积越大
        damp_percent=0.01,            # 阻尼系数,调大可抑制量化带来的数值抖动
        desc_act=False,               # False 明显提速,困惑度会略微变差
        static_groups=False,          # False 表示分组在量化过程中可动态调整
        sym=True,                     # 对称量化,零点固定在 0,误差更小
        true_sequential=True,         # 按权重顺序逐个处理,精度更高
        model_name_or_path=None,
        model_file_base_name="model",
    )

    tokenizer = AutoTokenizer.from_pretrained(
        args.model_name_or_path, trust_remote_code=True)
    # Qwen 没有单独的 pad token,用结束符顶上
    tokenizer.pad_token_id = tokenizer.eod_id

    with open(args.data_path, encoding="utf-8") as f:
        raw = json.load(f)
    data = preprocess(raw, tokenizer, args.max_len)
    logging.info("校准样本数:%d", len(data))

    model = AutoGPTQForCausalLM.from_pretrained(
        args.model_name_or_path,
        quantize_config,
        device_map="auto",
        trust_remote_code=True,
    )

    # 真正的量化在这一行发生,日志会逐层打印 avg loss
    # cache_examples_on_gpu=False:校准中间结果放 CPU,省显存
    model.quantize(data, cache_examples_on_gpu=False)

    # use_safetensors=True:存成 safetensors,加载时不执行任意代码
    model.save_quantized(args.out_path, use_safetensors=True)
    tokenizer.save_pretrained(args.out_path)
    logging.info("量化完成,产物已写入:%s", args.out_path)


if __name__ == "__main__":
    main()

阶段一:preprocess() 把对话拼成 ChatML

校准数据是普通的对话 JSON,但模型只认自己的对话格式。preprocess() 做的事就是把原始对话转换为符合 Qwen 模型输入格式的 token 序列,拼出来的文本长这样:

<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
What is AI?<|im_end|>
<|im_start|>assistant
AI is...<|im_end|>

具体做了四件事:

动作细节
添加特殊标记每段以 <|im_start|>角色 开头、<|im_end|> 收尾,这套记法就是 ChatML。代码里用 tokenizer.im_start_id / im_end_id 取它们的 token id
构建系统消息上下文开头固定铺一段 system 消息,默认是 You are a helpful assistant.
区分用户 / 助手角色段落首句若不是 user 就丢掉,保证对话从用户发言开始
生成 input_idsattention_mask前者是截断到 max_len 的 token 序列,后者标出哪些位置不是 padding
IGNORE_TOKEN_ID 在屏蔽什么 IGNORE_TOKEN_ID = LabelSmoother.ignore_index,值是 -100。它标记「这个位置不参与 loss 计算」:system 段整段屏蔽,user 段整段屏蔽,只有 assistant 的回答内容保留下来。
放在校准场景里理解:试吃时只尝主厨要求改的那几味,客人点单的原话不算在评分里。
tokenizer.pad_token_id = tokenizer.eod_id 不能省 Qwen 没有单独的 pad token。不补这一行,后面 input_id.ne(tokenizer.pad_token_id) 会拿 None 去比较,直接抛错。

阶段二:BaseQuantizeConfig 逐参数

量化配置决定了「刻度怎么划」。七个参数逐个看:

参数取值作用
bits4量化位宽,4 表示 int4 模型。这是体积压缩的主要来源
group_size128指定量化时使用的组大小。组量化把模型中的多个权重组合在一起进行量化,以减少模型大小并提高计算效率
damp_percent0.01用于在量化过程中控制权重的调整程度。较高的值可以减少量化带来的影响
desc_actFalse设置为 False 可以显著加快推理速度,但困惑度可能会略有下降
static_groupsFalse是否在量化过程中使用静态量化。设为 True 则量化过程中组不会被动态调整
symTrue对称性。控制量化是否是对称的,可以减少量化误差
true_sequentialTrue控制量化过程中是否考虑权重的顺序。设为 True 时量化过程会考虑权重顺序,可能提高量化后模型的精度

真正触发量化的只有一行:

model.quantize(data, cache_examples_on_gpu=False)

cache_examples_on_gpu=False 把校准过程的中间结果放在 CPU 而不是显存里。量化本身就是为了省显存,这里再省一道——代价是多一些 CPU 与 GPU 之间的搬运时间。

阶段三:保存为可部署格式

model.save_quantized(args.out_path, use_safetensors=True)
tokenizer.save_pretrained(args.out_path)

use_safetensors=True 让权重以 safetensors 格式落盘——这是一种安全序列化格式,加载时不会执行任意代码,也就不会因为拿到一个来路不明的权重文件而被注入。同时要记得把 tokenizer 一起存到同一个目录,否则部署时模型加载得起来、分词器找不到。

4.3 读懂量化日志:avg loss 是什么,为什么越往后越大

量化跑起来后,日志会逐层、逐个模块地刷。截取第 1 层的片段:

INFO - Start quantizing layer 1/24
INFO - Quantizing attn.c_attn in layer 1/24...
[auto_gptq.quantization.gptq] duration: 1.5492913722991943
[auto_gptq.quantization.gptq] avg loss: 0.30306549643755853
INFO - Quantizing attn.c_proj in layer 1/24...
[auto_gptq.quantization.gptq] duration: 0.5725982189178467
[auto_gptq.quantization.gptq] avg loss: 9.118557701843061e-05
INFO - Quantizing mlp.w1 in layer 1/24...
[auto_gptq.quantization.gptq] avg loss: 0.605090880661868
INFO - Quantizing mlp.w2 in layer 1/24...
[auto_gptq.quantization.gptq] avg loss: 0.5784293756949321
INFO - Quantizing mlp.c_proj in layer 1/24...
[auto_gptq.quantization.gptq] avg loss: 0.018937584166223192

再看第 23、24 层同样位置的数字:

模块layer 1/24layer 22/24layer 23/24layer 24/24
attn.c_attn0.303113.324511.960911.1491
attn.c_proj0.00009120.45310.51820.8637
mlp.w10.60517.98428.35948.1233
mlp.w20.57846.72737.14817.1085
mlp.c_proj0.01892.66012.87965.5982

avg loss 就是 2.2 节第 ③ 步的度量:这个模块量化前后,在校准数据上的平均输出误差duration 是这个模块量化耗时,单位秒。

这份日志里,靠后的层数值明显高于第 1 层。一个合理的解释是:GPTQ 是逐层独立处理的,后面的层拿到的输入已经是前面所有层量化之后的输出,误差在层间累积,所以越靠后的层要拟合的目标本身就带着前面的偏差。

这里要老实一点 上面这组数字来自这一次具体的量化运行,24 层、Qwen 架构、这一份校准数据。「层数越深 avg loss 越大」在这次运行里成立,但它不是一条普适定律——从 22 层到 24 层,attn.c_attn 反而在下降(13.32 → 11.96 → 11.15)。不同模型、不同校准数据,曲线形状会变。
avg loss 的绝对值也没有统一及格线,不同模块的量级本来就差着好几个数量级(同一层里 attn.c_proj 是 9.1e-05,mlp.w1 是 0.605)。真正该看的是量化完之后跑一遍业务评测,用任务指标判断能不能上线,而不是盯着日志数字猜。

量化跑完会进入 Packing model... 阶段,把逐层结果打包写盘,日志逐个打印 transformer.h.0.attn.c_attn 这样的模块名,直到 Model packed. 出现,整个过程结束。

4.4 第三道工序:vLLM 上线

先用离线接口确认模型能正常出字,再起服务——这样出问题时能立刻分清是模型的事还是服务的事。

vllm_offline_infer.py —— 离线批量推理,先验证模型
"""vLLM 离线批量推理:一次把整批 prompt 喂进去,拿回全部结果。

离线模式不起 HTTP 服务,适合跑评测、批量刷数据。
依赖:pip install vllm
运行:python vllm_offline_infer.py --model <模型目录>
"""

import argparse

from vllm import LLM, SamplingParams


def build_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser("vLLM offline batch inference")
    parser.add_argument("--model", type=str, required=True,
                        help="模型目录,合并后的全量权重或 GPTQ 量化产物都行")
    parser.add_argument("--temperature", type=float, default=0.8,
                        help="采样温度,越大越发散")
    parser.add_argument("--top_p", type=float, default=0.95,
                        help="核采样阈值")
    parser.add_argument("--max_tokens", type=int, default=100,
                        help="单条输出的最大 token 数")
    return parser.parse_args()


def main() -> None:
    args = build_args()

    # 一次投喂多条,vLLM 内部会自己做连续批处理,不必等凑满一批
    prompts = [
        "总结下面这段文本的摘要:随着科技的飞速发展,我们的生活方式也在悄然改变。"
        "智能手机、人工智能、物联网等科技产品的出现,为我们的日常生活带来了更多便利和舒适。",
        "用三句话说明什么是 KV cache。",
    ]

    sampling_params = SamplingParams(
        temperature=args.temperature,
        top_p=args.top_p,
        max_tokens=args.max_tokens,
    )

    # trust_remote_code=True:Qwen 等带自定义建模代码的模型需要它
    llm = LLM(model=args.model, trust_remote_code=True)

    outputs = llm.generate(prompts, sampling_params)

    for output in outputs:
        print("=" * 60)
        print("Prompt:", output.prompt)
        print("Output:", output.outputs[0].text)


if __name__ == "__main__":
    main()

启动日志里有几行值得停一下:

日志含义
Available KV cache memory: 16.56 GiB模型权重装完之后,剩给 KV cache 的显存。这个数字直接决定能并发多少
GPU KV cache size: 90,400 tokens把上面的显存换算成能缓存多少个 token 的 K/V
Maximum concurrency for 8,192 tokens per request: 11.04x按每请求 8192 token 估算,大约能并发 11 路。这就是 2.5 节说的「并行量」
Chunked prefill is enabled长 prompt 的 prefill 会被切块执行,避免一次占满调度窗口
est. speed input: 424.53 toks/s, output: 176.47 toks/s这一批请求的实测输入 / 输出吞吐

确认没问题就起 HTTP 服务:

python -m vllm.entrypoints.openai.api_server \
  --model /path/to/your-model \
  --host 0.0.0.0 \
  --port 8000 \
  --trust_remote_code True

如果跑在云平台的容器里,本机端口还要在控制台做一次端口映射,才能拿到一个对外可访问的 URL。拿到之后,客户端只需要把 base_url 指过去:

vllm_client.py —— OpenAI 客户端调用 api server 并统计速度
"""用 OpenAI 客户端访问 vLLM 起的 api server,并顺手量一下输出速度。

服务端地址与凭据全部来自环境变量,脚本里不写死任何 key:
    export VLLM_BASE_URL="http://127.0.0.1:8000/v1"
    export VLLM_API_KEY="本地服务随便填一个占位串"
    export VLLM_MODEL="add1"      # 指定走哪个 LoRA;留空则走基座模型

依赖:pip install openai
运行:python vllm_client.py
"""

import os
import time

from openai import OpenAI


def build_client() -> OpenAI:
    base_url = os.environ.get("VLLM_BASE_URL", "http://127.0.0.1:8000/v1")
    # vLLM 本地服务不校验 key,但 openai SDK 要求这个字段非空,
    # 所以仍然从环境变量取,绝不在源码里写死真实凭据
    api_key = os.environ.get("VLLM_API_KEY")
    if not api_key:
        raise RuntimeError("未找到 VLLM_API_KEY,请先在环境变量里配置")
    return OpenAI(api_key=api_key, base_url=base_url)


def call_server(client: OpenAI, prompt: str) -> str:
    # model 传 LoRA 的挂载名就走那个 LoRA,传空字符串则走基座模型
    model = os.environ.get("VLLM_MODEL", "")
    result = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        max_tokens=2048,
    )
    return result.choices[0].message.content


def main() -> None:
    client = build_client()
    prompt = "用一段话说明 PagedAttention 解决了什么问题。"

    start = time.time()
    answer = call_server(client, prompt)
    cost = time.time() - start

    print(answer)
    print("-" * 60)
    print(f"输出长度:{len(answer)} 字")
    print(f"耗时:{cost:.2f} 秒")
    print(f"输出速度:{len(answer) / cost:.2f} 字/秒")


if __name__ == "__main__":
    main()
model 参数填什么 没挂 LoRA 时,model启动时 --model 给的那个路径字符串(服务端会把它作为 served model name)。挂了 LoRA 时填 adapter 的挂载名。传空串则直接使用基座模型推理。

4.5 多 LoRA 同时挂载

vLLM 能够同时加载多个 LoRA,随时可以指定使用其中的任意一个。启动命令换成 vllm serve 形式,加上 --enable-lora--lora-modules

serve_vllm.template.sh —— 起服务骨架,含多 LoRA 挂载写法骨架模板
#!/usr/bin/env bash
# vLLM api server 启动骨架,改完 TODO 直接 bash serve_vllm.template.sh
set -euo pipefail

# TODO 1: 基座模型或量化产物目录
MODEL_PATH="TODO-PATH-TO-MODEL"
# TODO 2: 对外端口,容器/云平台上还要在控制台做端口映射
PORT="${PORT:-8000}"
# TODO 3: 用哪块卡
export CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}"

# ---------- 写法一:模块入口,最通用 ----------
python -m vllm.entrypoints.openai.api_server \
  --model "${MODEL_PATH}" \
  --host 0.0.0.0 \
  --port "${PORT}" \
  --trust_remote_code True

# ---------- 写法二:同时挂多个 LoRA ----------
# 挂上去之后,客户端用 model 参数点名走哪个 LoRA,传空串则走基座模型。
# 去掉下面的注释并补上 TODO 路径即可启用(注意与写法一二选一)。
#
# vllm serve "${MODEL_PATH}" \
#   --host 0.0.0.0 \
#   --port "${PORT}" \
#   --enable-lora \
#   --lora-modules \
#     cot=TODO-PATH-TO-LORA-A/checkpoint-1250/ \
#     cat=TODO-PATH-TO-LORA-B/checkpoint-120/ \
#     spo=TODO-PATH-TO-LORA-C/checkpoint-375/

--lora-modules 后面是一串 名字=路径,名字由你自己取(上面用了 cotcatspo),路径指到 checkpoint-xxx 目录。挂上去之后,请求里 model="cot" 就走第一个 adapter,model="" 就走基座。

这个特性在实际生产中非常方便,好处有三条:

1方便对比测试

可以同时加载对比测试的 N 个 LoRA,无缝切换使用任意一个,随时对比它们在不同输入下的表现。省掉了「改配置、重启服务、再测一遍」的来回。

2节约服务器资源

如果有多个低频使用的 LoRA,可以把它们加载到少数几台服务器上,节省部署服务的硬件资源——基座只装一份,adapter 才几十 MB。

3方便负载均衡

多个 LoRA 分开部署时,每个 LoRA 需要做自己的服务器组,组内资源共享,但组间无法共享;统一部署则可以共享所有资源,峰谷互相填补。

还是中央厨房:同一条流水线上挂着好几本菜谱,点单时报菜谱名。要是每本菜谱都单开一间厨房,中午川菜排长队时,隔壁粤菜厨房的灶台只能空着。

4.6 速度实测:换框架换来了什么

同一个 3B 量级的 GPTQ-Int4 模型,同一块卡,同一个测试问题:

测试问题:对于「初三女生在搀扶跌倒老奶奶后反被冤枉,但仍选择资助她千元」的新闻事件,你有什么看法?
输出长度:1794 字   耗时:16.03 秒   输出速度:111.92 字/秒
部署方式实测输出速度倍数
LLaMA-Factory api server5.05 字/秒基准
LLaMA-Factory api server(另一次记录)7.26 字/秒约 1.4×
vLLM api server111.92 字/秒约 15–22×
口径说明,不要把这个倍数当承诺 训练框架侧的两个数字(5.05 与 7.26)来自不同次运行,问题长度与是否首次加载并不一致,所以倍数给的是区间而不是一个确定值。更重要的是:这是单请求的速度对比。vLLM 真正拉开差距的地方是并发——PagedAttention 带来的高显存利用率意味着同样一块卡能同时跑更多路请求,那个差距比单请求还要大。但本页没有并发压测数据,不做量化断言

05骨架模板:五个文件覆盖全流程

改完 TODO 就能跑,凭据全部走环境变量

这一讲的产物是五个文件,按三道工序排开。每个文件都只留了必须由你决定的 TODO,其余参数已经按 4.x 节的取值逻辑填好。

文件工序改哪里
merge_lora.template.yaml① 合并3 处 TODO:基座路径、adapter 路径、导出目录
run_gptq.py② 量化不用改,三个路径全部走命令行参数
serve_vllm.template.sh③ 部署3 处 TODO:模型路径、端口、显卡;多 LoRA 段按需解注释
vllm_offline_infer.py③ 验证prompts 换成你的业务样例
vllm_client.py③ 调用不用改,三个环境变量控制行为

5.1 合并配置模板

顶部那行注释是提醒自己的:合并时不要使用量化模型,也不要写 quantization_bit

merge_lora.template.yaml骨架模板
### LoRA / QLoRA 权重合并模板
### 用法:llamafactory-cli export merge_lora.template.yaml
### 合并时不要使用量化模型,也不要写 quantization_bit

### model
# TODO 1: 基座模型路径或 HuggingFace 模型 ID,必须与训练时用的基座完全一致
model_name_or_path: TODO-PATH-TO-BASE-MODEL
# TODO 2: 训练产出的 adapter 目录(checkpoint-xxx 那一层)
adapter_name_or_path: TODO-PATH-TO-LORA-CHECKPOINT
# 合并 Qwen2 系列时必须为 qwen
template: qwen
# 无论训练时用的是 LoRA 还是 QLoRA,这里一律填 lora
finetuning_type: lora

### export
# TODO 3: 合并后完整权重的落盘目录
export_dir: models/qwen2-7b-sft-lora-merged
# 单个权重分片的最大体积,单位 GB
export_size: 2
# 合并在 CPU 上做,显存不够也能完成;有充裕显存可改 cuda
export_device: cpu
# 是否导出为旧版 .bin 格式,false 表示使用 safetensors
export_legacy_format: false

5.2 起服务脚本模板

两种写法都留在里面:默认启用的是模块入口写法(最通用),下面注释掉的是多 LoRA 写法。两者二选一,不要同时放开,否则第二条命令永远等不到执行。

serve_vllm.template.sh骨架模板
#!/usr/bin/env bash
# vLLM api server 启动骨架,改完 TODO 直接 bash serve_vllm.template.sh
set -euo pipefail

# TODO 1: 基座模型或量化产物目录
MODEL_PATH="TODO-PATH-TO-MODEL"
# TODO 2: 对外端口,容器/云平台上还要在控制台做端口映射
PORT="${PORT:-8000}"
# TODO 3: 用哪块卡
export CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}"

# ---------- 写法一:模块入口,最通用 ----------
python -m vllm.entrypoints.openai.api_server \
  --model "${MODEL_PATH}" \
  --host 0.0.0.0 \
  --port "${PORT}" \
  --trust_remote_code True

# ---------- 写法二:同时挂多个 LoRA ----------
# 挂上去之后,客户端用 model 参数点名走哪个 LoRA,传空串则走基座模型。
# 去掉下面的注释并补上 TODO 路径即可启用(注意与写法一二选一)。
#
# vllm serve "${MODEL_PATH}" \
#   --host 0.0.0.0 \
#   --port "${PORT}" \
#   --enable-lora \
#   --lora-modules \
#     cot=TODO-PATH-TO-LORA-A/checkpoint-1250/ \
#     cat=TODO-PATH-TO-LORA-B/checkpoint-120/ \
#     spo=TODO-PATH-TO-LORA-C/checkpoint-375/

5.3 调用端模板

三个环境变量决定它的行为,一个都不写死:

环境变量默认值作用
VLLM_BASE_URLhttp://127.0.0.1:8000/v1服务地址,注意结尾的 /v1 不能少
VLLM_API_KEY无默认,缺失直接报错本地服务不校验,但仍从环境变量取,绝不写死
VLLM_MODEL空串挂了多 LoRA 时点名用哪个;空串走基座模型
vllm_client.py骨架模板
"""用 OpenAI 客户端访问 vLLM 起的 api server,并顺手量一下输出速度。

服务端地址与凭据全部来自环境变量,脚本里不写死任何 key:
    export VLLM_BASE_URL="http://127.0.0.1:8000/v1"
    export VLLM_API_KEY="本地服务随便填一个占位串"
    export VLLM_MODEL="add1"      # 指定走哪个 LoRA;留空则走基座模型

依赖:pip install openai
运行:python vllm_client.py
"""

import os
import time

from openai import OpenAI


def build_client() -> OpenAI:
    base_url = os.environ.get("VLLM_BASE_URL", "http://127.0.0.1:8000/v1")
    # vLLM 本地服务不校验 key,但 openai SDK 要求这个字段非空,
    # 所以仍然从环境变量取,绝不在源码里写死真实凭据
    api_key = os.environ.get("VLLM_API_KEY")
    if not api_key:
        raise RuntimeError("未找到 VLLM_API_KEY,请先在环境变量里配置")
    return OpenAI(api_key=api_key, base_url=base_url)


def call_server(client: OpenAI, prompt: str) -> str:
    # model 传 LoRA 的挂载名就走那个 LoRA,传空字符串则走基座模型
    model = os.environ.get("VLLM_MODEL", "")
    result = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        max_tokens=2048,
    )
    return result.choices[0].message.content


def main() -> None:
    client = build_client()
    prompt = "用一段话说明 PagedAttention 解决了什么问题。"

    start = time.time()
    answer = call_server(client, prompt)
    cost = time.time() - start

    print(answer)
    print("-" * 60)
    print(f"输出长度:{len(answer)} 字")
    print(f"耗时:{cost:.2f} 秒")
    print(f"输出速度:{len(answer) / cost:.2f} 字/秒")


if __name__ == "__main__":
    main()
✅ 一条命令串起全流程 llamafactory-cli export merge_lora.template.yamlpython run_gptq.py --model_name_or_path <合并产物> --data_path <校准数据> --out_path <量化产物>bash serve_vllm.template.shpython vllm_client.py。中间任何一步失败,先回到 06 节对照现象。

06易错点汇总

按三道工序归类,每条都写清「现象 → 原因 → 修法」

⚠️ 一、合并阶段

  • 压根没合并就去加载 adapter 目录。 现象:模型加载报错,或者加载成功但输出完全是基座的味道。原因:checkpoint-xxx 里只有 LoRA 权重,几十 MB,不是一个完整模型。修法:先跑 llamafactory-cli export,确认导出目录体积与基座相当。
  • template 没设成 qwen 现象:合并能完成,但推理时对话格式错乱、模型不停自问自答。原因:模板决定了对话怎么拼装。修法:合并 Qwen2 模型权重,务必将 template 设为 qwen
  • 训的是 QLoRA,就把 finetuning_type 写成 qlora 现象:配置解析失败或合并结果不对。修法:无论 LoRA 还是 QLoRA 训练,合并权重时 finetuning_type 均为 lora。QLoRA 只是训练时对基座做了量化,产出的 adapter 本身仍然是 LoRA。
  • 合并时带上了量化模型或 quantization_bit 现象:合并出来的模型质量明显低于训练时的评测结果。原因:在被压粗刻度的权重上叠加增量,精度被吃掉。修法:用 FP16 基座合并,量化放到合并之后单独做
  • 基座版本对不上。 现象:不报错,但输出莫名其妙。原因:LoRA 增量是相对某一份具体权重训出来的。修法:把训练时的 model_name_or_path 原样抄进合并配置,包括版本号和本地路径。
  • 合并之后又挂了一次同一个 adapter。 现象:输出风格夸张、重复、明显跑偏。原因:增量被加了两遍。修法:合并产物当普通模型用,不要再传 adapter_name_or_path
  • export_device: cuda 但显存不够。 现象:合并过程 OOM。修法:改成 cpu,慢一些但不吃显存。

⚠️ 二、量化阶段

  • 忘了 tokenizer.pad_token_id = tokenizer.eod_id 现象:preprocess()input_id.ne(tokenizer.pad_token_id) 抛异常。原因:Qwen 没有独立的 pad token,默认是 None。修法:照代码补上这一行。
  • 把校准数据当训练数据准备。 现象:量化跑几个小时还没完,或者内存爆掉。原因:校准只需要一小部分样本,几百条真实对话即可。修法:抽样,不要全量。
  • 校准数据和业务场景不搭。 现象:量化后通用能力还在,但业务任务上明显退化。原因:GPTQ 是在校准数据上最小化输出误差,校准数据决定了「保住哪部分能力」。修法:用真实业务样本做校准,别拿一份无关的公开数据集凑数。
  • avg loss 当成模型质量的唯一判据。 现象:看到后面几层 loss 上到 10 以上就慌了,或者看到都小于 1 就直接上线。原因:不同模块的量级天然差好几个数量级(同一层里 attn.c_proj 是 9.1e-05、mlp.w1 是 0.605),而且它没有统一及格线。修法:量化完跑一遍业务评测,用任务指标决定能不能上线。
  • 把「层数越深 avg loss 越大」当成定律记。 现象:换个模型发现曲线不是这样,怀疑自己跑错了。原因:那只是某一次运行的观察,同一份日志里 attn.c_attn 从 22 层到 24 层反而在下降。修法:当成「误差会在层间累积」这个机制的一个例子看,别当公式。
  • 只存了权重、忘了存 tokenizer。 现象:部署时模型能加载,分词器找不到。修法:save_quantized 之后紧跟 tokenizer.save_pretrained(out_path),两者必须落在同一个目录。
  • 显存不够但 cache_examples_on_gpu 开着。 现象:量化中途 OOM。修法:设成 False,中间结果放 CPU。
  • 期待 4-bit 之后显存正好降到四分之一。 现象:实测没降那么多。原因:GPTQ 只量化权重,激活值和 KV cache 都还是高精度。修法:按「权重那部分降到约四分之一」估算,KV cache 单独算。
  • desc_act=True 当默认好选项。 现象:推理速度明显变慢。原因:desc_act=False 可以显著加快推理速度,代价只是困惑度略有下降。修法:没有明确精度诉求就保持 False

⚠️ 三、部署阶段

  • 漏了 --trust_remote_code True 现象:加载 Qwen 直接报错,提示需要执行远程代码。修法:补上。同理,离线接口里 LLM(model=..., trust_remote_code=True) 也要带。
  • --host 用了默认的本地回环。 现象:本机 curl 通,容器外 / 局域网内连不上。修法:--host 0.0.0.0;云平台上还要在控制台做一次端口映射才能拿到对外 URL。
  • base_url 漏了 /v1 现象:404 或者返回一段 HTML。修法:地址写成 http://host:8000/v1
  • model 参数随便填。 现象:报模型不存在。原因:没挂 LoRA 时它是启动时 --model 那个路径字符串;挂了 LoRA 时是 adapter 的挂载名。修法:不确定就先 GET /v1/models 看服务端报了哪些名字。传空串则走基座模型。
  • api_key 写死成一个字面量。 现象:代码本身能跑,但这个习惯迁到需要真实凭据的服务上就会泄漏。修法:一律 os.environ.get(...),取不到就报错退出——模板里就是这么写的。
  • 并发一上去首字延迟突然抬头。 现象:压测长尾延迟飙升。原因:超出并行量时最后的请求会被抢占(preemption),退出占用资源、排队等待。修法:看启动日志里的 Maximum concurrency 估算上限,超了就加卡或限流,不是调采样参数能解决的。
  • 多 LoRA 启动时两种写法同时放开。 现象:第二条命令永远不执行。原因:前一条 api_server 是前台阻塞进程。修法:模板里两段二选一。
  • 拿单请求速度去承诺并发吞吐。 现象:上线后实际表现和汇报数字对不上。修法:单请求 111.92 字/秒 是单请求口径;并发能力要另跑压测,没测就别报数

⚠️ 四、跨阶段的顺序问题

  • 先量化后合并。 这是本讲最贵的一个坑:量化产物不是 FP16,再拿它当基座合并,增量精度被量化误差吞掉。修法记死顺序:合并 → 量化 → 部署
  • 把 QLoRA 的 4-bit 当成「已经量化过了,可以直接上线」。 QLoRA 训完拿到的仍是高精度 adapter,基座的量化是训练期的显存手段,不是部署产物。修法:照样走合并,再决定要不要 GPTQ。
  • 全量微调也去跑一遍合并。 现象:找不到 adapter 报错。原因:全量参数训练无需执行合并这一步,训练产物本身就是完整模型。修法:直接进量化或部署。

07自测题

点击题目展开答案;这 12 题答得上来,三道工序就通了

一、合并
LoRA 训练完为什么不能直接拿 checkpoint 去推理?全量微调也要合并吗?

采用 LoRA 或 QLoRA 训练时,脚本只保存对应的 LoRA 权重(低秩增量,几十 MB),它是相对基座的「增量」,离开基座没有意义,需要合并权重才能进行推理。全量参数训练无需执行此步骤——它的产物本身就是一份完整模型。

合并 Qwen2 时,templatefinetuning_type 分别填什么?

template 必须设为 qwenfinetuning_type 无论训练时用的是 LoRA 还是 QLoRA,一律填 lora。另外合并时不要使用量化模型,也不要写 quantization_bit

合并之后模型体积会变大吗?为什么?

不会。合并是把低秩增量 加进 原有权重矩阵(W + BA → W'),参数量与基座完全一致,所以合并产物的体积等于基座体积,而不是「基座 + adapter」。如果导出目录只有几十 MB,说明导出的还是 adapter,配置没生效。

二、量化
PTQ 和 QAT 有什么区别?各自的代表是什么?

PTQ(Post-Training Quantization,训练后量化)——训练完成后进行量化,代表是 GPTQQAT(Quantization Aware Training,量化感知训练)——训练/微调过程中同时进行量化,代表是 QLoRA。前者压的是推理阶段的体积与显存,不需要重训;后者压的是训练阶段的显存。

GPTQ 的四个基本步骤是什么?

收集校准数据:从训练数据或相关数据集中抽取一小部分样本;② 逐层处理:对每一层独立量化,避免全局优化的复杂度;③ 最小化输出误差:对每一层寻找最佳量化权重,使校准数据上的输出误差最小;④ 更新权重:用量化后的权重替换原始权重。

Hessian 逆矩阵在 GPTQ 里起什么作用?数值高的权重重要还是数值低的重要?

Hessian 告诉我们模型输出对每个权重变化的敏感程度,也就是权重的重要性。与 Hessian 矩阵中较小值相关的权重更为重要,因为这些权重的微小变化可能对模型性能产生重大影响。在 Hessian 矩阵的逆矩阵中,数值越低,权重越「重要」。 对权重的每一列,算法量化权重、计算误差,并相应更新其中的权重,处理完后再更新所有其余权重。

group_size=128 是什么意思?调大调小各有什么代价?

组量化把多个权重组合在一起进行量化,每 128 个权重共享一组量化参数(缩放系数与零点),以减少模型大小并提高计算效率。调小(如 32)刻度更贴合局部分布、精度更好,但要存的量化参数更多、体积变大;调大则额外参数少、体积小,但一个离群权重就能把整组刻度带偏。

量化日志里的 avg loss 是什么?为什么这次运行里靠后的层数值更大?能当成普适定律吗?

avg loss 是该模块量化前后在校准数据上的平均输出误差。这次运行里靠后的层数值更大,合理的解释是 GPTQ 逐层独立处理,后面的层拿到的输入已经是前面各层量化之后的输出,误差在层间累积
但它不是普适定律:同一份日志里 attn.c_attn 从 22 层到 24 层反而在下降(13.32 → 11.96 → 11.15);不同模块的量级也天然差好几个数量级。判断能否上线要跑业务评测,不能盯日志数字猜。

为什么 4-bit 量化后显存占用不会精确降到四分之一?

因为 GPTQ 只对权重进行量化,激活值和 KV cache 仍然是高精度。省下的是「显存里放权重」的那部分和加载体积,计算过程并没有整体变成 4-bit 整数运算。

三、部署
LLM 推理的两个阶段分别是什么?KV cache 在哪个阶段被写入?

prefill(预填充):把整段 prompt 喂给模型做 forward 计算,采用 KV cache 技术时,prompt 过后得到的 K、V 就保存进 cache;decode(生成):根据 prefill 的结果,一个 token 一个 token 地生成 response。

为什么是 KV cache 而不是 QKV cache?

decode 阶段每一步只有当前这一个新 token 需要 Query,去和前面所有 token 的 K、V 做注意力;历史 token 的 Q 再也不会被用到,缓存它没有意义。而历史 token 的 K、V 每一步都会被重新用到,缓存它们才省算力。

传统 KV cache 直接分配连续显存有哪三个问题?

输出序列长度无法预先知道,很难提前为 KV cache 量身定制存储空间;② 预留多了,大量显存被占着不用,能并发的请求数被压低;③ 预留少了,生成到一半空间不够。随着 prompt 变多变长,KV cache 不断变大,对显存造成压力。

PagedAttention 的四个概念分别对应操作系统里的什么?vLLM 默认一个 block 装多少 token?显存利用率能到多少?

请求(request)↔ 进程;逻辑内存(logical KV blocks)↔ 虚拟内存,每个 block 类比一个 page;块表(block table)↔ 虚拟内存到物理内存的映射表;物理内存(physical KV blocks)↔ 物理内存,块在 GPU 显存上。vLLM 中 block 默认大小为 16,即可装 16 个 token 的 K/V 值;这种方式下显存利用率能达到 96%

vLLM 中一批任务正在推理,又来了一条新请求,会怎么处理?超出并行量呢?

vLLM 不再要求所有并行任务处于同一阶段:其他推理任务进行时,只要资源充足,新请求可以随时开始,减少了任务批次间的等待。如果超出并行量,最后的请求会被抢占(preemption),退出所有占用资源,等待运行中的任务完成后再继续——表现为这条请求的首字延迟明显变长。

同时加载多个 LoRA 有哪三个好处?客户端怎么指定用哪一个?

方便对比测试:同时加载 N 个 LoRA,无缝切换,随时对比不同输入下的表现;② 节约服务器资源:多个低频 LoRA 可以加载到少数几台服务器上;③ 方便负载均衡:分开部署时组内资源共享、组间无法共享,统一部署则可共享所有资源。
启动时用 --enable-lora --lora-modules 名字=路径 挂载,调用时用 model 参数点名;model 传空串则直接使用基座模型推理

实测速度对比里那三个数字分别是什么口径?能不能说「vLLM 快 20 倍」?

LLaMA-Factory 起的 api server 实测有 5.05 字/秒7.26 字/秒 两个记录,来自不同次运行;vLLM api server 部署后为 111.92 字/秒(输出 1794 字、耗时 16.03 秒)。
三个数字都是单请求口径,且训练框架侧两次测量条件不完全一致,所以只能说量级差异约 15–22 倍,不能报一个确定倍数。并发吞吐的差距要另跑压测,没测就不报数。

术语表

这一讲出现过的英文原形,按工序排

术语中文一句话解释
adapter适配器LoRA 训练保存下来的低秩增量权重,几十 MB,离开基座无法独立推理
checkpoint检查点训练过程中定期落盘的产物目录,LoRA 路线下里面只有 adapter
merge / export权重合并 / 导出把增量算回主干权重,产出一份能独立加载的完整模型
PTQ训练后量化Post-Training Quantization,训练完成后单独量化,代表是 GPTQ
QAT量化感知训练Quantization Aware Training,训练过程中同时量化,代表是 QLoRA
GPTQ只量化权重的高效后量化算法,逐层独立处理,能压到 4-bit 并保住精度
Hessian海森矩阵描述模型输出对权重变化的敏感程度;其逆矩阵中数值越低,权重越重要
calibration data校准数据一小批样本,用来衡量量化前后的输出差异,不参与训练
group_size分组大小多少个权重共享一组量化参数,默认 128,是精度与体积的平衡点
avg loss平均误差量化日志里每个模块量化前后在校准数据上的平均输出误差
ChatMLQwen 使用的对话标记格式,以 <|im_start|>角色 开头、<|im_end|> 收尾
safetensors安全序列化的权重格式,加载时不执行任意代码
prefill预填充阶段把整段 prompt 一次性喂给模型做 forward 计算
decode生成阶段基于 prefill 结果,一个 token 一个 token 地产出 response
KV cacheKV 缓存把注意力的 K、V 中间结果存下来,避免每输出一个字符都从头算
PagedAttention分页注意力借鉴操作系统虚拟内存分页,把 KV cache 按固定块按需发放
block table块表逻辑 KV blocks 到物理 KV blocks 的映射表
preemption抢占超出并行量时,最后的请求退出占用资源、排队等待
vLLM伯克利团队开发的高性能推理框架,靠显存管理与调度策略提升吞吐
api server接口服务vLLM 提供的 OpenAI 风格 HTTP 服务,换个 base_url 就能调