让 Agent 跑得快:LLM 推理服务
一个 Agent 工作流,最终的性能瓶颈往往不在路由逻辑、工具调用,而在 LLM 推理本身。同样的模型,同样的硬件,不同的推理框架可以带来 10 倍以上的吞吐差距。这篇文章讨论推理服务的底层机制,以及三个主流框架:vLLM、SGLang、TensorRT-LLM,各自从哪个维度突破瓶颈。 ---
作者:toy
一个 Agent 工作流,最终的性能瓶颈往往不在路由逻辑、工具调用,而在 LLM 推理本身。同样的模型,同样的硬件,不同的推理框架可以带来 10 倍以上的吞吐差距。这篇文章讨论推理服务的底层机制,以及三个主流框架:vLLM、SGLang、TensorRT-LLM,各自从哪个维度突破瓶颈。
一、推理服务的瓶颈在哪里
Prefill 和 Decode:同一模型的两种性格
LLM 推理分两个阶段,它们的性能特征截然不同。
第一阶段叫 Prefill,处理用户输入的全部 token,并行完成注意力计算,产出第一个生成 token。这是计算密集型操作,GPU 矩阵乘法吃满,利用率高,瓶颈在算力(FLOPS)。用户感受到的指标是 TTFT(Time To First Token,首 token 延迟)。
第二阶段叫 Decode,以自回归方式逐个生成后续 token,每步只生成一个 token。这是内存密集型操作,GPU 要从 HBM 里把整个模型权重和 KV Cache 搬出来,做的是一次向量乘矩阵,算力大部分空着等内存。瓶颈在内存带宽(Memory Bandwidth),用户感受到的指标是 ITL(Inter-Token Latency,词间延迟)。
两个阶段的根本性差异是很多工程决策的基础。优化 TTFT 和优化 ITL 需要完全不同的手段。
为什么 Prefill 和 Decode 的性格如此不同?核心在于矩阵运算的类型。Prefill 阶段处理的是 [batch_size × seq_len × hidden_dim] 的矩阵,做矩阵乘矩阵(GEMM),这是 GPU 最擅长的操作,Tensor Core 全力参与,FLOPS 利用率接近理论上限。Decode 阶段每次只处理 [batch_size × 1 × hidden_dim],做向量乘矩阵(GEMV),并行度低,Tensor Core 利用率极低,瓶颈转移到从 HBM 加载权重的带宽。这两种操作在同一张 GPU 上呈现出完全不同的性能曲线。
KV Cache:为什么不需要每次重算
在 Decode 阶段,每步生成新 token 时,理论上需要对整个序列重新计算自注意力,这是平方复杂度,1000 个 token 的输出代价是天文数字。KV Cache 解决这个问题:在 Prefill 阶段将每一层 Transformer 的 Key 矩阵和 Value 矩阵保存到 GPU 内存中,Decode 阶段每生成一个新 token,只需计算这个新 token 的 Query,与已缓存的 K/V 做注意力,然后把新 token 的 K/V 追加进缓存。
这将复杂度从 O(n²) 降到 O(n),代价是显存占用随序列长度线性增长。一个 13B 参数的模型,每个 token 的 KV Cache 大约消耗 1MB 显存。4K 上下文长度需要约 4GB VRAM 只用于 KV Cache。显存成了新的瓶颈。
KV Cache 的大小可以精确计算。对于一个 L 层、H 个 KV 头、head_dim 维度的模型,每个 token 的 KV Cache 占用为:2 × L × H × head_dim × dtype_size 字节(2 代表 K 和 V)。Llama-3.1-8B 有 32 层,8 个 KV 头(GQA),head_dim=128,BF16(2 bytes),每 token 约 131KB。这个数字乘以序列长度,乘以批大小,就是 KV Cache 的总显存消耗。当并发请求增加时,KV Cache 和模型权重争抢显存,这是 serving 框架优化的核心战场。
GPU 利用率为什么会欺骗你
监控面板上 GPU 利用率 90% 并不意味着效率高。Prefill 阶段 GPU 确实打满,但 Decode 阶段 GPU 在等内存,利用率可能只有 30%。平均数掩盖了阶段性差异。
Memory-bound 问题的直接体现是:同一张 H100,计算能力高达 1979 TFLOPS(FP16),但内存带宽只有 3.35 TB/s。Llama-3.1-70B 参数量约 140GB(FP16),每次 Decode 步骤需要将整个权重从 HBM 搬到计算单元。理论上限每秒约 23 个 Decode 步骤,无论显卡算力有多强,带宽是天花板。
提高批处理大小是打破 memory-bound 的经典手段:多个请求共享同一次权重读取,摊薄 IO 成本,让更多算力有机会参与计算。但批处理越大,每个请求的排队延迟也越高。
衡量推理效率有一个更准确的指标:算法强度(Arithmetic Intensity),单位是 FLOP/Byte。计算这个值的方法是:模型一次 forward pass 的总浮点运算量除以从 HBM 读取的总字节数。Decode 阶段 Arithmetic Intensity 约为 batch_size/2(粗略估算),H100 的最优工作点(Roofline model 的脊点)约在 90 FLOP/Byte。换句话说,batch_size 需要超过 180 才能让 H100 进入 compute-bound 区间。实际部署中达到这个批大小并不容易,尤其是交互式服务场景。
吞吐量 vs 延迟:不可避免的权衡
批处理是推理服务最核心的工程权衡。大批量提高 GPU 利用率和系统吞吐,但每个请求要等更多其他请求积累后才能开始,延迟增加。小批量或在线服务模式保证低延迟,但 GPU 大部分时间在等待,利用率低,成本高。
这不是技术问题,是业务问题。交互式聊天场景需要低 TTFT(通常要求 <500ms),容忍较低吞吐;批量评测、离线处理场景追求最大吞吐,延迟可以接受在秒级。选框架之前,先想清楚自己的业务形态。
这个权衡还有一个常被忽略的维度:输入长度的分布。如果请求的 prompt 长度差异悬殊(有的 100 token,有的 10000 token),静态批处理会让短请求等长请求完成 Prefill,浪费大量时间。连续批处理部分缓解了这个问题,但 Prefill 和 Decode 的调度冲突依然存在。实际系统中常见的解法是对请求按 prompt 长度分桶,不同桶用独立的工作线程处理,避免长短请求相互干扰。
二、vLLM:PagedAttention 重新定义 KV Cache
内存浪费从哪里来
传统 LLM serving 框架(包括 FasterTransformer、早期版本的 HuggingFace Text Generation Inference)在处理请求时需要提前为 KV Cache 预留内存。由于不知道序列最终会生成多少 token,系统通常按最大可能长度预留,这造成严重的内存碎片和浪费。
实测数据:静态/提前预留策略导致 60%–80% 的 GPU 显存被浪费。一张 80GB 的 H100,实际可用于服务的有效容量只有 16–32GB。并发请求数受此严重限制,系统吞吐上不去。
传统方案的内存浪费来自三个叠加因素。第一,按最大 max_model_len 预留(比如 8192 token),但大多数请求实际只生成几百 token;第二,必须为每个请求分配连续的内存块,GPU 内存管理不像 CPU 有 malloc/free 那样灵活,碎片无法合并;第三,长序列和短序列混合批处理时,短序列的预留空间无法给其他请求借用,只能空等。这三个因素使得利用率上不去,也限制了并发数。
PagedAttention:操作系统的分页思想移植到 GPU
vLLM 在 SOSP 2023 发表的论文中提出 PagedAttention,核心思想来自操作系统的虚拟内存分页机制。
传统方案把每个请求的 KV Cache 存储为连续的内存块,必须提前分配最大可能大小。PagedAttention 将 KV Cache 切分为固定大小的逻辑块(Block),每个 Block 默认存储 16 个 token 的 K/V。物理内存中,这些 Block 可以不连续,只要通过块表(Block Table)维护逻辑块到物理块的映射关系就行。
Block 的内存计算公式:
block_memory = 2 × block_size × num_kv_heads × head_size × dtype_size
其中 2 代表 Key 和 Value 各一份。以 Llama-3.1-8B 为例(32 个 KV heads,128 的 head_size,BF16),每个 16-token Block 占用约 1MB 显存。
这种设计带来三个直接效果。物理块可以不连续分配,外部碎片从结构上消除。浪费只存在于每个请求最后一个未填满的 Block,最大浪费 block_size - 1 个 token 位置(15 个)。内存利用率实测从 60–80% 浪费降至不足 4%。
连续批处理:不等最慢的那个请求完成
静态批处理的问题在于一批请求必须等最长的序列完成才能释放 slot、接受下一批。短序列完成后,GPU 空等。
连续批处理(Continuous Batching,也叫迭代级调度,iteration-level scheduling)由 Orca 论文(Yu et al., 2022)提出。核心是把调度粒度从"一批请求"细化到"每个 Decode 步骤":每次完成一个 token 生成后,已完成的序列立即释放 KV Cache 并从 batch 中移除,新到达的请求立即插入。GPU 的每个 Decode 步骤都在处理尽可能多的请求。
Orca 测量显示相比已有系统最高 36.9 倍的吞吐提升。vLLM 将连续批处理与 PagedAttention 结合,成为当前业界标准 serving 范式。
理解连续批处理为什么有效,要先理解静态批处理的浪费有多严重。想象一批 8 个请求:6 个请求生成了 100 token 就结束,2 个请求需要生成 1000 token。静态批处理下,那 6 个已完成的请求的 GPU slot 要空等 900 步,利用率 20%。连续批处理下,6 个请求完成后,立即有 6 个新请求进入,GPU 持续满载。这个场景中,连续批处理可以给出 5 倍的吞吐提升,而代价几乎为零(调度器的开销相比推理计算可以忽略)。
Copy-on-Write:Beam Search 的内存优化
Beam Search 和 Parallel Sampling 需要从同一个前缀生成多条候选序列。PagedAttention 通过 Copy-on-Write(CoW)机制处理这种"序列分叉":多条候选序列共享相同的物理 KV Block,引用计数大于 1。只有当某条序列需要写入新的 token 时,才拷贝该物理块、更新块表映射,引用计数归零时物理块释放回池。
实测效果:Beam Search 和 Parallel Sampling 的内存开销降低最高 55%,吞吐最高提升 2.2 倍。
实际性能数据
vLLM 官方 benchmark(2023 年数据,对比对象是当时版本的 HuggingFace Transformers):
- 吞吐量:比 HuggingFace Transformers 高 14–24 倍
- 单输出对比 TGI:高 2.2–2.5 倍
- 3 路并行输出对比 TGI:高 3.3–3.5 倍
H100 实测(Llama-3.3-70B,FP8,512 输入/256 输出,100 并发):2,400 tokens/sec,TTFT p50 约 740ms。
OpenAI 兼容接口
vLLM 提供 OpenAI 兼容的 HTTP API,可以一行代码切换后端:
# 启动 vLLM server,暴露 OpenAI 兼容接口
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B-Instruct \
--gpu-memory-utilization 0.9 \
--enable-prefix-caching \
--port 8000
# 客户端:从 OpenAI SDK 切换到 vLLM,只改 base_url
from openai import OpenAI
client = OpenAI(
api_key="EMPTY",
base_url="http://localhost:8000/v1",
)
response = client.chat.completions.create(
model="meta-llama/Llama-3.1-8B-Instruct",
messages=[{"role": "user", "content": "解释 PagedAttention 的原理"}],
max_tokens=512,
)
print(response.choices[0].message.content)
Python API 使用示例:
from vllm import LLM, SamplingParams
# 初始化推理引擎
# gpu_memory_utilization:预留给 KV cache 的显存比例(默认 0.9)
# max_model_len:支持的最大序列长度,超出则 OOM
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
gpu_memory_utilization=0.9, # PagedAttention 动态分配此比例的显存
max_model_len=8192,
tensor_parallel_size=1, # 多卡推理时设置 TP size
)
sampling_params = SamplingParams(
temperature=0.7,
top_p=0.9,
max_tokens=512,
)
# 批量推理(continuous batching 自动处理不等长请求)
prompts = [
"解释 PagedAttention 的原理",
"静态批处理和连续批处理有什么区别?",
"KV Cache 在 Transformer 中的作用是什么?",
]
outputs = llm.generate(prompts, sampling_params)
for output in outputs:
print(f"输出: {output.outputs[0].text}\n")
三、SGLang:把上下文复用做到极致
RadixAttention:前缀共享的精确实现
vLLM 的 PagedAttention 以固定大小的 Block(16 tokens)为单位管理 KV Cache,前缀缓存也以 Block 为匹配粒度,只有完整 Block 才能被缓存和复用,部分填充的 Block 不缓存。
SGLang 在 NeurIPS 2024 论文中提出 RadixAttention,使用基数树(Radix Tree,一种压缩前缀树)管理全部并发请求的 KV Cache。基数树的每条边标签是变长 token 序列,可以精确到单个 token 粒度进行前缀匹配和共享。
具体来说:每个树节点代表一段 token 序列,KV Cache 以页为单位存储(每页 1 个 token)。逐出策略是 LRU,优先逐出最近最少使用的叶节点。正在运行的请求持有 KV 块的引用计数,防止被提前逐出。调度器实现 Cache-aware scheduling,优先调度与已缓存前缀最匹配的请求。
官方测量:cache-aware scheduling 使命中率接近最优命中率的 96%。
为什么选择 Radix Tree 而不是 Hash Table?Hash Table 的前缀匹配是精确匹配:key 必须完全一致才能命中。Radix Tree 支持最长前缀匹配(Longest Common Prefix):两个请求的前缀有 1000 个 token 完全相同、后面开始分叉,Radix Tree 可以共享这 1000 个 token 的 KV Cache,而 Hash Table 由于 key 不同(整个 prompt 不同),直接 miss。这在 Agent 工作流中尤其有价值:system prompt 加上工具定义是公共前缀,用户问题在后面分叉,完全符合 Radix Tree 的最优使用场景。
不同工作负载下的命中率
RadixAttention 在前缀高度重叠的场景下效果显著:
| 工作负载 | 典型命中率 |
|---|---|
| 多轮对话 | 70–80% |
| Few-shot(MMLU/HellaSwag) | 75–99% |
| Tree-of-Thought | 85–95% |
| RAG | 50–70% |
| Vicuna-33B 生产实测 | 74.1% |
这些数据来自 SGLang 论文(arxiv.org/html/2312.07104v2)。Agent 工作流中,系统提示词(system prompt)通常是固定的,加上工具定义可能有 2000–5000 token。RadixAttention 让这部分只计算一次,后续所有请求直接复用。
性能对比
SGLang 整体吞吐对比 vLLM 等系统最高提升 6.4 倍,单请求延迟最高降低 3.7 倍。在存在大量前缀共享的工作负载(聊天机器人、RAG、Agent)上,吞吐比 vLLM 高约 29%。
H100 实测(Llama-3.3-70B,FP8,100 并发):2,460 tokens/sec,TTFT p50 约 710ms,略优于 vLLM 的 2,400 tokens/sec 和 740ms。在前缀密集场景下差距会进一步拉大。
Compressed FSM:结构化输出的加速
LLM 生成 JSON 或其他结构化格式时,传统约束解码方案(如 Outlines)在每个 token 生成后检查合法性,处理一个 JSON 字段需要多次 forward pass。
SGLang 提出 Compressed Finite State Machine(2024 年 2 月):
- 将正则表达式编译为有限状态机(FSM)
- 压缩 FSM 中所有只有单一转移边的连续路径为单条跳转边(singular path)
- 在一次 forward pass 中生成多个 token(jump-forward decoding)
- 利用 RadixAttention 的 KV Cache 复用避免重复计算
实测效果:JSON 解码比 Outlines+vLLM 方案延迟降低最高 2 倍,吞吐提升最高 2.5 倍。re-tokenization 机制引入约 4% 计算开销,总体净收益明显。
为什么压缩 FSM 有效?考虑生成 {"name": " 这段 JSON 前缀。在 FSM 中,每个字符都是一个状态转移,这 10 个字符对应 10 个确定性单向转移。传统约束解码需要 10 次 forward pass,每次生成一个 token。Compressed FSM 把这 10 个连续单向转移压缩为一条跳转边:一次 forward pass 直接跳到"字段值开始"状态,生成所有 10 个 token。在结构化程度高的输出(大量固定键名、括号、引号)中,多数 token 处于单一转移路径,压缩收益极大。这与投机解码的加速逻辑有相似之处:减少 Target Model(大模型)的调用次数,但 Compressed FSM 是确定性的(不是概率性验证),所以不需要 rejection sampling 开销。
import sglang as sgl
from sglang import RuntimeEndpoint
import json
# 使用外部已启动的 SGLang server
# 启动命令:python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct --port 30000
runtime = RuntimeEndpoint("http://localhost:30000")
sgl.set_default_backend(runtime)
@sgl.function
def extract_entities(s, document):
s += sgl.system("You are an entity extraction assistant.")
s += sgl.user(f"Extract entities from: {document}")
# gen() 配合 regex:Compressed FSM 自动压缩 FSM,多 token 一次生成
s += sgl.assistant(
sgl.gen(
"result",
max_tokens=512,
# 正则约束,SGLang 自动转换为 Compressed FSM
regex=r'\{"persons":\s*\[.*?\],\s*"organizations":\s*\[.*?\]\}',
)
)
# 批量调用:相同 system prompt 被 RadixAttention 自动缓存复用
documents = [
"Apple CEO Tim Cook announced a partnership with Microsoft.",
"Google DeepMind researcher Demis Hassabis won the Nobel Prize.",
"Tesla and SpaceX founder Elon Musk met with Amazon CEO Andy Jassy.",
]
states = extract_entities.run_batch(
[{"document": doc} for doc in documents],
num_threads=4, # 并发请求,RadixAttention 在服务端共享 system prompt KV
)
for state in states:
try:
result = json.loads(state["result"])
print(json.dumps(result, indent=2, ensure_ascii=False))
except json.JSONDecodeError:
print(state["result"])
四、TensorRT-LLM:NVIDIA 的深度优化路线
Plugin 架构:手写 CUDA Kernel
TensorRT-LLM 不走通用推理路线,而是针对 Transformer 各个算子手写高度优化的 CUDA Kernel,并通过 Plugin 架构组合这些 Kernel。
关键优化点包括:Multi-Head Attention 的 Flash Attention 变体、FlashMLA(针对 MLA 注意力机制)、Tensor Core 对齐的矩阵分块、FP8/INT8 量化算子原生支持。代价是灵活性低,架构必须提前编译为固定的 TensorRT 引擎,无法动态修改。
这个选择背后有深层逻辑:通用框架(PyTorch)为了支持任意模型结构,使用动态图和运行时调度,带来较高的开销。TensorRT 在编译阶段分析整个计算图,做全局优化:算子融合(把多个小算子合并为一个 Kernel,减少 GPU 调度开销)、常量折叠(把固定权重的运算提前计算好)、精度优化(针对每层自动选择最优的量化精度)。这些编译期优化是运行时框架无法做到的,也是 TensorRT-LLM 在性能数字上领先的根本原因。
FP8 量化:H100 原生加速
FP8 量化是 TensorRT-LLM 最显著的差异化能力,需要 NVIDIA Hopper/Ada/Blackwell 架构(compute capability > 8.9,即 H100/H200/RTX 4090 等)。
量化的本质是用低精度表示权重和激活,减少内存占用和带宽需求,让更多数据塞进 L2 缓存和寄存器,减少 HBM 读取次数。精度格式对应的存储大小:FP32 = 4 bytes,BF16/FP16 = 2 bytes,FP8 = 1 byte,INT4 = 0.5 bytes。FP8 相比 BF16 存储减半,带宽需求减半,从 HBM 搬运模型权重的时间减半,对 memory-bound 的 Decode 阶段,这直接对应近 2 倍的理论加速上限。
官方实测数据(Llama-3.3-70B,H100):
| 配置 | 吞吐 (tokens/sec) | TTFT p50 | 相对 FP16 |
|---|---|---|---|
| FP16(调优后) | 2,474 | 147.6ms | 基准 |
| FP8 W8A8 | 6,049 | 88.0ms | +144.48% |
| FP8 KV Cache(FP16 权重) | 5,299 | — | +56.2% |
精度损失可控:FP8 量化在 MMLU 基准上精度损失仅 0.14–0.87%,远优于 INT4-AWQ(0.85–2.11%)和 INT8 SmoothQuant(2.50–2.75%)。
代价是硬件绑定:FP8 不兼容旧架构 GPU,无法在 A100 上使用。如果硬件是 A100,量化选项降为 INT8 或 INT4。A100 的 FP16 内存带宽 2 TB/s,低于 H100 的 3.35 TB/s,且不支持 FP8 Tensor Core,INT8 的吞吐提升约 50%,不如 H100 上 FP8 的 144%。硬件代际差异直接影响量化收益,在选硬件时就应该把量化方案纳入考量。
Inflight Batching vs Continuous Batching
TensorRT-LLM 的 Inflight Batching 和 vLLM 的 Continuous Batching 解决的是同一问题,实现细节略有不同。两者都在每个 token 生成迭代步骤动态插入新请求,相比静态批处理可提升 1.5–2 倍吞吐。
TensorRT-LLM 的 MAX_UTILIZATION 模式会将请求数量打满到引擎支持的上限,最大化 GPU 利用率。在生产部署中,通常配合 GUARANTEED_NO_EVICT 模式避免请求被抢占。
编译型推理的代价
TensorRT-LLM 是编译型框架:首次使用前必须将模型编译为 TRT 引擎,根据目标精度(FP8/INT8/FP16)和并行配置(TP/PP size)构建。
冷启动数据(Llama-3.3-70B 为例):
- 首次编译:约 28 分钟
- 引擎缓存后二次启动:约 90 秒(设置环境变量
TLLM_LLMAPI_BUILD_CACHE=1) - vLLM 冷启动:约 62 秒
- SGLang 冷启动:约 58 秒
对于固定模型的生产部署,编译开销是一次性的,之后复用缓存引擎。但频繁换模型、调试阶段,28 分钟的等待成本很高。
from tensorrt_llm import LLM, SamplingParams
from tensorrt_llm.llmapi import QuantConfig, QuantAlgo
import os
# 启用编译缓存:避免每次 28 分钟的重新编译
os.environ["TLLM_LLMAPI_BUILD_CACHE"] = "1"
# FP8 量化配置
# quant_algo=W8A8_FP8: 权重和激活均使用 FP8
# kv_cache_quant_algo=FP8: KV cache 也量化为 FP8(H100 上额外 +56% 吞吐)
quant_config = QuantConfig(
quant_algo=QuantAlgo.W8A8_FP8,
kv_cache_quant_algo=QuantAlgo.FP8,
)
# 初始化 LLM(首次运行编译 TRT 引擎,约 28 分钟;后续使用缓存约 90 秒)
llm = LLM(
model="meta-llama/Llama-3.3-70B-Instruct",
quant_config=quant_config,
tensor_parallel_size=1, # 多 GPU 时调大,如 H100x8 设为 8
)
sampling_params = SamplingParams(
temperature=0.7,
max_tokens=256,
)
prompts = [
"解释 FP8 量化相比 INT8 的优势",
"Inflight Batching 在 LLM 服务中如何工作?",
]
outputs = llm.generate(prompts, sampling_params)
for output in outputs:
print(f"输出: {output.outputs[0].text}")
# 释放引擎资源
llm.shutdown()
五、PagedAttention 的深度解析
传统静态分配的碎片问题
理解 PagedAttention 需要先理解它解决的问题的严重程度。
传统 serving 框架在请求到达时,必须决定为这个请求预留多少 KV Cache 空间。由于无法知道序列最终长度,系统通常预留到配置的 max_model_len。一个 8K 上下文长度的模型,不管用户实际生成多少 token,都预留了 8K 的 KV Cache 空间。
这产生两种碎片。内部碎片(Internal Fragmentation)来自提前预留但实际未用的空间:一个按 8K 预留、实际只生成 200 token 的请求,白白占了 7800 个 token 的显存,其他请求用不了。外部碎片(External Fragmentation)来自请求结束后释放的不连续空闲块:总剩余量足够,但没有足够大的连续块给新的大请求使用。
实测结果:这两种碎片叠加,使 60–80% 的 GPU 显存成为有效死区。
碎片问题的另一个来源是预分配策略的不确定性。实际部署中,服务接受多种长度的请求,预分配必须针对最坏情况(max_model_len)。但"最坏情况"和"平均情况"之间的差距巨大:如果 80% 的请求只需要 500 token,却都按 8192 token 预留,实际利用率只有 500/8192 = 6%。这不是假设,是真实系统中普遍存在的问题,尤其是支持多用户的公共服务。
Block 级管理的优雅性
PagedAttention 的解决方案优雅在于:把"连续内存"这个假设去掉。
每个请求的 KV Cache 被切分为固定大小的逻辑 Block(默认 16 tokens)。物理内存中有一个 Block Pool,以相同大小的物理 Block 为单位管理。Block Table 维护每个请求的逻辑 Block 到物理 Block 的映射。
分配流程:请求到来时,按需分配物理 Block,不提前预留。每生成满 16 个 token,再分配下一个物理 Block。Block Pool 中的物理 Block 可以来自 GPU 内存的任意位置,不需要连续。
释放流程:请求完成时,其所有物理 Block 归还 Block Pool,可立即被其他请求使用。
内部碎片被限制在每个请求最后一个未填满的 Block 中,最大浪费 15 个 token 位置(block_size - 1 = 16 - 1 = 15)。外部碎片从结构上消除,所有空闲块的粒度相同,任何空闲块都能分配给任何请求。
抢占(Preemption):内存压力下的优雅降级
当并发请求数过多、Block Pool 耗尽时,vLLM 不会直接 OOM 崩溃,而是执行抢占策略。
系统按优先级(通常按请求到达时间,越早到达优先级越高)选择低优先级请求,将其 KV Cache 换出(swap out)到 CPU 内存,释放 GPU Block 供高优先级请求使用。被抢占的请求稍后重新调度,从 CPU 换回 KV Cache 继续生成。
Recomputation 是另一种降级策略:直接丢弃低优先级请求的 KV Cache,等到重新调度时重新执行 Prefill。对于 KV Cache 较小的短序列,Recomputation 比 Swap 更快(避免 PCIe 传输延迟)。
共享前缀(Shared Prefix):系统提示词只存一份
PagedAttention 的 Block Table 设计天然支持跨请求共享物理 Block:多个请求的块表可以映射到同一个物理 Block,只要引用计数 > 1 且没人写入(只有 Decode 阶段的当前 token 才写)。
系统提示词通常在所有请求中完全相同,可以提前计算其 KV Cache,将对应物理 Block 标记为只读共享。后续每个请求的 Block Table 直接引用这些共享 Block,不复制、不重算。这是前缀缓存(Prefix Caching)的基础机制,后续章节详细展开。
Block Table 的共享机制还有一个重要性质:共享块的 Prefill 计算只需做一次,但多个请求的 Decode 阶段可以同时持有其引用。对于有 50 个并发请求且都共享同一个 5000 token 系统提示的场景,5000 token 的 Prefill 计算只发生一次,5000 × 50 = 25 万个 token 的潜在冗余计算被消除。显存节省更直观:5000 token 的 KV Cache 无论有多少个并发请求,物理存储只有一份。这在大型 Agent 系统中是实质性的成本优化。
六、投机解码:让小模型帮大模型猜
核心原理:草稿 + 验证
标准自回归解码每步调用一次目标大模型(Target Model),生成 1 个 token。GPU 利用率受 memory bandwidth 制约,算力浪费严重。
投机解码(Speculative Decoding)引入一个轻量级的草稿模型(Draft Model),通常是同家族的小模型。工作流程:
- Draft Model 以自回归方式快速生成 γ 个候选 token(γ 称为 speculative length,通常取 5)
- Target Model 对这 γ 个候选 token 执行一次并行 Prefill(而非串行 Decode),得到每个位置的概率分布
- 按照拒绝采样规则(rejection sampling)逐个验证候选 token:候选 token 的目标模型概率/草稿模型概率 ≥ 随机数,则接受;否则从修正分布中重新采样并截断后续
- 最终结果保证与直接从 Target Model 采样的分布完全一致(无精度损失)
加速比的直觉:Target Model 一次验证 γ 个 token,平均接受 TAR(Token Acceptance Rate)个。理论加速比约为 TAR / (t_target + t_draft),其中 t_target 和 t_draft 是各自的单步时间。
这里有一个微妙的精度保证机制值得关注。传统感觉上,"用小模型替代大模型"一定会损失精度。但投机解码的拒绝采样保证了最终 token 分布与直接从 Target Model 采样完全一致:每个被接受的 token,都是大模型认可的高概率输出;被拒绝的 token,从大模型的修正分布重新采样。结果是,在统计意义上,输出和直接用大模型没有区别,只是速度更快。这是投机解码最优雅的特性。
TAR 和 γ 的关系
接受率(TAR)是投机解码的核心指标。经验数据:TAR ≥ 0.6 且 γ = 5 时,可达 2–3 倍加速比。
TAR 取决于 Draft Model 和 Target Model 在当前 token 分布上的吻合程度。温度越高(生成越随机),两者分布差异越大,TAR 越低。格式化输出(代码、JSON)TAR 通常更高,因为分布集中;开放域生成 TAR 较低。
一个反直觉的发现(来自 arxiv.org/html/2402.01528v1):Draft Model 的大小和推理速度比其 NLP 任务准确率更重要。OPT-66B 目标模型测试中,OPT-125M 草稿模型表现优于 OPT-6.7B:更小的草稿模型速度更快,带来的吞吐收益超过了更高接受率的收益。
γ(投机长度)的选择是另一个需要实测的参数。γ 越大,每次目标模型验证覆盖的候选越多,单次 forward pass 的价值越高;但如果接受率不高,后面的候选几乎都会被拒绝,γ 过大反而增加了草稿模型的无效计算量。经验值是 γ = 5 在大多数场景下是合理起点,实际最优值通过网格搜索确定,固定测试集上找 TTFT × 吞吐的最优平衡点。
投机解码在高并发场景下有一个反直觉的副作用:高并发时 Target Model 本身的 batch 已经很大,批处理已经把 memory-bound 问题大幅缓解,再加入投机解码带来的额外收益会变小。投机解码的收益在低并发(比如单用户、延迟敏感)场景下最大,在高并发(大批量、吞吐优先)场景下可能接近零甚至负收益(草稿模型占用了本可用于更大批量的显存)。
Token Tree Verification:并行验证多条路径
SpecInfer(Miao et al., 2024,arxiv.org/pdf/2305.09781)将单条草稿序列扩展为 Token Tree(词元树):多个草稿模型生成的候选 token 组织为树状结构,Target Model 对整棵树并行验证。
序列化验证的接受率(52–57%)在树状验证中提升到 96–97%(随机采样模式)。原理是:树状结构允许在第 k 个 token 被拒绝后继续验证 k 之前其他路径的分叉,不必从头重来。每次 Target Model 调用平均接受的 token 数大幅增加。
Token Tree 的构建方式也有讲究。简单方案是让多个独立 draft model 各生成一条路径,组合为树。EAGLE 系列采用更聪明的策略:单个 draft model 在生成每个位置时输出 top-k 个候选,形成宽度为 k 的树,通过 beam 策略展开。这使得树的候选覆盖目标模型的高概率区域,接受率比随机多路采样更高。树的宽度和深度(即 γ)是需要根据实际工作负载调优的超参数,宽而浅适合分布均匀的场景,窄而深适合分布集中的场景。
EAGLE-3:训练专用草稿模型
EAGLE(Extrapolation Algorithm for Greater Language-model Efficiency)系列是 vLLM 当前支持的高质量投机解码方案。EAGLE-3 在 NeurIPS 2025 中发表,vLLM 0.8.5+ 已原生集成。
EAGLE-3 草稿模型不是通用小模型,而是专门训练来预测目标模型的 token 分布。实测接受率:自定义训练后,生成 5 个投机 token 时平均接受 3.02 个(TAR ≈ 0.6),接近 3 倍加速的理论值。
配置方式:
from vllm import LLM, SamplingParams
# 启用前缀缓存 + EAGLE-3 投机解码
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
enable_prefix_caching=True, # 自动缓存相同前缀的 KV blocks
speculative_config={
"method": "eagle3", # 使用 EAGLE-3 草稿模型
"num_speculative_tokens": 5, # 每步推测 5 个 token;TAR >= 0.6 时 2-3x 加速
"draft_tensor_parallel_size": 1,
},
gpu_memory_utilization=0.9,
)
# 长系统提示(多次请求共享,prefix cache 命中可避免重复 prefill)
SYSTEM_PROMPT = (
"You are a helpful AI assistant specialized in cloud infrastructure. "
"You have deep knowledge of Kubernetes, Docker, and cloud-native patterns. "
"Always provide concise, actionable answers with code examples when relevant. " * 10
)
sampling_params = SamplingParams(temperature=0.0, max_tokens=256)
questions = [
f"{SYSTEM_PROMPT}\nHow do I set up HPA in Kubernetes?",
f"{SYSTEM_PROMPT}\nExplain pod affinity rules.",
f"{SYSTEM_PROMPT}\nWhat is the difference between Deployment and StatefulSet?",
]
# 第一个请求:前缀未命中,触发完整 prefill
# 后续请求:前缀命中,KV blocks 直接复用,大幅降低 TTFT
outputs = llm.generate(questions, sampling_params)
for o in outputs:
print(o.outputs[0].text[:200])
注意:vLLM 当前实现中投机解码的加速效果低于参考实现,这是已知的工程问题(优化正在进行中)。在生产使用前建议做实际 benchmark,而非直接套用论文数据。
投机解码和前缀缓存可以叠加使用,两者优化的是不同阶段:前缀缓存优化 Prefill(减少需要计算的 token 数),投机解码优化 Decode(每次目标模型调用产出更多 token)。在 Agent 工作流中,同时开启两者通常是最优配置:长的固定系统提示由前缀缓存处理,剩余的 Decode 阶段由投机解码加速。vLLM 示例代码中已展示了两者同时启用的配置方式。唯一需要注意的是显存压力:前缀缓存需要保留大量 KV 块,投机解码需要额外加载 draft model,两者同时启用时 gpu_memory_utilization 通常不宜超过 0.85,避免 OOM。
七、前缀缓存:system prompt 不再重复计算
工作机制
前缀缓存(Prefix Caching)是对 KV Cache 的跨请求复用:如果两个请求共享相同的前缀(system prompt、固定工具定义、few-shot 示例),第二个请求不需要重新计算这部分的 KV Cache,直接复用第一个请求留下的结果。
vLLM 的 Automatic Prefix Caching(APC,v1 实现)机制:
- 每个 KV Cache Block 在填满后计算哈希值,哈希输入包含:父块哈希值 + 当前块 token 序列 + 可选附加键(如 LoRA ID、cache salt)
- Scheduler 在分配 KV Cache 时,先调用
get_computed_blocks()查找哈希匹配的已缓存块 - 命中时增加引用计数(防止被逐出),将命中块加入请求的 Block Table,Prefill 从未命中位置开始
- 请求完成后,释放的块合并回 Free Queue,供后续请求匹配
安全特性:通过注入 cache_salt 可以隔离不同租户的缓存,防止缓存内容跨用户泄露。
vLLM 的哈希使用父块哈希 + 当前块 token 序列的链式结构,形成一个有序的哈希链。这个设计的好处是:如果前 N 个 Block 都命中,第 N+1 个 Block 的哈希依赖第 N 个块的哈希,所以不可能出现"中间某块命中但前面某块未命中"的情况,哈希链保证了前缀的连续性。这个设计也使得跨 LoRA 模型的缓存隔离变得自然:不同 LoRA ID 作为附加键注入哈希,相同 token 序列但不同 LoRA 的缓存块自动区分。
缓存命中率的决定因素
前缀缓存的命中率取决于 prompt 中变化部分的位置。
命中率最高的情况是变化部分放在最后:system prompt 固定,工具定义固定,用户问题在最末。前缀完全匹配,命中率接近 100%,只有用户问题部分需要 Prefill。
命中率最差的情况是变化部分在最前:每次请求开头加时间戳或请求 ID,导致 system prompt 之前有变化内容,所有后续内容的哈希值都不同,缓存完全失效。
RAG 场景有个容易踩的坑:检索到的文档内容通常插在 system prompt 之后、用户问题之前。如果每次检索结果不同,这个插入位置会让后续所有 token 的缓存失效。解法是将动态检索内容移到 prompt 最末,固定部分放前面。
前缀缓存和 tokenization 之间有一个细节需要注意。大多数 LLM 的 tokenizer 在 BPE(字节对编码)分词时,边界词(比如第一个用户消息的第一个词)的分词结果可能因为前缀内容不同而改变。如果 system prompt 结尾和用户消息开头之间有分词边界问题,可能导致 token 序列和预期不一致,进而降低缓存命中率。使用支持 add_special_tokens=False 的 tokenizer 接口,分段处理然后拼接 token ID,可以避免这个问题。这是实际部署中容易踩到的坑,值得在集成测试中验证。
跨请求共享 vs 单用户内复用
前缀缓存有两种使用场景,机制上有细微差别。
跨请求共享是最常见的使用场景:多个不同用户的请求共享相同的 system prompt KV Cache,单份 KV Cache 服务所有并发请求,显存利用率最高。前提是 system prompt 对所有用户完全相同;多租户 SaaS 场景中每个客户有独立 system prompt 时,需要在缓存 key 中加入租户 ID 区分。
单用户内复用针对多轮对话:第 N 轮时,前 N-1 轮的 KV Cache 如果还在,直接复用,不重算。前提是每轮对话用相同的会话 ID 路由到同一个服务实例,避免负载均衡把同一用户的请求打散到不同实例导致缓存无法命中。
生产系统中,通常两种场景叠加:system prompt 全局共享,对话历史在会话内复用。这需要在负载均衡层实现"会话亲和"(session affinity):将同一用户的请求路由到同一服务实例。nginx 或 API gateway 层的 sticky session 配置是实现这个需求的常见手段。
vLLM APC 和 SGLang RadixAttention 的实现对比
两者的核心差异在于匹配粒度:
| 维度 | vLLM APC | SGLang RadixAttention |
|---|---|---|
| 数据结构 | Hash Table(Block 级) | Radix Tree(Token 级) |
| 匹配粒度 | 固定 16 token Block | 精确到单个 token |
| Partial Block | 不缓存 | 可缓存(树中最后一段) |
| 逐出策略 | LRU + Free Queue | LRU + 引用计数 |
| 跨请求调度 | 不感知缓存分布 | Cache-aware scheduling |
| 实现复杂度 | 较低 | 较高 |
在 system prompt 能被完整 Block 整除的场景(现实中多数情况),两者效果相近。在 prompt 边界不对齐到 Block 边界的场景,SGLang 可以匹配更多内容。
对 Agent 工作流的实际收益
Agent 工作流中,前缀缓存的收益非常显著:
固定部分通常包括:System Prompt(角色定义、约束、格式要求)500–2000 token,工具定义(function calling schema)每个约 200–500 token,10 个工具共 2000–5000 token,以及固定 few-shot 示例 1000–3000 token。
收益计算很直接:假设固定前缀 5000 token,用户每次问题 200 token。没有前缀缓存时,每次 Prefill 需处理 5200 token;有前缀缓存且命中时,只处理 200 token,Prefill 计算量降低 96%,TTFT 从数秒降至几十毫秒。
多轮对话的收益还会累加:对话进行时,历史轮次的 KV Cache 也可以被缓存复用。第 10 轮时,前 9 轮已存在缓存,只需计算第 10 轮用户输入部分。上下文越长,缓存收益越大。
实际部署时需注意:前缀缓存消耗 GPU 显存,KV Cache 不能被其他请求的实时计算使用。高峰期并发请求多时,缓存的 KV Block 可能被 LRU 逐出,命中率下降。可以通过调大 gpu_memory_utilization 或减小 max_model_len 来给缓存腾出更多空间。
下面这个例子展示前缀缓存在 Agent 场景下的完整配置:
from vllm import LLM, SamplingParams
# 启用前缀缓存的 Agent 服务配置
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
enable_prefix_caching=True,
gpu_memory_utilization=0.90, # 留出更多显存给 KV cache 缓存
max_model_len=16384, # 支持长对话上下文
tensor_parallel_size=1,
)
sampling_params = SamplingParams(temperature=0.1, max_tokens=512)
# 构建 Agent system prompt(固定部分放最前)
SYSTEM_PROMPT = """你是一个专业的代码审查助手。你的职责是:
1. 检查代码的正确性、性能和安全性
2. 提供具体可执行的改进建议
3. 识别潜在的 bug 和边界情况
工具定义:
- analyze_code(code: str) -> dict:分析代码结构和复杂度
- check_security(code: str) -> list:检查安全漏洞
- suggest_refactor(code: str) -> str:提供重构建议
输出格式:
- 问题严重程度:[高/中/低]
- 具体位置:文件名:行号
- 问题描述:2-3句话
- 修复建议:可直接执行的代码片段
"""
# 模拟多个并发的代码审查请求
# 所有请求共享相同的 SYSTEM_PROMPT 前缀
# 第一个请求:完整 Prefill(SYSTEM_PROMPT + code1)
# 后续请求:SYSTEM_PROMPT 部分直接从缓存读取,只 Prefill 各自的 code
code_reviews = [
f"{SYSTEM_PROMPT}\n\n请审查以下 Python 代码:\ndef process(data):\n for i in range(len(data)):\n print(data[i])",
f"{SYSTEM_PROMPT}\n\n请审查以下代码:\ndef read_file(path):\n f = open(path)\n return f.read()",
f"{SYSTEM_PROMPT}\n\n请审查以下代码:\ndef query_db(user_input):\n sql = f'SELECT * FROM users WHERE name={user_input}'\n return db.execute(sql)",
]
outputs = llm.generate(code_reviews, sampling_params)
for i, output in enumerate(outputs):
print(f"=== 审查结果 {i+1} ===")
print(output.outputs[0].text[:300])
print()
框架选型:如何做决策
量化精度选择的决策树
量化精度的选择不是越低越好,而是在精度损失和性能收益之间找到可接受的平衡点。
FP16/BF16 是基准,无精度损失,适用于精度要求极高的场景(医疗、金融、法律),或作为 A/B 测试对照组。
INT8 SmoothQuant 精度损失约 2.5–2.75%(MMLU),吞吐比 FP16 约提升 30–50%,适用于 A100 等不支持 FP8 的硬件,或对精度要求中等的通用场景。
FP8 W8A8 是 H100 专用选项,精度损失仅 0.14–0.87%,吞吐比 FP16 约提升 144%。精度损失小于 INT8 但吞吐提升大得多,是 H100 部署的首选。
INT4 AWQ 精度损失 0.85–2.11%,显存减半,4 倍压缩(相比 FP16),适用于需要在 VRAM 有限的 GPU 上运行大模型(如单卡跑 70B),或极端吞吐场景愿意接受精度下降。
在生产环境中,建议用目标任务的真实数据集(而非通用 MMLU)验证量化精度损失。通用 benchmark 的数字无法代替业务场景的实测。
H100 三框架基准数据
以下数据来自 spheron.network 的实测(Llama-3.3-70B,FP8,512 token 输入/256 token 输出,100 并发):
| 框架 | 吞吐 (tokens/sec) | TTFT p50 | VRAM 峰值 | 冷启动 |
|---|---|---|---|---|
| TensorRT-LLM | 2,780 | 680ms | 79GB | 约 28 分钟(首次) |
| SGLang | 2,460 | 710ms | 78GB | 约 58 秒 |
| vLLM | 2,400 | 740ms | 78GB | 约 62 秒 |
注意这是通用场景(无前缀复用)的数据。在前缀命中率高的场景,SGLang 的优势会拉大。
选型建议
选 vLLM 的情况: - 快速迭代阶段,需要频繁换模型 - 通用 serving,请求前缀多样性高 - 需要最广泛的模型支持和社区资源 - 团队熟悉 Python,需要深度定制
选 SGLang 的情况: - Agent / RAG 工作流,system prompt 和工具定义固定 - 需要高质量的结构化输出(JSON schema 约束) - 多轮对话,需要最大化历史上下文复用 - 需要在前缀密集场景下的最优吞吐
选 TensorRT-LLM 的情况: - 生产环境固定模型,H100/H200 硬件,追求极限吞吐 - FP8 量化是核心需求(吞吐比 FP16 高 144%) - 对 28 分钟编译时间可以接受(通过缓存降为 90 秒) - NVIDIA 官方支持和 Triton Inference Server 集成
三个框架在功能上有明显重叠,但设计哲学不同。vLLM 追求易用性和覆盖面,是推理领域的"Django",功能齐全、文档好、生态大;SGLang 追求前缀密集场景的最优性能,适合明确知道工作负载特征的团队;TensorRT-LLM 追求硬件级别的极限性能,是要榨干 H100 每一滴算力的选择。
在实际部署中,混合方案也值得考虑:用 vLLM 做开发和测试(冷启动快,切换成本低),用 TensorRT-LLM 做最终生产(性能最优),用 SGLang 处理 Agent 子任务(前缀缓存收益最大)。三者都支持 OpenAI 兼容接口,切换成本不高。
框架选型还需要考虑一个实际运营因素:版本迭代速度。三个框架都在快速演进,vLLM 月均发布 2–3 个版本,SGLang 和 TensorRT-LLM 也保持类似节奏。新版本通常带来性能提升,但也可能引入 breaking change 或新的 bug。生产环境建议锁定版本,用 pip install vllm==0.x.y 固定,并在升级前在 staging 环境回归测试。今天的 benchmark 数字明天可能失效,持续跟踪框架更新日志是必要的工程习惯。
一个容易忽略的结构性问题
三个框架都在优化 Decode 阶段的 memory-bound 问题,但 Prefill 阶段的优化空间同样值得关注。
当 batch 中混有长 Prefill 请求(大 prompt)和正在 Decode 的请求时,长 Prefill 会阻塞 Decode 请求的下一步,导致已在生成的请求出现明显的延迟尖刺(jitter)。这在 RAG 场景中尤为突出:检索到的文档越长,Prefill 越慢,同批次的 Decode 请求等待越久。
解法是 Prefill-Decode 分离(Disaggregated Prefill):将 Prefill 计算卸载到专用的 Prefill Worker,Decode Worker 只做生成。代价是系统复杂度大幅上升,KV Cache 需要在 Prefill 和 Decode Worker 之间传输。这是当前学术界和工业界的热点研究方向,生产可用的方案还在演进中。
另一个结构性问题是多 GPU 配置的选择。大模型(70B 以上)单卡放不下,需要张量并行(Tensor Parallelism,TP)跨 GPU 切分模型。TP 度越高(比如 8 卡 TP),延迟越低(并行计算更多),但 GPU 间 AllReduce 通信开销增大,对 NVLink 带宽要求高。流水线并行(Pipeline Parallelism,PP)将模型层分配给不同 GPU,吞吐好但延迟高(流水线气泡)。TensorRT-LLM 的 PP 仅在 Linux 上支持,vLLM 的 TP 支持更好。实际 70B 模型通常选 4 卡或 8 卡 TP,这既不需要 PP,又能分摊显存压力和计算负载。
可执行行动建议
如果你现在需要在生产系统中部署 LLM 推理服务,以下是优先级排序的行动清单:
第一步:确认业务形态 跑一次真实流量的统计:system prompt 多长、用户输入平均多长、期望输出多长、并发量级、对 TTFT 的容忍上限。数字不出来,框架选型没有依据。
第二步:打开前缀缓存
在 vLLM 启动命令中加 --enable-prefix-caching。零改动、零风险,对 Agent 和 RAG 工作流通常有 30%–60% 的 TTFT 降低。同时检查 prompt 结构:变化部分必须放在最末尾,否则缓存失效。
第三步:做真实 benchmark 不要直接套用论文数字。用生产流量样本(或代表性测试集),对框架候选做实际吞吐和延迟测试。关键指标:TTFT p50/p99、ITL p50/p99、并发 100/500/1000 下的吞吐。论文的合成 benchmark 和你的实际工作负载往往有显著差异。
第四步:量化评估 如果用 H100,先测 FP8(TensorRT-LLM 或 vLLM 均支持)。FP8 在精度损失可忽略的情况下,吞吐可提升 50%–140%。这是显存和算力换精度的最优点,大多数场景值得接受。
第五步:投机解码只在特定场景评估 投机解码需要额外显存和系统复杂度。只在以下场景值得尝试:有合适的 EAGLE-3 draft model、温度较低(TAR 高)、输出 token 远多于输入 token。在高并发场景下,投机解码的收益会被批处理抵消,反而不如提高批量大小。
第六步:评估 Grouped-Query Attention 和 Multi-head Latent Attention 的影响 现代模型(Llama 3 系列、DeepSeek V3/R1)普遍使用 GQA(分组查询注意力)或 MLA(多头潜在注意力),大幅压缩 KV Cache 尺寸。Llama 3.1-8B 有 32 个 Query 头但只有 8 个 KV 头,每个 token KV Cache 约为标准 MHA 的 1/4。DeepSeek V3/R1 的 MLA 更进一步,通过低秩投影将 KV Cache 压缩到标准 MHA 的约 10%(在百万 token 上下文时效果尤为突出)。选模型时,KV Cache 大小是重要的运营成本指标,直接影响可承载的并发数。
部署不是终点,而是验证的开始。推理服务的性能调优需要持续观测:监控 KV Cache 命中率、Block Pool 使用率、TTFT 和 ITL 的分位数分布,这些数字会告诉你下一步优化在哪里。
vLLM 暴露 Prometheus 指标,可以直接接入 Grafana 监控面板。核心指标包括:
# 查看 vLLM 暴露的关键 Prometheus 指标
# 启动时加 --enable-metrics 参数暴露 /metrics endpoint
# 核心指标含义:
# vllm:num_requests_running - 当前正在处理的请求数
# vllm:num_requests_waiting - 等待 KV Cache 的请求数(队列深度)
# vllm:gpu_cache_usage_perc - KV Cache 使用率(>90% 意味着即将触发抢占)
# vllm:cpu_cache_usage_perc - CPU Swap 使用率(>0 意味着有请求被抢占)
# vllm:time_to_first_token_seconds - TTFT 直方图
# vllm:time_per_output_token_seconds - ITL 直方图
# vllm:request_success_total - 成功完成的请求总数
# vllm:cache_hit_rate - 前缀缓存命中率(启用 APC 后可见)
# Grafana 面板的关键告警:
# 1. gpu_cache_usage_perc > 0.85 持续 > 1min → 扩容或降低 max_model_len
# 2. num_requests_waiting > 50 → 服务已过载,请求开始积压
# 3. cache_hit_rate < 0.3 → 前缀结构可能有问题,检查 prompt 格式
# 4. time_to_first_token p99 > 5s → 有长 prefill 请求阻塞短请求
这些指标不仅是告警工具,更是优化指南。cache_hit_rate 低意味着前缀缓存没有发挥作用,重新审查 prompt 结构;cpu_cache_usage_perc 大于零意味着有请求被抢占到 CPU,说明 GPU 显存紧张,需要降低并发或减小 max_model_len;num_requests_waiting 持续增加意味着服务处理速度低于请求到达速度,需要加节点或优化批处理配置。
推理引擎横向对比
vLLM vs SGLang vs TensorRT-LLM 全面对比
以下数据综合自 Spheron 实测(H100,Llama-3.3-70B,FP8,512 token 输入/256 token 输出,100 并发)和各框架官方 benchmark。
| 维度 | vLLM | SGLang | TensorRT-LLM |
|---|---|---|---|
| 吞吐量(100 并发,tokens/sec) | 2,400 | 2,460 | 2,780 |
| TTFT p50(100 并发) | 740ms | 710ms | 680ms |
| TTFT(batch=1,低延迟模式) | ~50ms | ~45ms | <10ms |
| 前缀缓存密集场景吞吐 | 基准 | +29%(RadixAttention) | 取决于前缀长度 |
| VRAM 峰值(70B FP8) | 78GB | 78GB | 79GB |
| 冷启动 | ~62 秒 | ~58 秒 | ~90 秒(缓存)/ 28 分钟(首次) |
| FP8 支持 | 支持 | 支持 | 原生最优(+144% vs FP16) |
| 结构化输出 | 基础支持 | Compressed FSM,延迟降低 2× | 基础支持 |
| 硬件覆盖 | NVIDIA/AMD/Intel | NVIDIA 为主 | NVIDIA 专属 |
| 模型覆盖 | 200+ | 100+ | 主流模型 |
| 部署复杂度 | 低 | 中 | 高(需预编译) |
| 社区活跃度 | 最高 | 高 | 中(NVIDIA 官方维护) |
通用场景下三者吞吐差距在 20% 以内。差距拉大的条件:前缀命中率高时 SGLang 的 RadixAttention 优势显著;FP8 极限吞吐场景 TensorRT-LLM 领先;新模型跟进速度和快速迭代 vLLM 最好。
如何根据业务场景选型
低延迟场景(实时对话、语音助手,TTFT < 100ms):TensorRT-LLM 在 batch=1 时 TTFT 可低至 10ms 以下,是低延迟交互的最终生产选型。vLLM 和 SGLang 在 batch=1 下约 45–50ms,满足多数交互场景。追求低 TTFT 时需要限制并发数,否则请求在队列中的等待时间会侵蚀延迟收益。
高吞吐场景(离线评测、批量摘要、数据标注):三框架差距不大,TensorRT-LLM FP8 领先约 15%。更有效的提升手段是调大批大小和并发数,以及评估 Prefill-Decode 分离以减少长 Prefill 对 Decode 请求的阻塞。不考虑编译成本的情况下,TensorRT-LLM FP8 是离线高吞吐场景的最优选。
多模态场景(视觉语言模型,图文理解):vLLM 对多模态模型(LLaVA、Qwen-VL、InternVL)支持最广泛,已实现视觉 token 的 KV Cache 管理。SGLang 也在跟进,但覆盖模型数量较少。TensorRT-LLM 的多模态支持依赖 NVIDIA 官方适配,新模型上线往往滞后数周到数月。
Agent / RAG 工作流(系统提示词长、工具定义多、多轮对话):SGLang 的 RadixAttention 在这个场景具备结构性优势。system prompt 加工具定义共 2000–5000 token,命中率接近 100%,Prefill 计算大幅降低,TTFT 可从 710ms 降至数十毫秒。Compressed FSM 对 JSON 工具调用结果的解码也有 2 倍的延迟加速,Agent 工作流中 SGLang 是当前综合收益最高的选择。
生产部署的关键配置
并发请求数配置
max_num_seqs 决定 GPU 同时服务的请求数上限,是吞吐和延迟权衡的核心参数。
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B-Instruct \
--max-num-seqs 256 \ # 最大并发请求数;增大提升吞吐,同时增大 TTFT
--max-num-batched-tokens 8192 \ # 每次 forward pass 的 token 总量上限
--gpu-memory-utilization 0.9 \
--enable-prefix-caching \
--port 8000
设置依据:用实际流量样本压测,找到 TTFT p99 开始超过 SLA 阈值的并发点,取该点的 80% 作为生产配置,留出余量应对流量尖峰。经验数字:8B 模型单 A10G 推荐 64–128,70B 模型单 H100 推荐 32–64。
GPU 显存分配比例
gpu_memory_utilization 控制预留给 KV Cache 的显存比例,默认 0.9。剩余 10% 留给 CUDA 运行时和其他开销。
from vllm import LLM
# 保守配置:适合模型权重大或有 LoRA 适配层的场景
llm_conservative = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
gpu_memory_utilization=0.85,
max_model_len=4096,
)
# 激进配置:单模型、无其他进程占用时可用
llm_aggressive = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
gpu_memory_utilization=0.95,
max_model_len=8192,
)
设置超过 0.95 的风险:CUDA 内核本身需要显存,触顶导致 OOM 崩溃。设置低于 0.7:KV Cache Block Pool 容量不足,高并发时频繁触发请求抢占,吞吐反而下降。0.9 是大量生产部署中验证过的平衡点,偏离前先用小流量压测确认。同一节点运行多个模型实例(分卡部署)时,需要给每个实例预留足够的显存间距,避免进程间竞争导致随机 OOM。
健康检查端点
vLLM 暴露两个关键端点,建议在负载均衡器和 Kubernetes readinessProbe 中使用:
# 健康检查:返回 200 代表服务正常,非 200 代表异常
curl http://localhost:8000/health
# OpenAI 兼容的模型列表(也可以用作存活检查)
curl http://localhost:8000/v1/models
Kubernetes readinessProbe 配置示例:
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 90 # vLLM 加载 70B 模型约需 60 秒,留足裕量
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 120
periodSeconds: 30
initialDelaySeconds 设置为 90 秒以上:vLLM 在加载 70B 模型权重期间(约 60 秒)服务尚未就绪,探针过早触发会导致 Pod 被反复重启,模型永远加载不完。
TensorRT-LLM 构建引擎命令
TensorRT-LLM 的使用分两步:将 HuggingFace 权重转换为 TRT checkpoint,再编译为可执行引擎。
# Step 1:转换为 TRT-LLM checkpoint 格式
python examples/llama/convert_checkpoint.py \
--model_dir ./Llama-3.1-8B-Instruct \
--output_dir ./trt-ckpt/llama-3.1-8b \
--dtype float16
# Step 2:编译 TRT 引擎(8B 约 5-10 分钟,70B 约 28 分钟)
trtllm-build \
--checkpoint_dir ./trt-ckpt/llama-3.1-8b \
--output_dir ./trt-engine/llama-3.1-8b \
--gemm_plugin float16 \
--max_batch_size 64 \
--max_input_len 2048 \
--max_seq_len 4096
# FP8 量化版本(仅 H100/H200/RTX 4090 支持)
trtllm-build \
--checkpoint_dir ./trt-ckpt/llama-3.1-8b-fp8 \
--output_dir ./trt-engine/llama-3.1-8b-fp8 \
--gemm_plugin float8 \
--kv_cache_dtype fp8 \
--max_batch_size 64 \
--max_seq_len 4096
设置环境变量 TLLM_LLMAPI_BUILD_CACHE=1 后,同一配置的二次启动复用缓存,耗时从 28 分钟降至约 90 秒。引擎编译完成后可持续复用,直到模型权重、精度配置或 Tensor Parallel 度数发生变化。