大模型推理性能优化实战:从 KV Cache、量化到并发调度的工程落地路径
做大模型服务时,很多团队最先遇到的不是“模型不够聪明”,而是“模型太慢、太贵、扛不住并发”。
我自己在落地推理服务时,最常见的现场是这样的:离线测试看起来还行,到了线上一压测,首 token 延迟飙升、GPU 显存打满、吞吐上不去,最后只能一边降并发一边加机器。问题不在某一个点,而在于推理链路是一个系统工程:模型结构、KV Cache、量化方式、批处理策略、请求调度、上下文长度,都会互相影响。
这篇文章不追求“把所有理论讲完”,而是站在工程落地视角,带你把一条常见优化路径走通:
- 先理解性能瓶颈在哪里
- 再抓住最关键的三个杠杆:KV Cache、量化、并发调度
- 最后用一套可运行代码做一个最小实践,并给出排查清单
如果你已经能跑通 Hugging Face 或 vLLM 的基础推理,这篇内容会比较适合你。
背景与问题
大模型推理慢,通常不是一个模糊概念,而是几类指标出了问题:
- TTFT(Time To First Token):首 token 时间过长
- TPOT(Time Per Output Token):每个生成 token 的平均耗时高
- 吞吐量低:单位时间处理不了足够多请求
- 显存压力大:上下文一长或者并发一高就 OOM
- 成本高:单次调用太贵,GPU 利用率还不高
这些问题往往来自推理阶段两个不同过程:
- Prefill 阶段:把输入上下文整段送进模型,计算历史 token 的注意力表示
- Decode 阶段:每次只生成一个新 token,并不断追加状态
这两个阶段的瓶颈并不一样:
- Prefill 更偏计算密集
- Decode 更偏访存密集 / cache 密集
也正因为这样,很多“看起来合理”的优化,放到具体阶段未必有效。比如:
- 量化通常对显存和吞吐有帮助,但不同量化方式对首 token 和生成速度影响不完全一样
- KV Cache 会显著降低重复计算,但也会持续吞噬显存
- 连续批处理能提升 GPU 利用率,但排队策略不对会让尾延迟很难看
先看一张总览图。
flowchart TD
A[用户请求到达] --> B[Tokenizer]
B --> C[Prefill 阶段]
C --> D[建立 KV Cache]
D --> E[Decode 阶段]
E --> F[流式返回 Token]
C --> G[计算瓶颈]
D --> H[显存瓶颈]
E --> I[调度瓶颈]
前置知识与环境准备
这篇文章默认你知道这些基本概念:
- Transformer 的注意力机制
- 自回归生成的基本过程
- PyTorch 基础推理用法
- Hugging Face Transformers 的基础加载方式
环境建议
为了让示例更容易跑起来,我这里给两套思路:
方案 A:本地最小实验
适合验证 KV Cache 与量化的效果。
- Python 3.10+
- PyTorch 2.2+
- transformers 4.40+
- accelerate
- bitsandbytes(如果你在 NVIDIA GPU 上做 4bit/8bit 量化)
安装示例:
pip install torch transformers accelerate
pip install bitsandbytes
方案 B:工程化服务验证
适合进一步做并发调度和吞吐实验。
- vLLM 或 TGI
- 一张 24GB 以上显存 GPU 更舒服
- locust / wrk / vegeta 做压测
核心原理
这一节只讲工程上最有用的部分,不绕太深。
1. 为什么 KV Cache 能加速推理
在自回归生成里,模型每生成一个 token,都需要关注前面所有 token。如果不做缓存,那么第 t 步生成时,会把前 1..t-1 的 Key/Value 重新算一遍,代价非常高。
KV Cache 的做法是:
- 第一次 prefill 时,把每一层 attention 的 K/V 张量保存起来
- 后续 decode 时,只计算新 token 的 Q/K/V
- 其中历史 token 的 K/V 直接复用缓存
这会把大量重复计算省掉。
sequenceDiagram
participant Client as Client
participant Model as LLM
participant Cache as KV Cache
Client->>Model: 输入 prompt
Model->>Cache: 写入历史 token 的 K/V
Model-->>Client: 首个 token
loop 每生成一个 token
Client->>Model: 继续生成
Model->>Cache: 读取历史 K/V
Model->>Cache: 追加新 token K/V
Model-->>Client: 下一个 token
end
KV Cache 的收益
- 降低 decode 阶段重复计算
- 提升长输出场景下的 token/s
- 对多轮对话尤其有效
KV Cache 的代价
- 显存占用会随着:
- batch 增大
- 上下文变长
- 层数变多
- hidden size 变大
持续增长
- 并发高时,cache 管理会成为服务核心问题
一句话概括:KV Cache 本质上是在用显存换速度。
2. 量化为什么能显著降低成本
量化的目标,是把模型权重从高精度表示压缩成低精度表示。
常见情况:
- FP16 / BF16:2 字节
- INT8:1 字节
- INT4 / 4bit:0.5 字节左右(实际还有额外元数据)
直观好处很明显:
- 模型占用显存更少
- 同一张卡能放更大的模型
- 同一张卡能开更高并发
- 数据搬运带宽压力降低
但量化不是“白拿收益”,它涉及三类取舍:
精度取舍
量化后可能有困惑度上升、幻觉增多、格式稳定性下降的问题,尤其在:
- 代码生成
- 数学推理
- 严格结构化输出
算子支持取舍
有些量化方式推理框架支持非常好,有些只是“能跑”,但速度不一定快。
运维复杂度取舍
量化模型、权重格式、推理引擎、GPU 架构之间有兼容性差异。
一个比较实用的经验是:
- 先用 8bit/4bit 做容量验证
- 再针对核心业务集做 A/B 精度评估
- 最后决定是否大规模切换
3. 并发调度为什么常常决定最终吞吐
很多人以为“把模型优化好了,服务就自然快”。实际上,服务层调度策略对线上体验影响极大。
同样一张 GPU,为什么有的服务吞吐高很多?常见差异就在这里:
- 是否做了 动态批处理(dynamic batching)
- 是否做了 连续批处理(continuous batching)
- 是否区分 prefill 和 decode 的资源竞争
- 是否对长请求、短请求做队列隔离
- 是否有 cache 回收和请求抢占机制
下面这张图可以帮助理解:
flowchart LR
A[请求队列] --> B{调度器}
B --> C[Prefill Batch]
B --> D[Decode Batch]
C --> E[GPU 执行]
D --> E[GPU 执行]
E --> F[结果流式返回]
E --> G[KV Cache 分配/回收]
4. Prefill 与 Decode 要分开看
这是很多性能分析里最容易忽略的一点。
Prefill 特征
- 输入长时,耗时陡增
- 更依赖算力和大批量计算效率
- 影响 TTFT 很明显
Decode 特征
- 一次只生成一个 token
- 更依赖 cache 读写和调度效率
- 影响稳定 token/s 和整体吞吐
所以工程上经常要做不同优化:
- 优化 TTFT:优先看 prompt 长度、prefill batching、模型大小、prompt cache
- 优化吞吐:优先看 KV Cache、连续批处理、量化、并发调度
一条实用的工程落地路径
如果你现在有一个“能跑但不快”的大模型服务,我建议按这个顺序推进,而不是同时改一堆东西:
阶段 1:先建立基线
先测这些指标:
- P50 / P95 TTFT
- P50 / P95 TPOT
- 平均 token/s
- GPU 显存占用
- GPU 利用率
- 每请求平均输入长度 / 输出长度
阶段 2:启用 KV Cache
目标:
- 验证 decode 提速是否明显
- 观察显存增长曲线
- 建立 cache 生命周期监控
阶段 3:尝试量化
目标:
- 降低显存占用
- 提升可承载 batch / 并发
- 评估精度损失边界
阶段 4:改造调度
目标:
- 让 GPU 少空转
- 减少短请求被长请求拖慢
- 把吞吐做上去,同时控制尾延迟
阶段 5:做压测闭环
目标:
- 找到最优 batch / 并发点
- 明确 OOM 拐点
- 给出 SLA 边界条件
实战代码(可运行)
下面我用 Hugging Face 给一个最小可运行示例,演示三件事:
- 比较启用 / 不启用 KV Cache 的生成性能
- 使用 4bit 量化加载模型
- 做一个简单的并发请求模拟
说明:示例使用较小模型,方便你在普通 GPU 上验证流程。真正线上服务建议迁移到 vLLM / TGI 之类更成熟的推理引擎。
示例 1:对比 KV Cache 开关
import time
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_NAME = "distilgpt2" # 演示用小模型,方便跑通
device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME).to(device)
model.eval()
prompt = "Explain KV cache in transformer inference in simple words."
inputs = tokenizer(prompt, return_tensors="pt").to(device)
def benchmark(use_cache: bool, max_new_tokens: int = 64):
torch.cuda.empty_cache() if torch.cuda.is_available() else None
start = time.perf_counter()
with torch.no_grad():
output = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
use_cache=use_cache,
pad_token_id=tokenizer.eos_token_id,
)
end = time.perf_counter()
text = tokenizer.decode(output[0], skip_special_tokens=True)
return end - start, text
t1, text1 = benchmark(use_cache=False)
t2, text2 = benchmark(use_cache=True)
print(f"use_cache=False 耗时: {t1:.4f}s")
print(f"use_cache=True 耗时: {t2:.4f}s")
print("输出预览:")
print(text2[:300])
你应该关注什么
use_cache=True通常会更快,尤其在生成 token 较多时更明显- 小模型差距可能不夸张,但在大模型长输出场景下收益会放大
- 如果你发现没变快,要考虑:
- 输出太短
- 模型本身太小
- CPU 推理不明显
- 测试方式把 tokenizer / I/O 时间也算进去了
示例 2:4bit 量化加载模型
如果你使用 NVIDIA GPU,并且安装了 bitsandbytes,可以试试 4bit 量化。
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
MODEL_NAME = "Qwen/Qwen2.5-1.5B-Instruct"
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
)
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model.eval()
prompt = "请用简单语言解释什么是 KV Cache。"
messages = [
{"role": "system", "content": "你是一个简洁清晰的技术助手。"},
{"role": "user", "content": prompt},
]
text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=128,
do_sample=False,
use_cache=True,
)
result = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(result)
这段代码验证的重点
- 量化后模型是否能在你的显存内稳定加载
- 输出质量是否还能满足业务要求
- 同样 GPU 下,是否能承载更长上下文或更高并发
一个很现实的提醒
4bit 并不总是“最快”,它往往首先带来的是:
- 更低显存占用
- 更好的部署可行性
速度收益是否明显,要看:
- 模型大小
- GPU 架构
- 框架优化程度
- batch 大小
- 是否受带宽限制
示例 3:简单并发压测脚本
下面给一个非常轻量的并发测试示例,模拟多个请求同时调用模型。
注意:这不是最佳生产方案,只是为了帮你建立“调度影响吞吐”的直觉。
import time
import asyncio
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_NAME = "distilgpt2"
device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME).to(device)
model.eval()
prompts = [
"What is KV cache?",
"Explain quantization in LLM inference.",
"Why does dynamic batching improve throughput?",
"What causes OOM during long context generation?",
"How to reduce time to first token?",
] * 2
def run_inference(prompt: str):
inputs = tokenizer(prompt, return_tensors="pt").to(device)
start = time.perf_counter()
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=64,
do_sample=False,
use_cache=True,
pad_token_id=tokenizer.eos_token_id,
)
end = time.perf_counter()
return {
"prompt": prompt,
"latency": end - start,
"text": tokenizer.decode(outputs[0], skip_special_tokens=True)
}
async def worker(prompt: str):
return await asyncio.to_thread(run_inference, prompt)
async def main():
start_all = time.perf_counter()
results = await asyncio.gather(*[worker(p) for p in prompts])
end_all = time.perf_counter()
print(f"总耗时: {end_all - start_all:.4f}s")
print(f"请求数: {len(results)}")
avg_latency = sum(r["latency"] for r in results) / len(results)
print(f"平均单请求耗时: {avg_latency:.4f}s")
for i, r in enumerate(results[:3]):
print(f"\n--- 请求 {i+1} ---")
print(f"latency={r['latency']:.4f}s")
print(r["text"][:200])
if __name__ == "__main__":
asyncio.run(main())
这段代码的意义
它会让你看到一个现象:
“并发发请求”不等于“GPU 吞吐最优”。
如果你继续把这类测试升级成:
- 请求分桶
- 动态 batch
- 长短 prompt 混合
- 流式输出
你就会很快触达真正的服务调度问题,而不是停留在单请求优化。
逐步验证清单
我很建议你按这个顺序做,不然很容易“改了很多,却不知道哪一步有效”。
第一步:单请求基线
- 固定 prompt
- 固定输出长度
- 记录 TTFT、总耗时、显存占用
第二步:开关 KV Cache
- 比较
use_cache=False/True - 输出长度从 32、128、256 逐步增大
- 观察收益是否随输出长度增加而放大
第三步:量化对比
- FP16/BF16 vs INT8/INT4
- 记录显存、速度、输出质量
- 准备一小批业务真实样本做人工评估
第四步:并发实验
- 固定模型和参数
- 并发从 1、2、4、8、16 逐步压
- 记录 P95 延迟和 OOM 点
第五步:长短请求混测
- 短 prompt + 长 prompt 混合
- 看尾延迟是否恶化
- 看短请求是否被“拖车”
常见坑与排查
这一节我尽量讲得接地气一点,因为这些坑真的很常见。
坑 1:开了 KV Cache,却没看到明显提速
可能原因
- 输出 token 太少
- 模型太小,小到 cache 收益不明显
- 主要瓶颈在 prefill,不在 decode
- 你把 tokenizer、日志、网络传输时间也混进去了
排查建议
- 拉大
max_new_tokens - 分开测 prefill 与 decode
- 用 profiler 看 GPU kernel 时间分布
坑 2:量化后显存省了,但速度没提升,甚至变慢
可能原因
- 某些量化算子在你的硬件上没充分优化
- batch 太小,看不到吞吐优势
- CPU/GPU 之间有额外数据搬运
- 推理框架对该量化格式支持一般
排查建议
- 先看显存收益是否达到预期
- 再测不同 batch 大小
- 换更适合生产的引擎,如 vLLM、TensorRT-LLM、TGI
- 不要只盯着单请求延迟,也看吞吐
坑 3:高并发时 GPU 利用率不高,但延迟很高
这是线上最让人烦的情况之一。
常见原因
- 请求没有有效 batching
- 长短请求混在一个队列
- decode 粒度太细,调度开销大
- CPU 侧 tokenization / postprocess 成了瓶颈
- 流式输出线程模型有阻塞
排查建议
- 看队列等待时间
- 看 prefill 和 decode 是否分离统计
- 监控 CPU 使用率、线程切换、事件循环阻塞
- 检查 tokenizer 是否需要迁移到更高效实现
坑 4:上下文一长就 OOM
常见原因
- KV Cache 增长超出预估
- batch size 设置过激进
- 没有及时回收已完成请求的 cache
- 多轮对话 session 累积上下文没有裁剪
排查建议
先建立一个基本意识:
OOM 不只是模型权重问题,更多时候是“权重 + 激活 + KV Cache + batch”的总和问题。
可以从这几个方向排查:
- 限制最大上下文长度
- 限制最大生成长度
- 对会话历史做摘要或裁剪
- 做 cache eviction
- 把长请求和短请求分池
坑 5:压测结果很好,线上体验却不稳定
这个坑我自己踩过。离线压测通常过于理想化,线上会多出很多变量:
- prompt 分布不均匀
- 用户会中断、重试、取消请求
- 流式连接数量变化大
- 多租户抢资源
- 上游限流不稳定
建议
压测时至少模拟这三种负载:
- 短 prompt,短输出
- 长 prompt,短输出
- 长 prompt,长输出
否则很容易误判最佳参数。
安全/性能最佳实践
这一节把工程上最值得坚持的做法收拢一下。
1. 先定容量红线,再谈性能优化
上线前明确这些边界:
- 最大上下文长度
- 最大输出长度
- 单实例最大并发
- 单租户最大 QPS
- OOM 后的降级策略
如果这些边界不清晰,任何优化都可能在流量上来后失效。
2. 把 KV Cache 当成一等公民管理
建议你监控这些指标:
- 当前 cache 占用显存
- 活跃 session 数
- 平均上下文长度
- cache 命中 / 复用情况
- cache 回收耗时
如果服务支持多轮对话,不管理 cache,后面几乎一定出问题。
3. 量化不要只看“能不能跑”,要看“业务质量是否可接受”
建议准备一个小型评测集,覆盖:
- 问答
- 摘要
- 代码
- 结构化输出
- 多轮上下文理解
然后对比:
- 原始模型输出
- 量化模型输出
- 是否出现格式错误、事实偏差、推理断裂
有些场景 4bit 很稳,有些场景就会明显掉点,别一刀切。
4. 并发调度上优先做“简单但有效”的策略
在早期版本里,我建议先做这几件事:
- 动态批处理
- 长短请求分桶
- 限制超长 prompt
- 流式输出时支持取消
- 请求超时和背压机制
这些策略并不花哨,但收益通常非常大。
5. 做好可观测性
至少要有以下监控:
- TTFT、TPOT、总延迟
- P50/P95/P99
- GPU 利用率、显存利用率
- 每请求输入长度、输出长度
- 队列等待时间
- OOM 次数、重试次数、取消次数
没有这些数据,你很难判断问题到底出在模型、框架还是调度。
6. 对外暴露能力时要考虑安全与滥用风险
大模型推理服务不只是性能问题,也有资源安全问题:
- 超长输入可能造成资源恶意占用
- 高频请求可能拖垮实例
- 多轮会话可能积累异常大的上下文
建议配套措施:
- 输入长度限制
- 用户级限流
- 并发数配额
- 请求超时
- 输出 token 上限
- 异常请求熔断
一个更贴近生产的方案对比
如果你准备从 demo 走向线上,通常会在三种路线中选择:
| 路线 | 适合场景 | 优点 | 局限 |
|---|---|---|---|
Hugging Face 原生 generate | 单机验证、原型开发 | 上手快、可控 | 调度和吞吐能力有限 |
| vLLM | 通用在线推理服务 | 连续批处理、KV 管理优秀、吞吐高 | 框架约束较多 |
| TensorRT-LLM / 专项加速引擎 | 极致性能场景 | 性能强、硬件优化深 | 工程复杂度更高 |
如果你问我一个偏实战的建议:
- 个人实验 / 小规模内网服务:先用 Transformers
- 生产级在线服务:优先评估 vLLM
- 超大规模高性能场景:再考虑更重型的加速栈
一套排障思路:从现象反推问题位置
flowchart TD
A[现象: 延迟高/吞吐低/OOM] --> B{先看哪类指标异常}
B -->|TTFT 高| C[重点查 Prefill: prompt 长度/模型大小/输入 batching]
B -->|TPOT 高| D[重点查 Decode: KV Cache/访存/调度]
B -->|显存高| E[重点查 权重精度/KV Cache/上下文长度]
B -->|GPU 利用率低| F[重点查 批处理/队列/CPU瓶颈]
C --> G[限长、prompt cache、优化 prefill]
D --> H[启用 KV Cache、连续批处理、优化流式调度]
E --> I[量化、裁剪会话、cache 回收]
F --> J[动态批处理、长短请求分桶、排队可视化]
这张图的核心意思很简单:
不要一上来就“换模型、换框架、开量化”,先定位瓶颈在哪一段。
总结
大模型推理优化,真正有效的方法很少是“单点神技”,而是几件事配合起来:
- KV Cache:解决 decode 阶段重复计算,用显存换速度
- 量化:解决显存与成本问题,为更高并发腾空间
- 并发调度:决定 GPU 是否真正吃满,也是线上吞吐的关键
如果你要快速落地,我建议按下面这个顺序执行:
- 建立基线指标:TTFT、TPOT、显存、吞吐
- 先开 KV Cache:确认 decode 是否提速
- 再试量化:先看显存收益,再看精度边界
- 最后调度优化:动态 batch、长短请求分桶、背压与超时
- 用真实流量分布压测:找到容量上限和 SLA 边界
最后给一个很实用的结论:
如果你的服务已经进入“线上多人共用”的阶段,决定体验上限的,往往不只是模型本身,而是你如何管理 KV Cache、如何选择量化方案、以及如何调度请求。
把这三件事做好,大模型推理服务的性能通常就能从“能跑”迈向“能上线、能扛量、能控成本”。