SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

Prefill与Decode:为什么首Token延迟更高?

Prefill受计算能力限制,决定首Token延迟;Decode受内存带宽限制,决定每秒Token数。请在您的设备上分别测量两项指标。

Prefill 与 decode:用一段话说明

Prefill 与 decode 的区别,可以解释自托管 LLM(大语言模型)的大多数延迟问题。Prefill 在一次处理中读取完整提示词,受计算能力限制。Decode 一次生成一个 token,受内存带宽限制。首 token 延迟属于 prefill 指标。每秒生成的 token 数属于 decode 指标。

两个阶段使用同一块 GPU(图形处理器)、同一组权重,并在同一进程中运行,因此将它们视为一个工作负载很自然。实际上,它们更像是在同一设备上共享资源的两个不同程序。将两者区分开后,许多看似矛盾的结果就不再难以解释。

为什么 prefill 受计算能力限制?

Prefill 会让完整提示词依次通过每一层。2,000 token 的提示词会为每次矩阵乘法提供 2,000 行计算任务,因此 GPU 每加载一个字节的权重,就要执行大量算术运算。单位数据移动量对应的算术运算量称为算术强度,prefill 的算术强度较高。设备会接近计算能力上限,而内存总线仍有余量。

Prefill 会生成两项内容:提示词中每个 token 对应的 KV cache(key 和 value 张量),以及第一个输出 token。在这次处理完成前,读取端不会收到任何内容。因此,prefill 时间和首 token 时间(TTFT)通常接近同一个指标。

Prefill 的开销会随提示词长度增长。线性部分是每层的矩阵计算。二次部分是 attention,因为每个 token 都要关注此前的每个 token;在上下文较长时,这部分开销会开始显著增加。因此,将提示词长度加倍后,TTFT 至少也会加倍。

您可以在一分钟内观察到这一点。向服务器发送一个 200 token 的提示词,再发送一个 2,000 token 的提示词,并让两次请求生成相同数量的输出 token。TTFT 会明显上升。首 token 之后的流式输出速度几乎不变。

为什么解码受内存带宽限制?

解码每一步只生成一个 token。要生成这一个 token,GPU 必须从内存中读取模型的全部权重,让每个权重参与几次运算,然后丢弃它。算术强度接近 1,因此计算单元大部分时间都在等待。

解码速度慢,是因为每个 token 都需要从内存中读取整个模型。因此,内存总线决定处理速度,而计算单元处于空闲状态。

这意味着,单流解码速度的上限可以直接通过算术计算得出。将内存带宽除以权重占用的字节数即可。

ChartPublished memory bandwidth and the decode ceiling it implies for a 16 GB model
The data behind this chart
[
  {
    "device": "CPU, dual channel DDR5-5600",
    "mem_bandwidth_gb_s": 90,
    "decode_ceiling_tok_s": 6
  },
  {
    "device": "NVIDIA A10G",
    "mem_bandwidth_gb_s": 600,
    "decode_ceiling_tok_s": 38
  },
  {
    "device": "NVIDIA L40S",
    "mem_bandwidth_gb_s": 864,
    "decode_ceiling_tok_s": 54
  },
  {
    "device": "NVIDIA RTX 4090",
    "mem_bandwidth_gb_s": 1008,
    "decode_ceiling_tok_s": 63
  },
  {
    "device": "NVIDIA A100 80GB SXM",
    "mem_bandwidth_gb_s": 2039,
    "decode_ceiling_tok_s": 127
  },
  {
    "device": "NVIDIA H100 SXM",
    "mem_bandwidth_gb_s": 3350,
    "decode_ceiling_tok_s": 209
  }
]

带宽列使用各厂商公布的规格值。上限列是该规格值除以 16 GB,后者是以 16 bit 精度存储的 80 亿参数模型的大小。这只是算术结果,不是基准测试结果。实测速率会低于该上限。了解实际值低多少很有用,因为这能告诉您应该调整推理服务栈,还是升级硬件。

按顺序阅读 6 行,规律很明显。采用双通道 DDR5 的 CPU 带宽约为 90 GB/s,因此该模型的解码速度上限约为 6 tokens/s。L40S 的上限约为 54。H100 SXM 公布的带宽为 3350 GB/s,其上限约为 209

这也是量化对解码速度提升最大的单一因素。将同一个模型从 16 bit 改为 8 bit 存储后,每个 token 需要读取的字节数会减半,因此上限大致会翻倍。计算量没有增加。需要读取的内存减少了。

如何在自己的服务器上测量每个阶段?

Ollama 会在响应正文中返回拆分结果。请求一次非流式补全,然后读取计数器。

curl -s http://localhost:11434/api/generate -d '{
  "model": "llama3.2",
  "prompt": "Explain memory bandwidth in two sentences.",
  "stream": false
}' | jq '{prompt_eval_count, prompt_eval_duration, eval_count, eval_duration}'

使用你实际已拉取的模型标签,ollama list 会显示可用标签。prompt_eval_countprompt_eval_duration 是预填充阶段的数据:提示词 token 数量及其耗时。eval_counteval_duration 是解码阶段的数据。耗时单位为纳秒,因此解码速度为 eval_count / eval_duration * 1e9,预填充速度为 prompt_eval_count / prompt_eval_duration * 1e9。对于同一个请求,预填充速率通常会远高于解码速率。本文其余内容解释的就是这两者之间的差距。

对于 vLLM 这类兼容 OpenAI 的服务器,可以使用 curl 测量首字节时间。

curl -N -s -o /dev/null \
  -w 'pretransfer %{time_pretransfer}s  first_byte %{time_starttransfer}s\n' \
  http://localhost:8000/v1/completions \
  -H 'Content-Type: application/json' \
  -d '{"model": "meta-llama/Llama-3.1-8B-Instruct", "prompt": "Explain memory bandwidth.", "max_tokens": 128, "stream": true}'

time_starttransfer 是收到正文第一个字节的时刻,因此结合 "stream": true 可得到包含连接建立时间的 TTFT。减去 time_pretransfer 即可排除连接建立耗时。运行两次并保留第二次结果,因为第一次调用可能包含模型冷加载时间。

vLLM 还会在 /metrics 上以 Prometheus 指标形式发布这组拆分数据。运行 curl -s http://localhost:8000/metrics | grep -E 'time_to_first_token|inter_token_latency',即可获得直方图 vllm:time_to_first_token_secondsvllm:inter_token_latency_seconds。添加 vllm:num_requests_runningvllm:num_requests_waiting 可查看队列深度,添加 vllm:kv_cache_usage_perc 可查看缓存压力。这 5 个名称就是完整的仪表板。

在负载下,vllm bench serve --model <name> --num-prompts 200 --request-rate 4 会驱动运行中的服务器,并按百分位数报告首 token 时间和每个输出 token 的延迟。只有这样才能观察两个阶段如何相互争用。在调整任何参数之前,先建立干净的基线:测量本地 LLM 的每秒 token 数中的方法可提供一个重启后仍然有效的基线。

为什么较长的系统提示会延迟第一个 token,却不会影响流式输出速度?

因为系统提示属于预填充工作,仅此而已。它会与提示的其余部分在同一轮处理中完成,并在第一个 token 出现前处理完毕。处理完成后,它只作为 KV cache 条目存在,解码时会与其他内容一起读取。因此,3,000 token 的系统提示会在每个请求中增加 TTFT,但几乎不会改变每秒生成的 token 数。

这里说的是“几乎”,并非完全不变。每个解码步骤都会再次读取这些额外的 KV 条目,因此很长的提示确实会略微降低解码速度。下一节会介绍这一点。

解决方法是停止重复计算相同的前缀。启用前缀缓存的服务器会保留共享前缀的 KV cache 并重复使用,因此,第二个携带相同系统提示的请求可以完全跳过这部分预填充。vLLM 将其称为 automatic prefix caching;请检查您所用版本中的 vllm serve --help,因为该默认设置在不同版本中有所变化。GPU 中的 KV cache 与 API 提供商计费所使用的提示缓存是两种不同的机制。在调整其中任何一种之前,建议先阅读KV cache 与提示缓存的区别】【。

上下文填满后,为什么解码会变慢?

原因有两个,都与 KV 缓存有关。

第一个原因是带宽。在每个解码步骤中,注意力机制都会读取所有前序 token 的键和值。权重是每个 token 的固定开销,而 KV 缓存会不断增长。您可以根据模型的 config.json 计算其大小:每个 token 的字节数等于 2 乘以 num_hidden_layers,再乘以 num_key_value_heads,再乘以头维度(hidden_size 除以 num_attention_heads),最后乘以每个元素占用的字节数。开头的 2 表示一个键和一个值。

对于一种常见的 8 billion 参数布局,它包含 32 个层、GQA(分组查询注意力)下的 8 个键值头、128 的头维度,并使用 16 bit 精度。此时计算结果为 2 x 32 x 8 x 128 x 2 = 131,072 bytes,即每个 token 约 128 KiB。因此,一次 8,000 token 的对话大约需要 1 GB 的 KV 缓存。

第二个原因是容量。这 1 GB 内存无法同时用于存放权重或其他用户的上下文。服务器会在启动时一次性确定 KV 池的大小;在 vLLM 中,该设置通过 --gpu-memory-utilization 配置。KV 池满后,新请求会等待。vllm:num_requests_waiting 持续升高,同时 vllm:kv_cache_usage_perc 接近 1,就是这种状态的准确特征。某些技术栈会暂停正在运行的请求,稍后重新计算其缓存,而不是将请求排队。对用户而言,这会表现为流式输出中途卡顿。

长上下文会带来双重成本:开始时需要执行更多预填充计算,之后生成答案的每个 token 都需要读取更多内存。

为什么批处理有助于吞吐量,却会损害尾部延迟?

由于 decode 受带宽限制,额外请求几乎不会增加计算开销。一次读取权重就可以为批次中的每个序列生成一个 token,因此总吞吐量会随着批大小近似线性增长,直到 KV 池耗尽,或批次变大到再次受计算能力限制。Continuous batching 会在每个 step 重建批次,因此已完成的请求可以退出,排队请求可以加入,无需等待其他请求完成。

代价会体现在百分位数上。现在,每个用户获取下一个 token 都要等待共享 step 中最慢的部分,因此 p50(中位数)仍可接受,但 p99(100 个请求中最慢的 1 个)会变长。用户通常能感知到 p99,因为它表现为句子中间的停顿。

Prefill 会进一步放大这一问题。较大的 prompt 在流式生成过程中到达时,会占用设备完成一个很长的 step,所有当前正在生成的请求都会出现间隔。Chunked prefill 将较长的 prompt 切分为多个片段,并将每个片段混入 decode 批次,从而消除大部分影响。截至 August 2026,vLLM V1 engine 默认启用此功能,并通过 --max-num-batched-tokens 提供平衡控制。vLLM 调优文档明确说明了这一权衡:较小的值(约 2048)可获得更好的 inter token latency (ITL),因为较少的 prefill 会打断 decode;较大的值可获得更好的 TTFT,因为单个批次可以容纳更多 prefill token。这个 flag 将 prefill 与 decode 之间的权衡暴露为一个可调数字。p99 在何处开始变得不可接受,取决于容量;一台自托管 LLM 可以服务多少并发用户会使用相同的指标分析这一问题。

为什么更大的 GPU 有时没有任何改善?

因为更大通常意味着更强的计算能力,而解码并不主要受计算能力限制。

比较上方图表中的两行数据。A100 80GB 的公开带宽为 2039 GB/s,L40S 为 864 GB/s;解码上限也完全对应,分别为 127 tokens/s 和 54。按大多数指标衡量,RTX 4090 都是一款速度很快的显卡,其 1008 GB/s 带宽将解码上限设为 63。无论两张卡在其他方面有何差异,单流解码速度都遵循规格表中的带宽水平。

因此,提高解码速度有两种方法:减少每个 token 需要读取的字节数(对权重进行量化,或运行更小的模型),或者使用带宽更高的显卡。Prefill 则相反,它需要计算能力,因此更快的显卡确实可以缩短长提示词的 TTFT。如果问题是生成第一个 token 需要 4 秒,更好的硬件可能会解决问题。如果问题是文本逐字生成得很慢,升级硬件可能无法解决。

是否应在不同 worker 上分别运行 prefill 和 decode?

大型推理服务栈确实会这样做,这种技术称为 prefill 和 decode 解耦。一个 worker 池只运行 prefill,另一个 worker 池只运行 decode。第一个池构建的 KV cache 通过高速互连传输到第二个池。这样做可行,是因为两个阶段需要不同的硬件和调度方式。Prefill 需要计算能力和较大的 token 批次。Decode 需要内存带宽和大量并发序列。将两者拆分后,每个池都可以独立扩展,也能避免一个超长 prompt 阻塞所有活动流。

在只有一块 GPU 的单台 VPS(virtual private server)上,这样做几乎从来不值得。您实际上是在让一个设备与自身分工,还会把指针传递变成数 GB cache 的网络传输。只有在加速器数量足够、可以为每个阶段分配整台机器,并且流量足够稳定、能够让两个池持续繁忙时,这项技术才有收益。在此规模以下,只需设置一个 flag,chunked prefill 就能提供大部分相同的隔离效果。

数值异常时的调整方法

TTFT 过高时:

  • 缩短提示词。预填充开销取决于提示词 token 数量,而且每次请求都要计算系统提示词。
  • 启用前缀缓存,使重复前缀只需计算一次,而不是每次都重新计算。
  • 提高 --max-num-batched-tokens,让每个 step 执行更多预填充工作。
  • 先检查队列,不要立即归咎于模型。vllm:num_requests_waiting 大于 0 表示请求尚未开始处理,问题在于容量不足。

每秒 token 数过低时:

  • 对权重进行量化。每个权重占用的字节数越少,每个 token 需要读取的字节数就越少。
  • 将显卡公布的内存带宽与上方图表进行比较,确认当前性能距离上限还有多远。
  • 降低 --max-num-batched-tokens,减少预填充对解码的中断。
  • 检查上下文长度。对话增长到数千个 token 后,每个 step 都需要读取大得多的 KV 缓存。

运行时在这里同样重要,因为 Ollama 和 vLLM 对预填充和解码的调度方式不同,某个设置可能对其中一个有效,但对另一个不起作用。先分别测量这两个阶段,再一次只修改一个设置。

FAQ

为什么第一个 token 需要等待几秒,后续 token 却能快速流式输出?

等待阶段是 prefill,流式输出阶段是 decode。prefill 会在输出产生前,通过一次受计算限制的处理遍历整个提示词,因此耗时会随提示词长度增加。随后 decode 每一步输出一个 token,速度主要由内存带宽决定,几乎不受提示词长度影响。每次请求都携带很长的系统提示词,通常是主要原因。前缀缓存可以消除这部分重复开销。

更长的提示词会降低每秒输出的 token 数吗?

会有一点影响,但原因不同于 TTFT。每个 decode 步骤都会读取此前所有 token 的 keys 和 values,因此 KV cache 越大,每个 token 需要读取的字节数越多。对于常见的 8 billion 参数布局,KV cache 约为每个 token 128 KiB,因此 8,000 token 的上下文约为 1 GB,并且每一步都会访问这部分数据。长提示词的主要影响仍然体现在 TTFT,而不是流式输出速度上。

哪项 GPU 规格最能预测 decode 速度?

内存带宽。将产品公布的带宽除以内存中权重的大小,即可得到单路流的理论计算上限。计算能力更强但带宽相同的显卡,流式输出速度不会更快。这也是量化为 8 bits 后 decode 速度大约翻倍的原因:每个 token 需要读取的字节数减半,但计算量不变。

为什么增加用户后吞吐量会上升,但每个用户感觉更慢?

读取一次权重即可为批次中的每个序列处理一个 token,因此总 tokens per second 会随批次大小增加。每个 token 现在都要等待共享步骤完成,所以单个用户的延迟也会同时上升。应监控 p99 inter token latency,而不是只看聚合吞吐量,并检查 vllm:num_requests_waiting,确认请求是在排队,而不是正在运行。