结构化输出工程:从 JSON Schema 到约束解码的可靠链路
结构化输出工程:从 JSON Schema 到约束解码的可靠链路
一、问题的起点:为什么模型"能说人话"反而是个麻烦
把大模型接进业务流程,第一个撞上的墙往往不是模型不够聪明,而是它输出得太自由。你让它返回一个订单判断结果,它给你一段三百字的分析,最后附一句"综上所述,建议拒绝该订单"。人看着很舒服,程序却完全无从下手。
于是所有人都会走同一条路:先说"请以 JSON 格式输出",然后加一句"不要输出其他内容",然后写正则表达式去抠 JSON,然后在正则失败的地方加 try-except,然后发现自己在一个月里维护了四十行容错代码,仍然每周收到几次解析失败的告警。
这个链条暴露的问题很根本:我们把"格式约束"当成了提示词的礼貌请求,而不是系统的硬性契约。提示词是软约束,模型有权不遵守;而下游的解析器是硬要求,不遵守就崩。两者之间的缝隙,就是线上故障的来源。
这篇文章讲的是怎么把这条链路做硬:从 Schema 设计、约束解码、校验重试,到最终的可观测性。
二、Schema 设计:先想清楚"下游要什么"
很多团队是先写提示词,再回头补 Schema。正确的顺序反过来:Schema 是业务契约,提示词只是生成手段。
一个好的输出 Schema 有三个特征。
第一,字段名表达业务含义,而不是数据结构含义。 status 比 field_1 好,reject_reason 比 extra 好。模型对字段名的语义是有感知的,is_refundable 这种命名比 flag 能让它更准确地填值。
第二,枚举优于自由字符串。 如果业务上只允许三种结论,就不要用 "answer": "string",而要用 enum: ["approve", "reject", "need_review"]。自由字符串会让模型在每次调用时创造新的措辞,下游的匹配逻辑永远追不上。
第三,把"不确定"作为一等公民。 真实的业务系统里,"我不确定"是一个合法且必要的输出。如果 Schema 只允许二选一,模型被迫猜测,错误率必然上升。加一个 confidence 字段或者 need_human 布尔位,能显著降低误判造成的损失。
from enum import Enum
from pydantic import BaseModel, Field
from typing import Optional, Literal
class Decision(str, Enum):
approve = "approve"
reject = "reject"
need_review = "need_review"
class RefundAssessment(BaseModel):
decision: Decision
confidence: float = Field(ge=0.0, le=1.0)
cited_policy: list[str] = Field(default_factory=list,
description="引用的政策条款编号,必须是知识库中真实存在的编号")
reject_reason: Optional[Literal["timeout", "used", "no_receipt", "other"]] = None
need_human: bool = False
```
这段 Schema 里,`cited_policy` 用列表而不是字符串,是为了后续做"引用是否真实存在"的校验;`reject_reason` 用枚举,是为了让下游能直接做统计分布,而不是做文本聚类。
## 三、三层约束:从软到硬
只靠提示词是软约束,加了 Schema 校验是中约束,约束解码才是硬约束。三层应该叠加使用,而不是选一个。
**软约束层**:提示词里明确"只输出 JSON,不要 Markdown 代码块,不要解释"。这一层的作用是降低无效 token,但不能指望它保证格式。
**中约束层**:调用时开启 JSON Mode(多数厂商支持 `response_format={"type": "json_object"}`),并在拿到结果后立刻做 Schema 校验。这一层能覆盖语法错误,但覆盖不了语义违规——比如枚举值编造、必填字段缺失。
**硬约束层**:约束解码(Constrained Decoding / Grammar-based Decoding)。原理是在每个解码步,根据当前已生成的 token 和目标的语法(JSON Schema 转成的上下文无关文法),把不合法的 token 概率置零,只允许合法 token 被采样。
硬约束的价值在于"不可能出错",代价是灵活性。它适合字段固定、枚举封闭的场景;不适合需要模型自由发挥长文本再嵌入结构里的场景(比如"在 summary 字段里写 500 字分析")。后者用 Schema 校验加一次重试更划算。
```python
# 硬约束的典型接入方式(不同推理框架 API 不同,思路一致)
schema = RefundAssessment.model_json_schema()
resp = client.chat.completions.create(
model="your-model",
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {"name": "refund_assessment",
"schema": schema,
"strict": True}
},
temperature=0.1,
)
```
`strict: True` 在支持它的服务端意味着服务端会按 Schema 做解码约束;不支持的场景下它只是个提示。这里必须实测确认,不能看文档写着"支持"就上线。
## 四、校验失败之后:重试要有策略
即使做了三层约束,仍然会有失败:超时、截断(`finish_reason == "length"`)、Schema 演进后旧提示词不匹配、模型把金额填成了字符串。
重试策略的设计要点是**区分失败类型**,因为不同失败类型的正确应对完全不同。
| 失败类型 | 典型信号 | 正确应对 | 错误应对 |
|---|---|---|---|
| 语法错误 | JSON 解析异常 | 把解析错误信息回传,重试一次 | 用正则硬抠 |
| 字段缺失 | 校验报 missing | 指出缺失字段,重试一次 | 用默认值静默填充 |
| 枚举越界 | 值不在 enum 中 | 把合法枚举列出来,重试 | 模糊匹配映射 |
| 截断 | finish_reason=length | 提高 max_tokens 或缩短输出 | 重试(会稳定失败) |
| 语义违规 | 校验通过但业务不合理 | 进入人工复核 | 重试(模型会重复同样错误) |
关键的判断点是:**重试只能解决"模型这次没做好",不能解决"模型不知道怎么做"**。前者重试有效,后者重试只是消耗预算。
```python
def call_with_repair(messages, max_retry=2):
last_error = None
for attempt in range(max_retry + 1):
raw = call_model(messages)
if raw.finish_reason == "length":
raise TruncatedOutput("输出被截断,请检查 max_tokens 或精简 Schema")
try:
return RefundAssessment.model_validate_json(raw.content)
except ValidationError as e:
last_error = e
messages = messages + [
{"role": "assistant", "content": raw.content},
{"role": "user", "content":
f"上面的输出未通过校验,错误如下:\n{e}\n"
f"请只输出修正后的 JSON,不要任何解释。"},
]
raise StructuredOutputFailed(str(last_error))
```
注意最后那个 `raise`:重试两次仍失败,应该明确失败并进入降级路径(人工队列、规则兜底、返回"暂无法处理"),而不是返回一个"差不多能用"的对象。后者是最危险的做法,因为它会把错误数据写进业务库,事后极难追溯。
## 五、把字段顺序当作提示词的一部分
一个容易被忽略的细节:JSON 里字段的生成顺序会影响准确率。模型是自回归生成的,先生成的字段会成为后续字段的上下文。
因此,**把"推理结论"放在"结论标签"之前**通常更好。比如让模型先输出 `evidence`(依据),再输出 `decision`(决定),比反过来更准确——因为它在写 decision 时已经"看到"了自己写的依据。
```python
class RefundAssessment(BaseModel):
# 先证据,后结论
key_facts: list[str]
cited_policy: list[str]
decision: Decision
confidence: float
```
这个技巧在分类任务上收益很明显,而且几乎零成本。唯一需要注意的是:如果下游对字段顺序敏感(比如某些低代码平台按位置解析),要先确认兼容性。
## 六、可观测性:把"解析失败率"当成一级指标
结构化输出的质量必须被度量,否则优化没有方向。建议记录四个数:
1. **一次通过率**:首次调用即通过 Schema 校验的比例。这是最核心的指标,健康系统应在 95% 以上。
2. 2. **修复成功率**:重试后通过的比例。如果这个数很高但一次通过率很低,说明提示词或 Schema 有系统性问题。
3. 3. **截断率**:`finish_reason=length` 的占比。这个数上去通常意味着最近加了字段但没调 `max_tokens`。
4. 4. **语义违规率**:Schema 通过但业务校验不通过(比如引用了不存在的政策编号)的占比。这一项最难自动化,通常靠抽样。
把这四个数按模型版本、提示词版本、业务场景三个维度切开看,故障定位会快很多。实践中,"某天开始失败率上升"最常见的三个原因依次是:模型版本静默升级、提示词被改、Schema 加了必填字段。
## 七、和 Agent 的关系:工具参数本身就是结构化输出
Agent 场景下,结构化输出的重要性被再次放大。因为 Function Calling 的本质就是"让模型生成一段符合 Schema 的 JSON 作为工具参数"。工具参数错一个字段,下游就是一次失败的调用。
所以工具定义里的 `parameters` 应该按上面的原则设计:枚举优先、必填明确、描述里写清格式("订单号形如 ORD 开头的 12 位字符串")。这些描述不是给人看的文档,而是模型生成参数时的唯一依据。
进一步,工具返回值也应该是结构化的。返回一段自然语言,模型就得再解析一遍;返回 JSON,模型可以直接读字段。后者在长链路 Agent 里能显著降低错误传播。
## 八、小结:把"请求"变成"契约"
结构化输出这件事,本质上是在做一次权力划分:格式的最终裁定权在程序侧,不在模型侧。程序负责定义什么是合法的,模型负责在合法空间里找一个好答案。
这个划分一旦确立,很多连锁问题会自动缓解——解析容错代码变少、下游统计变准、故障定位变快、Agent 工具调用的成功率变高。反过来,如果一直把格式当成"提示词的礼貌请求",就会陷在"改提示词—加正则—补 try-except"的循环里,永远在追模型的下一次自由发挥。
落地顺序建议是:先把 Schema 按业务契约设计好,再叠三层约束,再写带类型区分的重试,最后把四个指标接进监控。这四步做完,结构化输出就不再是一个"偶尔会崩的环节",而是一条可以承诺 SLA 的链路。
更多推荐


所有评论(0)