AB
AiBoss
チュートリアル

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。这节省了计算量,但也带来了额外的显存需求。

两个阶段的差异可以整理成一张对照表:

特征PrefillDecode
目的处理输入提示生成输出 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)
ITLtoken 间延迟(Inter-Token Latency)
吞吐量每秒处理的 token 数或请求数
端到端延迟完成一个请求的总时间
GPU 利用率加速器资源被有效使用的程度
每 token 成本服务模型输入或输出的成本

TTFT 衡量用户在看到第一个输出 token 之前等待了多久,它包含排队、预填充和最初的解码。TPOT 衡量第一个 token 之后各输出 token 之间的平均间隔。吞吐量衡量服务系统在单位时间内完成的工作量:一个同时服务大量用户的模型,即使单个用户拿到 token 的速度较慢,也可能实现很高的总体吞吐。

生产环境的基准测试还应当测量 p95 和 p99 延迟,它们能暴露被平均值掩盖的慢请求。

第五步:逐项应用优化手段

不同的优化针对不同的瓶颈,有的降低显存占用,有的改善计算效率或 GPU 利用率。

1. 量化(Quantization)

量化用更低的数值精度表示模型权重或激活值。以一个 80 亿参数的模型为例,权重的粗略存储需求如下:

精度存储
FP3232 GB
FP16 / BF1616 GB
INT88 GB
4-bit4 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 生成,解码阶段常受显存带宽限制。
  • 本文涉及的框架功能、支持的模型、默认参数与硬件兼容性会随版本变化,请以各项目官网当前信息为准。