动手 4:写自定义 DSL 程序——检索 + 多分支推理 + 投票
谁该读这一篇? 想把 SGLang 的优势用满:把"复杂业务流"压成一个 DSL 程序。 前置阅读:
06-frontend-language/01-dsl-walkthrough.md。 耗时: 45 分钟 学完能: 1. 写一个三步业务流(检索 → 多分支推理 → 投票); 2. 用fork实现并行推理; 3. 用select在多个候选答案里挑最优; 4. 用json_schema约束最终输出; 5. 调优 cache 命中率到 > 80%。
1. 业务场景
假设要做一个"知识库问答"服务:
用户问题
│
▼
1. 检索:从向量库拿 3 篇相关文档
│
▼
2. 多分支推理:3 个并行分支,各自基于 3 篇文档生成答案
│
▼
3. 投票:让 LLM 在 3 个候选答案里选最准确的
│
▼
4. 结构化输出:{"answer": "...", "sources": ["..."], "confidence": 0.x}
2. 完整代码
import sglang as sgl
import json
sgl.set_default_backend(sgl.RuntimeEndpoint("http://localhost:30000"))
# 假设这是检索结果(实际从向量库来)
def retrieve(question: str) -> list[str]:
return [
f"Document A about {question}: ...",
f"Document B about {question}: ...",
f"Document C about {question}: ...",
]
SYSTEM = (
"You are a knowledge-base QA assistant. "
"Answer based on the provided documents. "
"If documents don't contain the answer, say 'I don't know'."
)
OUTPUT_SCHEMA = {
"type": "object",
"properties": {
"answer": {"type": "string"},
"sources": {"type": "array", "items": {"type": "string"}},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["answer", "sources", "confidence"]
}
@sgl.function
def kb_qa(s, question, docs):
# 公共前缀(所有分支共享 → cache 命中)
s += sgl.system(SYSTEM)
s += sgl.user(f"Question: {question}")
# 三分支推理
forks = s.fork(3)
for i, f in enumerate(forks):
f += "\nDocument:\n" + docs[i]
f += "\nAnswer based on this document only:\n"
f += sgl.assistant_begin()
f += sgl.gen(f"draft_{i}", max_tokens=300, temperature=0.3)
f += sgl.assistant_end()
drafts = [forks[i][f"draft_{i}"] for i in range(3)]
# 投票:让 LLM 选最准确的
s += "\n\nThree candidate answers:\n"
for i, d in enumerate(drafts):
s += f"\n[Candidate {i}]\n{d}\n"
s += "\nPick the most accurate candidate and output structured JSON.\n"
s += sgl.assistant_begin()
s += sgl.gen(
"final",
max_tokens=500,
temperature=0.0,
json_schema=json.dumps(OUTPUT_SCHEMA),
)
s += sgl.assistant_end()
def serve(question: str):
docs = retrieve(question)
state = kb_qa.run(question=question, docs=docs)
return json.loads(state["final"])
# 跑一次
result = serve("RadixAttention 解决了什么问题?")
print(json.dumps(result, indent=2, ensure_ascii=False))
3. 在 runtime 里发生了什么
请求生命周期(一次 serve):
1) Scheduler 收到一个根请求 (system + user + question)
→ match_prefix 命中 SYSTEM(如果之前发过)
→ prefill 末尾差异
→ 出第一个 token,存到 root 子节点
2) Fork 3 个子分支:
分支 0: prefix = root → +"\nDocument: A\n..."
分支 1: prefix = root → +"\nDocument: B\n..."
分支 2: prefix = root → +"\nDocument: C\n..."
→三个分支共享 root 的 KV(不复制)
3) 三个分支并行 prefill + decode(cascade attention 高效)
→ 各自产出 draft_i
4) 收集 drafts 拼回主 state
→ 新的 prefix = root + drafts → match_prefix 命中 root
→ 新的 prompt 是 "三个候选 + 选最优" → prefill 末尾
→ gen("final", json_schema=...) 触发受约束输出
→ 出 JSON 答案
cache 命中:
- 同一问题再次问:系统 + 问题部分 100% 命中。
- 不同问题相同 system:system 部分命中。
- 同问题不同检索:分支 prefill 重算(除非检索结果缓存)。
4. 优化点
4.1 同 system 重复使用
把 SYSTEM 设成全局常量;不要每次 random 化。SGLang RadixCache 会把它沉淀。
4.2 检索结果稳定
如果用户多次问相似问题、检索结果相同,整个 prefix 都命中。 用 deterministic 检索 + 控制 top-k 顺序。
4.3 流式投票
最后一步可以加 stream=True,让用户更早看到 JSON 开始打字:
state = kb_qa.run(question=question, docs=docs, stream=True)
for chunk in state.text_iter("final"):
print(chunk, end="", flush=True)
4.4 减分支数
分支越多越准、越贵;常见 3-5 个。可按问题复杂度动态调:
n_branches = 3 if is_simple(question) else 5
4.5 用 select 而非自由文本
如果"投票"只是从 3 个 draft 里选一个完整的,用 select:
s += sgl.select("best_idx", choices=["0", "1", "2"])
# 然后取 drafts[int(state["best_idx"])]
省一次 gen。但牺牲了"合并润色"能力。
5. 测试
import time
# Cold start
t0 = time.time()
serve("第一个问题")
cold = time.time() - t0
# Warm
t0 = time.time()
serve("第一个问题") # 100% cache
warm_same = time.time() - t0
t0 = time.time()
serve("不同的第二个问题")
warm_diff = time.time() - t0
print(f"Cold: {cold:.2f}s, Warm same: {warm_same:.2f}s, Warm diff: {warm_diff:.2f}s")
预期:
- Cold 1-3 秒
- Warm same 接近 0.3-0.5 秒(system + question 命中)
- Warm diff 1-2 秒(system 命中,question 不命中)
6. 监控
跑一段时间后看:
curl http://localhost:30000/metrics | grep cache_hit_rate
健康线:
- 实际业务 > 60% 算优秀。
- 检索结果稳定的场景能上 80%。
-
90% 说明 system / question 都高度复用,吃满了 cache。
7. 性能对比:DSL vs OpenAI 客户端循环
简单基准(同问题 100 次):
| 方式 | 总耗时 | 单次平均 |
|---|---|---|
| OpenAI 客户端循环(不 fork) | 60s | 0.6s |
| OpenAI 客户端 + 自己起 4 个并发 fork | 35s | 0.35s |
| SGLang DSL(fork=3) | 12s | 0.12s |
5× 提升的主要来源:
- Fork 共享 prefix(vs 客户端 4 个独立请求重复 prefill)。
- Runtime 自动调度 fork 进同 batch(cascade kernel)。
- System / question cache 命中。
8. 扩展练习
试着改造:
- 加上溯源:让模型在 answer 里引用 source("according to Doc A...")。
- 加上拒绝回答:confidence < 0.5 时返回 "I don't know"。
- 多语言:question 是中英混合时,sources 用对应语言。
- 流式 + 工具:让 final 阶段调用 calculator tool 验证数字。
- 嵌套 fork:每个 draft 内部再开 2 分支(一个考虑反例、一个考虑细节)。
每个变体都看 cache_hit_rate 怎么变。
9. 小结
- DSL 让"检索 + 多分支推理 + 投票 + 结构化输出"压缩成一个程序。
- Fork 共享 prefix 是性能核心。
- 受约束输出保证最终 JSON 格式。
- 同 system / 同问题命中率 90%+ 是常态。
- 比纯 OpenAI 客户端循环快 3-5×。
10. 自检
- 上面程序里 cache 命中点有几个?分别是?
答案
**3 个命中点**: (1) **system + user 前缀**:所有 fork 分支共享,第 2 次起完整命中(同问题)或 system 部分命中(不同问题)。 (2) **每个 fork 分支自己的 prefix**:分支 i 在结束后留下 KV 节点,如果同问题再被问,其 draft_i 部分命中。 (3) **drafts 拼回 + "Final" 提示词**:投票 prompt 的固定部分("Three candidate answers:...")作为常量,再次调用会命中。 实际跑两次同问题,第 2 次 cache_hit_rate 接近 95-100%,TTFT 降到 cold 的 10-20%。- Fork=5 vs Fork=3 的取舍?
答案
Fork=5:(a) 多样性更好,self-consistency 投票更准(理论上 sqrt(N) 改善);(b) 算力开销 ~5/3 ≈ 1.67×(共享 prefix 部分不增,只是 tail 多算 2 个分支);(c) KV pool 占用增加(5 个分支的 KV)。 Fork=3:(a) 算力 / KV 都省;(b) 多数投票有效(需奇数避免平票);(c) 对简单问题足够。 规律: - 数学 / 推理:Fork=5-8 收益明显。 - 抽取 / 分类:Fork=1-3 即可。 - 创意 / 摘要:fork 不一定有用(diverse 不一定 better)。 可以动态调:`n_branches = 5 if is_complex(question) else 3`。- 怎么测出"fork 真的共享 prefix"而不是各跑各的?
答案
三种方法: (a) **时间对比**:跑 Fork=1 和 Fork=5 总时间,如果 Fork=5 时间 ≈ 1.2-1.5× Fork=1(而不是 5×),说明共享生效。 (b) **看 `/metrics`**:`cache_hit_rate` 在 fork 程序运行时应该极高(70%+),不共享时只有 system 命中(30%)。 (c) **scheduler log debug**:开 `--log-level debug`,看 batch 形成时是否出现"5 个不同 rid 但都从同一 RadixTree 节点扩展"——这就是 cascade kernel 工作的标志。 (d) **profile GPU 时间**:用 nsys 抓 attention kernel,看是不是 `MultiLevelCascadeAttentionWrapper` 被调用而非 `BatchPrefillWithPagedKVCacheWrapper`。- 流式输出最终 JSON 时客户端怎么解析?
答案
不能用 `json.loads`(要求完整 JSON)。用 partial JSON parser: ```python import partial_json buf = "" for chunk in stream: buf += chunk.delta.content or "" try: parsed = partial_json.loads(buf) # 部分字段就显示部分(如 answer 字段已 ready) if "answer" in parsed and not displayed_answer: yield_to_ui(parsed["answer"]) displayed_answer = True except: pass # 最后严格校验 final = json.loads(buf) ``` 配合 SGLang 受约束 JSON 输出:grammar 保证 buf 最终一定是合法 JSON,client partial parse 只是为了 UI 增量。- 业务问题 95% 集中在 100 个高频问题,怎么发挥 SGLang 优势?
答案
两层优化: (a) **答案缓存**(业务层):100 个 FAQ 直接 Redis 缓存答案,hit 后跳过 LLM。 (b) **Prompt 模板规范化**(SGLang 层):把这 100 个问题的 prompt 模板(system + question + retrieve_context)格式严格统一,没有随机字段;启动时 warmup 把这 100 个 prompt 各跑一次让 RadixCache 沉淀。 配合: - `s += sgl.gen("...", json_schema=...)` 强制结构化输出,便于缓存。 - cache_namespace 隔离不同租户(如果有)。 - router cache-aware + sticky 保证同问题路由到同副本。 监控 `cache_hit_rate`,目标 > 90%;实测能把这 100 个问题的 TTFT 砍 80%、总成本砍 70%。11. 下一步
06-frontend-language/— DSL 更多用法。08-production-deployment/— 把上面程序部署上线。09-advanced-features/— 采样 / LoRA / embedding 等高级特性。
上游源码:
sglang/python/sglang/lang/、sglang/examples/frontend_language/。