预计阅读 6 分钟

结构化输出(进阶):复杂 schema、性能调优、失败兜底

谁该读这一篇? 已经用过 json_schema 但想把复杂场景 / 性能边界吃透的开发者。 前置阅读: 02-core-concepts/04-compressed-fsm.md06-frontend-language/03-json-schema.md耗时: 35 分钟 学完能: 1. 写复杂深嵌套 schema(带 oneOf、$ref、自定义 pattern); 2. 优化 schema 编译耗时; 3. 配合 prompt engineering 让模型"愿意"遵守 schema; 4. 处理 schema 失败兜底; 5. 选对 grammar backend。


1. 复杂 schema 的工程问题

1.1 编译耗时

复杂度 → FSM 状态数 → 编译时间:

Schema 状态数 编译 ms
简单 JSON (5 字段) < 100 5-10
嵌套 JSON (3 层) 500-1000 50-100
oneOf × 5 分支 2000-5000 200-500
大型 $ref 复用 5000+ 1-3 s

第一次见到的 schema 会编译,后续 cache 命中。

优化

  • schema 字符串 deterministic(json.dumps(s, sort_keys=True))。
  • 业务上线 warmup 把所有用到的 schema 预编译。
  • 避免大型 oneOf;改用 enum + 后处理。

1.2 Vocab mask 缓存

每个 FSM 状态有一个 vocab mask(vocab_size 大的 bool 张量)。 状态多 → 缓存吃显存。 SGLang 用 LRU 控制 cache 大小,但要监控 grammar_mask_cache_size


2. 高级 schema 写法

2.1 $ref 复用

schema = {
    "$defs": {
        "Address": {
            "type": "object",
            "properties": {
                "city": {"type": "string"},
                "country": {"type": "string"}
            }
        }
    },
    "type": "object",
    "properties": {
        "home": {"$ref": "#/$defs/Address"},
        "work": {"$ref": "#/$defs/Address"}
    }
}

xgrammar 和 llguidance 支持 $ref;outlines 部分支持。

2.2 Const 字段

{"type": "object", "properties": {
    "version": {"const": "v1"}
}}

强制特定值。等价于 enum 单值。 runtime 直接 jump 整个 "v1"。

2.3 Conditional schema

{
    "type": "object",
    "properties": {"type": {"enum": ["circle", "square"]}, "radius": {"type": "number"}},
    "if": {"properties": {"type": {"const": "circle"}}},
    "then": {"required": ["radius"]}
}

⚠️ if/then/else 语义复杂,多数 grammar backend 部分支持或忽略。 推荐改写为 oneOf

2.4 Discriminated union (oneOf with const)

{
    "oneOf": [
        {"type": "object", "properties": {"kind": {"const": "user"}, "name": {"type": "string"}}, "required": ["kind", "name"]},
        {"type": "object", "properties": {"kind": {"const": "admin"}, "perms": {"type": "array"}}, "required": ["kind", "perms"]}
    ]
}

效率比一般 oneOf 高(const 让 backend 快速分支)。

2.5 Pattern 进阶

{
    "type": "string",
    "pattern": r"^\d{4}-\d{2}-\d{2}$"     # 日期
}

{
    "type": "string",
    "pattern": r"^[a-z][a-z0-9_]{2,30}$"  # 用户名
}

直接编译进 FSM,速度极快。但慎用 .*(无限分支)。


3. 字段顺序

LLM 生成时按 schema 字段顺序输出(grammar 强制)。 但模型对字段顺序敏感:把"内容核心"放前面会让模型更易生成。

# ❌ 把答案放最后,模型先写一堆 metadata
{
    "metadata": {...},
    "reasoning": "...",
    "answer": "..."
}

# ✓ 答案放前面(reasoning 在 chain-of-thought 上下文里)
{
    "answer": "...",
    "reasoning": "..."
}

4. Prompt 配合 schema

光开 schema 还不够,prompt 里也要提示:

prompt = """
Extract person info from text. Output JSON with:
- name (string)
- age (integer 0-150)
- email (string in valid email format)

Text: ...

Output:
"""

效果:

  • 模型 logit 倾向于"接下来该写 JSON 了"。
  • Grammar 在背后强制,但模型自己更易吐对。

避免:

  • 让 schema 和 prompt 字段不一致(模型困惑)。
  • 让 schema 太严格但 prompt 没说明(生成内容质量低)。

5. 失败兜底

理论上 grammar 100% 保证语法正确。但:

  • 模型可能在 schema 之外卡死(一直生成空格直到 max_tokens)。
  • 字段语义错(schema 允许的"正确"JSON 但内容胡说)。

兜底:

import json
from jsonschema import validate, ValidationError

try:
    output = response_text
    parsed = json.loads(output)
    validate(instance=parsed, schema=schema)
    # 二次业务校验
    if not is_business_valid(parsed):
        raise ValueError("business check failed")
except (json.JSONDecodeError, ValidationError, ValueError):
    # fallback: 重试 / 用默认值 / 告诉用户
    ...

业务 SLA 高时配 retry 1-2 次。


6. 流式 + schema

stream = client.chat.completions.create(
    ..., response_format={"type": "json_schema", "json_schema": {...}},
    stream=True
)

buf = ""
for chunk in stream:
    delta = chunk.choices[0].delta.content or ""
    buf += delta
    # 增量 partial parse
    try:
        parsed = partial_json.loads(buf)
        # 用 partial parsed UI 更新(如已知 name 字段就先展示)
    except:
        pass

partial-json 这类库做容错 parse。


7. 性能微调

7.1 关 jump-ahead 看差异

sgl.gen(name, json_schema=..., disable_jump_forward=True)

对比开关下的 TPOT。差距大 → jump-ahead 有用,schema 里多字面常量。 差距小 → schema 主要是自由字段。

7.2 提前 warmup

# 上线脚本
for schema in BUSINESS_SCHEMAS:
    requests.post(URL, json={
        "model": ..., "messages": [...],
        "response_format": {"type": "json_schema", "json_schema": {"schema": schema}},
        "max_tokens": 1
    })

把所有热 schema 编译进 cache。

7.3 监控 grammar cache

curl /metrics | grep grammar

grammar_cache_hit_rate 应该 > 0.95。 不到 → schema 字符串变化太多。


8. Backend 选型再次回顾

Schema 特征 推荐
简单 JSON,3-10 字段 outlines
复杂深嵌套 xgrammar
Discriminated union xgrammar
大量 const literal outlines (jump-ahead 强)
CFG(不只 JSON) llguidance
Reasoning 模型 reasoner_grammar

不确定就跑 benchmark:

for backend in outlines xgrammar llguidance; do
    SGLANG_GRAMMAR_BACKEND=$backend python -m sglang.bench_serving \
        --dataset-name custom \
        --custom-output-format json_schema \
        ...
done

9. 小结

  • 复杂 schema 编译有开销,要 cache + warmup。
  • $ref / const / discriminated union 是高效写法。
  • if/then 慎用,改 oneOf。
  • 字段顺序 + prompt 配合,让模型主动配合。
  • 业务侧做 schema 校验 + 业务校验 + 失败兜底。
  • 流式 + partial parse 让 UI 更早更新。

10. 自检

  1. oneOf 慢的根本原因?怎么改成 discriminated union?
答案 `oneOf` 编译为"并行候选 FSM":N 个子 schema 同时存活,每步都要算所有分支的合法 token 并集,状态空间组合爆炸。 Discriminated union 在首字段加 `{"const": "type_value"}` 让 FSM 一开始就能根据这个字段 jump 到具体分支: ```json { "oneOf": [ {"properties": {"kind": {"const": "user"}, "name": {"type": "string"}}, "required": ["kind"]}, {"properties": {"kind": {"const": "admin"}, "perms": {"type": "array"}}, "required": ["kind"]} ] } ``` 编译时 FSM 在看到 `"kind":"u` 就 jump 到第一个分支,看到 `"kind":"a"` 就 jump 到第二个,**没有并行候选**。 速度:discriminated union 与单分支 schema 几乎同速;纯 oneOf 慢 N×。
  1. Schema 字符串变化导致 cache miss 怎么办?
答案 GrammarManager 按 schema 字符串做 cache key,字段顺序 / 空格 / 数值 0 vs 0.0 都会 miss。 解决: (1) **`json.dumps(schema, sort_keys=True)`**:字段按字母序,固定输出。 (2) **业务侧常量化**:把 schema 字符串作为 Python 模块常量定义一次,不要每请求 `json.dumps`。 (3) **Warmup**:业务上线前批量发请求覆盖所有热 schema,让 cache 沉淀。 (4) **监控**:`grammar_cache_miss_rate` 应该 < 5%;高于此值就是字符串不规范。 (5) **避免动态拼接**:不要在 schema 里嵌入 `f"{user_input}"`——每用户独立 schema 就是 cache 灾难。
  1. 字段顺序对模型质量的影响?
答案 Grammar 强制按 schema 顺序输出,但模型质量**对字段顺序敏感**。 原因:模型生成是顺序的,前面已生成内容是后面生成的 condition。 - ❌ `{"metadata": {...}, "reasoning": "...", "answer": "..."}`:模型先编 metadata,再"找理由",再凑答案;答案被"为前文圆话"影响。 - ✅ `{"answer": "...", "reasoning": "..."}` 或 chain-of-thought 模式 `{"reasoning": "...", "answer": "..."}`:先 reasoning 再 answer,逻辑更通顺。 - 数字类字段尽量靠前(让模型"想清楚再说话")。 实战:业务上线前对比"字段顺序 A" vs "字段顺序 B"的业务 eval,挑高分的。同 schema 不同顺序质量能差 10%+。
  1. Grammar 强制语法正确,为什么还要业务校验?
答案 Grammar 保证:JSON **结构合法**(括号配对、类型正确、required 字段都有)。 Grammar 不保证:**业务语义正确**。 例:schema `{"age": {"type": "integer"}}`,模型可以输出 `"age": 9999` 完全合法但显然错。 `{"email": {"type": "string"}}`,模型可以输出 `"email": "not-an-email"`。 `{"date": {"type": "string", "pattern": "..."}}`,模型可以输出语法正确但不可能存在的日期 `"2026-02-30"`。 业务校验补充: - 用 jsonschema 做严格 type / range / pattern 验证(比 grammar 严)。 - 业务字段范围(age ∈ [0, 150])。 - 跨字段一致性(start_date < end_date)。 - 外部 fact-check(email 可触达?地址存在?)。 规则:grammar 是协议层、业务校验是语义层,缺一不可。
  1. Reasoning 模型用 reasoner_grammar 的原因?
答案 Reasoning 模型(DeepSeek-R1 / QwQ / OpenAI o1 类)在生成过程中先吐 `...` chain-of-thought,再吐最终答案。 普通 grammar:会强制 thinking 段也符合 schema,破坏 reasoning 能力(模型不能自由思考,被迫每 token 都符合 JSON 格式)。 reasoner_grammar ([`srt/constrained/reasoner_grammar_backend.py`](../sglang/python/sglang/srt/constrained/reasoner_grammar_backend.py)) 的处理: (a) **检测 think 边界**:状态机识别 `` 开始,期间放开 grammar(模型自由 think)。 (b) **回到 grammar**:检测 `` 后切回受约束模式。 (c) **避免 think 串到答案**:thinking 期间生成的"答案 token"不被算到最终输出里。 不用 reasoner_grammar 时,reasoning 模型质量会严重下降——失去 think 能力。

11. 下一步

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