SGLang 深度拆解:当 RadixAttention 把 KV Cache 变成一棵可复用的「记忆树」——从前缀缓存、零开销调度到生产级推理服务的全链路实战
如果你在生产环境里把大模型服务跑过一段时间,大概率会撞上这样一个反直觉的事实:GPU 算力明明还有富余,但吞吐就是上不去,P99 延迟还时不时抽风。问题往往不在模型,也不在你的 prompt 写得不够好,而在于「每一次请求到来时,框架都把前面已经算过的 KV Cache 当成垃圾丢掉,从头再来一遍」。SGLang 的核心赌注就是:把 KV Cache 从「一次性纸巾」变成「可以跨请求复用的记忆树」。这篇文章我们从工程视角把它拆到底。
一、背景介绍:LLM 推理服务的真正瓶颈在哪里
在聊 SGLang 之前,得先说清楚 LLM 推理服务到底卡在哪。很多人直觉里以为「模型越大越慢」,但真实生产场景里,瓶颈通常不是 FLOPs,而是三件事的叠加:显存墙、批处理颗粒度、以及缓存复用策略。
1.1 自回归生成的本质开销
Transformer 是自回归的:生成第 N 个 token,必须基于前 N-1 个 token 的 KV(Key/Value)状态。每生成一个 token,都要把整段历史重新做一遍注意力吗?理论上不用——你可以把历史的 K 和 V 缓存下来,只对新 token 算增量。这就是 KV Cache。
但 KV Cache 是吃显存的大户。以 Llama-3-70B 为例,单层单 token 的 KV 状态大约是 2 * hidden * n_heads * head_dim 的精度字节数,乘以层数、乘以序列长度、乘以 batch 大小,很容易就是几十上百 GB。这意味着:能塞进显存的并发序列数,直接决定了你的吞吐上限。
1.2 传统推理的两个浪费
早期朴素推理(以及很多「教学级」实现)有两个典型浪费:
浪费一:静态批处理(static batching)的木桶效应。
把一批请求组队一起算,但必须等整批里最长的那个生成完才能一起返回。短请求被长请求「拖死」,GPU 在大量时间里其实是在等少数长尾请求。
浪费二:前缀计算的重复。
这是 SGLang 真正要解决的命门。在以下场景里,不同请求的前面一大段 prompt 其实是完全相同的:
- RAG / 多轮对话:系统 prompt、检索到的上下文、few-shot 模板,每次请求都重发一遍;
- Agent 工具调用:同一个任务的多个分支(Tree-of-Thoughts)、同一个 system 指令下的多轮对话;
- 结构化生成:固定 JSON schema 的描述前缀。
传统框架在请求处理完后直接释放 KV Cache,下一个请求来了,哪怕前面 2000 个 token 和上一个请求一字不差,也得重新跑一遍 prefill(把 prompt 的前向计算走完)。这叫 prefix recomputation(前缀重算),在 RAG 和 Agent 场景里能吃掉你 30%~70% 的有效算力。
SGLang 的答案,是把「前缀」当成一等公民来管理,并用一棵基数树(Radix Tree)把它们组织起来——这就是 RadixAttention。
二、核心概念:RadixAttention 到底解决了什么
2.1 一句话理解 RadixAttention
RadixAttention = 用一棵基数树(radix tree / prefix tree)来索引所有请求的 KV Cache,让「相同前缀」的 KV 状态在显存里只存一份,并被任意多个请求共享。
它解决的核心问题叫 automatic prefix caching(自动前缀缓存):你不需要像某些框架那样显式声明「这段 prompt 是缓存的」,框架在运行时自动检测请求之间的公共前缀,命中就直接复用,未命中或已被淘汰的才做 prefill。
2.2 基数树是怎么组织的
基数树本质上是一棵压缩前缀树。SGLang 把每个请求的 token 序列当成字符串,把公共前缀合并到同一个树节点上。每个树节点持有一段连续的 token + 对应的 KV Cache 块。
[root]
│
┌──────────────┼───────────────┐
"你是一个严谨的..." "把下面代码翻译成..."
│ │
┌────┴────┐ "def foo():..."
"用户问A" "用户问B"
- 节点 = 一段前缀 + 它的 KV Cache 块集合;
- 两个请求只要前面走的是同一条路径,它们就共享这些节点的 KV Cache;
- 新请求到来时,调度器沿树做最长前缀匹配,命中的部分直接「借」现成 KV,只对未命中的尾部做 prefill;
- 树满了怎么办?用 LRU 淘汰(最近最少使用的叶子节点先被回收),保证热前缀常驻。
这套机制对一个典型 RAG 服务意味着:第一请求把「系统提示 + 检索上下文」算一遍,之后同一份上下文的成百上千个用户问题,全都只算「用户问题」那十几个 token 的 prefill,前缀部分零成本复用。实测在固定模板场景里,SGLang 相对 vLLM 能有 2~5 倍的吞吐提升——这正是那段「把算力从前缀重算里解放出来」的收益。
2.3 和 vLLM 的 PagedAttention 区别在哪
容易混淆的一点是:vLLM 的 PagedAttention 和 SGLang 的 RadixAttention 不是竞争关系,而是解决不同层面的问题。
- PagedAttention(vLLM) 解决的是 KV Cache 的内存管理:把 KV 切成固定大小的「页(block)」,像操作系统虚拟内存一样非连续存放,消除碎片,让显存利用率从 20%~40% 提升到 90%+。
- RadixAttention(SGLang) 解决的是 KV Cache 的跨请求复用:在 PagedAttention 的分页基础上,额外用基数树记住「哪些前缀是公共的」,让不同请求的页能共享。
可以粗略理解为:PagedAttention 让你把显存用得更满,RadixAttention 让你把已经算过的东西别再算第二遍。SGLang 底层同样做了分页式显存管理,只是它在这之上叠了前缀共享这一层。
2.4 零开销 CPU 调度器(Zero-overhead CPU Scheduler)
另一个被低估的工程亮点:SGLang 的调度器跑在 CPU 的独立线程上,与 GPU 上的模型前向计算并行。
很多推理框架的调度逻辑和模型计算挤在同一条路径上——GPU 算完一个 batch,才轮到 CPU 决定「下一个 batch 装谁」,这个决策时间会「偷走」GPU 的空档。SGLang 把调度(选请求、拼 batch、查 RadixAttention 树、做约束解码的 token 掩码)放到 CPU 线程,GPU 在跑当前 batch 时,CPU 已经把下一个 batch 排好了。结果是:调度开销几乎不占用 GPU 时间,这也是它能把 GPU 利用率顶到很高的重要原因。
2.5 结构化生成(Structured Output)不是事后补丁
SGLang 从设计上就把「结构化输出」当核心能力,而不是挂个正则后处理。它用 约束解码(constrained decoding):在每一步生成时,根据 JSON schema / 正则 / 文法,动态计算「哪些 token 是合法的」,把非法 token 的概率直接置零(logits mask),保证模型只能生成合法的 JSON / 符合正则的文本,不需要任何「生成完再校验、不合法就重生成」的笨办法。
底层引擎从早期的 outlines 演进到了 xgrammar,后者用编译文法 + 状态机把掩码计算也搬到了 GPU 友好的路径上,进一步压低结构化生成的开销,做到「零额外延迟」级别。
三、架构分析:前端 DSL + 后端 Runtime 的分层设计
SGLang 的架构可以拆成两个清晰的层次,这也是它「好用又好优化」的根本原因。
3.1 前端:把 prompt 当程序写
前端是一个嵌在 Python 里的 DSL(领域特定语言),用 @sglang.function 装饰器定义「一段有控制流的生成程序」。它让你可以像写普通 Python 一样描述「先生成什么、再根据结果分支、再并行生成几个候选」。
import sglang as sgl
@sgl.function
def rag_qa(s, question: str):
# 固定系统提示 + 检索上下文,这部分会被 RadixAttention 自动缓存复用
s += sgl.system("你是一个严谨的技术助手,只基于给定上下文回答。")
s += "上下文:\n" + sgl.retrieve("context", question) + "\n\n"
s += "问题: " + question + "\n"
s += "回答: " + sgl.gen("answer", max_tokens=512, temperature=0.3)
# 顺手做结构化抽取
s += "\n\n关键词: " + sgl.gen("keywords", max_tokens=32,
regex=r"([\u4e00-\u9fa5a-zA-Z0-9]+)(, [\u4e00-\u9fa5a-zA-Z0-9]+)*")
注意 sgl.retrieve("context", question) 这种写法——它不只是字符串拼接,而是给运行时一个语义提示:这段内容是可被前缀缓存识别的「检索块」。配合 RadixAttention,同一份上下文会被自动复用。
前端还支持:
- 分支与并行:
sgl.choose(["A","B","C"])做多候选,sgl.parallel并发生成; - 控制流:在生成里写
if/for,实现 Tree-of-Thoughts 这类复杂推理; - 多模态:直接把图片塞进 prompt。
3.2 后端:Tokenizer Manager → Scheduler → Model Runner
后端是一个事件驱动的 runtime,主要角色:
HTTP / gRPC
│
┌───────▼────────┐
│ TokenizerManager │ 接收请求、tokenize、把请求交给调度器
└───────┬────────┘
│
┌───────▼────────┐
│ Scheduler │ CPU 线程:选 batch、查 RadixAttention 树、
│ (CPU, 并发) │ 拼约束解码掩码、管理前缀淘汰
└───────┬────────┘
│ (送去 GPU 的 token batch)
┌───────▼────────┐
│ Model Runner │ GPU:prefill + decode,写入/读取 KV Cache
│ + RadixAttention │ (分页显存 + 基数树索引)
└────────────────┘
几个工程要点:
- Tokenizer Manager 是请求入口,负责把原始文本变成 token id,并维护「请求 ↔ 生成状态」的映射;
- Scheduler 是大脑,连续批处理(continuous batching)在这里发生:一个 batch 里可以混着不同进度的请求——有的在 prefill,有的在 decode,互不阻塞;
- RadixAttention 的 KV 池 是全局共享资源,由 Scheduler 统一分配与回收,节点引用计数决定何时真能淘汰。
3.3 并行与分布式
SGLang 不是为了「单卡 demo」设计的,它原生支持:
- 张量并行(Tensor Parallelism):把一层权重切到多卡;
- 流水线并行(Pipeline Parallelism):把不同层放到不同卡,切 micro-batch;
- 专家并行(Expert Parallelism):专门给 MoE 模型(如 DeepSeek、Qwen-MoE)用;
- 数据并行(Data Parallelism) + 路由(Router):多副本前面加一层 router,把请求按负载分发,GPU 间用 NCCL 同步;
- Prefill-Decode 分离(PD Disaggregation):把「算前缀(prefill,算力密集、短时长)」和「逐 token 解码(decode,带宽密集、长时长)」拆到不同实例,各自独立扩缩容。这是生产环境把尾延迟压下来的关键手段。
四、代码实战:从零跑起一个生产级 SGLang 服务
光讲架构没意思,直接上手。下面所有命令都可在单卡或多卡环境跑通(多卡参数略作说明)。
4.1 启动服务(OpenAI 兼容)
# 用官方镜像最省心
docker run --gpus all --shm-size 32g -p 30000:30000 \
lmsysorg/sglang:latest \
python -m sglang.launch_server \
--model-path Qwen/Qwen3-8B \
--port 30000 \
--tp 1 \
--mem-fraction-static 0.85 \
--enable-radix-cache \
--host 0.0.0.0
关键参数解读:
--tp:张量并行度,单卡填 1,双卡填 2,依此类推;--mem-fraction-static 0.85:把 85% 显存留给 KV Cache 和权重,剩下给碎片/激活值;--enable-radix-cache:开启 RadixAttention 前缀缓存(默认开,显式写上更清晰);- 服务起来后,默认提供 OpenAI 兼容 的
/v1/chat/completions接口,你现有的 OpenAI SDK 代码几乎不用改。
4.2 用 OpenAI SDK 直接打(零改造接入)
from openai import OpenAI
client = OpenAI(base_url="http://localhost:30000/v1", api_key="EMPTY")
resp = client.chat.completions.create(
model="Qwen/Qwen3-8B",
messages=[
{"role": "system", "content": "你是一个严谨的运维助手。"},
{"role": "user", "content": "用一句话解释什么是 RadixAttention。"},
],
temperature=0.5,
max_tokens=256,
)
print(resp.choices[0].message.content)
这一步看不出 SGLang 的特殊之处——但好处正是「兼容」。你先无缝切过来,再把高价值场景用下面的结构化 + 前缀复用能力放大。
4.3 结构化输出:强制返回合法 JSON
这是 SGLang 的杀手锏。假设你要从模型里抽「故障分类 + 原因 + 建议」的结构化结果:
import sglang as sgl
@sgl.function
def classify_incident(s, alert_text: str):
s += "你是 SRE 助手。根据告警文本,严格输出 JSON。\n"
s += "告警: " + alert_text + "\n"
# json() 约束:直接把 schema 当约束,模型无法吐出非法 JSON
s += sgl.gen("result",
json={
"type": "object",
"properties": {
"category": {"type": "string"},
"severity": {"enum": ["P0", "P1", "P2", "P3"]},
"root_cause": {"type": "string"},
"suggestion": {"type": "string"}
},
"required": ["category", "severity", "root_cause", "suggestion"]
})
with sgl.Runtime(model_path="Qwen/Qwen3-8B", port=30000):
state = classify_incident.run(
alert_text="CPU 使用率持续 98%,磁盘 IO 等待飙升,服务 5xx 比例 12%"
)
print(state["result"]) # 保证是合法、可 json.loads 的字符串
这里 json={...} 是约束解码的 schema。模型每一步的候选 token 都会被文法状态机过滤,输出必然是合法 JSON,你拿到手就能 json.loads,不需要任何「抽不出来就重试」的兜底逻辑。在 Agent 工具调用、数据抽取流水线里,这个特性的收益是质变级的。
注意:SGLang 也提供 OpenAI 兼容接口上的
response_format={"type":"json_schema", ...},不写 DSL 也能用结构化输出,适合「不想改现有 SDK 代码」的团队。
4.4 亲眼看到前缀缓存命中
想验证 RadixAttention 到底有没有在帮你省算力?SGLang 在返回里带了缓存统计。用 sglang 原生的 benchmark / 或直接看响应里的 meta_info:
import requests
# 第一次:带长系统前缀 + 用户问题
payload = {
"model": "Qwen/Qwen3-8B",
"messages": [
{"role": "system", "content": "【超长固定系统提示 2000 字...】你负责..."},
{"role": "user", "content": "问题 A"},
],
"max_tokens": 128,
}
r1 = requests.post("http://localhost:30000/v1/chat/completions", json=payload).json()
print(r1["meta_info"]["prompt_tokens"]) # 例如 2050
print(r1["meta_info"]["cached_tokens"]) # 第一次 ≈ 0(前缀还没缓存)
# 第二次:相同系统前缀,不同问题
payload["messages"][1]["content"] = "问题 B(完全不同)"
r2 = requests.post("http://localhost:30000/v1/chat/completions", json=payload).json()
print(r2["meta_info"]["cached_tokens"]) # 第二次 ≈ 2000(前缀被复用!)
print(r2["meta_info"]["completion_tokens"])
cached_tokens 就是你「白嫖」到的前缀长度。在 RAG 场景,同一份检索上下文服务 1000 个用户,只有第 1 个请求付了 prefill 的代价,后面 999 个的 cached_tokens 都接近上下文长度——这就是吞吐翻倍的来源。
4.5 压测:用自带 bench_serving 看真实收益
python -m sglang.bench_serving \
--backend sglang \
--model-path Qwen/Qwen3-8B \
--dataset-name random \
--random-input-len 1024 \
--random-output-len 256 \
--num-prompt 2000 \
--request-rate 32
它会吐出请求级别延迟分布和吞吐(token/s)。想验证前缀缓存价值,把 --random-input-len 换成「共享长前缀 + 短尾」的数据集,对比开/关 --enable-radix-cache 的两组数字,差距会非常直观。
五、性能优化:把 SGLang 榨到生产级
跑起来只是第一步。要真正顶住生产流量,下面几条是工程师最常踩、也最值得做的优化。
5.1 量化:先把权重压小,显存留给 KV Cache
KV Cache 和权重抢同一块显存。权重用量化压下来,省出的显存就能多塞并发序列。
python -m sglang.launch_server \
--model-path Qwen/Qwen3-8B \
--quantization fp8 \
--tp 1 --port 30000
- FP8:H100/B200 等支持 FP8 的卡首选,精度损失极小,显存和带宽双收益;
- AWQ / GPTQ(INT4):消费级显卡(如 24G 的 4090)跑 70B 级模型的关键路径;
- 量化不只是省显存,还降低显存带宽压力,decode 阶段(受带宽限制)直接变快。
5.2 显存分配:给 KV Cache 留够「静态份额」
--mem-fraction-static 决定启动时预留多少显存给 KV Cache 池。调得太小,并发上不去;调得太大,激活值没地方放会 OOM。经验值:
- 纯 decode 密集型、长上下文:0.85~0.9;
- 短输入 + 大 batch:0.8 左右更稳。
配合 --max-running-requests 限制同时在跑的序列数,避免极端长尾把显存打爆。
5.3 Chunked Prefill:别让长 prompt 卡住整个 batch
一个 8k token 的 prefill 如果和一堆短 decode 挤在同一个 step,短请求会被「饿」住,尾延迟飙升。开启 分块预填充(chunked prefill):
python -m sglang.launch_server \
--model-path Qwen/Qwen3-8B \
--chunked-prefill-size 4096 \
--port 30000
它会把长 prefill 切成小块,和 decode 穿插执行,P99 尾延迟显著下降。代价是单请求 prefill 略慢,但整体 SLO 更稳——生产环境通常值得。
5.4 PD 分离:prefill 和 decode 各管各的
当流量和模型都大到一定程度,把 prefill 实例和 decode 实例拆开是压尾延迟的终极手段:
# Prefill 实例(算力密集,少而精)
python -m sglang.launch_server --model-path Qwen/Qwen3-8B \
--port 30001 --分离角色参数略
# Decode 实例(带宽密集,多而小)
python -m sglang.launch_server --model-path Qwen/Qwen3-8B \
--port 30002
# 前面加一个 router 把请求按阶段分发
python -m sglang.router --prefill http://localhost:30001 \
--decode http://localhost:30002 --port 30000
prefill 实例专心把长前缀「算完并缓存」,decode 实例专心高效地吐 token,两者按负载独立扩缩。Kimi、DeepSeek 这类大规模服务背后都是类似架构。
5.5 多 LoRA 批处理:一个底座服务 N 个垂直场景
如果你要同时服务「客服」「代码」「法务」等多个微调版,不用起 N 个实例。SGLang 支持 多 LoRA 同批推理:
python -m sglang.launch_server \
--model-path Qwen/Qwen3-8B \
--lora-paths 客服=/path/lora_kefu 代码=/path/lora_code 法务=/path/lora_law \
--port 30000
请求里通过 lora_id 指定用哪个 adapter,不同 LoRA 的请求可以进同一个 batch 一起算,显存和算力利用率都远高于「每场景一个进程」。
5.6 一个生产调优清单(照着勾)
- 权重量化选对(FP8 > AWQ/GPTQ,看卡型);
-
--mem-fraction-static调到不 OOM 的最大值; - 长上下文必开
chunked-prefill; - 固定模板 / RAG 场景确认
cached_tokens命中; - 结构化输出用
json约束而非后处理校验; - 高并发用
bench_serving定请求速率,看 P99 而非平均值; - 大规模用 PD 分离 + router;
- 多租户用多 LoRA 批处理而非多实例。
六、总结展望:为什么 RadixAttention 代表了推理框架的下一个共识
把整篇文章收一下。SGLang 真正有价值的,不是「又一个推理框架」,而是它把两个被长期忽视的常识工程化了:
KV Cache 是资产,不是垃圾。 推理服务里大量算力浪费在「重复算相同前缀」上。RadixAttention 用基数树把前缀变成可跨请求复用的共享状态,让 RAG、Agent、多轮对话这类「前缀重」的场景吞吐翻倍。这大概率会成为所有主流推理框架的标配能力——事实上,vLLM 等也在补齐自动前缀缓存,说明方向已经是行业共识。
结构与调度要前置到生成层。 把结构化输出做成「约束解码」而不是「生成后校验」,把调度放到 CPU 线程与 GPU 并行,把 prefill/decode 在架构上分开——这些都不是炫技,而是把「生产环境的延迟和成本」当成一等设计目标。
对工程团队的务实建议:
- 如果你已经在跑 RAG / Agent / 多轮对话,切到 SGLang 几乎是「免费午餐」,前缀复用带来的吞吐提升通常当天就能在监控里看到;
- 如果你主要是简单问答,vLLM 依然稳,但 SGLang 的兼容接口让你迁移成本极低;
- 如果你在做结构化数据抽取 / 工具调用,SGLang 的约束解码能直接干掉一整层「校验重试」的脆弱代码。
最后说一个判断:未来一两年,推理框架的竞争焦点会从「谁能跑起来」转向「谁把前缀复用、约束解码、PD 分离、异构量化这四个生产级能力做得最无感」。SGLang 在这条路上走得相当靠前,值得每个认真做 LLM 服务的团队放进技术选型清单里实打实地 benchmark 一遍。
文中所有命令与参数均基于 SGLang 公开文档与稳定接口,具体版本请以你部署时的官方文档为准;生产落地前请用
bench_serving在你的真实 prompt 分布上复测,别直接照搬基准数字。