从一个 Token 到 vLLM:大模型推理、KV Cache、量化与模型适配的理解
学大模型量化时,很容易一上来就研究 AWQ、GPTQ,或者直接复制一段量化脚本。
但真正开始做推理部署以后,会发现很多问题绕不过去:
- 为什么 7B 模型 BF16 大约需要 14 GB 权重显存?
- 为什么量化成 INT4 后,整张 GPU 的显存却没有严格缩小 4 倍?
- 为什么上下文和并发一高就容易 OOM?
- KV Cache 到底缓存了什么?
- 为什么 TP 能让单卡放不下的模型跑起来?
- vLLM 的 Paged KV Cache、Continuous Batching 和 Scheduler 到底分别解决什么问题?
这些问题看起来属于不同领域,
其实可以看成一条线:
text
Token
↓
Embedding
↓
Transformer
↓
Q / K / V
↓
Attention
↓
KV Cache
↓
Prefill / Decode
↓
GPU 显存
↓
量化
↓
AWQ / GPTQ / ...其他算法
↓
TP / PP
↓
vLLM
把这条链真正串起来后,很多所谓的"大模型部署参数"就不再是需要死记的配置项,而是可以从原理推出来的东西。
一、先从最基本的东西开始:大模型处理的不是文字,而是张量
假设输入一句话:
text
中国的首都是
第一步是 Tokenizer。
它会把文本切成 token,再映射成 token ID:
text
文字
↓
Tokenizer
↓
token IDs
比如某个 token 的 ID 是:
text
103
这里的 103 只是词表中的编号,本身没有"103 比 102 更接近某种语义"这样的意义。
真正进入 Transformer 之前,还要经过 Embedding:
text
token_id = 103
↓
Embedding
↓
[0.21, -0.83, 0.14, ...]
假设模型的 hidden_size=4096,那么一个 token 最终会变成一个包含 4096 个数字的向量。
多个 token 放在一起,就形成 Transformer 经常处理的:
text
X.shape = [B, S, H]
其中:
text
B = Batch Size
S = Sequence Length
H = Hidden Size
例如:
text
[2, 512, 4096]
表示:
一批有 2 条序列,每条 512 个 token,每个 token 使用一个 4096 维向量表示。
理解这一点非常重要,因为 Transformer 里面大量计算,本质都是张量和矩阵乘法。
最基础的规则就是:
text
[A, B] × [B, C]
=
[A, C]
例如:
text
[512, 4096]
×
[4096, 8192]
=
[512, 8192]
这条简单的规则,后面会直接连接到 QKV 和 Tensor Parallel。
二、一层 Transformer 到底做了什么?
先暂时忽略各种实现差异,一层 Transformer 可以粗略理解成:
text
X
↓
Norm
↓
Attention
↓
Residual
↓
Norm
↓
FFN
↓
Residual
这里最重要的部分首先是 Attention。
输入 X 会分别经过三套训练好的权重矩阵:
text
Q = X × Wq
K = X × Wk
V = X × Wv
一定要分清两类东西:
text
Wq / Wk / Wv
= 模型训练好的权重
= 模型加载以后基本常驻显存
Q / K / V
= 请求运行时根据 X 计算出来的中间结果
= Activation
这也是理解 GPU 显存结构的第一步:
Weight 和 Activation 不是一回事。
三、Q、K、V 到底在做什么?
数学公式可以以后再深入,但需要我们先建立一个足够准确的直觉:
text
Q:我现在想找什么?
K:我有什么特征,可以被别人匹配?
V:如果你决定关注我,我提供什么信息?
因此 Attention 可以粗略拆成两步。
第一步:
text
Q × K
↓
计算不同 token 之间应该关注多少
比如某个 token 对其他位置算出的注意力权重是:
text
银行:60%
钱: 30%
其他:10%
第二步,再使用这些权重组合对应的 V:
text
银行的 V × 60%
+
钱的 V × 30%
+
其他 V × 10%
↓
Attention 输出
所以一句话概括:
Q 和 K 负责决定"看谁、看多少",V 负责提供真正被融合的信息。
Attention 的意义也就很直观了:
让一个 token 有选择地吸收其他 token 的上下文信息。
四、Residual 和 FFN:一个负责保留,一个负责加工
Attention 算完以后,并不是直接把原来的 X 扔掉。
直觉上可以理解为:
text
新的表示
=
原来的 X
+
Attention 子层产生的新信息
这就是 Residual Connection 的核心意义。
严格写法会因为 Pre-Norm、Post-Norm 等架构不同而有所区别,但理解时先记住:
模型不是每层重新创造一个表示,而是在已有表示上不断增加新的变化。
Attention 之后还有 FFN。
可以暂时把两者理解成:
text
Attention
= token 和其他 token 交流
FFN
= 对当前 token 自己的表示进一步加工
FFN 内部同样存在大量训练好的权重矩阵。
所以一个模型所谓的几十亿参数,并不只是 Wq/Wk/Wv。
它大致来自:
text
Embedding
+
每一层 Attention 的 Wq/Wk/Wv/Wo
+
每一层 FFN 的权重
+
其他模型参数
而且每一层 Transformer 都有自己独立的一套参数。
五、为什么一定要有 KV Cache?
这是理解大模型推理性能最关键的知识点之一。
假设 Prompt 是:
text
中国 的 首都 是
模型首先要处理整段 Prompt。
这个阶段叫:
Prefill
Prefill 会让这些 token 经过所有 Transformer Layer,并在每一层产生对应的 K 和 V。
这些 K/V 不会立刻丢掉,而会保存起来:
text
KV Cache
接下来开始生成:
text
北
京
...
这个阶段叫:
Decode
Decode 每次只新增少量 token。
新 token 会计算自己的:
text
Q / K / V
然后:
text
新的 Q
↓
和历史 K 做 Attention
↓
得到注意力权重
↓
读取并组合历史 V
所以历史 token 的 K/V 后面还要不断使用。
历史 Q 呢?
它已经完成了自己当时的"查询任务",后续新 token 并不需要重新使用旧 Q。
于是:
text
历史 Q → 通常不需要长期保存
历史 K → 保存
历史 V → 保存
这就是为什么叫:
text
KV Cache
而不是:
text
QKV Cache
六、一个容易搞错的地方:KV Cache 不保存 token 两两关系
假设有 100 个历史 token。
KV Cache 保存的更接近:
text
token1 → K1 + V1
token2 → K2 + V2
token3 → K3 + V3
...
token100 → K100 + V100
也就是大约 100 份 K/V。
不是:
text
100 × 100
份 token 关系。
token 与 token 之间的 Attention Score 是运行 Attention 时根据 Q 和 K 动态计算的。
因此:
text
KV Cache
= 保存 Attention 后面还要复用的原材料
Attention
= 动态计算这些原材料之间的关系
这一区分对后面计算显存非常重要。
七、KV Cache 为什么会成为显存大户?
一个简化但非常实用的公式是:
text
KV Cache
≈
2
× Layers
× Token 数
× KV Heads
× Head Dim
× 每个元素字节数
× 并发序列数
最前面的 2 是:
text
K + V
举一个具体例子:
text
Layers = 32
KV Heads = 8
Head Dim = 128
dtype = BF16 = 2 Byte
一个 token 在一层中的 K/V:
text
2 × 8 × 128 × 2
=
4096 Byte
1024 个 token:
text
4096 × 1024
≈ 4 MB / Layer
32 层:
text
4 MB × 32
≈ 128 MB
也就是说:
text
一个请求
1024 token
≈ 128 MB KV Cache
如果同时有 8 个类似请求:
text
128 MB × 8
≈ 1 GB
如果上下文从 1024 增长到 4096:
text
KV Cache × 4
如果并发同时也从 1 增长到 8:
text
再 × 8
所以部署大模型时有一条非常重要的经验:
上下文长度和并发数不是免费的,它们最终都会变成 KV Cache 显存。
八、GPU 显存到底被谁吃掉了?
做推理部署时,可以先把显存拆成三大块:
text
GPU Memory
│
├── Weight
│
├── KV Cache
│
└── Activation / Runtime
Weight
模型训练完成后的参数:
text
Wq / Wk / Wv / Wo
FFN weights
...
模型加载以后基本固定。
把上下文从 4K 改成 32K,不会让 Weight 变成 8 倍。
KV Cache
请求运行时产生。
它随着:
text
上下文 ↑
并发 ↑
持续增长。
Activation / Runtime
例如:
text
X
Q
Attention 中间结果
FFN 中间结果
临时 Buffer
Kernel / Runtime 开销
这部分也是动态的。
因此以后看到 OOM,不应该只问:
"这个模型多少 B?"
而应该问:
text
Weight 占多少?
+
KV Cache 要多少?
+
Activation / Runtime 还需要多少?
九、量化真正压缩的是什么?
先看模型规模。
B 是 Billion:
text
1B ≈ 10 亿参数
1.5B ≈ 15 亿参数
7B ≈ 70 亿参数
70B ≈ 700 亿参数
再看不同数据类型的理论大小:
text
FP32 = 4 Byte
FP16 = 2 Byte
BF16 = 2 Byte
INT8 = 1 Byte
INT4 = 0.5 Byte
因此一个 7B 模型,如果权重使用 BF16:
text
7 × 10^9 × 2 Byte
≈ 14 GB
如果理论上把权重变成 INT4:
text
7 × 10^9 × 0.5 Byte
≈ 3.5 GB
注意:
参数数量没有变。
还是 70 亿参数。
改变的是:
text
每个参数需要多少 bit 来保存
因此更准确的说法是:
BF16 → INT4 后,理论 Weight 存储约缩小到四分之一。
而不是:
整个 GPU 显存一定缩小四倍。
因为 KV Cache、Activation、Runtime、scale、metadata,以及部分未量化参数都还存在。
十、浮点权重到底怎么变成 INT4?
量化不是简单地把:
text
1.13
直接塞进 INT4。
一个最简化的对称量化思路是引入:
text
scale
例如:
text
原始权重 = 1.13
scale = 0.2
量化:
text
1.13 ÷ 0.2
=
5.65
round(5.65)
=
6
保存整数:
text
6
使用时近似恢复:
text
6 × 0.2
=
1.2
于是:
text
原始:1.13
恢复:1.20
误差:0.07
这里最重要的是:
text
≈
而不是:
text
=
这就是 Quantization Error。
量化本质上是在做:
用更少、更粗的离散刻度去近似原来的高精度浮点值。
省下的是空间,付出的是近似误差。
十一、group_size 为什么会影响精度?
假设一组权重是:
text
0.1
0.2
8.0
10.0
如果全部共享一个 scale,就很难同时照顾非常小和非常大的数值。
于是实际量化会经常进行分组。
例如:
text
group_size = 128
表示:
每 128 个权重共用一组量化参数。
如果有 1024 个权重:
text
1024 ÷ 128
=
8 组
如果:
text
group_size = 64
则:
text
1024 ÷ 64
=
16 组
所以一定不要把 group_size 理解成"有多少组"。
它表示:
每组里面放多少个权重。
通常:
text
group_size ↓
→ 分组数量 ↑
→ scale 数量 ↑
→ 每组描述更精细
→ 量化误差通常 ↓
但代价是:
text
scale / metadata ↑
存储和访存开销 ↑
实现复杂度 ↑
因此量化从来不是单纯追求最小 group,而是在精度、存储和性能之间取平衡。
十二、AWQ:观察 Activation,量化 Weight
普通 INT4 最大的问题之一,是容易把所有权重"一视同仁"。
但现实是:
两个权重即使都产生 0.1 的量化误差,对模型输出造成的影响也可能完全不同。
AWQ 的核心思想可以先压缩成:
text
准备 Calibration 数据
↓
运行原模型
↓
观察 Activation
↓
判断哪些通道 / 权重更重要
↓
量化时重点保护
所以:
text
AWQ
=
Activation-aware Weight Quantization
最容易误解的一点是:
Activation-aware 不代表把 Activation 量化成 INT4。
例如常见的:
text
W4A16
表示:
text
Weight → 4 bit
Activation → 16 bit
AWQ 是:
观察 Activation,帮助自己更聪明地量化 Weight。
因此 Calibration 数据也不能随便选。
如果未来模型主要用于中文聊天,却全部拿 Python 代码做 calibration,那么观察到的 Activation 分布可能并不能很好代表真实业务。
原则很简单:
Calibration 数据应该尽量接近模型未来真正面对的数据分布。
十三、GPTQ:关注量化以后"输出错了多少"
GPTQ 可以从另一个角度理解。
普通量化容易只关心:
text
原权重 = 1.20
量化后 = 1.18
误差只有 0.02
看起来似乎无所谓。
但如果这 0.02 最终导致某一层输出:
text
10.0 → 7.0
那它显然不能被认为"不重要"。
所以 GPTQ 更关注:
权重量化以后,对这一层输出到底造成了多大影响。
它利用近似二阶信息,在量化过程中决定如何量化并补偿误差,目标是尽可能降低层输出误差。
第一阶段其实不用急着钻 Hessian。
先把 AWQ 和 GPTQ 分成:
text
AWQ:
看 Activation
→ 找重要部分
→ 提前重点保护
GPTQ:
看量化对层输出造成的影响
→ 在量化过程中降低 / 补偿误差
这个理解已经足够支撑后续工程实践。
十四、TP 和 PP:多张 GPU 到底怎么一起跑模型?
当模型或者单层矩阵在一张 GPU 上放不下,就需要分布式。
最先要分清:
text
TP:切一层内部
PP:切 Transformer Layer
Tensor Parallel
假设:
text
X = [1024, 4096]
W = [4096, 8192]
单卡计算:
text
X × W
=
[1024, 8192]
如果 TP=4,可以沿某个维度把 W 切成:
text
GPU0:[4096, 2048]
GPU1:[4096, 2048]
GPU2:[4096, 2048]
GPU3:[4096, 2048]
每张卡分别计算:
text
[1024,4096]
×
[4096,2048]
=
[1024,2048]
四份结果再根据并行方式组合。
因此 TP 不只是:
把权重分开放。
而是:
把同一层的权重和对应计算一起切给多张 GPU。
继续深入以后,还要理解 Column Parallel、Row Parallel、AllReduce、AllGather,以及 Activation 在不同阶段是完整还是分片状态。
但 TP 的核心起点就是这一句:
切同一层的大矩阵。
Pipeline Parallel
PP 更直观。
例如 32 层 Transformer:
text
GPU0:
Layer 1 ~ 16
GPU1:
Layer 17 ~ 32
数据跑完 GPU0 的前半段,再把 Activation 交给 GPU1 继续计算。
所以:
text
TP → 横着切一层
PP → 纵着切模型层
十五、到这里再看 vLLM,很多参数终于有意义了
例如:
text
--tensor-parallel-size
--max-model-len
--max-num-seqs
--gpu-memory-utilization
它们已经不是几个孤立参数。
可以直接映射:
text
tensor-parallel-size
→ 一层模型计算如何分到多张 GPU
max-model-len
→ 单条序列允许达到的最大长度
→ 影响潜在 KV Cache 需求
max-num-seqs
→ 单轮可处理 sequence 数量的重要上限之一
gpu-memory-utilization
→ vLLM 可用于模型执行和 KV Cache 等用途的显存预算相关配置
而 vLLM 真正有意思的地方,是它如何高效管理大量请求。
当前官方文档仍将 PagedAttention、Continuous Batching、Chunked Prefill 等列为 vLLM 的核心能力。
十六、Paged KV Cache:不是把 KV 变小,而是把显存管得更好
假设:
text
max_model_len = 8192
某个请求实际只用了 1200 token。
如果一开始就必须给它申请完整、连续的 8192-token KV 空间,会造成大量浪费。
Paged KV Cache 的思路类似虚拟内存分页:
text
KV Cache
↓
拆成固定大小的 Block
一个请求逻辑上可能是:
text
逻辑 Block 0
逻辑 Block 1
逻辑 Block 2
但实际映射到:
text
物理 Block 7
物理 Block 19
物理 Block 25
所以:
逻辑连续,但物理显存不要求连续。
当前 vLLM 的 Paged Attention 设计依旧以分块的 paged KV cache 为基础,K/V Cache 被组织在独立的 block 中。
因此一个请求继续生成时:
text
最后一个 Block 写满
↓
再申请新的空闲 Block
↓
更新映射
↓
继续写 K/V
不需要搬动前面所有 KV 去重新寻找一整块连续空间。
Paged KV Cache 解决的核心问题是:
KV Cache 的显存分配与碎片管理。
不是量化。
十七、Continuous Batching:Batch 不再是一批人一起上、一起下
传统固定 Batch 很容易出现:
text
A:还要生成 100 token
B:已经结束
C:还要生成 50 token
但新请求 D 还要等整个 Batch 结束。
Continuous Batching 的思路是:
text
B 结束
↓
位置释放
↓
D 可以进入后续调度
因此 Batch 的组成会随着推理轮次不断改变。
官方文档也明确将 Continuous Batching 作为提高 GPU 利用率的重要机制。
可以把它理解成:
Batch 是动态流动的,不是一辆发车以后必须所有乘客同时到终点的大巴。
十八、Scheduler:真正决定"这一轮谁上 GPU"
Paged KV Cache 管:
text
KV 显存怎么分
Continuous Batching 管:
text
请求怎么动态进出
而 Scheduler 管:
text
这一轮到底让谁计算
当前 vLLM 的 Engine Core 会持续运行调度循环,同时管理 KV Cache,并把工作下发给 GPU Worker。
调度时不仅有"多少个请求"的概念,还有:
text
Token Budget
例如:
text
max_num_batched_tokens = 2000
表示一轮能够处理的 token 数量存在预算约束。
同时:
text
max_num_seqs
限制单轮最多处理多少条 sequence。当前官方 Scheduler 配置仍明确区分这两个维度。
可以理解成:
text
max_num_seqs
= 人数上限
max_num_batched_tokens
= 本轮总工作量上限
十九、Chunked Prefill:为什么长 Prompt 不一定一次吃完?
假设:
text
token_budget = 2000
当前三个 Decode 请求分别需要:
text
A = 1 token
B = 1 token
C = 1 token
那么还剩:
text
2000 - 3
=
1997
这时来了一个:
text
5000-token Prompt
它不一定非要一口气处理完 5000 token。
可以:
text
本轮 Prefill 1997
下一轮继续 Prefill
...
这就是 Chunked Prefill。
当前 vLLM 的 Scheduler 配置明确支持根据剩余 max_num_batched_tokens 将 Prefill 分块。
它的意义不仅是"Prompt 太长,预算放不下"。
更重要的是:
避免一个巨大的 Prefill 长时间霸占 GPU,让正在 Decode 的请求迟迟拿不到下一个 token。
vLLM 官方调优文档也明确说明,调整 max_num_batched_tokens 会在 TTFT、ITL 和吞吐之间产生权衡。
二十、为什么 Prefill 和 Decode 的性能特征不一样?
这是理解推理性能的另一个关键点。
Prefill
例如:
text
X = [1000, H]
同一份 Weight 可以一次服务大量 token:
text
1000 个 token
×
同一套 W
因此 GPU 可以做大量并行矩阵计算。
Prefill 通常更容易表现出明显的计算密集特征。
Decode
Decode 时可能只有:
text
X = [1, H]
当前只新增一个 token。
但是它仍然需要:
text
读取大量模型 Weight
+
读取历史 KV Cache
然后才生成这一个新 token。
所以可以粗略记成:
text
Prefill:
搬数据以后可以做很多计算
Decode:
为了算少量新 token,
仍然需要读取大量 Weight 和历史 KV
因此 Decode 往往更容易受到显存带宽影响。
这也解释了为什么多个 Decode 请求组成 Batch 后,总吞吐可能反而提高。
例如 32 个 Decode 请求:
text
A → 1 token
B → 1 token
...
32 个请求
可以共同组成:
text
X ≈ [32, H]
同一套模型 Weight 可以在这一轮同时服务更多 token。
所以:
text
并发提高
→ Batch 变大
→ Weight 读取成本被更多 token 分摊
→ GPU 利用率提高
→ 总吞吐通常提高
但要注意:
总吞吐提高,不等于单个请求一定更快。
并发继续升高以后,排队、资源竞争和长上下文 Attention 成本都会开始影响单请求延迟。
二十一、不同请求可以 Batch,但不能乱用彼此的 KV Cache
假设:
text
A:
"北京天气怎么样?"
B:
"帮我写一个 Python 函数"
两条请求可以:
text
一起组成 Decode Batch
因为它们共同使用同一套模型 Weight。
但它们不能共享彼此的上下文:
text
A 当前 Q → A 自己的历史 KV
B 当前 Q → B 自己的历史 KV
所以真正共享的是:
text
模型 Weight
+
批量计算机会
而不是:
text
请求上下文
一句话:
Batching 合并的是计算,不是把不同用户的语义上下文混在一起。
二十二、为什么同样 Decode 一个 token,8K 上下文比 500 token 更贵?
假设:
text
A 历史长度 = 8000
B 历史长度 = 500
这一轮都只生成 1 个新 token。
两者当前 token 都需要经过模型权重。
但 Attention 不一样:
text
A:
新的 Q
↓
匹配约 8000 个历史 K
↓
读取并组合约 8000 个历史 V
B:
text
新的 Q
↓
匹配约 500 个历史 K
↓
读取并组合约 500 个历史 V
因此即使都只叫:
text
Decode 1 token
它们实际计算成本也不是完全一样的。
这也是为什么:
长上下文影响的不只是 KV Cache 容量,也会增加 Decode 阶段 Attention 对历史 KV 的读取和计算成本。
二十三、把整条推理链重新串一次
现在可以得到一张非常有用的脑图:
text
用户文本
↓
Tokenizer
↓
Token IDs
↓
Embedding
↓
X [B,S,H]
↓
┌───────────────────────────────┐
│ Transformer Layer │
│ │
│ X │
│ ├─ ×Wq → Q │
│ ├─ ×Wk → K ──→ KV Cache │
│ └─ ×Wv → V ──→ KV Cache │
│ │
│ Q × K │
│ ↓ │
│ Attention Weight │
│ ↓ │
│ 加权组合 V │
│ ↓ │
│ Attention Output │
│ ↓ │
│ Residual │
│ ↓ │
│ FFN │
│ ↓ │
│ Residual │
└───────────────────────────────┘
× N Layers
↓
Next Token
模型部署到 GPU 后:
text
显存
│
├── Weight
│
├── KV Cache
└── Activation / Runtime
如果显存不够:
text
Weight 太大
→ 可以考虑量化 / TP / PP
KV Cache 太大
→ 看上下文、并发、KV dtype、调度策略
Activation / Runtime 太大
→ 看 Prefill、Batch、执行引擎等
如果做量化:
text
BF16 Weight
↓
INT8 / INT4
↓
Weight 显存下降
再继续考虑:
text
scale
group_size
quantization error
AWQ
GPTQ
如果进入多卡:
text
TP
→ 切一层内部的大矩阵和计算
PP
→ 切 Transformer Layer
最后进入 vLLM:
text
Paged KV Cache
→ 管 KV 显存
Continuous Batching
→ 动态组织请求
Scheduler
→ 决定本轮谁运行
Chunked Prefill
→ 控制长 Prompt 如何分轮处理
至此,大模型推理部署里最常见的几个概念就真正连到了一起。
最后:不要把量化理解成一个孤立工具
回头看会发现:
text
AWQ
GPTQ
TP
PP
KV Cache
PagedAttention
Continuous Batching
看起来是七八个不同概念,但它们实际上都在回答一个问题:
如何让一个已经训练好的大模型,更高效地在有限 GPU 资源上完成推理?
量化解决的是:
text
Weight 太大
KV Cache 管理解决的是:
text
请求上下文太占显存
TP / PP 解决的是:
text
单卡资源不够
Batching 和 Scheduler 解决的是:
text
GPU 怎么同时服务更多请求
真正理解这些底层关系以后,再去看:
text
--tensor-parallel-size
--max-model-len
--max-num-seqs
--max-num-batched-tokens
--gpu-memory-utilization
就不会再把它们当作"网上别人这么配,所以我也这么配"的神秘参数。
下一阶段真正值得继续深入的,是三个方向:
text
① TP 的 Column/Row Parallel 与通信
② vLLM Scheduler / KV Cache Manager 的源码实现
③ AWQ/GPTQ 实际量化 + Benchmark
最后用真实模型做一次完整闭环:
text
原始 BF16 模型
↓
记录显存和性能 Baseline
↓
AWQ / GPTQ
↓
INT4 模型
↓
vLLM 部署
↓
Benchmark
↓
对比:
显存
吞吐
TTFT
TPOT / ITL
精度
最大并发
做到这里,模型压缩就不再是"会跑一个脚本"。
而是已经能够解释:
它为什么省显存、为什么可能掉精度、为什么吞吐可能变化、为什么上下文和并发会影响容量,以及这些变化最终如何映射到实际部署配置。
这才是我认为真正有价值的"大模型压缩与推理部署基础"。