预计阅读 9 分钟

前端 DSL:六个原语与一个 ProgramState

谁该读这一篇? 想搞清 SGLang 凭什么比"客户端循环 + OpenAI API"更快的工程师;准备用 SGLang 写 agent / 多分支推理程序的开发者。 前置阅读: 01-overview/01-what-is-sglang.md §3.4。 耗时: 40 分钟 学完能: 1. 列出六个核心原语(gen / select / fork / system / user / assistant)和它们的语义; 2. 画出 DSL 程序如何被编译成一棵执行计划树,提交给运行时; 3. 解释 ProgramState 在客户端 / 服务端各承担什么; 4. 写一个"system + 多分支 + 投票"的 DSL 程序; 5. 看到一个慢的 DSL 程序能立刻判断 cache 命中是否成立。


1. 一个 DSL 程序的样子

import sglang as sgl

@sgl.function
def react(s, question, tools):
    s += sgl.system("You are a tool-using assistant.")
    s += sgl.user(f"Q: {question}\nAvailable tools: {tools}")
    s += "Thought: "
    s += sgl.gen("thought", max_tokens=200, stop="\n")
    s += "\nAction: "
    s += sgl.select("action", choices=tools)        # 受限:只能选已知 tool
    s += "\nObservation: " + run_tool(s["action"])
    s += "\nAnswer: "
    s += sgl.gen("answer", max_tokens=200)

state = react.run(question="今天上海几度?", tools=["weather", "calculator"])
print(state["answer"])

这个程序在客户端看起来是顺序代码,但运行时收到的是一个执行计划

计划:
  常量段:  "[SYS] You are ... [/SYS][USER] Q: ... [/USER]Thought: "
  gen("thought", max_tokens=200, stop="\n")
  常量段:  "\nAction: "
  select("action", choices=["weather","calculator"])
  常量段:  "\nObservation: <result>\nAnswer: "
  gen("answer", max_tokens=200)

运行时的优势:

  1. 常量段被 RadixCache 沉淀——同 system / 同 user 模板的请求自动复用。
  2. select 让运行时直接做"4 选 1"(限定 token,不必等模型自然吐 token id)。
  3. 多步生成在同一个 ProgramState 里,运行时知道它们是同一条上下文,不必客户端来回发完整 prompt。

2. 六大原语:一句话语义

原语 语义
s += "..."s += sgl.gen(...) 把内容追加到 ProgramState 的字符串里
sgl.gen(name, max_tokens=...) 让 LLM 自由生成,结果存到 s[name]
sgl.select(name, choices=[...]) 让 LLM 在指定 N 个字符串里选一个
sgl.fork(N) 显式开 N 个分支,每个分支独立续写但共享 prefix
sgl.system / user / assistant role 标签(最终编译为模型的 chat template)
sgl.image(path) / sgl.video(path) 多模态输入(占一个 placeholder token,runtime 替换)

每个原语都对应 lang/api.py 里一个函数。看代码就是看那个文件。


3. gen:基本原语

sgl.gen(
    name,                  # 结果在 s[name] 里
    max_tokens=128,        # 最大生成
    temperature=0.7,
    top_p=0.95,
    stop=["\n", "###"],    # 停止符
    regex=r"\d+",          # 受限:正则约束
    json_schema={...},     # 受限:JSON Schema 约束
)

最重要的两个参数:

  • stop:可以是字符串或字符串列表。早停能省 token。
  • regex / json_schema:触发受约束解码(04-compressed-fsm.md 主题)。

api.py:75def gen(...))是入口;底层最终调到运行时的 /generate 接口。

3.1 gen_int / gen_string 速写

sgl.gen_int("count", max_tokens=10)    # 等价于 gen + regex=r"\d+"
sgl.gen_string("color", max_tokens=20) # 等价于 gen + regex='"[^"]*"'

新手喜欢用,老手直接 gen + regex


4. select:受限选择

sgl.select(name, choices=["red", "blue", "green"])

实现思路:

  1. 把每个 choice 都 tokenize 成 token 序列。
  2. 一次 forward 拿到 logits。
  3. 对每个 choice 算 P(choice | prompt) = ∏ P(token_i | prompt + token_<i)(要做一次 forward 拿前面的)。
  4. 取分数最高的 choice,把对应 token 序列写回 ProgramState。

为什么这比客户端 4 次请求快得多

  • 客户端 4 次请求 = 4 次完整 prefill。
  • select = 1 次 prefill + 一次"短 forward"扫每个 choice 的 token。
  • 命中 cache 的话 prefill 都省了。

性能差距通常 5×。源码入口:api.py:236


5. fork:显式分支

@sgl.function
def vote(s, question):
    s += sgl.system("Think step by step.")
    s += sgl.user(question)
    forks = s.fork(5)                  # 开 5 个分支
    for f in forks:
        f += sgl.assistant_begin()
        f += sgl.gen("reasoning", max_tokens=400)
        f += sgl.assistant_end()
    # 收集 5 个分支的答案
    s += "Final answer (majority vote): "
    s += sgl.gen("final", max_tokens=50, choices=[
        forks[i]["reasoning"] for i in range(5)
    ])

fork(5) 在 RadixTree 里的语义:

       [prefix node: SYS + USER]    ← lock_ref += 5(5 个分支都 hold 住)
      /     |     |     |     \
    f1     f2    f3    f4    f5    ← 各自的 gen 结果挂这里

收益:

  • 5 个分支共享前面所有 prefill 算力(不复制 KV,5 个分支的 attention 都在同一段 K/V 上算)。
  • Cascade attention kernel 把"shared prefix + 不同 query" 的计算一次性做完。

如果用 OpenAI API 模拟 fork:发 5 次独立请求 → 5 次完整 prefill → 算力 5×。SGLang 5×(≥)收益的核心来源


6. system / user / assistant:chat template

s += sgl.system("You are X.")
s += sgl.user("Q?")
s += sgl.assistant_begin()
s += sgl.gen("answer")
s += sgl.assistant_end()

不同模型的 chat template 不同(Llama / Qwen / Mistral 各自的 SYS、USER 包裹符不一样)。 SGLang 内部维护一张 chat template 表(chat_template.py),按模型自动选;你写 DSL 时不必关心物理 token 是 <|im_start|> 还是 [INST]

显式 begin/end 的用处

assistant_begin() 单独成块时,运行时知道"接下来 gen 的内容属于 assistant 角色",做 prefix cache 时会按角色边界切片,避免不同角色串接污染缓存。


7. ProgramState:客户端 vs 服务端的双面

客户端 ProgramState(interpreter.py:852

  • 一个 Python 对象,保存所有已经生成的命名结果(s["thought"]s["answer"])。
  • 维护一个字符串"程序当前的完整 prompt"。
  • 每次 gen 调用时通过 backend(RuntimeEndpoint / Engine)发请求。

服务端 ReqState(tokenizer_manager.py:142

  • 每个 in-flight 请求一份。
  • 当客户端 ProgramState 调用 gen 时,HTTP 请求里带"上次返回的 rid"作为 continuation context。
  • 服务端因此知道这个 gen 应该续在哪段 KV 后面,cache 命中 100%。

这种"双侧 state 同步" 是 SGLang DSL 相比纯 OpenAI API 的关键。


8. StreamExecutor:执行器

@sgl.function 装饰的函数被调用时(.run(...)),其实是 StreamExecutor.run(program, ...)interpreter.py:274)。 执行器做的事:

  1. 解析 program 的 AST(每个 s += ... 调用产生一个 SglExpr 节点)。
  2. 按顺序执行:常量段直接拼字符串;gen 触发 backend 请求;fork 拷贝 state 并并行执行。
  3. 把每次 gen 的结果填进 ProgramState。

并行:fork 创建的多个分支默认用 asyncio 并发提交请求;运行时自动按 cache-aware 调度去合并。


9. 一个完整例子:多步 ReAct

import sglang as sgl

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

TOOLS = {
    "weather": lambda q: "上海 22 度,晴",
    "calculator": lambda q: str(eval(q)),
}

@sgl.function
def react(s, question):
    s += sgl.system("You are a tool-using assistant. Use the format:\n"
                    "Thought: ...\nAction: <tool>\nAction Input: ...\n"
                    "Observation: ...\nFinal Answer: ...")
    s += sgl.user(question)
    s += sgl.assistant_begin()

    for step in range(3):                              # 最多 3 步
        s += "Thought: "
        s += sgl.gen(f"thought_{step}", max_tokens=200, stop="\n")
        s += "\nAction: "
        s += sgl.select(f"action_{step}", choices=list(TOOLS.keys()) + ["FINISH"])
        if s[f"action_{step}"] == "FINISH":
            break
        s += "\nAction Input: "
        s += sgl.gen(f"input_{step}", max_tokens=100, stop="\n")
        observation = TOOLS[s[f"action_{step}"]](s[f"input_{step}"])
        s += f"\nObservation: {observation}\n"

    s += "\nFinal Answer: "
    s += sgl.gen("final", max_tokens=200)
    s += sgl.assistant_end()

print(react.run(question="2+3+5 等于多少?")["final"])

为什么这个程序对运行时友好

  • system + user 是常量,所有 react.run(...) 调用都共享。
  • 每步内部都是"上一步的 prefix + 新增 token",每步的 prefill 完整命中。
  • select 强制 action 只能取已知 tool,模型不会乱编。
  • assistant_begin/end 让缓存正确切片。

10. 调试 DSL 程序

sgl.set_default_backend(sgl.RuntimeEndpoint("http://localhost:30000"))
state = react.run(question="...", verbose=True)
print(state.text())              # 完整拼接后的 prompt + 生成
print(state.get_meta_info("final"))  # 单步 metadata(token 数、耗时等)

state.text() 是最高频用法,能看到 LLM 拿到的完整字符串,排查"为什么 chat template 错位"超有用。


11. 性能调参 cheatsheet

现象 调什么
DSL 程序比同等 OpenAI 调用还慢 检查 RadixCache 命中率(/metrics);调度策略
多 fork 没有线性加速 看 max_running_requests 是否够;GPU 是否已经吃满
受约束 gen 很慢 看 schema 复杂度;考虑换 grammar backend
select 的概率全是 0 choices 文本要 tokenize 一致,避免空格差异
大量请求时 ProgramState 内存涨 客户端 ProgramState 持有结果 string;用完释放

12. 小结

  • 六大原语:gen / select / fork / system / user / assistant,再加 image / video 共八个。
  • DSL 程序被编译成执行计划,常量段沉淀进 RadixCache,gen / select 触发 forward。
  • ProgramState 在客户端和服务端各有一份,同步靠 rid 和续 cache 命中。
  • fork 是最能体现 SGLang 优势的原语:多分支共享 prefix,Cascade attention kernel 一次性算。
  • 调试用 state.text()/metrics 的 cache_hit_rate。

13. 自检

  1. 用 OpenAI API 模拟 s.fork(5) 需要发几个请求?性能差距来源?
答案 需要发 **5 个独立请求**(每个分支一次),都带完整 prefix。差距来源: (a) **Prefill 算力**:5 次完整 prefill,而 `fork(5)` 只 prefill 一次共享 prefix; (b) **KV 内存**:5 个独立请求各占一份 prefix KV;fork 内部所有分支引用同一棵 radix tree 节点; (c) **Cascade kernel**:fork 触发 FlashInfer 的 multi-level cascade,一次 attention 算 5 个 Q 共享同段 K/V,L2 命中率拉满;客户端独立请求做不到。 叠加起来通常 3-5×。
  1. sgl.select(choices=["red","blue"]) 在运行时实际跑了几次 forward?
答案 一次完整 prefill(消化 prompt)+ 一次"短 forward"扫两个 choice 的 token logprob。 实现:把 `["red", "blue"]` 各自 tokenize,拼到 prompt 末尾构造一个特殊 batch,跑一次 forward 拿到每个候选的 token 序列 logprobs,算 `∑ log P(token_i | ...)`,挑大的。 注意短 forward 仍是单步 forward,不是 N 步——因为每个 choice token 序列长度小(通常 1-3 token),可以一次性算完所有候选 token 的 logits。
  1. 我写了 s += "Hi"; s += sgl.gen(...),可以用 s += "Hi" + sgl.gen(...) 吗?为什么?
答案 不能。`"Hi" + sgl.gen(...)` 会先求值右边 —— 此时 `sgl.gen(...)` 是个 `SglExpr` 对象(执行计划节点)而不是字符串,`str.__add__` 不接受这种类型,要么报 TypeError 要么走 SglExpr 的 `__radd__` 但语义就变了。 语法 `s += X` 走的是 `StreamExecutor.__iadd__`,它知道如何顺序处理常量字符串和 `SglExpr`——常量直接拼进 prompt,`SglExpr` 触发 backend 调用。这是 DSL 的核心 trick。
  1. 同一个 @sgl.function 被并发调用 100 次,RadixCache 里会有 100 个独立分支还是共享?为什么?
答案 **共享一段公共前缀**,然后按各次调用的参数分叉。 每次调用产生一个 client 端的 ProgramState;如果 prompt 里前面部分(system + 公共模板)相同,第 1 次执行完后 RadixCache 里就有一个共享节点;后续 99 次的 `match_prefix` 都命中这个节点(lock_ref 累加),只在后续不同 user input 处分叉。 如果 100 次的输入完全不一样(如 user 内容随机),分叉点会很早,共享部分只有 system;如果 system 占大头,仍能省 80%+ prefill。
  1. 我的 select 老是选错的 choice,可能是什么原因?
答案 常见三种: (a) **tokenize 边界**:`"red"` 和 `" red"`(带前导空格)是不同 token 序列,模型可能更倾向后者。检查 prompt 末尾是否有空格,让 choices 跟它一致。 (b) **choice 长度差异大**:长 choice 累计的 logprob 自然更负,被惩罚(虽然 SGLang 做了 length-normalization,但不完美)。统一长度或加 prefix。 (c) **prompt 引导不够**:模型对 choice 的偏好被 system / examples 影响弱。强化 user prompt(如 "Answer with one of: A/B/C/D"),或者用 few-shot example。 极端时改用 `sgl.gen` + `regex="A|B|C|D"`,强制走压缩 FSM 路径。

14. 下一步

上游源码:sglang/python/sglang/lang/