预计阅读 6 分钟

动手 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. 扩展练习

试着改造:

  1. 加上溯源:让模型在 answer 里引用 source("according to Doc A...")。
  2. 加上拒绝回答:confidence < 0.5 时返回 "I don't know"。
  3. 多语言:question 是中英混合时,sources 用对应语言。
  4. 流式 + 工具:让 final 阶段调用 calculator tool 验证数字。
  5. 嵌套 fork:每个 draft 内部再开 2 分支(一个考虑反例、一个考虑细节)。

每个变体都看 cache_hit_rate 怎么变。


9. 小结

  • DSL 让"检索 + 多分支推理 + 投票 + 结构化输出"压缩成一个程序。
  • Fork 共享 prefix 是性能核心。
  • 受约束输出保证最终 JSON 格式。
  • 同 system / 同问题命中率 90%+ 是常态。
  • 比纯 OpenAI 客户端循环快 3-5×。

10. 自检

  1. 上面程序里 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%。
  1. 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`。
  1. 怎么测出"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`。
  1. 流式输出最终 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 增量。
  1. 业务问题 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. 下一步

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