背景与问题
做大模型推理服务时,很多团队一开始都盯着“模型参数量”,但真正把服务跑起来后,瓶颈往往不是单一的“模型太大”,而是以下几件事叠加:
- 显存不够:模型权重、KV Cache、激活值争抢 GPU 内存
- 首 Token 慢:Prefill 阶段计算量大,长上下文尤其明显
- 吞吐上不去:单卡利用率不高,批处理策略粗糙
- 并发一高就抖:请求长度差异大,导致尾延迟飙升
- 成本失控:同样的 QPS,不同部署方案成本能差出几倍
我自己在做推理服务压测时,最常见的误区是:
只做模型量化,不看 KV Cache;只看单请求速度,不看混合流量下的稳定性。
对于中级读者来说,可以把推理服务理解为一个三层问题:
- 模型层:怎么让模型“装得下、算得快”
- 运行时层:怎么让 GPU“别闲着”
- 服务层:怎么让线上流量“稳得住”
这篇文章就按这三个层次展开,重点讲清楚:
- 模型量化到底解决什么问题
- KV Cache 为什么是长上下文时代的核心资源
- 高并发部署为什么不能只靠“多加几张卡”
- 实际工程里如何做参数取舍、容量估算与排障
方案全景:推理服务的性能瓶颈在哪里
先看整体链路。一个用户请求进入系统后,通常会经历路由、排队、分词、Prefill、Decode、流式返回等环节。
flowchart LR
A[客户端请求] --> B[API 网关]
B --> C[调度器/队列]
C --> D[Tokenizer]
D --> E[Prefill 阶段]
E --> F[KV Cache 写入]
F --> G[Decode 阶段]
G --> H[流式返回]
G --> I[日志/指标/Tracing]
这里最值得关注的是两个阶段:
- Prefill:一次性处理全部输入 token,计算密集
- Decode:逐 token 生成,访存敏感,KV Cache 占比高
如果一句话概括性能调优方向,就是:
通过量化降低权重成本,通过 KV Cache 优化降低上下文成本,通过批处理和调度提升设备利用率。
核心原理
1. 模型量化:先解决“装不下”和“带宽不够”
1.1 什么是量化
量化是把原本高精度的参数表示,例如 FP16 / BF16,压缩为更低位宽的格式,例如:
- INT8
- GPTQ/AWQ 的 4bit
- FP8(部分硬件支持)
- 权重低比特 + 激活高精度的混合方案
量化的主要收益:
- 降低模型权重占用
- 减少显存带宽压力
- 提升单卡可部署模型规模
- 在部分场景下提高吞吐
但它不是免费午餐,代价可能包括:
- 精度下降
- 首次加载复杂度更高
- 某些算子/硬件支持不完整
- 实际吞吐未必线性提升
1.2 权重量化与运行时表现的关系
很多人把量化理解成“模型体积减半,速度翻倍”,这通常不成立。
因为推理延迟不只取决于权重大小,还受这些因素影响:
- Kernel 实现质量
- 访存模式
- Batch 大小
- 上下文长度
- 是否启用 paged attention / continuous batching
简单理解:
- 短上下文、小 batch:算子启动和调度开销明显,量化收益不一定大
- 长上下文、大 batch:显存带宽与容量成为核心,量化收益更明显
1.3 一个经验取舍表
| 方案 | 显存占用 | 精度风险 | 部署复杂度 | 推荐场景 |
|---|---|---|---|---|
| BF16/FP16 | 高 | 低 | 低 | 精度优先、资源充足 |
| INT8 | 中 | 低~中 | 中 | 通用线上服务 |
| 4bit GPTQ/AWQ | 低 | 中 | 中~高 | 成本敏感、大模型部署 |
| FP8 | 中~低 | 中 | 高 | 新硬件、特定框架优化 |
如果你是线上服务起步阶段,我通常建议:
- 先用 BF16 跑通基线
- 再对比 INT8 / 4bit
- 不要一开始就追求最低 bit,先看 P50/P95 延迟和质量是否可接受
2. KV Cache:真正决定长上下文成本的关键
2.1 KV Cache 是什么
在 Transformer 解码时,每生成一个新 token,都会用到前面 token 的 Key 和 Value。
如果每次都重新计算整段上下文,成本会爆炸,所以运行时会把历史 token 的 K/V 存下来,这就是 KV Cache。
它带来的好处是:
- 避免重复计算
- 提高逐 token 生成效率
但代价是:
- 上下文越长,KV Cache 越大
- 并发越高,累积占用越大
- 可能成为比模型权重更难管理的显存资源
2.2 KV Cache 的容量估算
一个常见的估算公式:
KV Cache 大小 ≈ 2 × 层数 × hidden_size × token数 × bytes_per_element × batch
其中:
2表示 K 和 Vbytes_per_element对于 FP16 约是 2 字节
实际实现会和 head 数、head_dim、分页策略有关,但做容量预估够用了。
举个感性的例子:
- 模型层数多
- 上下文 8k/16k
- 并发几十到几百
- 哪怕权重已量化,KV Cache 也可能吃掉大部分显存
2.3 Paged KV Cache 为什么重要
传统 KV Cache 容易出现两类问题:
- 内存碎片
- 请求长度不一致导致浪费
Paged KV Cache 的思路是把 KV 按块分页管理,而不是给每个请求预留一整段连续空间。这样做的好处:
- 减少内存碎片
- 支持更灵活的动态批处理
- 请求结束后更容易回收页
- 更适合高并发混合长度流量
flowchart TB
A[请求1 2k tokens] --> P1[KV Pages]
B[请求2 512 tokens] --> P2[KV Pages]
C[请求3 8k tokens] --> P3[KV Pages]
subgraph GPU显存分页池
X1[Page 1]
X2[Page 2]
X3[Page 3]
X4[Page 4]
X5[Page 5]
X6[Page 6]
end
P1 --> X1
P1 --> X2
P2 --> X3
P3 --> X4
P3 --> X5
P3 --> X6
2.4 Prefix Cache 与复用
如果你的业务有大量相似前缀,比如:
- 固定系统提示词
- 相同检索模板
- 相同工具调用描述
那就可以做 Prefix Cache。
也就是把公共前缀的 prefill 结果复用掉,减少重复计算。
这对以下场景很有价值:
- RAG 问答
- Agent 系统 prompt 很长
- 企业内统一提示模板
不过 Prefix Cache 不是无脑开:
- 前缀要足够稳定
- Tokenization 必须一致
- 多租户环境要注意缓存隔离
3. 高并发部署:核心不只是“多副本”,而是“调度”
3.1 连续批处理(Continuous Batching)
传统批处理有个问题:必须等一批请求凑齐再一起跑。
而 LLM 在线服务里,请求长度差异非常大,这会导致:
- 短请求被长请求拖慢
- GPU 空闲时间增加
- 尾延迟恶化
连续批处理的思路是:
- 每一步 decode 都允许新请求插入
- 已完成请求及时移出
- 运行时不断重组 batch
这样可以显著提升吞吐和资源利用率。
3.2 调度策略决定尾延迟
常见策略包括:
- FIFO:简单,但容易被长请求阻塞
- Shortest-Job-First 近似:短请求更友好
- Token Budget 调度:按 token 总量控制每轮负载
- 租户隔离队列:避免一个租户把全局队列打爆
我踩过一个坑:
只看平均 QPS,结果某类超长上下文请求一进来,P99 直接翻倍。最后发现不是 GPU 算不动,而是调度没做长度分层。
3.3 多卡与多实例的取舍
部署时经常会问:
- 单实例多卡好?
- 还是多实例单卡好?
答案取决于模型大小和流量形态。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单卡单实例 | 简单、隔离好 | 容量有限 | 中小模型 |
| 多实例单卡 | 横向扩展灵活 | 热点卡不均衡 | 高并发在线服务 |
| 单实例多卡 TP | 可部署更大模型 | 通信开销高 | 超大模型 |
| PP/TP 组合 | 支持超大规模 | 架构复杂 | 离线/大规模专用集群 |
一个朴素但实用的建议:
能单卡跑通,就优先单卡;必须多卡,再考虑张量并行。
因为一旦进入多卡通信,问题会明显变多:
- NCCL 初始化问题
- 跨卡同步抖动
- 某张卡慢导致整体慢
- 容器与驱动兼容性复杂
架构设计:一套可落地的推理服务方案
下面给一个比较稳妥的线上架构,适合中等规模业务起步。
flowchart LR
A[Client] --> B[API Gateway]
B --> C[Auth & Rate Limit]
C --> D[Request Router]
D --> E1[短上下文实例池]
D --> E2[长上下文实例池]
D --> E3[高优先级实例池]
E1 --> F[LLM Runtime]
E2 --> F
E3 --> F
F --> G[KV Cache Manager]
F --> H[Prefix Cache]
F --> I[Tokenizer Worker]
F --> J[Metrics/Tracing/Logs]
这个架构的关键点:
-
按请求类型分池
- 短上下文与长上下文分开
- 避免短请求被长请求拖死
-
运行时层做连续批处理
- 提升 GPU 利用率
- 控制 decode 阶段空转
-
KV Cache 独立治理
- 做页式管理
- 做上限、淘汰、复用策略
-
限流与租户隔离前置
- 不要等 GPU 打满了才想起限流
容量估算:上线前必须算清楚三件事
架构讨论不能只停留在“能跑”。部署前至少要估算:
- 单卡能承载多少并发
- 上下文长度会吃掉多少 KV Cache
- 峰值流量是否会击穿队列
1. 单卡显存预算
总显存大致分成:
- 模型权重
- KV Cache
- 中间激活/运行时开销
- 碎片与预留空间
经验上不要把 GPU 显存打到 100%,建议至少留 10%~20% 安全余量。
2. 吞吐估算方法
一个实用方法是分别测:
- Prefill tokens/s
- Decode tokens/s
因为这两段的瓶颈不同。
线上真实流量的总体吞吐,通常不是实验室里的“纯 decode 吞吐”。
3. 队列容量与 SLA
如果目标是:
- P95 首 token < 2s
- 峰值并发 300
- 平均输出 256 tokens
那就不能只看“GPU 跑满时的最大吞吐”,还要看:
- 最大排队时长
- 长请求比例
- 重试流量占比
- 流式连接数上限
实战代码(可运行)
下面用一个可运行的 Python 示例,模拟推理服务中的几个关键动作:
- 估算模型权重显存
- 估算 KV Cache 显存
- 基于 token budget 做简单调度
- 输出一个推荐并发范围
这个例子不是完整的 LLM 引擎,但非常适合做容量规划的小工具。
from dataclasses import dataclass
from typing import List
import math
@dataclass
class ModelConfig:
name: str
num_layers: int
hidden_size: int
num_params_billion: float
weight_dtype_bytes: float # bf16/fp16=2, int8=1, int4=0.5
kv_dtype_bytes: float # fp16=2, fp8=1(近似)
@dataclass
class RequestProfile:
input_tokens: int
output_tokens: int
concurrency: int
@dataclass
class GPUConfig:
total_memory_gb: float
reserve_ratio: float = 0.15 # 预留 15% 给碎片/运行时
def estimate_weight_memory_gb(model: ModelConfig) -> float:
total_bytes = model.num_params_billion * 1e9 * model.weight_dtype_bytes
return total_bytes / (1024 ** 3)
def estimate_kv_cache_gb(model: ModelConfig, req: RequestProfile) -> float:
total_tokens = req.input_tokens + req.output_tokens
bytes_per_request = (
2 # K and V
* model.num_layers
* model.hidden_size
* total_tokens
* model.kv_dtype_bytes
)
total_bytes = bytes_per_request * req.concurrency
return total_bytes / (1024 ** 3)
def estimate_available_memory_gb(gpu: GPUConfig) -> float:
return gpu.total_memory_gb * (1 - gpu.reserve_ratio)
def recommend_max_concurrency(
model: ModelConfig,
gpu: GPUConfig,
input_tokens: int,
output_tokens: int,
runtime_overhead_gb: float = 2.0,
) -> int:
available = estimate_available_memory_gb(gpu)
weight_gb = estimate_weight_memory_gb(model)
kv_per_req_bytes = (
2
* model.num_layers
* model.hidden_size
* (input_tokens + output_tokens)
* model.kv_dtype_bytes
)
kv_per_req_gb = kv_per_req_bytes / (1024 ** 3)
remain = available - weight_gb - runtime_overhead_gb
if remain <= 0 or kv_per_req_gb <= 0:
return 0
return max(0, math.floor(remain / kv_per_req_gb))
def schedule_by_token_budget(requests: List[RequestProfile], token_budget: int) -> List[RequestProfile]:
selected = []
used = 0
# 简化策略:优先短请求
sorted_reqs = sorted(requests, key=lambda r: (r.input_tokens + r.output_tokens))
for req in sorted_reqs:
req_tokens = req.input_tokens + req.output_tokens
if used + req_tokens <= token_budget:
selected.append(req)
used += req_tokens
return selected
if __name__ == "__main__":
model = ModelConfig(
name="Example-13B",
num_layers=40,
hidden_size=5120,
num_params_billion=13,
weight_dtype_bytes=1.0, # int8
kv_dtype_bytes=2.0 # fp16 kv cache
)
gpu = GPUConfig(total_memory_gb=80, reserve_ratio=0.15)
weight_gb = estimate_weight_memory_gb(model)
print(f"模型权重显存约: {weight_gb:.2f} GB")
profile = RequestProfile(input_tokens=4096, output_tokens=512, concurrency=16)
kv_gb = estimate_kv_cache_gb(model, profile)
print(f"KV Cache 显存约: {kv_gb:.2f} GB")
max_cc = recommend_max_concurrency(
model=model,
gpu=gpu,
input_tokens=4096,
output_tokens=512,
runtime_overhead_gb=4.0
)
print(f"推荐最大并发(粗略): {max_cc}")
reqs = [
RequestProfile(512, 128, 1),
RequestProfile(1024, 256, 1),
RequestProfile(4096, 512, 1),
RequestProfile(256, 64, 1),
RequestProfile(2048, 256, 1),
]
selected = schedule_by_token_budget(reqs, token_budget=3000)
print("本轮调度请求:")
for r in selected:
print(f" input={r.input_tokens}, output={r.output_tokens}, total={r.input_tokens + r.output_tokens}")
运行结果你应该怎么看
这个脚本能帮你先回答几个基础问题:
- 权重量化后是否真的腾出了足够空间
- 长上下文下,KV Cache 是否已经压过权重成本
- 某个 token budget 是否会让 batch 过大或过小
如果你在线上排容量,建议把这个思路扩展成:
- 不同模型规格一张表
- 不同上下文长度一张表
- 不同并发档位一张表
- 最后形成可观测的容量基线
推理服务中的关键时序
理解请求生命周期,对定位性能问题特别有帮助。
sequenceDiagram
participant U as User
participant G as Gateway
participant S as Scheduler
participant R as Runtime
participant K as KV Cache
U->>G: 发起生成请求
G->>S: 入队 + 限流检查
S->>R: 进入连续批处理
R->>R: Tokenize + Prefill
R->>K: 写入前缀 KV
loop 每步解码
S->>R: 重组 batch
R->>K: 读取历史 KV
R->>U: 流式返回 token
end
R->>K: 释放 KV 页
如果线上出现“首 token 慢,但单 token 速度还行”,往往是:
- Prefill 太重
- 前缀太长
- Tokenizer 成了 CPU 瓶颈
- 调度排队严重
如果是“前面挺快,后面越来越慢”,则往往考虑:
- KV Cache 压力增加
- batch 重组不合理
- 长请求拖累 decode
- 流式连接堆积
常见坑与排查
1. 量化后吞吐没提升,甚至更差
现象
- 模型显存变小了
- 但 tokens/s 没怎么涨
- 某些场景还变慢
常见原因
- 量化 kernel 不成熟
- batch 太小,算子启动开销占比高
- 真瓶颈在 KV Cache,不在权重
- CPU 侧预处理、网络、队列成了瓶颈
排查方法
- 分别压测 prefill 和 decode
- 对比 GPU 利用率、显存带宽、SM 占用
- 观察 batch size 实际分布
- 关闭/开启连续批处理做对照
2. 显存明明够,还是频繁 OOM
现象
- 理论计算没超
- 实际运行一高并发就 OOM
常见原因
- KV Cache 碎片化
- 运行时预留空间不足
- 多租户流量导致上下文长度超预期
- 某些请求带超长 system prompt
排查建议
- 打印每请求 token 长度分布
- 监控 page 分配失败率
- 增加显存水位告警
- 将长短请求分池
我当时踩过一个非常典型的坑:
压测集全是 1k 左右输入,线上却有大量 6k~8k 输入。结果不是模型不行,而是 容量模型错了。
3. P99 延迟很差,但平均值很好看
现象
- 平均延迟不错
- 但少数请求特别慢
- SLA 经常被尾部请求击穿
原因
- 长短请求混跑
- 调度策略太粗
- 某租户突发流量抢占资源
- Prefix Cache 命中不稳定
建议
- 监控分位数,不只看均值
- 给长请求单独池子
- 按 token 数而不是请求数限流
- 对高优先级流量单独保底
4. 首 Token 很慢
优先排查顺序
- Tokenizer 是否占满 CPU
- 是否存在超长前缀
- Prefill batch 是否过大
- Prefix Cache 是否命中
- 请求是否排队过久
经验建议
- Tokenizer 做并行化
- 固定 prompt 争取复用前缀
- 限制超长上下文直入主池
- 预热模型和常见模板
安全/性能最佳实践
这一节我尽量说得“能直接拿去用”。
1. 设置明确的资源护栏
至少要有这些限制:
- 单请求最大输入 token
- 单请求最大输出 token
- 单租户并发上限
- 全局排队长度上限
- 流式连接超时时间
如果不设护栏,系统早晚会被异常长请求拖垮。
2. 长短请求分池,不要混部
一个简单但极有效的策略:
短请求池:input < 2k中请求池:2k ~ 8k长请求池:> 8k
边界值要根据你自己的模型和 SLA 调整,但“分池”这件事本身非常值。
3. 监控不要只看 GPU 利用率
推理服务常见误区是只盯着 GPU utilization。
真正应该同时关注:
- 首 token 延迟
- 每 token 生成速度
- 队列等待时间
- 活跃 batch 大小
- KV Cache 占用率
- Prefix Cache 命中率
- OOM 次数与重试次数
- 不同输入长度区间的 P95/P99
4. 做灰度,不要一次性切量化方案
量化模型上线建议分三步:
- 离线质量评估
- 小流量灰度
- 对比线上核心指标后逐步扩容
重点关注:
- 幻觉率是否升高
- 结构化输出是否变差
- 工具调用参数是否更不稳定
- 数学/代码类任务是否退化
5. 多租户环境要做缓存隔离
Prefix Cache、KV 复用这类能力,一旦进入多租户环境,要特别注意:
- 不能跨租户误复用
- 缓存 key 要包含模型版本、模板版本、租户信息
- 敏感 prompt 不应长期保留
- 日志脱敏要前置
6. 预留退化模式
线上一定要有“止血按钮”:
- 降低最大输出长度
- 关闭长上下文入口
- 降低批处理上限
- 切回高精度模型
- 将低优先级租户限流
这类能力平时看着多余,真出故障时能救命。
方案对比与取舍分析
把前面的内容收敛一下,如果你要从 0 到 1 搭一个推理服务,我建议按这个优先级做:
路线 A:稳定优先
- BF16/FP16 基线
- 单卡单实例
- 基础限流 + 队列
- 简单批处理
- 完整监控
适合:PoC、内测、精度敏感业务
优点:简单、好排障
缺点:成本偏高
路线 B:成本与性能平衡
- INT8 或 4bit 权重量化
- Paged KV Cache
- 连续批处理
- 长短请求分池
- Prefix Cache
适合:大多数线上生产环境
优点:投入产出比高
缺点:工程复杂度上升
路线 C:极致吞吐
- 深度量化
- 多卡并行
- 精细 token-budget 调度
- 多级缓存与租户隔离
- 高度定制运行时
适合:超大规模平台
优点:吞吐高、成本低
缺点:维护门槛高,排障难度大
对于中级团队,我的建议通常是:
优先走路线 B,但保留随时回退到路线 A 的能力。
总结
大模型推理服务的优化,不是单点突破,而是一个组合拳:
- 模型量化解决权重体积与带宽问题
- KV Cache 优化解决长上下文与高并发下的显存压力
- 连续批处理与调度解决 GPU 利用率与尾延迟问题
- 分池、限流、隔离解决线上稳定性问题
如果你准备真正落地,我建议按这个顺序推进:
- 先建立 BF16 基线
- 测清 Prefill / Decode 两段性能
- 做 KV Cache 容量建模
- 上 量化方案 做 A/B 对比
- 上 连续批处理 + 长短请求分池
- 完善 监控、限流、退化开关
最后给一个很实在的边界条件:
- 如果你的业务上下文普遍很短、并发不高,别过度设计,先把服务做稳
- 如果你的业务有大量长上下文、多租户、高峰突发,KV Cache 和调度一定要优先治理
- 如果团队对运行时与 GPU 排障经验还不足,优先选择成熟框架,不要过早自研内核
推理服务优化这件事,真正有效的不是“某一个神奇参数”,而是你是否建立了可测量、可回归、可灰度的工程闭环。只要这个闭环在,性能问题基本都能一步步啃下来。