做 LLM 推理优化的时间长了,你会发现一个特别拧巴的现象:模型参数看着没多大,但跑起来显存说爆就爆;单看一次推理延迟好像还能忍,一上并发吞吐立刻拉胯。网上聊推理优化的文章不少,但大多停留在"KV Cache 要省着用""上 FP16 量化"这种结论层面,真正把背后的原理讲透、让读者能自己判断"该往哪个方向优化"的内容并不多。这篇文章想做的就是这个事——从 LLM 推理的底层机制出发,把算力、显存、访存、调度这些核心约束逐层拆开,再用我实际跑过的案例和数据,告诉你每一步优化到底在解决什么问题,以及为什么某些方案在这个场景有效、换个场景就翻车。
内容包括:解码过程的本质瓶颈、KV Cache 的显存公式与量化取舍、注意力机制的计算模式与 Flash Attention 为什么快、批量调度与投机解码的数学逻辑、以及我自己整理的一套排查流程和指标观测方法。适合正在做推理服务部署、想优化响应速度或降低显存占用的工程师,也适合刚接触 LLM 应用开发、想搞明白"为什么本地跑个 7B 模型都这么慢"的学习者。读完你至少能回答三个问题:显存被谁吃了?时间花在哪了?优化手段各自的代价是什么?
1. 核心瓶颈拆解:LLM 推理的时间与显存都去哪了
1.1 自回归解码:一切优化都要服从的硬约束
要理解 LLM 推理优化,先得接受一个底层事实:大语言模型的推理本质上是串行的自回归过程。模型每次只生成一个 token,生成下一个 token 时要依赖前面已经生成的所有 token。所以生成 100 个 token,就要调用 100 次模型前向计算,这 100 次没法并行(至少现在主流架构下没法跨 token 并行),只能一次一次来。这就是为什么 LLM 推理的延迟和显存压力都绕不开一个词——顺序依赖。
有一个很直观的生活类比:查字典写句子。你每写下一个字之前,要先看看已经写好的整句话是什么,再决定这个字怎么写。你不能跳过前一个字直接写后一个字,因为你后一个字的内容取决于前面的语义。自回归解码就是这个过程,只不过"看已经写好的句子"这一步在计算上代价极高——每一次都要把历史 token 的隐藏状态重新读一遍、算一遍交互。
这个特性直接决定了优化逻辑的边界:你没法靠单纯堆显卡并行度来压单请求延迟,因为第 N 个 token 的计算必须在第 N-1 个 token 算出之后才能开始。所以从架构层面,所有加速手段都只能做两件事:让单次前向计算更快,或者让一次前向计算同时服务更多请求。
从工程视角来看,自回归还带来了第二个隐藏问题:访存瓶颈。当前主流 GPU 做矩阵乘法的速度非常快,但模型权重要从显存取到计算单元里,这个搬运过程是有带宽上限的。当你 batch size 很小的时候,实际计算量很小,但权重搬运还是要花同样多的时间,这就导致计算单元大量空闲,整体表现为"跑一个请求很慢"。而增大 batch size 后,权重只需要搬运一次,可以被多个请求复用,计算资源的利用率一下就上去了。这是后面所有批处理优化的根本出发点。
1.2 一次推理的完整时间构成:prefill 与 decode 的差异
要分析耗时,得把一次完整的推理拆成两段:prefill 阶段和decode 阶段。
prefill 阶段是处理用户输入的那一下。比如你输入了 100 个 token 的 prompt,模型需要把这 100 个 token 并行处理,一次性生成每个 token 对应的隐状态,并计算出第一个输出 token。这阶段的特点是:数据量大、计算密度高,AI 加速卡擅长干这活,所以实践中 prefill 的吞吐很高,但耗时依然可观——prompt 越长,这个阶段越慢。
decode 阶段是逐 token 生成后续内容的过程。每个 token 生成时,需要把之前所有 token 对应的 KV Cache 读出来参与注意力计算。这个阶段的特点是:计算量小、访存量线性增长,GPU 的算力根本喂不饱,瓶颈几乎完全卡在显存带宽上。我曾经拿一张消费级显卡做过粗略测试:7B 模型 FP16 权重占 14GB,单次 decode 的计算量不到 1 TFLOPs,但需要从显存读取的权重和数据加起来超过 14GB。这张卡的算力可以做到 30 TFLOPs 以上,但显存带宽只有 600GB/s 左右,一算就知道——就算算力翻了四倍,decode 的速度也几乎不变,因为时间全花在搬数据上了。
这个差异直接决定了优化策略的分野:prefill 阶段要优化计算效率,比如用更长的 prompt 批处理;decode 阶段要优化访存效率,比如把权重和 KV Cache 做量化压缩减小搬运量。一套方案想同时适配两个阶段,往往就得做取舍。
1.3 显存的四笔账:权重、KV Cache、激活值、运行时开销
很多人以为显存压力主要来自模型权重,实际跑起来才发现,KV Cache 才是那个隐形吞内存的大户。一块显存的去向主要有四块:
- 模型权重:7B 模型 FP16 格式就是 14GB,13B 就是 26GB,这是显存占用的基石,但它是静态的,好算也好预估。
- KV Cache:每个请求在生成过程中都会不断累积,这个值是动态的,随并发数和序列长度线性增长,是最难控制的部分。
- 激活值:前向计算过程中产生的中间张量,prefill 阶段因为要处理长序列,激活值会非常大;decode 阶段因为 batch size 一般较小,激活值相对可控。
- 运行时开销:CUDA context、框架缓存、内存碎片等,看起来不多,但多个服务叠加后也能吃掉几百 MB 到几个 GB。
我实际部署时的经验是:权重只能算"地基",KV Cache 才是决定你能开多大并发、跑多长上下文的"天花板"。很多场景下 2K 上下文够用,但如果业务要支持 32K 甚至更长,KV Cache 会以线性速度吃掉显存,这比权重翻倍要可怕得多。所以业界才有那么多 KV Cache 压缩、量化、offload 的方案出来。
2. KV Cache 的账本:显存公式、量化与精度代价
2.1 KV Cache 到底是怎么算出来的
KV Cache 的全称是 Key-Value Cache,它缓存的是注意力机制中每个历史 token 经过投影矩阵计算出的 Key 向量和 Value 向量。为什么要缓存?因为生成新 token 时,注意力计算需要当前 token 的 Query 和所有历史 token 的 Key、Value 做点积。如果不缓存,就得把历史 token 重新往前传一遍,计算量直接翻 N 倍(N 是序列长度),这在工程上完全不可接受。
KV Cache 的显存占用有个经典公式:
显存字节数 = 2(K 和 V 两份) × num_layers(层数) × num_heads(头数) × head_dim(头维度) × seq_len(序列长度) × batch_size(批量大小) × bytes_per_element(每个元素字节数)
以 FP16 为例,7B 模型通常是 32 层、32 个头、head_dim 128,算下来每 token 的 KV Cache 占用大约是 32×32×128×2×2 = 524,288 字节,即 0.5MB。单个请求生成 2000 token,KV Cache 就要吃 1GB 显存;如果要并发 16 个请求,光 KV Cache 就是 16GB。
这个数字放出来,你就明白为什么"本地跑大模型动不动显存 OOM"了。权重 14GB 还能用一张 24GB 的卡装下,但并发一开、上下文一长,KV Cache 才是压垮显存的最后一根稻草。
2.2 量化 KV Cache 的收益与代价
既然 KV Cache 占大头,那把它从 FP16 压到 INT8 甚至 INT4,不就省了 50% 到 75% 的内存?逻辑没问题,但代价要算清楚。
KV Cache 量化的核心难点在于:注意力分数对量化误差非常敏感。Key 和 Value 不同位置的值分布差异很大,尤其长文本场景下可能存在极端值,简单按全局 scale 量化会出现个别 token 的注意力计算严重失真,进而影响生成质量和稳定性。这也是为什么 KV Cache 量化不能像权重量化那样"直接压缩就完事",通常需要做 per-channel 或者 per-head 的 scale 校准。
从我实际跑过的对比实验看,INT8 KV Cache 在多数任务上质量损失很小,BLEU/准确率下降通常在一个点以内,工程上可以直接默认开启。INT4 KV Cache 就得分场景了:短文本、生成任务里表现还行,但长文本或者需要精确数值运算的场景(比如数学推理),偶尔会出现明显劣化,需要结合具体业务评估。另外还要注意,量化会引入额外的反量化开销,decode 阶段本来带宽就吃紧,压缩省下的搬运时间和反量子操作增加的计算时间之间,需要实测才能判断哪边更划算。
我自己的建议是:在带宽严重吃紧的消费级显卡上,优先上 INT8 KV Cache;如果显存实在不够,再考虑 INT4,同时做好长文本场景的回归测试。服务端有条件的话,还可以试试 NVIDIA 的 TransformerEngine 里提供的 FP8 方案,它在保证一定精度的前提下压缩比更高。
2.3 显存不够时:offload 和重计算这类"曲线救国"方案
显存实在不够怎么办?很多框架提供了 offload 方案——把部分权重或者 KV Cache 挪到 CPU 内存,计算时再临时搬回显存。这个方案的代价非常直接:CPU 与 GPU 之间的 PCIe 带宽通常只有几十 GB/s,比显存带宽慢了近一个数量级,频繁搬运会严重拖慢速度。我试过把 7B 模型的 KV Cache 做 CPU offload,短文本下延迟还能接受,但上下文一长,整体吞吐直接掉了四成以上。所以 offload 只适合"保运行"而非"保性能"的场景,比如本地演示或者显存极有限的环境。
还有一种激活重计算(activation checkpointing)方案,在前向计算时丢掉部分中间激活值,反向传播时再重新算一遍。这种方式在训练场景很常见,推理场景用得少——因为推理没有反向传播,激活值重算只会白白增加计算量。不过如果框架里提供了某些中间状态的按需重算机制,也可以用来节省峰值显存,具体收益得看实现。
说到底,显存优化的本质是在"容量"和"速度"之间做权衡,只有把账算清楚,才能找到最优解。
3. 注意力机制优化:Flash Attention 与 PagedAttention 的底层逻辑
3.1 标准注意力的显存瓶颈:为什么长上下文就 OOM
聊 KV Cache 的时候偏重容量维度,注意力计算的另一个痛点是中间矩阵的峰值显存。标准 attention 计算要先算 Q 和 K 的转置乘积得到注意力分数矩阵,这个矩阵的形状是 [batch_size, num_heads, seq_len, seq_len]。seq_len 一长,这个中间结果会爆炸性增长。
举个例子:batch_size 1、16 个头、seq_len 4096,注意力分数矩阵的大小是 16×4096×4096×2 字节,算下来大约 512MB。看起来还能忍,但 seq_len 翻到 8192,这个值直接变 2GB,而且它只是前向计算里众多中间张量中的一个。换句话说,即便权重和 KV Cache 都放得下,长上下文场景下标准注意力的中间矩阵仍然可能把显存打穿。
这就是为什么会有 Kernel 融合和分块计算的思路——不把完整矩阵算出来,而是把一个大的计算拆成多个小块,边算边用,用完即弃,峰值显存就能大幅下降。
3.2 Flash Attention 的工作方式与真实收益
Flash Attention 的核心思想是分块计算 + Kernel 融合。它把 Q、K、V 都切成小块,每次只加载一个块到片上 SRAM 里做计算,算完立即更新输出,然后丢弃中间矩阵。因为不需要把完整的注意力分数矩阵写回显存,省掉了一次大矩阵的写入和读取,所以不仅节省了显存,还大幅减少了访存量。
在长序列场景下,Flash Attention 的优势可以用数据说话。我实际测过 seq_len 从 2K 涨到 8K 的对比,标准 attention 的显存占用是线性上涨然后突然崩掉(OOM),而 Flash Attention 的显存增长曲线要平缓得多,8K 长度下显存占用大约是标准实现的四分之一到五分之一。速度方面,FLOPS 高的情况下 Flash Attention 没有明显优势,但在 decode 这类访存密集场景,时间能缩短 20%~40%。
不过要提醒一点:Flash Attention 有多种实现版本,不同 GPU 架构适配度不同。比如 A100/H100 上用 cuDNN 或 CUTLASS 的 Flash Attention 内核优化很到位,但消费级卡的某些自定义实现未必有收益,甚至可能因为 kernel 兼容性问题导致速度下降。实操时建议先小规模 benchmark 再决定是否全套开启。另外 Flash Attention 默认通常只支持 FP16/BF16,如果你跑的是 FP32 模型,需要先做精度转换。
3.3 服务框架里的显存管理:PagedAttention 的启发
PagedAttention 这个思路最早来自 vLLM 项目,核心想法是借鉴操作系统虚拟内存的分页机制,把 KV Cache 分成固定大小的块(block),不要求物理上连续,通过块表来管理映射。你可能会问:KV Cache 不就是一段连续张量吗,为什么需要这种分页?
关键在于内存碎片和预分配浪费。如果每个请求都预分配最大长度的 KV Cache,那么实际生成较短的请求就会浪费大量显存。而如果不预分配,连续内存空间又容易因为请求不断增长而频繁搬移,造成碎片和停顿。PagedAttention 用分页的方式按需分配,只给实际用到的块分配物理内存,同时还能通过块共享机制支持并行采样(多个输出分支共享同一个前缀的 KV Cache),内存利用率大幅提升。
vLLM 官方数据里,PagedAttention 在多请求场景下能将显存利用率提升到接近 90%,而传统方式往往只有 60%~70%。这在高并发服务里就是实打实的吞吐差异。现在很多框架(如 TensorRT-LLM、SGLang)都沿用了类似思路,做推理服务选型时,"是否支持 PagedAttention 或等效方案"应该作为一条硬性标准来看。
4. 解码加速技术:投机解码、并行采样与批处理调度
4.1 投机解码:用"小模型猜答案,大模型做验证"来换速度
自回归解码是串行的——生成第 N+1 个 token 必须等第 N 个 token 算完。即使单次前向计算已经优化得很快,N 次串行的时间仍然压不下来。投机解码(Speculative Decoding)换了个思路:用一个更小的草稿模型(draft model)先快速猜出接下来 γ 个 token,再用大模型一次前向并行验证这些 token 是否正确。验证结果是整块接受的:如果小模型连续猜对了,大模型一次计算就相当于一次生成了多个 token,串行次数直接减少。
这个方案听起来很美,但实际效果完全取决于一个小模型猜测的准确率。我跑过一组测试:用 1B 模型草稿、13B 模型验证,短句生成场景加速比大约 1.5~2 倍;但涉及专业术语密集的领域文本时,草稿模型频繁猜错,验证不通过就得回滚重新生成,加速比掉到 1.2 倍甚至更低。所以投机解码不是免费的午餐,它的开销在于要同时加载两个模型,显存占用会增大;收益上限取决于草稿模型与目标模型的"默契程度"。
从工程角度讲,投机解码更适合对延迟敏感、生成长度较长、且业务数据与草稿模型训练分布比较接近的场景。如果业务 prompt 内容复杂多变,建议先用代表性数据集测一下收益再决定是否部署。
4.2 连续批处理:动态调度比固定等待高效得多
传统的批处理策略是静态的:攒够一定数量的请求,然后一起跑,所有请求同一批次开始、同一批次结束。这么做的问题很明显——如果请求长短不一,短请求必须等长请求跑完才能释放资源,设备利用率完全看最长请求的脸色。而连续批处理(Continuous Batching)的核心改进是:每个请求的 prefill 和 decode 阶段不再绑定在同一批次里,只要某个请求生成完了,立刻归还资源,新请求马上补位。
这个机制让 GPU 始终处于"能算就不过夜"的状态。用我做过的一个对比实验说明:相同请求量下,连续批处理的服务吞吐比静态批处理高 1.8~2.3 倍,尤其在高并发、长短请求混合的场景,差距更明显。这在生产环境几乎已经是默认标配了,vLLM、TensorRT-LLM 等主流框架都原生支持。
不过连续批处理也带来一个新问题:不同请求的 KV Cache 生命周期不一致,显存分配与释放频率就会很高。这正好呼应前面提到的 PagedAttention——动态批处理配合分页显存管理,才是完整的调度闭环。只做连续批处理但没有好的显存管理器,实际收益会大打折扣。
4.3 并行采样与 beam search 的取舍:要多样还是准
除了加速,很多业务还需要在推理时做多次采样或 beam search。这里有个误解值得澄清:多次采样看起来是"同一个模型跑了多次",但如果用并行采样并且有前缀共享机制,相同 prompt 的处理是可以复用 KV Cache 的,成本远低于独立跑多次。vLLM 的并行采样就是通过块共享实现前缀复用,这也是 PagedAttention 带来的隐藏收益之一。
beam search 则是另一套逻辑——同一时刻保留多个候选序列,不断扩展并剪枝,计算量和显存占用都更高,但在需要"最优结果"的场景(比如翻译、摘要)不可替代。实践中建议先确认业务是否真的需要 beam search:如果只是内容创作、开放对话这类任务,温度采样配合 top-p 就足够,没必要为了"稳定"盲目上 beam search 牺牲吞吐。
4.4 选型手记:vLLM / TensorRT-LLM / SGLang 的取舍思路
框架选型是推理优化落地时绕不开的话题。我基于实际踩坑经验做个简单对照:
- vLLM:生态成熟、上手最快,PagedAttention 和连续批处理开箱即用,社区文档丰富,适合绝大多数生产场景。缺点是自定义算子深度调优空间不如 TensorRT-LLM,极限性能可能差一些。
- TensorRT-LLM:NVIDIA 官方出品,针对自家 GPU 做了深度算子级优化,量化支持也最全面,适合追求极致吞吐和延迟、且 GPU 型号较单一的场景。缺点是需要把模型转成 TRT engine,编译时间长,调试链路偏复杂。
- SGLang:主打 RadixAttention,可以在请求间复用公共前缀的 KV Cache,对多轮对话、few-shot 这类前缀高度重合的场景非常友好。适合 Prompt 复用率高的业务形态。
- llama.cpp:单机 CPU/消费级 GPU 场景的老牌选择,量化格式(GGUF)生态完善,在低配环境里优化得相当到位,适合本地部署和边缘设备。部署简单但要小心第三方绑定的加速实现,不同 fork 之间性能差异可能很大。
我的建议是:能查清并预测业务形态之前,先用 vLLM 贴着模型跑一版基线,根据 profiling 结果再考虑是否往 TensorRT-LLM 迁移。不要一上来就追"最强性能",没有基线数据做基准,优化方向的判断会非常主观。
5. 实战验证:从基线到优化落地的完整流程
5.1 搭一套可复现的 benchmark:指标与数据采集
任何优化动作之前,第一步都是把基线和目标定出来。我一般会记录这些指标:
- TTFT(Time To First Token):从请求发起到返回第一个 token 的时间,决定用户"首字感知"。
- TPOT(Time Per Output Token)/ITL(Inter-Token Latency):生成每个 token 的平均耗时,决定"打字机"速度。
- 吞吐量(tokens/s):单位时间所有请求生成的 token 总数,衡量服务整体产能。
- 显存占用曲线:峰值显存、KV Cache 用量、是否有 OOM。
测的时候要格外注意并发和输入长度的组合。很多优化在单请求下表现平平,并发一上来差异才明显;也有优化在短文本上高效,长文本下反而退化。所以我会建一个混合 worklaod 的压测集:30% 短 prompt + 50% 中等长度 + 20% 长 prompt,每种并发下记录 P50、P95 和 P99 延迟。没有百分位延迟数据,优化很容易做出"平均快了但长尾更糟"的伪优化。
5.2 一步一验:配置改动与效果对照的实操记录
我拿一个实际项目举例。目标是部署 13B 模型在高并发场景下的服务,具体配置过程和数据如下:
第一版直接上 vLLM 默认配置,模型 FP16,KV Cache 默认 FP16。压测结果是:并发 8、输入 512 token 时,P99 TTFT 1.4 秒,P99 TPOT 约 90ms,显存峰值 28GB,已经接近 32GB 卡的极限。问题在于并发上到 12 之后,显存直接不够,OOM 频繁出现。
第二步开启 KV Cache INT8 量化,代码只改了一行配置参数。显存峰值降到约 23GB,TPOT 反而从 90ms 降到 78ms。为什么量化后反而更快?因为 decode 阶段是访存瓶颈,KV Cache 数据量减半意味着每次前向计算要搬运的字节数减半,访存时间直接缩短。这是理论上预期的效果,测出来的数字完全印证了。
第三步把调度策略从默认的 prefill 优先改成连续批处理 + 动态 chunk 大小,并开启前缀缓存。这一步对高并发帮助最大:并发 16 时,P99 TPOT 稳定在 95ms 附近,吞吐从原来的 180 tokens/s 提升到了 420 tokens/s。注意这步之后显存也没涨太多,因为分页管理和前缀缓存把重复计算省掉了。
第四步尝试投机解码,挂了个 1B 草稿模型。短文本场景加速比约 1.6 倍,但显存峰值多了约 2GB,而且长 prompt 场景收益衰减到 1.2 倍。考虑到业务长文本比例不低,最后没有在生产开启。
这组实验最大的价值不是"哪一步参数最优",而是展示了一种排查路径:显存紧张先看 KV Cache;速度瓶颈在 decode 阶段优先考虑访存优化;再往上就是框架层面的调度策略调优。
5.3 别被平均数骗了:延迟分布与显存碎片排查
最后说一个很容易被忽视的坑——性能测试不能只看平均值,必须看百分位分布和显存分配曲线。特别是显存碎片问题,在高并发长稳运行后特别常见:服务刚启动时一切正常,跑了几小时后开始偶发 OOM,但看监控峰值显存又没超限。这种往往是 KV Cache 块反复分配释放后内存碎片化,PagedAttention 这类方案虽然缓解了碎片问题,但不同框架的实现程度不一样。
我会用两个工具辅助排查:一是框架自带的 metrics 接口看 KV Cache 使用率和块命中率(vLLM 有内置的 /metrics 输出);二是 PyTorch 的显存快照(memory snapshot)抓全量分配图,看是否存在大量小碎片块。如果确认是碎片问题,可以调大块大小(比如从 16 token 调到 32 token),或者定期重启节点来缓解——但长期方案还是得升级到支持碎片整理的框架版本。
6. 附:推理优化常见坑点速查
下面是我在多次部署和调优中积累的常见问题与解决思路,整理成表格方便对照排查:
| 现象 | 根因方向 | 排查与处理建议 |
|---|---|---|
| 并发一高就 OOM | KV Cache 占满显存 | 开启 KV Cache INT8 量化、限制单请求最大生成长度、控制最大并发数 |
| 生成速度很慢但算力没跑满 | decode 阶段访存瓶颈 | 降低权重精度(INT8/FP8)、开启 Flash Attention、检查是否启用了连续批处理 |
| 长 prompt 首字延迟高 | prefill 阶段计算量大 | 开启 chunked prefill、检查注意力实现是否为 FlashAttention |
| 短请求被长请求拖累 | 调度策略不当 | 启用连续批处理、按请求长度分队列或设置超时 |
| 长稳运行后偶发 OOM | 显存碎片化 | 调整 KV Cache 块大小、定期重启节点、检查框架版本更新说明 |
| 投机解码开了反而更慢 | 草稿模型命中率过低 | 换更大的草稿模型、针对业务数据微调草稿模型、或停用投机解码 |
| 模型输出质量波动 | 量化精度损失 | 对比 INT8/INT4 在代表性任务集上的评估指标、适当保留部分层为高精度 |
坑点里最容易忽略的是"不同优化之间的相互干扰"。比如你开了投机解码又开了 INT4 量化,草稿模型和目标模型之间的验证一致性会受量化误差影响,命中率可能进一步下降,叠加后的性能未必是两者收益之和。每次只改一个变量、做对照实验,是推理优化里性价比最高的方法。
最后再分享一个我自己的操作习惯:每次调参后,先跑一个快速 smoke test 确认服务可用,再跑全量压测;压测数据落盘,留档对比,避免下次凭感觉说"好像快了"。优化这个东西,没有数据支撑的判断,都容易变成玄学。