SGLang 结构化输出:约束解码怎么保证 JSON 一定合法
线上跑着一个抽取服务,模型吐 JSON,下游程序 json.loads 之后入库。绝大多数时候没事,但每天总有那么几十条会挂:多了一句「好的,以下是您需要的 JSON:」、少了一个右花括号、把数字写成了 "population": "约两百万"。于是有人开始在 prompt 里加感叹号——「只输出 JSON!不要任何解释!」——加完之后失败率从百分之几降到千分之几,然后就再也降不下去了。
这条路是走不通的,因为方向本身错了。提示词只能影响概率分布,改不了「模型下一步理论上可以吐出任何 token」这个事实。只要采样还是自由的,不合法的输出就永远有非零概率。要想让它从「大概率合法」变成「一定合法」,得换一个层面下手:在采样这一步就把不合法的 token 从候选里删掉。这就是约束解码,SGLang 把它做成了一等公民。下面全部按 SGLang 官方文档的口径讲,参数名和请求示例都照文档原文抄。
一、约束解码和「求它输出 JSON」的本质区别
先看文档自己怎么说的。结构化输出那页开头一句话就把边界划清了:你可以指定一个 JSON schema、一个正则表达式或者一份 EBNF 语法来约束模型输出,而模型的输出将被保证遵循给定的约束。注意这个「保证」(guaranteed)不是营销口径,它是机制带来的必然结果。
机制本身不复杂。模型每一步前向算出来的是整个词表上的一个分布,正常采样就是在这个分布上按温度、top-p 之类的规则挑一个。约束解码在挑之前插了一道:根据当前已经生成的内容,推断出语法状态机现在处在哪个状态,算出这个状态下哪些 token 是合法的下一步,把其余全部屏蔽掉(置成负无穷),再去采样。举个最直观的例子:已经吐出了 {"name": "Paris", "population": ,那么按 schema 里 population 是 integer 的声明,此刻合法的下一步只有空格和数字字符," 这个 token 在这一步根本不在候选里,模型想把整数写成字符串也写不出来。到了该闭合的位置,} 之外的东西被屏蔽,那句「以下是您需要的 JSON」连出场机会都没有。
所以这里的「保证」是语法层面的保证,说得更准一点:保证输出能被解析,不保证内容是对的。这个区别后面第七节还要专门说,因为它是最容易被高估的地方。
有一条文档写得很明确、但很多人第一次看会觉得多余的建议:为了更好的输出质量,仍然建议在 prompt 里显式写清你要的格式,比如「请按以下 JSON 格式输出:……」。既然语法已经被锁死了,为什么还要在提示词里啰嗦一遍?因为约束只管住了「能写什么」,管不住「该写什么」。如果模型完全不知道自己在填一个表单,它会在合法的形状里填进去毫不相关的内容——形状对了,语义歪了。约束和提示词是两层,不是替代关系。
还有一条硬约束要记住:一个请求里只能指定一个约束参数,json_schema、regex、ebnf 三者互斥。想又要 schema 又要正则,只能想办法把正则表达成 schema 里字段的 pattern。
如果你还没把 SGLang 这个引擎本身搞清楚,建议先看 SGLang 是什么那篇过一遍它的定位和核心机制,再回来看这一层。
二、三种约束形式,加一个 structural tag
文档支持的约束形式,按能力从窄到宽排:
正则表达式。参数是 regex。适合输出空间本来就很窄的场景——分类标签、枚举值、固定格式的编号。文档给的示例直白到有点搞笑:约束成 "(Paris|London)",模型只能在这两个词里选一个。别小看这种用法,分类任务用它比让模型输出 JSON 再解析字段要稳得多,也省 token。
JSON Schema。参数是 json_schema。这是最常用的一种,两种写法都在文档里:直接手写 schema 的 JSON,或者用 Pydantic 定义模型再调 model_json_schema() 转出来。后者的好处是同一份定义既用来约束生成、又用来在拿到结果后做校验,两边不会走偏。文档示例里的 Pydantic 类是这样的:
from pydantic import BaseModel, Field
class CapitalInfo(BaseModel):
name: str = Field(..., pattern=r"^\w+$", description="Name of the capital city")
population: int = Field(..., description="Population of the capital city")
注意 Field 上那个 pattern,它会随 schema 一起被翻译进语法里,也就是说字段级的正则约束是能生效的——这是绕过「只能给一个约束参数」那条限制的正经办法。
EBNF。参数是 ebnf。当你要的东西根本不是 JSON,而是某种自定义的文本格式、DSL、或者一套有嵌套结构的表达式时,才轮到它。文档的示例语法是这样的:
root ::= city | description
city ::= "London" | "Paris" | "Berlin" | "Rome"
description ::= city " is " status
status ::= "the capital of " country
country ::= "England" | "France" | "Germany" | "Italy"
这段东西值得多看两眼,因为它演示了 EBNF 真正的用处:它把输出锁进了一个有限的句式集合。模型不是在写自由文本,而是在一棵语法树上走路径。做协议生成、做固定句式的报告、做 SQL 片段这类活,这个表达力是 JSON Schema 给不了的。
Structural Tag。这个不是第四种约束语法,而是一种混合模式,专门解决「输出不全是结构化的」这类需求——大部分是自由文本,但其中某些片段必须是严格的 JSON。最典型的就是工具调用:模型先自然地说几句,然后吐出一个 <function=get_current_weather>{...}</function>,那段花括号里的东西必须严格符合函数参数的 schema,外面的文本不该被管。第五节单独讲它。
三、三个语法后端,按你要用的约束形式来选
约束解码的实际执行者是语法后端。SGLang 文档列了三个,各自的支持面不一样,这是选型时唯一真正要看的东西:
| 后端 | 支持的约束形式 | 文档态度 |
|---|---|---|
| XGrammar | JSON schema、正则、EBNF | 文档当前的默认后端,也是官方建议优先用的 |
| Outlines | JSON schema、正则 | 不支持 EBNF |
| Llguidance | JSON schema、正则、EBNF | 三种都支持 |
切换方式是启动参数 --grammar-backend,文档给的可选值是 xgrammar、outlines、llguidance、none。不指定就走 XGrammar。文档还提了一句实现细节:XGrammar 当前用的是 GGML BNF 格式,写 EBNF 的时候要注意这个方言。
那个 none 值容易被忽略,但它在排错时很有用——如果你怀疑某个诡异现象(吞吐掉下来、输出被截断在奇怪的位置)是约束解码引起的,把后端关掉跑一轮对照,比猜十次都快。
空白字符这一对参数是最实的坑,也是选后端的第二个理由。 JSON 里的缩进和换行都是合法的语法空白,模型每吐一个空格都要占一个解码步。文档给了两个方向相反的旋钮:
--constrained-json-whitespace-pattern:只对 Outlines 和 Llguidance 后端有效,用一个正则指定 JSON 约束输出里允许出现哪些语法空白。文档举的例子是想让模型能生成连续空白时,把 pattern 设成[\n\t ]*。--constrained-json-disable-any-whitespace:只对 XGrammar 和 Llguidance 后端有效,作用是在 JSON 约束输出里强制紧凑表示。
方向很清楚:给人看的场合(日志、调试面板)可以放开空白让它排版好看;给程序吃的场合,该把空白关掉,生成的每一步都用来产出真正有信息量的内容。这两个参数只有 Llguidance 两边都吃得下,另外两个后端各占一半——如果你两件事都要在同一套服务里做,这就是个实在的选型输入。
四、请求怎么写:三条路径,传参形状不一样
同一个功能在 SGLang 的三套接口上都有,但传参的形状不同,这一点没人提醒过就一定会踩。
OpenAI 兼容端点。 JSON schema 走标准的 response_format,照文档原文:
response = client.chat.completions.create(
model="meta-llama/Meta-Llama-3.1-8B-Instruct",
messages=[
{
"role": "user",
"content": "Please generate the information of the capital of France in the JSON format.",
},
],
temperature=0,
max_tokens=128,
response_format={
"type": "json_schema",
"json_schema": {
"name": "foo",
# convert the pydantic model to json schema
"schema": CapitalInfo.model_json_schema(),
},
},
)
正则和 EBNF 不在 OpenAI 的标准字段里,所以走 extra_body 这个逃生口:
response = client.chat.completions.create(
model="meta-llama/Meta-Llama-3.1-8B-Instruct",
messages=[
{"role": "user", "content": "What is the capital of France?"},
],
temperature=0,
max_tokens=128,
extra_body={"regex": "(Paris|London)"},
)
原生 /generate 端点。 约束参数挪进了 sampling_params,和 temperature、max_new_tokens 并列:
response = requests.post(
f"http://localhost:{port}/generate",
json={
"text": text,
"sampling_params": {
"temperature": 0,
"max_new_tokens": 64,
"json_schema": json.dumps(CapitalInfo.model_json_schema()),
},
},
)
对比一下就能看出那个陷阱:OpenAI 路径的 schema 传的是 dict(示例里另一种写法明确是 json.loads(json_schema)),而原生路径的 json_schema 传的是 字符串(json.dumps(...))。同一个 schema,走哪条路决定了你该不该序列化。structural_tag 在原生路径上同样是 json.dumps 出来的字符串,而在 OpenAI 路径上是直接嵌在 response_format 里的对象。这类事情读文档示例时一眼就过去了,等到运行时报一个语义不明的解析错误才回头查,就得花掉一整个下午。
还有一点,走原生端点意味着你要自己套对话模板——文档示例里都是先 tokenizer.apply_chat_template(...) 拿到 text 再发出去的。OpenAI 兼容端点这一步是服务端做的。多这一步的代价是你得保证模板和服务端加载的一致,好处是对输入有完全控制权。端点本身的差异和调用方式,SGLang 部署 OpenAI 兼容服务那篇讲得更全,包括并发与显存那几个参数。
离线 Engine。 不起 HTTP 服务、直接在进程里跑批的场景,后端在构造时指定:
import sglang as sgl
llm = sgl.Engine(
model_path="meta-llama/Meta-Llama-3.1-8B-Instruct", grammar_backend="xgrammar"
)
之后 sampling_params 的写法和原生端点一致。批量刷数据做结构化抽取,这条路比自己包一层 HTTP 要省事,而且一批 prompt 共用同一个 schema 时语法状态机只需编译一次。
顺便说一句,文档里所有 JSON 约束的示例都带着 temperature=0。这不是巧合:抽取类任务要的是确定性,约束解码管住了形状,温度管住的是同一份输入会不会得到两个不同答案,两件事要一起设。
五、Structural Tag:工具调用这一段的正确做法
这是整份文档里最容易被跳过、但实战价值最高的一节。
它的配置由三部分组成:triggers 是一组触发串,模型吐出其中任意一个之后,约束才开始生效;每个结构体有 begin、schema、end 三个字段,分别是开始标记、这段内容要满足的 JSON schema、结束标记。文档示例里 triggers 是 ["<function="],两个结构体分别对应 <function=get_current_weather> 和 <function=get_current_date>。
这个设计的巧妙之处在于约束是按需启动的。触发串出现之前,模型完全自由地说话;一旦出现 <function=,语法机就接管,强制它把后面的函数名和参数体写成合法的形状,直到 </function> 闭合,然后又放开。你不需要为了让工具调用可靠,而把整个回复都塞进一个 JSON 壳子里。
文档同时列了 XGrammar 较新的 structural tag 格式,形状不一样:format 里 type 是 triggered_tags,tags 数组里每一项的内容不再是裸 schema,而是包了一层 content,写成 {"type": "json_schema", "json_schema": ...}。这个新格式多出两个开关:at_least_one 和 stop_after_first,示例里都是 False。名字本身说明了它们在管什么——是否必须至少产生一个标签、以及产生第一个之后是否就停。这两个开关对应的正是工具调用里两个真实的分歧:允不允许模型选择「不调用任何工具」,以及允不允许它一次调多个。
文档里那两种格式是并存列出的,新旧之间该怎么迁移、旧格式会不会退役,这页文档没有说明,以官方文档与 XGrammar 侧的说明为准。新写的代码我倾向于直接用带 content 的新格式,它把「这段内容用什么约束」显式化了,扩展性明显更好。
六、推理模型:只说文档里能查到的
带思考段的模型上做结构化输出,麻烦在于输出被分成了两截:思考的部分应该是自由的,最终答案的部分才需要被约束。如果一上来就用 schema 把整个输出锁死,模型连思考都没法思考。
SGLang 有一份专门讲推理模型结构化输出的文档,但我手上这批上游文件里没有它,所以这一节我不去展开那些细节——编一套听起来合理的处理流程出来,比不写它危害更大。这块请以官方文档中「structured outputs for reasoning models」那一页为准。
我能从服务端参数表里核实到的,只有两条相关的:
--reasoning-parser:指定推理模型的解析器,用auto可以从模型的对话模板里自动识别。它负责把思考段和答案段分开,是这类模型能正常工作的前提。--enable-strict-thinking:在思考阶段启用严格 token 过滤,屏蔽模型特定的排除 token(文档举的例子是工具调用标记)。它有一个明确的依赖——需要一个支持 token 过滤的语法后端。
最后这条依赖关系值得留意:它说明思考段的行为控制和语法后端是同一套底层能力。也就是说你选的语法后端,不只影响 JSON 约束能不能用,还可能影响推理模型的某些开关能不能开。上量前把这条串起来验一遍。
七、失败与边界:它保证的不是你以为的那件事
这一节是全文最该被记住的部分。
语法合法不等于内容正确。 约束能保证 population 是一个整数,保证不了这个整数是真的。字段名对、类型对、结构对,值可以是模型凭空造的。约束解码把「解析失败」这类问题彻底消灭了,但把它换成了一类更隐蔽的问题:脏数据会安静地入库。以前一条格式错误的输出会在 json.loads 那里炸出来,你立刻知道;现在它长得漂漂亮亮地通过了所有检查。
所以校验必须留着。注意文档自己的示例是怎么写的——用 Pydantic 那个版本,拿到响应之后还要 CapitalInfo.model_validate_json(response_content) 再验一遍。约束都开着了还验什么?验的是那些 schema 表达不了的东西:数值范围合不合理、枚举值在不在业务白名单里、几个字段之间的关系对不对。这是官方示例里的做法,不是我加的谨慎。
几个具体的边界,按文档口径列一下:
- 一个请求只能带一个约束参数,前面说过,这是硬的。字段级的正则要写进 schema 的
pattern。 - 后端能力不齐。选了 Outlines 就没有 EBNF;空白字符那两个参数各自只覆盖两个后端。换后端不是等价替换,要连着约束形式一起考虑。
- EBNF 的方言问题。XGrammar 用 GGML BNF 格式,同一份语法换后端可能要改。
- 约束和长度上限会互相作用。所有示例都同时给了
max_tokens或max_new_tokens,而 JSON 的闭合是在末尾的——如果 token 预算在闭合之前用光,你拿到的是一个被截断的串。这种情况下「保证合法」并不成立,因为它根本没生成完。schema 字段多、允许长文本字段的时候,长度上限要留够余量。 - 空白字符会消耗解码步。给程序吃的输出,紧凑表示那个开关该开。
开销这件事,不给数字只给判据。 每生成一步都要算一次 token 掩码,这个计算在 CPU 上,GPU 的前向在另一边,能不能重叠、能不能把连续确定的多个 token 一次跳过,就是各家实现拉开差距的地方。你自己那个 schema 的开销有多大,取决于它的复杂度、嵌套深度、有没有大段自由文本字段——这些变量差一个数量级,结论就完全不同,只能拿你自己的 schema 去量。两家引擎的宣传数字都替你回答不了这个问题,SGLang 与 vLLM 怎么选那篇里我也是同样的态度。真要排查约束解码是不是你的瓶颈,参照吞吐优化的那套方法先定位瓶颈在预填充侧还是解码侧,再动手。
八、上约束解码之前,先确认这是不是你的问题
先说什么时候值得上。下游是程序而不是人,解析失败会直接造成故障;输出结构相对固定,schema 写出来不会天天变;失败的代价高,要么是入库脏数据,要么是链路后半段全挂。工具调用和 Agent 的结构化决策是典型场景——这两类地方解析一次失败往往意味着整条任务链断掉,重试的成本比约束解码的开销高得多。
再说什么时候用后置校验更划算。输出里自由文本占大头,只有零星几个字段需要结构,那不如让模型正常写,拿到之后用正则抽字段、抽不到就重试一次。结构还在频繁变动的原型期,schema 天天改,维护它的人力成本比失败率省下来的那点更贵。失败可以廉价重试的场景——离线批处理里解析失败的那条重跑一次就是了,没人等着——多花一层解码开销去换一个用不上的保证,不值。还有一种情况:你已经在用一个自己维护得很熟的解析兜底逻辑,它处理各种畸形输出的经验是这些年攒下来的,强行换成约束解码等于把这部分经验扔掉重建。
最后一句得放在这儿。约束解码解决的是格式问题,它一分都解决不了内容质量问题。如果你真正的痛点是模型抽错了字段、把甲方的地址填进了乙方的位置,那锁死格式只会让错误变得更难被发现——原来会报错的地方现在静静通过了。这种情况下该动的是提示词、是示例、是模型选择,甚至是把一个大 schema 拆成几次小抽取,不是在采样层加一道锁。先搞清楚自己每天挂掉的那几十条到底是解析失败还是内容错误,这个前提判断错了,后面所有工作都是在错误的方向上使劲。
算完账发现自建推理不划算?
先用托管端点把业务跑起来,量上来了再回头算自建的平衡点。