大模型量化从0到1(十三):KV Cache 量化——长上下文为什么越跑越吃显存
前十二篇里,我们一直在讨论如何把模型权重压得更小: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 / FP16 | 64GiB |
| FP8 / INT8 | 32GiB |
| INT4 | 16GiB |
| INT2 | 8GiB |
但真实实现通常会更大,因为还要保存:
- 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:低比特编码;x̂:反量化近似值。
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 / MQA | KV Head 数量 |
| MLA | 每 token 要缓存的表示维度 |
| Sliding Window | 每层保留的 token 数 |
| KV 量化 | 每个缓存元素的位宽 |
| Cache Pruning | 实际保留的 token 集合 |
| PagedAttention | 分配浪费与碎片 |
| Prefix Cache | 重复前缀的计算与副本 |
| Offload | GPU 上驻留的 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
不同版本的 llmcompressor、compressed-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_key、axis_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 |
| Cache | Dynamic、INT4、INT2 |
| residual length | 64、128、256 |
| group size | 32、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 已不只有 auto 和 fp8。不同版本、设备和后端还可能列出:
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_scale 和 v_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-len 和 random-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 长度 | 并发 |
|---|---|
| 2K | 1、4、8、16 |
| 8K | 1、4、8、16 |
| 16K | 1、4、8 |
| 32K | 1、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 KV | NVFP4 KV |
|---|---|---|
| 理论主体大小 | BF16 的约 1/2 | BF16 的约 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~2K | 20% |
| 2K~8K | 25% |
| 8K~16K | 20% |
| 16K~32K | 20% |
| 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 能容纳的长度
解决顺序:
- 降低
--max-model-len; - 使用
--max-model-len -1或当前版本支持的自动选择方式; - 提高可用显存预算,但必须给权重和工作区留空间;
- 使用 FP8/更低位 KV Cache;
- 减少权重占用;
- 增加 GPU 或调整并行方案;
- 检查是否有其他进程占用显存。
不要把 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 TTFT | P95 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 | 容量、生态和质量平衡较好 |
| 大量相同系统 Prompt | Prefix 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 根本不能直接放在一张表里?
参考资料
Noam Shazeer, Fast Transformer Decoding: One Write-Head is All You Need, 2019. https://arxiv.org/abs/1911.02150 ↩︎
Joshua Ainslie et al., GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints, 2023. https://arxiv.org/abs/2305.13245 ↩︎
Zirui Liu et al., KIVI: A Tuning-Free Asymmetric 2bit Quantization for KV Cache, 2024. https://arxiv.org/abs/2402.02750 ↩︎ ↩︎ ↩︎ ↩︎
Hugging Face Transformers, KV Cache Strategies / QuantizedCache. https://huggingface.co/docs/transformers/en/kv_cache ↩︎ ↩︎ ↩︎
vLLM, Quantized KV Cache. https://docs.vllm.ai/en/latest/features/quantization/quantized_kvcache/ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Coleman Hooper et al., KVQuant: Towards 10 Million Context Length LLM Inference with KV Cache Quantization, 2024. https://arxiv.org/abs/2401.18079 ↩︎ ↩︎
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 ↩︎ ↩︎
Amir Zandieh et al., Online Vector Quantization with Near-optimal Distortion Rate (TurboQuant), 2025. https://arxiv.org/abs/2504.19874 ↩︎ ↩︎
DeepSeek-AI, DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model, 2024. https://arxiv.org/abs/2405.04434 ↩︎ ↩︎
Woosuk Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention, 2023. https://arxiv.org/abs/2309.06180 ↩︎
Hugging Face Transformers, Caching. https://huggingface.co/docs/transformers/main/cache_explanation ↩︎
vLLM, Engine Arguments — CacheConfig. https://docs.vllm.ai/en/stable/configuration/engine_args/ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
vLLM, Benchmark Dataset API — RandomDataset. https://docs.vllm.ai/en/latest/api/vllm/benchmarks/datasets/datasets/ ↩︎
NVIDIA TensorRT-LLM, Quantization — FP8 KV Cache and NVFP4 KV Cache. https://nvidia.github.io/TensorRT-LLM/latest/features/quantization.html ↩︎ ↩︎
EleutherAI, Language Model Evaluation Harness. https://github.com/EleutherAI/lm-evaluation-harness ↩︎
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 ↩︎
Yushi Bai et al., LongBench: A Bilingual, Multitask Benchmark for Long Context Understanding, 2023. https://arxiv.org/abs/2308.14508 ↩︎
Yushi Bai et al., LongBench v2: Towards Deeper Understanding and Reasoning on Realistic Long-context Multitasks, 2024. https://arxiv.org/abs/2412.15204 ↩︎
更多推荐
所有评论(0)