大模型推理优化实战:从量化、KV Cache 到并发调度的性能提升路径
做大模型应用时,很多人一开始关注的是“模型够不够聪明”,但项目一上线,最先把人打醒的往往不是效果,而是延迟、吞吐、显存和成本。
我见过不少团队在 PoC 阶段用一张卡跑得挺开心,等用户一多,问题就集中爆发:
- 首 token 很慢,用户觉得“像卡住了”
- 多用户并发一上来,吞吐掉得厉害
- 显存不够,模型一加载就 OOM
- 同样的 GPU,别人能跑得更快,自己却总是“跑不满”
这篇文章不讲空泛概念,而是从实战角度把大模型推理优化拆成三条主线:
- 量化:先把模型“塞得下、跑得动”
- KV Cache:再把生成阶段加速起来
- 并发调度:最后把单请求优化,变成整体吞吐优化
如果你已经会基本的 Transformers 推理,这篇内容应该能帮你建立一个比较完整的优化路径。
背景与问题
大模型推理的性能问题,通常不是单点造成的,而是多个瓶颈叠加:
- 模型参数量大:权重加载占用大量显存
- 注意力计算昂贵:上下文越长,计算和显存压力越大
- 生成是逐 token 的:天然难像训练那样充分并行
- 服务端存在多租户并发:不同请求长度不同,调度稍差就浪费算力
可以把一次推理粗略分成两个阶段:
- Prefill 阶段:把整段输入 prompt 编码进模型
- 计算密集
- 对长上下文更敏感
- Decode 阶段:逐 token 生成输出
- 强依赖 KV Cache
- 对调度和批处理策略更敏感
下面这张图能帮助你先建立全局视角。
flowchart LR
A[用户请求] --> B[Tokenizer]
B --> C[Prefill<br/>处理整段输入]
C --> D[建立 KV Cache]
D --> E[Decode<br/>逐 token 生成]
E --> F[后处理/流式返回]
G[量化] --> C
G --> E
H[KV Cache] --> D
H --> E
I[并发调度/批处理] --> C
I --> E
常见性能指标
优化前,先统一指标口径,不然后面很容易“感觉变快了”,但没法复现。
建议至少看这几个指标:
- TTFT(Time To First Token):首 token 延迟
- TPOT(Time Per Output Token):每个输出 token 平均耗时
- 吞吐:tokens/s 或 requests/s
- 显存占用:静态权重 + 动态 KV Cache
- P95/P99 延迟:比平均值更有意义
一句话概括:
- 量化主要解决“模型太大、算太慢”
- KV Cache主要解决“重复算历史上下文”
- 并发调度主要解决“GPU 没吃满、请求互相拖累”
前置知识与环境准备
本文示例使用 Python,尽量用你本地就能跑起来的方式演示。
建议环境:
- Python 3.10+
- CUDA 11.8+(如使用 NVIDIA GPU)
- PyTorch 2.x
- transformers
- accelerate
- bitsandbytes
安装示例:
pip install torch transformers accelerate bitsandbytes
如果你没有 GPU,示例中的部分代码仍然能运行,但性能优化效果就不明显了。
核心原理
这一节我会把三类优化放到同一张“性能地图”里讲清楚。
1. 量化:降低权重存储和访存成本
量化的核心思想是:
把原来用 FP16 / BF16 存储的模型权重,转换成更低位宽,比如 8bit、4bit。
这会带来两个直接收益:
- 显存占用下降
- 访存带宽压力减轻
对于推理来说,很多时候瓶颈不完全是算力,而是内存带宽和数据搬运。所以量化常常比大家想象中更有效。
常见量化方式:
- INT8:精度损失通常较小,兼容性较好
- 4bit:压缩更狠,适合显存紧张场景
- AWQ/GPTQ:离线量化,推理表现通常更好
- SmoothQuant:适合某些服务端部署方案
但要注意:
量化不是“白捡性能”,它也可能带来:
- 生成质量下降
- 某些层不稳定
- 特定硬件/内核支持不佳
- 首次加载时间变长
2. KV Cache:避免重复计算历史 token
在自回归生成中,第 t 步生成时,并不需要把前面所有 token 再完整算一遍。
注意力机制里,历史 token 对应的 Key/Value 可以缓存起来,后续直接复用。
如果没有 KV Cache:
- 第 1 个 token 算一次
- 第 2 个 token 又把历史重算
- 第 100 个 token 还在不断重复算前面 99 个 token
有了 KV Cache:
- 历史 K/V 存下来
- 新 token 只补增量计算
这就是为什么长输出场景下,KV Cache 几乎是必选项。
不过 KV Cache 也不是没有代价:
- 它会显著增加显存占用
- 上下文越长、并发越高,缓存越大
- 容量管理不好会直接 OOM
3. 并发调度:把 GPU 真正跑满
单请求快,不代表整体服务快。
在线服务里更常见的问题是:请求长度不均、到达时间随机、资源被切碎。
并发调度的目标是:
- 通过动态批处理提高 GPU 利用率
- 尽量减少短请求被长请求拖慢
- 在延迟和吞吐之间找到平衡
常见策略包括:
- Static Batching:固定批大小,简单但不灵活
- Dynamic Batching:按时间窗聚合请求
- Continuous Batching:生成过程中动态插入/移除请求
- 长度分桶:把相近长度请求放一起,减少 padding 浪费
下面这张图展示三种优化作用在哪个阶段。
flowchart TD
A[推理请求生命周期] --> B[模型加载]
B --> C[Prefill]
C --> D[Decode]
D --> E[返回结果]
F[量化] --> B
F --> C
F --> D
G[KV Cache] --> D
H[动态批处理/并发调度] --> C
H --> D
一个很重要的判断:先优化哪一层?
在真实项目里,我建议按这个顺序排查:
- 先看模型是否装得下、显存是否稳定
不稳定先做量化 - 再看 decode 是否慢
输出长时优先看 KV Cache - 最后看多用户并发吞吐
服务化后重点看调度策略
这不是绝对顺序,但通常是最省时间的路线。
实战代码(可运行)
下面我们做三个逐步实验:
- 基线推理
- 开启量化
- 对比 KV Cache 开关
- 用一个简化版并发调度器演示动态批处理思路
说明:示例使用 Hugging Face Transformers,模型用较小的指令模型以方便测试。你可以替换成自己的模型。
实战一:基线推理与指标采集
先写一个最小可运行版本,并记录耗时。
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
MODEL_NAME = "Qwen/Qwen2-0.5B-Instruct"
device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype=torch.float16 if device == "cuda" else torch.float32,
).to(device)
model.eval()
prompt = "请用简洁语言解释什么是 KV Cache,以及它为什么能提升大模型推理速度。"
inputs = tokenizer(prompt, return_tensors="pt").to(device)
start = time.perf_counter()
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=128,
do_sample=False,
use_cache=True,
)
end = time.perf_counter()
text = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(text)
print(f"总耗时: {end - start:.3f}s")
if device == "cuda":
print(f"显存占用: {torch.cuda.max_memory_allocated() / 1024**2:.2f} MB")
这段代码看什么
先别急着改参数,先记录三件事:
- 总耗时
- 最大显存
- 输出质量是否正常
如果你连这个基线都没记录,后面任何“优化”都很难证明是有效的。
实战二:开启 4bit 量化
下面用 bitsandbytes 演示 4bit 量化加载。
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
MODEL_NAME = "Qwen/Qwen2-0.5B-Instruct"
device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
quant_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
quantization_config=quant_config,
device_map="auto"
)
model.eval()
prompt = "请解释量化对大模型推理性能和显存的影响,并说明可能的副作用。"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
start = time.perf_counter()
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=128,
do_sample=False,
use_cache=True,
)
end = time.perf_counter()
text = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(text)
print(f"4bit 量化总耗时: {end - start:.3f}s")
if torch.cuda.is_available():
print(f"显存占用: {torch.cuda.max_memory_allocated() / 1024**2:.2f} MB")
量化实验怎么判断值不值
建议对比以下项目:
| 维度 | 未量化 | 4bit 量化 |
|---|---|---|
| 模型加载显存 | 高 | 低 |
| 首次响应时间 | 可能较稳定 | 可能略有变化 |
| 输出速度 | 视硬件而定 | 可能更快,也可能持平 |
| 质量稳定性 | 高 | 需验证 |
我自己的经验是:
- 显存不足场景:量化几乎是必选
- 高质量、强一致输出场景:先做回归测试再上线
- 极致低延迟场景:不要只看量化,内核实现和服务框架也很关键
实战三:KV Cache 开关对比
很多人知道 use_cache=True,但没有真的做过对比。
下面用相同输入分别测试开关。
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
MODEL_NAME = "Qwen/Qwen2-0.5B-Instruct"
device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype=torch.float16 if device == "cuda" else torch.float32,
).to(device)
model.eval()
prompt = "请生成一段关于企业级大模型推理优化的说明,包含量化、KV Cache 与并发调度三部分,每部分两句话。"
inputs = tokenizer(prompt, return_tensors="pt").to(device)
def run_once(use_cache: bool):
if device == "cuda":
torch.cuda.reset_peak_memory_stats()
start = time.perf_counter()
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=200,
do_sample=False,
use_cache=use_cache,
)
end = time.perf_counter()
text = tokenizer.decode(outputs[0], skip_special_tokens=True)
mem = torch.cuda.max_memory_allocated() / 1024**2 if device == "cuda" else 0
return end - start, mem, text
t1, m1, _ = run_once(use_cache=False)
t2, m2, _ = run_once(use_cache=True)
print(f"use_cache=False -> 耗时: {t1:.3f}s, 显存: {m1:.2f} MB")
print(f"use_cache=True -> 耗时: {t2:.3f}s, 显存: {m2:.2f} MB")
你大概率会观察到
use_cache=True更快- 但显存占用更高
- 输出越长,KV Cache 的收益越明显
KV Cache 的本质是用空间换时间。
如果你是长对话、长生成、Agent 多轮场景,这个交换通常很值。
实战四:一个简化版并发调度器
真正的在线高性能服务,通常会用 vLLM、TGI、TensorRT-LLM 等框架。
但为了理解原理,我们可以先写一个简化版动态批处理器。
这个示例不追求工业级完备,而是帮助你看懂“请求聚合”的核心思路。
import time
import queue
import threading
from dataclasses import dataclass
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
MODEL_NAME = "Qwen/Qwen2-0.5B-Instruct"
device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype=torch.float16 if device == "cuda" else torch.float32,
).to(device)
model.eval()
@dataclass
class Request:
prompt: str
max_new_tokens: int
result: dict
request_queue = queue.Queue()
def batch_worker(batch_size=4, wait_ms=50):
while True:
batch = []
start_wait = time.time()
while len(batch) < batch_size:
timeout = max(0, wait_ms / 1000 - (time.time() - start_wait))
try:
req = request_queue.get(timeout=timeout)
batch.append(req)
except queue.Empty:
break
if not batch:
continue
prompts = [r.prompt for r in batch]
max_new_tokens = max(r.max_new_tokens for r in batch)
inputs = tokenizer(
prompts,
return_tensors="pt",
padding=True,
truncation=True
).to(device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
use_cache=True,
)
texts = tokenizer.batch_decode(outputs, skip_special_tokens=True)
for req, text in zip(batch, texts):
req.result["text"] = text
req.result["done"] = True
def submit_request(prompt, max_new_tokens=64):
result = {"done": False, "text": None}
req = Request(prompt=prompt, max_new_tokens=max_new_tokens, result=result)
request_queue.put(req)
return result
worker = threading.Thread(target=batch_worker, daemon=True)
worker.start()
examples = [
"解释什么是推理量化。",
"解释 KV Cache 的作用。",
"说明连续批处理为什么能提高吞吐。",
"Prefill 和 Decode 有什么区别?",
]
results = [submit_request(x) for x in examples]
while not all(r["done"] for r in results):
time.sleep(0.1)
for i, r in enumerate(results, 1):
print(f"=== 请求 {i} ===")
print(r["text"])
print()
这个调度器体现了什么
它做了两件关键事:
- 短时间窗口聚合请求
- 把多个 prompt 合成一个 batch 一起推理
这能提升吞吐,但也有代价:
- 等待窗口会增加首 token 延迟
- 长短请求混在一起可能造成尾部拖累
- padding 太多会浪费计算
所以在线上,调度策略通常不是“批越大越好”,而是要根据 SLA 调整。
并发调度策略对比
下面这张图可以帮助你理解为什么 continuous batching 更适合大模型服务。
sequenceDiagram
participant U1 as Request A
participant U2 as Request B
participant S as Scheduler
participant G as GPU Worker
U1->>S: 提交请求
S->>G: 加入 batch 执行 prefill/decode
U2->>S: 提交请求
S->>G: 在后续轮次动态插入
G-->>S: A 生成一个 token
G-->>S: B 生成一个 token
S-->>U1: 流式返回
S-->>U2: 流式返回
再进一步抽象一下,调度器内部常见状态如下:
stateDiagram-v2
[*] --> Waiting
Waiting --> Batched: 达到时间窗/批大小
Batched --> Prefill
Prefill --> Decoding
Decoding --> Decoding: 继续生成 token
Decoding --> Finished: 请求完成
Finished --> [*]
逐步验证清单
如果你准备把这些优化落到项目里,我建议按下面顺序做验证。
第一步:确认基线
- 固定模型版本
- 固定 prompt 集合
- 固定
max_new_tokens - 记录 TTFT、tokens/s、显存、P95
第二步:验证量化收益
- 对比加载显存下降比例
- 看输出质量是否可接受
- 观察首 token 是否异常变慢
- 检查某些 prompt 是否出现明显退化
第三步:验证 KV Cache
- 对短输出与长输出分别测试
- 看速度提升是否随输出长度变大
- 测显存增长曲线
- 多并发下观察是否容易 OOM
第四步:验证并发调度
- 单请求与多请求吞吐分别测试
- 比较 batch size = 1/2/4/8
- 比较等待窗 10ms/30ms/50ms
- 观察短请求是否被长请求显著拖慢
常见坑与排查
这一节很重要,因为推理优化最烦人的地方不是“原理难”,而是现象很碎。我把常见坑按症状列出来。
1. 开了量化,反而不快
可能原因
- 当前 GPU 对低比特推理支持一般
- 模型虽然量化了,但某些算子没走高效 kernel
- batch 太小,量化节省的访存不足以覆盖额外开销
排查方法
- 看 GPU 型号和驱动版本
- 用 PyTorch profiler 或 Nsight 看热点算子
- 对比
load_in_8bit和load_in_4bit - 比较不同 batch size 下的性能
建议
- 如果重点是装得下,量化通常有效
- 如果重点是绝对低延迟,要实测,不要凭感觉
2. KV Cache 开了以后 OOM
可能原因
- 上下文太长
- 并发请求太多
max_new_tokens设置过大- 忘了控制会话生命周期,缓存一直积累
排查方法
- 统计平均输入长度和输出长度
- 限制最长上下文
- 降低并发或 batch size
- 检查服务端是否有“僵尸会话”
建议
我踩过一个很典型的坑:
离线测试时一切正常,上线后因为用户对话轮数越来越长,KV Cache 持续膨胀,最终显存被吃满。
所以别只测“单轮短对话”,一定要测长会话场景。
3. 动态批处理吞吐高了,但用户觉得更慢
可能原因
- 等待窗口太长
- 为了凑 batch,牺牲了 TTFT
- 长短请求混合导致 tail latency 上升
排查方法
- 同时看平均延迟和 P95/P99
- 单独统计短请求延迟
- 缩小 batching window
- 对请求做长度分桶
建议
如果你的产品是聊天应用,用户对首 token 非常敏感。
这时不要盲目追求大 batch,很多时候小窗口 + continuous batching更平衡。
4. 结果偶发异常、乱码或重复输出
可能原因
- tokenizer 和 model 版本不一致
- pad/eos 配置错误
- 量化后个别 prompt 数值不稳定
- 批处理时输出切分逻辑有问题
排查方法
print("pad_token_id:", tokenizer.pad_token_id)
print("eos_token_id:", tokenizer.eos_token_id)
print("model dtype:", next(model.parameters()).dtype)
还可以显式设置:
outputs = model.generate(
**inputs,
max_new_tokens=128,
do_sample=False,
use_cache=True,
pad_token_id=tokenizer.eos_token_id,
eos_token_id=tokenizer.eos_token_id,
)
安全/性能最佳实践
推理优化不只是“更快”,还要“更稳、更可控”。
1. 设定输入与输出上限
这是最基础也最容易被忽略的一点。
建议限制:
- 最大输入 token 数
- 最大输出 token 数
- 单用户并发数
- 单会话最大轮数
否则一个超长 prompt 就可能把整个服务拖垮。
2. 区分在线流量与离线批处理
两类场景优化目标不同:
- 在线服务:优先 TTFT、P95
- 离线批处理:优先吞吐、单位成本
不要拿离线最优参数直接套在线服务。
3. 做长度分桶,减少 padding 浪费
把长度接近的请求放进同一批次,常常是最划算的调优手段之一。
比如:
- 1~256 tokens
- 257~1024 tokens
- 1025~4096 tokens
这件事实现不算复杂,但收益通常很稳定。
4. 把显存预算拆开看
不要只盯着“模型权重占多少”。
更合理的显存预算应包含:
- 模型权重
- 激活临时开销
- KV Cache
- 框架运行时开销
- 碎片化冗余
很多人 OOM 的原因不是模型本身,而是低估了 KV Cache 和碎片。
5. 优先使用成熟推理框架
如果你是业务团队,不一定要自己从零写调度器。
优先考虑成熟方案:
- vLLM:KV Cache 管理与 continuous batching 很强
- TGI:部署方便,生态成熟
- TensorRT-LLM:对 NVIDIA 体系优化深入
- SGLang:适合复杂推理编排场景
自己手写服务可以帮助理解原理,但线上大规模部署,还是尽量站在成熟框架肩膀上。
6. 监控要能区分 Prefill 和 Decode
很多团队只看总耗时,这会让问题定位非常模糊。
建议至少拆分监控:
- prefill latency
- decode latency
- TTFT
- output tokens/s
- active KV cache size
- batch size 分布
只有这样,你才能知道瓶颈到底在输入阶段、生成阶段,还是调度阶段。
一个可执行的优化路线图
如果你现在手上已经有一个“能跑但不够快”的服务,我建议这样推进:
场景 A:单卡显存紧张
优先级:
- 4bit/8bit 量化
- 限制上下文长度
- 控制
max_new_tokens - 再考虑更高效的推理框架
场景 B:单请求生成太慢
优先级:
- 确认
use_cache=True - 检查长输出场景收益
- 看是否能切到更高效 attention 内核
- 评估 speculative decoding 等更高级策略
场景 C:并发上来后吞吐掉太多
优先级:
- 动态批处理
- continuous batching
- 长度分桶
- 更好的 KV Cache 管理策略
场景 D:用户抱怨“首字太慢”
优先级:
- 缩短 batching window
- 优化 prefill
- 控制超长 prompt
- 做 prompt cache 或模板复用
总结
大模型推理优化,最容易犯的错就是:
只盯一个点,而忽略整个链路。
这篇文章的核心结论其实很朴素:
- 量化解决“模型太大、资源太重”
- KV Cache解决“生成阶段重复计算”
- 并发调度解决“服务化后 GPU 利用率不高”
你可以把它们理解成三个层次:
- 先让模型跑起来
- 再让单请求跑快
- 最后让整体服务跑稳、跑满
如果你问我最实用的建议是什么,我会给这三条:
- 先量指标,再谈优化
- 先找主瓶颈,再选手段
- 吞吐、延迟、显存,三者一定要一起权衡
最后给一个很接地气的边界条件判断:
- 如果你还是在单机实验阶段,先把量化 + KV Cache吃透
- 如果你已经准备上线,尽快引入成熟推理框架 + 调度监控
- 如果你在做高并发生产服务,重点就不再是“会不会开参数”,而是缓存管理、调度策略和容量规划
推理优化这件事,没有一招鲜,但只要你按“量化 → KV Cache → 并发调度”这条路径一步步压实,性能提升通常是看得见的。