前十二篇里,我们一直在讨论如何把模型权重压得更小:GPTQ、AWQ、GGUF、bitsandbytes、EXL3、FP8,解决的主要是“模型加载时,几十亿参数到底占多少显存”。

但很多人第一次真正跑长上下文时,会遇到一个看似反常的现象:

模型明明已经量化到 4 bit,加载后还剩十几 GB 显存;
为什么输入一篇长文,或者并发一高,还是突然 OOM?

原因通常不是权重又变大了,而是推理过程中出现了另一块会不断增长的“动态显存”:KV Cache

权重大小基本固定,而 KV Cache 会随着上下文长度、输出长度、batch、并发会话和 beam 数量持续增长。短对话里它可能只是几百 MB;到了 32K、128K 上下文或高并发服务中,它完全可能比量化后的权重还大。

这一篇,我们从注意力公式开始,手算 KV Cache 每个 token 到底占多少字节,再完整拆解 FP8、INT8、INT4、INT2 KV Cache 的原理与代价。实战部分会覆盖 Hugging Face Transformers 的 QuantizedCache、vLLM 的 FP8 KV Cache、数据集校准、OpenAI 兼容服务和长上下文基准测试。


目录

  • 一、最常见的现场:模型能加载,长 Prompt 却 OOM
  • 二、KV Cache 到底缓存了什么
  • 三、不用 KV Cache 会怎样:为什么它几乎不可或缺
  • 四、手撕显存公式:一个 token 的 KV Cache 有多大
  • 五、MHA、GQA、MQA 为什么能让显存相差数倍
  • 六、上下文、batch、beam 与并发如何一起放大 KV Cache
  • 七、权重量化和 KV Cache 量化不是同一件事
  • 八、为什么 KV Cache 比静态权重更难量化
  • 九、KIVI 的关键发现:Key 和 Value 不该用同一种粒度
  • 十、KV Cache 量化的数学:scale、分组与残差窗口
  • 十一、FP8、INT8、INT4、INT2 KV Cache 怎么选
  • 十二、别把所有优化都叫量化:GQA、MLA、滑动窗口与剪枝
  • 十三、PagedAttention、Static Cache、Offload 和 Prefix Cache 各解决什么
  • 十四、实战一:用脚本估算任意 Hugging Face 模型的 KV Cache
  • 十五、环境准备与版本自检
  • 十六、实战二:Transformers 一行开启 INT4 QuantizedCache
  • 十七、严谨测量 Transformers 的显存和延迟
  • 十八、实战三:vLLM 开启 FP8 KV Cache
  • 十九、实战四:用真实数据校准 K/V scale
  • 二十、启动 OpenAI 兼容服务并做长上下文压测
  • 二十一、TensorRT-LLM 的 FP8 与 NVFP4 KV Cache
  • 二十二、如何评估长上下文质量有没有掉
  • 二十三、常见报错与避坑
  • 二十四、一套可以直接照抄的选型流程
  • 二十五、小结

一、最常见的现场:模型能加载,长 Prompt 却 OOM

先看一个非常典型的场景。

你有一张 24GB 显卡,把一个 7B 模型量化到了 4 bit。模型权重加载后可能只占 5GB 左右,加上 CUDA 和框架开销,nvidia-smi 仍显示还有十几 GB 空闲。

你于是认为:

既然还剩这么多显存,跑 32K 或 64K 上下文应该很轻松。

但实际运行时,可能出现以下情况:

  • 2K Prompt 正常;
  • 8K Prompt 正常;
  • 32K Prompt 开始接近显存上限;
  • 同时跑 4 个请求直接 OOM;
  • 开启 beam search 后,显存突然翻倍甚至更多;
  • 服务端空闲时显存看起来很多,一上并发就频繁抢占和重算。

这不是量化失效,而是你只算了静态权重,没有算动态缓存。

推理显存可以粗略拆成:

总显存
≈ 权重
+ KV Cache
+ 激活和临时工作区
+ CUDA / 框架开销
+ 显存碎片

其中:

权重:加载后基本固定
KV Cache:随 token 和并发持续增长

权重量化解决的是第一项。

KV Cache 量化解决的是第二项。

1.1 为什么短 Prompt 时不明显

KV Cache 和序列长度近似线性关系。

假设某个模型的 BF16 KV Cache 每个 token 占 128KiB:

2K token  ≈ 256MiB
8K token  ≈ 1GiB
32K token ≈ 4GiB
128K token≈ 16GiB

2K 时,几百 MB 很容易被权重和 CUDA 开销掩盖。

128K 时,单个请求就可能吃掉 16GiB。再来两个并发请求,KV Cache 理论需求就变成 32GiB。

这就是为什么:

同一个模型,在短聊天场景中是权重主导,在长上下文和高并发场景中可能转为 KV Cache 主导。

1.2 “支持 128K 上下文”不等于你的显卡能跑 128K

模型配置里的 max_position_embeddings=131072,只表示模型设计或训练时允许处理这么长的序列。

它不保证:

  • 你的显存能装下对应 KV Cache;
  • 当前推理引擎能高效处理;
  • 该长度下模型质量仍然稳定;
  • 该长度下还能维持可接受的首 token 延迟;
  • 多个 128K 请求能够并发。

上下文上限是模型能力边界之一,不是硬件容量承诺。


二、KV Cache 到底缓存了什么

要理解 KV Cache,先回到自注意力。

在 Transformer 的某一层中,输入隐藏状态 X 会分别乘上三组投影矩阵:

Q = XW_Q
K = XW_K
V = XW_V

然后计算:

Attention(Q, K, V)
= softmax(QKᵀ / √d) V

其中:

  • Q 是 Query,表示“当前 token 想找什么”;
  • K 是 Key,表示“历史 token 能被怎样匹配”;
  • V 是 Value,表示“匹配后真正取回什么信息”。

2.1 自回归生成一次只增加一个 token

假设 Prompt 已经有 4 个 token:

A B C D

模型生成第 5 个 token E 时,需要让 E 的 Query 去关注:

A、B、C、D、E 对应的 Key 和 Value

接下来生成 F 时,又需要:

A、B、C、D、E、F 对应的 Key 和 Value

关键点在于:

A、B、C、D 的 K 和 V 并没有变化。

如果每生成一个 token,都重新计算所有历史 token 的 K/V,会产生大量重复工作。

因此,推理引擎会把历史 token 在每一层产生的 K 和 V 保存下来:

第 1 层:K₁[历史 token],V₁[历史 token]
第 2 层:K₂[历史 token],V₂[历史 token]
...
第 L 层:K_L[历史 token],V_L[历史 token]

这就是 KV Cache。

2.2 KV Cache 不是一份文本副本

KV Cache 里保存的不是原始 token ID,也不是自然语言文本。

它保存的是每一层注意力投影后的高维张量。

常见形状可以写成:

[batch, num_kv_heads, sequence_length, head_dim]

而且 Key 和 Value 各有一份。

所以一个 token 虽然在输入中只是一个整数 ID,但经过几十层注意力后,需要在每一层都留下 K/V 状态。

这就是它看起来“只是多几个 token”,实际却能增长到数十 GB 的原因。

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

Query 的作用对象是“当前正在计算的 token”。

生成下一个 token 时,会产生新的 Query,旧 Query 通常不再需要。

而历史 Key 和 Value 会被之后每一个新 token 反复读取,所以值得缓存。

因此常见缓存是:

缓存 K
缓存 V
不长期缓存 Q

某些特殊内核或 FP8 路径可能会临时量化 Query,但那不改变 KV Cache 的基本定义。


三、不用 KV Cache 会怎样:为什么它几乎不可或缺

KV Cache 是典型的:

用显存换计算

不开缓存,确实能减少持久显存,但生成会变得非常慢。

3.1 没有缓存时的重复计算

假设已有 10,000 个 token,现在要生成第 10,001 个 token。

如果没有 KV Cache,模型需要重新对前面的 10,000 个 token 做各层投影,重新得到它们的 K 和 V。

生成第 10,002 个 token 时,又要对前面的 10,001 个 token重复一遍。

随着生成继续,重复计算越来越多。

3.2 有缓存后,每步只新增一份 K/V

有 KV Cache 时:

历史 token 的 K/V:直接读取缓存
新 token 的 K/V:只计算一次并追加到缓存

注意力仍然要让新 Query 和全部历史 Key 做匹配,所以每一步读取的数据量仍会随上下文增长。

但最昂贵的历史 K/V 投影不再反复计算。

因此,更准确的说法是:

KV Cache 消除了历史 token 的重复 K/V 计算,但并没有让长上下文注意力变成常数成本。

上下文越长:

  • Cache 越大;
  • 每个新 token 要读取的 Key/Value 越多;
  • Decode 阶段的显存带宽压力越大;
  • 单 token 延迟通常也会增长。

3.3 为什么通常不建议为了省显存直接关闭 use_cache

在 Hugging Face Transformers 中,可以设置:

use_cache=False

它在训练、调试或某些特殊任务中有意义,但对常规自回归推理通常不是一个好的“省显存方案”。

你省下了 KV Cache,却要反复重算整个历史序列。

真正更实用的方向通常是:

量化 KV Cache
限制注意力窗口
使用 GQA / MQA / MLA 架构
分页管理 KV Cache
把冷缓存 offload 到 CPU
复用相同前缀

四、手撕显存公式:一个 token 的 KV Cache 有多大

对于常见的 decoder-only Transformer,每层 Key 和 Value 的形状大致是:

K: [B, H_kv, S, D]
V: [B, H_kv, S, D]

其中:

  • B:batch size,或同一时刻保留的有效序列数量;
  • H_kv:Key/Value Head 数量;
  • S:缓存 token 数;
  • D:每个 Head 的维度;
  • L:Transformer 层数;
  • b:每个缓存元素占用的字节数。

因为 K 和 V 各有一份,所以理论大小是:

KV Cache 字节数
= 2 × L × B × S × H_kv × D × b

最前面的 2 就是:

1 份 Key + 1 份 Value

4.1 每个 token 的 KV Cache

S=1,就能得到单 token 成本:

每 token KV 字节数
= 2 × L × B × H_kv × D × b

这条公式非常重要,因为之后所有长度和并发都只是乘法。

4.2 一个标准 MHA 例子

假设模型参数如下:

层数 L = 32
KV Head 数 H_kv = 32
Head Dim D = 128
缓存精度 BF16 = 2 Byte
batch = 1

单 token:

2 × 32 × 1 × 32 × 128 × 2
= 524,288 Byte
= 512 KiB

也就是说,每多一个 token,整个模型所有层的 KV Cache 合计增加约 512KiB。

那么:

缓存长度BF16 KV Cache
4,096 token约 2GiB
8,192 token约 4GiB
32,768 token约 16GiB
131,072 token约 64GiB

这还只是 batch=1。

128K 上下文时,仅 KV Cache 就约 64GiB,远大于多数消费级显卡。

4.3 换成 FP8、INT8 和 INT4 会怎样

若忽略 scale、zero-point、对齐和残差窗口等额外开销:

BF16 / FP16:2 Byte/元素
FP8 / INT8:1 Byte/元素
INT4:0.5 Byte/元素
INT2:0.25 Byte/元素

刚才 128K 的 64GiB 理论上会变成:

KV 精度理论大小
BF16 / FP1664GiB
FP8 / INT832GiB
INT416GiB
INT28GiB

但真实实现通常会更大,因为还要保存:

  • scale;
  • zero-point;
  • 分组元数据;
  • 对齐与打包空间;
  • 最近 token 的高精度残差缓存;
  • 内核工作区。

所以这些数字是理论下限,不是运行时承诺。

4.4 更准确的混合层公式

现代模型可能同时包含:

  • Full Attention 层;
  • Sliding Window Attention 层;
  • 不同 KV Head 数量的层;
  • 局部注意力与全局注意力混合;
  • Mamba / SSM 层;
  • MLA 层。

这时不能简单用一组统一参数相乘。

更一般的公式是:

总 KV 字节数
= 2 × B × b × Σ_l (S_l × H_kv,l × D_l)

其中每一层可以有自己的:

  • 缓存长度 S_l
  • KV Head 数 H_kv,l
  • Head Dim D_l

例如滑动窗口层最多只保留 4096 个 token,而全局注意力层保留完整 128K,那么两类层的缓存长度就不同。

4.5 Tensor Parallel 下为什么不能盲目除以卡数

多卡 Tensor Parallel 通常会把注意力 Head 分到不同 GPU 上。

在理想情况下,每张卡只保存自己负责的 KV Head,因此单卡 KV Cache 可能近似除以 TP 数量。

但实际还要考虑:

  • KV Head 数是否能被 TP 整除;
  • GQA 模型的 KV Head 本来就很少;
  • 某些后端会复制 KV Head,而不是继续切分;
  • Pipeline Parallel 只让每张卡保存部分层;
  • Context Parallel 会改变序列维度的分片方式;
  • 不同引擎的缓存布局不同。

因此,单卡估算不能机械写成:

总 KV / GPU 数

最可靠的方法是先用理论公式做上界,再查看具体推理引擎的分片规则和启动日志。


五、MHA、GQA、MQA 为什么能让显存相差数倍

上一节公式中,影响 KV Cache 最大的架构参数之一是:

H_kv:Key/Value Head 数量

Query Head 数不一定等于 KV Head 数。

这就引出了 MHA、GQA 和 MQA。

5.1 MHA:每个 Query Head 都有独立 K/V Head

标准 Multi-Head Attention 中:

num_query_heads = num_kv_heads

例如:

32 个 Query Head
32 个 Key Head
32 个 Value Head

优点是表达能力充分,缺点是 KV Cache 最大。

5.2 MQA:所有 Query Head 共用一组 K/V

Multi-Query Attention 中:

num_query_heads = 32
num_kv_heads = 1

所有 Query Head 共享同一组 Key 和 Value。

与 32-Head MHA 相比,KV Cache 理论上缩小约 32 倍。

MQA 的出发点就是降低增量解码时反复读取 K/V 的显存带宽成本。1

5.3 GQA:多个 Query Head 共享一组 K/V

Grouped-Query Attention 位于 MHA 和 MQA 之间。

例如:

32 个 Query Head
8 个 KV Head

每 4 个 Query Head 共享一组 K/V。

这样:

  • KV Cache 比 MHA 小 4 倍;
  • 通常比极端 MQA 保留更多表达能力;
  • Decode 时需要读取的 K/V 也更少。

GQA 论文把 MQA 看作 1 个 KV 组,把 MHA 看作每个 Query Head 各自一组,GQA 则使用中间数量的组。2

5.4 用数字对比 MHA 与 GQA

假设两种模型都有:

32 层
32 个 Query Head
Head Dim = 128
BF16 KV Cache
batch = 1

区别只有 KV Head 数:

MHA:H_kv = 32
GQA:H_kv = 8

MHA 每 token

2 × 32 × 32 × 128 × 2
= 512 KiB

GQA 每 token

2 × 32 × 8 × 128 × 2
= 128 KiB

32K 上下文:

注意力类型BF16 KV Cache
MHA,32 KV Heads约 16GiB
GQA,8 KV Heads约 4GiB
MQA,1 KV Head约 0.5GiB

这就是为什么模型参数量相近,上下文显存却可能差数倍甚至数十倍。

比较模型长上下文成本时,不能只看参数量,还必须看 num_key_value_heads

5.5 GQA 与 KV 量化可以叠加

GQA 是架构级压缩:

减少要缓存的向量数量

KV 量化是数值级压缩:

减少每个缓存元素的位数

二者可以相乘。

例如上面的 GQA 32K BF16 KV Cache 约 4GiB:

FP8 理论约 2GiB
INT4 理论约 1GiB

所以最省缓存的模型,往往不是单独依靠某一种技术,而是:

GQA / MQA / MLA
+
低精度 KV Cache
+
高效缓存管理

六、上下文、batch、beam 与并发如何一起放大 KV Cache

KV Cache 的危险之处不是某一个变量,而是多个变量会相乘。

公式再看一次:

KV Cache
= 2 × L × B × S × H_kv × D × b

其中最容易在运行时变化的是:

B:有效序列数量
S:每条序列的缓存长度

6.1 Prompt 和输出 token 都要进入 Cache

假设:

Prompt = 24K token
输出 = 8K token

最终缓存长度不是 24K,而接近:

24K + 8K = 32K

因此只配置 max_new_tokens=8192 而不考虑输入长度,很容易低估峰值。

服务端通常要满足:

prompt_tokens + generated_tokens <= max_model_len

而 KV Cache 要按请求实际走到的总长度增长。

6.2 batch 与并发近似线性增长

单请求 32K KV Cache 为 4GiB 时:

1 个请求 ≈ 4GiB
4 个请求 ≈ 16GiB
8 个请求 ≈ 32GiB

真实服务端使用 continuous batching,请求长度不完全一致,缓存块也会动态分配,但总量仍取决于同时驻留的 token 数。

因此生产服务更应该关注:

总驻留 token 数

而不只是“并发请求个数”。

8 个 2K 请求和 8 个 64K 请求,完全不是同一个压力等级。

6.3 beam search 会扩大有效 batch

Beam Search 会同时保留多个候选序列。

例如:

num_beams=4

在许多实现中,KV Cache 需要随 beam 展开、复制或重排。

因此有效序列数可能近似从:

batch

变为:

batch × num_beams

这也是为什么同一个模型,greedy decoding 能跑,beam search 却突然 OOM。

6.4 多轮对话为什么越聊越慢、越吃显存

如果每一轮都保留完整历史:

第一轮:2K
第二轮:4K
第三轮:8K
...

KV Cache 会随对话历史变长。

若系统把会话 cache 长期驻留在 GPU,还会影响其他请求的可用缓存容量。

实际服务通常需要设计:

  • 会话超时;
  • 缓存淘汰;
  • CPU / 外部存储 offload;
  • 前缀复用;
  • 历史摘要;
  • 最大上下文策略。

6.5 并发上限不是静态数字

同一张 GPU 的“最大并发”取决于:

  • 平均 Prompt 长度;
  • 平均输出长度;
  • 长尾请求比例;
  • KV dtype;
  • 是否启用 prefix caching;
  • 请求是否共享前缀;
  • 是否允许抢占与重算;
  • SLA 对 TTFT 和 ITL 的要求。

因此不要只发布一句:

这张卡能并发 64 路。

更有意义的说法是:

在输入 4K、输出 512、请求率 X、P99 延迟 Y 的条件下,
该配置可稳定承载 Z 并发。

七、权重量化和 KV Cache 量化不是同一件事

这是本篇最需要反复强调的概念。

7.1 权重是静态的

模型权重在推理前已经确定:

W_Q、W_K、W_V、MLP 权重……

它们可以在部署前:

  • 扫描完整分布;
  • 使用校准数据;
  • 搜索 clipping;
  • 做 GPTQ 的误差补偿;
  • 做 AWQ 的通道缩放;
  • 离线打包成固定格式。

量化一次后,可以重复服务无数请求。

7.2 KV Cache 是动态激活

KV Cache 由用户输入实时产生:

同一个模型
不同 Prompt
→ 完全不同的 K/V 分布

因此它具有几个难点:

  • 无法提前知道所有输入;
  • 每生成一个 token 都要写入新 K/V;
  • attention 每一步都要读取并解量化历史缓存;
  • scale 可能需要在线计算或离线校准;
  • 量化/反量化开销会直接进入 token 延迟;
  • 误差会影响后续所有 token 的注意力。

7.3 权重 4 bit,不代表 KV Cache 也是 4 bit

一个典型组合可能是:

权重:AWQ INT4
激活:FP16
KV Cache:FP16

此时模型文件很小,但 KV Cache 完全没有压缩。

也可能是:

权重:FP8
激活:FP8
KV Cache:BF16

或者:

权重:INT4
激活:FP16
KV Cache:FP8

因此部署配置应该分别记录:

Weight dtype
Activation / compute dtype
KV Cache dtype
Accumulation dtype

只说“这是一个 4-bit 模型”,信息远远不够。

7.4 KV Cache 量化不会缩小模型文件

KV Cache 不在预训练 checkpoint 中。

它是请求运行时产生的数据。

所以开启 KV Cache 量化后:

  • 模型下载文件通常不会变小;
  • 权重加载显存通常不会直接变化;
  • 长上下文容量会增加;
  • 高并发可驻留 token 数会增加;
  • 实际 nvidia-smi 不一定显示更低。

最后一点在 vLLM 中尤其重要:服务引擎常常会把可用显存预分配给 KV Cache。

当 KV 元素变小后,框架可能不是少占显存,而是在同样的显存预算里创建更多 Cache Block。

所以你看到的可能是:

BF16 KV:占 90% 显存,可缓存 100K token
FP8 KV:仍占 90% 显存,可缓存 200K token

这时仅看 nvidia-smi 会误以为“FP8 没省显存”,实际提升的是容量。


八、为什么 KV Cache 比静态权重更难量化

如果只是把每个 K/V 张量找最大值,然后压到 INT4,为什么还需要 KIVI、KVQuant、AQUA-KV、TurboQuant 等大量研究?

因为注意力对误差的放大方式很特殊。

8.1 Key 的误差会先进入点积,再经过 Softmax

注意力分数是:

score_i = q · k_i / √d

如果 Key 被量化为:

k̂_i = k_i + ε_i

那么分数误差是:

Δscore_i = q · ε_i / √d

即使 ε_i 的均方误差不大,只要它和 Query 某些方向对齐,就可能显著改变分数。

随后 Softmax 会把分数转换成概率:

p_i = exp(score_i) / Σ_j exp(score_j)

Softmax 不是线性函数。

当两个 token 的分数接近时,一个很小的误差就可能改变谁获得最大注意力。

所以:

Key 量化不是只看重构 MSE,还要关注内积和排序是否稳定。

8.2 Value 的误差直接进入加权求和

注意力输出是:

o = Σ_i p_i v_i

Value 的误差会变成:

ô - o = Σ_i p_i ε_i

它不会先经过 Softmax,但会被注意力权重加权后直接注入输出。

如果某个 Value 对应的 token 获得很大注意力,它的量化误差就更重要。

8.3 分布会随层、Head、token 和输入变化

KV Cache 不是一个稳定的静态矩阵。

它可能在以下维度上差异很大:

  • 不同层;
  • 不同 KV Head;
  • 不同通道;
  • 不同 token;
  • 不同语言;
  • 不同任务;
  • Prefill 与 Decode;
  • 普通文本与代码、数学、结构化数据。

一个全局 scale 很容易被少量 outlier 撑大,导致绝大多数值挤在少数刻度中。

8.4 量化发生在在线关键路径上

权重可以离线量化几个小时,推理时直接读取。

KV Cache 每个新 token 都要:

生成 K/V
→ 计算 scale 或读取固定 scale
→ 量化
→ 打包写入 Cache

读取历史时又要:

读取低比特缓存
→ 解包 / 反量化
→ 参与注意力

如果内核没有融合这些步骤,额外开销可能抵消带宽收益。

因此:

压得更小
≠ 一定更快

低比特 KV Cache 只有在算法、缓存布局和注意力 Kernel 协同优化时,才更可能同时省显存和提速。


九、KIVI 的关键发现:Key 和 Value 不该用同一种粒度

KIVI 是理解 KV Cache 低比特量化非常重要的一篇工作。3

它没有简单地把 K 和 V 一视同仁,而是先分析它们的分布特征。

9.1 Key 的 outlier 更偏向固定通道

KIVI 的观察可以用直觉化语言概括:

Key 中某些通道,会跨多个 token 持续表现出较大幅值。

如果按 token 单独量化 Key,每个 token 的少量通道 outlier 都会撑大该 token 的范围。

更合适的策略是:

Key:per-channel 量化

也就是让同一通道沿 token 维度共享量化参数,更好地隔离通道级 outlier。

9.2 Value 的 outlier 更偏向 token 变化

Value 的分布更适合:

Value:per-token 量化

每个 token 独立选择 scale,可以更贴合该 token 的 Value 向量范围。

于是 KIVI 的经典非对称设计是:

Key:per-channel
Value:per-token

这里的“非对称”不是仅指有无 zero-point,而是 K 和 V 使用不同的量化方向与策略。

9.3 为什么还要保留一段高精度 Residual Cache

自回归生成是流式的。

新 token 到来时,某些 per-channel 统计需要沿 token 维度组织。如果每来一个 token 就重打包整个历史 Cache,成本会非常高。

KIVI 使用一个高精度残差缓冲区:

较旧 token:低比特量化缓存
最近若干 token:FP16 / BF16 残差缓存

当残差缓冲区达到一定长度,再批量量化并合并到旧缓存。

这样做有三个好处:

  • 减少每 token 重新量化大块数据的频率;
  • 最近 token 保持高精度;
  • 适合流式追加。

代价是实际压缩率不会严格等于理论位宽比例。

9.4 Transformers 的实现与论文并不完全相同

Hugging Face Transformers 的 QuantizedCache 明确说明其实现受 KIVI 启发,但当前通用实现会对 Key 和 Value 都使用按配置轴和 q_group_size 的分组量化,而不是完全复现论文中“Key per-channel、Value per-token”的原始设计。它同样保留一个 residual_length 高精度缓存。4

这提醒我们:

论文算法名、框架类名和生产 Kernel 之间不能直接画等号。

评估时必须看实际代码路径、量化轴、分组大小和后端。

9.5 KIVI 的结果应该怎样理解

KIVI 论文报告,在其测试模型和实现中,2-bit KV Cache 可以在近似保持质量的同时降低峰值显存,并通过更大 batch 提升吞吐。3

但这不是“所有模型、所有任务都能无损 INT2”的保证。

结果会受到:

  • 模型架构;
  • 上下文长度;
  • 量化 Kernel;
  • 残差长度;
  • 分组大小;
  • 任务类型;
  • 评测指标;
  • 生成长度;

等因素影响。

2 bit 应该被视为需要严格验证的激进档位,而不是默认安全选择。


十、KV Cache 量化的数学:scale、分组与残差窗口

KV Cache 量化的基础公式与普通仿射量化相似。

对于一组浮点值 x

q = clamp(round(x / s) + z, q_min, q_max)

反量化:

x̂ = (q - z) × s

其中:

  • s:scale;
  • z:zero-point;
  • q:低比特编码;
  • :反量化近似值。

FP8 路径常写成:

q = cast_fp8(x / s)
x̂ = cast_high_precision(q) × s

10.1 scale 粒度决定谁和谁共用刻度

常见粒度包括:

Per-tensor

一整个 K 张量共用一个 scale,V 另一个 scale。

优点:

  • scale 最少;
  • 运行时简单;
  • Kernel 容易优化。

缺点:

  • 一个 outlier 可能影响全部 Head、token 和通道;
  • 精度通常不如细粒度方案。

Per-layer

每一层的 K/V 各有自己的 scale。

实际文档中有时把它归入 per-tensor,因为每层看到的是自己的 K/V Tensor。

Per-head

每个 Attention Head 独立 scale:

K scale 数量 ≈ num_kv_heads
V scale 数量 ≈ num_kv_heads

它能适配不同 Head 的范围差异。

vLLM 当前的 per-attention-head FP8 路径需要通过 LLM Compressor 校准,并依赖对应 Flash Attention 后端。5

Per-token

每个 token、每个 Head 或每个向量独立 scale。

优点是适应在线分布,缺点是:

  • scale 数量更多;
  • 写 Cache 时要动态计算;
  • Cache 布局和 Kernel 更复杂。

Per-channel

同一通道沿 token 维度共享 scale。

特别适合处理 Key 中稳定的通道 outlier。

Per-group

q_group_size 个元素共用 scale 和 zero-point。

例如:

q_group_size = 64

意味着每 64 个值一组。

组越小,通常越能贴合局部分布,但元数据和内核开销越大。

10.2 scale 的来源:固定、动态还是校准

默认 scale=1

最省事,但未必合理。

如果真实 K/V 范围与 FP8 表示范围不匹配,会产生:

  • 溢出;
  • 截断;
  • 有效精度浪费。

它适合做功能冒烟测试,不适合直接当成生产质量结论。

在线动态 scale

运行时根据当前张量或 token 计算 scale。

优点:

  • 能适应当前输入;
  • 不需要离线数据集。

缺点:

  • 增加在线计算;
  • scale 统计可能不稳定;
  • 不同实现会影响图捕获和 Kernel 融合。

离线校准 scale

用代表性数据跑一遍模型,统计各层、各 Head 的 K/V 分布,把 scale 写入 checkpoint。

优点:

  • 推理时不用反复搜索 scale;
  • 可使用真实业务分布;
  • 便于固定和复现。

缺点:

  • 需要校准流程;
  • 数据分布变化时可能失配;
  • 需要为不同模型架构配置目标模块。

10.3 残差长度如何影响压缩和速度

假设:

总上下文 S = 8192
residual_length = 128

那么大致可以理解为:

8064 个旧 token:低比特
128 个最近 token:高精度

若 KV 主缓存 4 bit、高精度缓存 FP16,则平均位宽粗略为:

平均 bits
≈ (8064 × 4 + 128 × 16) / 8192
≈ 4.19 bit

还没算 scale 和 zero-point。

当上下文很长时,128 个高精度 token 占比很小。

当上下文只有 256 token 时,残差区占一半,压缩收益就有限。

所以 QuantizedCache 更适合长上下文,而不是几十个 token 的短问答。

10.4 元数据开销不能忽略

假设 INT4 每组 64 个值:

数据:64 × 4 bit = 32 Byte

若每组还保存:

FP16 scale:2 Byte
8-bit zero-point:1 Byte

仅这两项就是 3 Byte 额外开销:

3 / 32 ≈ 9.4%

实际格式还可能有对齐和头信息。

因此:

4 bit 编码
不等于
端到端严格 4 bit/元素

十一、FP8、INT8、INT4、INT2 KV Cache 怎么选

不同位宽不是简单的“越低越好”。

11.1 BF16 / FP16:质量基线

优点:

  • 不需要量化和反量化;
  • 质量风险最低;
  • Kernel 最成熟;
  • 短上下文延迟通常更好。

缺点:

  • 每元素 2 Byte;
  • 长上下文和高并发容量最差。

适合:

  • 显存充足;
  • 上下文较短;
  • 质量极敏感;
  • 用作所有量化方案的对照基线。

11.2 FP8:当前最实用的温和压缩路径之一

理论上相对 FP16/BF16 缩小一半。

优点:

  • 保留浮点指数结构;
  • 压缩较温和;
  • 现代 GPU 和服务框架支持较好;
  • scale 元数据相对少;
  • 更容易构建融合注意力 Kernel。

缺点:

  • 只有 2 倍理论压缩;
  • scale 不合理仍会掉点;
  • 不同硬件和 Attention Backend 支持不同;
  • 不能只看 dtype 名称判断计算路径。

适合:

  • vLLM、TensorRT-LLM 等服务端;
  • 需要明显增加 token 容量,但不想直接冒险 4 bit;
  • H100/H200、Ada、Blackwell、部分 AMD 平台等有匹配后端的环境。

11.3 INT8:同为 1 Byte,但表示方式不同

INT8 与 FP8 都是 8 bit,但数值结构不同。

INT8 使用线性整数刻度,强依赖 scale;FP8 自带指数结构,能覆盖更宽数量级。

在特定校准和粒度下,INT8 也可以很准;但工程上应根据后端 Kernel 和模型分布选择,不能仅凭“都是 8 bit”认为二者等价。

11.4 INT4:显存收益更大,但开始更依赖算法

理论上相对 BF16 缩小 4 倍。

优点:

  • 适合消费级显卡长上下文;
  • 能显著提高并发驻留 token 数;
  • 与残差窗口结合后可以控制近期误差。

缺点:

  • 量化误差明显增加;
  • scale、zero-point 和打包开销占比更高;
  • 通用实现可能因为解量化而变慢;
  • 长上下文质量必须单独测试。

11.5 INT2 / 3 bit:研究与极限容量档位

2 bit 只有 4 个基本编码。

要保持质量,通常需要更多技巧:

  • K/V 非对称策略;
  • 旋转变换;
  • 非均匀码本;
  • outlier 分离;
  • 更细粒度 scale;
  • 残差窗口;
  • 自定义注意力 Kernel;
  • 层级混合精度。

KIVI、KVQuant、AQUA-KV、TurboQuant 等工作都在尝试让超低比特 KV Cache 更可用。3678

但对普通部署者来说,2 bit 更适合作为经过严格评测后的优化选项,而不是起点。

11.6 一个实用的优先顺序

可以先按下面顺序测试:

BF16 / FP16 基线
→ FP8
→ INT8 或 INT4
→ INT2 / 3 bit

每降一档,都重新验证:

  • 最大可驻留 token 数;
  • TTFT;
  • ITL / TPOT;
  • 吞吐;
  • PPL;
  • 长上下文检索;
  • 业务任务质量。

十二、别把所有优化都叫量化:GQA、MLA、滑动窗口与剪枝

KV Cache 量化只是压缩缓存的一条路线。

很多技术也能减少 KV 成本,但原理完全不同。

12.1 GQA / MQA:减少 KV Head 数

前文已经讲过:

MHA:每个 Query Head 独立 K/V
GQA:一组 Query Head 共享 K/V
MQA:所有 Query Head 共享一组 K/V

它们改变的是模型架构。

推理时不需要把缓存数值压到低比特,也能显著减少 Cache 大小和带宽。

12.2 MLA:缓存压缩后的潜变量

Multi-head Latent Attention 不再直接保存完整 K/V,而是把 Key 和 Value 联合压缩到更低维的 latent 表示,再在计算时恢复或吸收相应投影。

DeepSeek-V2 报告,MLA 相比其对照架构显著减少了 KV Cache,并提升最大生成吞吐。9

MLA 与 KV 量化的区别是:

KV 量化:相同向量维度,降低每个数的位宽
MLA:改变要缓存的表示本身,降低缓存维度

理论上,MLA 的 latent Cache 仍然可以进一步量化,但需要 MLA 专用内核和数据布局。

12.3 Sliding Window Attention:只保留最近窗口

如果每一层只关注最近 W 个 token,那么缓存增长到 W 后就可以停止:

S_cache = min(S_total, W)

例如窗口为 4096,即使总上下文是 128K,该局部注意力层也只需要保存最近 4096 token。

代价是该层无法直接看到窗口外的历史。

一些模型会混合:

局部滑动窗口层
+
周期性全局注意力层

这样在能力和缓存之间折中。

12.4 Cache Eviction / Pruning:只保留“重要 token”

另一类方法会根据注意力分数、位置、语义或启发式规则,删除不重要的 KV。

它改变的是:

保留多少 token

而量化改变的是:

每个 token 的 KV 占多少位

剪枝可能带来非连续缓存和信息丢失,也需要专用注意力实现。

12.5 这些方法可以组合

例如:

GQA 模型
+ FP8 KV Cache
+ PagedAttention
+ Prefix Caching
+ Sliding Window 层

每一种技术压缩或管理的是不同维度:

技术主要减少什么
GQA / MQAKV Head 数量
MLA每 token 要缓存的表示维度
Sliding Window每层保留的 token 数
KV 量化每个缓存元素的位宽
Cache Pruning实际保留的 token 集合
PagedAttention分配浪费与碎片
Prefix Cache重复前缀的计算与副本
OffloadGPU 上驻留的 Cache 数量

十三、PagedAttention、Static Cache、Offload 和 Prefix Cache 各解决什么

KV Cache 一变大,工程系统会同时暴露出两类问题:

问题 A:每个元素本身占得太多
问题 B:缓存如何分配、复用和迁移不够高效

KV Cache 量化主要解决问题 A。

PagedAttention、Static Cache、Offload、Prefix Caching 更多是在解决问题 B。

这几类技术经常一起出现,所以很容易被误认为都是“压缩 KV Cache”。实际上,它们操作的维度完全不同。

13.1 PagedAttention:减少碎片,而不是改变每个元素的位宽

传统做法往往为每条序列预留一块连续显存。

问题在于,自回归请求的长度事先很难知道:

请求 A 最终生成 50 token
请求 B 最终生成 2000 token
请求 C 还没生成完就被用户取消

如果按最大长度提前分配,会浪费大量空间;如果频繁扩容和复制,又会增加开销,还容易产生显存碎片。

PagedAttention 借鉴操作系统分页思想,把 KV Cache 切成固定大小的 block:

逻辑 token 序列
        ↓ 映射
离散的物理 KV block

这样做带来的好处包括:

  • 不必为每个请求预留一整块最大长度的连续显存;
  • 请求增长时按 block 增量分配;
  • 请求结束后可快速回收 block;
  • 多条序列可共享相同的物理前缀 block;
  • 高并发时显存碎片和预留浪费明显减少。

但必须注意:

PagedAttention 并不会自动把 BF16 KV 变成 FP8 或 INT4。

假设一个 token 的 BF16 KV 理论上需要 128KiB,那么使用 PagedAttention 后,单个有效 token 的数据主体仍然接近 128KiB,只是分配和复用效率更高。

因此,PagedAttention 与 KV Cache 量化并不是替代关系,而是常见组合:

FP8 / INT4:减少每个 block 的字节数
PagedAttention:减少 block 分配浪费并提升复用能力

vLLM 的核心论文报告,PagedAttention 可以让 KV Cache 浪费接近于仅剩最后一个未填满 block 的内部碎片,并支持跨序列共享 KV block。10

13.2 Dynamic Cache:用多少长多少

Hugging Face Transformers 的默认缓存通常是 Dynamic Cache。

它的行为接近:

序列生成一个 token
→ Cache 再增长一份 K/V

优点:

  • 不需要提前知道最终输出长度;
  • 不会一上来就为最大长度分配完整缓存;
  • 适合普通交互式生成。

缺点:

  • Cache 长度不断变化;
  • 动态张量形状不利于某些编译优化;
  • 长序列中可能涉及重新分配或拼接成本;
  • 高并发服务的全局内存管理能力有限。

13.3 Static Cache:提前分配,换取固定形状

Static Cache 会根据最大 batch 和最大长度,预先建立固定大小的缓存。

它的核心价值不是省显存,而是让张量形状更稳定,从而更适合:

  • torch.compile
  • CUDA Graph;
  • 固定 batch、固定最大长度的低延迟场景;
  • 避免生成过程中不断改变 Cache 形状。

代价也很明确:

实际只生成 500 token
但最大长度配置为 32768
→ 未使用部分仍可能被预留

因此:

Dynamic Cache 往往节省“未使用空间”,Static Cache 往往节省“动态管理开销”。

两者是在不同方向上做取舍。

13.4 Offloaded Cache:不压缩,搬到 CPU

Cache Offload 会把部分或全部 KV Cache 放到 CPU 内存,需要计算某一层时再搬回 GPU。

它的优势是:

  • 不改变 K/V 数值;
  • 通常不会像低比特量化那样直接引入精度误差;
  • GPU 显存不够时,可以把系统内存作为更大的缓冲区。

代价是:

  • 每层都可能发生 CPU 与 GPU 之间的数据传输;
  • PCIe 带宽远低于 GPU HBM 带宽;
  • 长上下文逐 token 解码时,传输延迟会反复出现;
  • 主机内存占用仍然会随上下文增长。

所以 Offload 更像:

用传输延迟换 GPU 显存

而量化更像:

用数值精度和转换开销换缓存体积

二者也可以组合,例如将 INT4 Cache 再 offload 到 CPU,但是否有实际收益取决于框架是否提供融合良好的实现。

13.5 Prefix Cache:别让相同前缀重复保存和计算

生产服务中,不同请求经常拥有大段相同前缀:

相同的系统提示词
相同的工具说明
相同的知识库文档
相同的多轮历史开头

Prefix Caching 会根据 token 前缀查找已经计算过的 KV block,并在符合条件时复用。

例如:

请求 A:系统提示词 + 用户问题 A
请求 B:系统提示词 + 用户问题 B

如果系统提示词有 8000 token,那么第二个请求不必重新 Prefill 这 8000 token,也不必再创建一份完全相同的前缀 KV。

它能同时改善:

  • TTFT;
  • 重复 Prefill 的算力开销;
  • 相同前缀造成的缓存副本。

但 Prefix Cache 对完全不同的 Prompt 没有帮助。

它减少的是:

重复前缀

而不是:

每个独立 token 的 K/V 位宽

13.6 为什么开启 FP8 后,nvidia-smi 可能看起来没有下降

这是 vLLM 用户最容易误判的一点。

服务型推理引擎常常会根据:

  • gpu_memory_utilization
  • 显式的 KV Cache 内存预算;
  • 权重和临时工作区剩余空间;

预先拿出一大块显存作为 KV block pool。

因此,在相同 gpu_memory_utilization 下:

BF16 KV Cache:占用 18GB block pool
FP8 KV Cache:仍占用 18GB block pool

nvidia-smi 可能几乎不变。

但两者并不等价:

BF16:每个 token 需要 2 Byte / 元素
FP8:每个 token 需要约 1 Byte / 元素

同样大小的 block pool,FP8 可以容纳接近两倍的 KV 元素,表现为:

  • 更高的可缓存 token 数;
  • 更长的最大上下文;
  • 更多并发序列;
  • 更少的抢占与重计算。

所以评估服务端 KV 量化时,不要只比较空闲状态下的 nvidia-smi,还要看:

启动日志中的 KV Cache 容量
可容纳的 block / token 数
最大并发
压力下的抢占次数
请求失败率
吞吐与尾延迟

13.7 一张表把它们彻底区分开

技术改变每元素位宽减少分配碎片复用重复前缀使用 CPU 内存可能影响数值精度
FP8 / INT4 KV 量化
PagedAttention可配合
Dynamic Cache部分
Static Cache否,甚至可能多预留
Cache Offload通常否
Prefix Cache间接
Sliding Window会丢弃窗口外直接注意能力
Cache Pruning可能

十四、实战一:用脚本估算任意 Hugging Face 模型的 KV Cache

在决定是否开启 KV Cache 量化之前,第一步不是安装某个框架,而是先把账算清楚。

下面写一个通用脚本,从 Hugging Face 模型配置中读取:

  • 层数;
  • Query Head 数;
  • KV Head 数;
  • Head Dim;
  • 上下文长度;
  • batch size;
  • Cache 精度;

然后估算标准注意力模型的理论 KV Cache。

保存为:

kv_cache_estimator.py
from __future__ import annotations

import argparse
from dataclasses import dataclass
from typing import Any

from transformers import AutoConfig


GIB = 1024 ** 3
MIB = 1024 ** 2


DTYPE_BYTES = {
    "fp32": 4.0,
    "float32": 4.0,
    "fp16": 2.0,
    "float16": 2.0,
    "bf16": 2.0,
    "bfloat16": 2.0,
    "fp8": 1.0,
    "int8": 1.0,
    "int4": 0.5,
    "int2": 0.25,
}


@dataclass(frozen=True)
class AttentionShape:
    num_layers: int
    num_attention_heads: int
    num_key_value_heads: int
    head_dim: int


class ConfigFieldError(ValueError):
    pass


def first_present(config: Any, *names: str) -> Any:
    """返回配置中第一个存在且不为 None 的字段。"""
    for name in names:
        value = getattr(config, name, None)
        if value is not None:
            return value
    raise ConfigFieldError(f"未找到字段:{', '.join(names)}")


def unwrap_text_config(config: Any) -> Any:
    """兼容部分多模态模型外层 config 包裹 text_config 的情况。"""
    text_config = getattr(config, "text_config", None)
    return text_config if text_config is not None else config


def read_attention_shape(config: Any) -> AttentionShape:
    cfg = unwrap_text_config(config)

    num_layers = int(
        first_present(cfg, "num_hidden_layers", "n_layer", "num_layers")
    )
    num_attention_heads = int(
        first_present(cfg, "num_attention_heads", "n_head")
    )

    # 没有显式 num_key_value_heads 时,标准 MHA 通常等于 Query Head 数。
    num_key_value_heads = int(
        getattr(cfg, "num_key_value_heads", None) or num_attention_heads
    )

    explicit_head_dim = getattr(cfg, "head_dim", None)
    if explicit_head_dim is not None:
        head_dim = int(explicit_head_dim)
    else:
        hidden_size = int(first_present(cfg, "hidden_size", "n_embd", "d_model"))
        if hidden_size % num_attention_heads != 0:
            raise ConfigFieldError(
                "hidden_size 不能整除 num_attention_heads,"
                "请检查该模型是否使用特殊注意力结构。"
            )
        head_dim = hidden_size // num_attention_heads

    return AttentionShape(
        num_layers=num_layers,
        num_attention_heads=num_attention_heads,
        num_key_value_heads=num_key_value_heads,
        head_dim=head_dim,
    )


def estimate_bytes(
    shape: AttentionShape,
    seq_len: int,
    batch_size: int,
    bytes_per_element: float,
) -> float:
    # 2 代表 Key 和 Value 两份缓存。
    return (
        2
        * shape.num_layers
        * batch_size
        * seq_len
        * shape.num_key_value_heads
        * shape.head_dim
        * bytes_per_element
    )


def main() -> None:
    parser = argparse.ArgumentParser(
        description="估算标准 MHA/GQA/MQA 模型的理论 KV Cache 大小。"
    )
    parser.add_argument(
        "--model",
        required=True,
        help="Hugging Face 模型 ID 或本地目录",
    )
    parser.add_argument(
        "--seq-lens",
        nargs="+",
        type=int,
        default=[4096, 8192, 32768],
        help="一个或多个上下文长度",
    )
    parser.add_argument("--batch-size", type=int, default=1)
    parser.add_argument(
        "--dtype",
        choices=sorted(DTYPE_BYTES),
        default="bf16",
    )
    parser.add_argument("--trust-remote-code", action="store_true")
    args = parser.parse_args()

    if args.batch_size <= 0:
        raise ValueError("batch-size 必须大于 0")
    if any(length <= 0 for length in args.seq_lens):
        raise ValueError("seq-lens 中的长度必须全部大于 0")

    config = AutoConfig.from_pretrained(
        args.model,
        trust_remote_code=args.trust_remote_code,
    )
    shape = read_attention_shape(config)
    bytes_per_element = DTYPE_BYTES[args.dtype]

    per_token = estimate_bytes(
        shape=shape,
        seq_len=1,
        batch_size=args.batch_size,
        bytes_per_element=bytes_per_element,
    )

    print(f"模型                    : {args.model}")
    print(f"层数                    : {shape.num_layers}")
    print(f"Query Head 数           : {shape.num_attention_heads}")
    print(f"KV Head 数              : {shape.num_key_value_heads}")
    print(f"Head Dim                : {shape.head_dim}")
    print(f"batch size              : {args.batch_size}")
    print(f"Cache dtype             : {args.dtype}")
    print(f"每 token 理论 KV Cache : {per_token / 1024:.2f} KiB")
    print()
    print(f"{'序列长度':>12} {'理论缓存 MiB':>16} {'理论缓存 GiB':>16}")
    print("-" * 48)

    for seq_len in args.seq_lens:
        total = estimate_bytes(
            shape=shape,
            seq_len=seq_len,
            batch_size=args.batch_size,
            bytes_per_element=bytes_per_element,
        )
        print(f"{seq_len:>12,} {total / MIB:>16.2f} {total / GIB:>16.3f}")

    print("\n注意:")
    print("1. 结果只计算 K/V 主体,不含 scale、元数据、block 对齐和框架开销。")
    print("2. 对滑动窗口、混合注意力、跨层共享 Cache、MLA 等结构可能不准确。")
    print("3. batch、beam 或并发副本增加时,实际需求还会继续增长。")


if __name__ == "__main__":
    main()

14.1 运行示例

例如估算一个 Qwen2.5 7B 模型,在 BF16 下不同上下文长度的理论 KV Cache:

python kv_cache_estimator.py \
  --model Qwen/Qwen2.5-7B-Instruct \
  --seq-lens 4096 8192 32768 131072 \
  --batch-size 1 \
  --dtype bf16

再看 FP8:

python kv_cache_estimator.py \
  --model Qwen/Qwen2.5-7B-Instruct \
  --seq-lens 4096 8192 32768 131072 \
  --batch-size 1 \
  --dtype fp8

再看理论 INT4 主体:

python kv_cache_estimator.py \
  --model Qwen/Qwen2.5-7B-Instruct \
  --seq-lens 4096 8192 32768 131072 \
  --batch-size 1 \
  --dtype int4

理论上:

FP8 约为 BF16 主体的 1/2
INT4 约为 BF16 主体的 1/4
INT2 约为 BF16 主体的 1/8

但实际占用不会严格按这个比例缩放,因为低比特 Cache 还要保存 scale,部分方案还会保留高精度残差窗口。

14.2 把并发纳入预算

假设服务端目标是同时保留 16 条、每条平均 12K token 的会话,可以先做一个保守近似:

python kv_cache_estimator.py \
  --model Qwen/Qwen2.5-7B-Instruct \
  --seq-lens 12288 \
  --batch-size 16 \
  --dtype fp8

这里把“16 条独立序列”近似成 batch size 16。

真实服务中还要考虑:

  • 不同请求长度不一致;
  • PagedAttention 的 block 对齐;
  • 前缀是否共享;
  • 请求是否被抢占;
  • 输出 token 是否继续增长;
  • 滑动窗口层是否只保留局部历史;
  • beam search 是否复制 Cache;
  • 投机解码是否存在额外状态。

所以估算值适合作为下限与量级判断,不能替代运行时测量。

14.3 哪些模型不能直接套这个公式

上面的脚本适合标准的:

  • MHA;
  • GQA;
  • MQA;
  • 每层都缓存完整标准 K/V 的 Decoder-only Transformer。

遇到以下架构时要格外谨慎:

混合滑动窗口

部分层只保存最近 W 个 token,部分层保留全局历史。

此时不能简单使用:

层数 × 总序列长度

而要逐层相加。

MLA

Multi-head Latent Attention 缓存的是低维潜表示和位置相关分量,不等同于标准 H_kv × D 的完整 K/V。

DeepSeek-V2 论文报告,其 MLA 相比所对比的标准架构大幅减少了 KV Cache,但具体体积要按模型实现计算。9

状态空间模型或混合模型

Mamba、Jamba 等模型可能同时拥有:

  • 注意力 KV Cache;
  • 状态空间层的 recurrent state;
  • 卷积状态。

这时“Cache”不再只有 K 和 V 两个张量。

跨层共享 KV

某些新架构会让多个层共享或重用 Cache,标准公式会高估。

因此,估算脚本最后的警告不是客套话:

先读模型配置和实现,再决定公式中的每一项代表什么。


十五、环境准备与版本自检

下面的实战分成三条路线:

路线 A:Transformers QuantizedCache
路线 B:vLLM FP8 KV Cache
路线 C:LLM Compressor 离线校准

为了减少依赖冲突,建议分别创建环境,而不是把所有包都塞进同一个 Python 环境。

15.1 Transformers 实验环境

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

python -m pip install -U pip
pip install -U torch transformers accelerate

# Quanto 后端
pip install -U optimum-quanto

# HQQ 后端,按需安装
pip install -U hqq

Windows PowerShell 激活命令:

.venv-hf-kv\Scripts\Activate.ps1

15.2 vLLM 环境

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

python -m pip install -U pip
pip install -U vllm openai

vLLM 对 CUDA、PyTorch 和 GPU 架构有明确要求。生产环境建议按照官方安装矩阵选择镜像或 wheel,不要只看 pip install 是否成功。

15.3 LLM Compressor 校准环境

可以单独建立:

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

python -m pip install -U pip
pip install -U llmcompressor transformers datasets accelerate

不同版本的 llmcompressorcompressed-tensors、Transformers 和 vLLM 之间存在兼容约束。真正上线时应把成功组合固定到:

requirements.txt
或
uv.lock / poetry.lock / conda-lock

15.4 版本与 GPU 自检脚本

保存为 check_kv_env.py

from __future__ import annotations

import importlib
import platform

import torch


PACKAGES = [
    "transformers",
    "accelerate",
    "optimum.quanto",
    "hqq",
    "vllm",
    "llmcompressor",
]


print(f"Python       : {platform.python_version()}")
print(f"PyTorch      : {torch.__version__}")
print(f"CUDA runtime : {torch.version.cuda}")
print(f"CUDA 可用    : {torch.cuda.is_available()}")

if torch.cuda.is_available():
    print(f"GPU          : {torch.cuda.get_device_name(0)}")
    capability = torch.cuda.get_device_capability(0)
    print(f"Compute Cap. : {capability[0]}.{capability[1]}")
    total_gib = torch.cuda.get_device_properties(0).total_memory / 1024**3
    print(f"总显存       : {total_gib:.2f} GiB")

print("\nPython 包:")
for package in PACKAGES:
    try:
        module = importlib.import_module(package)
        version = getattr(module, "__version__", "已安装,未暴露 __version__")
        print(f"{package:<18}: {version}")
    except Exception as exc:
        print(f"{package:<18}: 不可用({type(exc).__name__}: {exc})")

运行:

python check_kv_env.py

15.5 固定版本为什么尤其重要

KV Cache 量化的接口变化比普通 FP16 推理更快。

例如,同一个概念在不同版本中可能出现:

  • 枚举名发生变化;
  • 某个 dtype 只在特定后端可用;
  • scale 的加载方式变化;
  • 旧的在线计算参数被弃用;
  • Flash Attention 与 Triton 后端支持范围不同;
  • 模型只在新 Cache API 下工作;
  • 特殊模型的滑动窗口或混合 Cache 尚未适配。

因此每次实验都应记录:

python -V
pip freeze > requirements-lock.txt
nvidia-smi
vllm --version
python -c "import torch, transformers; print(torch.__version__, transformers.__version__)"

对于 vLLM,还应以当前安装版本为准检查:

vllm serve --help | grep -A 8 -B 2 "kv-cache-dtype"

不要仅凭一篇旧博客里的参数名直接上线。


十六、实战二:Transformers 一行开启 INT4 QuantizedCache

Hugging Face Transformers 提供统一 Cache API,其中 QuantizedCache 可以在缓存增长到一定程度后,把较老的 K/V 压缩到低比特格式。当前官方文档列出的通用后端包括:

  • Quanto:支持 INT2、INT4;
  • HQQ:支持 INT2、INT4、INT8。

它的思路不是把所有 Cache 从第一个 token 开始都立即变成低比特,而是维护两部分:

高精度 residual cache:最近的一小段 token
量化 cache:更早的历史 token

新 token 先进入高精度残差区。

当残差长度达到阈值后,较旧的部分被量化并转移到低比特缓存中。这样可以避免每生成一个 token 都立即执行量化,也能让最近 token 保持更高精度。Transformers 当前实现受到 KIVI 思路启发,但具体 K/V 量化轴和分组由后端配置控制,并不等同于原论文的全部实现。4

16.1 最小示例:Quanto INT4 Cache

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer


MODEL_ID = "Qwen/Qwen2.5-1.5B-Instruct"


tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    torch_dtype=torch.float16,
    device_map={"": 0},
)
model.eval()

messages = [
    {
        "role": "user",
        "content": "请分三点解释 KV Cache 为什么会随上下文增长。",
    }
]

text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,
)
inputs = tokenizer(text, return_tensors="pt").to("cuda:0")

with torch.inference_mode():
    output_ids = model.generate(
        **inputs,
        max_new_tokens=256,
        do_sample=False,
        use_cache=True,

        # 关键配置:使用量化缓存。
        cache_implementation="quantized",
        cache_config={
            "backend": "quanto",
            "nbits": 4,
            "q_group_size": 64,
            "residual_length": 128,
        },

        pad_token_id=tokenizer.eos_token_id,
    )

new_tokens = output_ids[0, inputs["input_ids"].shape[1]:]
print(tokenizer.decode(new_tokens, skip_special_tokens=True))

真正决定 Cache 行为的是:

cache_implementation="quantized"

以及:

cache_config={...}

16.2 四个参数分别是什么意思

backend

决定使用哪一种量化后端:

quanto
hqq

它不仅影响可用位数,也影响量化张量布局、依赖和实际性能。

nbits

每个量化元素使用的比特数。

例如:

4 → INT4
2 → INT2

位数越低,Cache 主体越小,但量化误差和转换开销通常越难控制。

q_group_size

多少个元素共享一组量化参数。

例如:

q_group_size = 64

表示局部 64 个元素共用一组 scale,具体分组维度由 axis_keyaxis_value 和后端实现共同决定。

更小的 group:

  • 通常更贴合局部分布;
  • scale 元数据更多;
  • 量化调用更细碎;
  • 未必拥有更高效的 Kernel。

residual_length

高精度残差区保留多少个最近 token。

例如:

residual_length = 128

意味着最近约 128 个 token 继续保留高精度 Cache,更老部分再进入量化区。

它影响三项指标:

更大 residual_length
→ 近期信息精度更稳
→ 量化触发次数更少
→ 显存节省略少

16.3 HQQ INT4 示例

with torch.inference_mode():
    output_ids = model.generate(
        **inputs,
        max_new_tokens=256,
        do_sample=False,
        use_cache=True,
        cache_implementation="quantized",
        cache_config={
            "backend": "hqq",
            "nbits": 4,
            "axis_key": 1,
            "axis_value": 1,
            "q_group_size": 64,
            "residual_length": 128,
        },
        pad_token_id=tokenizer.eos_token_id,
    )

Transformers 文档当前建议,HQQ 后端通常将 K/V 的量化轴配置为 1;Quanto 常用 0。但模型内部 Cache 布局和后端实现仍可能变化,遇到维度错误时应以当前版本文档和报错中的张量形状为准。4

16.4 INT2 示例

只需改成:

cache_config={
    "backend": "quanto",
    "nbits": 2,
    "q_group_size": 64,
    "residual_length": 128,
}

但 INT2 并不是“INT4 再省一半这么简单”。

它只有 4 个编码值,对:

  • scale;
  • 分组;
  • 离群值;
  • 长距离检索;
  • 累积误差;

都更敏感。

所以 INT2 更适合研究或显存极端受限的场景,不能只凭一段流畅输出判断可用。

16.5 为什么短上下文可能更慢

QuantizedCache 增加了额外工作:

量化旧 K/V
保存 scale 与编码
注意力前读取低比特缓存
反量化或使用专用低比特计算路径
维护 residual cache

如果上下文很短,本来 Cache 只占几百 MB:

  • 节省的显存很有限;
  • 量化和反量化开销却真实存在;
  • 通用后端不一定有服务框架级融合 Kernel。

因此 Transformers 官方文档也明确提醒:在上下文较短、显存充足时,量化 Cache 可能降低延迟。11

它最适合的场景是:

不量化就 OOM
或
量化后可以显著增加可用上下文

而不是“所有请求默认开启,速度一定更快”。

16.6 不要把 Cache 位数和权重位数绑定

完全可以使用:

FP16 权重 + INT4 KV Cache
INT4 权重 + BF16 KV Cache
INT4 权重 + FP8 KV Cache
FP8 权重 + FP8 KV Cache

这是两个独立维度。

例如:

  • 短上下文、本地显存紧张:可能只需要 INT4 权重;
  • 权重已经很小、上下文特别长:可能进一步压缩 KV;
  • 质量敏感但并发高:权重保持 BF16,KV 使用经过校准的 FP8;
  • 极限本地部署:权重和 KV 都使用低比特,但必须做任务评测。

十七、严谨测量 Transformers 的显存和延迟

上一节能跑通,还不等于已经知道量化 Cache 的真实收益。

我们需要把:

Dynamic BF16/FP16 Cache
Quanto INT4 Cache
HQQ INT4 Cache
甚至 INT2 Cache

放到独立进程中,用同一个模型、相同 token 长度和生成参数测试。

17.1 为什么必须独立进程

同一个 Python 进程中依次测试不同 Cache,会受到:

  • CUDA Context;
  • PyTorch caching allocator;
  • 已加载 Kernel;
  • 模型权重对象;
  • 计算图和工作区;
  • 内存碎片;

影响。

torch.cuda.empty_cache() 只能把分配器中未被张量占用的缓存块交还给 CUDA,并不会让进程回到完全干净的初始状态。

所以最简单可靠的方法是:

一个 mode
→ 一个独立 Python 进程

17.2 完整测试脚本

保存为:

measure_hf_kv_cache.py
from __future__ import annotations

import argparse
import math
import time
from typing import Any

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer


GIB = 1024 ** 3


def make_exact_length_input(
    tokenizer: Any,
    target_tokens: int,
    device: str,
) -> dict[str, torch.Tensor]:
    """用可重复文本构造接近精确长度的单条 token 序列。"""
    if target_tokens < 8:
        raise ValueError("prompt-tokens 至少设为 8")

    seed_text = (
        "模型量化需要同时关注显存、延迟和质量。"
        "KV Cache 会随上下文长度线性增长。"
    )
    seed_ids = tokenizer(
        seed_text,
        add_special_tokens=False,
    )["input_ids"]

    if not seed_ids:
        raise RuntimeError("分词器没有为种子文本生成 token")

    repeats = math.ceil(target_tokens / len(seed_ids))
    token_ids = (seed_ids * repeats)[:target_tokens]

    input_ids = torch.tensor(
        [token_ids],
        dtype=torch.long,
        device=device,
    )
    attention_mask = torch.ones_like(input_ids)

    return {
        "input_ids": input_ids,
        "attention_mask": attention_mask,
    }


def cache_kwargs(mode: str) -> dict[str, Any]:
    if mode == "dynamic":
        return {
            "cache_implementation": "dynamic",
        }

    if mode == "quanto4":
        return {
            "cache_implementation": "quantized",
            "cache_config": {
                "backend": "quanto",
                "nbits": 4,
                "q_group_size": 64,
                "residual_length": 128,
            },
        }

    if mode == "quanto2":
        return {
            "cache_implementation": "quantized",
            "cache_config": {
                "backend": "quanto",
                "nbits": 2,
                "q_group_size": 64,
                "residual_length": 128,
            },
        }

    if mode == "hqq4":
        return {
            "cache_implementation": "quantized",
            "cache_config": {
                "backend": "hqq",
                "nbits": 4,
                "axis_key": 1,
                "axis_value": 1,
                "q_group_size": 64,
                "residual_length": 128,
            },
        }

    raise ValueError(f"未知 mode:{mode}")


def gib(num_bytes: int | float) -> float:
    return float(num_bytes) / GIB


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--mode",
        choices=["dynamic", "quanto4", "quanto2", "hqq4"],
        required=True,
    )
    parser.add_argument(
        "--model",
        default="Qwen/Qwen2.5-1.5B-Instruct",
    )
    parser.add_argument("--prompt-tokens", type=int, default=8192)
    parser.add_argument("--max-new-tokens", type=int, default=128)
    parser.add_argument("--warmup-prompt-tokens", type=int, default=256)
    args = parser.parse_args()

    if not torch.cuda.is_available():
        raise RuntimeError("未检测到 CUDA GPU")
    if args.prompt_tokens <= 0 or args.max_new_tokens <= 0:
        raise ValueError("token 长度必须大于 0")

    device = "cuda:0"
    print(f"GPU          : {torch.cuda.get_device_name(0)}")
    print(f"模型         : {args.model}")
    print(f"Cache mode   : {args.mode}")
    print(f"Prompt token : {args.prompt_tokens}")
    print(f"输出上限     : {args.max_new_tokens}")

    tokenizer = AutoTokenizer.from_pretrained(args.model)
    model = AutoModelForCausalLM.from_pretrained(
        args.model,
        torch_dtype=torch.float16,
        device_map={"": 0},
        low_cpu_mem_usage=True,
    )
    model.eval()

    pad_token_id = tokenizer.pad_token_id
    if pad_token_id is None:
        pad_token_id = tokenizer.eos_token_id
    if pad_token_id is None:
        raise RuntimeError("分词器没有 pad_token_id 或 eos_token_id")

    generation_cache_kwargs = cache_kwargs(args.mode)

    # 先做一次小规模预热,减少首次 Kernel 初始化对延迟的影响。
    warmup_inputs = make_exact_length_input(
        tokenizer,
        target_tokens=args.warmup_prompt_tokens,
        device=device,
    )
    with torch.inference_mode():
        _ = model.generate(
            **warmup_inputs,
            max_new_tokens=8,
            do_sample=False,
            use_cache=True,
            pad_token_id=pad_token_id,
            **generation_cache_kwargs,
        )
    torch.cuda.synchronize()

    # 释放预热输出引用并清理统计周期。
    del _
    del warmup_inputs
    torch.cuda.empty_cache()

    inputs = make_exact_length_input(
        tokenizer,
        target_tokens=args.prompt_tokens,
        device=device,
    )

    base_allocated = torch.cuda.memory_allocated(0)
    base_reserved = torch.cuda.memory_reserved(0)
    torch.cuda.reset_peak_memory_stats(0)

    start = time.perf_counter()
    with torch.inference_mode():
        output_ids = model.generate(
            **inputs,
            max_new_tokens=args.max_new_tokens,
            do_sample=False,
            use_cache=True,
            pad_token_id=pad_token_id,
            **generation_cache_kwargs,
        )
    torch.cuda.synchronize()
    elapsed = time.perf_counter() - start

    input_len = inputs["input_ids"].shape[1]
    generated = output_ids.shape[1] - input_len

    peak_allocated = torch.cuda.max_memory_allocated(0)
    peak_reserved = torch.cuda.max_memory_reserved(0)
    extra_peak_allocated = max(0, peak_allocated - base_allocated)
    extra_peak_reserved = max(0, peak_reserved - base_reserved)

    print("\n结果:")
    print(f"实际输入 token       : {input_len}")
    print(f"实际生成 token       : {generated}")
    print(f"总耗时                : {elapsed:.3f} 秒")
    print(f"端到端生成速度        : {generated / elapsed:.3f} token/s")
    print(f"测试前 allocated      : {gib(base_allocated):.3f} GiB")
    print(f"测试前 reserved       : {gib(base_reserved):.3f} GiB")
    print(f"峰值 allocated        : {gib(peak_allocated):.3f} GiB")
    print(f"峰值 reserved         : {gib(peak_reserved):.3f} GiB")
    print(f"新增峰值 allocated    : {gib(extra_peak_allocated):.3f} GiB")
    print(f"新增峰值 reserved     : {gib(extra_peak_reserved):.3f} GiB")


if __name__ == "__main__":
    main()

17.3 分别运行四个模式

Dynamic Cache:

python measure_hf_kv_cache.py \
  --mode dynamic \
  --prompt-tokens 8192 \
  --max-new-tokens 128

Quanto INT4:

python measure_hf_kv_cache.py \
  --mode quanto4 \
  --prompt-tokens 8192 \
  --max-new-tokens 128

HQQ INT4:

python measure_hf_kv_cache.py \
  --mode hqq4 \
  --prompt-tokens 8192 \
  --max-new-tokens 128

Quanto INT2:

python measure_hf_kv_cache.py \
  --mode quanto2 \
  --prompt-tokens 8192 \
  --max-new-tokens 128

每个命令执行结束后再启动下一个,让进程彻底退出。

17.4 为什么这个脚本仍然不是完整 Benchmark

它至少比“看一眼 nvidia-smi”严谨,但仍有局限:

没有拆分 Prefill 和 Decode

脚本报告的是:

Prompt 处理
+
逐 token 生成

总耗时。

长 Prompt 下,Prefill 占比可能很大。要比较 Decode 延迟,需要进一步记录首 token 时间,或使用专门推理框架的 benchmark 工具。

输入是重复文本

重复文本的 token 长度稳定,适合显存测量,但不适合作为模型质量评测。

只测了 batch size 1

真实服务的 Cache 管理、调度、并发和碎片行为远复杂于单进程单请求。

通用 QuantizedCache 不代表服务端专用 Kernel

Transformers 路线强调兼容性和可用性;vLLM、TensorRT-LLM 等服务框架会使用不同的 block 管理与注意力 Kernel。

所以不要把这里的 token/s 直接推断到生产服务。

17.5 建议的测试矩阵

至少测试以下组合:

维度建议值
Prompt 长度2K、8K、16K、32K
输出长度32、128、512
CacheDynamic、INT4、INT2
residual length64、128、256
group size32、64、128
模型一个小模型验证流程,一个目标模型验证结论

记录:

是否 OOM
峰值 allocated
峰值 reserved
端到端耗时
首 token 延迟
Decode token/s
输出质量

最重要的判断不是:

哪个配置数字最小?

而是:

哪个配置让目标长度能够运行,且质量与延迟仍在可接受范围内?

十八、实战三:vLLM 开启 FP8 KV Cache

Transformers 的 QuantizedCache 适合快速实验和单进程推理。

如果目标是:

  • OpenAI 兼容服务;
  • 连续批处理;
  • PagedAttention;
  • 多请求调度;
  • 高并发吞吐;
  • Prefix Cache;
  • KV block 级显存管理;

那么 vLLM 更接近真实生产环境。

当前 vLLM 的 --kv-cache-dtype 已不只有 autofp8。不同版本、设备和后端还可能列出:

fp8_e4m3
fp8_e5m2
fp8_per_token_head
int8_per_token_head
int4_per_token_head
nvfp4
TurboQuant 相关格式
以及部分模型专用格式

但“CLI 里出现”不代表当前 GPU、注意力后端和模型组合一定可用。

第一条原则仍然是:

vllm serve --help | grep -A 8 -B 2 "kv-cache-dtype"

然后核对当前版本的硬件支持与 Quantized KV Cache 文档。512

18.1 先跑一个 BF16 基线

下面以 Qwen2.5 7B 为例。

先启动原生 Cache 基线:

vllm serve Qwen/Qwen2.5-7B-Instruct \
  --served-model-name kv-test \
  --dtype bfloat16 \
  --kv-cache-dtype auto \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.90 \
  --port 8000

几个参数分别表示:

--dtype bfloat16

模型主要计算和未量化张量使用 BF16。

--kv-cache-dtype auto

KV Cache 跟随模型或引擎自动选择的原生类型。在这个对照实验中,要查看启动日志确认最终使用的是 BF16 还是 FP16,而不是只根据参数名猜测。

--max-model-len 32768

允许 Prompt 与输出 token 总和最多 32768。

例如:

Prompt 16384
输出 256
总长度 16640

在范围内。

这个参数不是“提前生成 32768 个 token”,但引擎会据此检查 KV Cache 容量是否能够支持目标最大序列。

--gpu-memory-utilization 0.90

允许当前 vLLM 实例使用约 90% 的 GPU 显存预算。

当前稳定文档中的默认值可能与旧教程不同,因此这里显式指定,避免跨版本结果悄悄改变。也可以使用 --kv-cache-memory-bytes 直接规定每张 GPU 的 KV Cache 预算;一旦显式指定,它会覆盖基于利用率的自动推断。12

启动后,保存日志中的:

  • 权重加载占用;
  • KV Cache dtype;
  • GPU block 数;
  • 可缓存 token 数;
  • 估算最大并发;
  • 使用的 Attention backend。

这些数据比空闲时的 nvidia-smi 更有解释力。

18.2 FP8 E4M3 冒烟测试

停止基线服务后,使用相同端口和显存预算启动 FP8:

vllm serve Qwen/Qwen2.5-7B-Instruct \
  --served-model-name kv-test \
  --dtype bfloat16 \
  --kv-cache-dtype fp8_e4m3 \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.90 \
  --port 8000

也可以写:

--kv-cache-dtype fp8

在当前 CUDA 路径中,fp8 通常对应 E4M3;为了让实验记录更清楚,本文优先显式写 fp8_e4m3

从理论主体大小看:

BF16 Cache:2 Byte / 元素
FP8 Cache :1 Byte / 元素

所以同样大小的 KV block pool,FP8 理论上可以容纳接近两倍的元素。

但请暂时把上面的命令理解成:

验证硬件、vLLM 版本、模型和 Attention backend 能否跑通 FP8 KV Cache。

它还不是生产质量配置。

18.3 默认 scale=1.0 意味着什么

量化关系可以简化为:

q = cast_fp8(x / s)

反量化:
x̂ = cast_high_precision(q) × s

如果没有从 checkpoint 读到 K/V scale,而在线计算也未开启,vLLM 当前默认使用:

k_scale = 1.0
v_scale = 1.0

这相当于假设 K/V 的数值范围天然适合直接落入 FP8 表示区间。

有些模型确实可能表现尚可,但这不是一个普适保证:

  • 数值过大可能溢出或饱和;
  • 数值过小可能浪费有效刻度;
  • 不同层、不同头的分布可能差异很大;
  • 长上下文下的尾部行为不一定能被短 Prompt 暴露。

vLLM 官方文档把无校准配置列为一种可用方式,但把真实数据集校准标为推荐路径。5

因此,正确流程应该是:

默认 scale=1.0
→ 只做兼容性与冒烟验证

真实数据集校准
→ 再做质量、显存和性能对比

18.4 E4M3 和 E5M2 怎么选

两种格式都是 8 bit 浮点,但指数位与尾数位分配不同:

E4M3:指数更少、尾数更多
E5M2:指数更多、尾数更少

直觉上:

  • E4M3 通常拥有更细的有效精度;
  • E5M2 拥有更大的动态范围;
  • 合适的 scale 能显著影响最终表现;
  • 实际速度还取决于 GPU 和 Kernel。

对于推理 KV Cache,常见起点是 E4M3,因为 K/V 一般会通过 scale 映射到适合的范围。但不能仅凭格式名称决定,应在目标模型上同时评测:

fp8_e4m3
fp8_e5m2

尤其当 E4M3 出现明显饱和、异常输出或数值分布跨度较大时,E5M2 可能值得测试。

18.5 跳过敏感层

混合注意力模型中,部分层可能对 Cache 量化更敏感。

当前 vLLM 支持按层号或注意力类型跳过:

vllm serve <model> \
  --kv-cache-dtype fp8 \
  --kv-cache-dtype-skip-layers sliding_window

也可以指定层索引:

vllm serve <model> \
  --kv-cache-dtype fp8 \
  --kv-cache-dtype-skip-layers 0 1 23

这是一种混合精度思路:

大部分层:FP8 KV
敏感层:原生 BF16 / FP16 KV

代价是显存节省略少,但可能换来更稳的质量。

不要默认“滑动窗口层一定要跳过”。官方选项只是提供能力,是否需要跳过应由模型结构和评测结果决定。5

18.6 Per-tensor 与 Per-attention-head scale

vLLM 当前文档把 FP8 KV Cache scale 方案区分为:

Per-tensor scale

每个 Q、K、V 张量分别使用一个 scale:

q_scale: 1 个
k_scale: 1 个
v_scale: 1 个

优点:

  • 元数据少;
  • Kernel 和加载更简单;
  • 兼容范围通常更广。

缺点:

  • 一个极端 Head 可能撑大整层 scale;
  • 其他 Head 的有效精度被压缩。

Per-attention-head

每个注意力 Head 单独拥有 scale:

q_scale: num_heads 个
k_scale: num_kv_heads 个
v_scale: num_kv_heads 个

优点:

  • 更贴合不同 Head 的数值范围;
  • 对离群 Head 更稳;
  • 可能获得更好的质量。

代价:

  • scale 更多;
  • 需要对应 Attention backend;
  • 当前 vLLM 文档要求通过 LLM Compressor 校准,并使用支持它的 Flash Attention 路径。5

需要注意:

per-attention-head 静态校准 scale

和 CLI 中可能出现的:

fp8_per_token_head

并不是可以仅凭名字互换的同一配置。后者涉及更细的运行时粒度和专用 Kernel,应单独查看当前版本文档、设备限制和实现状态。

18.7 Flash Attention 3 下可能直接在 FP8 域计算注意力

许多 KV Cache 量化实现的路径是:

低比特存储 K/V
→ 读取时反量化
→ 高精度 Attention

而 vLLM 当前文档说明,在 Flash Attention 3 与 FP8 KV Cache 的特定配置下,Q 也会被量化,注意力运算可以在 FP8 域中执行。5

这意味着:

  • FP8 不再只是存储格式;
  • Q scale 同样重要;
  • 校准 recipe 会同时收集 Q、K、V 的 scale;
  • 精度与速度表现不能用“只压缩 K/V”简单推断。

因此做 Benchmark 时必须记录实际 Attention backend。


十九、实战四:用真实数据校准 K/V scale

如果 FP8 只有 8 bit,为什么还需要 scale?

因为“有指数位”不等于“天然适配每一层的全部数值分布”。

校准要回答的是:

每层或每个 Head 的 Q/K/V
通常落在哪个范围?
用什么 scale 才能既少溢出,又充分利用 FP8 刻度?

19.1 三种 scale 获取方式

当前 vLLM 文档区分三条路径:

方式一:不校准

scale = 1.0

优点:启动最简单。

适合:兼容性冒烟测试。

风险:不一定适合真实 K/V 分布。

方式二:随机 token 预热

旧的在线路径会在启动时使用一批随机 token 估计 scale:

随机 token
→ 跑一次 warmup
→ 收集 K/V 范围
→ 固定 scale

它比完全不校准多了一些模型统计,但随机 token 并不代表真实业务输入。

更重要的是,当前稳定版 vLLM 已把 --calculate-kv-scales 标记为 deprecated,并说明计划在 v0.19 移除。关闭该选项时,引擎会优先从 checkpoint 加载 scale,否则回退到 1.0。12

因此,不建议把随机 token 在线校准作为新生产流程的长期基础。

方式三:真实数据集离线校准

业务代表性样本
→ 运行模型并收集 Q/K/V 统计
→ 计算 scale
→ 写入 checkpoint
→ 部署时直接加载

优势:

  • 可复现;
  • 可审计;
  • 能匹配真实语言、任务和长度分布;
  • 可以保存 per-tensor 或 per-head scale;
  • 服务启动不依赖一次随机 warmup。

这是当前 vLLM 文档推荐的路径。5

19.2 完整 LLM Compressor 校准脚本

下面基于当前官方示例整理。

保存为:

calibrate_kv_fp8.py
from __future__ import annotations

import argparse
from pathlib import Path

from compressed_tensors.quantization import QuantizationArgs, QuantizationScheme
from datasets import load_dataset
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import QuantizationModifier
from transformers import AutoModelForCausalLM, AutoTokenizer


def process_and_tokenize(
    example: dict,
    tokenizer: AutoTokenizer,
    max_seq_len: int,
) -> dict:
    """把 UltraChat messages 转成模型实际会看到的 token。"""
    text = tokenizer.apply_chat_template(
        example["messages"],
        tokenize=False,
    )
    return tokenizer(
        text,
        padding=False,
        truncation=True,
        max_length=max_seq_len,
        add_special_tokens=False,
    )


def build_recipe(
    strategy: str,
    attention_target: str,
) -> QuantizationModifier:
    fp8_args = QuantizationArgs(
        num_bits=8,
        type="float",
        strategy=strategy,
    )

    return QuantizationModifier(
        config_groups={
            "attention": QuantizationScheme(
                # 这里用于收集 Query 输入激活,从而写入 q_scale。
                targets=[attention_target],
                input_activations=fp8_args,
            )
        },

        # 这里用于收集并写入 k_scale / v_scale。
        kv_cache_scheme=fp8_args,
    )


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--model",
        default="meta-llama/Llama-3.1-8B-Instruct",
    )
    parser.add_argument(
        "--dataset",
        default="HuggingFaceH4/ultrachat_200k",
    )
    parser.add_argument("--dataset-split", default="train_sft")
    parser.add_argument(
        "--strategy",
        choices=["tensor", "attn_head"],
        default="tensor",
    )
    parser.add_argument(
        "--attention-target",
        default="LlamaAttention",
        help="模型中的 Attention 模块类名",
    )
    parser.add_argument("--num-samples", type=int, default=512)
    parser.add_argument("--max-seq-len", type=int, default=2048)
    parser.add_argument("--seed", type=int, default=42)
    parser.add_argument(
        "--output-dir",
        default="Llama-3.1-8B-Instruct-kvattn-fp8-tensor",
    )
    args = parser.parse_args()

    if args.num_samples <= 0 or args.max_seq_len <= 0:
        raise ValueError("num-samples 与 max-seq-len 必须大于 0")

    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    model = AutoModelForCausalLM.from_pretrained(
        args.model,
        torch_dtype="auto",
        low_cpu_mem_usage=True,
    )
    tokenizer = AutoTokenizer.from_pretrained(args.model)

    dataset = load_dataset(
        args.dataset,
        split=f"{args.dataset_split}[:{args.num_samples}]",
    )
    dataset = dataset.shuffle(seed=args.seed)
    dataset = dataset.map(
        lambda example: process_and_tokenize(
            example,
            tokenizer,
            args.max_seq_len,
        ),
        remove_columns=dataset.column_names,
    )

    recipe = build_recipe(
        strategy=args.strategy,
        attention_target=args.attention_target,
    )

    oneshot(
        model=model,
        dataset=dataset,
        recipe=recipe,
        max_seq_length=args.max_seq_len,
        num_calibration_samples=args.num_samples,
    )

    model.save_pretrained(
        output_dir,
        save_compressed=True,
    )
    tokenizer.save_pretrained(output_dir)

    print(f"校准 checkpoint 已保存到:{output_dir.resolve()}")


if __name__ == "__main__":
    main()

使用 per-tensor scale:

python calibrate_kv_fp8.py \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --strategy tensor \
  --attention-target LlamaAttention \
  --num-samples 512 \
  --max-seq-len 2048 \
  --output-dir ./Llama-3.1-8B-kvattn-fp8-tensor

使用 per-attention-head scale:

python calibrate_kv_fp8.py \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --strategy attn_head \
  --attention-target LlamaAttention \
  --num-samples 512 \
  --max-seq-len 2048 \
  --output-dir ./Llama-3.1-8B-kvattn-fp8-head

19.3 这段 recipe 到底量化了什么

容易误读的部分是:

config_groups={
    "attention": QuantizationScheme(
        targets=["LlamaAttention"],
        input_activations=fp8_args,
    )
}

它不是把整个 Attention 层的所有权重直接改成 FP8。

在这个 KV Cache recipe 中,它主要负责生成 Attention Query 路径需要的 q_scale;而:

kv_cache_scheme=fp8_args

负责生成 K/V Cache 的 k_scalev_scale

这和 Flash Attention 3 下 Q/K/V 都进入 FP8 Attention 路径有关。

所以对比实验时,要清楚:

校准 checkpoint 可能提供 q/k/v scale
运行时是否实际使用 q_scale
取决于 Attention backend 和量化路径

19.4 attention_target 不能照抄

官方 Llama 示例使用:

LlamaAttention

换成 Qwen、Mistral、Gemma 或自定义模型后,模块类名可能不同。

可以先运行:

from transformers import AutoModelForCausalLM

model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype="auto",
)

names = sorted({
    module.__class__.__name__
    for module in model.modules()
    if "Attention" in module.__class__.__name__
})

print("\n".join(names))

然后把真正的 Attention 类名填入 --attention-target

不要仅仅把字符串猜成 QwenAttention。Transformers 版本变化也可能导致模块类名和包装层发生变化。

19.5 校准数据怎么选

校准数据不需要像训练集一样巨大,但要尽量覆盖真实分布。

至少关注:

语言

中文客服模型不要只用英文论坛数据校准。

任务

代码、数学、RAG、长文摘要、工具调用的激活分布可能不同。

Prompt 模板

系统提示词、Chat Template、工具 schema 会直接进入模型,校准时应尽量复现真实模板。

长度

如果生产请求主要是 16K~64K,全部用 512 token 短样本校准并不理想。

但把所有样本直接设成 128K 也非常昂贵。更实际的方法是构建分层长度桶:

短请求:  1K~2K
中请求:  4K~8K
长请求: 16K~32K
极长请求:按业务少量抽样

离群场景

包括:

  • 大量数字;
  • 代码块;
  • JSON 与工具定义;
  • 表格;
  • 多语言混排;
  • 超长重复文本;
  • 业务中最容易失败的输入。

19.6 样本越多不一定越好

如果数据完全不匹配,增加样本只会更稳定地估计错误分布。

校准集应该满足:

代表性
>
盲目追求规模

512 条可以作为起点,不是神奇常数。

建议比较:

128 条
512 条
1024 条

观察 scale 是否稳定、质量是否继续改善,再决定是否增加。

19.7 部署校准后的 checkpoint

完成校准后:

vllm serve ./Llama-3.1-8B-kvattn-fp8-tensor \
  --served-model-name kv-test \
  --kv-cache-dtype fp8 \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.90 \
  --port 8000

Per-head checkpoint 需要确保当前 Attention backend 支持:

vllm serve ./Llama-3.1-8B-kvattn-fp8-head \
  --served-model-name kv-test \
  --kv-cache-dtype fp8 \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.90 \
  --port 8000

启动日志中应确认:

  • checkpoint 量化配置被识别;
  • K/V scale 成功加载;
  • 没有静默回退到 1.0;
  • per-head scale 与 Attention backend 匹配;
  • KV Cache dtype 是预期格式;
  • 模型没有回退到不支持的通用 Kernel。

19.8 如何初步检查 checkpoint 是否写入量化配置

可以查看目录:

find ./Llama-3.1-8B-kvattn-fp8-tensor -maxdepth 2 -type f | sort

搜索相关字段:

grep -R -n -E 'kv_cache|k_scale|v_scale|q_scale|quantization' \
  ./Llama-3.1-8B-kvattn-fp8-tensor

但不要把 grep 没找到某个明文字段直接等价为“没有 scale”。

scale 可能位于:

  • safetensors 张量;
  • 压缩配置;
  • 模型 config;
  • 量化元数据;
  • 后端特定文件。

最可靠的验证仍然是:

查看加载日志
+
检查模型张量/配置
+
运行基线与异常检测

二十、启动 OpenAI 兼容服务并做长上下文压测

“FP8 服务能回答一句你好”只能说明接口通了。

KV Cache 量化的价值要在下面的压力中体现:

长 Prompt
更高并发
更长输出
接近 Cache 容量上限

20.1 用 OpenAI SDK 做冒烟请求

先启动服务,然后安装客户端:

pip install -U openai

保存为 smoke_openai.py

from openai import OpenAI


client = OpenAI(
    base_url="http://127.0.0.1:8000/v1",
    api_key="EMPTY",
)

response = client.chat.completions.create(
    model="kv-test",
    messages=[
        {
            "role": "user",
            "content": "用四句话解释 KV Cache 量化的收益与风险。",
        }
    ],
    temperature=0,
    max_tokens=256,
)

print(response.choices[0].message.content)
print(response.usage)

运行:

python smoke_openai.py

20.2 BF16 和 FP8 必须顺序测试

单张 GPU 上不要同时启动两个都使用 90% 显存预算的服务。

推荐流程:

1. 启动 BF16 Cache 服务
2. 预热
3. 完成所有 Benchmark
4. 保存日志
5. 停止服务
6. 启动 FP8 Cache 服务
7. 使用完全相同的 Benchmark 参数

确保两次测试使用:

  • 同一张 GPU;
  • 同一 vLLM 版本;
  • 同一模型权重;
  • 同一 max-model-len
  • 同一显存预算;
  • 同一 Attention backend;
  • 同一随机种子;
  • 同一 Prompt 与输出长度;
  • 同一并发与请求速率;
  • 相同的 Prefix Caching 设置。

否则差异很难归因到 KV dtype。

20.3 用 vllm bench serve 生成固定长度压力

当前 vLLM 提供合成的 text-only RandomDataset,可通过 random-input-lenrandom-output-len 控制长度。13

例如:

mkdir -p results/bf16

vllm bench serve \
  --backend vllm \
  --endpoint /v1/completions \
  --model kv-test \
  --tokenizer Qwen/Qwen2.5-7B-Instruct \
  --dataset-name random \
  --num-prompts 128 \
  --max-concurrency 8 \
  --random-input-len 16384 \
  --random-output-len 256 \
  --random-range-ratio 0.0 \
  --request-rate inf \
  --ignore-eos \
  --seed 42 \
  --port 8000 \
  --save-result \
  --save-detailed \
  --result-dir results/bf16

FP8 服务启动后,原样运行,只改结果目录:

mkdir -p results/fp8

vllm bench serve \
  --backend vllm \
  --endpoint /v1/completions \
  --model kv-test \
  --tokenizer Qwen/Qwen2.5-7B-Instruct \
  --dataset-name random \
  --num-prompts 128 \
  --max-concurrency 8 \
  --random-input-len 16384 \
  --random-output-len 256 \
  --random-range-ratio 0.0 \
  --request-rate inf \
  --ignore-eos \
  --seed 42 \
  --port 8000 \
  --save-result \
  --save-detailed \
  --result-dir results/fp8

参数名会随 vLLM 版本演进,执行前应检查:

vllm bench serve --help

特别确认:

  • random 数据集名称;
  • --tokenizer
  • 保存结果参数;
  • 后端与 endpoint 组合。

20.4 为什么使用 --ignore-eos

如果不忽略 EOS,不同请求可能提前结束:

请求 A 输出 19 token
请求 B 输出 256 token

这样不同方案的 Decode 工作量不一致。

--ignore-eos 会尽量强制生成目标输出长度,更适合固定负载压测。

但它不是正常业务行为,所以结果只能用于容量和性能比较,不能代替真实流量测试。

20.5 建议的长度与并发矩阵

先固定输出 256 token:

Prompt 长度并发
2K1、4、8、16
8K1、4、8、16
16K1、4、8
32K1、2、4

再固定 Prompt 8K,改变输出:

32
128
512
2048

为什么要同时改变输入和输出?

  • 长输入主要放大 Prefill 与初始 KV Cache;
  • 长输出让 Cache 在 Decode 阶段继续增长;
  • 高并发放大总 block 需求;
  • 不同组合可能触发完全不同的调度行为。

20.6 重点观察哪些指标

TTFT

Time To First Token,首 token 延迟。

它主要包含排队和 Prefill,是长 Prompt 体验的核心指标。

TPOT

Time Per Output Token,平均每个输出 token 的时间。

ITL

Inter-token Latency,连续输出 token 之间的延迟。

平均值之外,还要看 P95、P99。

E2E Latency

从发送请求到完整响应结束的总延迟。

Request Throughput

每秒完成多少请求。

Output Token Throughput

每秒生成多少输出 token。

成功率与 OOM

某个方案 token/s 很高,但高并发下大量失败,就不能算更好。

Preemption / Recompute

KV block 不足时,引擎可能抢占请求,并在之后重新计算部分状态。

这会让尾延迟急剧变差。

KV 容量与驻留

当前 vLLM 还提供 KV Cache 相关可观测参数和指标。部署时可以按需开启 --kv-cache-metrics,但指标采集本身也有开销,应在压测中验证。12

20.7 不要只测“刚好能跑”的点

假设:

BF16 最大并发 4
FP8 最大并发 8

只比较并发 4 时,可能看不到 FP8 的主要价值。

应该同时比较:

相同并发下:延迟与吞吐变化
最大稳定并发:容量变化
相同 SLO 下:可承载请求数变化

例如 SLO 是:

P95 TTFT < 2 秒
P95 ITL < 80 毫秒
失败率 < 0.1%

真正的问题是:

BF16 和 FP8 在满足相同 SLO 的前提下,分别能承载多大流量?

20.8 Prefix Cache 会污染对照吗

如果测试请求共享相同长前缀,Prefix Cache 可能让后续请求直接复用 KV:

  • Prefill 变少;
  • KV block 被共享;
  • TTFT 显著下降。

这是真实优化,但会掩盖 KV dtype 的独立效果。

因此建议做两轮:

实验 A:关闭或避免前缀复用,测纯 KV dtype
实验 B:开启真实 Prefix Cache,测生产组合收益

不要在 BF16 组拥有高命中率、FP8 组没有命中率时直接比较。

20.9 随机数据只能测性能,不能测质量

RandomDataset 的价值是:

  • 控制 token 长度;
  • 控制输出长度;
  • 生成大量请求;
  • 避免下载大数据集。

但随机 token 不代表:

  • 中文语义;
  • 代码结构;
  • RAG 文档;
  • 长距离检索;
  • 业务 Prompt;
  • 指令遵循。

所以性能压测和质量评测必须分开。


二十一、TensorRT-LLM 的 FP8 与 NVFP4 KV Cache

在 NVIDIA GPU 的高性能生产部署中,TensorRT-LLM 也是重要路线。

它把:

  • 模型图优化;
  • 低精度 Kernel;
  • Attention 插件;
  • KV Cache 管理;
  • 多 GPU 并行;

放在一个更偏 NVIDIA 平台深度优化的栈中。

21.1 手动开启 FP8 KV Cache

当前 TensorRT-LLM LLM API 可以直接配置:

from tensorrt_llm import LLM
from tensorrt_llm.llmapi import KvCacheConfig


llm = LLM(
    model="/path/to/model",
    kv_cache_config=KvCacheConfig(dtype="fp8"),
)

outputs = llm.generate("请解释什么是 KV Cache 量化。")
print(outputs)

官方文档说明,即使 checkpoint 默认没有启用,也可以手动设置 FP8 KV Cache。14

但这不等于任何模型、任何 NVIDIA GPU 都有相同表现。

仍需核对:

  • TensorRT-LLM 版本;
  • GPU 架构;
  • 模型支持矩阵;
  • Attention 插件;
  • scale 生成方式;
  • 权重和激活量化组合。

21.2 NVFP4 KV Cache

NVFP4 是更激进的 4-bit 浮点路径。

当前 TensorRT-LLM 文档要求先使用 NVIDIA Model Optimizer 做离线量化,然后配置:

from tensorrt_llm import LLM
from tensorrt_llm.llmapi import KvCacheConfig


llm = LLM(
    model="/path/to/nvfp4-quantized-model",
    kv_cache_config=KvCacheConfig(dtype="nvfp4"),
)

官方示例的离线转换流程类似:

git clone https://github.com/NVIDIA/Model-Optimizer.git
cd Model-Optimizer/examples/llm_ptq

scripts/huggingface_example.sh \
  --model <huggingface_model_card> \
  --quant fp8 \
  --kv_cache_quant nvfp4

当前文档还指出,启用 NVFP4 KV Cache 时,相关路径对权重/激活量化组合存在要求。具体支持必须查看当期 Model Support Matrix 和 Hardware Support Matrix,不能把命令从 Blackwell 环境直接复制到任意显卡。14

21.3 FP8 与 NVFP4 的工程取舍

维度FP8 KVNVFP4 KV
理论主体大小BF16 的约 1/2BF16 的约 1/4
数值余量更宽松更激进
校准敏感度更高
硬件与 Kernel 要求通常更高、更新
质量风险相对较低更需要严格长上下文评测
适用起点大多数低精度服务实验极限容量与新硬件优化

在没有强容量压力时,FP8 往往是更合理的第一步。

NVFP4 的意义不是“数字更小所以必选”,而是:

在硬件、模型、Kernel 和校准都匹配时
进一步提高可容纳 token 或并发

21.4 不要跨引擎直接比较格式名字

vLLM 的 fp8 和 TensorRT-LLM 的 fp8 都写着 FP8,但它们可能在以下方面不同:

  • scale 粒度;
  • Q 是否量化;
  • Attention 是否在 FP8 域执行;
  • Cache block 布局;
  • scale 存储;
  • 反量化位置;
  • GPU Kernel;
  • 调度方式;
  • Prefix Cache 和分页管理。

所以公平比较必须是:

完整部署栈 vs 完整部署栈

而不是只比较 dtype 字符串。


二十二、如何评估长上下文质量有没有掉

KV Cache 量化最危险的评测方式是:

问一句“北京是中国的首都吗?”
模型答对了
→ 宣布量化无损

这个测试几乎没有给 KV Cache 施加压力。

真正应该问的是:

当关键信息位于很长上下文的不同位置、Cache 中大量历史 K/V 已经被量化后,模型还能否稳定检索、聚合和推理?

22.1 先控制变量

要评估“只改变 KV Cache dtype”的影响,必须保持以下变量相同:

  • 模型权重完全相同;
  • tokenizer 相同;
  • Chat Template 相同;
  • RoPE 和最大长度配置相同;
  • 推理引擎版本相同;
  • Attention backend 尽量相同;
  • Prompt token 完全相同;
  • 生成参数相同;
  • Prefix Caching 状态相同;
  • 不使用 CPU offload 或其他只在一组开启的优化。

对照组例如:

A:BF16 KV Cache
B:未校准 FP8 KV Cache
C:per-tensor 校准 FP8 KV Cache
D:per-head 校准 FP8 KV Cache
E:INT4 KV Cache

这样才能回答:

质量差异来自位数?
来自 scale?
来自 scale 粒度?
还是来自别的 Kernel?

22.2 使用确定性解码

质量回归测试首先使用:

temperature = 0
或 greedy decoding
固定 max_tokens
固定 stop 条件

采样本身会产生随机差异。

如果量化组和基线组使用 temperature=0.8,输出不同并不能证明 KV Cache 导致了退化。

即使使用 greedy,不同低精度 Kernel 在极接近的 logits 上也可能改变首个分叉 token。此时要区分:

文本不同

和:

任务答案错误

文本逐字一致率可以记录,但不能作为唯一质量指标。

22.3 第一层:短上下文通用能力回归

先确认 KV 量化没有在短序列上造成明显异常:

  • 基础知识问答;
  • 数学;
  • 代码;
  • 指令遵循;
  • 结构化 JSON;
  • 多语言。

可以使用 lm-evaluation-harness 或已有业务评测集跑一组稳定基线。15

这一步的意义是排查:

  • scale 严重错误;
  • Kernel Bug;
  • checkpoint 不兼容;
  • 某些层异常饱和。

但短上下文通过,仍不能证明长上下文通过。

22.4 第二层:Needle in a Haystack

最简单的长上下文检索测试是把一条唯一信息插入大量干扰文本中:

干扰文本……
验证码是 ZXQ-731942。
干扰文本……

问题:验证码是什么?

至少改变两个维度:

上下文长度

4K
8K
16K
32K
64K
128K

Needle 深度

10%
25%
50%
75%
90%

因为某些模型只在信息位于开头或结尾时表现好。

下面给出一个可用于 vLLM OpenAI Completion API 的小脚本。

保存为:

eval_needle.py
from __future__ import annotations

import argparse
import math

from openai import OpenAI
from transformers import AutoTokenizer


def build_prompt(
    tokenizer,
    target_tokens: int,
    depth: float,
    needle: str,
) -> tuple[str, int]:
    if not 0 <= depth <= 1:
        raise ValueError("depth 必须在 0 和 1 之间")

    question = "\n\n请只回答验证码,不要解释。验证码是什么?"
    filler = (
        "这是一段用于测试长上下文检索能力的背景材料。"
        "其中大多数句子与最终问题无关,请继续认真阅读。\n"
    )
    needle_text = f"\n重要信息:本次测试的唯一验证码是 {needle}。\n"

    filler_ids = tokenizer(
        filler,
        add_special_tokens=False,
    )["input_ids"]
    needle_ids = tokenizer(
        needle_text,
        add_special_tokens=False,
    )["input_ids"]
    question_ids = tokenizer(
        question,
        add_special_tokens=False,
    )["input_ids"]

    available = target_tokens - len(needle_ids) - len(question_ids)
    if available <= len(filler_ids):
        raise ValueError("target_tokens 太小,无法构造测试")

    repeats = math.ceil(available / len(filler_ids))
    haystack_ids = (filler_ids * repeats)[:available]

    insert_at = int(len(haystack_ids) * depth)
    prompt_ids = (
        haystack_ids[:insert_at]
        + needle_ids
        + haystack_ids[insert_at:]
        + question_ids
    )

    prompt = tokenizer.decode(
        prompt_ids,
        skip_special_tokens=True,
    )

    # decode 后再次 tokenize,真实长度可能有少量变化,因此重新统计。
    actual_tokens = len(
        tokenizer(prompt, add_special_tokens=False)["input_ids"]
    )
    return prompt, actual_tokens


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--base-url", default="http://127.0.0.1:8000/v1")
    parser.add_argument("--model", default="kv-test")
    parser.add_argument(
        "--tokenizer",
        default="Qwen/Qwen2.5-7B-Instruct",
    )
    parser.add_argument("--target-tokens", type=int, default=16384)
    parser.add_argument("--depth", type=float, default=0.5)
    parser.add_argument("--needle", default="ZXQ-731942")
    args = parser.parse_args()

    tokenizer = AutoTokenizer.from_pretrained(args.tokenizer)
    prompt, actual_tokens = build_prompt(
        tokenizer=tokenizer,
        target_tokens=args.target_tokens,
        depth=args.depth,
        needle=args.needle,
    )

    client = OpenAI(
        base_url=args.base_url,
        api_key="EMPTY",
    )
    response = client.completions.create(
        model=args.model,
        prompt=prompt,
        temperature=0,
        max_tokens=32,
    )

    text = response.choices[0].text.strip()
    passed = args.needle.lower() in text.lower()

    print(f"目标 token : {args.target_tokens}")
    print(f"实际 token : {actual_tokens}")
    print(f"Needle 深度: {args.depth:.2%}")
    print(f"预期答案   : {args.needle}")
    print(f"模型输出   : {text}")
    print(f"是否通过   : {passed}")


if __name__ == "__main__":
    main()

例如:

python eval_needle.py --target-tokens 16384 --depth 0.10
python eval_needle.py --target-tokens 16384 --depth 0.50
python eval_needle.py --target-tokens 16384 --depth 0.90

再改成 32K:

python eval_needle.py --target-tokens 32768 --depth 0.10
python eval_needle.py --target-tokens 32768 --depth 0.50
python eval_needle.py --target-tokens 32768 --depth 0.90

需要提醒:

NIAH 通过,只能证明模型能够从长上下文中找到一条显眼信息,不能证明它具备完整长文理解能力。

22.5 第三层:RULER

RULER 在普通 Needle 测试之上增加了:

  • 多个 Needle;
  • 不同 Needle 类型;
  • 多跳追踪;
  • 聚合;
  • 变量跟踪;
  • 更复杂的上下文干扰。

它的价值在于揭示:有些模型在单 Needle 上接近满分,但随着长度和任务复杂度增加会明显下降。16

对于 KV Cache 量化,建议至少报告:

8K RULER
16K RULER
32K RULER
64K RULER

并分别列出每个任务,不要只给一个平均分。

量化可能对某些任务影响很小,却对多跳或聚合任务影响更大。

22.6 第四层:LongBench 与 LongBench v2

LongBench 覆盖:

  • 单文档问答;
  • 多文档问答;
  • 摘要;
  • Few-shot Learning;
  • 合成任务;
  • 代码补全;
  • 中文与英文。

它比单一检索任务更接近真实长文本应用。17

LongBench v2 更强调真实场景中的深层理解和推理,包含长对话、代码仓库、结构化数据等任务。18

评估 KV Cache 量化时,建议同时保留:

原始样本分数
按长度分桶分数
按任务类别分数
失败案例

平均分可能掩盖一个严重问题:

8K 几乎不掉
32K 小幅下降
64K 后突然崩坏

22.7 第五层:真实业务回放

公开 Benchmark 永远不能完全代表生产。

至少抽取一批脱敏后的真实请求,覆盖:

  • 常见请求;
  • 最长请求;
  • 最复杂系统提示词;
  • 多轮对话;
  • RAG 多文档;
  • 工具调用;
  • JSON 输出;
  • 代码与表格;
  • 历史上失败率最高的样本。

一个实用分桶方式:

长度桶样本占比建议
0~2K20%
2K~8K25%
8K~16K20%
16K~32K20%
32K 以上15%

这个比例不必照搬,应该匹配你的真实流量。

22.8 记录相对退化,而不是只记绝对分数

假设:

BF16:85.0
FP8 :84.7
INT4:82.1

应同时报告:

FP8 相对 BF16:-0.3
INT4 相对 BF16:-2.9

更重要的是报告置信区间和重复实验波动。

如果基准本身随机波动 ±0.5,那么一次 -0.3 不能直接下结论。

22.9 检查不同长度下的误差曲线

KV Cache 量化误差不是一定随长度严格线性增加,但长上下文会让更多历史 K/V 进入低精度区。

建议画:

横轴:上下文长度
纵轴:任务得分
曲线:BF16、FP8、INT4、INT2

同时画:

横轴:上下文长度
纵轴:P95 TTFT / P95 ITL / 峰值显存

最终才能看清:

质量下降发生在哪个长度,显存收益又从哪个长度开始真正值得。

22.10 不要让模型本身的长上下文缺陷背锅给量化

即使使用 BF16 KV Cache,模型也可能在 64K 以后明显退化。

原因可能包括:

  • 训练时长上下文不足;
  • RoPE 外推;
  • Position Bias;
  • Attention Sink;
  • 有效上下文远短于声明窗口;
  • Benchmark 超出模型擅长任务。

所以永远需要 BF16/FP16 KV 基线。

正确结论是:

同一个模型、同一个长度下
量化组相对原生 Cache 掉了多少

而不是:

量化模型在 128K 没答对
所以一定是量化导致的

二十三、常见报错与避坑

误区 1:权重已经 4 bit,KV Cache 也自动是 4 bit

错。

权重 dtype 和 KV dtype 是两个独立配置。

一个 AWQ/GPTQ/EXL3/GGUF 模型的 KV Cache 仍然可能默认使用 FP16 或 BF16。

必须单独检查:

kv_cache_dtype
cache_type_k
cache_type_v
或对应框架参数

误区 2:量化后模型文件没有变小,所以没生效

KV Cache 是推理时根据输入动态产生的。

运行时 KV 量化通常不会修改基础模型权重文件大小。

判断是否生效应该看:

  • 运行日志;
  • Cache dtype;
  • block 容量;
  • 长序列显存;
  • 可承载并发;
  • Profile。

而不是看 .safetensors 是否变小。

误区 3:vLLM 开 FP8 后,nvidia-smi 没下降

不一定是没生效。

vLLM 可能把省下来的单位 token 空间重新用于扩大 KV block pool。

同样是 18GB 池:

BF16 装较少 token
FP8 装较多 token

要比较可缓存 token 和并发容量。

误区 4:未校准 FP8 能说话,所以可以上线

默认 scale=1.0 只能说明没有立刻崩溃。

真正上线前至少需要:

  • 真实数据集校准;
  • 长上下文评测;
  • 业务回放;
  • 异常样本检查;
  • 负载压测。

误区 5:随机 token 校准等价于业务校准

错。

随机 token 只覆盖一种人工分布。

你的生产输入可能包含:

  • 中文;
  • 代码;
  • JSON;
  • 表格;
  • 超长文档;
  • 固定系统提示词。

而且 vLLM 当前已经把在线 calculate_kv_scales 标记为弃用路径。新的生产流程更应把 scale 写入 checkpoint。12

误区 6:FP8 一定比 BF16 更快

不一定。

可能出现:

Cache 更小
但量化/反量化有开销

只有在:

  • Kernel 支持;
  • Attention 融合良好;
  • 当前受显存带宽或容量限制;
  • 更大 batch/并发带来的收益被利用;

时,FP8 才更容易体现吞吐优势。

短上下文、batch 1 下甚至可能看不到加速。

误区 7:INT4 一定比 FP8 节省整整一半显存

只对 Cache 主体近似成立。

INT4 还可能包含:

  • scale;
  • zero-point;
  • group 对齐;
  • residual cache;
  • block padding;
  • 高精度敏感层。

所以真实压缩率通常小于理论 2 倍。

误区 8:Prompt 32K 就只需要 32K Cache

生成过程中 Cache 继续增长。

输入 32K
输出 4K
最终序列 36K

服务端的 max-model-len 通常约束输入与输出总和。

预算必须包含目标输出长度。

误区 9:batch size 只影响算力,不影响 KV Cache

错。

独立序列通常拥有独立 KV:

Cache ∝ batch / 活跃序列数

连续批处理虽然让请求动态进出,但总驻留 token 仍然决定 KV block 需求。

误区 10:beam search 和普通 greedy 占用相同

Beam Search 通常要为多个候选分支保留或复制状态。

num_beams=4 可能让有效 Cache 规模接近单序列的数倍,具体取决于框架共享与重排实现。

显存预算中必须单独测试。

报错 1:The model's max seq len is larger than the maximum number of tokens that can be stored in KV cache

含义:

你声明的 max-model-len
>
当前 KV block pool 能容纳的长度

解决顺序:

  1. 降低 --max-model-len
  2. 使用 --max-model-len -1 或当前版本支持的自动选择方式;
  3. 提高可用显存预算,但必须给权重和工作区留空间;
  4. 使用 FP8/更低位 KV Cache;
  5. 减少权重占用;
  6. 增加 GPU 或调整并行方案;
  7. 检查是否有其他进程占用显存。

不要把 gpu_memory_utilization 直接设到 1.0 作为第一反应,运行时工作区和显存波动仍可能 OOM。

报错 2:invalid choice 或不认识 fp8_per_token_head

原因通常是:

  • vLLM 版本较旧;
  • 当前平台没有注册该 dtype;
  • 教程使用了开发分支参数;
  • dtype 名称发生变化。

先运行:

vllm serve --help | grep -A 8 "kv-cache-dtype"

以本机输出为准。

不要为了一个参数盲目升级整个生产栈,先在隔离环境验证模型、CUDA 和 Kernel 兼容性。

报错 3:Per-head scale 无法加载或回退

检查:

  • checkpoint 是否真的包含 per-head q/k/v scale;
  • strategy 是否为 attn_head
  • Attention target 是否匹配模型;
  • 当前 backend 是否为支持路径;
  • Flash Attention 版本是否满足要求;
  • 模型的 Q Head 与 KV Head 数是否匹配 scale 形状。

Per-head 不是“多写一个参数”就能启用,它需要从校准到 Kernel 的整条链路一致。

报错 4:Transformers 提示找不到 Quanto 或 HQQ

Quanto:

pip install -U optimum-quanto

HQQ:

pip install -U hqq

然后确认包安装到了正在运行脚本的同一个 Python:

which python
python -m pip show optimum-quanto hqq

避免系统 Python、conda、venv 混用。

报错 5:QuantizedCache 出现轴或 shape 错误

可能原因:

  • axis_key / axis_value 不适合当前后端;
  • 模型使用特殊 Cache layout;
  • GQA 张量轴与旧版本假设不同;
  • 模型拥有滑动窗口、跨层共享或混合层;
  • Transformers 与量化后端版本不兼容。

先用官方推荐轴测试:

Quanto:常见 axis 0
HQQ:常见 axis 1

再打印实际 Cache 张量 shape,并核对当前源码,不要在不知道每个轴代表什么时随机改数字。

报错 6:量化 Cache 反而更慢

这在通用 Transformers 后端和短上下文中很常见。

排查:

  • Prompt 是否太短;
  • residual cache 是否已经覆盖大部分上下文;
  • 是否频繁量化/反量化;
  • batch 是否为 1;
  • 当前是否算力瓶颈而非带宽瓶颈;
  • 是否没有专用 fused kernel;
  • 测试是否包含首次预热;
  • 是否把 Prefill 与 Decode 混在一个 token/s 中。

如果显存足够且延迟更重要,原生 Cache 可能就是更好的答案。

报错 7:长 Prompt 仍然 OOM

KV Cache 不一定是唯一峰值来源。

长 Prompt Prefill 还会产生:

  • Attention 临时张量;
  • 激活;
  • Workspace;
  • Logits;
  • 多模态输入状态。

KV Cache 量化能减少持久缓存,但不保证所有 Prefill 中间开销按相同比例下降。

可继续检查:

  • Flash Attention;
  • Chunked Prefill;
  • batch/token 调度上限;
  • 权重量化;
  • Context Parallel;
  • 临时工作区;
  • 是否一次性返回所有 logits。

报错 8:32K 正常,64K 答案明显变差

先分别测试:

BF16 32K
BF16 64K
FP8 32K
FP8 64K

如果 BF16 在 64K 也下降,问题可能是模型自身有效上下文。

如果只有 FP8 在 64K 明显下降,再检查:

  • scale 饱和;
  • 校准长度分布;
  • sensitive layer;
  • E4M3 与 E5M2;
  • per-tensor 与 per-head;
  • Attention backend。

报错 9:测试结果波动很大

固定:

  • temperature=0
  • 随机种子;
  • 请求顺序;
  • Prompt token;
  • 输出长度;
  • GPU 时钟/功耗模式;
  • 后台进程;
  • Prefix Cache;
  • 服务预热;
  • 测试轮次。

性能结果至少报告多次重复和 P50/P95/P99,不要只截一轮最高 token/s。

报错 10:启用了 use_cache=False,显存反而更低

是的,因为没有保存 KV Cache。

但每生成一个新 token,模型必须重复计算全部历史上下文,计算复杂度和延迟会急剧增加。

这不是一个正常的长上下文优化方案。

use_cache=False 更适合训练、调试或特殊算法,不应作为常规自回归服务的省显存捷径。


二十四、一套可以直接照抄的选型流程

面对一个长上下文模型,可以按照下面的顺序做,而不是直接猜 INT4 还是 FP8

第一步:确认模型 Cache 架构

从配置和实现中确认:

层数 L
Query Head 数 Hq
KV Head 数 Hkv
Head Dim D
MHA / GQA / MQA / MLA
是否有 Sliding Window
是否混合 Attention 与 Mamba
是否跨层共享 Cache

这是所有显存计算的基础。

第二步:计算目标负载的理论下限

至少代入:

平均 Prompt 长度
P95 Prompt 长度
平均输出长度
P95 输出长度
目标并发
Cache dtype

不要只计算单请求最大长度,也不要只计算输入而忽略输出。

第三步:跑原生 Cache 基线

先测 BF16/FP16:

  • 质量;
  • TTFT;
  • ITL;
  • 吞吐;
  • 峰值显存;
  • 最大稳定并发;
  • KV block 容量。

没有基线,后续所有“掉了多少”和“快了多少”都没有参照。

第四步:先优化管理,再判断是否必须量化

检查是否已经使用:

  • PagedAttention;
  • Prefix Caching;
  • 合理的 block 大小;
  • Chunked Prefill;
  • GQA/MLA 友好模型;
  • 正确的 max-model-len
  • 合理的并发调度;
  • 避免过度预留 Static Cache。

如果问题只是碎片或重复前缀,量化未必是第一工具。

第五步:服务端优先从 FP8 开始

在支持良好的 GPU 和引擎上,FP8 通常是较稳妥起点:

约减半 KV 主体
数值余量比 INT4 更大
服务框架支持更成熟

先做冒烟,再做真实数据校准。

第六步:把 scale 写入 checkpoint

生产路线优先:

业务代表性数据
→ 离线校准
→ 保存 q/k/v scale
→ 固定 checkpoint 与运行时版本

不要把默认 1.0 或已弃用的随机 warmup 当成最终方案。

第七步:容量仍不够,再测试 INT4 或更激进格式

此时重点调:

  • group size;
  • residual length;
  • Key 与 Value 粒度;
  • 敏感层混合精度;
  • outlier 处理;
  • 专用 Kernel。

INT2、3-bit、TurboQuant、KIVI、KVQuant、AQUA-KV 等更适合在有明确极限需求时深入。它们的论文结果建立在特定模型、数据、Kernel 和实验条件上,不能直接当成所有部署的默认收益。3678

第八步:组合其他优化

常见生产组合:

低比特权重
+ FP8 KV Cache
+ PagedAttention
+ Prefix Caching
+ Chunked Prefill

极端显存环境还可能使用:

KV Offload
Context Parallel
Sliding Window
Cache Pruning

每增加一项优化,都要重新跑完整基准,不要假设收益可以简单相加。

第九步:按 SLO 选择,而不是按 bit 数选择

建立最终表格:

方案最大稳定并发P95 TTFTP95 ITL输出吞吐长上下文得分失败率
BF16 KV
FP8 默认 scale
FP8 校准
INT4 KV

然后根据业务 SLO 选:

满足质量与延迟要求的方案中
单位 GPU 成本最低者

而不是“位数最低者”。

第十步:冻结并监控

上线时固定:

  • 模型 commit;
  • tokenizer commit;
  • 量化 checkpoint;
  • scale;
  • CUDA;
  • PyTorch;
  • vLLM / TensorRT-LLM;
  • Attention backend;
  • 启动参数。

持续监控:

  • KV 使用率;
  • block 命中与回收;
  • Prefix Cache 命中率;
  • 抢占次数;
  • OOM;
  • TTFT/ITL;
  • 长请求失败率;
  • 业务质量漂移。

24.1 按场景快速选

场景推荐起点原因
本地短聊天,显存足够原生 KV最简单,避免额外转换
本地长文,Transformers 单进程Quanto/HQQ INT4容易验证,能突破显存上限
NVIDIA GPU 在线服务校准 FP8 KV + vLLM容量、生态和质量平衡较好
大量相同系统 PromptPrefix Cache 优先,再考虑 FP8先消除重复计算与副本
Cache 很大但质量极敏感BF16/FP16 + PagedAttention/Offload避免量化误差
新一代 NVIDIA 平台极限容量TensorRT-LLM FP8/NVFP4,按矩阵验证利用专用硬件与 Kernel
研究 2~3 bit 极限上下文KIVI/KVQuant/AQUA-KV/TurboQuant需要专用算法、校准与评测
混合滑动窗口模型分层预算,必要时跳过敏感层每层 Cache 行为不同

24.2 一句话决策树

显存够、延迟优先?
→ 先用原生 KV

显存不够或并发受限?
→ 先试校准 FP8

FP8 仍不够?
→ 试 INT4 / 专用低比特方案

有大量重复前缀?
→ 同时开 Prefix Cache

GPU 仍装不下?
→ 考虑 Offload / 多卡 / Context Parallel

质量下降?
→ 重新校准、细化 scale、跳过敏感层或提高位数

二十五、小结

这一篇的核心,不是记住一个 --kv-cache-dtype 参数,而是建立下面这套完整认知。

1. KV Cache 是长上下文的动态显存大户

权重加载后基本固定,而 KV Cache 会随着:

上下文长度
输出长度
batch
并发序列
beam 数

近似线性增长。

所以“4-bit 权重模型只占几 GB”并不代表 128K 上下文也只需要几 GB。

2. 标准 KV Cache 的核心公式

对于标准 MHA/GQA/MQA:

KV Cache 字节数
≈ 2
× 层数
× batch
× token 数
× KV Head 数
× Head Dim
× 每元素字节数

2 代表 Key 与 Value。

3. GQA、MQA 和 MLA 是结构级优化

它们减少的是:

  • KV Head 数;
  • 或每 token 缓存的表示维度。

这和把 BF16 元素变成 FP8/INT4 是不同维度,可以组合。

4. KV Cache 比权重更难量化

因为它:

  • 随输入动态产生;
  • 每个 token 分布不同;
  • 参与后续所有注意力;
  • 长时间驻留并反复读取;
  • K 与 V 的离群模式不同;
  • 错误会改变注意力分数与信息读取。

5. Key 和 Value 不应机械使用同一种粒度

KIVI 的重要发现是:

Key 更适合 per-channel
Value 更适合 per-token

现代方案还会加入:

  • residual cache;
  • outlier 保护;
  • 非均匀码本;
  • 旋转;
  • 跨层预测;
  • 混合精度。

6. FP8 是服务端常见起点

它把 Cache 主体从 2 Byte/元素降到约 1 Byte/元素,通常比 INT4 更容易控制质量。

但 FP8 仍需要合适的 scale。

默认 scale=1.0 更适合冒烟测试,真实数据集离线校准才是更稳的生产路线。

7. INT4/INT2 更省,但不是免费午餐

位数越低,越依赖:

  • 合理 group size;
  • residual length;
  • K/V 不同粒度;
  • 离群值处理;
  • 专用 Kernel;
  • 长上下文质量评测。

8. PagedAttention 不是量化

它减少的是分配浪费和碎片,并支持 KV block 共享。

同理:

  • Prefix Cache 复用重复前缀;
  • Offload 把 Cache 搬到 CPU;
  • Static Cache 换取固定形状;
  • Sliding Window 限制保留长度。

这些方法解决的问题不同。

9. 服务端不要只看 nvidia-smi

引擎可能把相同显存预算全部预分配为 block pool。

开启 FP8 后,显存数字不变,但可容纳 token、并发和最长上下文可能显著增加。

要看:

KV block 容量
最大稳定并发
抢占
失败率
TTFT
ITL
吞吐

10. 质量必须按长度评测

至少结合:

  • 短上下文能力回归;
  • Needle in a Haystack;
  • RULER;
  • LongBench / LongBench v2;
  • 真实业务回放。

而且要和同模型 BF16/FP16 KV 基线对比。

11. 最终目标不是最低 bit,而是最低可接受成本

正确问题不是:

能不能把 KV 压到 2 bit?

而是:

在满足质量、TTFT、ITL 和失败率 SLO 的前提下,哪种 KV Cache 方案能让每张 GPU 承载最多有效请求?


一句话总结:

KV Cache 量化不是给模型文件再压缩一次,而是在生成过程中,把不断增长、反复参与注意力的历史记忆用更低精度保存。它真正换来的,是更长上下文和更高并发;真正付出的,则是 scale 管理、Kernel 复杂度、额外转换,以及必须认真验证的长距离信息误差。

下一篇,我们把前面所有格式放进同一套测试框架:

《大模型量化从0到1(十四):量化模型怎么公平评测——PPL、任务得分、显存、TTFT 与吞吐横向对比》

届时会统一比较:

FP16 / BF16
bitsandbytes NF4
GPTQ
AWQ
GGUF
EXL3
FP8
不同 KV Cache dtype

并完整解决一个经常被忽略的问题:

为什么不同框架测出来的 token/s 根本不能直接放在一张表里?

参考资料


  1. Noam Shazeer, Fast Transformer Decoding: One Write-Head is All You Need, 2019. https://arxiv.org/abs/1911.02150 ↩︎

  2. Joshua Ainslie et al., GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints, 2023. https://arxiv.org/abs/2305.13245 ↩︎

  3. Zirui Liu et al., KIVI: A Tuning-Free Asymmetric 2bit Quantization for KV Cache, 2024. https://arxiv.org/abs/2402.02750 ↩︎ ↩︎ ↩︎ ↩︎

  4. Hugging Face Transformers, KV Cache Strategies / QuantizedCache. https://huggingface.co/docs/transformers/en/kv_cache ↩︎ ↩︎ ↩︎

  5. vLLM, Quantized KV Cache. https://docs.vllm.ai/en/latest/features/quantization/quantized_kvcache/ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  6. Coleman Hooper et al., KVQuant: Towards 10 Million Context Length LLM Inference with KV Cache Quantization, 2024. https://arxiv.org/abs/2401.18079 ↩︎ ↩︎

  7. Alina Shutova et al., Cache Me If You Must: Adaptive Key-Value Quantization for Large Language Models (AQUA-KV), 2025. https://arxiv.org/abs/2501.19392 ↩︎ ↩︎

  8. Amir Zandieh et al., Online Vector Quantization with Near-optimal Distortion Rate (TurboQuant), 2025. https://arxiv.org/abs/2504.19874 ↩︎ ↩︎

  9. DeepSeek-AI, DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model, 2024. https://arxiv.org/abs/2405.04434 ↩︎ ↩︎

  10. Woosuk Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention, 2023. https://arxiv.org/abs/2309.06180 ↩︎

  11. Hugging Face Transformers, Caching. https://huggingface.co/docs/transformers/main/cache_explanation ↩︎

  12. vLLM, Engine Arguments — CacheConfig. https://docs.vllm.ai/en/stable/configuration/engine_args/ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  13. vLLM, Benchmark Dataset API — RandomDataset. https://docs.vllm.ai/en/latest/api/vllm/benchmarks/datasets/datasets/ ↩︎

  14. NVIDIA TensorRT-LLM, Quantization — FP8 KV Cache and NVFP4 KV Cache. https://nvidia.github.io/TensorRT-LLM/latest/features/quantization.html ↩︎ ↩︎

  15. EleutherAI, Language Model Evaluation Harness. https://github.com/EleutherAI/lm-evaluation-harness ↩︎

  16. Cheng-Ping Hsieh et al., RULER: What’s the Real Context Size of Your Long-Context Language Models?, 2024. https://arxiv.org/abs/2404.06654 ↩︎

  17. Yushi Bai et al., LongBench: A Bilingual, Multitask Benchmark for Long Context Understanding, 2023. https://arxiv.org/abs/2308.14508 ↩︎

  18. Yushi Bai et al., LongBench v2: Towards Deeper Understanding and Reasoning on Realistic Long-context Multitasks, 2024. https://arxiv.org/abs/2412.15204 ↩︎

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐