JSON Schema 输出:把模型变成严格 API
谁该读这一篇? 业务要求 LLM 输出严格 JSON、抽取实体、做数据增强的开发者。 前置阅读:
02-core-concepts/04-compressed-fsm.md、01-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. 自检
- 简单 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。- 为什么
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`。- 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 到对应分支,避免并行候选。- 流式输出 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` 严格校验。- function calling 和 json_schema 输出的差别?
答案
两者都是受约束 JSON,但**协议层不同**: - **json_schema 输出**:模型直接吐 JSON 字符串;客户端 `json.loads` 拿到 dict,自己处理。 - **function calling**:模型吐 `12. 下一步
04-tool-use.md— Function calling。09-advanced-features/02-structured-output.md— 结构化进阶。02-core-concepts/04-compressed-fsm.md— 算法原理。- 源码:
srt/constrained/。