编程 vLLM 与 SGLang 深度横评:两种推理范式的工程哲学对决

2026-07-24 06:14:45 +0800 CST views 7

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

静态批处理有三个致命问题

  1. 短请求饿死:一个只需要生成50个token的短请求,必须等到4个请求都完成才能开始
  2. GPU空转:等待批次攒满时,GPU完全空闲
  3. 资源浪费:短请求完成后,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树为什么没命中"。

这才是工程直觉的真谛。


参考资料

  1. vLLM Paper: "Efficient Memory Management for Large Language Model Serving with PagedAttention" (SOSP 2023)
  2. SGLang Paper: "SGLang: Efficient Execution of Structured Language Model Programs" (SOSP 2024)
  3. Continuous Batching: "Orca: A Distributed Serving System for Transformer-Based Generative Models" (OSDI 2022)
  4. RadixAttention: LMSYS Blog & SGLang Documentation
  5. Prefix Caching: vLLM 0.4 Release Notes

标签: vLLM|SGLang|LLM推理|PagedAttention|RadixAttention|Continuous Batching|GPU优化|推理引擎|性能优化|深度学习

关键字: vLLM, SGLang, LLM推理, PagedAttention, RadixAttention, Continuous Batching, GPU优化, 推理引擎, 性能优化, 深度学习

推荐文章

使用 Go Embed
2024-11-19 02:54:20 +0800 CST
Vue中的样式绑定是如何实现的?
2024-11-18 10:52:14 +0800 CST
go发送邮件代码
2024-11-18 18:30:31 +0800 CST
如何在 Vue 3 中使用 Vuex 4?
2024-11-17 04:57:52 +0800 CST
给Go程序加个沙箱:go-landlock
2026-07-03 06:32:08 +0800 CST
程序员茄子在线接单