ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SGLang核心解析:Radix Attention与DSL如何重塑LLM推理调度

SGLang核心解析:Radix Attention与DSL如何重塑LLM推理调度 每次有人把SGLang、vLLM这两个名字摆在一起我都习惯先反问一句你的流量长什么样SGLang这个名字在LLM推理评测里出现得越来越多几乎必然和Radix Attention、领域特定语言DSL绑在一起。如果你只把它当成“又一个推理加速工具”其实只碰到了表面一层。它真正想解决的不是单次prefill跑多快而是当大量请求之间共享prefix、还得按不同流程分支跳转时系统能不能把重复计算省下来、把并发调度做得足够聪明。这篇内容适合给正在做LLM应用服务化、Agent编排、批量推理的同学看我会把这两个核心概念拆开揉碎再讲清楚它们之间为什么是配套关系最后落到部署实操上。1. 先别急着对比vLLMSGLang要解决的问题是“应用级调度”1.1 一句话说清它是什么SGLang是斯坦福LAI Lab主导的框架完整名字很长但核心意思并不复杂用一套领域特定语言表达“需要LLM做什么”再用一个运行时系统把这些表达高效地调度到GPU上。市面上绝大多数推理框架比如vLLM重心在“单次请求怎么进来、怎么分配KV cache、怎么连续batching、怎么把吞吐拉满”。SGLang的起点更高一层它默认一个事实你的程序里不只有一句话而是有系统提示词、用户多轮对话、结构化输出、分支、并行生成、Agent里反复调工具。把这些组成一个“程序”来看而不是拆成一个个孤立的HTTP请求才有机会做跨请求、跨步骤的优化。Radix Attention是这个运行时系统里最出名的一块负责缓存复用DSL则是前端表达层负责描述程序结构。两者分开看都是普通技术合在一起才成立。1.2 我实际遇到的三类痛点最终都指向同一个答案先说前几年我踩过的坑这样大家更容易理解SGLang出现的动机。第一类是多轮对话的TTFT问题。用户每次发新消息经典实现都要把整个历史重新拼一回模型对前面所有的token重新做prefill。长会话到十几轮之后几个用户同时发消息整批请求的最长公共前缀明明一模一样系统却在每个请求上重复算这一大段KV。第二类是批量评测或者知识库问答。一个数据集的数千道问题往往共享同一个巨大的system prompt甚至共享同一批few-shot示例。很多推理框架把每道题都当成独立请求处理几千道题就把同一段system prompt重复prefill几千次这是纯粹的浪费。第三类是Agent场景。Agent循环里常常要做多步工具调用每步调用之后要把工具结果追加到历史里再问一次模型也就是说每一步的请求前缀都比上一步更长而前面那段长历史在每步之间可能完全相同。手动管理这种动态前缀缓存写起来非常痛苦而且容易错。这三类问题靠单点算子优化很难根治。直到我后来把模型服务切到SGLang看到Radix Attention把请求前缀组织成树来做复用才意识到它们本该用同一个机制解决。1.3 把Radix Attention和DSL放在同一张图上理解这两者的关系可以类比成DSL是“我要生成什么”的图纸Radix Attention是“怎么让那些生成请求跑得快”的施工方。图纸上写着哪些片段是共享模板哪些片段是运行时变量施工时就能按图把共享片段缓存下来让每个人少干活。注意这里的“领域特定语言”不是用来给模型写提示词的而是用来让工程师描述“模型调用之间的组合关系”的。同一个语言结构里哪些字符串会保持不变、哪些文本要等上一个模型输出后才出现、哪些分支会并行发散这些信息对调度器极有价值。我自己更愿意把它理解成SGLang想把“写提示词”这件事逐步变成“写生成程序”只不过这个程序天然要考虑GPU显存、前缀缓存、Attention内核这些底层东西。2. Radix AttentionKV缓存复用从“整段匹配”升级成“最长公共前缀匹配”2.1 前缀缓存的初衷省掉的全是重复prefill先复习一个基础但关键的事实。LLM在生成每个token时都要访问历史token的KV向量。请求第一次到达时GPU需要从零开始计算整个prompt的KV cache这个过程叫prefill。prompt越长prefill的计算量越大。多个请求之间如果存在相同前缀理论上KV cache是可以复用的先算出的那个请求把KV存在显存里后来的请求直接取出来用就行不需要重新计算。朴素的前缀缓存做法是按“完整请求序列”为单位缓存。比如有100个请求都完全共享“系统提示词固定few-shot”这100个请求的前缀一模一样那么缓存可以命中。但现实里的请求结构远没有这么整齐。一批请求可能共享前500个token另一部分共享前300个token还有几个只共享系统消息的开头几十个token。如果采用满序列前缀匹配就只能匹配“能整段对上缓存项”的那批请求剩下很大一部分发生共享的请求无法收益。传统前缀缓存是线性思维把每个请求的前缀看成一条直线。但把多个请求放在一起看它们的前缀关系天然是一棵树大家从同一个根出发走到某个位置后分叉走一段又分叉。2.2 从单链条缓存到基数树分叉才是常态Radix Attention的核心数据结构是用一棵radix tree来组织所有请求历史中出现过的token序列。radix tree也叫基数树或前缀树每个节点保存一段连续的token序列该节点对应的KV cache会被完整保存。从根到某个节点的路径就对应一个完整前缀。当一个新请求到达时SGLang从根开始尽可能沿着树向下匹配这个请求的token。如果发现当前节点里的token序列在这个请求的前半部分正好全部命中就直接复用那一段的KV cache之后遇到树里没有的新token才从那里开始计算新的KV cache再把新计算出的部分作为新节点挂到树上。如果新请求命中的不是某个完整节点而是节点中间的某一部分这个节点就会被“分裂”。举例来说假设树里已经有一个节点保存了“系统你是一名数据工程师。请解释什么是”这段token这时候来了一个新prompt前半段相同但到了“数”字之后要走不同方向那么原节点中间就会多出一个分叉点树自动把原来的长节点拆成公共前缀节点和可复用的后半段节点新请求单独的分支再从分叉点延伸出去。这种结构的威力在于共享粒度是真正的“最长公共子前缀”而不是“某个整段模板”。比如下面三个请求A系统你是一名数据工程师。请解释什么是数仓分层B系统你是一名数据工程师。请解释什么是数据湖C系统你是一名心理咨询师。请解释什么是边界感A和B共享“系统你是一名数据工程师。请解释什么是数”A、B、C三者共享“系统你是一名”。radix tree能同时记住这些不同长度的共享段。下一次再来一个“系统你是一名数据工程师。请解释什么是数据网格”它只需要算出“数据网格”这几个新token其余部分全部复用。这个能力对多轮对话场景几乎是量身定做的。2.3 树不是建完就完了引用计数、淘汰策略和块级内核只要树建起来下一步问题就是显存管理。LLM的KV cache占显存非常厉害radix tree会把大量历史请求的KV cache都留在显存里等待复用如果不管理显存很快就爆。这就需要在树结构上做淘汰策略。我知道的工程实践里SGLang的RadixCache实现并不是字面上每来一个请求就保存一份完整KV而是维护节点的引用计数和访问统计。正在被当前批量请求使用的节点绝对不能驱逐对那些已经完成服务、暂时没有请求引用的节点在显存不足时会按照LRU类策略优先淘汰最久没被命中的节点。有些节点代表高频公共前缀比如长系统消息几乎每个请求都会命中它这类节点保留优先级就很高而某些随机生成的临时分支长时间没人用下一轮淘汰就会先落到它们头上。还有一个容易被忽略的点radix tree里缓存的是token序列级别的KV cache对Attention内核层来说Attention计算时KV来源可能同时跨多个树节点而这些节点在显存里并不连续。因此radix tree方案要配合一套能从多个显存块里取KV并完成Attention的内核实现。这也是为什么radix tree不是只在Python层做数据结构就行真正性能好不好还要看底层kernel能不能把这种“跨块复用”的Attention打满算力。我见过不少朋友以为Radix Attention只是一个“Python缓存字典”把prompt映射到KV就行实际偏差很大。它的难点其实在树节点分裂的代价控制、淘汰策略的保活优先级、以及最终Attention算子上。这些细节做得不好你会在日志里看到命中率不低但整体吞吐没有提升多少。2.4 用三个判据看你的流量能不能吃满这个优化不是所有场景都能从radix cache里获益。我用下来总结出三个判断条件基本可以覆盖大多数情况。第一公共前缀占比高不高。如果你线上每个请求都带一个几百token的复杂system prompt或者都嵌入了相同的few-shot那么公共前缀占比就会很高radix cache的收益会非常明显。如果请求之间完全随机每个请求前缀几乎没有交集那树再聪明也找不到可复用的东西。第二请求之间是否为“聚集-分叉”模式。多轮对话天然符合同一个用户的新一轮请求包含之前整段历史和上一轮共享那段长前缀Agent循环更符合每一轮比上一轮多出一段工具结果长历史反复出现。这类模式里树的每个分支都会频繁被后续请求命中。第三最近窗口内是否持续有复用流量。缓存有显存成本如果你让一批共享长前缀的任务一次性跑完收益会很大如果流量断断续续每两个复用请求之间隔了很久淘汰策略可能已经把这个前缀清掉了下次来又要重新算。判断方法也很简单——看服务日志里有没有输出cache hit相关指标或者自己对比冷缓存和预热后同一批请求的TTFT。如果预热后首token延迟明显下降说明这个场景吃到了红利如果基本没变化就别把性能预期寄托在radix cache上。3. 领域特定语言让“提示词模板”升级为“可被调度的生成程序”3.1 常规字符串模板在复杂场景里为什么会失控大多数项目现在写LLM调用还是用字符串模板硬拼。比如把system prompt定义成一个常量字符串再把用户输入做一次格式化最后拼出完整的messages列表传给服务端。简单场景这样做没问题但一旦场景复杂起来问题就开始堆积。举个例子Agent工具调用循环里第一步要先生成一个初步答案第二步要把答案交给另一个分支去验证第三步还得在两个候选方案之间做比较。用普通字符串模板你不得不在业务代码里堆一堆if else和Python列表拼接再一遍遍调API。这个时候prompt已经不是一个静态文本而是带控制流和数据依赖的程序。字符串层面根本看不出哪段前缀在多个分支里重复缓存优化也就无从谈起。还有一个反直觉的问题你觉得自己写的是“模板变量”但为了把流程拆开业务代码里往往被迫在多个位置重复拼接同一段system prompt。每次重复拼接出来的文本哪怕差一个空格在token层面就是完全不同的前缀缓存立刻失效。我在代码评审里见过太多次同一个system prompt在不同分支里被用不同换行方式拼出来结果前端变量看着很规整后端KV缓存命中率极低。3.2 一段最小DSL看看它到底描述了什么东西SGLang的DSL思路是让工程师用带装饰器的Python函数定义生成流程而不是拼字符串。下面是这个思路的最小示例大家先感受一下画风import sglang as sgl sgl.function def simple_qa(s, question): s sgl.user(你是擅长技术解释的助手。我的问题 question) s sgl.assistant(sgl.gen(answer, max_tokens256)) sgl.set_default_backend( sgl.OpenAI(base_urlhttp://127.0.0.1:30000/v1, api_keyEMPTY) ) state simple_qa.run(question请用通俗语言解释Radix Attention) print(state[answer])这个函数看起来像普通Python但需要理解它的运行方式。首次定义时sgl.function会把这个函数记录为一个可执行的生成程序真正运行simple_qa.run(...)时框架不是简单执行一遍Python函数而是按执行路径去追踪每一步s xxx累积了哪些prompt文本并把gen(answer)当作一个需要模型填充的节点。我最初也不太习惯这种抽象总觉得不如直接requests调API直接。但用多了之后发现它把一件很重要的事情固定下来了提示词的常见组成部分被显式表达成了结构化的“状态累积”和“生成节点”。以后你想在中间加一步并行生成、加一个结构化输出限制都不用把外层调用逻辑全部重写。DSL还提供了一些只在“程序结构”层面才能出现的原语比如fork可以在一个生成流程里并行发散出多个分支让同一个问题同时生成多种候选方案之后再用另一个生成节点做打分或选择。这在Radix Attention的配合下尤其有意思多个并行分支共享前一段prompt那一段KV cache在树里只需一份并行分支各自从分叉点形成树的不同子节点。3.3 静态结构如何反哺Radix Attention需要特别注意DSL不只是让代码好看它在SGLang的“系统级优化”里承担了重要职责。调度器能够利用DSL里可静态分析的控制流信息提前判断哪些字符串是常量的哪些文本要等模型运行之后才能确定。例如在刚才的s sgl.user(你是擅长技术解释的助手...)里开头的system prompt是纯字面量所有用户请求只要调用这个函数都会共享这串前缀。SGLang在后端维护radix tree时对这类常量前缀有更强的缓存倾向。当一个服务里有大量请求都运行同一个DSL函数时相当于大家约定好了“前几十个token永远不变”。这种结构性约定会让radix tree的命中目标更稳定。相比之下纯HTTP API模式下调度器只能被动看到一堆独立请求根本不知道这次请求和下一次请求外层流程是什么关系。DSL等于把外层流程信息“喂”给了调度器让它知道哪些地方适合在树上做分叉缓存哪些分支生成结束后应该清理。更深一层DSL的编译和执行是延迟绑定的同一个函数在多次调用之间可能有不同的参数但函数内部那些字面量文本可以预先展开成可复用的树节点。这是SGLang架构上很妙的地方用编程语言结构来指导KV缓存复用的顺序和粒度而不是把所有文本flat成一个巨型序列再等着被切。3.4 哪些情况别硬上DSLDSL也不是万能的。我用下来的经验是如果你的业务非常简单一个system prompt加一个user message就完事完全没必要引入这一层抽象直接用OpenAI兼容接口会省很多事。DSL的收益要在结构复杂度上去之后才开始显现否则只会增加理解成本。另外DSL里能写的控制流其实是一个“受控子集”。你可以在程序里做循环、条件判断、生成节点但并不是所有Python写法都适合塞进去。像访问外部任意IO、依赖多线程共享状态这种写法放到DSL执行模型里容易翻车。我建议把DSL函数当作一个独立的小型编排单元不要在它内部堆太多业务副作用保持函数职责单一。最后提醒一点因为SGLang的版本迭代很快不同版本的API细节和prompt行为可能有差异。生产环境里务必固定一个版本做回归测试别在升级后直接裸奔上线。4. vLLM与SGLang的真实差异别只看单测benchmark4.1 表面上都提供OpenAI兼容服务内核取舍不同vLLM和SGLang都能对外提供/v1/chat/completions接口都支持连续batching底层也都在优化KV cache的显存效率。但两家的核心取舍是有区别的。vLLM走的是“把标准推理引擎做到极致”的路线。它的PagedAttention很精准地解决了KV cache显存碎片问题之后把大量精力放在调度策略、投机采样、量化推理生态上。它不太关心你的请求是怎么编排出来的它面对的单位永远是单个请求内部的attention计算。SGLang更在意“多个请求之间的复用”和“结构化生成程序执行”。服务端处理的对象不只是单个请求而是一棵不断生长的前缀树。可以把vLLM当成一个非常高效的“单发执行器”SGLang更像一个“带缓存的批量调度工作台”。如果单发执行器每次都把工作交给GPU重新算一遍调度工作台可能会说这批工作之前干过一大半剩下的活我来分配。我见过有人在SGLang的issue区问“为什么vLLM也有prefix caching难道不是一回事吗”。严格说vLLM近几个版本确实加入了prefix cache也会把相同前缀的KV block复用起来。但SGLang的radix tree在“多个同类后缀分支共享同一个长前缀”这种场景下结构上表达得更自然。而且SGLang把前缀复用和DSL结构绑定在一起后调度器能主动预测哪些节点会在下一步被高频访问。4.2 我认为更适合SGLang的几类流量实话说我已经把内部好几套服务都迁到了SGLang。从实践看下面几类流量在SGLang上的收益最明显。第一类是长上下文多轮对话。会话历史越来越长每一轮新的请求都天然带上一个冗长而固定的前缀。radix cache能让你在用户后续发消息时几乎不用重新prefill前面的对话历史。多用户同时在线时虽然每个用户的对话内容不同但只要都带了同一个超长system prompt这个公共根的KV cache就是共享的。第二类是批量评测和离线推理。上千条测试样本共享同一份prompt模板切换成SGLang后速度提升不是百分之几而是你可能需要重新评估整条数据管线的程度。第三类是Agent循环和工具调用。每一步追加的工具结果都让前缀多出一段而前面的历史被反复使用。SGLang在执行每一步时通过DSL里的状态累积能精确复用上一步算过的KVAgent场景的每一轮额外延迟因此会被砍掉不少。第四类是有并行分支的生成任务。需要先生成一个主答案再并行派生多个验证方向最终合并结果并打分。这类任务在纯字符串流程里你要自己管理多个并发请求还可能重复计算公共prompt。在SGLang里写成一个带fork的DSL程序运行时能自动识别哪些前缀属于公共段。4.3 适合继续留在vLLM的情况不必因为SGLang在某些benchmark上好看就觉得vLLM没有存在价值。我在下面这些情况里会继续倾向用vLLM。如果团队对vLLM生态已经非常熟悉内部监控、告警、镜像、量化工具链都围绕vLLM做好了为了一个可能的20%吞吐收益去换框架并不划算。运维心智的隐性成本往往比看得见的benchmark高很多。如果你的主要目标是单一模型对外开放大规模服务请求之间相关性很低前缀共享率不高那么vLLM的成熟度、文档数量、周边插件覆盖度都更省心。尤其很多开源项目默认适配vLLM的接口参数接进去几乎零改造。还有一点如果你非常追求“极简可预测”。SGLang启动时会做很多调度和缓存初始化结构上要处理的内部状态更多调试理解的难度通常高于vLLM的PagedAttention模型。越是核心金融、生产告警这类“不能出乱子”的线上服务我越倾向选择团队手里掌控得更熟的那套。4.4 一份按需求打的选型表你的主场景建议倾向我的理由单模型大规模并发前缀共享少vLLM成熟稳定生态完善调度策略经过大量生产验证长多轮Chatsystem prompt很长SGLang树形前缀复用收益直观用户后续轮次TTFT明显下降离线批量评测共享同一套模板SGLang大批请求共享长前缀时cache命中收益容易顶满Agent工具循环、多步调用历史SGLang每步历史复用是radix tree天然擅长的形态需要OpenAI兼容服务、周边工具深度绑定vLLM周边兼容性最丰富问题也最多人踩过自带复杂并行/分支/结构化生成流程SGLang有DSL做控制流表达后端才能帮你调度这张表不是绝对真理因为两个项目都在快速迭代。但“选型看你的流量结构是否适合复用”这一条判断标准不会过时。我的习惯是先拿一周线上请求日志做离线回放在两边各跑一遍看吞吐、TTFT、显存占用再决定。5. 从一棵树开始实践SGLang本地部署与缓存命中的观察姿势5.1 安装和最小服务启动如果你已经决定亲手试一下SGLang先准备一台有NVIDIA GPU的机器显存建议至少24GB个人调试用一张消费级卡也足够了。Python环境建议用3.10以上新建一个虚拟环境别直接装在系统环境里。最简单的安装方式是pip install sglang[all]这里[all]会把常见后端依赖都装上。如果你只想跑CPU demo或特定后端也可以去掉all只装核心包但对大多数想复现Radix Attention效果的用户来说all是最省事的。启动一个模型的命令类似这样python -m sglang.launch_server \ --model-path Qwen/Qwen3-8B \ --host 0.0.0.0 \ --port 30000 \ --mem-fraction-static 0.8--mem-fraction-static控制KV cache预分配的显存比例这个值调大会给radix cache留更多空间但如果模型本身很大预留太少反而容易爆显存。我一般会在8B模型上从0.8开始试如果是70B以上模型就要小心调低到0.7甚至更低。注意不同版本部分参数名会变。SGLang更新很快如果启动时报参数不认识优先查当前版本python -m sglang.launch_server --help的输出。5.2 OpenAI客户端接入模型名这个坑先踩掉服务起来后可以先用curl确认它真的在跑curl http://127.0.0.1:30000/v1/models如果返回的列表里模型名和你的预期不一致不用慌。SGLang默认会用模型路径对应的名字你也可以在启动时加上类似--served-model-name的参数自定义对外暴露的名称。很多新手第一步就卡在这个模型名上明明本地路径是Qwen/Qwen3-8B请求时报错model not found十有八九是客户端传的模型名和服务端不匹配。用OpenAI Python SDK接入只需要设置base_urlfrom openai import OpenAI client OpenAI( api_keyEMPTY, base_urlhttp://127.0.0.1:30000/v1 ) resp client.chat.completions.create( modelQwen/Qwen3-8B, messages[ {role: system, content: 你是编程助手回答尽量简洁。}, {role: user, content: Radix Attention里树节点分裂会带来什么开销} ] ) print(resp.choices[0].message.content)api_key填什么都可以本地服务一般不会校验。这一步能通说明基础链路已经打通。5.3 多轮请求里如何确认radix cache真正生效服务跑通之后最值得做的一件事是验证radix cache真的命中而不是只在PPT上听说。先准备一个固定的系统提示词长度写长一点最好在500token以上。然后把同一段历史连续发送两次。第一次请求属于冷启动系统需要完整prefill第二次请求只要前缀没被淘汰就应该能从radix tree里复用大量KV cache。观察的位置有两个。一个是服务端日志把日志级别调到info后很多SGLang版本会在请求处理时输出cache hit、cache miss或类似字段。具体字段名会随版本变化但看到类似“hit”指标出现就说明树里发生了复用。第二个观察点更直观比较两次请求的首token延迟和prefill耗时。第二次如果明显比第一次快甚至快到百毫秒以内那基本就是吃到了前缀缓存。一个容易踩的坑是如果你把第二次请求的系统提示词末尾多打了一个空格或换行符不一样token序列就被改变跨请求缓存直接失效。这种问题在字符串模板时代经常发生迁移到DSL之后反而能规避因为模板文本被框架规范化了。我自己早期排查性能不稳定时经常发现只是业务代码在拼接prompt时多了一个\n导致radix cache命中率忽高忽低。5.4 顺带说一句“SGLang提Qwen3-Reranker”这类问题最近看到一些朋友问“能不能用SGLang跑Qwen3-Reranker”这类问题正好在这个实践章节里统一说下我的理解。SGLang的核心场景是decoder-only的生成模型负责把prompt变成连续token文本输出。而Qwen3系列里像reranker这类模型通常承担的是“给文本对打分排序”的任务输入输出形态和自回归生成模型不一样。我的习惯是分工使用LLM生成主流程放在SGLang里跑享受radix cache和DSL带来的加速RAG流程里的rerank阶段再用专用模型服务或本地推理库做。二者通过业务管线的API串起来可能比强行把所有模型塞进同一个serving框架更稳妥。另一个网络上的高频问题是“SGLang和vLLM到底怎么选”。我的答案从来不是“谁快选谁”而是“先看你的请求结构和团队已经掌握的工具栈”。如果大量的代价都来自重复前缀计算那SGLang的结构化缓存能力会带来革命性的体验变化。如果只是需要一个坚如磐石的通用服务vLLM的成熟生态依然是靠谱选择。我自己在SGLang多轮和Agent场景上吃到过很明显的甜头所以这两个项目在我这里不是替代关系而是各自服务不同流量模板的工具。只要流量结构判断对了部署哪个都不会太差。
返回列表