调一个对话接口,你有没有盯过那个"打字机"效果背后的时间分布:第一个字憋了两秒才蹦出来,之后十几个字却像开了闸一样往外涌。很多人第一反应是"模型预热没做好"或者"网络抖动",但把日志拉出来看,prefill 和 decode 这两个阶段的时间占比清清楚楚地摆在那儿——前者一次性吃掉你几千个 token 的输入,后者每次只挪动一个 token 往前爬。搞不清楚这两个阶段各自在算什么、瓶颈卡在哪,调参基本就是蒙。
这篇内容面向已经能把模型跑起来、但对其内部时间花在哪儿还比较模糊的读者。我会把Prefill(预填充)和Decode(解码)两个阶段的计算形态、显存行为、性能瓶颈逐层拆开,配上可以自己算的算术强度账本、一份最小验证骨架,以及我在实际调优里踩过的几个典型坑。看完你至少能做到两件事:拿到一组 TTFT 和 TPOT 数据,能反推出瓶颈大概在哪;给一个模型配显存和 batch 时,能把账算对。
1. 从"第一个字为什么卡"说起:Prefill 与 Decode 到底在切分什么
1.1 把一次生成请求的时间轴摊平来看
一个自回归生成请求,从到达服务到吐出最后一个 token,内部其实被切成了几段性质完全不同的工作。前置的 tokenize、模板拼接、多模态编码这些先放一边,真正吃算力的是后面这条链路:先把整段 prompt 一次性喂进模型做一次完整前向,得到第一个输出 token 的概率分布,采样出第一个字;然后把第一个字作为新的输入,再跑一次前向,得到第二个字;如此循环,直到触发停止条件。第一段叫 Prefill,后面那段循环叫 Decode。
这个切分不是为了讲概念方便,而是因为两段在硬件上表现出的特征几乎是相反的。衡量它们最常用的两个指标也正好对应:**TTFT(Time To First Token,首 token 延迟)**主要由 Prefill 决定,**TPOT(Time Per Output Token,每输出 token 耗时)**由 Decode 决定。用户感受到的"卡不卡",前半段看 TTFT,后半段看 TPOT;而服务端的整体吞吐能力,则取决于这两段能被多紧凑地塞进同一个 batch 里。
我经常用一个类比解释这两段的分工:Prefill 像是考试前把整本参考书从头到尾通读一遍并做好笔记,一次性投入大、但后面受益;Decode 像是答题时每写一个字都要翻一次笔记本——注意是"翻整本",不是翻某一页,这个细节后面会展开,它正是 Decode 阶段效率低下的根源。
值得强调的是,Prefill 之所以能把 S 个 token 一次性并行算完,靠的是因果注意力掩码这个性质:位置 i 的输出只依赖位置 0 到 i 的输入,不依赖后面。所以在训练时可以把整条序列铺开并行算,推理时同样可以。这跟 Decode 阶段"必须等上一个字出来才能算下一个字"的串行依赖形成鲜明对比——Decode 的串行性不是工程实现没优化好,而是自回归生成这个范式的数学本质决定的,任何框架都绕不过去。
1.2 两种阶段在矩阵形状上的根本分叉
抛开所有工程细节,Prefill 和 Decode 的差异可以浓缩成一句话:Prefill 做的是大矩阵乘大矩阵,Decode 做的是小向量乘大矩阵。
具体到形状上,假设隐层维度是 d,序列长度是 S。Prefill 阶段某一层的线性变换,输入是[S, d],权重是[d, d],输出[S, d]——这是标准的 GEMM,矩阵规模足够大,硬件里的矩阵运算单元能被充分喂饱。而 Decode 阶段每一步,输入只有[1, d]一行,同样是乘[d, d]的权重,输出[1, d]——这退化成 GEMV,矩阵乘法单元的大部分行和列在这一步里是闲着的。
形状一变,瓶颈就变了。大矩阵乘的时候,每读一次权重,能被复用 S 次(S 就是 batch 方向上被"摊薄"的次数),所以计算单元是忙的,瓶颈在算力;小向量乘的时候,每读一次权重只做一次乘加,权重读完就丢,瓶颈立刻从算力转移到"能把权重多快地从显存搬进计算单元",也就是显存带宽。
这个分叉还带来一个容易被忽略的后果:Prefill 的耗时随输入长度增长比较线性(至少在线性层主导的区间内),而 Decode 的单步耗时几乎和输入长度无关——它只跟模型参数量、精度、以及 KV Cache 有多长有关。很多人第一次做压测时会惊讶地发现,把 prompt 从 500 token 加到 4000 token,TTFT 涨了七八倍,但 TPOT 几乎没动。这不是 bug,这就是两个阶段的固有特征,只不过当上下文长到一定程度,KV Cache 的读取量会反超权重读取量,TPOT 才会开始缓慢爬升,这一点在第 6 章会专门讲。
2. Prefill 阶段:把整段提示词一次性"吃掉"的矩阵运算
2.1 QKV 是怎么在一步里全算出来的
Prefill 的核心动作,是在每一层里把[S, d]的隐状态分别投影成 Q、K、V 三个[S, d]的张量。以单头视角看,Q 的每一行对应一个输入位置的查询向量,K 的每一行是它的键向量,V 的每一行是值向量。因为因果掩码的存在,位置 i 只需要和位置 0..i 的 K 做点积,所以哪怕一次性把 S 行的 Q 都算出来了,也不会造成信息泄漏——上三角部分在 softmax 之前会被掩成负无穷。
注意力分数矩阵是[S, S],规模随序列长度平方增长,这是长上下文场景里最扎眼的一项开销。拿到分数后按行做 softmax,再乘 V 得到[S, d]的注意力输出,最后过输出投影和 MLP。整个前向在层与层之间顺序执行,但层内的 S 个位置是齐头并进的。
这里有个实操中很值得留意的点:[S, S]的注意力分数矩阵是要真实落在显存(或片上缓存)里的。S = 8192 时,单个头单层的分数矩阵就是 8192×8192 个元素,FP16 下约 128 MB。虽然有了 FlashAttention 这类分块计算策略之后,不需要把完整的[S, S]矩阵实体化到显存里,但分块大小、片上内存容量这些约束会直接影响 Prefill 的实际效率。我自己在长 prompt 场景下测过,同样的总 token 数,切成 4 段 2048 分别 prefill 和一次性 8192 prefill,前者的总耗时反而更高——因为分块把大 GEMM 打碎了,每次的有效利用率都掉了一截。这个观察是后面讲"分块预填充要选多大块"的重要依据。
2.2 把 Prefill 的算力账算到手
要判断 Prefill 是不是瓶颈,最直接的办法是把 FLOPs 估出来。线性层部分(QKV 投影、输出投影、MLP 的上下投影)贡献的浮点运算量近似是2 × P × S,其中 P 是模型参数量,S 是输入 token 数,系数 2 来自每个参数一次乘法一次加法。
拿一个 7B 量级的模型举例,S = 2048:2 × 7×10^9 × 2048 ≈ 2.87×10^13,也就是约 28.7 TFLOPs。如果手上的加速卡 FP16 稠密算力是 300 TFLOPS 级别,纯理论需要约 0.096 秒。实际能跑出 40% 左右的 MFU(模型算力利用率)就已经算调得不错了,对应 0.24 秒左右。这个数量级和大多数线上服务的实测 TTFT 是对得上的,可以拿来做快速估算的锚点。
再看注意力那部分,它的 FLOPs 大致是2 × 2 × L × S² × d,L 是层数、d 是隐层维度。同样 7B、32 层、d = 4096、S = 2048:4 × 32 × 2048² × 4096 ≈ 2.2×10^12,也就是 2.2 TFLOPs。相比线性层的 28.7 TFLOPs,小了一个数量级,所以短上下文场景下注意力不是主要开销。
但这个比例随 S 变化得非常快。S 从 2048 拉到 32768,线性层项按 S 线性增长 16 倍,注意力项按 S² 增长 256 倍,于是注意力项从 2.2 TFLOPs 涨到约 563 TFLOPs,而线性层只涨到 459 TFLOPs——注意力反超了。这就解释了为什么长上下文场景下 Prefill 的耗时会突然变得很难看,很多团队在把上下文从 8K 扩到 32K 之后发现 TTFT 涨了 5 倍以上,原因就在这个平方项上。
| 输入长度 S | 线性层 FLOPs(估) | 注意力 FLOPs(估) | 主导项 |
|---|---|---|---|
| 2048 | 28.7 TFLOPs | 2.2 TFLOPs | 线性层 |
| 8192 | 114.7 TFLOPs | 35.2 TFLOPs | 线性层 |
| 32768 | 458.8 TFLOPs | 563.2 TFLOPs | 注意力 |
表中数字是按 7B 参数、32 层、d = 4096 的设定粗估的,用途是判断量级和主导项切换的拐点,不要拿去当精确基准。
2.3 Prefill 结束时,KV Cache 里留下了什么
Prefill 阶段除了算出第一个输出 token 的 logits,还顺带完成了一件对整个 Decode 阶段至关重要的事:把每一层、每个位置的 K 和 V 全部写进 KV Cache。
这里有个关键问题值得说清楚:为什么只缓存 K 和 V,不缓存 Q?因为 Decode 每一步只处理一个新 token,这个新 token 需要用自己的 Q 去和全部历史 K 做点积算注意力权重,再拿权重去加权求和全部历史 V。历史 token 的 Q 在后续计算里完全用不上了——它们已经完成了自己的使命。所以缓存 K/V 是充分且必要的,缓存 Q 纯属浪费显存。
KV Cache 的显存占用量可以用这个式子估:
bytes = 2 × L × n_kv_heads × d_head × S × B × bytes_per_elem
其中开头的 2 是 K 和 V 两份,L 是层数,n_kv_heads 是键值头数(注意不是注意力头数,用了分组查询注意力的话这个数会小好几倍),d_head 是每个头的维度,S 是累计序列长度,B 是并发批大小。以一个 32 层、32 个键值头、头维度 128、FP16 精度的模型为例,单个 token 的 KV Cache 是2 × 32 × 32 × 128 × 2 ≈ 524 KB。听起来不大,但乘上 4096 的上下文就是 2 GB,再乘上 32 的并发 batch 就是 64 GB——这个数字往往比模型权重本身占的显存还多,是决定单卡能扛多少并发的真正天花板。
3. Decode 阶段:一次只出一个字的循环里藏着什么
3.1 单步解码的完整数据流
Decode 每一步的输入极其简单:上一步采样出来的那一个 token。它先被查表成[1, d]的向量,然后逐层往前走。每一层里,这个向量被投影成一份新的 Q、K、V,注意形状都是[1, d]。新的 K 和 V 会被追加到该层 KV Cache 的末尾,缓存长度从 S+t-1 变成 S+t。然后拿这一行 Q 去和缓存里全部 S+t 个 K 做点积,得到一个[1, S+t]的注意力权重向量,softmax 之后去加权求和全部 S+t 个 V,得到[1, d]的注意力输出。
这个流程逐层重复,最后拿到[1, d]的最终隐状态,过一个[d, V]的输出投影(V 是词表大小),得到[1, V]的 logits,经过温度缩放、top-p、top-k 之类的采样策略,选出一个 token。如果这个 token 是结束符、或者撞上了预设的停止字符串、或者达到了 max_tokens 上限,循环终止;否则把它喂回去,继续下一轮。
整条链路里,唯一随步数增长的是 KV Cache 的长度,其他所有张量的形状都是恒定的。这带来一个很实用的推论:如果只看模型计算部分,纯 Decode 的耗时应该是几乎恒定的。你在线上看到 TPOT 从 15 毫秒慢慢爬到 40 毫秒,那一定是别的东西在起作用——KV Cache 读取量增长、批内其他请求变长、或者调度策略把新请求插进来了。
3.2 为什么 Decode 是内存带宽密集型:一本带宽账
前面说 Decode 每一步像是"翻整本笔记本",现在把这笔账算清楚。Decode 单步要做的事情,本质上是把模型全部 P 个参数从显存里读一遍,每个参数参与一次乘加(2 FLOPs)。当 batch = 1 时,算术强度就是2P / (P × bytes_per_param),FP16 下 bytes = 2,所以算术强度只有1 FLOP/Byte。
这个数字是什么概念?一张算力 300 TFLOPS、显存带宽 2 TB/s 的加速卡,其 roofline 模型的拐点算术强度是300×10^12 / 2×10^12 = 150 FLOP/Byte。也就是说,算术强度要超过 150,计算单元才能被喂饱;而 Decode 在 batch = 1 时只有 1,差了两个数量级。结果就是:加速卡上绝大部分计算单元全程闲着,时间全花在等显存把权重搬过来。
按带宽直接估单步耗时:7B 模型 FP16 权重约 14 GB,2 TB/s 带宽下理论最短 7 毫秒。实测算上显存访问效率、kernel 启动开销、通信开销,跑到 15 到 25 毫秒很常见,对应单请求 40 到 70 token/s 的输出速度。这个数量和大多数本地部署的体感是一致的,可以拿来快速判断你的服务是不是跑在合理区间。
那 batch 能不能救?能。算术强度随 batch 线性增长:2 × B / bytes = B。batch 开到 150 才碰到拐点,实际业务里 batch 到几十的时候,算力就开始成为新的约束了。但 batch 不是想开多大开多大,KV Cache 的显存占用随 batch 线性增长,这是硬约束。而且当上下文很长时,每步要读的不只是权重,还有B × 2 × L × n_kv_heads × (S+t) × d_head这么多 KV Cache 数据,这项会随着上下文长度线性膨胀,最终反超权重读取量,成为 Decode 的新瓶颈。一个 32K 上下文、batch = 16 的请求,KV Cache 单步读取量能到几个 GB,跟权重读取量同一个量级甚至更大。
3.3 采样策略、停止条件与流式的实现细节
采样这一段虽然计算量微不足道,但在工程上容易出问题。logits 出来之后,温度参数、top-k、top-p、重复惩罚、频率惩罚这些是按什么顺序作用,各家实现不完全一样,顺序不同结果会有差异。我自己的习惯是固定成"先应用重复/频率惩罚,再除以温度,然后 top-k,最后 top-p",并且在压测时把这套参数完全固定住,否则跑出来的 TPOT 波动里会混进采样分支差异带来的噪声。
流式输出这块,实现上要注意的是 token 到文本的边界。很多分词方案里,一个汉字或者一个词可能被切成多个 token,如果每出一个 token 就立刻推给前端,用户会看到半个字闪一下再补全。稳妥的做法是维护一个小的缓冲,遇到能构成完整字符或标点的时候再刷出去。这个细节不影响 TPOT 的测量,但直接影响用户对"流畅度"的感知——我见过好几次有人抱怨"输出卡顿",最后查下来是前端按 token 刷新导致的。
停止条件也要留意。除了结束符和 max_tokens,业务上常用的还有"命中停止字符串就截断"。这个判断如果在每个 token 之后都对全量已生成文本做子串匹配,长输出场景下会变成 O(n²) 的字符串扫描,反而成了瓶颈。合理的做法是只在最近若干个字符的窗口里做匹配,窗口长度取最长停止串的长度减一即可。
4. 两个阶段的资源画像对比:为什么不能一套参数跑到底
4.1 算力与带宽的错配全貌
把两个阶段的关键特征摆在一起看,错配就非常直观了:
| 维度 | Prefill | Decode |
|---|---|---|
| 输入形状 | [S, d]大矩阵 | [1, d]单向量 |
| 计算类型 | GEMM | GEMV |
| 算术强度(B=1) | 约 S FLOP/Byte | 约 1 FLOP/Byte |
| 主要瓶颈 | 算力 | 显存带宽 |
| 显存增量 | 激活值、注意力中间结果 | KV Cache 持续增长 |
| 随输入长度变化 | 近似线性 + 平方项 | 单步耗基本不变,KV 读取线性增长 |
| 对应指标 | TTFT | TPOT |
| 批处理收益 | 有限(本身已饱和) | 显著(摊薄权重读取) |
这张表里最容易被忽视的是最后一行。很多人下意识觉得"batch 越大越好",但对 Prefill 来说,它本来就已经把算力单元喂饱了,加大 batch 只是把多个请求的 prefill 拼在一起变成更大的 GEMM,收益有限,反而会把 TTFT 拉长。而对 Decode 来说,加大 batch 是把权重读取的成本摊到更多请求上,收益接近线性——直到算力或 KV Cache 显存先撑不住为止。
所以一个成熟的服务配置,通常是Prefill 用小 batch 追求低延迟,Decode 用大 batch 追求高吞吐。这也正是把两个阶段拆到不同实例上分别部署(业界常说的 PD 分离)的动机所在:让两段各自跑在最合适的并行策略和硬件配比上,而不是互相迁就。
4.2 连续批处理与分块预填充:调度层怎么抹平两段的冲突
最朴素的批处理是"静态批":攒够一批请求,一起跑到全部结束,再收下一批。这个策略在大模型推理里几乎不可用,因为不同请求的输出长度差异巨大,一个生成 2000 token 的请求会把同批里只生成 20 token 的请求拖住,后者的用户干等。表现出来就是尾延迟极难看。
现在主流的做法是连续批处理,也叫迭代级调度:每完成一个 decode step,就把已经结束的请求踢出 batch,把排队的请求补进来。这样 batch 的填充率始终维持在高位,吞吐提升非常可观。实现上需要注意的是,新请求进来时要先做 prefill 才能进入 decode 循环,而这个 prefill 如果很长,会把这个 step 的耗时整体拉高,导致同批其他请求的 TPOT 出现尖刺。这个现象叫队头阻塞。
解决它的手段是分块预填充:把一个长 prompt 的 prefill 切成若干块,每块几百到两千个 token,分多个 step 陆续算完,每个 step 里除了这块 prefill,还混着其他请求的 decode。这样队列里每个 step 的耗时被拉平了,TPOT 抖动显著减小。代价是 prefill 本身的效率下降——块切得越碎,GEMM 的有效规模越小,算力利用率越低,TTFT 反而变长。
块大小怎么选,我给一个经验区间:512 到 2048 个 token 之间。低于 512,prefill 的算力利用率掉得太厉害,长 prompt 的 TTFT 会明显变差;高于 2048,单 step 耗时变长,decode 请求的 TPOT 抖动开始被用户感知到(一般来说,TPOT 的 P99 波动超过中位数的 1.5 倍,用户就会有"卡一下"的感觉)。具体落在区间哪个位置,取决于你的业务更在意 TTFT 还是 TPOT——对话类应用偏 TPOT,离线批处理偏吞吐,可以往大调。
4.3 前缀缓存为什么能省掉大半个 Prefill
线上服务的流量有个很明显的特征:大量请求的前缀是重复的。固定的系统提示词、few-shot 示例、多轮对话的历史部分、RAG 场景里同一份文档的反复引用,这些内容的 token 序列完全一致。如果每个请求都从头 prefill 一遍,等于把同样的矩阵乘法做了成百上千次。
前缀缓存的思路很直接:把已经算过的 KV Cache 按 token 前缀存起来,新请求进来时做最长前缀匹配,命中的部分直接复用缓存里的 K/V,只对没命中的后缀部分做 prefill。效果有多明显取决于命中率——如果系统提示词占了 1500 token、用户实际问题只有 100 token,命中率超过 90%,TTFT 能降到原来的十分之一左右。
实现上有几个必须处理的细节,漏一个就会出错或者白干。第一,匹配必须是精确的 token 序列前缀匹配,不是字符串前缀匹配,因为分词边界可能让相同字符串切出不同 token。第二,位置编码要处理对——缓存里的 K/V 是按原始位置算好的,续接的新 token 位置编号必须接着往后排,不能从零开始。第三,缓存的显存管理要用分页或者块分配的方式,否则不同长度的前缀反复申请释放会制造大量碎片,最后明明有空闲显存却分配不出连续块。第四,淘汰策略一般是 LRU,但要按"块"而不是按"请求"来淘汰,因为一个长前缀可能被很多请求共享。
5. 自己动手:用一个最小实现把结论验证一遍
5.1 环境与最小骨架
不用真的把整个模型跑起来才能验证这些结论,写一个只保留形状和计算结构的骨架就够了。下面这段是伪代码,重点是形状标注和两段的对照关系,具体框架的 API 按你手上的替换即可:
# 仅保留形状语义的最小骨架 # d: 隐层维度, L: 层数, S: prompt 长度, T: 生成长度 def prefill(X): # X: [S, d] for layer in range(L): Q = X @ Wq[layer] # [S, d] K = X @ Wk[layer] # [S, d] V = X @ Wv[layer] # [S, d] kv_cache[layer].append(K, V) # 写入 [S, d] 两份 # 因果掩码下的注意力: [S,S] 分数 -> softmax -> 加权 V attn = causal_attention(Q, K, V) X = attn @ Wo[layer] # [S, d] X = mlp(X, layer) # [S, d] return X # 取最后一行算 logits def decode_one(token_id): # 输入是单个 token x = embed(token_id) # [1, d] for layer in range(L): q = x @ Wq[layer] # [1, d] k = x @ Wk[layer] # [1, d] v = x @ Wv[layer] # [1, d] kv_cache[layer].append(k, v) # 长度 +1 K_all, V_all = kv_cache[layer].read_all() # [S+t, d] attn = softmax(q @ K_all.T) @ V_all # [1, d] x = attn @ Wo[layer] x = mlp(x, layer) return sample(x @ W_out) # logits: [1, vocab]对着这段代码可以直观看出两个阶段的形状差异:prefill 里X @ Wq是[S, d] × [d, d],decode 里x @ Wq是[1, d] × [d, d]。同一个权重矩阵,被复用的次数差了 S 倍。这就是全部差异的源头。
5.2 用实测数据验证两个阶段的差异
想验证结论,不需要复杂的压测框架,抓三组数据足够:
第一组,固定 prompt 长度 512,把输出长度从 16 依次设到 512,测每次的总耗时。理论上总耗时应该近似等于TTFT + T × TPOT,是一条线性关系。如果拟合出来截距很大而斜率很小,说明 Prefill 占比高;反过来就是 Decode 主导。
第二组,固定输出长度 64,把 prompt 长度从 256 依次加到 8192,只测 TTFT。你会看到前段近似线性增长,超过某个长度后增长率抬头——那个抬头点就是注意力平方项开始主导的位置。
第三组,固定 prompt 和输出长度,把并发 batch 从 1 加到 32,测吞吐(token/s)和单请求 TPOT。吞吐应该在前段近似线性上升,TPOT 基本持平;到某个 batch 之后,吞吐增速放缓、TPOT 开始明显抬升,那就是碰到了算力或者显存的天花板。
我自己测第三组数据时,最有价值的观察不是"batch 多大最好",而是TPOT 开始抬升的那个拐点位置,会随着上下文长度增加而显著前移。同样一张卡,2K 上下文能撑到 batch 24 才拐,8K 上下文 batch 12 就拐了。原因就是第 3.2 节说的 KV Cache 读取量随长度线性膨胀。这个观察直接决定了线上按"平均上下文长度"来动态调整 batch 上限,而不是配一个固定值。
5.3 几个能直接抄走的调参阈值
这些是我在不同规模模型上反复试出来、可以直接当起点的经验值,实际落地时按你的硬件和业务再微调:
- TTFT 的目标线:对话类应用压在 500 毫秒以内体感最好,超过 1 秒用户会开始怀疑是不是没发出去。在线客服类的首字延迟容忍度稍高,1.5 秒内一般能接受。
- TPOT 的目标线:中文场景下 TPOT 在 30 到 50 毫秒之间,阅读体验接近自然语速;低于 25 毫秒的提升用户基本感知不到,不必为它牺牲吞吐。
- Prefill 和 Decode 的实例配比:如果两段分离部署,按总 prefill FLOPs 和总 decode 带宽需求的比例来配。经验上输入输出长度比在 10:1 左右时,Prefill 实例和 Decode 实例的数量比落在 1:2 到 1:3 比较合适,具体要看你的实际 token 分布。
- KV Cache 显存预留:至少留出总显存的 30% 给 KV Cache,低于这个值会频繁触发请求排队甚至抢占,尾延迟会突然劣化。
- 分块预填充的块大小:从 1024 开始试,TTFT 不达标就往小调,TPOT 抖动超标就往大调。
6. 常见误解与踩坑记录
6.1 "Prefill 慢就是模型太大"——多数时候不是
这是我见过最多的误判。看到 TTFT 高,第一反应是模型参数太多,于是换小模型,结果 TTFT 只降了一点,业务效果却掉了一大截。实际上按第 2.2 节的公式算一遍就会发现,Prefill 的耗时主要取决于P × S,其中 S 是你能控制的。同样一个模型,prompt 从 2000 token 压到 800 token,TTFT 直接降 60%,这个收益比换小模型划算得多。
真正需要排查的方向,按优先级我一般这么排:先看实际输入长度分布,是不是有一批超长请求混在里面(P99 长度往往是中位数的十几倍,这批请求会显著拉高平均 TTFT);再看是不是分块预填充的块切得太碎,导致 GEMM 吃不满;然后看批内是不是混了长 prefill 和大量 decode,队头阻塞把单个 step 拉长了;最后才考虑模型规模。
还有一个隐蔽的情况:prompt 里的重复内容很多,但因为位置编码或者模板拼接的细微差异导致前缀缓存没命中。我遇到过一次,系统提示词末尾多拼了一个空格,缓存命中率从 85% 掉到 0,TTFT 直接翻了四倍。查了两天才定位到。后来我在拼接逻辑里加了一条校验,把最终 prompt 的 hash 打出来做命中率监控,这类问题就能第一时间发现。
6.2 KV Cache 显存预估,几个必错的地方
KV Cache 的显存估算看起来公式简单,但实际配错的人特别多。我总结下来高频出错的有这么几处:
| 出错点 | 错误算法 | 正确做法 |
|---|---|---|
| 漏乘 2 | 只算了 K 或只算了 V | K 和 V 各一份,要乘 2 |
| 头数用错 | 用了注意力头数 n_heads | 用键值头数 n_kv_heads,分组查询下差好几倍 |
| 漏乘层数 | 按单层估算后忘了乘 L | 每层都有独立的 KV Cache |
| 漏乘 batch | 只算了单请求 | 并发数要乘上去 |
| 精度字节错 | FP16 按 1 字节算 | FP16 是 2 字节,INT8 才是 1 字节 |
| 忘记预留增长 | 按 prompt 长度算完就完事 | 要加上最大生成长度,Decode 期间缓存还在涨 |
最后一条尤其容易被忽略。按 prompt 长度算出 8 GB,觉得很宽裕,结果生成长度设的是 2048,实际峰值要到 12 GB,跑到一半显存爆掉。配置 max_tokens 的时候,一定要把"prompt 长度 + 最大生成长度"作为 KV Cache 的估算基准,而不是只用 prompt 长度。
6.3 长上下文下 Decode 的隐性成本
短上下文时代,很多人形成了"Decode 单步耗时是恒定的"这个印象,因为那时候 KV Cache 读取量远小于权重读取量,被掩盖了。上下文一拉长,这个假设就崩了。
举个具体的数:一个 32 层、8 个键值头、头维度 128 的模型,FP16 精度下每个 token 的 KV Cache 是2 × 32 × 8 × 128 × 2 = 131 KB。上下文 4K 时是 512 MB,单步读取这点数据相对 14 GB 的权重可以忽略;上下文到 128K 时,单请求的 KV Cache 就是 16 GB,超过了权重本身,这时候 Decode 的瓶颈彻底变成 KV Cache 读取。
用户端感受到的就是"越写越慢"——开头几个字出得挺快,写到后面明显拖沓。这个体验问题在长文档生成、长代码补全场景里特别扎眼。缓解手段有几个方向:用分组查询注意力或者多查询注意力把键值头数压下来(这是最直接有效的一招,头数减半,KV Cache 直接减半);把 KV Cache 量化到 INT8 甚至更低;用滑动窗口注意力只保留最近一段的 K/V;或者直接把长请求路由到专门的长上下文实例上,别让它污染常规实例的 TPOT。
我自己的做法是在调度层加一条按预估总长度分流的规则:预估总长度超过某个阈值的请求,走单独的实例池。这样常规请求的 TPOT 保持平稳,长请求虽然慢一点但至少有专属资源,不会互相拖累。这条规则上线之后,TPOT 的 P99 从原来中位数的三倍多降到了一倍半以内。
6.4 别把 TTFT 和 TPOT 混在一起看
最后一个坑偏观测层面。有些监控面板只报一个"平均响应时间",把 TTFT 和整个生成过程混在一起算,这个指标几乎没有诊断价值。同样 3 秒的平均响应时间,可能是 TTFT 2.8 秒 + 快速输出,也可能是 TTFT 0.3 秒 + 慢速输出,两者的优化方向完全相反。
必须分开埋点:TTFT 单独统计,TPOT 单独统计,再补上输入 token 数和输出 token 数的分布。有这四个维度,绝大多数性能问题都能一眼定位到是 Prefill 侧还是 Decode 侧。我习惯在日志里同时记下命中前缀缓存的 token 数,因为一旦命中率异常,TTFT 的解读会完全跑偏——看着 TTFT 很低,其实是缓存把 prefill 的大部分工作省掉了,这时候如果按这个数据去压测容量,上线后缓存一冷就会全线崩盘。
压测时还要特别注意缓存预热。冷缓存和热缓存下的 TTFT 能差一个数量级,用热缓存的数据做容量规划,等于给自己埋雷。我的做法是压测脚本里同时跑冷、热两组,按冷缓存的数字定容量,按热缓存的数字定延迟目标。
最后分享一个我最近在用的排查习惯:拿到任何一组异常的推理性能数据,先不看模型、不看框架配置,先算三个数——理论 prefill FLOPs、理论 decode 单步带宽耗时、估算的 KV Cache 显存占用。这三个数一算出来,实际数据和理论值的差距有多大、差在哪个环节,基本就有方向了。大部分所谓的"性能玄学",其实都是某笔账没算对。