大模型结构化输出约束与解码校验落地

日期:2026-08-09

一、项目背景

客户是一家集团型企业,在私有化底座上跑了十几个业务智能体,做的事情五花八门:把客户来函解析成工单、把会议纪要抽成待办、把采购申请转成结构化单据。这些场景有个共同特征,模型的输出不是给人看的,是直接喂给下游系统的,所以必须是严格的 JSON。

上线之后一直有个甩不掉的毛病。模型大部分时候输出规范,但总有那么百分之二十几的情况会跑偏。表现形式很多样:多一个尾逗号、少一个右括号、在 JSON 前面加一句"好的,以下是解析结果"、把数字写成带单位的字符串、枚举字段填了一个没定义过的值、嵌套层级少了一层。

下游系统对这些容错很差。有一次采购单据解析的时候,模型把金额字段输出成了"人民币 128000 元"而不是数字 128000,下游的解析直接抛异常,那批单据卡了两个小时才被发现。运维那边统计过,因为格式问题产生的告警占智能体相关告警的六成以上。

各个业务团队各自想了些土办法,有的在提示词里反复强调"只输出 JSON 不要任何解释",有的写正则去剥离前后的废话,有的失败了就重试三次。这些办法能缓解,但都不彻底,而且每个团队重复造一遍轮子。平台团队决定把这件事收到底座层统一解决。

二、落地场景

我们在底座的模型服务化层加了一个结构化输出的能力,业务侧通过接口传入 Schema,底座保证返回的内容符合 Schema,不符合就在底座内部完成重试或降级,不把脏数据抛给业务。

Schema 约束解码是主路径。业务定义 JSON Schema,底座把 Schema 编译成解码期的状态约束,在每一步 token 生成时限制候选集合,让模型在语法上无法产出非法结构。这条路径下,括号匹配、引号闭合、字段名拼写这类问题从源头上就不会发生。

字段类型与枚举校验是第二道。约束解码能保证语法合法,但保证不了语义合理。比如某个字段定义是整数,模型可能生成 0,语法没问题但业务上不合理;枚举字段虽然被约束在候选集内,但模型可能选错。这一层做的是生成完成后的规则校验和业务规则校验。

失败重试与降级处理兜底。校验不通过的走重试,重试时把失败原因作为反馈拼进提示词。重试仍然失败的走降级,降级策略由业务配置,可以是返回部分字段、可以是转人工、可以是用规则引擎兜底。

流式场景下的增量校验是单独一套。有些交互场景要求流式返回,但流式输出的中间态天然是不完整的 JSON,不能等到结束再校验。我们做了增量解析,边收边判断当前前缀是否还有可能构成合法的目标结构。

三、关键技术挑战与解决思路

约束解码的性能损耗是最先撞上的问题。原理上,每生成一个 token 都要根据当前解析状态计算合法的 token 集合,然后对 logits 做掩码。朴素实现下,这个计算在每步都要遍历词表,词表十几万,开销很可观。第一版实现测下来,吞吐掉了百分之三十八,延迟涨了将近一倍,业务方一看这个数字直接说不能用。

优化做了几层。最有效的是把 Schema 预编译成有限状态机,并且把每个状态对应的合法 token 掩码提前算好缓存起来。同一个 Schema 反复调用时,掩码是可以复用的,不需要每次重算。其次是把掩码计算从 Python 侧挪到了推理框架内部,避免每步跨进程通信。再就是识别出"自由文本字段"这种状态,在这类状态下几乎所有 token 都合法,直接跳过掩码计算。三项加起来,吞吐损耗压到百分之六点四,延迟增量控制在百分之九以内,业务方接受了。

枚举与嵌套结构是另一类麻烦。枚举值如果很长或者包含中文,会被切成多个 token,约束解码需要跨多步维持状态,实现上容易出错。我们在这块踩过一个坑:某个枚举值是"待客户确认",另一个是"待客户补充材料",两者前缀相同,状态机在前缀分叉点上的处理有 bug,导致偶发生成出"待客户确认材料"这种混合值。这个问题在测试环境没复现出来,是上线后被业务方报上来的,查了一天半才定位到。修复后我们补了一批前缀重叠的枚举用例进回归集。

深层嵌套的性能也需要注意。有个业务的 Schema 嵌套了六层,数组套对象套数组,状态机节点数膨胀得厉害。我们加了个约束,Schema 嵌套超过四层的会在编译期给出警告,建议业务方拆分。这不是技术上做不了,是从可维护性角度做的限制,实际上没有哪个下游系统真的需要六层嵌套,那个 Schema 是业务方图省事一把梭出来的。

流式增量校验的思路是"前缀可行性判断"。收到一段增量 token 之后,判断当前累积的字符串是否可能是某个合法 JSON 的前缀。这个判断本身不难,难的是判断失败之后怎么办。已经吐给前端的内容收不回来。我们的处理是分两种:如果业务允许,就在检测到不可行时立即中断并重新生成,前端展示一个"重新组织中"的状态;如果业务不允许中断,就退回非流式模式。大部分业务选了第一种,实测中断率在百分之一点二左右,用户基本感知不到。

失败兜底这块,我们一开始设计得比较激进,重试三次全失败就抛错。运行一段时间后发现,有些场景下模型确实解析不出完整结构,但部分字段是对的,全部丢弃太浪费。改成了支持"部分成功",Schema 里可以标注哪些字段是必需的,只要必需字段齐全就算成功,可选字段缺失只记录不阻断。这个改动之后,端到端的成功率提升了四个百分点。

案例片段(已脱敏):一个采购单据解析的 Schema 与约束配置。

json {  "$schema": "http://json-schema.org/draft-07/schema#",  "type": "object",  "required": ["supplier", "items", "total_amount"],  "properties": {    "supplier":     {"type": "string", "maxLength": 64},    "apply_dept":   {"type": "string"},    "urgency":      {"type": "string", "enum": ["普通","加急","特急"]},    "total_amount": {"type": "number", "minimum": 0},    "items": {      "type": "array", "minItems": 1, "maxItems": 200,      "items": {        "type": "object",        "required": ["name", "qty", "unit_price"],        "properties": {          "name":       {"type": "string"},          "spec":       {"type": "string"},          "qty":        {"type": "integer", "minimum": 1},          "unit_price": {"type": "number", "minimum": 0}        }      }    }  },  "x-runtime": {    "decode_mode": "constrained_fsm",    "mask_cache":  true,    "retry": {"max": 2, "feedback_on_fail": true},    "partial_ok":  true,    "fallback":    "RULE_ENGINE"  } }

案例片段(已脱敏):约束解码上线前后的一组压测对比。

``` model=内部14B  concurrency=32  prompt_len≈1.2k  output_len≈380

                无约束     朴素约束    优化后约束

throughput(tok/s)    1842       1142        1724 P50 latency(ms)       910       1680         986 P95 latency(ms)      1630       3210        1778 json_valid_rate      76.3%      100%        100% schema_pass_rate     68.1%       94.7%       96.2%

备注: schema_pass_rate 含语义校验(枚举取值、数值范围、必填项)      优化项 = FSM预编译 + 掩码缓存 + 自由文本态跳过 ```

四、效果数据

能力上线并被十一个业务智能体接入之后,跑了三个多月,数据如下。

格式合规率从百分之七十六点三提到百分之百。这个是必然的,约束解码在语法层面不给非法输出留空间。真正有意义的是下一个指标。

Schema 完整通过率,也就是语法加语义都符合定义的比例,从百分之六十八点一提到百分之九十六点二。剩下的百分之三点八主要是模型理解偏差,比如把某个字段的内容填进了另一个字段,这类问题约束解码解决不了,得靠提示词和微调。

解析失败率,从下游系统角度看,从百分之二十三点七降到百分之零点四。这个降幅比 Schema 通过率的提升更大,是因为加了部分成功和降级兜底,即使模型输出不完美,下游也能拿到可用结果。

延迟增量控制在百分之九以内,吞吐损耗百分之六点四。业务方最初担心的性能问题,实际影响比预期小。

平均重试次数从原来各业务自己实现的一点八次降到零点零七次。重试少了直接省算力,粗算下来这块每月省下的 GPU 时间大概相当于两张卡。

因格式问题产生的运维告警,从月均四百七十条降到十九条。运维团队的反馈是终于不用半夜起来处理"又一个 JSON 解析失败"了。

流式场景的中断率百分之一点二,用户侧未收到相关投诉。

文中性能与业务数据为脱敏后的复盘口径。

五、可复用经验总结

结构化输出这件事,靠提示词是靠不住的。我们接手前,各业务在提示词里加了各种强调,"必须""只能""不要解释",甚至用大写字母和感叹号,效果都是边际递减的。约束解码是从机制上解决问题,跟提示词是两个层面的东西。有条件的话应该往底座沉,不要让每个业务自己搞。

性能损耗是可以优化下来的,不要因为第一版慢就放弃这条路。我们第一版掉了百分之三十八的吞吐,当时业务方的态度很消极,差点这个项目就黄了。后来那三项优化其实都不复杂,预编译和缓存是很常规的手段,只是需要有人静下心来做。

约束解码保证语法不保证语义,这个边界要跟业务讲清楚。有业务方以为接了这个能力之后模型就不会填错内容了,期望管理没做好,上线后有过一次不愉快的沟通。后来我们在接入文档里用加粗写了这一条。

前缀重叠的枚举值是个隐蔽的坑。测试用例里一定要覆盖这种情况,我们那次线上问题就是因为回归集里全是前缀互不相同的枚举。现在我们的回归集里专门有一组"恶意"枚举,包含公共前缀、包含彼此、含特殊字符、超长中文。

部分成功这个设计值得推广。二元的成功失败判定在实际业务里太粗糙,允许必需字段和可选字段区别对待,能捞回不少本来要丢弃的结果。设计的时候要注意让业务方明确标注哪些是必需的,不要默认全部必需。

这块能力现在还在补一个东西,就是把校验失败的样本自动收集起来做微调数据。目前收集是有了,微调闭环还没跑通,主要卡在样本标注的人力上。