
LLM 推理性能入门:从 Prefill 与 Decode 到 KV Cache 与吞吐优化
LLM 推理性能入门:从 Prefill 与 Decode 到 KV Cache 与吞吐优化
LLM 推理是模型权重固定、逐 token 生成输出的过程,由 Prefill 与 Decode 两个阶段组成,瓶颈分别落在算力与显存带宽上。本文梳理推理流水线、KV Cache 的作用与代价、TTFT 与 TPOT 等关键指标,并逐项说明量化、FlashAttention、连续批处理、PagedAttention、张量并行与投机解码各自解决什么问题,最后给出一个可自行搭建的推理性能测量实验。
向一个已经训练好的语言模型提交问题,屏幕上会先停顿一小段时间,然后答案开始逐词出现。这段停顿和随后的逐词输出,其实是 GPU 上两种性质完全不同的工作。理解它们的区别,是让 AI 应用响应更快、同时服务更多用户、并把运行成本控制住的前提。这套过程统称为 LLM 推理(LLM inference)。本文从推理的定义讲起,拆解请求在模型内部经过的各个阶段,说明速度慢在哪里、有哪些成熟的优化手段,最后给出一个可以在自己机器上搭建的推理性能测量实验。
什么是 LLM 推理
LLM 推理是指使用一个已经训练完成的语言模型,对新的输入生成输出,过程中不更新模型权重。训练阶段模型通过调整参数从数据中学习规律;推理阶段这些参数保持固定,模型只根据输入以及此前已经生成的 token 预测下一个 token。
举一个具体例子。输入提示是「解释神经网络如何学习」,模型处理这段文本后,一个 token 一个 token 地生成回答。每一个新生成的 token 都会成为预测下一个 token 时上下文的一部分。模型在每一步计算出一个概率分布,由解码策略从中选出一个 token,如此循环,直到回答结束。
与训练不同,常规生成过程中推理不需要反向传播,也不需要梯度更新。它真正要面对的挑战是响应速度、GPU 显存占用、吞吐量以及运行成本。
准备工作
本文偏重原理与测量方法,不依赖某个特定云服务。要跟着后文的实验动手,需要准备以下几样东西。
- 一台带 GPU 的机器:本地工作站或租用的 GPU 实例均可。显存容量决定了能装下多大的模型以及多大的 KV Cache,是选择硬件时最先要看的指标。
- Python 运行环境:用于编写测量脚本、调用推理引擎的接口。
- 一个推理引擎:本地试验可以用 Ollama 或 llama.cpp,追求吞吐的生产场景通常选 vLLM,NVIDIA GPU 上还可以考虑 TensorRT-LLM,需要更复杂调度能力时可以考虑 SGLang。
- 一个可用的模型:参数量要与显存匹配。后文的量化对照表可以帮助估算权重占用的显存。
- 测量脚本:记录首 token 时间、每个输出 token 的间隔、总耗时等数据。
具体的安装方式、支持的模型列表、许可证与硬件兼容性会随版本变化,请以各项目官网的当前信息为准。
操作步骤
第一步:理解推理流水线的三个阶段
一个推理请求在用户看到最终回答之前,会依次经过若干阶段。
1. 分词(Tokenization)
输入文本被转换成数值化的 token ID。例如「Machine learning is powerful」这句话可能被切分成词或子词,具体切分方式取决于所用的分词器。这些 token ID 随后被转换为 embedding,供 transformer 处理。
2. 预填充(Prefill)
在预填充阶段,模型让输入提示完整地穿过 transformer 各层。如果提示包含 2000 个 token,模型可以在遵守因果注意力约束的前提下并行处理其中许多位置。这个阶段同时会初始化 Key-Value 缓存,用来保存后续生成 token 时需要的注意力中间表示。预填充通常涉及大规模矩阵运算,属于计算密集型。
3. 解码(Decoding)
预填充完成后,模型开始生成输出 token。与预填充不同,解码在生成位置上是有顺序的:模型必须先选定一个 token,才能知道预测下一个 token 所需的完整上下文。在每一个解码步中,模型复用此前算好的 KV Cache,而不是对整个序列重新计算 key 和 value。这节省了计算量,但也带来了额外的显存需求。
两个阶段的差异可以整理成一张对照表:
| 特征 | Prefill | Decode |
|---|---|---|
| 目的 | 处理输入提示 | 生成输出 token |
| 处理方式 | 在提示的各个位置上并行 | 在输出位置上串行 |
| 常见瓶颈 | 计算 | 显存带宽 |
| 重要指标 | 预填充耗时、TTFT | 每个输出 token 的耗时 |
| 最相关的场景 | 长文档、RAG | 聊天机器人、代码生成 |
预填充和解码在 GPU 硬件上的行为差异很大,这也是现代推理引擎越来越倾向于对两个阶段分别优化的原因。
第二步:理解模型如何记住之前的 token
设想模型正在生成「The capital of France is Paris」这句话。如果没有缓存,模型每生成一个新 token,都要重新计算此前所有 token 的表示,这会浪费大量计算。
KV Cache 通过保存此前算好的注意力 key 和 value 来解决这个问题。在 transformer 注意力中,每个 token 由三个投影表示:
- Query(Q):当前需要什么信息?
- Key(K):有哪些信息可用?
- Value(V):应该取回哪些信息?
解码时,模型复用此前 token 的 key 和 value,只为新 token 计算新的注意力信息,同时借助已有缓存访问之前的上下文。
代价是显存消耗。上下文越长,KV Cache 越大;同时服务多个用户,显存需求会进一步叠加。因此 KV Cache 的管理成为高效 LLM 服务中最重要的环节之一。
第三步:定位推理慢在哪里
模型的响应时间并不只由参数量决定。推理性能取决于 GPU 计算能力、显存带宽、可用显存、上下文长度,以及请求调度效率。
计算瓶颈:预填充阶段 GPU 执行大规模矩阵乘法,这一阶段的性能往往取决于可用的计算能力和高效的算子实现。
显存瓶颈:解码阶段系统反复访问模型权重和 KV Cache 内容。在小批量情况下,把这些数据在显存中搬运所花的时间,可能比数学运算本身更长。这就是为什么计算能力更强的 GPU 不一定能带来成比例更快的 token 生成速度。
长上下文:更长的提示需要更多注意力处理,也会产生大得多的 KV Cache。对经常处理数千输入 token 的 RAG 应用、文档分析系统和编码助手来说,这一点尤其关键。
并发请求:单用户负载与同时服务数百名用户是完全不同的两件事。推理引擎必须调度请求、管理缓存、分配显存,并避免某些请求过度拖慢其他请求。优化这一过程正是专用推理引擎存在的主要理由。
第四步:建立性能指标口径
优化之前先要能测量。常用的指标如下:
| 指标 | 含义 |
|---|---|
| TTFT | 首 token 时间(Time to First Token) |
| TPOT | 每个输出 token 的耗时(Time per Output Token) |
| ITL | token 间延迟(Inter-Token Latency) |
| 吞吐量 | 每秒处理的 token 数或请求数 |
| 端到端延迟 | 完成一个请求的总时间 |
| GPU 利用率 | 加速器资源被有效使用的程度 |
| 每 token 成本 | 服务模型输入或输出的成本 |
TTFT 衡量用户在看到第一个输出 token 之前等待了多久,它包含排队、预填充和最初的解码。TPOT 衡量第一个 token 之后各输出 token 之间的平均间隔。吞吐量衡量服务系统在单位时间内完成的工作量:一个同时服务大量用户的模型,即使单个用户拿到 token 的速度较慢,也可能实现很高的总体吞吐。
生产环境的基准测试还应当测量 p95 和 p99 延迟,它们能暴露被平均值掩盖的慢请求。
第五步:逐项应用优化手段
不同的优化针对不同的瓶颈,有的降低显存占用,有的改善计算效率或 GPU 利用率。
1. 量化(Quantization)
量化用更低的数值精度表示模型权重或激活值。以一个 80 亿参数的模型为例,权重的粗略存储需求如下:
| 精度 | 存储 |
|---|---|
| FP32 | 32 GB |
| FP16 / BF16 | 16 GB |
| INT8 | 8 GB |
| 4-bit | 4 GB |
这些数字不包含元数据、缓存和运行时开销。降低精度可以让更大的模型装进更小的硬件配置,也能降低显存带宽需求。但精度下降可能影响准确率,实际能带来多少速度提升取决于硬件和具体实现。
2. FlashAttention
标准注意力计算会在 GPU 显存与片上存储之间搬运大量中间张量。FlashAttention 通过分块处理注意力计算、减少昂贵的显存读写来提升效率。它不在高带宽显存中完整物化注意力矩阵,而是采用分块(tiling)和精心管理的中间计算。这让注意力对长序列更加显存友好,同时在浮点数值误差范围内保持精确的注意力结果。
3. 连续批处理(Continuous batching)
传统的固定批处理把请求成组后作为一个批次处理。问题在于各请求生成的 token 数量不同,有的早早结束,有的还在继续。连续批处理允许新请求在有容量空出时加入执行。它帮助 GPU 保持忙碌,可以提升整体服务吞吐。
4. PagedAttention
当 KV Cache 以大片连续区域预留时,显存利用会变得低效。PagedAttention 把缓存切分成更小的块,通过映射机制管理。这减少了显存碎片,让缓存容量得到更充分的利用。该技术与 vLLM 关系密切,帮助引擎应对大量并发请求。
5. 张量并行(Tensor parallelism)
有些模型大到一块 GPU 装不下。张量并行把模型张量计算的一部分分散到多块 GPU 上。例如一个大型线性层可以被切分,让多块 GPU 各自计算结果的一部分。它让更大的模型得以跨多个加速器运行,但会引入通信开销。由于 GPU 可能在推理过程中的多个节点交换中间结果,高速互连变得很重要。
6. 投机解码(Speculative decoding)
普通解码一个接一个地生成 token。投机解码先用一个更快的草稿机制提出多个候选 token,再由目标模型进行验证。当候选被接受时,生成可以推进得更快。这可以降低延迟,但改善幅度取决于接受率和验证开销。
上述技术,连同分块预填充(chunked prefill)和前缀缓存(prefix caching),在现代推理引擎中都有支持。具体支持哪些特性、默认是否开启,请以所用引擎官网的当前信息为准。
第六步:评估硬件需求
推理性能同时取决于硬件和软件。GPU 的计算能力很重要,显存容量和显存带宽同样重要。
- 显存容量决定了多少模型数据和 KV Cache 能留在加速器上。
- 显存带宽影响 GPU 访问模型权重和缓存注意力状态的速度。
- 计算性能影响矩阵乘法等运算的执行速度。
- GPU 互连带宽在模型被拆分到多个加速器上时变得重要。
这也解释了为什么模型服务架构不能只看 GPU 标称的计算规格。单用户、短提示的负载可能在配置普通的硬件上就跑得不错;高并发、长上下文的应用则可能需要多块加速器和专门的推理软件。
第七步:选择合适的推理引擎
有若干框架可以帮助开发者部署和优化语言模型,各自的定位不同:
| 框架 | 主要用途 |
|---|---|
| Ollama | 本地推理与实验 |
| llama.cpp | 轻量级与边缘部署 |
| vLLM | 高吞吐生产服务 |
| TensorRT-LLM | 面向 NVIDIA GPU 优化的推理 |
| SGLang | 进阶的模型服务与调度 |
Ollama 适合本地试验,vLLM 面向高效服务,具备连续批处理等特性。各框架的功能范围、支持的模型和硬件要求变化较快,选型时请以官网当前信息为准。
一个完整示例:搭建推理性能测量实验
下面给出一个最小可用的测量流程,目标是在自己的机器上量化 TTFT、TPOT 和吞吐量。脚本用 Python 编写,通过 HTTP 调用本地推理引擎的接口,因此不依赖某个特定引擎的 SDK。
第一步:启动推理引擎
以 vLLM 为例,在命令行中启动一个兼容 OpenAI 接口的服务。模型名称和端口按自己的环境替换:
python -m vllm.entrypoints.openai.api_server \
--model <your-model> \
--port 8000
如果只是做本地试验,也可以用 Ollama 拉取并运行一个模型,再通过它提供的接口访问。启动参数与默认值请以对应项目官网的当前信息为准。
第二步:编写测量脚本
脚本的核心是记录三个时间点:请求发出、第一个 token 到达、最后一个 token 到达。流式响应让第一个 token 的到达时间可以被单独观测。
import json
import time
import urllib.request
URL = "http://localhost:8000/v1/chat/completions"
PROMPT = "Explain how a neural network learns."
def run_once(prompt):
body = json.dumps({
"model": "<your-model>",
"messages": [{"role": "user", "content": prompt}],
"stream": True,
"max_tokens": 256,
}).encode("utf-8")
req = urllib.request.Request(
URL, data=body,
headers={"Content-Type": "application/json"},
)
start = time.perf_counter()
first = None
tokens = 0
with urllib.request.urlopen(req) as resp:
for raw in resp:
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
payload = line[len("data:"):].strip()
if payload == "[DONE]":
break
chunk = json.loads(payload)
delta = chunk["choices"][0].get("delta", {})
if delta.get("content"):
tokens += 1
if first is None:
first = time.perf_counter()
end = time.perf_counter()
ttft = (first - start) if first else float("nan")
tpot = ((end - first) / (tokens - 1)) if tokens > 1 else float("nan")
return {
"ttft_s": ttft,
"tpot_s": tpot,
"tokens": tokens,
"total_s": end - start,
"tokens_per_s": tokens / (end - start),
}
if __name__ == "__main__":
for i in range(5):
print(run_once(PROMPT))
第三步:解读结果
脚本输出的 ttft_s 对应首 token 时间,主要反映排队与预填充的开销;tpot_s 是第一个 token 之后每个 token 的平均间隔,主要反映解码阶段的显存带宽压力;tokens_per_s 是单请求视角下的生成速度。注意这里的 token 计数来自流式返回的增量块,与实际分词器切出的 token 数可能存在差异,做横向对比时应保持统计口径一致。
第四步:做对照实验
单次测量说明不了太多问题。可以按下面的方式做几组对照,观察各因素对指标的影响:
- 固定其他条件,只改变提示长度,观察 TTFT 如何变化,以及长提示对 KV Cache 占用的影响。
- 固定其他条件,只改变输出长度,观察 TPOT 是否稳定。
- 从单请求改为并发发起多个请求,观察总吞吐与单个请求延迟之间的取舍。
- 在硬件允许的前提下,对比不同量化精度下的显存占用与生成速度。
每组实验重复多次,记录平均值以及 p95、p99 分位数,避免被个别慢请求误导。
生产环境的推理架构与最佳实践
把推理从实验推进到生产,需要把前面这些概念落到架构上。一个典型的服务系统要处理请求排队、批处理调度、KV Cache 分配、多副本负载均衡以及监控告警。结合前文的原理,可以归纳出几条实践方向。
- 按阶段分别优化:预填充是计算密集型,解码是显存带宽密集型,两者需要不同的调优手段。长文档与 RAG 场景要重点压 TTFT,聊天与代码生成场景要重点压 TPOT。
- 把 KV Cache 当作一等资源管理:上下文长度和并发数共同决定缓存规模,缓存管理策略直接决定系统能同时服务多少用户。
- 用分位数而不是平均值做验收:p95 和 p99 延迟更能反映真实用户遇到的慢请求。
- 关注每 token 成本:吞吐、硬件利用率和成本是同一件事的不同侧面,只盯速度容易忽略经济性。
- 先测量再优化:每引入一项优化(量化、连续批处理、张量并行等),都用同一套指标口径复测,确认收益来自预期中的瓶颈。
注意事项
- 量化带来的速度提升并不保证,取决于硬件与实现;精度下降对准确率的影响需要在自己的任务上验证。
- 张量并行会引入 GPU 之间的通信开销,互连带宽不足时收益可能被抵消。
- 投机解码的收益取决于草稿 token 的接受率和验证开销,接受率低时可能得不偿失。
- KV Cache 随上下文长度增长,长上下文与高并发叠加时显存压力最大,需要提前规划容量。
- 计算能力更强的 GPU 不一定带来成比例更快的 token 生成,解码阶段常受显存带宽限制。
- 本文涉及的框架功能、支持的模型、默认参数与硬件兼容性会随版本变化,请以各项目官网当前信息为准。