适合读者:调用过大模型 API、或自己部署过推理服务的人;被"长上下文为什么贵""并发为什么上不去""为什么加了卡还是慢"这类问题困扰的人。
核心收获:① 预填充与解码为什么是两种完全不同的负载;② 能在纸上估出任意模型的 KV Cache 显存占用和并发上限;③ 看懂五种优化手段各自在治什么病、代价是什么、什么时候不该用;④ 一套线上"变慢"的定位流程。
新开一个对话,发一句"你好",字是"跳"出来的,几乎没有延迟。聊到十几轮,同样问一句话,先卡两秒,然后才慢慢往外吐字;再往后,甚至会出现打字机那样一顿一顿的感觉,最后干脆超时报错。
直觉上你会怪网络、怪服务器、怪模型"想得太久"。但做过推理服务的人都知道,这三样通常都不是主因。真正在变慢的是同一件事:你要它看的历史,越来越长了。
更反直觉的是第二层现象:升级到一张更贵的卡,变慢的问题往往没怎么改善。 如果你只把"AI 慢"理解成"算力不够",这个结果无法解释。而理解它,需要先理解一件事------大模型生成文本时,瓶颈根本不在算力上。
这篇文章会从最基本的生成机制讲起,一路推到显存估算、并发上限、五种优化手段的原理与取舍,最后给一套可执行的线上诊断流程。
第一部分:前提 ------ 自回归生成到底在做什么
1.1 "预测下一个词"是一个循环
大模型生成文本的方式,本质上是一个循环:
- 把当前的 token 序列送进模型;
- 模型输出一个概率分布,代表"下一个 token 最可能是谁";
- 按某种采样策略挑一个 token,追加到序列末尾;
- 回到第 1 步,直到生成结束符或达到长度上限。
这个循环叫自回归生成 (autoregressive generation)。关键词是追加:每生成一个 token,序列就长一格,然后整段重新送进模型。
理解这一点非常重要,因为它决定了一个天然的代价:生成第 N 个 token 时,模型必须能看到前 N-1 个 token。 而注意力机制要求"每个位置和前面每个位置都发生一次交互"------这就埋下了后面所有性能问题的种子。
1.2 两个阶段:预填充与解码
虽然从用户视角看,回答是一口气出来的,但在模型内部,一次请求被清晰地切成两个性质完全不同的阶段。
第一阶段:预填充(Prefill)。
你这次发过去的所有内容------系统提示、历史对话、检索到的资料、本轮问题------被一次性、并行地送进模型。所有位置同时计算,产出第一个 token。这一步可以吃满算力,因为它有大量相互独立的计算可以并行。
第二阶段:解码(Decode)。
从第二个 token 开始,一次只能生成一个。每生成一个,就把它接到序列末尾,再算下一个。这一步是严格串行的------不生成第 3 个 token,就没法生成第 4 个。
而串行带来的后果是:每生成一个 token,都要把这一层所有的权重从显存完整读一遍。
这就引出了整篇文章最核心的一组概念:
| 预填充 Prefill | 解码 Decode | |
|---|---|---|
| 并行度 | 高(所有输入 token 同时算) | 无(一个一个来) |
| 瓶颈 | 算力(计算密集) | 显存带宽(访存密集) |
| 决定指标 | 首字延迟 TTFT | 出字速度 TPOT |
| 优化方向 | 减少输入长度、复用前缀 | 减少搬运的数据量 |
1.3 为什么"访存密集"这四个字这么关键
这里需要一个概念:算术强度(arithmetic intensity),定义是"这段计算里,每搬运 1 字节数据,能完成多少次运算"。
- 预填充阶段 :一次读入一批权重,可以同时服务几百上千个 token 的计算。算术强度很高,属于计算密集------瓶颈是"算得够不够快"。
- 解码阶段 :生成一个 token,就要把全部权重读一遍,而这些权重只服务这一次计算。算术强度极低,属于访存密集------瓶颈是"数据搬得够不够快"。
这个区别直接解释了一个让很多人困惑的现象:为什么换个算力更强的 GPU,出字速度提升有限?
因为解码阶段 GPU 的计算单元大部分时间在等数据。算力翻倍,但显存带宽没翻倍,速度就上不去。这也是为什么后面所有关于解码的优化,本质上都在做同一件事:减少需要搬运的数据量。
1.4 两个必须分清的性能指标
- TTFT(Time To First Token,首字延迟):从发出请求到第一个字出现。主要由预填充决定,也就是"你发过去的内容有多长"。
- TPOT(Time Per Output Token,每字耗时):第一个字之后,平均每个字的耗时。主要由解码决定,也就是"每一步要搬多少数据"。
把这两个混在一起谈"快慢",是线上事故里最常见的沟通障碍。用户说"卡",可能指的是首字等了 3 秒,也可能指的是出字像挤牙膏------这两者的排查方向和优化手段完全不同。
第二部分:KV Cache 的来龙去脉
2.1 没有缓存会怎样:平方级的代价
先做个思想实验:假如不缓存任何东西,生成一个长度为 N 的回答,要付出多少代价?
生成第 1 个 token 时,模型要看 1 个位置;生成第 2 个时,要看 2 个;生成第 N 个时,要看 N 个。总计算量是:
scss
1 + 2 + 3 + ... + N = N(N+1)/2 ≈ O(N²)
长度翻倍,代价翻四倍。 一个 2000 字的回答,如果每步都从头重算,慢到完全没有实用价值------这不只是"慢一点",而是根本无法上线。
但这里有一个关键观察:前面那些 token 的中间结果,其实是可以复用的。
因为当你追加新 token 时,前面那些 token 的表示不会改变。第 5 个 token 在长度为 10 的序列里是什么样,在长度为 1000 的序列里还是什么样。它不受后面内容的影响。
这就是缓存的数学基础:可复用性来自注意力的因果性。
2.2 注意力的三个角色:Q、K、V
要理解缓存什么,得先看清注意力在算什么。
每个 token 进入某一层后,会经过三个不同的线性投影,得到三个向量:
- Query(Q):我"要找什么"。代表当前这个位置发出的检索请求。
- Key(K):我"是什么"。代表这个位置能提供的匹配特征。
- Value(V):我"携带什么信息"。代表这个位置真正要传递的内容。
注意力的计算过程可以概括为三步:
- 用当前 token 的 Q,去和所有历史 token 的 K 做点积,得到一组相似度分数;
- 把这些分数做 softmax 归一化,变成一组权重;
- 用这组权重对所有历史 token 的 V 做加权求和,得到当前 token 的输出。
现在关键问题来了:当你在序列末尾追加一个新 token、重新计算这一层时,哪些东西变了,哪些没变?
- 新 token 的 Q、K、V:新算的。
- 历史 token 的 K:完全没变(K 只取决于该位置自身的表示,与后面追加什么无关)。
- 历史 token 的 V:完全没变(同理)。
- 历史 token 的 Q:没变,但也不需要了------它在上一步就已经用完了。
结论非常清晰:只需要缓存历史的 K 和 V,Q 不需要缓存。
这也顺便解释了显存公式里那个系数 2 的来历:K 和 V 各存一份。
2.3 缓存到底长什么样
在一层内部,KV Cache 是两个张量:
K_cache:形状大致是[历史长度, KV 头数, 每头维度]V_cache:形状相同
每生成一个新 token,做的事情是:
ini
# 伪代码:一层内的解码步骤
q, k_new, v_new = project(hidden_state) # 新 token 的 Q、K、V
K_cache.append(k_new) # 追加,不重算历史
V_cache.append(v_new)
scores = q @ K_cache.T # 用新 Q 匹配全部历史 K
weights = softmax(scores / sqrt(d))
output = weights @ V_cache # 对全部历史 V 加权求和
注意这里的变化:每一步从"重算全部历史",变成了"一次矩阵乘法 + 读一段缓存"。
计算量从"和历史长度成正比"(累计起来是平方级)降到了"和历史长度线性相关"(因为是读缓存,不是重算)。
而代价也很明确:这些缓存必须一直待在显存里,而且随对话长度不断增长。
2.4 一个容易被忽略的事实
KV Cache 不是"可选的性能优化",而是现代推理框架的默认前提。所有主流框架(vLLM、TensorRT-LLM、SGLang 等)都把它作为基础机制,不是在它之上做加法。
它的位置有点像数据库的索引:你不会因为"索引占空间"就把索引删掉,因为删掉之后查询会慢到不可用。
第三部分:算清显存这笔账
3.1 公式与它每一项的含义
这是本文最值得掌握的一个技能------能在纸上估出显存,才不会被"支持 128K 上下文"这种参数忽悠。
单个 token 的 KV Cache 体积:
markdown
每 token 字节数 = 2 × 层数 × KV 头数 × 每头维度 × 每个数的字节数
↑
K 和 V 各存一份
每一项的来源:
| 项 | 含义 | 为什么在里面 |
|---|---|---|
| 2 | K 和 V | 两个张量各存一份 |
| 层数 | 模型的 Transformer 层数 | 每一层都有自己独立的 KV Cache |
| KV 头数 | 参与缓存的注意力头数量 | 注意这里是 KV 头数,不是查询头数(GQA 下两者不同) |
| 每头维度 | 每个注意力头的向量长度 | 通常是 128 |
| 字节数 | 存储精度 | fp16 是 2 字节,int8 是 1 字节 |
整个请求的总占用是:
ini
单请求 KV Cache = 每 token 字节数 × 序列长度
3.2 实算:8B 级模型
取一个当前很主流的 8B 量级配置:32 层、KV 头 8 个、每头 128 维、fp16(2 字节)。
ini
2 × 32 × 8 × 128 × 2 = 131,072 字节 ≈ 128 KB / token
128 KB 听起来不多。但乘上长度:
| 上下文长度 | 单请求 KV Cache |
|---|---|
| 4K | 约 0.5 GB |
| 8K | 约 1 GB |
| 32K | 约 4 GB |
| 128K | 约 16 GB |
再看一眼这个模型的权重占多少:8B × 2 字节 = 约 16 GB。
也就是说,在 128K 上下文下,单个请求的 KV Cache 体积,和整个模型的权重一样大。 而模型权重是全局共享的一份,KV Cache 却是每个并发请求一份。
这就是问题的本质所在。
3.3 实算:70B 级模型,以及一个更值得警惕的结论
换个大模型:80 层、KV 头 8 个、每头 128 维、fp16。
ini
2 × 80 × 8 × 128 × 2 = 327,680 字节 ≈ 320 KB / token
| 上下文长度 | 单请求 KV Cache |
|---|---|
| 8K | 约 2.5 GB |
| 32K | 约 10 GB |
| 128K | 约 40 GB |
这里有个容易被算错的地方值得说清楚:70B 模型在 fp16 下权重约 140 GB,40 GB 的缓存看起来还不到权重的三分之一。
但实际部署 70B 时,几乎都会用量化。如果是 INT4 量化 ,权重压到约 35 GB ------这时候单个 128K 请求的 KV Cache(40 GB)就已经超过了模型权重本身。
这组数字揭示了一个现实的工程困境:你以为把模型量化到 4 bit 就能塞进单卡,结果发现真正吃显存的是缓存,不是权重。
3.4 并发上限怎么估
推理服务能同时服务多少请求,取决于显存预算怎么分。做一个完整的估算:
假设一张 80 GB 的卡,跑一个 8B 模型(fp16 权重 16 GB),框架开销预留 4 GB:
ini
可用于 KV Cache 的显存 = 80 − 16 − 4 = 60 GB
- 如果每个请求平均长度 4K (每个约 0.5 GB):
60 / 0.5 = 120个并发 - 如果每个请求平均长度 32K (每个约 4 GB):
60 / 4 = 15个并发
输入长度从 4K 涨到 32K,并发能力掉了 8 倍。
这一行算术,比任何"我们支持超长上下文"的宣传都更能说明问题。长上下文的真实成本不在单价上,而在"同一张卡能同时服务多少人"上。
第四部分:为什么"越聊越慢"------ 三个叠加的效应
现在把机制串起来,就能完整解释开篇那个现象。它不是错觉,而是三个效应叠加的结果。
效应一:预填充越来越长 → 首字变慢
多轮对话里,你这一轮发过去的内容包含了全部历史对话。历史越长,预填充需要并行处理的 token 就越多。
更关键的是,这部分开销是累积的:第 1 轮预填充 500 token,第 10 轮可能预填充 5000 token。同样是问一句话,首字延迟可以差十倍。
这也解释了一个常见抱怨:"我问的问题很短,为什么要等这么久?"------因为你发的问题短,但你发过去的上下文很长。
效应二:显存被占满 → 并发能力下降
这是最容易被忽略的一环。
推理服务的吞吐,取决于"同一时刻能装下多少个请求的 KV Cache"。缓存把显存吃满之后,新请求只能排队------而排队在用户看来,就是"卡"。
注意这里的因果关系:不是服务器算不动,而是服务器装不下。
效应三:批处理被打散 → 出字速度下降
为了不让任何一个超长请求独占显存导致其他人饿死,调度器往往要做取舍:把它单独处理,或者切成小块逐步推进。
而解码阶段的效率高度依赖批大小------因为它是访存密集的,一批处理 32 个请求和处理 1 个请求,读权重的开销几乎一样。批越小,每次搬运的"性价比"越低。
所以长请求不仅自己慢,还会拖累同批次其他请求的出字速度。
小结这条因果链
arduino
历史变长
├─→ 预填充 token 变多 ──→ 首字延迟 TTFT 上升
└─→ KV Cache 变大
├─→ 并发数下降 ──→ 排队 ──→ "卡"
└─→ 调度被迫拆批 ──→ 批变小 ──→ 出字速度 TPOT 下降
这条链条比"服务器忙"这个解释有用得多,因为它每一环都对应一个可测量的指标,也对应一个可操作的优化点。
第五部分:五种优化手段,从原理到取舍
理解了病根,就能看懂每项技术到底在治什么病、代价是什么。
手段一:GQA / MQA ------ 直接缩小每 token 的体积
治的病:缓存体积本身太大。
原理:传统多头注意力(MHA)里,每个 Query 头都配一个独立的 KV 头。GQA(Grouped-Query Attention)让若干个 Query 头共享一组 KV 头。
把 KV 头数从 32 降到 8,代入公式:
ini
MHA:2 × 32 × 32 × 128 × 2 = 524 KB / token
GQA:2 × 32 × 8 × 128 × 2 = 131 KB / token
缓存直接缩小到四分之一。
MQA 更激进:所有 Query 头共享唯一一组 KV,缓存再缩 8 倍(约 16 KB / token),但质量损失明显,没有成为主流。
取舍 :GQA 的质量损失很小,是目前几乎所有主流开源模型的默认选择。它是唯一既能显著砍掉缓存、又几乎不损失质量的手段------这也解释了为什么长上下文模型都爱用它。
手段二:KV 量化 ------ 把每个数从 2 字节压到 1 字节
治的病:同样的体积问题,换个角度解决。
原理:缓存里存的每个数值,从 fp16 换成 int8,体积直接减半;更激进的方案能压到 4 bit 甚至更低。
取舍 :实现简单,收益确定。代价是精度损失------而长链推理任务对缓存精度最敏感(因为误差会沿着推理步骤累积)。所以量化缓存通常配合一个"保留部分 fp16 层"的混合策略。
什么时候用:显存紧张,且你的任务对精度损失不敏感(比如摘要、改写类任务)。
手段三:PagedAttention ------ 治的是"碎片",不是"体积"
治的病:显存浪费,而不是显存占用大。这两个是完全不同的问题。
原理 :传统实现要为每个请求预留一整块连续显存,并且按"最大可能长度"来分配。问题是绝大多数请求的实际长度远小于上限,于是大量空间被浪费------实测利用率常常不到 50%。
PagedAttention 借用了操作系统内存分页的思路:把 KV Cache 切成固定大小的块(block),按需分配、不要求连续。
于是:
- 显存利用率从不足 50% 提升到 90% 以上;
- 更重要的是,它让"按需增长"成为可能,不需要预先猜测长度。
取舍 :这是 vLLM 出圈的核心技术。它不减少单个请求的缓存体积,但让同一张卡能装下更多请求。
一个常见误解 :很多人以为 PagedAttention 是"压缩缓存"的技术。不是。它解决的是分配效率,和前面的 GQA、量化是两个维度的问题,可以叠加使用。
手段四:前缀缓存 ------ 让相同的开头只算一次
治的病:预填充的重复计算,也就是首字延迟。
原理 :如果多个请求共享同一段开头(比如同一套系统提示、同一个长文档),那么这段前缀的 KV Cache 是可以直接复用的------因为前缀的 K/V 只取决于前缀自身,与后面追加什么无关(和 2.2 节是同一个道理)。
效果 :多轮对话场景下收益极大。因为第 N 轮的前缀,就是前 N-1 轮的完整内容------理论上最多可以省掉几乎全部的预填充计算。
取舍:需要额外的机制来识别和匹配前缀,实现复杂度不低。而且如果请求之间前缀差异大,命中率就低。
实操建议 :把内容完全相同 的部分(系统提示、工具定义、固定文档)严格放在最前面且保持字节级一致,能显著提高命中率。改造提示词顺序来提升缓存命中率,是性价比很高的优化。
手段五:滑动窗口与稀疏注意力 ------ 主动丢弃远处的缓存
治的病:缓存无限增长。
原理:只保留最近一段窗口内的 K/V,更早的直接丢弃;或者只对部分历史位置做注意力计算。
取舍 :这是主动的取舍 ,不是免费的午餐。省显存、能做超长文本,但代价是"远处的信息真的会看不到"。
适合的场景:对话、流式处理这类"近期信息更重要"的任务。 不适合的场景:需要在长文档中精确定位某一段的任务(比如"找出合同第 3 页的那条条款")。
五种手段的对照
| 手段 | 治什么病 | 影响哪个阶段 | 主要代价 |
|---|---|---|---|
| GQA / MQA | 缓存体积大 | 解码(并发) | 轻微质量损失 |
| KV 量化 | 缓存体积大 | 解码(并发) | 精度损失,长链推理敏感 |
| PagedAttention | 显存碎片、利用率低 | 解码(并发) | 实现复杂度 |
| 前缀缓存 | 预填充重复计算 | 预填充(首字) | 需要前缀匹配机制 |
| 滑动窗口 / 稀疏注意 | 缓存无限增长 | 两者 | 远处信息丢失 |
注意前两个和第三个的区别 :GQA 和量化是减小分子 (每个请求要多少显存),PagedAttention 是减少浪费 (同样显存装更多请求)。它们解决的是不同问题,可以而且应该同时用。
第六部分:什么时候 KV Cache 不划算
这一节容易被跳过,但它能帮你避免"技术正确、业务上白做"。
场景一:单次、无多轮的短任务。
比如批量分类、批量信息抽取。每条输入独立、长度都很短、请求之间没有共享前缀。这时缓存没有复用机会,反而增加了分配、回收、管理的内存开销。
场景二:极短输出。
如果每次只需要生成几个 token(比如一个分类标签、一个 yes/no 判断),那么缓存省下的重复计算非常有限,而管理成本照付。这种情况下,直接算可能更简单。
场景三:显存极度受限、且要高并发。
这时候"给缓存设上限、超了就丢弃或截断"可能比"什么都要完整缓存"换来更高的总吞吐。
一个反直觉的判断准则:
缓存是拿显存换时间。显存不缺时它是白赚的;显存紧张时,它本身就是瓶颈。
所以"关掉缓存省显存"是所有做法里最糟的一个------它把时间开销从线性推回平方级。要省显存,正确做法是减小缓存体积(GQA、量化)、限制长度,或改进调度,而不是砍掉缓存本身。
第七部分:线上变慢了,怎么定位
讲了这么多原理,最终要落回到"遇到问题怎么办"。下面是一套可以直接执行的流程。
第一步:先分清是哪种"慢"
问清楚(或者看监控确认):是首字慢,还是出字慢?
- 首字慢 (TTFT 高)→ 往预填充方向查。
- 出字慢 (TPOT 高)→ 往解码与批处理方向查。
- 两个都慢 → 大概率是并发问题,往下走。
第二步:看三个数
| 指标 | 看什么 | 异常意味着 |
|---|---|---|
| 输入 token 数 | 平均与 P95 | 持续上涨 → 预填充问题、成本问题 |
| 单请求显存占用 / 显存利用率 | 是否接近打满 | 接近打满 → 并发受限于缓存 |
| 运行中的并发请求数 | 实际值 vs 期望值 | 远低于期望 → 排队严重 |
一个快速判据 :如果输入 token 数明显在涨、显存接近打满,基本可以确定是缓存与调度问题;如果这两个都稳定、只有出字慢,才去怀疑模型或硬件。
第三步:按症状对应手段
| 症状 | 大概率原因 | 优先动作 |
|---|---|---|
| 首字越来越慢 | 预填充变长 | 前缀缓存、历史摘要、精简系统提示 |
| 出字速度变慢 | 批变小 / 显存紧张 | 上 PagedAttention、限并发、KV 量化 |
| 并发上不去 | KV Cache 占满显存 | 换 GQA 模型、降上下文、扩卡 |
| 长文本问答不准 | 有效上下文不足 | 检索、分段处理、关键信息放两端 |
| 成本突然升高 | 输入 token 累积 | 历史摘要、语义缓存 |
一个真实的排查误区
很多团队的排查顺序是"先加卡"。但对上面这张表你会发现:加卡只对应"并发上不去"中的一部分情况,而且通常是成本最高、见效最慢的那个动作。
正确顺序应该是:
- 先看输入是不是可以变短(摘要、裁剪、前缀缓存)------ 零硬件成本;
- 再看调度是不是有优化空间(PagedAttention、批处理参数)------ 软件层面;
- 再看缓存本身能不能变小(量化);
- 最后才考虑加卡或换模型。
这个顺序能省下大量预算,而且不牺牲效果。
常见误区
误区一:"越聊越慢是因为模型记住了上下文,在变聪明。"
与聪明无关。长度增加导致的注意力计算量上升和缓存体积膨胀,是纯粹的工程代价,跟推理能力没有关系。事实上,上下文过长反而会让模型更容易"看漏"关键信息。
误区二:"标称 128K 上下文,就能塞 128K 内容进去正常用。"
这混了两件事:能不能塞进去是显存问题,能不能用好是模型能力问题。 显存允许你塞,不代表模型在那么长的位置还能准确取到信息。业界的实测普遍表明,"有效上下文"远小于"标称上下文",而且任务难度越高、有效长度越短。
误区三:"换个更快的显卡就能解决。"
解码阶段是访存密集的,瓶颈在显存带宽和缓存体积。算力翻倍,带宽没翻倍,出字速度就上不去。 对这个瓶颈,把缓存体积降下来的收益,远大于换卡。
误区四:"KV Cache 是可选的优化,关掉能省显存。"
关掉之后每步都要重算全部历史,等于把时间开销从线性推回平方级。要省显存,正确做法是减缓存体积、限长度或改调度,而不是砍掉缓存。
误区五:"并发上不去是模型太重。"
更常见的原因是 KV Cache 把显存吃满了 。同样是这个模型,把平均上下文从 32K 降到 4K,并发能力可能提升 8 倍------模型没变,是缓存变了。
误区六:"首字延迟高是网络问题。"
网络通常只占几十到几百毫秒。如果首字要等好几秒,几乎一定是预填充在处理的输入太长。先去看输入 token 数,别急着抓包。
误区七:"批处理越大吞吐越高,所以应该把批开满。"
批太大有代价:一是显存被吃满后新请求排不上队,二是首字延迟会变差(新请求要等当前批次跑完才能插进去)。所以批大小的选择是"吞吐"和"延迟"之间的平衡,不是单向往大调。
误区八:"PagedAttention 能压缩 KV Cache。"
不压缩。它解决的是显存碎片与分配效率 ,让同样的显存装下更多请求。压缩体积要靠 GQA 和量化。 这两个概念经常被混淆,但在做容量规划时区别很大。
实战问答
Q1:面试被问"KV Cache 为什么能加速",怎么答得有层次?
A:按三层递进说。
第一层(根因):自回归生成里,历史 token 的 K/V 一旦算出就不再变化------因为注意力是因果的,追加新 token 不会改变前面位置的表示,所以天然可复用。
第二层(代价):不缓存的话,生成第 N 个 token 要重算 N 个位置,总计算量是 O(N²);缓存之后降到 O(N),代价是显存占用随长度线性增长。
第三层(推论):所以长上下文真正的瓶颈是显存而不是算力,这解释了为什么解码阶段是访存密集的、为什么 GQA 和量化是主流、为什么并发能力高度依赖上下文长度。
能说到第三层,就和背定义的人拉开了距离。
Q2:为什么同样的模型,别人家部署的并发是我的好几倍?
A:优先查这四项,通常答案就在其中。
- 平均上下文长度:这是最容易被忽略的。对方可能通过摘要、裁剪把平均输入压缩了一半,并发就直接翻倍。
- 是否用了 PagedAttention:显存利用率可能差出一倍。
- KV 头数是否相同:同样的模型家族,不同版本可能一个用 GQA、一个用 MHA,缓存体积差 4 倍。
- 批处理与调度策略:是否设置了合理的最大长度上限、是否有抢占机制。
这四项里没有一项是"硬件更好"。
Q3:多轮对话里,历史到底该保留多少?
A :没有统一答案,但有个可操作的判据:保留"后续对话还会用到的事实",丢掉"过程性内容"。
具体做法:把历史组织成三段结构------
- 摘要:把前面对话压缩成要点(保留事实、结论、用户偏好);
- 关键事实:结构化的键值对(比如"用户所在城市=上海""已确认的订单号=XXX");
- 最近 N 轮原文:保持对话连贯性。
这通常比全量保留更省、也更准------因为过长的历史本身就会稀释注意力,让关键信息更难被取到。
Q4:怎么判断该上 GQA 还是该上量化?
A:看你的约束在哪。
- 如果还能换模型:优先换 GQA 模型。质量损失更小,而且是"设计层面"的优化,不增加运行时开销。
- 如果模型不能换:用量化。它是"运行时"的优化,可以直接作用在现有部署上。
- 如果两者都能做:先 GQA 再量化,收益可以叠加(4 倍 × 2 倍)。
判断依据是"改动成本"而不是"哪个效果更好"------很多时候换模型的成本(重新评测、重新调提示词、验证下游兼容性)远高于它的收益。
Q5:前缀缓存命中率低怎么办?
A:先确认三件事。
- 前缀是否严格一致:哪怕多一个空格、换一个标点,都会导致缓存不命中。要做字节级比对,不要凭感觉。
- 内容顺序是否稳定 :如果前缀里包含时间戳、随机 ID、动态排序的内容,缓存永远不会命中。把动态内容挪到前缀之后。
- 是否有长度门槛:多数实现会设置最小长度(比如只对超过一定 token 数的前缀启用缓存),太短的前缀不缓存。
最容易出问题的是第 2 条:一个放在系统提示开头的时间戳,能让整个前缀缓存机制彻底失效。
Q6:为什么长上下文模型普遍用 GQA,而不是更省的 MQA?
A:因为 MQA 压得太狠了。
MQA 让所有 Query 头共享唯一一组 KV(缓存约为 GQA 的 1/8),但多个 Query 头被迫在同一个"信息通道"里读取内容,表达能力的损失比较明显,实测质量下降肉眼可见,所以没有被主流采用。
GQA 的关键在于它是可调的折中 :KV 头数可以从 1(等于 MQA)到与 Query 头数相同(等于 MHA)之间自由选择。实践中通常取 8 或更少------在缓存缩小 4 倍的同时,质量损失小到几乎测不出来。
这就是工程上的"最优解"长什么样:不是极值,而是曲线拐点。
Q7:单机跑不动了,是不是该上多卡?
A:先做完这四件事再考虑。
- 缩短平均输入:摘要、裁剪、前缀缓存。收益最直接,零硬件成本。
- 上 PagedAttention 类调度:把显存利用率从不到 50% 提到 90%。
- 限制最大长度与并发:设置合理的上限,防止单个长请求独占资源。
- KV 量化:缓存体积减半。
这四步做完,等效容量可能已经提升 3~5 倍。如果还不够,再上多卡。
而且要注意:多卡并行本身也有代价------跨卡通信开销、负载均衡、故障域扩大。这些是实打实的运维复杂度,不该被"加卡就能解决"这句话掩盖。
Q8:能不能预估一下我的服务需要多少显存?
A:可以,按这个顺序算。
ini
第 1 步:权重显存 = 参数量 × 每参数字节数
(fp16 是 2 字节,int8 是 1,int4 是 0.5)
第 2 步:单请求 KV Cache = 每 token 字节数 × 平均上下文长度
(每 token 字节数 = 2 × 层数 × KV 头数 × 每头维度 × 字节数)
第 3 步:并发所需缓存 = 单请求缓存 × 目标并发数
第 4 步:总需求 = 权重 + 并发缓存 + 框架开销(通常预留 10%~20%)
用一个具体例子走一遍:8B 模型(fp16 权重 16 GB),平均上下文 8K(单请求约 1 GB),目标并发 50:
16 + 1 × 50 + 预留 4 ≈ 70 GB
这个算式最大的价值不是"算得准",而是让你看清哪个变量最敏感。 在上面这个例子里,把平均上下文从 8K 降到 4K,总需求立刻从 70 GB 降到 45 GB------比换一张更好的卡有用得多。
术语表
| 术语 | 含义 |
|---|---|
| 预填充(Prefill) | 把整段输入一次性并行处理、生成第一个 token 的阶段;计算密集,决定首字延迟 |
| 解码(Decode) | 逐 token 串行生成的阶段;访存密集,决定出字速度 |
| KV Cache | 缓存历史 token 的 Key 与 Value,避免每步重算;推理提速的基础机制 |
| 算术强度 | 每搬运 1 字节数据能完成多少次运算;判断计算密集还是访存密集的核心概念 |
| GQA / MQA | 让多个 Query 头共享少量 KV 头,直接缩小缓存体积 |
| TTFT / TPOT | 首 token 延迟 / 每 token 耗时,分别反映预填充与解码的性能 |
| PagedAttention | 把 KV Cache 按块分配,解决显存碎片;vLLM 的核心技术 |
| 前缀缓存 | 复用相同开头部分的缓存,显著降低多轮与共享系统提示的延迟 |
| 滑动窗口注意力 | 只保留最近一段的 K/V,主动丢弃远处信息以控制缓存增长 |
| 有效上下文 | 模型在实际任务中真正能用好的长度,通常远小于标称上下文 |
结语
如果把这篇文章压缩成三句话:
第一,AI 生成文本的过程,天然会随对话变长而变慢------因为每生成一个字,都要和前面所有的字做一次交互。这是自回归机制的固有代价,不是实现缺陷。
第二,KV Cache 是把这个代价从平方级压到线性级的关键手段,它的原理很简单:历史 token 的 K/V 算完就不会再变,所以可以存下来复用。而它的代价同样简单:这些缓存必须一直待在显存里,并且随长度增长。
第三,理解了代价的形态,就能理解所有相关的工程问题------为什么长上下文贵在显存不在算力、为什么并发上不去、为什么换更好的卡效果有限、为什么 GQA 和量化是主流、为什么前缀缓存对多轮对话收益巨大。
这条线索的实用价值在于:它把"AI 慢"这个模糊的抱怨,拆成了一次可测量、可归因、可操作的排查。
而长上下文还有另一半问题没讲完:就算显存装得下、模型也算得动,"支持 100 万上下文"是否意味着它真的能在那么长的位置准确取到信息? 标称上下文与有效上下文之间的那条鸿沟,比大多数人想的要宽------这是下一个值得展开的话题。