跳过正文
Featured image for SGLang 结构化生成实战:RadixAttention、约束解码与多轮对话优化

SGLang 结构化生成实战:RadixAttention、约束解码与多轮对话优化

·1759 字·9 分钟·
目录

为什么我开始用 SGLang
#

接触 SGLang 之前我一直在 vLLM 和 TRT-LLM 之间打转。两个工具在"单次 completion"这件事上都做到了接近极限,但一旦场景变复杂——Agent 多轮调用、大量共享 prompt 前缀、需要严格 JSON 输出、带工具调用的循环——单纯的 vLLM 开始显得笨重。

触发我认真看 SGLang 是一次 Agent 线上故障。我们的 Agent 逻辑大致是:

  1. 给模型一个非常长的 system prompt(约 6K token,包含工具定义、示例)
  2. 用户每次发消息,模型决定调用哪个工具
  3. 工具返回结果拼回 prompt,再让模型生成回复
  4. 一个完整对话平均 5-8 轮,每轮都把前面历史塞回去

这种负载对传统的 KV Cache 不友好——每次都是"前缀高度重复,后缀不同"。即使开了 vLLM 的 prefix caching,命中率也会被前缀匹配的精确性吃掉一大块。P95 首 token 延迟稳定在 800ms,不可接受。

切到 SGLang 之后同样的场景首 token 降到 250ms 附近。原因只有一个:RadixAttention。这是 SGLang 区别于 vLLM 最核心的武器。这篇文章把 SGLang 的核心机制、前端、部署都讲清楚。

一、RadixAttention:核心创新
#

1.1 KV Cache 的共享问题
#

LLM 推理的 KV Cache 按请求分配,每个请求独立。但实际业务里很多请求的 prompt 前缀高度重合:

  • Chat 应用:system prompt 固定
  • RAG:检索到的文档大部分稳定
  • Agent:工具定义、示例每轮都带
  • 多轮对话:前 N 轮历史每次都塞

vLLM 的 prefix caching 把这些重复前缀缓存下来,命中了就直接用缓存的 KV,省掉 prefill 计算。但 vLLM 的实现是请求级别的精确匹配——你要么完全命中一段前缀,要么不命中。

1.2 RadixAttention 的做法
#

SGLang 的做法是把所有当前活跃请求的 KV Cache 组织成一棵 radix tree(基数树):

             [root]
              │
      ┌───────┴───────┐
   system A         system B
   (固定)            (固定)
      │               │
   ┌──┴──┐         ┌──┴──┐
 user1 user2     user3 user4
  │     │         │     │
 ...   ...       ...   ...

每个 prompt 从 root 开始沿树往下找最长公共前缀,找到的部分直接复用已有 KV,只对剩余部分做 prefill。这比"请求粒度"的 prefix caching 细很多:

  • 不同请求可以共享任意长度的公共前缀
  • 新请求加入树后,它的 KV 也对后续请求可见
  • LRU 淘汰整条路径,保证活跃前缀常驻

实际效果:

  • 多轮对话场景前 N-1 轮的 KV 完全不用重算
  • Agent 场景工具定义的 KV 常驻,每个请求只 prefill “用户 query + 模型输出”
  • 跑 benchmark 流程的 few-shot prompt,首 token 延迟接近零

1.3 和 vLLM prefix caching 的差别
#

vLLM Prefix Caching SGLang RadixAttention
粒度 block 级(16 token) token 级
数据结构 hash 表 + 引用计数 radix tree
共享范围 请求完成即淘汰 请求完成仍可共享
命中率
管理复杂度 较高

一句话:vLLM 的 prefix cache 偏"幸运命中",SGLang 的 RadixAttention 是"主动共享"。

二、架构总览
#

 ┌───────────────────────────────────────────┐
 │               SGLang Frontend             │
 │  (Python DSL: @sgl.function, sgl.gen ...) │
 └───────────────┬───────────────────────────┘
                 │  HTTP / 本地调用
 ┌───────────────▼───────────────────────────┐
 │              SGLang Runtime               │
 │  ┌─────────────────────────────────────┐  │
 │  │  Tokenizer Manager                  │  │
 │  └──────────────┬──────────────────────┘  │
 │                 │                          │
 │  ┌──────────────▼──────────────────────┐  │
 │  │  Scheduler (Radix 树管理)           │  │
 │  │  - 最长前缀匹配                      │  │
 │  │  - Continuous Batching              │  │
 │  │  - Chunked Prefill                  │  │
 │  └──────────────┬──────────────────────┘  │
 │                 │                          │
 │  ┌──────────────▼──────────────────────┐  │
 │  │  Model Worker (各种 attention 后端) │  │
 │  │  FlashInfer / FlashAttention /      │  │
 │  │  Triton kernel                      │  │
 │  └──────────────┬──────────────────────┘  │
 │                 │                          │
 │  ┌──────────────▼──────────────────────┐  │
 │  │  KV Cache Manager                   │  │
 │  │  (Token 级 Radix Tree)              │  │
 │  └─────────────────────────────────────┘  │
 └───────────────────────────────────────────┘

SGLang 分前端和后端两部分:

  • 前端:一个 Python DSL,让你把复杂 prompt 流程(条件生成、并行采样、多轮交互)写成函数式代码
  • 后端:推理运行时,提供 OpenAI 兼容 API 和原生 SGLang API

只用后端服务 OpenAI API 接口是最常见的部署方式,不一定非要用前端 DSL。

三、部署后端
#

3.1 启动命令
#

python -m sglang.launch_server \
    --model-path /models/Llama-3.1-70B-Instruct \
    --tp-size 8 \
    --mem-fraction-static 0.88 \
    --context-length 8192 \
    --max-running-requests 256 \
    --schedule-policy lpm \
    --disable-radix-cache=false \
    --host 0.0.0.0 --port 30000

关键参数:

参数 含义 推荐值
--tp-size Tensor Parallel 度 看卡数
--mem-fraction-static 类似 vLLM 的 gpu-memory-utilization 0.85~0.92
--context-length 最大上下文 按业务
--max-running-requests 同时跑的请求数 128~512
--schedule-policy 调度策略:fcfs / lpm(最长前缀匹配优先) 多轮场景用 lpm
--disable-radix-cache 禁用 radix cache 除非 debug 否则别关
--chunked-prefill-size chunked prefill 粒度 8192
--attention-backend attention kernel 后端 flashinfer / triton

3.2 attention backend 怎么选
#

SGLang 支持多个 attention backend:

  • FlashInfer:专门为 LLM 推理做的 attention 库,对 paged KV 和 RadixAttention 有深度优化
  • FlashAttention 2/3:老牌 Flash,通用
  • Triton:SGLang 自己用 Triton 写的 kernel,通用 GPU 支持

实测 H100 上 FlashInfer 最快。A100 上 FlashAttention 2 更稳。L40S / 消费卡用 Triton backend 兼容性最好。

3.3 启动验证
#

curl http://localhost:30000/v1/chat/completions \
    -H "Content-Type: application/json" \
    -d '{
        "model": "default",
        "messages": [{"role": "user", "content": "你好"}],
        "max_tokens": 64
    }'

SGLang 原生支持 OpenAI 兼容接口,直接用 openai SDK 调就行:

from openai import OpenAI
client = OpenAI(base_url="http://localhost:30000/v1", api_key="EMPTY")
resp = client.chat.completions.create(
    model="default",
    messages=[{"role": "user", "content": "写一首五言绝句"}],
    max_tokens=128,
)

四、前端 DSL:高阶用法
#

SGLang 的前端 DSL 是它区别于其他框架的另一个亮点。它把 prompt 流程写成 Python 函数,支持条件分支、并行采样、结构化输出。看例子。

4.1 基础:一次生成
#

import sglang as sgl

sgl.set_default_backend(sgl.RuntimeEndpoint("http://localhost:30000"))

@sgl.function
def greet(s, name):
    s += "用户: 你好,我是 " + name + "\n"
    s += "助手: " + sgl.gen("reply", max_tokens=64, stop="\n")

state = greet.run(name="张三")
print(state["reply"])

@sgl.function 声明一个带状态的 prompt 函数,s += 往对话里加内容,sgl.gen 让模型生成一段。整个函数像是在写一段"伪代码 prompt"。

4.2 并行采样
#

@sgl.function
def multi_answer(s, question):
    s += "问题: " + question + "\n"
    forks = s.fork(3)
    forks += "答案: " + sgl.gen("ans", max_tokens=200, temperature=0.9)
    forks.join()
    s += "最终答案: " + sgl.gen("final", max_tokens=400)

s.fork(3) 让 prompt 分叉成 3 条并行分支,每条独立采样,之后 join 回主干。这种模式下 SGLang 会自动让 3 条分支共享公共前缀的 KV Cache,只对后缀并行采样。

4.3 条件分支
#

@sgl.function
def classify_then_generate(s, text):
    s += "文本: " + text + "\n"
    s += "这是关于什么类别?选项: [科技, 体育, 娱乐]\n"
    s += "类别: " + sgl.gen("cat", choices=["科技", "体育", "娱乐"])
    if s["cat"] == "科技":
        s += "\n简要解释这个科技概念:" + sgl.gen("tech_explain", max_tokens=200)
    elif s["cat"] == "体育":
        s += "\n给出一条相关体育新闻:" + sgl.gen("sports_news", max_tokens=200)
    else:
        s += "\n推荐一个相关作品:" + sgl.gen("ent_rec", max_tokens=200)

sgl.gen(..., choices=[...]) 是"强制选项"生成,模型只能输出给定选项之一。然后 s["cat"] 在 Python 层可以直接 if/else 分支,不用自己做二次推理。

4.4 结构化 JSON 输出
#

json_schema = r"""{
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "age": {"type": "integer"},
        "skills": {"type": "array", "items": {"type": "string"}}
    },
    "required": ["name", "age", "skills"]
}"""

@sgl.function
def extract(s, text):
    s += "从文本抽取信息返回 JSON:\n" + text + "\n"
    s += sgl.gen("json_out", max_tokens=256, regex=None, json_schema=json_schema)

json_schema 告诉模型输出必须严格符合 schema,SGLang 在解码时做 token 级掩码,保证非法 token 不会被采样。

五、约束解码深入
#

LLM 结构化输出的落地有三种技术方案:

  1. Prompt 提示(最原始):system prompt 里让模型自己按 JSON 输出。不靠谱,长尾时会崩。
  2. Post-hoc 校验:生成完用 JSON parser 校验,失败就重试。浪费 token 且不稳定。
  3. 约束解码(constrained decoding):在每个 token 采样前,用一个 FSM/自动机裁剪合法 token 集合。

SGLang 支持的约束类型:

  • 正则sgl.gen(..., regex=r"\d{3}-\d{4}")
  • 选项sgl.gen(..., choices=[...])
  • JSON Schemasgl.gen(..., json_schema=...)
  • EBNF:更强大的上下文无关文法

5.1 约束解码的性能影响
#

约束解码不是零成本。每个 step 要维护 FSM 状态、计算当前允许的 token 集合、做 logits mask。对复杂文法,这个开销可能让 decode 延迟上升 20%。

SGLang 的做法:

  • 把常见的约束(简单正则、JSON schema)预编译成 compressed FSM
  • 对 FSM 的状态转移做缓存
  • 实际开销一般降到 <5%

依然注意:

  • 输入给 JSON schema 的规则越严格,搜索空间越小,压缩 FSM 越有效
  • 嵌套深的 schema 会让 FSM 爆炸
  • 自由文本字段("type": "string")基本没被约束,体积大

5.2 业务实战:工具调用
#

Agent 场景用约束解码做工具调用是非常自然的:

tool_schema = r"""{
    "type": "object",
    "properties": {
        "tool": {"type": "string", "enum": ["search", "calculator", "weather"]},
        "arguments": {"type": "object"}
    },
    "required": ["tool", "arguments"]
}"""

@sgl.function
def agent_step(s, history, user_msg):
    s += history
    s += "\n用户: " + user_msg + "\n"
    s += "思考: " + sgl.gen("thought", max_tokens=200) + "\n"
    s += "工具调用: " + sgl.gen("tool_call", json_schema=tool_schema, max_tokens=256)

生成完 tool_call 之后 Python 层解析 JSON 去调真工具,结果拼回 history,循环。

六、多 LoRA 和多模型服务
#

6.1 多 LoRA
#

SGLang 支持同一个 base 模型挂多个 LoRA adapter,请求时通过参数指定用哪个:

python -m sglang.launch_server \
    --model-path /models/Llama-3.1-8B-Instruct \
    --lora-paths lora_a=/loras/finance lora_b=/loras/medical \
    --max-loras-per-batch 4

请求:

{
  "model": "default",
  "messages": [...],
  "lora_path": "lora_a"
}

多 LoRA 对业务的意义:一个 base 模型服务多个定制方向,显存只多一点(LoRA 增量通常 <1% 参数),成本极低。

6.2 Multi-Model 路由
#

SGLang 本身是一个进程一个模型,多模型要起多个 SGLang server。上层用 LiteLLM 或自建网关做路由。

七、部署形态和 K8s
#

7.1 单机部署
#

和 vLLM 类似,单机 8 卡 70B 是舒适区:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sglang-llama70b
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: sglang
          image: lmsysorg/sglang:v0.x.x-cu121
          command: ["python", "-m", "sglang.launch_server"]
          args:
            - --model-path=/models/llama-3.1-70b
            - --tp-size=8
            - --mem-fraction-static=0.88
            - --context-length=8192
            - --max-running-requests=256
            - --host=0.0.0.0
            - --port=30000
          resources:
            limits:
              nvidia.com/gpu: 8
          volumeMounts:
            - { name: models, mountPath: /models }
            - { name: shm, mountPath: /dev/shm }
          readinessProbe:
            httpGet:
              path: /health
              port: 30000
            periodSeconds: 10
          startupProbe:
            httpGet:
              path: /health
              port: 30000
            failureThreshold: 60
            periodSeconds: 10
      volumes:
        - name: models
          persistentVolumeClaim:
            claimName: llm-models-pvc
        - name: shm
          emptyDir:
            medium: Memory
            sizeLimit: 16Gi

7.2 多机
#

SGLang 多机用类似 vLLM 的方式(Ray 或自建)。到 0.3+ 版本已经稳定支持多节点。启动示例:

# Node 0
python -m sglang.launch_server \
    --model-path /models/Llama-3.1-405B \
    --tp-size 16 \
    --nnodes 2 \
    --node-rank 0 \
    --dist-init-addr 10.0.1.10:20000 \
    --host 0.0.0.0 --port 30000

# Node 1
python -m sglang.launch_server \
    --model-path /models/Llama-3.1-405B \
    --tp-size 16 \
    --nnodes 2 \
    --node-rank 1 \
    --dist-init-addr 10.0.1.10:20000 \
    --host 0.0.0.0 --port 30000

和 vLLM 多机一样的网络要求:跨机至少 100Gbps RDMA,NCCL 环境变量配好。

八、RadixAttention 调优
#

8.1 如何验证 RadixAttention 生效
#

SGLang 的监控指标(/metrics Prometheus endpoint)会暴露缓存命中率:

  • sglang:cache_hit_rate:token 级命中率
  • sglang:num_cached_tokens:当前缓存的 token 总数
  • sglang:num_running_requests
  • sglang:num_queue_requests

多轮对话场景 cache_hit_rate 应该稳定在 60-85%,如果只有 5% 说明 RadixAttention 没发挥——一般是调度策略错了或者 prompt 前缀不稳定。

8.2 调度策略 lpm
#

--schedule-policy lpm(longest prefix match)让调度器优先选能命中最长前缀的请求执行。和 FCFS 比,lpm 在多轮对话场景能多榨出 10-20% 吞吐,代价是绝对公平性差一点(短 prompt 没前缀的请求可能排队久一些)。

8.3 mem-fraction-static 和 radix cache 的关系
#

SGLang 的显存分三部分:

  • static:模型权重、激活、workspace,启动时确定
  • KV cache / radix tree:剩下的都给缓存
  • 其他:NCCL workspace 等

--mem-fraction-static 控制 static 部分占总显存的比例,剩下的自动给 radix cache。调太小 → radix 很大,static 不够 OOM;调太大 → radix 小,缓存命中率低。经验值 0.85~0.88。

九、监控和告警
#

核心指标:

指标 告警阈值
sglang:num_queue_requests > 50 持续 5 分钟
sglang:cache_hit_rate 多轮场景 < 30% 异常
sglang:token_usage > 95% 预警
P50/P95 首 token 延迟 > SLA
P50/P95 token 间延迟 > SLA
GPU util < 40% 且有请求 → 调度异常
GPU 显存

SGLang 的 metrics 设计比 vLLM 更偏工程化,Prometheus 接入非常直接。

十、踩坑合集
#

坑 1:RadixAttention 对 prompt 前缀稳定性敏感
#

如果 system prompt 里有"当前时间: 2026-03-14 15:30:42"这种变动字段,每次请求前缀都不同,RadixAttention 完全失效。解决:把动态字段挪到 user message 开头,system prompt 保持不变。

坑 2:约束解码和 streaming
#

流式输出时约束解码的 FSM 状态要保持一致。SGLang 处理了但高频场景有 CPU 开销。长 JSON schema + 高并发流式时观察 CPU 水位。

坑 3:多 LoRA 热切换慢
#

LoRA 文件第一次加载时要从磁盘读 + 应用到 base,100-500ms 级别。热点 LoRA 常驻显存,冷 LoRA 每次切换都慢。设置 --max-loras-per-batch--max-cpu-loras 控制驻留策略。

坑 4:radix tree 淘汰抖动
#

当并发突然飙升,radix tree 大量淘汰已缓存路径,短期内缓存命中率掉到接近零,延迟瞬时尖刺。HPA 扩容要有预扩容策略(基于 queue 长度而不是当前 QPS)。

坑 5:FlashInfer kernel 对非 LLaMA 系模型支持差
#

FlashInfer 优先支持 LLaMA 架构。Falcon、Phi、DeepSeek V2 某些 attention 变体需要换 --attention-backend triton

坑 6:前端 DSL 和后端版本绑定
#

SGLang 前端和后端版本要一致,不然会出现 API 不兼容(比如 sgl.gen 里某个新参数老后端不认)。生产环境固定版本。

坑 7:JSON schema 过于自由导致约束失效
#

{"type": "string"} 允许任意字符串,约束基本等于没加。schema 要具体到 pattern / maxLength,才能真正防止胡乱输出。

坑 8:上下文超限的错误码
#

请求超过 context-length 时 SGLang 直接返回 400,而不是像某些 API 那样截断。客户端要处理这个错误,不要把异常当服务故障上报。

坑 9:tokenizer 不一致
#

SGLang 使用 HF tokenizer 加载模型路径下的 tokenizer,如果你的模型目录混进了其他 tokenizer 文件(比如 tokenizer.modeltokenizer.json 不匹配),生成结果会乱。以干净目录加载。

坑 10:CUDA Graph 形状敏感
#

开了 CUDA Graph(默认开)之后形状变化会触发 recapture。chunk size、max batch 这些参数影响 capture 的形状集合,一次配置好不要频繁改。

十一、SGLang vs vLLM vs TRT-LLM
#

维度 SGLang vLLM TRT-LLM
KV 共享机制 RadixAttention 最强 Prefix Caching 一般 KV reuse 较强
多轮对话 最优 一般 较好
Agent 场景 最优 一般 较好
结构化生成 原生 DSL 支持 支持但简单 支持但不如 SGLang
吞吐(单请求) 接近 vLLM 最高
延迟(非共享场景) 接近 vLLM 接近 TRT-LLM 最低
多 LoRA 支持 支持 支持
上手难度
前端 DSL
硬件 NVIDIA 为主 只 NVIDIA

11.1 我的选型决策树
#

你的业务是不是以多轮 / Agent / RAG 固定 prompt 为主?
├─ 是 → SGLang
└─ 否
   ├─ 延迟敏感到极致 + Triton 栈已有 → TRT-LLM
   └─ 否 → vLLM(最省心)

很多团队最后会混合部署:Agent 服务走 SGLang,开放式 chat 走 vLLM,极限延迟服务走 TRT-LLM。用 LiteLLM 这类网关统一接入,业务层无感。

十二、一个完整的 Agent 落地示例
#

把上面的知识串起来,写一个小 Agent:

import sglang as sgl
import json

sgl.set_default_backend(sgl.RuntimeEndpoint("http://sglang:30000"))

tool_schema = json.dumps({
    "type": "object",
    "properties": {
        "tool": {"type": "string", "enum": ["search", "calc", "done"]},
        "query": {"type": "string"}
    },
    "required": ["tool", "query"]
})

def do_search(q): return f"搜索结果: {q} 的答案..."
def do_calc(expr): return str(eval(expr))

@sgl.function
def agent(s, user_msg, max_steps=5):
    s += "你是一个 Agent,可以调用 search / calc / done 三个工具。\n"
    s += "用户: " + user_msg + "\n"
    for i in range(max_steps):
        s += f"第 {i+1} 步思考: " + sgl.gen(f"th_{i}", max_tokens=150) + "\n"
        s += "工具调用: " + sgl.gen(f"call_{i}", json_schema=tool_schema, max_tokens=200) + "\n"
        call = json.loads(s[f"call_{i}"])
        if call["tool"] == "done":
            s += "最终回答: " + sgl.gen("final", max_tokens=300)
            return
        elif call["tool"] == "search":
            result = do_search(call["query"])
        elif call["tool"] == "calc":
            result = do_calc(call["query"])
        s += "工具返回: " + result + "\n"

state = agent.run(user_msg="(3+5)*2 等于多少,顺便搜一下相关的数学史")
print(state["final"])

这段代码的几个关键点:

  1. system prompt 前缀在所有请求中完全一致 → RadixAttention 命中
  2. 工具 schema 用约束解码保证 JSON 合法 → 不用重试
  3. 多步循环在 Python 层展开,每步一次 LLM 调用,每次都能命中前面步骤的 KV
  4. 串行 step 中 radix tree 逐步生长,KV 充分复用

实测这种 pattern 下 Agent 的 P95 首 token 在 200-400ms,整条链 5 步跑完 2-4 秒,vLLM 跑同一链需要 8-15 秒。

十三、上线 checklist
#

[ ] 选对 attention backend (H100 用 flashinfer)
[ ] --schedule-policy lpm 启用
[ ] --mem-fraction-static 0.85~0.88
[ ] RadixAttention 没有被禁用
[ ] system prompt 前缀稳定无动态内容
[ ] 约束解码的 JSON schema 具体到 pattern
[ ] Prometheus /metrics 接入
[ ] cache_hit_rate 监控和告警
[ ] queue 长度告警(不要只看 QPS)
[ ] HPA 基于 queue_len + gpu_util 双指标
[ ] 前端 DSL 版本和后端锁定
[ ] 模型目录干净,tokenizer 文件一致
[ ] /dev/shm 足够大
[ ] 压测覆盖多轮对话、结构化输出、streaming 三种模式

SGLang 被严重低估。RadixAttention 不是一般的"优化"——它改写了 LLM 服务的工作模式假设。如果你业务有大量共享前缀(Agent、多轮、长 system prompt),切过去的收益会大得超预期。我们那条线 P95 从 800ms 压到 250ms 就是实打实的证据。

Wenzhuo Huang
作者
Wenzhuo Huang
搞运维的工程师,写代码的运维人。专注 Kubernetes、AWS、GitOps 与基础设施可靠性。这个博客既是我的技术笔记本,也是我踩过的坑的受害者档案。

相关文章

Prompt Engineering 完全指南:从入门到工程化

·721 字·4 分钟
Prompt Engineering 不是玄学,而是有规律可循的工程实践。从基础技巧到企业级工程化,本文覆盖提示词设计的完整方法论,包括 A/B 测试、版本管理、失效模式分析,以及在生产系统中管理提示词的最佳实践。