大模型推理性能优化实战:从 KV Cache、量化到批处理调度的系统化落地指南
做大模型应用的人,通常都会经历一个很真实的阶段:模型先跑起来,再想办法把账单和时延打下来。
我自己做推理服务时,最初也以为“换一张更大的卡”就能解决问题,后来发现真正影响体验和成本的,往往不是单点参数,而是一整条链路:Prefill/Decode 的计算特征、KV Cache 的内存占用、量化后的吞吐变化、批处理调度策略、甚至请求长度分布。这些因素叠在一起,才决定了服务到底是“能用”,还是“能规模化落地”。
这篇文章不只讲单个技巧,而是从系统视角把一套推理优化方法串起来,重点覆盖:
- KV Cache:为什么它既能提速,也会吞掉显存
- 量化:为什么有时吞吐提升明显,有时却不如预期
- 批处理调度:为什么“批更大”不一定更快
- 容量估算与取舍:如何把优化手段落到可运营的架构上
如果你已经会基本部署 LLM 服务,这篇文章会更适合你。
背景与问题
为什么大模型推理这么容易“又慢又贵”?
从服务角度看,一次生成请求大致分成两个阶段:
- Prefill 阶段:把整段输入 prompt 编码进模型
- Decode 阶段:逐 token 生成输出
这两个阶段的性能瓶颈不完全一样:
- Prefill 更偏计算密集
- Decode 更偏内存带宽和访问模式
- 当上下文变长时,KV Cache 会迅速膨胀
- 当并发变高时,调度策略 会直接影响 GPU 利用率和尾延迟
很多团队上线初期会遇到这些典型症状:
- 单卡显存明明还有剩余,但吞吐上不去
- 平均延迟还行,P95/P99 尾延迟非常差
- 开了量化后,成本下降了,但效果质量波动明显
- 请求一多,长短 prompt 相互拖累,服务抖动严重
本质上,这是一个典型的系统工程问题,不是只靠某一个“黑科技参数”就能解决。
一个更贴近真实业务的目标
如果你的目标只是“跑通 demo”,那很多问题都不会暴露。但真正上线时,通常要同时满足:
- 首 token 延迟(TTFT)可接受
- tokens/s 吞吐足够高
- 单位请求成本可控
- 长上下文不会把显存打爆
- 高并发下尾延迟仍稳定
因此,推理优化不是“越激进越好”,而是要围绕业务目标做取舍。
整体架构视角:先看全局,再做局部优化
先给出一个我更推荐的推理优化思路:按数据流和资源瓶颈拆层治理。
flowchart TD
A[客户端请求] --> B[请求接入层]
B --> C[Tokenizer/长度预估]
C --> D[批处理调度器]
D --> E1[Prefill Worker]
D --> E2[Decode Worker]
E1 --> F[KV Cache 管理器]
E2 --> F
F --> G[量化模型执行引擎]
G --> H[采样/后处理]
H --> I[响应返回]
J[监控系统] --> D
J --> F
J --> G
这张图表达一个核心原则:
推理优化不是只优化模型,而是优化“请求进入 GPU 到结果返回”的整个链路。
在这条链路中,最关键的三个抓手通常是:
- KV Cache 管理
- 模型量化
- 批处理调度
后面我们按这三个部分展开。
核心原理
1. KV Cache:为什么它能显著提速?
Transformer 在生成时,每个 token 都需要和历史 token 做 attention。如果每一步都把历史重新算一遍,代价会非常高。
KV Cache 的做法是:
- 在前面 token 的计算过程中,把每一层 attention 的 Key/Value 保存下来
- 后续生成新 token 时,直接复用过去的 K/V
- 这样就避免重复计算历史上下文
直观理解
如果没有 KV Cache,生成第 t 个 token 时,模型要“重新看一遍”前 1...t-1 的历史。
有了 KV Cache,模型只需要:
- 读取历史缓存的 K/V
- 为当前 token 计算新的 q/k/v
- 更新缓存并继续生成
因此:
- 计算量大幅下降
- 但代价是:显存占用显著上升
KV Cache 的近似内存估算
KV Cache 内存通常可近似估算为:
KV Cache ≈ batch_size × seq_len × num_layers × num_kv_heads × head_dim × 2 × dtype_bytes
其中:
2表示 K 和 V 两份dtype_bytes取决于缓存精度,如 fp16 为 2 字节
如果你部署的是多并发、长上下文模型,这部分内存常常比你想象得更可怕。
KV Cache 的工程关键点
实际落地时,KV Cache 不是“开了就完事”,常见要关注:
- 连续内存 vs 分页式管理
- 缓存复用
- 前缀共享(Prefix Caching)
- 缓存淘汰策略
- 长短请求混部造成的内存碎片
现代推理引擎里,Paged KV Cache 很常见,因为它更适合动态请求场景。
flowchart LR
A[请求A Prefill] --> B[分配 KV 页]
C[请求B Prefill] --> D[分配 KV 页]
B --> E[统一 KV Page 池]
D --> E
E --> F[Decode 读取映射]
F --> G[释放/复用空闲页]
KV Cache 不一定总是“越大越好”
这是我踩过的一个坑。很多人会想:“既然 cache 能提速,那就尽量保留更多历史。”
问题是:
- 更大的 cache 占更多显存
- 更高的并发下会挤压 batch 空间
- 显存压力上去后,调度器可能不得不降批,反而整体吞吐下降
所以正确的思路不是“尽量缓存”,而是:
让缓存命中率、显存占用、调度灵活性三者平衡。
2. 量化:为什么它能降成本,但不总是线性提速?
量化的本质是把模型权重、激活或 KV Cache 用更低精度表示,比如:
- FP16/BF16
- INT8
- INT4
- AWQ/GPTQ 等权重量化方案
量化主要带来的收益
- 降低显存占用
- 提升加载效率
- 在某些硬件和内核上,提升吞吐
但量化的收益不是永远线性的,原因主要有三个:
原因一:瓶颈不一定在算力
如果你的 Decode 阶段主要受内存访问限制,那么只压缩权重未必能带来同等比例提速。
原因二:量化内核实现质量差异很大
同样是 INT4:
- 不同框架内核性能差异可能非常大
- 有些方案对小 batch 更友好
- 有些方案只在特定 GPU 架构上收益明显
原因三:精度损失可能影响业务结果
尤其是:
- 结构化输出任务
- 数学推理
- 长上下文检索
- 代码生成
这些场景对量化更敏感。
如何选量化策略?
一个实用经验是:
- 线上主力模型:优先考虑成熟度高的
BF16/FP16 + 高效 KV Cache + 调度优化 - 成本敏感服务:考虑
INT8/INT4权重量化 - 长上下文/高并发:同时关注 KV Cache 量化 或缓存压缩能力
- 高质量要求场景:先离线评估,再灰度上线
一个典型取舍表
| 方案 | 显存占用 | 吞吐潜力 | 精度风险 | 工程复杂度 | 适用场景 |
|---|---|---|---|---|---|
| FP16/BF16 | 高 | 中 | 低 | 低 | 稳定生产环境 |
| INT8 | 中 | 中高 | 低中 | 中 | 通用在线服务 |
| INT4 | 低 | 高 | 中高 | 中高 | 成本优先、大规模服务 |
| KV Cache 低精度 | 更低 | 中高 | 中 | 高 | 长上下文高并发 |
3. 批处理调度:吞吐提升最容易被低估的一层
很多人以为批处理就是“把多个请求凑在一起跑”。这只说对了一半。
在 LLM 推理里,真正难的是:
- 请求长度不一致
- 输出长度不可预知
- Prefill 和 Decode 的资源模式不同
- 长请求会拖慢短请求
- 静态批处理会造成 GPU 空转
所以现在更主流的是动态批处理(Dynamic Batching)和连续批处理(Continuous Batching)。
静态批处理 vs 连续批处理
静态批处理
- 收集一批请求
- 一起 Prefill
- 一起 Decode 到结束
- 批次中最慢的请求决定整体节奏
问题是:短请求会被长请求“绑架”。
连续批处理
- 每个解码 step 都可以动态插入新请求
- 已完成请求及时出队
- 空出来的 slot 立即复用
- GPU 利用率明显更高
sequenceDiagram
participant C1 as 请求A
participant C2 as 请求B
participant S as 调度器
participant G as GPU执行器
C1->>S: 到达
S->>G: A进入批次
G-->>S: step1
C2->>S: 到达
S->>G: A+B连续批处理
G-->>S: step2
S-->>C1: A完成,出队
S->>G: B + 新请求C
调度的关键指标
做批处理调度时,我建议至少盯住这几个指标:
- TTFT(Time To First Token)
- TPOT(Time Per Output Token)
- 吞吐量(tokens/s)
- 队列等待时间
- GPU 利用率
- P95/P99 延迟
- OOM 次数 / Cache 分配失败次数
实际工程中的调度策略
常见可落地的策略包括:
1)按 prompt 长度分桶
把长度相近的请求放在一起,减少 padding 和拖尾。
2)Prefill / Decode 分离调度
因为两者资源特征不同,拆开调度往往更稳。
3)为长请求设置上限或单独队列
避免极端长请求拖垮常规业务流量。
4)基于 token 预算做 admission control
不是按“请求数”,而是按“预计 token 开销”入队,这比单纯限流更贴近真实资源消耗。
方案对比与取舍分析
在架构设计上,很多团队会纠结:先做哪个优化最划算?
我的建议是按收益/风险比排序。
一个务实的优化优先级
- 先把观测做好
- 没有 TTFT、TPOT、KV Cache 命中率、显存水位,你几乎无法正确优化
- 上连续批处理
- 往往是最直接的吞吐增益
- 治理 KV Cache
- 尤其是长上下文服务
- 做量化
- 在质量可接受前提下降本
- 再做更复杂的分离部署和多级调度
三种典型架构路线
| 路线 | 优点 | 缺点 | 适用阶段 |
|---|---|---|---|
| 单模型单实例 + 基础批处理 | 简单易维护 | 吞吐和弹性一般 | 早期验证 |
| 连续批处理 + Paged KV Cache | 性能和稳定性平衡较好 | 实现复杂度上升 | 主流生产 |
| Prefill/Decode 分离 + 多队列调度 | 资源利用率高 | 架构和网络复杂度高 | 大规模服务 |
容量估算:别只看“每秒请求数”
大模型服务更适合按 token 做容量估算。
一个简化模型:
总负载 ≈ QPS × (平均输入tokens + 平均输出tokens)
但更准确一些,建议拆成:
Prefill负载 ≈ QPS × 平均输入tokens
Decode负载 ≈ QPS × 平均输出tokens
因为二者对系统的压力模式不同。
如果你是在线问答场景:
- 平均输入 1200 tokens
- 平均输出 300 tokens
- QPS = 5
那么:
- Prefill token 负载约
6000 tokens/s - Decode token 负载约
1500 tokens/s
这种情况下,系统更可能先卡在 Prefill。
反过来,如果你做长文本生成:
- 平均输入 200
- 平均输出 1200
那往往是 Decode 和 KV Cache 压力更大。
实战代码(可运行)
下面我用一个可运行的 Python 示例,模拟一个简化版的连续批处理调度器,帮助你理解:
- 请求如何入队
- 如何按 token budget 组批
- 完成的请求如何出队
- 新请求如何填补批次空位
这个示例不是完整 LLM 引擎,但足够解释调度核心。
示例 1:简化版连续批处理调度器
import time
import random
from dataclasses import dataclass, field
from collections import deque
from typing import List, Deque
@dataclass
class Request:
req_id: int
prompt_tokens: int
max_new_tokens: int
generated_tokens: int = 0
prefilled: bool = False
arrival_time: float = field(default_factory=time.time)
@property
def finished(self) -> bool:
return self.generated_tokens >= self.max_new_tokens
class ContinuousBatchScheduler:
def __init__(self, max_batch_size=4, max_prefill_tokens=4096, max_decode_tokens=4):
self.waiting_queue: Deque[Request] = deque()
self.running_batch: List[Request] = []
self.max_batch_size = max_batch_size
self.max_prefill_tokens = max_prefill_tokens
self.max_decode_tokens = max_decode_tokens
def submit(self, req: Request):
self.waiting_queue.append(req)
def _select_prefill_requests(self) -> List[Request]:
selected = []
total_tokens = 0
while self.waiting_queue and len(self.running_batch) + len(selected) < self.max_batch_size:
req = self.waiting_queue[0]
if total_tokens + req.prompt_tokens > self.max_prefill_tokens:
break
selected.append(self.waiting_queue.popleft())
total_tokens += req.prompt_tokens
return selected
def prefill_step(self):
new_reqs = self._select_prefill_requests()
if not new_reqs:
return
for req in new_reqs:
req.prefilled = True
self.running_batch.append(req)
print(f"[PREFILL] add={[(r.req_id, r.prompt_tokens) for r in new_reqs]} "
f"running={[r.req_id for r in self.running_batch]}")
def decode_step(self):
if not self.running_batch:
return
active = self.running_batch[:self.max_decode_tokens]
for req in active:
req.generated_tokens += 1
finished_ids = [r.req_id for r in self.running_batch if r.finished]
self.running_batch = [r for r in self.running_batch if not r.finished]
print(
f"[DECODE] active={[r.req_id for r in active]} "
f"generated={[(r.req_id, r.generated_tokens) for r in active]} "
f"finished={finished_ids} "
f"remaining={[r.req_id for r in self.running_batch]}"
)
def has_work(self):
return bool(self.waiting_queue or self.running_batch)
def main():
random.seed(42)
scheduler = ContinuousBatchScheduler(
max_batch_size=4,
max_prefill_tokens=3000,
max_decode_tokens=4
)
requests = [
Request(req_id=1, prompt_tokens=500, max_new_tokens=5),
Request(req_id=2, prompt_tokens=1200, max_new_tokens=2),
Request(req_id=3, prompt_tokens=800, max_new_tokens=4),
Request(req_id=4, prompt_tokens=400, max_new_tokens=6),
Request(req_id=5, prompt_tokens=1500, max_new_tokens=3),
]
for req in requests:
scheduler.submit(req)
step = 0
while scheduler.has_work():
step += 1
print(f"\n--- step {step} ---")
scheduler.prefill_step()
scheduler.decode_step()
time.sleep(0.1)
print("\nAll requests finished.")
if __name__ == "__main__":
main()
运行后你会看到什么?
这个示例体现了几个关键点:
- Prefill 受
max_prefill_tokens约束 - Decode 受
max_batch_size和活动请求数约束 - 已完成请求会从 running batch 中移除
- 后续请求可以继续补进来
这就是连续批处理的基本思想:批次不是一成不变的,而是流动的。
示例 2:KV Cache 显存估算脚本
在真正上线前,我强烈建议先做 KV Cache 容量估算。下面这个脚本很适合放到压测前快速试算。
def estimate_kv_cache_gb(
batch_size: int,
seq_len: int,
num_layers: int,
num_kv_heads: int,
head_dim: int,
dtype_bytes: int = 2
) -> float:
total_bytes = (
batch_size
* seq_len
* num_layers
* num_kv_heads
* head_dim
* 2
* dtype_bytes
)
return total_bytes / (1024 ** 3)
if __name__ == "__main__":
gb = estimate_kv_cache_gb(
batch_size=16,
seq_len=8192,
num_layers=32,
num_kv_heads=8,
head_dim=128,
dtype_bytes=2 # fp16/bf16
)
print(f"Estimated KV Cache: {gb:.2f} GB")
怎么用这个结果?
你至少要把下面几部分加总考虑:
- 模型权重显存
- KV Cache 显存
- 激活和临时工作区
- CUDA runtime / 框架额外开销
- 留给碎片和峰值波动的余量
一个常见经验是:不要把卡打到 100%,否则很容易因为长度波动触发 OOM。
常见坑与排查
这一部分我尽量讲得“像现场一点”,因为很多问题不是理论不会,而是线上真的容易误判。
坑 1:开了 KV Cache,但 TTFT 反而没明显变好
原因
KV Cache 主要优化的是 Decode 阶段,对 Prefill 帮助有限。
如果你的 prompt 很长,TTFT 大头其实在 Prefill。
排查方法
- 分开统计 Prefill 耗时和 Decode 耗时
- 看 TTFT 是否主要被输入长度驱动
- 检查 tokenizer 和前处理是否成为隐性瓶颈
建议
- 对长 prompt 做裁剪/重写
- 做前缀缓存
- 考虑 Prefill 与 Decode 分离
坑 2:量化后显存降了,但吞吐没涨多少
原因
- 当前瓶颈在 KV Cache/内存带宽
- 量化内核不适配当前 GPU
- 批处理太小,无法吃满量化 kernel 的优势
排查方法
- 对比 FP16 与 INT8/INT4 的 GPU utilization、memory throughput
- 固定 batch、固定输入长度做 AB 测试
- 检查是否存在频繁 CPU/GPU 同步
建议
- 先确认“是算力瓶颈还是带宽瓶颈”
- 换更成熟的量化推理后端
- 不要只看平均吞吐,也看质量回归
坑 3:批处理越大,尾延迟越差
原因
这在在线服务里非常常见。批次变大虽然有利于吞吐,但也会导致:
- 队列等待更长
- 长请求拖住短请求
- decode step 周期变长
排查方法
- 同时观察 QPS、平均延迟、P95、P99
- 按 prompt 长度分层统计延迟
- 查看长请求比例是否在高峰时升高
建议
- 使用连续批处理,而不是盲目增大静态 batch
- 引入长度分桶
- 对超长请求单独限流或隔离
坑 4:明明算出来显存够,实际还是 OOM
原因
通常是以下几种叠加:
- CUDA allocator 碎片
- 临时 buffer 未计入
- 长短请求混部造成峰值水位波动
- 模型框架有额外 cache/workspace
排查方法
- 对比理论显存与
nvidia-smi峰值 - 记录每次 batch 的最大 seq_len、活跃请求数
- 检查是否在某类极端请求下稳定复现
建议
- 预留 10%~20% 显存余量
- 做 token budget admission control
- 对超长上下文做硬上限
坑 5:Prefix Cache 命中率低,效果不明显
原因
前缀缓存只在“前缀重复度高”时才有价值。
如果你的请求高度个性化,命中率会非常一般。
排查方法
- 统计标准化后的 prompt 前缀重复率
- 区分系统提示词、模板提示词、用户输入
建议
- 将系统提示词与用户变量分离
- 固定模板前缀
- 对检索增强场景,尽量稳定 instruction 部分
安全/性能最佳实践
推理优化不只是“跑得快”,还包括稳定、可控、不会被异常输入拖垮。
1. 做好输入边界控制
至少限制:
- 最大输入 tokens
- 最大输出 tokens
- 最大并发请求数
- 单租户 token 速率
否则一个超长输入就可能占满 KV Cache,把服务拖死。
2. 按 token 预算调度,而不是按请求数
一个 8k prompt 的请求,和一个 200 token 的请求,资源消耗完全不是一个量级。
所以 admission control 最好这样做:
允许进入批次的条件 = 当前活跃token预算 + 新请求预估token开销 <= 阈值
这比“最多 32 个请求”更合理。
3. 保留显存余量,拒绝满载幻觉
理论上跑得下,不代表线上稳。
我的经验是:
- 单卡部署尽量留出 10%~20% 的显存余量
- 高波动业务留更多
- 尾部超长请求单独隔离
4. 用分层指标看问题
不要只看一个总体 QPS。建议至少拆成:
- 按模型版本
- 按输入长度区间
- 按输出长度区间
- 按租户/流量来源
- 按 Prefill/Decode 阶段
这样你才能知道问题到底出在哪一层。
5. 灰度量化,不要全量硬切
量化模型切换建议这样做:
- 离线基准评测
- 小流量灰度
- 比较质量指标与延迟指标
- 再扩大流量
尤其是工具调用、代码生成、结构化 JSON 输出场景,量化精度回归一定要做。
6. 长短请求分池
如果业务允许,最好把:
- 短问答请求
- 长上下文分析请求
- 批量离线生成请求
拆到不同实例池。这样调度更简单,SLO 更稳定。
一份可落地的优化清单
如果你正在接手一个“已经能跑,但性能不理想”的推理服务,我建议按下面顺序推进:
第一步:建立基线
先记录这些指标:
- TTFT
- TPOT
- tokens/s
- GPU 利用率
- 显存占用峰值
- P95/P99
- 平均输入/输出 tokens
- OOM 次数
第二步:识别主瓶颈
问自己三个问题:
- 是 Prefill 慢,还是 Decode 慢?
- 是算力瓶颈,还是显存/带宽瓶颈?
- 是平均性能差,还是尾延迟差?
第三步:优先做高收益低风险优化
优先级一般建议:
- 连续批处理
- 长度分桶
- KV Cache 管理优化
- Prefix Cache
- 量化
第四步:做容量治理
- 加 token budget admission control
- 加最大上下文限制
- 为长请求单独分流
- 预留显存余量
第五步:压测验证
压测时至少覆盖:
- 短输入短输出
- 短输入长输出
- 长输入短输出
- 长输入长输出
- 高峰并发混合流量
别只测“平均场景”,线上真正出问题的往往是混合流量。
一个推荐的落地架构
如果你问我中等规模团队最值得采用的路线,我会推荐这套相对平衡的方案:
flowchart TD
A[API Gateway] --> B[Token预算限流]
B --> C[长度分桶队列]
C --> D[连续批处理调度器]
D --> E[Prefill执行层]
D --> F[Decode执行层]
E --> G[Paged KV Cache]
F --> G
G --> H[量化模型引擎]
H --> I[监控与告警]
J[Prefix Cache] --> E
它的优点是:
- 复杂度不算夸张
- 吞吐、时延、显存三者比较均衡
- 可以逐步演进到更复杂的多实例池/分离部署架构
总结
大模型推理优化,真正有效的方法不是某一个“银弹开关”,而是把系统拆开看:
- KV Cache 决定了 Decode 是否高效,但也决定显存水位
- 量化 可以显著降本,但收益取决于硬件、内核和任务类型
- 批处理调度 往往是吞吐提升最直接的手段,也是最容易影响尾延迟的一层
如果只给一个落地建议,我会说:
先建立分阶段观测,再做连续批处理和 KV Cache 治理,最后再推进量化。
因为在大多数线上系统里:
- 调度优化更容易立竿见影
- KV Cache 管理直接决定能否承载长上下文与高并发
- 量化则更适合在质量验证后,作为降本增效的进一步动作
最后给一个务实结论:
- 如果你是刚上线:先把指标、限流、长度分桶做好
- 如果你是吞吐不足:优先上连续批处理
- 如果你是显存吃紧:先算清 KV Cache,再考虑量化
- 如果你是尾延迟差:优先隔离长请求,别一味加大 batch
把这几件事做好,推理服务通常就能从“能跑”走到“能稳定赚钱地跑”。