SGLang

Efficient Execution of Structured Language Model Programs

结构化语言模型程序的高效执行:从前端程序、前缀缓存到受约束解码

01. 论文概览

一句话总结
SGLang 把多次模型调用视为可编程工作流,并通过前端与运行时协同,复用共享前缀的 KV 缓存、加速格式约束解码以及减少部分黑盒 API 调用。

论文:SGLang: Efficient Execution of Structured Language Model Programs
代码:sgl-project/sglang

最高 6.4×吞吐量提升论文中的最大值,依赖任务与比较对象,并非所有场景的平均值。
1.6×JSON 吞吐量压缩 FSM 在论文指定的 JSON 基准上的消融结果。
52.4% / 74.1%线上缓存命中率论文报告的两个模型、一个月观测值。

02. 背景与预备知识

语言模型应用常由多次调用组成:例如先检索资料,再生成答案,或将任务拆成并行分支后汇总。这些调用往往重复使用系统提示、示例或对话历史。若每次都从头处理相同前缀,就会产生重复计算。

理解后文只需区分两个推理阶段:预填充(prefill)处理输入提示并生成键值缓存(KV cache);解码(decode)利用已有缓存逐个生成新 token。KV 缓存保存注意力所需的中间结果,使模型不必在每一步重新计算整个历史。

图解|相同前缀:重复计算与缓存复用
不复用:每次重新处理

请求 A:公共前缀 + 问题 A
→ 计算公共前缀和问题 A → 生成答案 A

请求 B:公共前缀 + 问题 B
→ 再次计算公共前缀和问题 B → 生成答案 B

复用:公共部分只需计算一次

请求 A:计算公共前缀 → 保存其 KV 缓存
→ 处理问题 A → 生成答案 A

请求 B:匹配并读取公共前缀的 KV
→ 处理问题 B → 生成答案 B

论文的关键思路是把这些共享前缀组织成树,跨请求保留和查找对应的 KV 缓存;它还利用程序的并行结构与输出格式约束减少额外工作。接下来先看前端如何表达多调用程序,再看运行时如何执行。

图解|程序结构如何帮助运行时减少工作
输入:一个包含多次调用的语言模型程序例如:共享系统提示,对同一问题进行多维评价,并要求输出 JSON。
↓
① 共享前缀 → KV 缓存复用

请求 A:公共提示 → 问题 A
请求 B:公共提示 → 问题 B

将公共提示存为树中的共同路径;模型、位置等条件兼容时,后续请求可读取匹配前缀的 KV,只计算未命中的后缀。

② 独立分支 → 并行执行

共同上下文 → 清晰度评价
     ↘ 证据评价

互不依赖的分支可以同时执行;汇总时等待所需的分支结果。

③ 格式约束 → 跳过确定片段

JSON 模板:固定键名 → 模型生成字段值

压缩状态机识别唯一合法的固定文本并跳转,在需要选择内容时继续解码。

最终效果:减少不同类型的额外工作

① 减少重复 prefill
② 缩短独立分支的等待路径
③ 减少固定格式上的解码轮次

03. 编程模型:把依赖关系暴露出来

一项任务可能需要多次调用模型。SGLang 的前端让你在 Python 中写清楚:先做什么、哪些步骤可以同时做、最后要等哪些结果。论文用一个作文评审程序说明这个过程(Fig. 2)。

图解|作文评审程序怎么运行
① 输入图片和作文把图片、作文和评审要求放进提示。
↓
② 判断作文是否与图片相关调用模型,在“相关 / 不相关”中选择。
↓
不相关

提前结束,不再进行后续评审。

相关

复制当前提示,进入下面三个独立的评审分支。

↓ 仅相关时继续
③ 评清晰度作文是否表达清楚
③ 评原创性观点是否有新意
③ 评证据论述是否有依据
↓ 等待三个结果
④ 汇总并输出收集评语,生成摘要和等级,再按指定格式输出 JSON。
抽象图|先后、并行与汇合
多调用程序的抽象依赖图输入先经过步骤 A,之后分为可以并行的步骤 B 和步骤 C;步骤 D 等待两个步骤完成,然后输出结果。输入步骤 A先完成步骤 B可与 C 并行步骤 C可与 B 并行步骤 D汇合结果输出

图中的先后关系就是依赖关系:评审能否开始,取决于相关性判断;最终汇总,取决于三个评审的结果。三个评审彼此不需要等待,所以可以同时进行。

代码里,select 用于从候选项中选择,gen 用于生成文本,fork 用于创建并行分支。程序读取某个尚未生成的结果时会等待;不需要它的其他工作则可以继续执行。

这些信息也能帮助运行时:三个分支一开始有相同的提示,运行时可以尝试复用其前缀缓存;JSON 格式则为约束解码提供规则。论文实验默认使用解释器执行程序;后文将分别介绍前缀缓存和受约束解码的实现机制。

04. RadixAttention:以树管理跨请求缓存

问题:共享前缀会在不同层次出现

多个请求可能共享系统提示;相同 few-shot 例子之下又有不同问题;同一问题还可能分出多条采样路径。简单的整条请求缓存只能命中完全相同的请求,而只缓存一个固定系统提示又无法表达动态的多级分支。论文的方案把每条 token 路径组织进压缩前缀树(radix tree)。

图解|同一前缀为什么不必重复计算
第一次请求:建立缓存

系统提示 + 示例 + 问题 A

↓

完整 prefill → 生成答案 A → 将可复用的 token / KV 路径写入树

第二次请求:命中前缀

同样的系统提示 + 示例 + 问题 B

↓

最长前缀匹配 → 引用共享 KV → 只对未命中的后缀继续 prefill → 生成答案 B

每条边可以代表一段连续 token,关联其 KV 缓存位置;多个请求沿相同路径引用公共前缀。新请求到来时,从根开始寻找最长匹配;若匹配发生在边的中间,就将边拆分,使共享部分成为单独的祖先。论文 Fig. 3 展示了会话、few-shot、分支采样交替到来时的插入、拆分与淘汰过程。

图解|树如何随请求到来而变化

下面用“系统提示 S、问题 A、问题 B”代替 token 序列;每一段旁的 KV 表示该段对应的缓存位置。实际树边可以包含多个 token。

① 请求 A 完成:先存成一条路径
根
 └── [S + 问题 A] → KV(S + A)

系统提示 S 与问题 A 一起构成一段边,缓存位置被保留。

② 请求 B 到来:匹配并拆分
根
 └── [S] → KV(S)
      ├── [问题 A] → KV(A)
      └── [问题 B] → KV(B)

新请求也是从 S 开始:把原边拆成公共部分 S 和后缀 A;B 复用 S 的 KV,并为自己的后缀新增分支。

③ A 不再使用且需要空间:回收叶子
根
 └── [S] → KV(S)
      └── [问题 B] → KV(B)

回收:[问题 A] → KV(A)

假设 A 是可淘汰的 LRU 叶节点,回收 A 后,B 仍可沿祖先 S 找到共享缓存。正在使用的节点不能淘汰。

④ 又来一个请求 C:沿树继续查找
根
 └── [S] → KV(S)
      ├── [问题 B] → KV(B)
      └── [问题 C] → KV(C)

如果 S 仍在缓存且相关条件兼容,C 可以复用它,再计算并插入自己的后缀。

因此,树本身是缓存的索引和共享关系:从根到某个位置的路径表示一个 token 前缀;分叉表示共享部分之后出现不同后缀;拆分让公共部分成为共同祖先;叶子淘汰则优先保留仍可能被其他分支使用的祖先。树节点记录缓存位置和管理信息,实际 KV 张量存放在缓存内存中。

rootshared prefixKV stored oncequestion A → answer Aquestion B → answer B

缓存生存期与内存回收

图解|一份 KV 缓存从使用到回收
① 请求运行中公共前缀 S 和分支 A 正在被请求使用,相关节点受到保护。
→
② 请求 A 结束A 的引用释放;缓存暂时保留,后来的请求可以复用。
→
③ 内存不足从当前未被引用的叶节点中,选择最近最少使用的叶子回收。
→
④ 空间再次分配释放的内存可供新请求使用;仍有用的公共前缀继续保留。
图解|内存紧张时,为什么从叶子开始?
回收前:B 正在运行,A 已空闲
根
 └── S 公共前缀  🔒
      ├── A 后缀  ○ 空闲叶子
      └── B 后缀  🔒 运行中

S 同时是 A、B 的公共前缀。B 正在使用 S → B 这条路径,所以这些节点受到保护。

回收 A:公共部分仍可供 B 使用
根
 └── S 公共前缀  🔒
      └── B 后缀  🔒 运行中

释放:A 的 KV 缓存空间

假设 A 是可回收叶子中最近最少使用的节点,系统先释放 A 的空间,保留 B 仍在使用的路径。

↓ B 结束之后
以后仍可能继续回收B 的引用释放后,其缓存可留待复用;若之后内存仍紧张,它可以成为淘汰候选。S 只有在不再被使用、且其子分支已移除而成为叶节点时,才可能继续被淘汰。
🔒 表示节点正被运行中的请求引用,○ 表示没有运行中的请求引用。

论文实现以最近最少使用(LRU)为淘汰原则,先回收不再被运行中请求引用的叶节点。优先淘汰叶节点可以保留公共祖先,直到祖先本身成为可淘汰叶节点。节点引用计数保护正在使用的路径;缓存和运行中的请求共享内存池,不为缓存预留固定容量,内存紧张时可让位于新批次(§3)。

这种设计改善的是前缀复用和内存共享,不意味着一次命中就可跳过整个请求。若请求长度为 N,命中前缀长度为 L,仍需处理后缀;原文用 token 比例定义缓存命中率:

命中率 = ∑r∈R cached_prefill_tokens(r) / ∑r∈R prefill_tokens(r)

这个指标按 token 加权,不是命中请求数除以总请求数,也不直接等同于计算时间节约比例。命中越长通常越有利于 prefill,但实际延迟仍由批次、解码和设备利用率等因素决定。

图解|从请求进入到缓存回收
等待队列收集等待执行的请求
→
匹配与排序查树获得命中前缀;优先较长匹配
→
组成批次保护运行中请求使用的节点,分配内存
→
内存不足时淘汰可回收叶节点;之后执行并插入新路径

缓存感知调度与理论边界

图解|先处理哪个请求,为什么会影响缓存?

假设缓存空间有限,等待队列中的 A1、A2 共享前缀 A,B1、B2 共享另一个前缀 B;两组前缀无法同时长期保留。

交替处理:A1 → B1 → A2 → B2
A1  计算前缀 A
 ↓
B1  计算前缀 B,A 可能被挤出
 ↓
A2  A 若已被挤出,需重新计算
 ↓
B2  B 若已被挤出,也需重算

频繁切换不同前缀,可能反复挤掉下一条请求所需的缓存。

集中处理:A1 → A2 → B1 → B2
A1  计算并缓存前缀 A
 ↓
A2  命中并复用前缀 A
 ↓
B1  计算并缓存前缀 B
 ↓
B2  命中并复用前缀 B

让共享前缀的请求靠近执行,更容易在前缀被淘汰前复用它。

图解|离线最优结论:先看条件,再看结果
已知一整批请求可以提前看到全部请求,并把它们组织成一棵前缀树。
↓
前缀树示意
根
 ├── 前缀 A
 │    ├── 请求 A1
 │    └── 请求 A2
 └── 前缀 B
      ├── 请求 B1
      └── 请求 B2

深度优先:先处理完 A 分支,再处理 B 分支;同一分支内可共享祖先。

论文定理的条件与结论

条件:离线批处理;缓存容量至少能容纳最长请求;采用论文中的缓存与计算模型。

结论:按树的深度优先次序访问,可达到该模型下的最优缓存命中率;论文还证明最长共享前缀优先与这一访问次序等价。

↓ 真实线上环境
请求不断到达,输出长度也可能未知理想顺序可能被打断;优先命中长前缀虽有助于局部性,却不能保证线上全局最优,也可能让其他请求等待过久。

队列中若有多个等待请求,论文优先执行已缓存前缀更长的请求,以增加缓存局部性。其定理 3.1 在离线批处理、缓存容量至少等于最长请求长度等假设下,指出按树的深度优先顺序访问可以达到最优命中率,并将最长共享前缀优先与深度优先次序联系起来。

证明的直观要点是:在满足条件时,一条边的 KV 只需首次访问时计算一次。令 T 为所有请求形成的前缀树、|e| 为边 e 的 token 数,C 为需要计算的 prefill token 数,则该理想化模型有:

C ≥ ∑e ∈ edges(T) |e|, CDFS = ∑e ∈ edges(T) |e|

左式是必须覆盖所有不同树边的下界;右式表示理想 DFS 达到下界。其对应的最优命中率可写为:

Hoptimal = 1 − CDFS / ∑r∈R prefill_tokens(r)

这不保证在线业务总是最优:请求持续到达,输出长度事先未知,且批处理和公平性约束会打断理想访问顺序。论文还指出贪心调度可能使某些请求饥饿(§3、附录 A.3)。

源码对应:前缀匹配与回收

以下是某个固定提交的摘录,展示概念对应关系;工程版本支持可配置页面大小和淘汰策略,不能倒推论文实验版本的所有细节。代码链接保留固定提交与行号。

while len(key) > 0 and child_key in node.children.keys():
    child = node.children[child_key]
    child.last_access_time = access_time
    prefix_len = child.key.match(key, page_size=self.page_size)
    if prefix_len < len(child.key):
        new_node = self._split_node(child.key, child, prefix_len)
        value.append(new_node.value)
        node = new_node
        break
    else:
        value.append(child.value)
        node = child
        key = key[prefix_len:]

radix_cache.py:663–675:逐边比较,部分匹配时拆分节点;完全匹配则累积缓存并继续向下。

leaves = list(self.evictable_leaves)
eviction_heap = [
    (self.eviction_strategy.get_priority(node), node) for node in leaves
]
heapq.heapify(eviction_heap)
# ...
_priority, x = heapq.heappop(eviction_heap)
self.token_to_kv_pool_allocator.free_segment(x.value, start_pos=0)
num_evicted += len(x.value)
self._delete_leaf(x)
if len(x.parent.children) == 0 and x.parent.lock_ref == 0:
    new_priority = self.eviction_strategy.get_priority(x.parent)
    heapq.heappush(eviction_heap, (new_priority, x.parent))

radix_cache.py:563–580:此摘录省略了循环行,展示按策略选择可淘汰叶节点、释放缓存,以及未锁定父节点变成叶节点后进入候选集。

05. 压缩有限状态机:跳过确定文本

假设程序要求模型输出 {"grade": "A"}:花括号、字段名 grade 和引号由格式规定,模型真正需要选择的是等级,例如 A 或 B。压缩有限状态机(compressed FSM)的想法是:格式已经确定的部分不必逐个 token 让模型选择,可合并处理并更新模型状态;到有分支的地方,再让模型决定。

图解|填写固定格式,就像填写一张预印表格
普通约束解码:一步一步生成

模型每生成一个 token,系统都检查当前允许哪些 token,再进入下一步。

生成固定片段的一部分 → 检查
           ↓
生成固定片段的下一部分 → 检查
           ↓
选择 A 或 B → 检查 → 继续

固定片段可能跨多个 token,所以也可能需要多轮解码。

压缩 FSM:先填好确定的部分

系统识别格式中唯一合法的连续文本,将其合并处理并更新模型状态;到选择处再由模型决定。

固定:{"grade": "  →  跳过
           ↓
选择:A 或 B       →  模型决定
           ↓
固定:"}           →  跳过

它像预印好表格的字段名,只把需要选择的空格留给模型。

状态机在这里做什么?

可以把有限状态机理解为一张“合法输出路线图”:每个位置记录目前输出到哪里,箭头表示接下来允许出现什么。普通方法沿路线一步步检查;压缩方法把中间没有选择的连续路线合成一条长箭头。

图解|没有岔路的部分可以合并
起点准备输出 JSON
→
唯一合法的固定文本多个连续状态合并成一条路径
→
出现岔路:A 或 B不能跳过选择,由模型决定
→
剩余固定格式若后续路径唯一,可继续跳过

效果和边界

论文在指定的 JSON 解码基准上报告,压缩 FSM 带来 1.6 倍吞吐量。状态机也需要预处理;论文指出,如果每个请求都重新预处理而不在批次间复用,吞吐量会降低 2.4 倍(§6.3)。因此,固定文本越多不自动意味着端到端收益一定越大,仍需结合请求和预处理成本测量。

还有一个技术细节:格式规则描述的是字符,而模型处理的是 token,两者边界可能不同。论文使用重新分词处理跳转文本;作者也指出,某些候选文本的 token 切分可能使概率分布产生偏差。格式合法只说明输出满足约束,并不保证内容正确。(附录 B)

06. API 推测执行:提前生成并验证

对于仅能通过 API 调用的模型,系统无法修改内部 KV 管理或解码器。论文提出另一种推测:例如程序依次生成 name 和 job,第一次请求可忽略原本的停止位置,多生成一段;解释器随后尝试把额外输出与下一段模板匹配,如果匹配成功则复用,减少后续 API 调用。

图解|API 推测执行:提前生成与匹配
原程序依次生成 name 和 job,通常需要两次调用
→
第一次多生成越过 name 的停止位置,保留额外输出
→
匹配后续模板检查额外输出是否与下一步所需格式一致
→
匹配成功复用结果,可能减少后续调用;不匹配则不能获得同样收益

这种机制的收益来自跨调用摊销上下文输入和网络往返,不是常说的“小模型草稿 + 大模型验证”的 token 级推测解码。推测失败则不能获得相同收益;输出是否匹配、提示设计和提供商计费方式都会影响结果。论文在 GPT-3.5 的三字段抽取示例中报告输入 token 成本约降低三倍,不能推广为所有模型、价格或任务均节省三倍(§5、§6.2)。

07. 实验:结果、对照与解释

论文以 programs/s 衡量大量程序实例批量运行时的最大吞吐量;单程序重复运行的平均耗时用于延迟。主要实验包括单张 A10G 上的 Llama-7B、多卡张量并行的 Mixtral-8x7B 和 Llama-70B、图像视频模型,以及 GPT-3.5 API。比较对象包括论文当时的 vLLM 0.2.5、Guidance 0.1.8 和 LMQL 0.7.3,且部分工作负载因功能或性能原因没有完整基线(§6.1、Fig. 5–7)。

结果 / 证据论文报告如何解读
端到端,Fig. 5–6吞吐最高 6.4×;延迟最高降低 3.7×不同任务、基线和指标中的最大改善;不能当作所有任务同时达到。
缓存命中,§6.2 / Fig. 13各基准约 50%–99%;调度平均接近最优命中率的 96%反映这些工作负载中的前缀重用机会;与“命中率增加 96 个百分点”不同。
JSON 消融,§6.3压缩 FSM 吞吐量 1.6×说明结构化固定片段可减少解码轮次;预处理是否复用很关键。
无复用开销,§6.3100 个请求 74.3 秒,其中树结构管理 0.2 秒,低于 0.3%仅是该 ShareGPT 测试中记录的数据结构管理时间占比,不是所有开销的普遍上界。
生产观察,§6.2LLaVA-Next-34B 命中 52.4%;Vicuna-33B 命中 74.1%;后者首 token 延迟平均改善 1.7×Chatbot Arena 一个月观察;文中指出部分模型流量较低且仅一名 worker。
图解|为什么相同缓存优化在不同任务上收益不同
短输出、多轮对话

较多重复历史 → 前缀命中 → 减少重复 prefill → 首 token 延迟和总耗时更容易改善

长输出、多轮对话

前缀也可能命中 → 仍须生成大量新 token → decode 占比更高 → 总体加速可能较小

短输出多轮聊天受益尤其容易理解:历史对话重复 prefill 的比例较高,缓存命中后可以减少大量重复前缀计算;长输出场景中,后续逐 token decode 占比上升,而不同会话之间共享有限,因此论文观察到几乎没有加速(§6.2)。这说明缓存命中率不是脱离工作负载即可预测的端到端收益。

消融实验 Fig. 8(c) 分别移除缓存、树结构、缓存感知调度、前端并行及 fork hint。它支持的结论是:在选定任务中,这些组件的组合有助于达到最佳结果;并不意味着每个组件在所有任务上的收益一致或相互独立。Fig. 8(a)(b) 通过部分禁用匹配 token 改变命中率,显示更高命中率与更大批次、更高吞吐及更低延迟相关。

多模态对照

模型及基准原作者实现SGLang比值
LLaVA-v1.5-7B / 图像0.18 image/s1.15 image/s约 6.39×
LLaVA-NeXT-34B / 视频0.02 frame/s0.10 frame/s5×

数据来自原文 Table 2;比值由表内四舍五入数字计算。两行单位不同,不能直接比较绝对数值。

图像任务中同一图片对应多个问题,因此图片 token 的 KV 也有复用机会;论文使用图片哈希参与缓存键。该实验以模型作者的 Hugging Face Transformers 实现为基线,而不是同一时期所有多模态推理框架的全面排名。论文文字称“最高 6×”,表内数字直接相除约为 6.39×,两者可能涉及取整,故分别保留(§6.2、Table 2)。

08. 局限、适用边界与启示

作者提出的限制。贪心前缀调度可能饿死其他请求;论文将与公平调度结合列为未来工作。缓存层级扩展、语义模糊匹配、更多输出模态以及更强的编译期优化也未在论文中解决(§8)。这些是论文版本的研究边界,而不是对后续产品能力的断言。

压缩 FSM 对 tokenization 的处理仍可能扭曲选项概率,尤其当备选字符串映射为不同 token 路径时;符合格式也不保证内容真实或业务语义正确。附录 D 的 GPT-4 提示词重排尝试中,15 个测试模板有 12 个被人工确认语义未改变;重排属于激进优化,剩余失败说明不能把自然语言顺序调整当成严格等价变换。

独立判断。原文对比的是特定时间点的软件与硬件:例如 vLLM 0.2.5 与 7B/A10G 环境。论文注释还说明较新的 vLLM 当时已部分集成可选前缀缓存。因此,这些数字适合证明当时的设计有效性,不能直接代表今天各推理引擎的相对性能。Agent trace 重放也不完全等同于真实在线系统中的动态到达、工具耗时和尾延迟。

从系统设计看,最适合优先考虑前缀复用的是提示长、公共前缀稳定、并发请求有局部性的场景;要评价实际收益,应同时测量前缀命中 token 比例、首 token 延迟、端到端延迟、吞吐和公平性。请求很短、前缀缺乏重合或长输出占主导时,KV 复用的收益可能较小;这是一种从工作负载结构出发的推断,具体幅度仍需实测。

论文的主要贡献是把语言程序的结构与推理运行时连接起来:前端表达调用关系,运行时利用前缀树维护可复用状态,并为结构化输出与黑盒 API 设计不同的加速路径。其价值不仅是一个最高加速数字,更是展示了工作流信息如何改变推理系统的调度与缓存决策。

参考文献

  1. SGLang: Efficient Execution of Structured Language Model Programs(arXiv v2) · 论文 PDF
  2. SGLang 官方代码仓库