预计阅读 6 分钟

JSON Schema 输出:把模型变成严格 API

谁该读这一篇? 业务要求 LLM 输出严格 JSON、抽取实体、做数据增强的开发者。 前置阅读: 02-core-concepts/04-compressed-fsm.md01-dsl-walkthrough.md耗时: 30 分钟 学完能: 1. 用 SGLang 强制输出符合 JSON Schema 的结构; 2. 写复杂嵌套 schema 而不踩坑; 3. 选 grammar backend; 4. 排查 schema 编译慢 / 输出错位; 5. 知道 schema 高级写法(enum / oneOf / array constraint)。


1. 三种用法

1.1 DSL 方式

import json
import sglang as sgl

schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "age": {"type": "integer", "minimum": 0, "maximum": 150}
    },
    "required": ["name", "age"]
}

@sgl.function
def extract(s, text):
    s += sgl.user(f"Extract person: {text}")
    s += sgl.assistant_begin()
    s += sgl.gen("data", max_tokens=200, json_schema=json.dumps(schema))
    s += sgl.assistant_end()

result = json.loads(extract.run(text="Alice is 30")["data"])

1.2 OpenAI 兼容 API

from openai import OpenAI
client = OpenAI(base_url="http://localhost:30000/v1", api_key="EMPTY")

resp = client.chat.completions.create(
    model="...",
    messages=[...],
    response_format={
        "type": "json_schema",
        "json_schema": {"name": "person", "schema": schema}
    }
)

1.3 Regex 简化版

sgl.gen("color", regex=r"red|blue|green")
sgl.gen("number", regex=r"-?\d+(\.\d+)?")

简单选择 / 模式用 regex,复杂结构用 json_schema。


2. 复杂 schema 例子

2.1 嵌套对象

schema = {
    "type": "object",
    "properties": {
        "user": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "address": {
                    "type": "object",
                    "properties": {
                        "city": {"type": "string"},
                        "country": {"type": "string"}
                    }
                }
            }
        },
        "tags": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 5}
    }
}

2.2 Enum

schema = {
    "type": "object",
    "properties": {
        "status": {"type": "string", "enum": ["active", "inactive", "pending"]},
        "priority": {"type": "integer", "enum": [1, 2, 3]}
    }
}

Enum 在 SGLang 里编译为 FSM 分支边,性能最好。

2.3 oneOf / anyOf

schema = {
    "type": "object",
    "properties": {
        "event": {
            "oneOf": [
                {"type": "object", "properties": {"type": {"const": "login"}, "user": {"type": "string"}}},
                {"type": "object", "properties": {"type": {"const": "logout"}, "user": {"type": "string"}}}
            ]
        }
    }
}

⚠️ oneOf 编译复杂,慎用。anyOf 性能更稳。

2.4 数组约束

{"type": "array", "items": {"type": "string"}, "minItems": 2, "maxItems": 5}

minItems / maxItems 转 FSM 计数器,runtime 强制满足。

2.5 String pattern(regex)

{"type": "string", "pattern": "^[A-Z]{2}-\\d{4}$"}      # 如 "AB-1234"

pattern 直接编译进 FSM,速度极快。


3. Grammar Backend 切换

python -m sglang.launch_server \
    --grammar-backend xgrammar          # 编译快、稳定
    --grammar-backend outlines          # 默认,jump-ahead 强
    --grammar-backend llguidance        # 支持 CFG

选择指南(02-core-concepts/04-compressed-fsm.md §5 已展开):

schema 类型 推荐
简单 JSON(< 10 字段) outlines(默认)
复杂嵌套 + oneOf xgrammar
自定义 CFG(不仅 JSON) llguidance
reasoning 模型(带 think) reasoner_grammar

4. Schema 编译开销

Schema 复杂度 编译耗时
简单(3-5 字段) < 10 ms
中等(20 字段、1-2 层嵌套) 50-200 ms
复杂(深嵌套 + oneOf + 自定义 pattern) 500 ms - 5 s

解决

  • SGLang 编译结果缓存(grammar_manager.py)。
  • 同一 schema 重复使用时 cache 命中。
  • 业务上线前 warmup 把热 schema 编译好。

5. 常见踩坑

5.1 Schema 字符串变化 cache miss

# ❌ 每次 dict 序列化字段顺序不固定,cache miss
schema_str = json.dumps({"name": ..., "age": ...})

# ✓ 固定顺序(sort_keys=True)
schema_str = json.dumps(schema, sort_keys=True)

5.2 数字 type 注意

# ❌ type 是 number 时模型可能输出 1e10
{"type": "number"}

# ✓ 用 integer 或加 multipleOf
{"type": "integer", "minimum": 0}

5.3 字符串太短或太长

{"type": "string", "minLength": 1, "maxLength": 100}

不加约束时模型可能瞎填超长或空串。

5.4 Required 字段

{
    "type": "object",
    "properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
    "required": ["name", "age"]      # 强制存在
}

不写 required 时 grammar 允许跳过字段。


6. 性能 cheatsheet

写法 性能影响
多次相同 schema 请求 cache 命中,快
schema 内含长 literal(如 system prompt) jump-ahead 多,比无约束更快
大量 oneOf 分支 编译慢、运行 mask 大
大数组(maxItems=1000) 显存 / 时延 OK,但生成慢(要逐项采样)
Pattern 复杂正则 编译慢,建议拆字段

7. 与 function calling 的关系

Function calling = 受约束输出 + 解析。 区别:

  • JSON Schema 输出:模型直接出 JSON,你自己 json.loads
  • Function calling:模型出 "..." 包裹的 JSON,runtime 解析并触发 tool。

下一章 04-tool-use.md 展开。


8. Streaming + JSON Schema

stream = client.chat.completions.create(
    model="...",
    messages=[...],
    response_format={"type": "json_schema", "json_schema": {...}},
    stream=True
)
buf = ""
for chunk in stream:
    delta = chunk.choices[0].delta.content
    if delta:
        buf += delta
        # 增量 parse(json 不完整时容错)
        ...

注意:客户端拿到的 chunk 仍是字符串,不是 JSON 对象。 增量 parse 用 partial-json 这类库。


9. 进阶:自定义 grammar(CFG)

llguidance backend 支持 CFG(Context-Free Grammar):

grammar = """
root ::= "Question: " text " Answer: " text
text ::= [a-zA-Z0-9 .,!?]+
"""
sgl.gen("qa", grammar=grammar)

适合:DSL / 代码 / 特定 markup 生成。但学习成本高,初学者用 JSON schema 就够。


10. 小结

  • SGLang 用压缩 FSM / xgrammar / llguidance 强制结构化输出。
  • DSL 用 sgl.gen(json_schema=...),OpenAI API 用 response_format
  • Enum / required / pattern 都受支持,oneOf 慎用。
  • Schema 编译有开销,注意 cache 命中。
  • 复杂场景用 xgrammar 或 llguidance CFG。

11. 自检

  1. 简单 JSON schema 第一次请求和第二次的时延差异?
答案 第一次:触发 grammar 编译(schema → FSM)+ vocab mask 张量分配,开销 5-200ms(看 schema 复杂度)。简单 schema 5-10ms。 第二次:`GrammarManager` 缓存命中,编译跳过,~0ms 额外开销。 差异:简单 schema 1-10ms,复杂深嵌套 schema 200ms-5s(编译期)+ 0ms(复用期)。 生产建议:业务上线时 warmup 把所有热 schema 编译进 cache。
  1. 为什么 json.dumps(schema, sort_keys=True) 比不加好?
答案 不加时 `json.dumps(dict)` 字段顺序依赖 Python dict 插入顺序(CPython 3.7+ 保留插入序),不同代码路径构造的 dict 可能字段顺序不同 → 序列化字符串不同 → grammar cache **key 不同 → cache miss → 重复编译**。 加 `sort_keys=True` 后字段按字母序,相同 schema 一定产出相同字符串,cache 稳定命中。 实战:业务多处构造同一个 schema 时这个细节最容易踩坑;推荐把 schema 字符串作为模块常量,避免每请求 `json.dumps`。
  1. oneOf 慢的原因?
答案 `oneOf` 在 FSM 编译时变成"并行候选状态机"——N 个分支并存,每步 sample 时要检查所有分支的合法 token,再用 isomorphism check 排除不正确分支。 慢点: (a) **编译时**:N 个子 schema 各自构建 FSM,组合状态数 O(N × 单分支状态数)。 (b) **运行时 mask 计算**:每步要算"任一分支允许该 token"的并集;嵌套 oneOf 状态爆炸。 (c) **Jump-ahead 失效**:oneOf 不同分支的字面量不同 → 没有"唯一确定路径"。 优化:改用 **discriminated union**(每分支首字段是 `{"const": "type_name"}`)——FSM 可以根据首字段 jump 到对应分支,避免并行候选。
  1. 流式输出 JSON 时客户端怎么 parse?
答案 不能用 `json.loads`(要求完整 JSON)。用 **partial JSON parser** 库: ```python import partial_json # 或 partialjson buf = "" for chunk in stream: buf += chunk.delta.content or "" try: parsed = partial_json.loads(buf) # parsed 是当前能解析出的最深结构 update_ui(parsed) except: pass ``` 这类库会忽略不完整的尾部,返回当前完整部分(如已收到 `{"name": "Al`,返回 `{"name": "Al"}` 或 partial)。 用途:UI 增量更新(用户已经看到 name 字段就显示,不等 email 字段)。 注意:partial parse 容错 buggy schema 时会 silently 吞错,正式落库前还是要全文 `json.loads` 严格校验。
  1. function calling 和 json_schema 输出的差别?
答案 两者都是受约束 JSON,但**协议层不同**: - **json_schema 输出**:模型直接吐 JSON 字符串;客户端 `json.loads` 拿到 dict,自己处理。 - **function calling**:模型吐 `{"name":"...","arguments":{...}}` 包裹的 JSON;SGLang 的 tool parser([`srt/function_call/`](../sglang/python/sglang/srt/function_call/))识别包裹标签,把内容塞进 OpenAI 协议的 `tool_calls` 字段;客户端按 OpenAI SDK 接口拿 ToolCall 对象。 实际:function calling = json_schema + 包裹标签 + parser + OpenAI 协议适配。 选择:纯抽取 JSON 用 json_schema 直白;想接 OpenAI 兼容客户端的 agent 框架(LangChain / LlamaIndex)用 function calling。

12. 下一步

上游源码:sglang/python/sglang/srt/constrained/