大模型推理性能优化实战:从量化、KV Cache 到并发调度的系统化落地指南
大模型上线之后,很多团队会很快遇到一个现实问题:模型能跑,不代表能稳定、便宜、低延迟地跑。
我自己做这类系统时,最常见的场景不是“模型精度不够”,而是下面这些更工程化的问题:
- 同一张卡,吞吐上不去
- 首 Token 很慢,用户感觉“卡住了”
- 长上下文一来,显存直接爆
- 并发一高,延迟抖动严重
- 量化后虽然便宜了,但效果掉得没法上线
这篇文章不讲“某一个神奇参数”,而是从系统视角把大模型推理优化串起来:从量化、KV Cache 到并发调度,给出一套更适合生产环境落地的方法论。读完你应该能回答三个关键问题:
- 为什么推理慢,到底慢在什么阶段?
- 不同优化手段分别优化了哪一段瓶颈?
- 如何把这些手段组合起来,而不是互相打架?
背景与问题
大模型推理通常有两个完全不同的阶段:
- Prefill(预填充):把整段 Prompt 一次性喂给模型,计算出首轮隐藏状态与 KV Cache
- Decode(逐 Token 生成):每次只生成一个 Token,并复用历史 KV Cache
这两阶段的性能特征完全不同:
- Prefill:更偏计算密集,关注矩阵乘效率
- Decode:更偏显存带宽和访存,关注 KV Cache 的读写与调度
很多优化失败,就是因为把这两个阶段混为一谈。比如:
- 你以为是算力不够,实际是 KV Cache 占满显存
- 你以为量化能解决一切,实际 decode 受制于缓存访存
- 你以为加 batch 就能提吞吐,实际把首 Token 延迟拖爆了
一个典型症状表
| 症状 | 常见原因 | 优先排查方向 |
|---|---|---|
| 首 Token 延迟高 | Prefill 太重、动态 batch 不合理 | Prompt 长度、prefill batching |
| 长对话显存爆炸 | KV Cache 持续累积 | cache 分页、回收策略、最大上下文 |
| QPS 上不去 | 调度粒度粗、decode 阶段串行 | continuous batching、请求分桶 |
| 单卡成本高 | 模型权重太大、精度过高 | 4/8bit 量化、张量并行策略 |
| 延迟抖动大 | 长短请求混跑、调度不公平 | 队列拆分、SLO 分级调度 |
从系统视角看整体链路
先把系统结构画清楚,后面的优化才有落点。
flowchart LR
A[客户端请求] --> B[API 网关]
B --> C[请求队列与限流]
C --> D[Tokenizer]
D --> E[Prefill 阶段]
E --> F[KV Cache 分配]
F --> G[Decode 调度器]
G --> H[模型执行器 GPU]
H --> I[采样器]
I --> J[流式返回]
G --> K[Cache 回收与统计]
这个链路里,性能问题通常落在三层:
- 模型层:量化、算子融合、attention 实现
- 缓存层:KV Cache 布局、分页、复用、回收
- 服务层:并发调度、动态 batch、优先级、超时与限流
如果你只盯模型层,往往会发现“bench 数据很漂亮,线上却没改善多少”。
核心原理
1. 量化:先解决“权重太重”的问题
量化的本质,是把模型权重从高精度表示压缩到低精度表示,比如:
- FP16 / BF16
- INT8
- INT4
量化带来的收益
- 降低模型权重显存占用
- 提升加载速度
- 在部分硬件上提升推理吞吐
量化带来的代价
- 精度可能下降
- 某些算子需要反量化,收益不一定线性
- 不同硬件对低比特支持差异很大
一个简单估算
假设模型参数量为 P,权重精度为 b bit,那么仅权重显存近似为:
显存 ≈ P × b / 8
比如一个 7B 模型:
- FP16:约
7e9 × 2 byte ≈ 14 GB - INT8:约
7 GB - INT4:约
3.5 GB
但注意,这只是权重。真实推理还要加上:
- KV Cache
- 中间激活
- CUDA kernel workspace
- 框架管理开销
所以线上“明明 7B INT4 只有 3.5GB,为什么 24GB 卡还不够”,这并不奇怪。
量化适用边界
- 短上下文、低并发:量化收益明显,尤其是单卡部署
- 长上下文、高并发:KV Cache 可能变成主瓶颈,单靠量化不够
2. KV Cache:真正决定长上下文成本的关键
Transformer 在生成第 t 个 Token 时,不需要重新计算前面所有 Token 的 K/V,只要把历史 K/V 缓存起来复用即可,这就是 KV Cache。
为什么 KV Cache 这么重要?
没有 KV Cache 时,每生成一个 Token 都要重算历史上下文,复杂度接近灾难级增长。
有了 KV Cache 后,decode 阶段变成“追加式”计算,速度大幅提升。
但问题是:KV Cache 很占显存。
KV Cache 的粗略估算
设:
- 层数为
L - 隐藏维度为
H - 序列长度为
S - 数据类型字节数为
D - batch 大小为
B
则 KV Cache 量级近似为:
KV Cache ≈ B × S × L × H × 2 × D
其中乘以 2 是因为要存 K 和 V。
这意味着:
- 模型越长上下文,cache 增长越快
- 并发越高,cache 呈线性膨胀
- decode 阶段很多时候不是算不动,而是“搬不动”
Paged KV Cache 的思路
传统连续内存分配容易产生碎片,也不利于灵活回收。工程上更常见的是分页式 KV Cache:
- 把 KV Cache 切成固定大小 block
- 请求只持有 block 列表
- 回收时按 block 粒度释放
- 更容易做共享、扩容和重排
flowchart TD
A[请求 A 上下文] --> A1[Block 1]
A --> A2[Block 2]
A --> A3[Block 3]
B[请求 B 上下文] --> B1[Block 4]
B --> B2[Block 5]
C[空闲块池] --> A3
C --> B2
KV Cache 的几个工程关键点
-
Block 大小不是越小越好
太小会增加索引管理成本,太大会浪费尾部空间 -
长短请求混用会造成资源争抢
长请求长期占住大量 block,短请求容易排队 -
Cache 回收要有策略
请求结束即回收只是基础,还要考虑:- 超时中断
- 客户端断连
- 流式输出异常
- 多轮会话保活上限
3. 并发调度:吞吐和延迟的平衡术
大模型服务不是简单 Web 服务。它的难点在于:每个请求不是一次性执行完,而是跨多个 decode step 持续占用资源。
常见调度方式
静态批处理
- 固定时间窗口收集请求
- 拼成 batch 一起跑
优点:
- 实现简单
- 硬件利用率高
缺点:
- 用户首 Token 延迟容易变差
- 长短请求容易互相拖累
Continuous Batching(连续批处理)
- 每个 decode step 结束后,允许新请求动态加入
- 已完成请求立刻移出 batch
这是目前更适合在线推理的方式,因为它兼顾了吞吐和实时性。
sequenceDiagram
participant U1 as 请求1
participant U2 as 请求2
participant S as 调度器
participant G as GPU执行器
U1->>S: 到达
S->>G: prefill(U1)
G-->>S: ready
U2->>S: 到达
S->>G: decode step(U1) + prefill(U2)
G-->>S: token1(U1), ready(U2)
S->>G: decode step(U1,U2)
G-->>S: token2(U1), token1(U2)
调度优化的关键策略
1)按阶段拆分队列
不要把 prefill 和 decode 混成一个统一队列。
更好的做法是:
- prefill 队列:按 prompt 长度分桶
- decode 队列:按活跃请求数调度
这样可以减少“超长 prompt 把所有请求拖慢”的问题。
2)按长度分桶
把请求按输入长度分成几个 bucket,例如:
0~512513~20482049~8192
这样一个 batch 内的 shape 更接近,padding 浪费更少。
3)SLO 分级
如果线上同时有两类流量:
- 聊天交互:要求低延迟
- 离线生成:要求高吞吐
那就不要用一套调度策略。
可以做:
- 高优先级:小 batch,限制最大等待时间
- 低优先级:更大 batch,追求吞吐
方案对比与取舍分析
下面把最常见的几种优化手段放在一起看。
| 手段 | 优化对象 | 收益 | 代价 | 适用场景 |
|---|---|---|---|---|
| 权重量化 | 权重显存、部分算力 | 降低成本、提升单卡可部署性 | 精度波动、兼容性问题 | 单卡部署、成本敏感 |
| KV Cache | decode 重复计算 | 显著降低生成成本 | 显存占用高 | 所有生成场景 |
| Paged KV Cache | cache 管理 | 降低碎片、提升并发稳定性 | 实现复杂 | 长上下文、高并发 |
| Continuous batching | GPU 利用率 | 提升吞吐、降低空转 | 调度复杂 | 在线服务 |
| 长度分桶 | padding 浪费 | 提升 batch 有效利用率 | 队列管理更复杂 | 多样长度请求 |
| 优先级调度 | 延迟保障 | 降低关键流量抖动 | 公平性更难处理 | 混合业务场景 |
一个实际的决策顺序
如果你是从零开始搭建推理服务,我建议按这个顺序推进:
- 先打通 基础推理链路
- 上 KV Cache
- 做 长度分桶 + continuous batching
- 再评估 量化
- 最后再做 分页缓存、优先级调度、细粒度回收
原因很简单:
量化虽然显眼,但很多线上瓶颈其实先出在缓存管理和调度策略。
容量估算:别等显存炸了才算账
上线前最好做一个简化容量模型。
显存预算公式
总显存 ≈ 权重显存 + KV Cache 显存 + 临时激活 + 系统保留
可以粗略预留:
- 权重:40% ~ 60%
- KV Cache:30% ~ 50%
- 其余:10% ~ 20%
简化示例
假设:
- GPU 显存:24 GB
- 7B 模型 INT4 权重:约 4~5 GB(考虑额外开销)
- 框架与 workspace:约 3 GB
- 剩余可分配给 KV Cache:约 16 GB
如果单请求平均上下文 + 输出总长度较大,那么高并发会很快顶满这 16 GB。
此时继续压权重帮助有限,更有效的是:
- 限制最大上下文长度
- 控制活跃 decode 请求数
- 对长会话做 cache 回收或截断
- 使用分页式 KV Cache
实战代码(可运行)
下面用 Python 写一个可运行的推理调度模拟器,演示三个核心点:
- 请求进入 prefill / decode 两阶段
- KV Cache 按 token 数增长
- 调度器做简单的连续批处理
它不是完整模型实现,但能帮助你从系统层理解资源变化。
from dataclasses import dataclass, field
from typing import List, Deque
from collections import deque
import random
import time
@dataclass
class Request:
req_id: int
prompt_len: int
gen_len: int
generated: int = 0
stage: str = "prefill" # prefill or decode
kv_tokens: int = 0
done: bool = False
def prefill_cost(self):
return self.prompt_len
def decode_cost(self):
return 1
def total_tokens(self):
return self.prompt_len + self.generated
@dataclass
class Scheduler:
max_batch_size: int = 4
max_kv_tokens: int = 12000
prefill_queue: Deque[Request] = field(default_factory=deque)
decode_queue: Deque[Request] = field(default_factory=deque)
active: List[Request] = field(default_factory=list)
current_kv_tokens: int = 0
tick_id: int = 0
def submit(self, req: Request):
self.prefill_queue.append(req)
def _can_allocate_kv(self, tokens: int) -> bool:
return self.current_kv_tokens + tokens <= self.max_kv_tokens
def step(self):
self.tick_id += 1
print(f"\n===== tick {self.tick_id} =====")
# 1. 先处理 decode,模拟在线服务优先保障流式输出
next_active = []
decode_batch = []
while self.decode_queue and len(decode_batch) < self.max_batch_size:
req = self.decode_queue.popleft()
if req.done:
continue
decode_batch.append(req)
if decode_batch:
print(f"[decode] batch={ [r.req_id for r in decode_batch] }")
for req in decode_batch:
if req.generated < req.gen_len:
req.generated += 1
req.kv_tokens += 1
self.current_kv_tokens += 1
print(f" req={req.req_id} generated={req.generated}/{req.gen_len}, kv={req.kv_tokens}")
if req.generated >= req.gen_len:
req.done = True
# 回收全部 KV
self.current_kv_tokens -= req.kv_tokens
print(f" req={req.req_id} done, release kv={req.kv_tokens}")
req.kv_tokens = 0
else:
next_active.append(req)
# 2. 再尝试接入 prefill
prefill_batch = []
while self.prefill_queue and len(prefill_batch) < self.max_batch_size:
req = self.prefill_queue[0]
needed = req.prompt_len
if not self._can_allocate_kv(needed):
print(f"[prefill] req={req.req_id} blocked, need_kv={needed}, current={self.current_kv_tokens}")
break
self.prefill_queue.popleft()
prefill_batch.append(req)
if prefill_batch:
print(f"[prefill] batch={ [r.req_id for r in prefill_batch] }")
for req in prefill_batch:
req.kv_tokens = req.prompt_len
self.current_kv_tokens += req.prompt_len
req.stage = "decode"
next_active.append(req)
print(f" req={req.req_id} prefill_done, allocate kv={req.kv_tokens}")
# 3. 活跃请求重新进入 decode 队列
for req in next_active:
self.decode_queue.append(req)
print(f"[stats] current_kv_tokens={self.current_kv_tokens}, "
f"prefill_q={len(self.prefill_queue)}, decode_q={len(self.decode_queue)}")
def build_requests(n=8):
requests = []
for i in range(1, n + 1):
prompt_len = random.choice([64, 128, 256, 512, 1024])
gen_len = random.choice([16, 32, 64])
requests.append(Request(req_id=i, prompt_len=prompt_len, gen_len=gen_len))
return requests
def main():
random.seed(42)
scheduler = Scheduler(max_batch_size=3, max_kv_tokens=4000)
requests = build_requests(10)
for req in requests:
scheduler.submit(req)
print(f"submit req={req.req_id}, prompt={req.prompt_len}, gen={req.gen_len}")
while scheduler.prefill_queue or scheduler.decode_queue:
scheduler.step()
time.sleep(0.1)
print("\nall requests done")
if __name__ == "__main__":
main()
这段代码能看到什么?
运行后你会观察到:
- prompt 长的请求会在 prefill 阶段一次性吃掉较多 KV 预算
- decode 会不断追加 KV token
- 当 KV 容量不够时,新请求会被阻塞
- 请求结束后回收 KV,后续请求才有机会进入
这就是很多线上服务“QPS 突然掉下去”的本质:
不是模型挂了,而是KV Cache 预算被长请求占住了。
一个更贴近生产的调度建议
如果你准备把上面的模拟思想落地成服务,可以用如下状态机来设计请求生命周期。
stateDiagram-v2
[*] --> Waiting
Waiting --> Prefill: 被调度
Prefill --> Decoding: KV 分配成功
Prefill --> Waiting: 资源不足重排队
Decoding --> Streaming: 产生 token
Streaming --> Decoding: 继续生成
Decoding --> Finished: 达到停止条件
Decoding --> Aborted: 超时/断连/取消
Finished --> [*]
Aborted --> [*]
这个状态机的价值在于:
它把“模型推理”变成“资源受控的任务调度问题”,更容易扩展监控、超时、抢占和回收逻辑。
常见坑与排查
这部分我尽量写得接地气一些,都是线上高频问题。
坑 1:量化后吞吐没涨,甚至变慢
现象
- 权重显存下降了
- 但 token/s 没明显提升
- 某些输入下延迟还更高
原因
- 硬件对低比特算子支持一般
- 量化算子存在反量化开销
- 真正瓶颈在 decode 的 KV 访存,不在权重计算
排查方法
- 分别测 prefill 和 decode 吞吐
- 记录显存占用与带宽利用率
- 对比 FP16 / INT8 / INT4 在不同上下文长度下的表现
建议
- 不要只看单次请求 benchmark
- 要看在线混合流量下的 p95 / p99 延迟
- 对长上下文场景,优先看 KV 和调度,而不是盲目继续降 bit
坑 2:KV Cache 明明做了,为什么还是 OOM
现象
- 单请求没问题
- 一并发就 OOM
- 或者跑一段时间后碎片越来越严重
原因
- 只算了模型权重,没算 KV 增长
- cache 没及时回收
- 长会话持续保留上下文
- 连续内存分配导致碎片化
排查方法
- 统计每个请求的:
- prompt 长度
- 已生成 token 数
- KV 占用
- 统计活跃请求总 KV
- 观察是否存在异常长尾会话长期不释放
建议
- 给会话设置最大上下文和最大存活时间
- 引入 block 化缓存
- 断连后强制回收资源
坑 3:并发一高,首 Token 延迟突然恶化
现象
- 平均延迟还能看
- 但 p95 / p99 首 Token 延迟明显上升
原因
- prefill 请求堆积
- 长 prompt 混入实时交互队列
- batch 为了吞吐攒太久
排查方法
- 单独监控 TTFT(Time To First Token)
- 区分 prefill 等待时间和执行时间
- 按 prompt 长度分段统计延迟
建议
- 设置最大组批等待时间
- 长 prompt 请求单独分桶
- 给实时对话流量更高优先级
坑 4:吞吐高了,但用户体验反而差
这个坑很常见。你把 batch 做大,GPU 利用率很漂亮,但用户觉得“怎么每次都要等一下才开始出字”。
原因
- 吞吐优化压缩的是整体空转,不一定改善单请求感知
- 用户更敏感的是:
- 首 Token 时间
- 输出是否稳定连续
建议
生产上至少同时看三类指标:
- TTFT:首 Token 延迟
- TPOT(Time Per Output Token):每输出 token 的平均时间
- QPS / token/s:总体吞吐
只看其中一个,很容易把系统调偏。
安全/性能最佳实践
这里把我更推荐的落地原则归纳一下。
1. 先定 SLO,再选优化策略
不要一上来就说“我要最大吞吐”。
先定义清楚业务目标:
- 聊天机器人:优先 TTFT
- 批量生成:优先 token/s
- 混合业务:做流量分级
不同目标,对 batch 大小、等待时间、优先级策略都不同。
2. 给 KV Cache 设置硬上限
这是稳定性的底线。建议至少做三层限制:
- 单请求最大上下文长度
- 单实例最大活跃请求数
- 全局最大 KV tokens 预算
一旦超限,可以:
- 拒绝新请求
- 降级到短上下文
- 将低优先级请求排队
不要等显存被动 OOM。
3. 监控拆到阶段级别
至少监控这些指标:
prefill_latency_ms
decode_latency_ms
ttft_ms
tpot_ms
active_requests
kv_cache_used_tokens
kv_cache_block_utilization
request_abort_count
oom_count
batch_size_prefill
batch_size_decode
如果监控只停留在“平均响应时间”,排查几乎无从下手。
4. 对异常会话要可中断、可回收
生产环境里一定会有这些情况:
- 客户端断开连接
- 请求超时
- 用户主动取消
- 下游 backpressure 导致阻塞
如果不能中断并立即回收 KV Cache,资源泄漏会非常快。
5. 量化要做任务级回归,不要只看通用 benchmark
尤其是:
- RAG 场景
- 代码生成
- 多轮对话
- 工具调用
这些场景对数值扰动更敏感。
我一般会建议:
- 建一组自己的线上回放样本
- 分别评估首 Token、完整输出质量、拒答率、格式稳定性
- 量化收益达不到预期时,优先考虑更合适的 bit 配置或混合精度,而不是强推到底
6. 长短请求分池,通常比“一个大池子”更稳
如果资源允许,建议至少分成两类实例池:
- 低延迟池:服务短 prompt、交互型请求
- 高吞吐池:服务长文本或批量任务
这样做的好处是:
- 调度策略更简单
- 延迟更可控
- 故障隔离更容易
它的代价是资源利用率可能略低,但线上稳定性通常更值钱。
总结
大模型推理优化,真正有效的方式不是“迷信某个单点技巧”,而是把它当成一个模型、缓存、调度三层协同的系统工程。
可以把本文的核心结论浓缩成下面几句:
- 量化主要解决的是权重显存和部分算力问题
- KV Cache决定了长上下文和高并发下的真实成本
- 并发调度决定了线上吞吐、首 Token 延迟和抖动表现
- 生产优化必须分清 prefill 与 decode 两阶段
- 真正落地时,往往是缓存管理和调度比单纯“降 bit”更关键
如果你准备开始做一次系统化优化,我建议按下面顺序执行:
- 先拆分并监控 prefill / decode 指标
- 建立显存与 KV Cache 容量预算
- 上 continuous batching 和长度分桶
- 用业务样本验证量化收益
- 最后再做分页缓存、优先级调度与流量分池
这样推进,通常比“先把模型量化到极致”更稳,也更容易在线上拿到可验证的收益。
归根结底,大模型推理性能优化不是拼参数,而是拼你是否真正理解:资源花在哪,瓶颈卡在哪,系统怎么在边界条件下仍然稳定运行。这件事一旦想清楚,优化路径就会清晰很多。