前端 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)
运行时的优势:
- 常量段被 RadixCache 沉淀——同 system / 同 user 模板的请求自动复用。
select让运行时直接做"4 选 1"(限定 token,不必等模型自然吐 token id)。- 多步生成在同一个 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:75(def 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"])
实现思路:
- 把每个 choice 都 tokenize 成 token 序列。
- 一次 forward 拿到 logits。
- 对每个 choice 算 P(choice | prompt) = ∏ P(token_i | prompt + token_<i)(要做一次 forward 拿前面的)。
- 取分数最高的 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)。
执行器做的事:
- 解析 program 的 AST(每个
s += ...调用产生一个 SglExpr 节点)。 - 按顺序执行:常量段直接拼字符串;
gen触发 backend 请求;fork拷贝 state 并并行执行。 - 把每次 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. 自检
- 用 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×。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。- 我写了
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。- 同一个
@sgl.function被并发调用 100 次,RadixCache 里会有 100 个独立分支还是共享?为什么?
答案
**共享一段公共前缀**,然后按各次调用的参数分叉。 每次调用产生一个 client 端的 ProgramState;如果 prompt 里前面部分(system + 公共模板)相同,第 1 次执行完后 RadixCache 里就有一个共享节点;后续 99 次的 `match_prefix` 都命中这个节点(lock_ref 累加),只在后续不同 user input 处分叉。 如果 100 次的输入完全不一样(如 user 内容随机),分叉点会很早,共享部分只有 system;如果 system 占大头,仍能省 80%+ prefill。- 我的
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. 下一步
03-cache-aware-scheduling.md— 运行时如何利用 DSL 暴露的结构做调度。04-compressed-fsm.md—regex/json_schema背后的压缩 FSM。06-frontend-language/01-dsl-walkthrough.md— 用 DSL 写一个完整 agent 的细节。- 源码:
lang/api.py、lang/interpreter.py、lang/ir.py。