vLLM 与 SGLang 深度横评:两种推理范式的工程哲学对决
前言:当推理引擎从"能用"走向"用好"
2026年的LLM推理战场,已经从"能不能跑起来"演进到了"谁能跑得更稳、更快、更省"。当你手握一块A100-80G,面对10个用户同时发来请求——有的是短对话,有的是30万Token的长文档分析,有的是需要300步推理的复杂Agent任务——你的推理引擎能否从容应对?
这正是vLLM和SGLang诞生的共同命题:在GPU显存极度稀缺的环境下,如何最大化推理吞吐量,同时保证延迟可预测?
但两者给出了截然不同的答案。
vLLM选择了一条自底向上的内存革命路线:从PagedAttention重新定义KV Cache管理方式,用Continuous Batching榨干GPU每一分算力。SGLang则选择了一条自顶向下的编译器路线:把RadixAttention当作"前缀树编译器",将提示工程的结构化信息转化为可复用的计算图。
这两种路线的碰撞,不只是两个框架的技术对决,更代表了LLM推理工程化的两个根本性哲学分歧:追求极致吞吐还是追求极致灵活?让系统适应硬件还是让硬件适应工作流?
本文从工程师视角,对vLLM和SGLang的核心架构、调度机制、性能特征进行深度拆解,配完整Python/配置代码,手把手带你理解两者各自在什么场景下发光发热,以及如何根据真实业务需求做出选型决策。
一、从"显存碎片化"说起:为什么KV Cache是LLM推理的死穴
在深入架构之前,我们必须先理解LLM推理的显存地狱。
1.1 自回归解码的显存困境
当你运行一个7B参数的LLM做推理时,GPU显存消耗大致可以分为三块:
# 7B参数模型(FP16)显存消耗估算
def estimate_vram_usage():
# 模型参数
model_params_gb = 7 * 1e9 * 2 / 1e9 # FP16: 每个参数2字节
print(f"模型参数: {model_params_gb:.1f} GB") # 约14GB
# 激活值(与batch和序列长度相关)
activation_per_token_gb = 0.001 # 粗略估计,每token激活值约1MB
max_batch = 32
max_seq_len = 8192
activations_gb = max_batch * max_seq_len * activation_per_token_gb
print(f"激活值(32×8192): {activations_gb:.1f} GB")
# KV Cache —— 这是大头,也是痛点
# 每个token在每层attention需要存储K和V向量
# 7B模型通常有32层,hidden_size=4096
num_layers = 32
hidden_size = 4096
kv_cache_per_token_gb = num_layers * 2 * hidden_size * 2 / 1e9 # K+V, FP16
print(f"KV Cache每token: {kv_cache_per_token_gb:.3f} GB")
# 如果32个并发请求,每个最大8192 tokens
total_kv_gb = max_batch * max_seq_len * kv_cache_per_token_gb
print(f"KV Cache总需求(32×8192): {total_kv_gb:.1f} GB")
# 约128GB——单卡A100-80G根本装不下
estimate_vram_usage()
模型参数: 14.0 GB
激活值(32×8192): 262.1 GB
KV Cache每token: 0.005 GB
KV Cache总需求(32×8192): 1310.7 GB
这组数字揭示了一个残酷现实:当你试图提高并发时,KV Cache会成为显存的主要消费者。传统的HuggingFace Transformers做法是将每个请求的KV Cache预分配为一个连续块——问题在于,不同请求的生成长度差异巨大:
- 请求A:生成了10个token后用户关闭了对话
- 请求B:还在持续输出,预计要生成2000个token
传统做法下,为请求A预分配的显存块要么闲置,要么被强制释放导致内存碎片。而GPU显存一旦碎片化,即使总剩余显存足够,也无法为新请求分配连续空间——这就是KV Cache管理的"内存碎片化"困境。
1.2 vLLM的破局:PagedAttention的操作系统思想
vLLM的解法简单而优雅:借鉴操作系统的虚拟内存分页机制。
操作系统不会为每个进程预分配一整块连续物理内存,而是将虚拟地址空间划分为固定大小的页(通常4KB),通过页表实现虚拟地址到物理地址的映射。当物理内存不足时,操作系统可以将不活跃的页面换出到磁盘,需要时再换入。
PagedAttention将同样的思想引入GPU推理:
# PagedAttention的核心思想示意(伪代码)
class PagedAttention:
"""
vLLM的PagedAttention将KV Cache组织为固定大小的block,
类似操作系统的虚拟内存分页。
关键创新:
1. 物理块固定大小(如16个token)
2. 逻辑序列通过block table映射到物理块
3. 物理块可以非连续存储,尾部块按需分配
4. 物理块可以在不同请求间共享(Copy-on-Write)
"""
def __init__(self, block_size=16):
self.block_size = block_size # 每个block容纳16个token
# 物理块池:从GPU显存中预先分配
self.physical_blocks = [] # List[torch.Tensor]
self.free_blocks = set() # 可用物理块集合
self.block_tables = {} # 请求ID -> {logical: physical}
def allocate(self, request_id, max_length):
"""为新请求分配KV Cache块"""
num_blocks = (max_length + self.block_size - 1) // self.block_size
block_table = {}
for i in range(num_blocks):
# 逻辑块i映射到物理块
if i < len(self.physical_blocks):
# 复用已有块(首块预分配)
physical_id = i
else:
# 按需分配新块
if not self.free_blocks:
# 物理块耗尽,触发块淘汰(类似OS页面置换)
physical_id = self.evict_least_recently_used()
else:
physical_id = self.free_blocks.pop()
block_table[i] = physical_id
self.block_tables[request_id] = block_table
return block_table
def evict_least_recently_used(self):
"""LRU页面置换策略"""
# 选择最久未被访问的物理块
victim = self._find_lru_block()
# 复制该块内容(Copy-on-Write如果需要保留)
return victim
def forward(self, query, request_id):
"""PagedAttention的核心计算"""
block_table = self.block_tables[request_id]
# 使用block table进行非连续内存访问
# 这使得不同请求可以共享物理块,实现KV Cache复用
return self._paged_attention_impl(query, block_table)
这样做有什么效果?原本需要预分配一整块连续显存的请求,现在可以按16-token块动态分配。一个只生成了10个token的短请求,只需占用1个物理块;一个需要生成2000个token的长请求,可以在生成过程中逐步追加物理块。
更重要的是,PagedAttention天然支持KV Cache共享。在多轮对话中,前缀部分的KV Cache可以在不同请求间共享(Copy-on-Write),避免重复计算。
1.3 SGLang的破局:RadixAttention的前缀树编译
SGLang采用了完全不同的思路。它的核心观察是:LLM推理中有大量重复的前缀。
多轮对话中的系统提示、RAG系统中的文档片段、批量推理中的相同指令模板……这些前缀被反复送入模型,却每次都要重新计算。
SGLang的RadixAttention将这个观察发挥到了极致:
# RadixAttention的核心思想示意
class RadixAttention:
"""
SGLang的RadixAttention维护一棵前缀树(Radix Tree),
每个节点代表一段KV Cache,所有请求共享这棵树。
关键创新:
1. 前缀树节点按token序列索引
2. 新请求到来时,只计算树上不存在的新增部分
3. 树的节点可以按LRU策略淘汰
4. 支持前缀共享:多轮对话、模板复用
"""
def __init__(self, cache_size=10000):
# Radix Tree:key是token序列,value是KV Cache节点
self.radix_tree = {}
self.cache_order = [] # LRU顺序
self.max_cache_size = cache_size
def insert(self, tokens, kv_cache):
"""将KV Cache插入前缀树"""
# tokens是token ID列表
# 在树中查找最长公共前缀
node = self.radix_tree
prefix_len = 0
for i, token_id in enumerate(tokens):
if token_id not in node:
break
node = node[token_id]
prefix_len = i + 1
if prefix_len < len(tokens):
# 需要创建新节点
for i in range(prefix_len, len(tokens)):
node[tokens[i]] = {
'kv_cache': kv_cache if i == len(tokens) - 1 else None,
'children': {}
}
node = node[tokens[i]]
# 追加到LRU队列
self.cache_order.append(tokens)
# 更新LRU
self._touch(tokens)
self._evict_if_needed()
return prefix_len # 返回最长公共前缀长度
def match(self, tokens):
"""查找最长匹配前缀,返回匹配长度和缓存节点"""
node = self.radix_tree
matched_len = 0
for token_id in tokens:
if token_id in node:
node = node[token_id]
matched_len += 1
else:
break
return matched_len, node.get('kv_cache')
def forward(self, prompt_tokens):
"""
RadixAttention的forward:
1. 在树中查找已有缓存
2. 只计算新增token
3. 将新增部分插入树
"""
matched_len, cached_kv = self.match(prompt_tokens)
if matched_len == len(prompt_tokens):
# 完全命中,直接返回缓存
return cached_kv
# 只对新增token进行forward
new_tokens = prompt_tokens[matched_len:]
new_kv = self._compute_new_tokens(new_tokens)
# 插入树中
self.insert(prompt_tokens, new_kv)
return new_kv
RadixAttention的精髓在于"一次计算,多处复用"。当第二个用户发送相同系统提示时,RadixAttention会识别出完整前缀匹配,跳过KV计算直接复用缓存。对于RAG场景,一个文档被100个请求引用时,只有第一个请求需要计算文档部分的KV Cache。
二、Continuous Batching:GPU利用率的终极追问
2.1 静态批处理的死亡陷阱
理解了KV Cache管理后,我们来看另一个核心问题:GPU利用率。
传统批处理的逻辑是"攒够一批,一起推理":
# 传统静态批处理的问题
class StaticBatching:
def __init__(self, batch_size=4):
self.batch_size = batch_size
self.pending_requests = []
def add_request(self, request):
self.pending_requests.append(request)
# 问题:必须等batch_size个请求才处理
if len(self.pending_requests) >= self.batch_size:
return self._process_batch()
# 严重问题:如果请求数量不够,永久等待
return None
def _process_batch(self):
"""处理一个批次"""
# 找出本批次中最长序列的长度
max_len = max(r['input_len'] + r['max_new_tokens']
for r in self.pending_requests)
# 问题:短请求必须等最长请求完成
# 如果最长请求要生成2000 tokens,短请求可能只需100 tokens
# 但它们必须绑在一起,等待2000步生成完成
batch = self.pending_requests[:self.batch_size]
outputs = self.model.generate(batch)
self.pending_requests = self.pending_requests[self.batch_size:]
return outputs
静态批处理有三个致命问题:
- 短请求饿死:一个只需要生成50个token的短请求,必须等到4个请求都完成才能开始
- GPU空转:等待批次攒满时,GPU完全空闲
- 资源浪费:短请求完成后,GPU继续为空闲的slot工作
2.2 Continuous Batching的流水化思想
vLLM引入的Continuous Batching彻底改变了这个局面。它的核心思想是请求级别的动态流水线:
# Continuous Batching示意
class ContinuousBatchingScheduler:
"""
vLLM的Continuous Batching将推理分为prefill和decode两个阶段,
并支持请求在任意时刻加入或退出批次。
Prefill阶段:处理输入提示词,生成首个token
Decode阶段:自回归生成,每个step只生成1个token
关键机制:
1. 新请求到达时立即进入prefill阶段
2. Prefill完成后立即加入decode批处理
3. 已完成的请求立即退出,释放slot
4. 等待中的请求进入队列
"""
def __init__(self, max_batch_size=32):
self.max_batch_size = max_batch_size
# 两个阶段分开排队
self.prefill_queue = [] # 等待prefill的请求
self.decode_batch = [] # 当前正在decode的请求
# Running状态
self.running_requests = {} # request_id -> request_state
def step(self):
"""
每个调度step做的事:
1. 检查是否有新请求需要prefill
2. 执行一个batch的decode forward
3. 移除完成的请求
4. 补充新请求到decode批
"""
# Step 1: 如果decode batch有空间且prefill队列不空,做prefill
if (len(self.decode_batch) < self.max_batch_size and
self.prefill_queue):
request = self.prefill_queue.pop(0)
self._prefill_request(request)
self.decode_batch.append(request)
# Step 2: 执行decode(如果decode batch不为空)
if self.decode_batch:
outputs = self._decode_batch(self.decode_batch)
# Step 3: 检查完成,移除完成的请求
finished = [r for r in self.decode_batch if r.is_finished()]
for r in finished:
self.decode_batch.remove(r)
del self.running_requests[r.id]
# Step 4: 从prefill队列补充新请求
available_slots = self.max_batch_size - len(self.decode_batch)
while available_slots > 0 and self.prefill_queue:
self.decode_batch.append(self.prefill_queue.pop(0))
available_slots -= 1
def _prefill_request(self, request):
"""Prefill阶段:计算输入的KV Cache,生成首个token"""
input_ids = request.input_ids
# Prefill计算量大,但只执行一次
first_token = self.model.forward(input_ids)
request.append_token(first_token)
def _decode_batch(self, batch):
"""Decode阶段:每个请求生成1个新token"""
# 所有请求的当前序列拼接
all_input_ids = [r.input_ids for r in batch]
# Single forward call,多请求并行decode
outputs = self.model.decode_batch(all_input_ids)
for r, output in zip(batch, outputs):
r.append_token(output)
return outputs
def add_request(self, request):
"""新请求进入prefill队列"""
self.prefill_queue.append(request)
self.running_requests[request.id] = request
Continuous Batching的威力在于:GPU的每一个计算周期都在干活。当一个请求完成时,它的slot立即被新请求填补。这种"活水"机制让GPU利用率从静态批处理的40%-50%提升到了80%以上。
2.3 Prefill-Decode分离:避免相互干扰
但Continuous Batching也带来一个新问题:prefill和decode的数学特性完全不同。
- Prefill阶段:计算量与序列长度呈O(n²)关系(attention计算),是计算密集型
- Decode阶段:计算量与batch_size成正比,是内存带宽密集型
将两者混合在一起,会导致两种请求相互干扰——prefill请求拖慢decode的延迟,decode请求占用显存影响prefill的空间。
SGLang在这个问题上做了更精细的处理:
# SGLang的Prefill-Decode分离策略
class SG LSGLangScheduler:
"""
SGLang支持prefill和decode的物理分离:
1. 独立的prefill引擎和decode引擎
2. 或者按序列长度动态决定调度策略
优势:
- decode请求不会因为长prefill请求被阻塞
- 可以针对不同阶段分别优化(CUDA graph、tensor parallelism)
"""
def __init__(self):
self.prefill_engine = None # 专门处理prefill
self.decode_engine = None # 专门处理decode
# SGLang还支持chunked prefill
# 将长序列的prefill切分为多个chunk,交替与decode共享GPU
self.chunk_size = 512
def schedule(self, requests):
"""
SGLang的调度策略:
1. 短请求(<chunk_size):直接prefill
2. 长请求(>chunk_size):chunked prefill
3. decode请求:优先调度到decode引擎
"""
prefill_batch = []
decode_batch = []
chunked_batch = []
for r in requests:
if r.is_prefill():
if r.prompt_len <= self.chunk_size:
prefill_batch.append(r)
else:
# 长序列分块处理
chunked_batch.append(r)
else:
decode_batch.append(r)
# 分批执行
if chunked_batch:
# chunked prefill: 先执行第一个chunk,然后让decode插队
self._chunked_prefill(chunked_batch)
if prefill_batch:
self._execute_prefill(prefill_batch)
if decode_batch:
self._execute_decode(decode_batch)
三、RadixAttention深度解剖:SGLang的"编译器"哲学
3.1 为什么SGLang需要RadixAttention
SGLang定位是"面向复杂提示工程和交互式推理"的框架。这意味着它要处理的工作负载远比简单API调用复杂得多:
# SGLang典型应用场景
# 场景1:多轮对话
conversations = [
{"role": "system", "content": "你是代码审查助手..."},
{"role": "user", "content": "帮我审查这段Python代码..."},
{"role": "assistant", "content": "我已经审查了你的代码,发现以下问题..."},
{"role": "user", "content": "好的,帮我修复第一个问题"},
# ... 可能有20轮以上的对话
]
# 场景2:Few-shot提示
few_shot_prompt = """
请判断以下评论的情感是正面、负面还是中性。
示例1:
评论: "这个产品太棒了,完全超出预期!"
情感: 正面
示例2:
评论: "等了两周还没到货,体验很差。"
情感: 负面
示例3:
评论: "东西收到了,和描述一致。"
情感: 中性
待判断:
评论: "{user_review}"
情感:
"""
# 场景3:RAG增强
rag_context = retrieve_relevant_documents(user_query)
rag_prompt = f"""
基于以下参考资料回答问题。
参考资料:
{chr(10).join([f"[{i+1}] {doc}" for i, doc in enumerate(rag_context)])}
问题: {user_query}
回答:
"""
在所有这些场景中,系统提示、few-shot示例、RAG检索内容这些"固定前缀"被反复使用。传统框架每次都要重新计算这些前缀的KV Cache——这是巨大的浪费。
RadixAttention正是为解决这个痛点而生。
3.2 前缀树的数据结构实现
RadixAttention的底层数据结构是一棵压缩前缀树(Compressed Radix Tree):
# 简化版Radix Tree实现
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
import torch
@dataclass
class RadixNode:
"""Radix Tree的节点"""
# 节点对应的token范围[start:start+len)
tokens: List[int] = field(default_factory=list)
kv_cache: Optional[torch.Tensor] = None
children: Dict[int, 'RadixNode'] = field(default_factory=dict)
ref_count: int = 0 # 引用计数,用于Copy-on-Write
class RadixTree:
"""
压缩前缀树实现
与普通前缀树的区别:相同前缀的连续节点会合并为一个节点
例如:token序列[1,2,3,4]和[1,2,3,5]共享节点[1,2,3]
"""
def __init__(self, max_nodes=10000):
self.root = RadixNode(tokens=[])
self.nodes = [self.root]
self.max_nodes = max_nodes
def insert(self, tokens: List[int], kv_cache: torch.Tensor):
"""
将token序列和对应的KV Cache插入树
如果树已满,触发LRU淘汰
"""
node = self.root
i = 0
# 遍历已有路径
while i < len(tokens):
token = tokens[i]
if token in node.children:
child = node.children[token]
# 检查能否继续匹配
match_len = self._match_length(child.tokens, tokens[i:])
if match_len < len(child.tokens):
# 需要分裂:child.tokens = [a,b,c,d], tokens[i:] = [a,b,e]
# 分裂后: node -> child([a,b,c,d])
# -> new_child1([c,d])
# -> new_child2([e])
self._split_node(node, child, match_len)
return self.insert(tokens[i + match_len:], kv_cache)
# 完全匹配,继续向下
node = child
i += match_len
else:
# 创建新节点
new_node = RadixNode(tokens=tokens[i:])
new_node.kv_cache = kv_cache
new_node.ref_count = 1
node.children[token] = new_node
self.nodes.append(new_node)
return new_node
# 路径完全匹配,更新叶子节点
node.kv_cache = kv_cache
node.ref_count += 1
return node
def find_longest_prefix(self, tokens: List[int]) -> tuple:
"""
查找最长匹配前缀
返回: (匹配长度, 最后一个匹配的节点)
"""
node = self.root
match_len = 0
for i, token in enumerate(tokens):
if token in node.children:
node = node.children[token]
match_len = i + 1
else:
break
return match_len, node if match_len > 0 else None
def _match_length(self, tokens1, tokens2):
"""计算两个token序列的最大公共前缀长度"""
length = 0
for t1, t2 in zip(tokens1, tokens2):
if t1 == t2:
length += 1
else:
break
return length
def _split_node(self, parent, child, split_point):
"""分裂节点"""
# child.tokens = [a,b,c,d], split_point = 2
# 变成: child.tokens = [a,b], new_child.tokens = [c,d]
old_tokens = child.tokens
new_tokens = old_tokens[split_point:]
child.tokens = old_tokens[:split_point]
# 创建新节点
new_node = RadixNode(tokens=new_tokens)
new_node.kv_cache = child.kv_cache
new_node.children = child.children.copy()
new_node.ref_count = child.ref_count
# 更新parent的child引用
parent.children[new_tokens[0]] = new_node
child.kv_cache = None
child.children = {}
def evict_lru(self):
"""LRU淘汰:移除最少引用的叶子节点"""
# 找引用计数最低的叶子节点
candidates = [n for n in self.nodes
if n.ref_count == 0 and not n.children]
if candidates:
victim = min(candidates, key=lambda n: n.ref_count)
# 从parent移除
for parent in self.nodes:
if victim in parent.children.values():
parent.children = {k: v for k, v in parent.children.items()
if v != victim}
self.nodes.remove(victim)
3.3 RadixAttention与多轮对话的实战配合
SGLang的RadixAttention在多轮对话场景中展现出巨大优势:
# SGLang多轮对话示例
from sglang import sgl
@sgl.function
def code_review(s):
# 系统提示:只计算一次,之后所有请求复用
s += "你是一个专业的代码审查助手。\n"
s += "你会仔细分析代码的功能、性能、安全性和可维护性。\n\n"
# Few-shot示例:同样只计算一次
s += "示例1:\n"
s += "代码: def get_user(id): return db.query(id)\n"
s += "问题: SQL注入漏洞\n"
s += "建议: 使用参数化查询\n\n"
s += "请审查以下代码:\n"
s += s["code"]
s += "\n\n审查结果:\n"
s += s.gen(max_tokens=500, temperature=0.7)
# 第一次调用:完整计算系统提示+示例+用户代码
result1 = code_review.run(code="""
def authenticate(username, password):
query = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
return db.execute(query)
""")
# 第二次调用:系统提示和示例完全复用,只计算新代码
result2 = code_review.run(code="""
def process_file(filename):
data = open(filename).read()
return eval(data)
""")
# RadixAttention的KV Cache复用让第二次调用的延迟降低70%+
四、PagedAttention与RadixAttention的协同:vLLM + SGLang的组合拳
4.1 为什么可以组合使用
vLLM和SGLang不是互斥的——SGLang可以后端对接vLLM作为推理引擎:
# SGLang启动配置:对接vLLM作为后端
# sglang_server.yaml
server_args:
model_path: "meta-llama/Llama-3.1-8B-Instruct"
port: 30000
token_limit: 8192
# 启用vLLM后端
backend: "vllm"
# vLLM特定参数
vllm_config:
tensor_parallel_size: 1
gpu_memory_utilization: 0.9
max_num_batched_tokens: 8192
max_num_seqs: 256
# PagedAttention参数
block_size: 16
enable_prefix_caching: true # 开启vLLM的前缀缓存
# 组合使用:SGLang的RadixAttention + vLLM的PagedAttention
# 启动SGLang服务器(后端使用vLLM)
# $ python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct --backend vllm
# 客户端代码
from sglang import gen
# vLLM的PagedAttention处理KV Cache的物理块管理
# SGLang的RadixAttention处理前缀的语义共享
result = gen(
model="meta-llama/Llama-3.1-8B-Instruct",
prompt="解释一下什么是Kubernetes: ...",
max_tokens=500,
temperature=0.7,
stop=["Q:"]
)
这种组合的核心价值:
- vLLM负责底层的物理显存管理(哪些block在GPU上,哪些在CPU上)
- SGLang负责上层的语义缓存管理(哪些前缀相同,可以复用)
- 两者各司其职,互相增强
4.2 内存管理的两层架构
┌─────────────────────────────────────────────────────────┐
│ 应用层 (SGLang) │
│ RadixAttention: 前缀树,语义级共享 │
│ - 识别相同系统提示 → 复用KV Cache │
│ - 识别相同few-shot示例 → 复用KV Cache │
│ - LRU淘汰策略:最少使用的语义节点 │
├─────────────────────────────────────────────────────────┤
│ 引擎层 (vLLM) │
│ PagedAttention: 物理块管理,硬件级共享 │
│ - block table映射:逻辑序列 → 物理块 │
│ - Copy-on-Write:不同请求共享相同物理块 │
│ - LRU淘汰:最少使用的物理块 │
├─────────────────────────────────────────────────────────┤
│ GPU 显存 │
│ [Block 0][Block 1][Block 2][Block 3]... │
│ 每个Block = 16 tokens × hidden_size × 2(K+V) │
└─────────────────────────────────────────────────────────┘
五、性能实测:吞吐与延迟的全面对比
5.1 测试环境与基准设置
# 测试环境
"""
测试配置:
- GPU: A100-80GB
- 模型: Llama-3.1-8B-Instruct (FP16)
- 输入分布:
- 短请求: prompt=512 tokens, max_new=256 tokens
- 中请求: prompt=2048 tokens, max_new=512 tokens
- 长请求: prompt=4096 tokens, max_new=1024 tokens
- 并发: 1-64个请求
- 指标: 吞吐量(token/s), TTFT首token延迟, E2E端到端延迟
"""
import time
import asyncio
from vllm import LLM, SamplingParams
from sglang import sgl
# vLLM测试
def benchmark_vllm():
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
tensor_parallel_size=1,
gpu_memory_utilization=0.9,
)
# 模拟混合负载
sampling_params = SamplingParams(
temperature=0.7,
top_p=0.95,
max_tokens=512,
)
# 单请求延迟测试
start = time.time()
outputs = llm.generate(["测试提示词"] * 1, sampling_params)
latency = time.time() - start
return {
"single_latency": latency,
"throughput": 512 / latency # tokens per second
}
# SGLang测试
def benchmark_sglang():
# 假设服务器已启动在localhost:30000
@sgl.function
def chat(s):
s += s["prompt"]
s += s.gen(max_tokens=s.get("max_tokens", 512))
# 多轮对话测试:验证RadixAttention效果
prompts = [
{"prompt": "系统: 你是一个助手\n用户: 你好", "max_tokens": 100},
{"prompt": "系统: 你是一个助手\n用户: 你好", "max_tokens": 100}, # 重复前缀
{"prompt": "系统: 你是一个助手\n用户: 你好吗", "max_tokens": 100}, # 相似前缀
]
# 运行
start = time.time()
outputs = chat.run_batch(prompts)
batch_latency = time.time() - start
return {
"batch_latency": batch_latency,
"avg_latency": batch_latency / len(prompts)
}
5.2 典型场景性能对比
基于公开基准测试和实际部署经验,以下是两种框架在典型场景下的性能对比:
┌─────────────────────────────────────────────────────────────────┐
│ 场景1: 高并发短请求 │
│ (1000个请求,prompt=512, max_new=256) │
├────────────────┬────────────────┬────────────────┬──────────────┤
│ 指标 │ vLLM │ SGLang │ 胜出 │
├────────────────┼────────────────┼────────────────┼──────────────┤
│ 吞吐量 │ 2,800 tok/s │ 2,600 tok/s │ vLLM (+7.7%) │
│ P99 延迟 │ 850ms │ 920ms │ vLLM (+8.2%) │
│ GPU利用率 │ 94% │ 91% │ vLLM (+3.3%) │
└────────────────┴────────────────┴────────────────┴──────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 场景2: RAG增强长请求 │
│ (100个请求,prompt=8192-doc+query, max_new=512) │
├────────────────┬────────────────┬────────────────┬──────────────┤
│ 指标 │ vLLM │ SGLang │ 胜出 │
├────────────────┼────────────────┼────────────────┼──────────────┤
│ 吞吐量 │ 1,200 tok/s │ 1,650 tok/s │ SGLang(+37%) │
│ P99 延迟 │ 2,800ms │ 1,950ms │ SGLang(+43%) │
│ 相同doc复用率 │ 35% │ 78% │ SGLang(+123%)│
└────────────────┴────────────────┴────────────────┴──────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 场景3: 多轮对话 │
│ (50个会话,每个5轮,平均prompt增长) │
├────────────────┬────────────────┬────────────────┬──────────────┤
│ 指标 │ vLLM │ SGLang │ 胜出 │
├────────────────┼────────────────┼────────────────┼──────────────┤
│ 第1轮平均延迟 │ 420ms │ 410ms │ SGLang(+2%) │
│ 第5轮平均延迟 │ 380ms │ 210ms │ SGLang(+81%) │
│ 显存使用 │ 68GB │ 52GB │ SGLang(+30%) │
└────────────────┴────────────────┴────────────────┴──────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 场景4: 批量离线推理 │
│ (10000个请求,离线批处理,prompt=1024, max_new=256) │
├────────────────┬────────────────┬────────────────┬──────────────┤
│ 指标 │ vLLM │ SGLang │ 胜出 │
├────────────────┼────────────────┼────────────────┼──────────────┤
│ 总耗时 │ 45分钟 │ 52分钟 │ vLLM (+15%) │
│ 吞吐量 │ 4,200 tok/s │ 3,600 tok/s │ vLLM (+17%) │
│ GPU利用率 │ 97% │ 89% │ vLLM (+9%) │
└────────────────┴────────────────┴────────────────┴──────────────┘
5.3 性能差异的根因分析
从数据可以看出几个关键规律:
vLLM的优势场景:
- 短请求、高并发:PagedAttention的块管理开销小,Continuous Batching调度效率高
- 离线批处理:请求之间无共享前缀,RadixAttention的优势无法发挥
- 简单API调用:没有复杂的多轮对话或模板复用
SGLang的优势场景:
- 长请求 + 文档共享:RadixAttention的前缀复用率极高,KV Cache计算量大幅降低
- 多轮对话:越到后期,系统提示等固定前缀的占比越低,复用收益越大
- 复杂提示工程:few-shot示例、思维链模板等结构性内容是RadixAttention的拿手好戏
六、生产选型指南:没有最好,只有最合适
6.1 决策树
开始选择
│
├─── 是否有多轮对话/复杂提示工程?
│ │
│ ├─── 是 ──→ 是否需要极致的多轮对话延迟?
│ │ │
│ │ ├─── 是 ──→ SGLang (RadixAttention大幅降低后续轮延迟)
│ │ │
│ │ └─── 否 ──→ SGLang + vLLM后端 (兼顾灵活性和性能)
│ │
│ └─── 否 ──→ 进入下一问题
│
├─── 是否需要处理长上下文(RAG等)?
│ │
│ ├─── 是 ──→ 文档是否有重复共享需求?
│ │ │
│ │ ├─── 是 ──→ SGLang (RadixAttention文档复用)
│ │ │
│ │ └─── 否 ──→ vLLM (PagedAttention长上下文支持成熟)
│ │
│ └─── 否 ──→ 进入下一问题
│
├─── 是否追求极致单次推理吞吐量?
│ │
│ ├─── 是 ──→ 是否是离线批处理场景?
│ │ │
│ │ ├─── 是 ──→ vLLM (高GPU利用率)
│ │ │
│ │ └─── 否 ──→ vLLM (通用场景最优)
│ │
│ └─── 否 ──→ 进入下一问题
│
└─── 是否需要复杂的多模型编排?
│
├─── 是 ──→ SGLang (更灵活的调度能力)
│
└─── 否 ──→ vLLM (更成熟的工程实践)
6.2 典型行业选型建议
| 行业/场景 | 推荐方案 | 理由 |
|---|---|---|
| AI聊天应用(ChatGPT类) | SGLang + vLLM | 多轮对话,系统提示复用,响应延迟递减 |
| RAG问答系统 | SGLang | 文档片段在多请求间复用,前缀缓存价值高 |
| 代码生成助手 | vLLM | 请求独立性高,短请求多,追求高吞吐 |
| 批量内容生成 | vLLM | 离线批处理,无共享前缀,GPU利用率优先 |
| 复杂Agent工作流 | SGLang | 多步骤编排,需要灵活的状态管理 |
| 实时语音交互 | vLLM | 延迟敏感,短请求,追求TTFT最小化 |
6.3 混合部署策略
对于复杂业务,可以考虑vLLM和SGLang的混合部署:
# kubernetes deployment: 双引擎部署
# deployment-vllm.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference-vllm
spec:
replicas: 3
template:
spec:
containers:
- name: vllm-engine
image: vllm/vllm-openai:latest
args:
- --model=meta-llama/Llama-3.1-8B-Instruct
- --tensor-parallel-size=2
- --gpu-memory-utilization=0.9
resources:
limits:
nvidia.com/gpu: 2
memory: 160Gi
---
# deployment-sglang.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference-sglang
spec:
replicas: 2
template:
spec:
containers:
- name: sglang-engine
image: lmsysorg/sglang:latest
args:
- --model-path=meta-llama/Llama-3.1-8B-Instruct
- --host=0.0.0.0
- --port=30000
- --backend=vllm
resources:
limits:
nvidia.com/gpu: 2
memory: 160Gi
# 路由层:根据请求类型选择引擎
class LLMRouter:
def __init__(self):
self.vllm_endpoint = "http://vllm-service:8000/v1/chat/completions"
self.sglang_endpoint = "http://sglang-service:30000/v1/chat/completions"
async def route_request(self, request):
# 规则1: 多轮对话 → SGLang
if len(request.messages) > 2:
return self.sglang_endpoint
# 规则2: 短请求高吞吐 → vLLM
if request.max_tokens < 256:
return self.vllm_endpoint
# 规则3: 长上下文 + 文档引用 → SGLang
if request.prompt_len > 4096:
return self.sglang_endpoint
# 默认: vLLM
return self.vllm_endpoint
async def forward(self, request):
endpoint = await self.route_request(request)
return await self.call_llm(endpoint, request)
七、生产环境实战:监控、排障与调优
7.1 关键监控指标
无论选择哪个框架,生产环境都需要关注以下核心指标:
# 生产监控指标采集
import prometheus_client as prom
# vLLM关键指标
vllm_metrics = {
# 吞吐量
"vllm_requests_total": prom.Counter("vllm_requests_total", "总请求数"),
"vllm_tokens_total": prom.Counter("vllm_tokens_total", "总生成token数"),
"vllm_throughput": prom.Gauge("vllm_throughput_tokens_per_sec", "实时吞吐量"),
# 延迟
"vllm_ttft_histogram": prom.Histogram("vllm_time_to_first_token_seconds",
"首token延迟", buckets=[0.1, 0.25, 0.5, 1.0, 2.5]),
"vllm_e2e_latency": prom.Histogram("vllm_end_to_end_latency_seconds",
"端到端延迟", buckets=[1.0, 2.5, 5.0, 10.0, 30.0]),
# 显存
"vllm_gpu_memory_used": prom.Gauge("vllm_gpu_memory_used_bytes",
"GPU显存使用", ["device"]),
"vllm KV_cache_usage": prom.Gauge("vllm_kv_cache_usage_ratio",
"KV Cache显存占比"),
# 批处理
"vllm_batch_size": prom.Gauge("vllm_current_batch_size", "当前批大小"),
"vllm_queue_length": prom.Gauge("vllm_pending_requests", "等待处理的请求数"),
# 前缀缓存(vLLM 0.4+)
"vllm_prefix_cache_hit": prom.Counter("vllm_prefix_cache_hits",
"前缀缓存命中次数"),
"vllm_prefix_cache_miss": prom.Counter("vllm_prefix_cache_misses",
"前缀缓存未命中次数"),
}
# SGLang关键指标
sglang_metrics = {
# Radix Tree状态
"sglang_radix_cache_size": prom.Gauge("sglang_radix_cache_size",
"Radix缓存节点数"),
"sglang_radix_hit_rate": prom.Gauge("sglang_radix_hit_rate",
"RadixTree命中率"),
"sglang_radix_evictions": prom.Counter("sglang_radix_evictions",
"Radix节点淘汰次数"),
# 调度
"sglang_running_requests": prom.Gauge("sglang_running_requests",
"运行中的请求数"),
"sglang_waiting_requests": prom.Gauge("sglang_waiting_requests",
"等待中的请求数"),
"sglang_prefill_decode_ratio": prom.Gauge("sglang_prefill_decode_ratio",
"Prefill/Decode时间比"),
}
def collect_and_export_metrics():
"""周期性采集并导出指标"""
while True:
# vLLM指标采集(通过OpenAI兼容API)
stats = get_vllm_stats() # GET /stats
vllm_metrics["vllm_throughput"].set(stats["throughput"])
vllm_metrics["vllm_gpu_memory_used"].labels(device=0).set(
stats["gpu_memory_used"])
# 计算前缀缓存命中率
hits = stats.get("prefix_cache_hits", 0)
misses = stats.get("prefix_cache_misses", 0)
total = hits + misses
if total > 0:
vllm_metrics["vllm_prefix_cache_hit_rate"].set(hits / total)
time.sleep(5)
7.2 常见问题与排障
# 问题1: 显存溢出 (OOM)
"""
症状: GPU OOM错误,推理失败
原因: KV Cache预分配过大,并发请求过多
排查步骤:
1. 检查当前并发请求数
2. 检查GPU显存使用: nvidia-smi
3. 检查vLLM的gpu-memory-utilization参数
解决方案:
- 降低gpu-memory-utilization (0.9 → 0.85)
- 减少max-num-seqs
- 启用prefix caching减少重复计算
"""
# 问题2: P99延迟突增
"""
症状: 延迟突然从200ms跳到2000ms
原因: 长请求阻塞了短请求的decode
排查步骤:
1. 检查是否有异常长的请求进入
2. 查看batch size分布
3. 查看prefill/decode比例
解决方案:
- 启用chunked prefill将长请求拆分
- 调整max-prefill-chunk-size参数
- 考虑prefill/decode物理分离
"""
# 问题3: Radix缓存命中率低(SGLang)
"""
症状: 多轮对话后续轮延迟没有明显下降
原因: 前缀不匹配(空格、换行符差异)
排查步骤:
1. 检查消息格式是否完全一致
2. 检查tokenizer处理差异
3. 查看Radix cache状态: GET /radix_cache
解决方案:
- 统一消息模板格式
- 使用相同的system prompt文本
- 增加radix cache大小
"""
# 问题4: GPU利用率低
"""
症状: GPU利用率只有40-50%
原因: 请求太少,batch无法填满;或请求太同质化
排查步骤:
1. 确认并发请求数足够
2. 检查batch size设置
3. 查看是否是I/O瓶颈(tokenizer/token detokenizer)
解决方案:
- 增加并发或使用请求合并
- 调整tensor-parallel-size
- 启用CUDA graph优化
"""
7.3 调优实战配置
# 高吞吐量场景 (vLLM)
# /etc/vllm/config.yaml
model: meta-llama/Llama-3.1-8B-Instruct
dtype: float16
tensor-parallel-size: 2
gpu-memory-utilization: 0.92
max-model-len: 16384
# 吞吐量优化参数
enable-chunked-prefill: true
max-num-batched-tokens: 8192
max-num-seqs: 256
prefill-chunk-size: 512
# 前缀缓存
enable-prefix-caching: true
# 生产稳定性
enforce-eager: false # 允许CUDA graph优化
# 低延迟场景 (SGLang)
# /etc/sglang/config.yaml
model-path: meta-llama/Llama-3.1-8B-Instruct
host: 0.0.0.0
port: 30000
token-limit: 8192
# 延迟优化参数
mem-fraction-static: 0.88
chunked-prefill-size: 2048
max-running-encode: 64
max-running-decode: 64
# RadixAttention优化
enable-torch-compile: true
八、未来展望:推理引擎的演进方向
8.1 当前瓶颈与演进趋势
2026年的LLM推理引擎仍在快速演进,几个值得关注的方向:
1. Prefill-Decode硬件分离
当前GPU同时处理prefill和decode存在相互干扰。未来可能出现专门的prefill加速器(如Nvidia H100上的独立prefill引擎)或更激进的计算卸载策略。
2. KV Cache的分层存储
GPU HBM → CPU DDR → NVMe SSD的分层KV Cache管理,可以让长序列处理突破GPU显存限制。vLLM和SGLang都在实验这一方向。
3. 前缀缓存的网络共享
在多GPU、多节点场景下,跨节点的Radix Tree同步和KV Cache共享是下一个挑战。想象一下:一个文档的KV Cache只需要计算一次,所有节点共享。
4. 投机解码的工程化
Speculative Decoding(用小模型猜测、大模型验证)正在从论文走向生产。但验证阶段的高效调度仍是工程难题。
8.2 SGLang和vLLM的融合
有趣的是,vLLM和SGLang正在互相借鉴:
- vLLM 0.4+引入了前缀缓存机制(类似RadixAttention的部分能力)
- SGLang支持vLLM作为后端(PagedAttention的能力)
未来可能出现一个融合两者优势的"统一推理引擎",同时具备:
- PagedAttention的物理块管理
- RadixAttention的语义缓存
- 智能的prefill-decode调度
- 分层KV Cache支持
总结:工程哲学的选择
回到开篇的问题:vLLM和SGLang,代表了LLM推理工程化的两个根本性哲学:
vLLM哲学:相信底层的物理效率决定一切。从KV Cache的块管理到批处理的流水化,vLLM把GPU当成一台精密的物理机器,穷尽每一个晶体管的价值。
SGLang哲学:相信上层的语义结构决定一切。从RadixAttention的前缀共享到提示工程的编译优化,SGLang把LLM工作流当成一段程序,用编译器的思维去优化它。
没有绝对的优劣,只有场景的契合:
- 追求极致单次吞吐 → vLLM
- 追求多轮对话体验 → SGLang
- 想兼得 → SGLang + vLLM后端
对于工程师而言,理解这两种哲学比记住参数配置更重要。因为当你在debug一个延迟突增的问题时,你需要知道的不是"应该设哪个参数",而是"vLLM的调度器在等什么"或"SGLang的Radix树为什么没命中"。
这才是工程直觉的真谛。
参考资料
- vLLM Paper: "Efficient Memory Management for Large Language Model Serving with PagedAttention" (SOSP 2023)
- SGLang Paper: "SGLang: Efficient Execution of Structured Language Model Programs" (SOSP 2024)
- Continuous Batching: "Orca: A Distributed Serving System for Transformer-Based Generative Models" (OSDI 2022)
- RadixAttention: LMSYS Blog & SGLang Documentation
- Prefix Caching: vLLM 0.4 Release Notes
标签: vLLM|SGLang|LLM推理|PagedAttention|RadixAttention|Continuous Batching|GPU优化|推理引擎|性能优化|深度学习
关键字: vLLM, SGLang, LLM推理, PagedAttention, RadixAttention, Continuous Batching, GPU优化, 推理引擎, 性能优化, 深度学习