大模型推理服务实战:从模型量化、KV Cache 优化到高并发部署的性能调优指南
做大模型推理服务时,很多团队一开始都盯着“模型多大、显卡多贵、TPS 多高”,但真正上线后,问题往往出在更细的地方:显存不够、首 Token 延迟过高、并发一上来吞吐骤降、KV Cache 抖动、量化后回答质量不稳定。
这篇文章我想换一个更贴近落地的角度:把大模型推理服务看成一条完整的数据通路,从模型权重、注意力缓存、调度策略,到多卡部署和流量治理,逐层分析性能瓶颈和优化方法。目标不是“把每个点讲得最学术”,而是帮你搭出一套能跑、能测、能持续调优的推理架构。
背景与问题
大模型推理服务和传统 Web 服务最大的不同,是它的资源模型非常“偏态”:
- 显存是硬约束
- 延迟由 Prefill 和 Decode 两段组成
- 吞吐与并发并不是线性关系
- 同样的 QPS,不同请求长度对系统压力完全不同
如果把问题拆开看,线上常见痛点通常有这几类:
-
模型太大,单卡放不下
- 需要量化、张量并行、流水并行,或者更小的蒸馏模型。
-
TTFT(Time To First Token)太高
- 用户觉得“系统卡住了”,即使后续生成很快,体验仍然差。
-
Decode 阶段吞吐低
- GPU 利用率不高,但请求堆积越来越严重。
-
KV Cache 占满显存
- 尤其在长上下文、多轮对话、工具调用场景下非常明显。
-
高并发下尾延迟飙升
- 平均值看着不错,P95/P99 已经不可用了。
换句话说,大模型推理优化不是单点优化,而是多目标权衡:
- 模型质量
- 显存占用
- TTFT
- TPS
- 尾延迟
- 成本
这也是为什么很多团队“换了更强的卡”但效果仍然不理想,因为瓶颈往往不只在硬件。
先给结论:一条实用的优化路径
如果你是中级工程师,准备把服务从“能跑”优化到“可上线”,我建议按下面这个顺序推进:
-
先测基线
- 不量化、不并发优化,先测 TTFT、tokens/s、显存占用、P95 延迟
-
做模型量化
- 优先试 8bit,再试 4bit,验证质量损失边界
-
优化 KV Cache
- 使用 paged attention / prefix cache / chunked prefill
-
接入动态批处理
- 合并请求,提高 GPU 利用率
-
做并发治理
- 队列、限流、超时、优先级调度
-
最后才是多卡扩展
- 单机吃满后,再做张量并行或多实例横向扩容
这个顺序的好处是:每一步都可观测、可回滚、可比较收益。
核心原理
这一节我们重点讲三个核心对象:模型量化、KV Cache、并发调度。
1. 模型量化:先解决“放得下”和“跑得起”
大模型权重一般是 FP16/BF16 存储。以一个 7B 模型为例:
- FP16:约 14GB
- INT8:约 7GB
- INT4:约 3.5GB
这只是权重,还不包括:
- KV Cache
- 激活内存
- 框架开销
- CUDA 工作区
量化的本质
量化就是把高精度权重映射到低比特整数表示,再在推理时通过缩放因子恢复近似值。常见方案有:
- FP16/BF16
- 精度好,显存大
- INT8
- 精度损失较小,部署友好
- INT4 / GPTQ / AWQ
- 显存收益大,但对不同模型敏感
- KV Cache 量化
- 不是量化权重,而是量化注意力缓存
量化的收益和代价
| 方案 | 显存收益 | 速度收益 | 质量风险 | 适用场景 |
|---|---|---|---|---|
| FP16/BF16 | 低 | 基线 | 低 | 质量优先 |
| INT8 | 中 | 中 | 低 | 通用线上服务 |
| INT4 | 高 | 中到高 | 中 | 成本敏感场景 |
| KV Cache量化 | 中 | 视实现而定 | 中 | 长上下文高并发 |
我的经验是:业务模型先试 INT8,再看是否有必要下探到 4bit。因为很多时候瓶颈并不在权重,而在 KV Cache 和调度。
2. KV Cache:真正吃掉显存的“隐形大户”
生成式模型在 Decode 阶段,为了避免重复计算历史 token,会把每层注意力的 Key/Value 存下来,这就是 KV Cache。
它的特点是:
- 会随着上下文长度增长
- 会随着 batch 增长
- 会随着层数、头数、hidden size 增长
粗略看,KV Cache 占用可以近似理解为:
KV Cache ≈ batch_size × seq_len × num_layers × hidden_factor × dtype_size
这意味着两个事实:
- 长上下文用户非常贵
- 高并发下,显存经常不是被模型权重打满,而是被 KV Cache 吃光
KV Cache 优化的几种常见手段
Paged Attention
把 KV Cache 分页管理,而不是给每个请求预留一大块连续内存。这样可以减少内存碎片,提升请求复用效率。
Prefix Cache
如果很多请求共享相同前缀,例如:
- 系统提示词
- 固定工具描述
- 相同知识库模板
那么共享前缀部分的 KV Cache,可以显著降低 Prefill 成本。
Chunked Prefill
长 prompt 不一次性塞进去,而是分块预填充。这样能减少峰值显存和长请求对短请求的阻塞。
Sliding Window / Context Truncation
对超长会话保留最近窗口,或者摘要历史后再拼接,避免无上限增长。
3. 并发调度:吞吐提升的关键不只是“开更多副本”
很多团队会误以为高并发就是多开几个进程。实际上,大模型服务中的吞吐优化更依赖于调度策略。
Prefill 与 Decode 的差异
- Prefill
- 输入整段 prompt,计算密集
- GPU 更容易被打满
- Decode
- 每次只生成少量 token
- 更偏 memory-bound,效率受批处理影响很大
这导致一个很现实的问题:
如果把长 prompt 请求和短对话请求混在一起,系统很容易互相拖慢。
动态批处理
动态批处理会在极短时间窗口内收集多个请求,组成一个 batch 一起送入 GPU。收益是:
- 提高 GPU 利用率
- 增加 tokens/s
- 降低单位 token 成本
但代价也明显:
- 请求排队时间增加
- TTFT 可能变差
- 如果 batch 内长度差异太大,padding 浪费严重
所以动态批处理不该“越大越好”,而应根据业务指标找到平衡点。
推理服务整体架构设计
先看一张全链路图:
flowchart LR
A[Client / SDK] --> B[API Gateway]
B --> C[Request Queue]
C --> D[Scheduler]
D --> E[Prefill Worker]
D --> F[Decode Worker]
E --> G[KV Cache Manager]
F --> G
E --> H[Model Runtime]
F --> H
H --> I[GPU]
G --> I
H --> J[Streaming Response]
J --> A
这张图里最关键的不是“有多少层”,而是三个职责是否分清:
- Scheduler:决定请求何时进入 batch
- KV Cache Manager:决定缓存怎么分配、回收、复用
- Model Runtime:负责真正推理执行
如果这三块揉成一团,后面几乎没法系统调优。
方案对比与取舍分析
1. 单实例大模型 vs 多实例小模型
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单实例大模型 | 效果通常更好 | 成本高,扩展慢 | 高价值问答、复杂推理 |
| 多实例小模型 | 易扩展,吞吐高 | 能力上限较低 | 客服、摘要、分类改写 |
如果你的业务是高频、低客单价请求,往往更小的模型 + 更好的调度,比盲目追大模型更划算。
2. 只做权重量化 vs 同时做 KV Cache 优化
| 方案 | 立竿见影 | 实施复杂度 | 长上下文收益 |
|---|---|---|---|
| 只做权重量化 | 高 | 低 | 中 |
| 加上 KV Cache 优化 | 很高 | 中到高 | 高 |
实际线上里,长对话、多轮 Agent、RAG 这类场景,KV Cache 优化经常比权重量化更值钱。
容量估算:上线前别拍脑袋
一个简单实用的估算方式:
输入参数
- 模型:7B INT4
- 单卡显存:24GB
- 平均输入长度:1500 tokens
- 平均输出长度:300 tokens
- 并发目标:32
- 每请求最大上下文:4K
估算思路
-
权重占用
- 7B INT4 大约 4~5GB,加上运行时开销按 6GB 估
-
KV Cache 预留
- 对 32 并发、4K 上下文,往往远超 10GB,具体看实现和精度
-
留出安全余量
- 至少保留 10%~15% 显存,避免碎片和突发 OOM
一个经验判断
如果你发现:
- 模型权重只占显存的一小半
- 长请求一来就 OOM 或吞吐抖动
那大概率就不是模型太大,而是 KV Cache 策略和批处理策略不对。
KV Cache 生命周期示意
stateDiagram-v2
[*] --> Allocated: 请求进入
Allocated --> Prefill: 写入前缀KV
Prefill --> Decode: 增量生成
Decode --> Reused: 命中共享前缀
Decode --> Evicted: 超时/显存紧张
Reused --> Decode: 继续生成
Evicted --> [*]
这张图提醒我们一个常被忽视的点:
KV Cache 不是“存进去就完了”,而是需要像数据库缓存一样有生命周期管理。
实战代码(可运行)
下面用一个简化版的 Python 示例,模拟一个推理服务的核心调度逻辑:
- 请求进入队列
- 调度器按时间窗口收集 batch
- 模拟 Prefill / Decode 成本
- 提供基础指标输出
这个例子不会真的调用 GPU,但能帮助你理解动态批处理和队列延迟的关系。
import asyncio
import random
import time
from dataclasses import dataclass, field
from typing import List
@dataclass
class Request:
request_id: int
prompt_tokens: int
output_tokens: int
created_at: float = field(default_factory=time.time)
@dataclass
class Result:
request_id: int
queue_ms: float
ttft_ms: float
total_ms: float
class MockInferenceEngine:
def __init__(self, prefill_speed=8000, decode_speed=1200):
# tokens per second
self.prefill_speed = prefill_speed
self.decode_speed = decode_speed
async def run_batch(self, batch: List[Request]) -> List[Result]:
now = time.time()
# Prefill 阶段:按 batch 的总输入 token 近似模拟
total_prompt_tokens = sum(r.prompt_tokens for r in batch)
prefill_cost = total_prompt_tokens / self.prefill_speed
# Decode 阶段:按 batch 的最大输出长度分轮次模拟
max_output_tokens = max(r.output_tokens for r in batch)
decode_cost = max_output_tokens / self.decode_speed
await asyncio.sleep(prefill_cost + decode_cost)
results = []
finish = time.time()
for r in batch:
queue_ms = (now - r.created_at) * 1000
ttft_ms = (queue_ms / 1000 + prefill_cost) * 1000
total_ms = (finish - r.created_at) * 1000
results.append(Result(r.request_id, queue_ms, ttft_ms, total_ms))
return results
class DynamicBatchScheduler:
def __init__(self, engine: MockInferenceEngine, max_batch_size=8, batch_wait_ms=20):
self.engine = engine
self.max_batch_size = max_batch_size
self.batch_wait_ms = batch_wait_ms
self.queue = asyncio.Queue()
self.running = True
async def submit(self, req: Request):
await self.queue.put(req)
async def worker(self):
while self.running:
batch = []
try:
first = await asyncio.wait_for(self.queue.get(), timeout=1.0)
batch.append(first)
except asyncio.TimeoutError:
continue
start = time.time()
while len(batch) < self.max_batch_size:
remain = self.batch_wait_ms / 1000 - (time.time() - start)
if remain <= 0:
break
try:
item = await asyncio.wait_for(self.queue.get(), timeout=remain)
batch.append(item)
except asyncio.TimeoutError:
break
results = await self.engine.run_batch(batch)
for r in results:
print(
f"[done] req={r.request_id} "
f"queue={r.queue_ms:.1f}ms "
f"ttft={r.ttft_ms:.1f}ms "
f"total={r.total_ms:.1f}ms"
)
async def shutdown(self):
self.running = False
async def producer(scheduler: DynamicBatchScheduler, n=20):
for i in range(n):
req = Request(
request_id=i,
prompt_tokens=random.randint(200, 2000),
output_tokens=random.randint(50, 300),
)
await scheduler.submit(req)
await asyncio.sleep(random.uniform(0.005, 0.03))
async def main():
engine = MockInferenceEngine(prefill_speed=10000, decode_speed=1500)
scheduler = DynamicBatchScheduler(engine, max_batch_size=4, batch_wait_ms=15)
worker_task = asyncio.create_task(scheduler.worker())
await producer(scheduler, n=20)
await asyncio.sleep(5)
await scheduler.shutdown()
await asyncio.sleep(1)
worker_task.cancel()
if __name__ == "__main__":
asyncio.run(main())
如何运行
python mock_llm_scheduler.py
你可以怎么验证
建议改这几个参数观察输出变化:
max_batch_sizebatch_wait_msprefill_speeddecode_speed
你会很直观地看到:
- batch 变大,吞吐提高,但排队可能变长
- batch wait 太大,TTFT 明显变差
- prompt 越长,Prefill 成本越明显
- 输出越长,Decode 成本越明显
请求处理时序图
sequenceDiagram
participant U as User
participant G as Gateway
participant S as Scheduler
participant K as KV Cache
participant M as Model Runtime
participant GPU as GPU
U->>G: 发送请求
G->>S: 入队
S->>K: 检查前缀缓存
alt 命中前缀
K-->>S: 返回已存在KV
else 未命中
K-->>S: 分配KV页
end
S->>M: 组成动态批次
M->>GPU: Prefill
GPU-->>M: 首Token
M-->>G: 流式返回
loop Decode
M->>GPU: 增量生成
GPU-->>M: 新Token
M-->>G: 持续流式输出
end
M->>K: 回收或保留KV
G-->>U: 请求结束
实战中的关键指标
性能调优如果没有指标,基本等于盲飞。建议最少采集这些:
延迟指标
- TTFT
- TPOT(Time Per Output Token)
- End-to-End Latency
- P50 / P95 / P99
资源指标
- GPU Utilization
- GPU Memory Used
- KV Cache Hit Rate
- Queue Length
- Batch Size Distribution
业务指标
- 请求成功率
- 超时率
- 降级率
- 单请求平均成本
这里我特别建议把 TTFT 和 TPOT 分开看。
因为:
- TTFT 差,通常是 Prefill、排队、调度问题
- TPOT 差,通常是 Decode、KV Cache、batch 策略问题
这两者混在一个“平均延迟”里,很容易误判。
常见坑与排查
下面这些问题,我基本都见过,甚至踩过。
1. 量化后吞吐没变,质量还变差
现象
- 显存降了,但 tokens/s 提升不明显
- 某些任务回答准确率明显下降
排查思路
- 看瓶颈是不是权重加载,而不是 KV Cache
- 看量化 kernel 是否真的被启用
- 对比不同任务集,不要只看单一 benchmark
建议
- 先上 INT8
- 对复杂推理、代码生成、结构化抽取分别做质量回归
- 保留一条 FP16/BF16 基线做灰度对照
2. 并发一高就 OOM,但单请求完全正常
现象
- 单测没问题
- 线上高峰突然 OOM
- 显存曲线呈阶梯式上升
常见原因
- KV Cache 未及时回收
- 长请求扎堆
- batch 过大
- 前缀缓存策略没有上限
- CUDA 内存碎片
排查建议
- 打印每个请求的输入长度、输出长度、缓存占用
- 统计 active sequences 数量
- 监控 cache 分配失败次数
- 检查是否存在“流断了但请求没清理”的泄漏
3. GPU 利用率不高,但延迟就是高
这类问题最容易误导人
很多人看到 GPU Util 只有 40%,会以为“卡没跑满,说明还很闲”。
其实大模型推理里很可能是:
- CPU 侧 tokenizer 或调度成了瓶颈
- batch 太小
- 请求长度差异太大
- Decode 阶段本来就不是纯算力瓶颈
我通常怎么查
- 看请求是否大量排队在进入 GPU 前
- 看 batch size 分布是否长期偏小
- 看 prefill/decode 时间占比
- 看是否有 Python GIL、序列化、网络 flush 等外围问题
4. Prefix Cache 命中率低得离谱
典型原因
- prompt 模板里有动态时间戳、trace id
- system prompt 每次都不一致
- tokenizer 前处理不一致导致 token 序列不同
解决方法
- 固定可复用前缀
- 动态字段后置
- 统一 tokenizer 和 prompt 拼接规则
这个点特别值得做,因为前缀共享一旦命中,收益很直接。
安全/性能最佳实践
这一节我把“工程上最值得坚持的做法”集中列出来。
1. 做分级限流,不要让长请求拖垮全局
至少区分两类:
- 短请求:普通问答、摘要
- 长请求:超长上下文、复杂工具调用
如果混在同一队列,长请求会明显拉高尾延迟。更好的做法是:
- 独立队列
- 独立配额
- 独立超时
- 甚至独立实例池
2. 为 KV Cache 设置硬上限和回收策略
不要幻想“缓存越多越好”。缓存策略一定要明确:
- 最大缓存页数
- 单租户最大占用
- 空闲过期时间
- 内存紧张时的驱逐顺序
没有这些上限,系统迟早会在高峰时失控。
3. 流式输出要配合超时与取消
用户断开连接后,如果服务端还在继续生成,就会白白占着 GPU 和 KV Cache。
建议至少实现:
- 客户端断连检测
- 服务端取消生成
- 资源回收钩子
- 超时中断
这是性能优化,也是安全边界控制。
4. 灰度发布时同时看“质量”和“性能”
推理优化不是单一维度。每次变更都建议同时比较:
- 吞吐是否上升
- TTFT 是否变差
- P99 是否恶化
- 业务任务准确率是否下降
尤其是量化、KV Cache 压缩、激进 batching 这三类优化,非常容易“看起来更快,但业务效果更差”。
5. 保护多租户隔离
如果服务面向多个业务方,要避免一个租户的超长请求占满全局资源。
建议做:
- 租户级并发配额
- 租户级 token 预算
- 请求大小上限
- 租户优先级策略
否则某个租户突然打满长上下文请求,整个平台都会抖。
一套可落地的调优流程
如果你现在正准备优化一个已有的推理服务,我建议按这个 checklist 来:
第一步:建立基线
记录以下指标:
- 单请求显存占用
- TTFT / TPOT / 总延迟
- P50 / P95 / P99
- batch size 分布
- GPU 利用率
第二步:做最小改动实验
每次只改一个变量,比如:
- 从 FP16 切到 INT8
- 打开 prefix cache
- 把 batch wait 从 5ms 调到 15ms
第三步:分请求类型测
至少拆成:
- 短输入短输出
- 长输入短输出
- 长输入长输出
因为不同类型请求的性能画像完全不同。
第四步:上线前压尾延迟
不要只看平均值。很多服务“均值很漂亮,P99 很糟糕”,用户体验仍然差。
第五步:预留降级路径
比如:
- 长上下文自动截断
- 高峰期降级到更小模型
- 超长输出强制停止
- 非核心租户限流
真正线上稳定的系统,一定是“优化 + 降级”一起设计的。
总结
大模型推理服务的性能调优,核心不是某一个神奇参数,而是理解这三件事的耦合关系:
- 模型权重决定基础显存和精度
- KV Cache 决定长上下文和高并发下的生存空间
- 调度与批处理决定吞吐、TTFT 和尾延迟的平衡
如果你只能先做三件事,我建议优先做:
- 建立 TTFT / TPOT / P99 的观测体系
- 先做稳妥量化,再做 KV Cache 优化
- 把长请求和短请求分流,做动态批处理治理
最后给一个很务实的边界判断:
- 如果你的业务是低延迟交互,优先控制 TTFT,batch 不要太激进
- 如果你的业务是离线批量生成,优先追求吞吐,batch 可以更大
- 如果你的业务是长上下文 Agent/RAG,优先优化 KV Cache,而不是只盯权重量化
推理服务优化这件事,真正有价值的不是“跑出一次 benchmark”,而是让系统在真实流量下,持续稳定地快。这也是架构设计和工程实现最见功力的地方。