大模型不是单纯算得不够快,而是在一次次受限于历史上下文、显存容量与调度方式

先别急着怪 GPU:慢的根源是生成方式本身

很多人第一次部署大模型时,都会有一个近乎相同的疑问。

模型明明已经加载进显存了。

GPU 利用率看起来也不低。

为什么用户还是觉得它一个字一个字往外蹦,像在“打字”而不是“说话”。

答案的第一层并不神秘。

大语言模型大多数采用的是自回归生成autoregressive generationautoregressive generationAutoregressive generation predicts the next token conditioned on all previously generated tokens, so the model advances one token at a time instead of producing the whole answer in one pass.

所谓自回归,意思是模型只能先预测下一个 token,再把这个 token 接回上下文里,继续预测下一个。

它不是把整段回答一次性算出来。

而是按时间顺序不断往前推进。

这就决定了推理阶段天然带有串行瓶颈。

训练时我们可以把整段序列并行送进模型。

推理时却不能把未来 token 提前知道。

因此 decode 阶段总有一部分计算必须逐步发生。

逐 token 生成逐 token 生成逐 token 生成不是实现上的保守选择,而是自回归目标函数带来的约束。模型在时间步 t 的输出,必须依赖 t 之前已经确定的 token。

如果把这件事想清楚,很多后续优化策略就都有了位置。

PagedAttention、Continuous Batching、Speculative Decoding、KV Cache、量化、LoRA 合并,本质上都在绕着这个串行瓶颈做文章。

推理慢在哪里:不是所有阶段都一样慢

把一次大模型请求拆开看,至少可以分成两个阶段。

第一个阶段是把用户提示词整段吞进去。

第二个阶段是把回答一个 token 一个 token 生成出来。

这两个阶段常被称为 prefill 和 decode。

prefillprefillPrefill is the phase that encodes the entire input prompt and fills the attention cache for every layer before generation starts. It is usually compute-heavy and highly parallelizable across prompt tokens.

prefill 的特点是输入长,但并行度高。

比如用户一次发来 2000 个 token 的上下文。

模型会对这 2000 个 token 做一次前向计算。

虽然总算力消耗不小,但这一步可以沿着序列维度和矩阵乘法高度并行。

decode 则相反。

每一步只新增 1 个 token。

单步计算量看起来没那么大。

可它必须做很多轮。

而且每一轮都要等待上一轮采样结束。

所以用户感受到的“输出速度”,大多数时候来自 decode,而不是 prefill。

这也是为什么有时你会看到一种现象。

首字出来前等了很久。

一旦开始输出,后面反而稳定了。

那通常意味着 prefill 比较重。

反过来,如果首字很快,但每秒吐字不多,则 decode 才是主要瓶颈。

为什么不做 KV Cache 就会更慢

如果没有缓存,模型在生成第 200 个 token 时,需要重新为前 199 个 token 计算每一层注意力里的 Key 和 Value。

到了第 201 个 token,又要把前 200 个 token 再算一遍。

这当然不可接受。

因此实际部署里几乎都会开启KV 缓存KV cacheKV cacheKV cache stores attention keys and values from previous tokens so later decode steps reuse them instead of recomputing the entire prefix.

KV Cache 的本质并不复杂。

在每一层自注意力里,历史 token 的 Key 和 Value 一旦算完,对后续时刻来说就不会再变。

因为因果掩码决定了过去只能被未来读取,未来不能反过来修改过去。

所以我们可以把每层历史 K/V 存下来。

下一轮 decode 时,只计算新 token 的 Query、Key、Value。

然后把新的 K/V 追加进缓存。

注意力计算再直接读取“历史缓存 + 当前 token”即可。

这意味着每一步 decode 不需要重算整段前缀的 K/V。

它只需要消费越来越长的缓存。

因此 compute 省下来了,但 memory 占用了越来越多。

这就是推理优化里最常见的交换。

用显存换时间。

KV Cache 到底缓存了什么
缓存的不是最终 logits,也不是整层 hidden states 的完整副本。 缓存的是每一层注意力模块里投影后的 Key 和 Value 张量。 它们通常按层、按头、按序列长度组织,典型形状接近 `[batch, num_heads, seq_len, head_dim]`。 序列越长,batch 越大,层数越多,缓存就越占显存。 所以长上下文和高并发常常先把显存打满,而不是先把矩阵乘法算力吃满。

显存、batch size、上下文长度是一组三角关系

部署时经常有人只问一句话。

“这张卡最多能跑多大 batch。”

这个问题本身不完整。

因为 batch size 不是单独存在的参数。

它至少和模型权重、上下文长度、KV Cache 大小一起耦合。

同一张 24GB 显卡。

当你跑 7B 模型、4K 上下文、batch 8 时,也许还能稳定工作。

换成 14B 模型或者 16K 上下文,batch 可能立刻要降到 1 或 2。

根本原因就在于显存主要被三类东西占用。

  • 模型权重。
  • 中间激活与工作区。
  • 历史 K/V 缓存。

权重像固定成本。

模型一加载就先占一块。

中间激活在 prefill 更明显。

KV Cache 在长会话和高并发时更明显。

因此真正的约束不是“GPU 算不动”。

而是“你在这块显存上最多能同时保留多少请求的历史状态”。

vLLM 的 --gpu-memory-utilization 之所以重要,也是在控制这块预算。

官方文档里说明它默认会占用大约 0.92 的 GPU 显存预算给执行器与 KV Cache。

如果一味把它调满,短测可能更快。

上线后却更容易因为请求形状波动而 OOM。

prefill 更像吞吐问题,decode 更像延迟问题

理解 prefill 和 decode 的差异,对调参极其关键。

prefill 时,模型一次要处理整段 prompt。

矩阵乘法规模大,GPU 很容易被喂饱。

这阶段通常更偏向算力密集。

如果你做长文档 RAG,TTFT 也就是首 token 时间,常常主要受 prefill 影响。

decode 时,每轮只新增一个 token。

GPU 不一定总能满载。

系统反而更容易被调度开销、缓存访问、采样逻辑和请求切换影响。

这阶段更偏向时延敏感。

所以同一个系统里,经常会看到两类看似矛盾的现象。

吞吐测试下 TPS 很高。

单用户体验里 TPOT 却不够低。

或者单用户首字很快。

一上并发以后 TTFT 大幅抖动。

这不是指标冲突。

而是 prefill 和 decode 的主导瓶颈不同。

transformers 里最直接的 KV Cache 观察方式

如果只是想在本地理解 KV Cache,最直接的方法不是先上大规模引擎。

而是先看 transformers 的基本调用。

下面这个例子可以直接跑。

它用的是 Hugging Face transformers,并显式拿到 past_key_values

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

model_id = "Qwen/Qwen2.5-1.5B-Instruct"

tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    torch_dtype="auto",
    device_map="auto",
)
model.eval()

prompt = "请用三句话解释为什么大模型推理通常比训练更像串行过程。"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)

with torch.no_grad():
    first = model(**inputs, use_cache=True)
    next_token = first.logits[:, -1:].argmax(dim=-1)
    past_key_values = first.past_key_values

    second = model(
        input_ids=next_token,
        past_key_values=past_key_values,
        use_cache=True,
    )

print("prefill logits shape:", first.logits.shape)
print("decode logits shape:", second.logits.shape)
print("layers in cache:", len(past_key_values))
print("new token id:", next_token.item())

这段代码最值得看的不是输出内容。

而是调用形式。

第一次前向传了完整 prompt。

第二次只传了新 token,加上 past_key_values

这就是 decode 阶段“只算增量”的核心思路。

如果你把 use_cache=False,模型照样能生成。

只是每一步都会更笨地重复历史计算。

用 generate 也能测出 prefill 和 decode 的体感差异

很多人平时不会手写两段前向。

更常见的是直接 generate()

下面给一个更贴近业务脚本的版本。

import time
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

model_id = "Qwen/Qwen2.5-1.5B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
model.eval()

prompt = "请解释 prefill 和 decode 的差异,并说明 KV cache 为什么能降低重复计算。"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)

torch.cuda.synchronize()
t0 = time.perf_counter()
outputs = model.generate(
    **inputs,
    max_new_tokens=128,
    do_sample=False,
    use_cache=True,
)
torch.cuda.synchronize()
t1 = time.perf_counter()

text = tokenizer.decode(outputs[0], skip_special_tokens=True)
new_tokens = outputs.shape[1] - inputs["input_ids"].shape[1]

print(text)
print(f"generated_tokens={new_tokens}")
print(f"elapsed={t1 - t0:.3f}s")
print(f"avg_tpot={(t1 - t0) / max(new_tokens, 1):.4f}s")

这个脚本并没有严格拆出 TTFT。

但它足够让你感受到两个事实。

第一,prompt 越长,总时间通常先涨在 prefill。

第二,max_new_tokens 越大,平均输出速度最终取决于 decode。

vLLM 为什么常常比原生 generate 更稳

在单请求脚本里,transformers.generate() 已经能工作。

但只要一进入在线服务,问题就变了。

你不再只有一个请求。

而是有大量不同长度、不同输出上限、不同到达时间的请求混在一起。

这时候单纯让每个请求各跑各的,GPU 很快会被碎片化使用。

vLLM 的价值就在这里。

它把 KV Cache 做成更适合服务场景的内存管理与调度体系。

并通过 PagedAttention、连续批处理和更细粒度的显存预算管理,提高在线吞吐。

下面是一个可直接运行的离线推理示例。

from vllm import LLM, SamplingParams

llm = LLM(
    model="Qwen/Qwen2.5-1.5B-Instruct",
    max_model_len=4096,
    gpu_memory_utilization=0.90,
)

sampling_params = SamplingParams(
    temperature=0.0,
    max_tokens=128,
)

prompts = [
    "一句话解释 KV cache。",
    "用三点说明 prefill 和 decode 的差异。",
]

outputs = llm.generate(prompts, sampling_params)

for output in outputs:
    print(output.prompt)
    print(output.outputs[0].text)
    print("-" * 40)

如果要直接起 OpenAI 兼容服务,命令也很直接。

vllm serve Qwen/Qwen2.5-1.5B-Instruct \
  --host 0.0.0.0 \
  --port 8000 \
  --max-model-len 4096 \
  --gpu-memory-utilization 0.90

之后就可以用标准 Chat Completions 调它。

curl http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Qwen/Qwen2.5-1.5B-Instruct",
    "messages": [
      {"role": "system", "content": "You are a helpful assistant."},
      {"role": "user", "content": "请解释为什么 decode 阶段更像串行瓶颈。"}
    ],
    "temperature": 0,
    "max_tokens": 128
  }'

为什么加 batch 不一定一定更快

刚接触部署时,一个常见误判是把训练阶段的经验直接搬过来。

训练里 batch 大,吞吐通常更高。

推理里也许对,但只对了一半。

prefill 阶段增大 batch,往往确实更容易提升吞吐。

decode 阶段却不是线性增长。

因为每个请求的历史长度不同。

每个请求何时结束也不同。

如果引擎没有足够好的调度,就会出现一部分样本在等另一部分样本。

此外 batch 变大还会直接推高 KV Cache 占用。

当显存达到临界点,系统不是平滑变慢。

而是突然 OOM、抖动或者被迫降批。

所以线上最有效的经验通常不是“把 batch 调到最大”。

而是找到在 TTFT、TPOT 和稳定性之间都能接受的工作点。

真正影响用户体验的,常常不是平均吞吐

工程里最容易被误用的指标是平均值。

如果你只看平均 tokens/s,很多系统都会显得还不错。

但用户真正感受到的是首字有没有等太久。

输出过程中有没有突然停顿。

高峰时会不会大量超时。

因此推理优化不能只盯模型本身。

还要把请求形状、上下文长度分布、并发模式一起带进来。

一个对短 prompt 非常友好的配置,碰到长文档 RAG 可能立刻失效。

一个在压测里 TPS 很高的参数集,真实聊天场景下也可能因为 TTFT 太慢而不可用。

本篇要点

  • 大模型推理慢的第一原因是自回归生成导致 decode 阶段天然串行。
  • KV Cache 的本质是缓存历史 token 的 Key 和 Value,避免每次重复计算整个前缀。
  • 显存预算同时受模型权重、激活和 KV Cache 影响,batch size 不能脱离上下文长度单独讨论。
  • prefill 更像并行算力问题,decode 更像时延与调度问题。
  • transformers 适合理解机制,vLLM 更适合在线服务里处理缓存管理和批处理调度。

下一篇

理解了“为什么慢”,下一步就该看“怎么缩”。下一篇会进入最常见也最有效的一组方法:INT8 与 INT4 量化,讨论 scale、zero-point、GPTQ、AWQ 和 bitsandbytes 分别在解决什么问题。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:推理为什么慢

本文链接:https://www.sshipanoo.com/blog/ai/inference-opt/01-推理为什么慢/

本文最后一次更新为 天前,文章中的某些内容可能已过时!