首页 / 资讯中心 / 文章详情

SGLang结构化输出揭秘:约束解码从JSON Schema到Token掩码的实战

SGLang结构化输出揭秘:约束解码从JSON Schema到Token掩码的实战 ★ FEATURED ARTICLE
先说我最近一个真实感受让大模型输出 JSON从来都不是靠一句“请你以 JSON 格式返回”就能搞定的。最初我在做信息抽取类的模拟项目时一直是“自由生成 解析失败再重试”的路子结果是解析通过率大概只有六成重试一次延迟翻倍token 也白烧一大半。后来被同事按着头去研究约束解码才认真把 SGLang 的 Structured Output 机制啃了一遍——这才发现很多人只把它当推理加速框架用其实它真正值钱的地方是那个“生成时就约束你只能吐合法结果”的解码引擎。这篇文章我会从约束解码的底层原理讲起拆一下 SGLang 把正则、JSON Schema 变成 token 级掩码的整个链路再把那些容易踩的坑、以及一个可以直接抄的信息抽取示例一起给你。如果你在搞工具调用、数据入库、批量结构化抽取或者只是想搞清楚“结构化输出到底是怎么实现的”这篇应该能帮你省不少时间。1. 先破个误区SGLang不只是一个推理加速框架真正的底牌是约束解码1.1 我为什么从“事后修复”转向“生成前约束”先描述一下很多人都在经历的痛让模型生成结构化内容最常见的做法是——先自由生成然后丢给 JSON 解析器失败了就改 prompt 再重试一次。代码层面看起来很简单但放到生产环境里就很残酷。我测试过一个很普通的抽取场景系统提示词要求模型“必须输出 JSON不要输出多余内容”模型表面上听话但实际输出经常出现三种问题字段名拼错、数字被写成字符串、嵌套数组残缺。解析器一报错就得重试每次重试不光花时间还要重新算一遍 KV Cache。最离谱的一次一个简单的地址抽取重试了三轮才跑通时间直接翻了四倍。后来我明白了一个道理自由生成是“先输出再检查”模型的采样过程根本不在乎你要不要 JSON它只在乎下一个 token 对自己当前的“对话延续”来说是否自然。事后校验做得再好也只是在筛掉坏结果而不是阻止坏结果产生。1.2 约束解码引擎在 SGLang 中的位置SGLang 的结构化输出不是挂在采样器外面的一个“后处理钩子”而是一个跟采样器、KV Cache 管理深度耦合的模块。通俗讲它做了三件事把你给的约束正则、JSON Schema、EBNF 文法编译成一个解码器能理解的状态机描述在生成每一个 token 之前计算当前状态下“哪些 token 是合法的”并据此屏蔽掉非法 token 的 logits把这种约束能力跟调度器、前缀缓存结合在一起让结构化生成也能享受推理加速。很多人宣传 SGLang 的时候只提“前缀缓存”“调度器”“并发”却忽略了 Structured Output 才是它的核心差异化之一。如果你只拿它做自由聊天文本生成确实也能跑但那等于只用了它一半的功力。1.3 这篇文章会怎么讲先声明一下我不打算复述官方 README而是把约束解码这条线单独拎出来讲清楚——从有限状态机到底层怎么算 token 掩码开始再到我实际踩过的一个正则编译膨胀的坑最后给一个信息抽取的小例子。这样你不仅能跑通还能知道跑通背后的原理和边界。2. 约束解码的核心原理正则/JSON Schema如何变成token级掩码2.1 先理解“状态机”自动售货机的例子很多人一听到有限状态机FSM就头大其实可以用自动售货机来类比机器处于不同状态时你对它投币、按键它会决定是出饮料、找零还是报警。整个过程里“当前状态 输入符号”共同决定了下一步去哪。正则表达式天然就能被编译成状态机。比如[0-9]这个模式意思很明确至少读到一个数字字符之后可以继续读数字一旦读到非数字字符就拒绝。状态机里至少有“起始状态”“已读数字状态”“终止状态”三档。约束解码要做的事情本质上就是在模型生成序列的每一步都把人话翻译成“当前状态 合法输入字符集合”然后把词表里那些“不合法”的 token 全部拉黑。2.2 从语法约束到 token 掩码的完整链路SGLang 的实际处理链路可以拆成四步解析约束你传进来一个正则、JSON Schema 或 EBNF 文法会先被解析成内部表示。JSON Schema 这种带嵌套结构的还需要转成一个等价的文法描述因为普通正则表达不了任意深度的嵌套。构造状态机基于内部表示构造出用于引导生成的约束状态机同时确认哪些状态是“可终止”的。预计算合法 token这一步非常关键。引擎会把词表里所有 token 都拿到当前状态下过一遍看谁能从当前状态走到一个合法状态从而得到一个“合法 token ID 集合”。生成时屏蔽在采样前把所有非法 token 的 logits 设成负无穷模型根本不可能选择它们于是输出天然满足约束。举个例子你定义了一个 JSON Schema规定age字段必须是整数。那当模型生成到age:后方的值时字母开头的 token、标点 token、空格 token 都会被屏蔽只剩数字类和可能的负号 token 可选。模型在采样阶段根本碰不到字母输出自然长成合法的数字。2.3 JSON Schema不是正则但同样能被约束有人会问JSON 是嵌套递归的纯有限状态机怎么可能表达任意深度的{}这确实是把问题想复杂了。实际上JSON Schema 会被先翻译成一种上下文无关文法例如 EBNF然后约束引擎在生成过程中维护一个“解析栈”或“当前产生式位置”而不是一个简单的状态编号。本质上它比“正则状态机”多了一个栈的概念这样才能跟踪数组、对象、嵌套层级。SGLang 的约束引擎之所以叫“引擎”就是因为它不是拿一个正则库去 match 整段文本而是把这个正则/文法当成“边生成边指导采样”的解析器。这个区别很重要传统校验是“生成完之后拿正则去验证”约束解码是“生成过程中拿文法来决定下一步能选谁”。3. 最难啃的硬骨头tokenizer与约束状态机的拉扯含完整排查链路3.1 现象加上了约束integer字段还是翻车了先说结论约束解码不是银弹它只能保证“语法上合法”不能保证“语义上合理”。我最早是在给age字段加type: integer约束时发现异常的。测试样本里出现了一个让我很困惑的情况模型在整数字段一直在吐数字一直吐到max_new_tokens被截断。也就是说约束解码保证了它吐的全是数字字符但没法阻止它“停不下来”。JSON Schema 里的 integer 只限制“这是一个整数”并没规定“这个整数不能有一百位”。模型一旦进入数字 token 的惯性里就可能疯狂输出直到序列被截断最终产生一个看似合法、实际上完全不合语义的长数字。这个现象让我意识到约束引擎的“合法”是形式语言意义上的合法不是业务意义上的合法。后来我加上了minimum、maximum以及自定义正则才彻底解决。3.2 排查链路第一步怀疑 tokenizer打印合法 token 集合继续深挖时我做了个更“底层”的实验。当时换了一个新开源 8B 模型词表跟之前的模型完全不同结果同样的日期正则\d{4}-\d{2}-\d{2}出现了非常明显的“首 token 延迟变高”。我把引擎的 debug 日志打开在生成阶段导出了当前约束状态下被允许的 token ID 集合结果发现两个特征词表里存在大量“多位数字合并成一个 token”的情况比如某个 token 就是123另一个 token 是12同一个四位年份2024可以由2024两个 token 拼成也可以由2024一个 token 直接吐出路径非常多。问题来了路径多意味着状态机的分支多。尤其像\d{4}这种“恰好四位数字”的模式FSM 必须区分当前已经读到了第几位数字而多字符 token 的存在会让“读了几位”这件事变得需要大量中间状态来跟踪。我当时第一反应是“词表太大了”但真正找原因时发现问题不完全在词表而在我写的正则本身。3.3 根因真正所在正则导致约束状态快速膨胀继续排查时我把日期正则改成\d{4}-\d{2}-\d{2}后来又加了(19|20)\d{2}这样的前缀限定。实验结果是约束引擎的初始化耗时和单 token 解码的额外开销都明显上涨。原因梳理下来有三点\d{4}这种“恰好长度”的模式需要状态机区分 1 位、2 位、3 位、4 位数字这是线性增加的状态词表里每个 token 可能是 1 到多个字符的组合状态机需要对“一个 token 内的每个字符”做转移路径数会乘以词表中数字 token 的数量正则里一旦出现多个连续的\d重复块前面说到的乘法效应就会被放大状态数涨得比直觉快很多。后来我做了个很笨但有效的验证把词表里所有纯数字 token 抽出来按长度统计了一下数量再拿同一个正则分别在“完整词表”和“精简数字词表”上做约束初始化前者耗时的增长肉眼可见。修复方案也不复杂。我做了三件事对日期这类强结构字段不再写宽泛的\d正则而是尽量把年份、月份的取值范围写清楚对数值字段把minimum、maximum写进 JSON Schema而不是让引擎自由放飞正则里省的连续重复块尽量拆成多个状态更容易跟踪的写法。改完之后同一个模型不再出现“首 token 延迟暴涨”连续跑了 200 条样本全部通过 schema 校验。3.4 验证修正后的小规模回归验证环节我做得比较朴素把 200 条文本全部跑了一遍记录每条输出是否通过jsonschema库校验、首 token 延迟和总耗时。修复前“恰好长度”正则的那组样本里首 token 延迟高到能被明显感知修复后约束引擎的额外开销基本回到正常水平200 条样本的 schema 通过率是 100%。这里也提醒一下读者约束解码虽然让输出“合法”但它不是零成本的。正则越复杂、词表越大编译和逐 token 掩码计算的耗时就越高。上线前最好先用目标模型和正式约束做一次小样本压测别等上了生产环境才发现首 token 延迟超标。4. 结构化输出与Radix Cache为什么这两件事必须放在一起看4.1 结构化生成场景的“高前缀复用”特性SGLang 的加速体系中最顺手的就是 Radix Cache基数树前缀缓存。它做的事情可以理解成把之前计算过的 KV Cache 按 token 前缀组织成一棵树新请求来了先从树里找最长公共前缀丢掉重复计算的那部分 prefill。有意思的是结构化输出场景天然就让这个机制效果拉满。原因很简单当所有请求都要求输出 JSON 时大概率共享以下几段前缀——同一个系统提示词“请输出 JSON”这类固定指令JSON 模板里固定不变的键名顺序比如{name:、age:Schema 中固定写的枚举项、固定格式说明。自由对话场景里用户输入千奇百怪前缀匹配率可能很低但在结构化抽取场景里请求和请求之间的公共前缀往往长得可怕。我在实际测试里甚至遇到过 80 个并发请求共享同一个约 300 token 的公共前缀的情况这部分如果不缓存每个请求都得重新算一遍注意力纯属浪费。4.2 Radix Cache为什么跟约束解码配合得很好有人可能会担心加了约束解码之后模型在每个 token 的选择范围被限制了那前缀缓存还有用吗答案是当然有用。约束解码虽然限制了“合法 token 集合”但它并没有决定唯一 token。同一个 schema 下模型仍然会基于输入内容采样出不同的字段值。因此前文已经生成的 token 序列依然是“生成”出来的不是固定的模板常量KV 缓存依然有很高的重复利用价值。更微妙的一点是约束解码让字段出现的先后顺序更可预测了。比如 Schema 固定先 name 后 age 后 hobbies那么所有请求都会先在name:之后生成人名再进入age:字段。自由生成时模型可能会调皮地先写 age 再写 name而约束解码逼着它按模板顺序走反而让多个请求之间的前缀更加整齐划一。这对 Radix Cache 来说简直是最友好的场景。4.3 一个手头小实验结构化抽取下的缓存收益我在一个自建的小规模测试集上做过一组对照实验100 条文本同一个 8B 开源指令模型跑的同样的信息抽取任务配置平均单请求延迟一次 schema 通过率无前缀缓存 自由生成约 2.6s约 64%需重试开启前缀缓存 自由生成约 1.9s约 64%需重试开启前缀缓存 约束解码约 1.5s约 100%200 次均通过说明一下这不是严谨基准测试样本小、模型单一数字只能给个直觉。但它反映的趋势是稳定的——约束解码提升了通过率而前缀缓存把重复计算的成本砍掉了一大截两者叠加效果非常明显。当时让我印象最深的是prefix cache 不只是在单请求维度变速而是在高并发下体现得淋漓尽致同样一批并发请求开了缓存之后排队时间明显下降因为每个请求真正需要计算的 prefill 部分变短了。5. 上手实操从安装到编写一个严格的信息抽取小工具5.1 安装和环境检查SGLang 的安装不算复杂但要注意环境里的 PyTorch 和 CUDA 版本别冲突pip install sglang[all]装完之后先跑一个极小的推理验证一下环境是否可用。我个人的习惯是先用Engine直接跑一个文本生成的冒烟测试确认模型能加载、采样不报错再开始写约束相关代码。这里提醒一句SGLang 迭代比较快API 在不同小版本之间可能有差异下面的代码基于我当前在用的稳定版风格具体以最新官方文档为准。5.2 用Engine直接在脚本里跑如果你是做离线批量抽取我建议直接用Engine省去起服务的开销。下面是一个信息抽取函数用json_schema约束输出import sglang as sgl from sglang import Engine, function, gen, system, user, assistant function def info_extract(paragraph: str): system(你是一个信息抽取助手只输出合法 JSON不要输出任何解释。) user( 请从下面的文本中抽取人物信息严格按照给定的 JSON Schema 输出\n paragraph ) assistant( gen( output, json_schema{ type: object, properties: { name: {type: string}, age: {type: integer, minimum: 0, maximum: 200}, hobbies: { type: array, items: {type: string}, }, }, required: [name, age, hobbies], additionalProperties: False, }, ) ) engine Engine(model_path你的8B级别开源模型路径, mem_fraction_static0.6) state info_extract.run( engine, paragraph张三是一名软件工程师今年28岁平时喜欢编程、摄影和爬山。, ) print(state[output].text()) engine.shutdown()要点有两个一是additionalProperties: False一定要写否则模型可能自由发挥加字段二是给数值字段加minimum、maximum这正好对应我在第 3 章讲的语义约束问题避免出现“合法但离谱”的数字。5.3 用Server模式集成到HTTP服务如果想把约束能力暴露成 API可以用服务模式启动python -m sglang.launch_server --model-path 你的模型路径 --port 30000然后在业务代码里用 HTTP 调用。服务端提供的是主流兼容接口里面可以直接带约束信息import requests resp requests.post( http://localhost:30000/v1/chat/completions, json{ model: your-model, messages: [ {role: system, content: 你是一个信息抽取助手只输出合法 JSON。}, {role: user, content: 请从下面内容中抽取实体并输出 JSON\n...}, ], temperature: 0.0, max_tokens: 512, }, timeout60, )具体请求体参数在不同版本会有一点变化建议拿自己环境的 API 文档试一下。但思路是不变的约束是服务端帮你保证的客户端只需要把 schema 或正则丢过去。5.4 我在实测中遇到的三个隐蔽问题第一个是数组元素之间的分隔符。schema 里如果有数组数组里的每个对象之间该不该有逗号、换行有时候约束引擎能处理但 prompt 里如果给了太多“可以换行”的自由模型可能产出很长但完全合法的空字段对象白白浪费 token。第二个问题是max_new_tokens太短导致截断。约束解码只能保证每个 token 都是“合法前缀”的一部分但不能保证最终截断点正好在完整 JSON 的结尾。我踩过一次schema 完全没问题但max_tokens128太短输出被切在了一个数组中间。从“前缀合法性”角度它没错但从“完整 JSON”角度它就是个残缺结果。解决办法是把 max tokens 放宽或者后端做一次最终校验。第三个问题是字符串字段里的转义字符。JSON Schema 只约束“这是个字符串”但模型很可能在一个 string 字段里输出换行符、单引号、未转义的双引号。这不会违反“字符串”这个类型约束但在 JSON 解析层面会让结果仍然是合法 JSON因为引擎会自动做转义。如果你下游要拿原始文本而不是解析后的值这个细节会让你很迷惑。我的处理是在抽取字段的 schema 里加pattern限制字符类别或者在后处理时统一 strip 换行。6. 生产选型硬约束解码、软约束提示词、事后修复怎么选6.1 三条路线的本质差别把市面上常见的做法归成三类能帮你快速判断该用哪种路线核心机制通过率额外成本典型场景自由生成 事后修复先生成再解析重试中等不稳定token 浪费严重延迟高一次性的小批量探索软约束prompt 强引导让模型“尽量”按格式来较高但不保证prompt 长度增加结果不可判定给人类阅读的展示文本硬约束解码采样时屏蔽非法 token高由约束引擎保证合法性状态编译和掩码计算开销工具调用、数据入库、程序自动消费我个人的体会是如果下游是程序自动解析、入库、或者作为工具参数传入函数那就不要犹豫直接上硬约束。如果只是生成一段供人阅读的要点列表软约束完全够用没必要为了“结构化”而牺牲生成自由度。6.2 什么时候不必用硬约束解码有一种情况我不会推荐硬约束解码输出本身很开放或者约束会导致模型“憋”出一个形式上合法但内容退化的结果。比如让模型写一篇商品评论摘要你用正则约束它必须输出{summary: ...}这时候模型往往为了满足“首字段必须是 summary”从第一个 token 起就被锁死在一个固定模板里生成内容变得机械。还有一种情况是 schema 极度复杂、深层递归嵌套。约束引擎能处理但状态维护和掩码计算的开销会变大。我的建议是能简化 schema 就先简化把复杂的嵌套压成扁平结构如果业务确实需要再上硬约束解码。6.3 选型时我的三个判断标准第一个判断标准是“错误成本”。错误结果进入程序后会不会导致下游故障会就必须硬约束。第二个标准是“频率和延迟敏感度”。高频调用且延迟敏感优先考虑约束解码加前缀缓存的组合而不是反复重试。重试的代价是线性增加的硬约束的代价大多是线性的、一次性的。第三个标准是“schema 的稳定性”。如果字段结构天天变约束编译缓存带来的收益会被频繁失效抵消这时候可以考虑把 schema 做一层封装只对稳定字段做强约束其余字段用宽松字符串。最后再分享一个小技巧把固定模板部分的 token 尽量前置。比如 system 里固定放“只输出 JSON”schema 的描述放中间正文内容放最后。这样多个请求的公共前缀尽量长Radix Cache 命中率会高很多。我在前面的小实验里只做了这个顺序调整单请求延迟就肉眼可见地降了一截。结构约束带来的稳定感只有真正在数据抽取和工具调用场景里跑过才会有体会。它解决的也不是“能不能生成合法 JSON”而是“每次都能生成合法 JSON”这两件事在生产环境里差了十万八千里。
阅读完成 · 觉得有帮助?
咨询建站