DAY 38 / PHASE 4 · ENGINEERING

结构化输出

JSON Mode · Constrained Decoding · Schema 设计 · 解析容错

2026-06-17 · BigCat

让模型「吐出合法 JSON」是入门;让它在不掉智商的前提下吐出你要的 schema 才是工程。

// WHY THIS MATTERS

结构化输出是 agent 与外部系统之间的类型边界——tool call 的参数、抽取的字段、路由的标签,全是结构化输出在兜底。很多人以为加一句「请返回 JSON」就解决了,结果在生产里被三件事反复教育:模型偶尔返回带 markdown 围栏的「几乎合法」JSON、强制 schema 之后推理质量莫名下滑、以及 max_tokens 截断时拿到半截 JSON 直接 json.loads 崩溃。这一期不讲「JSON 是什么」,讲四件决定可靠性的工程事:四种拿 JSON 的机制为何不等价约束解码对推理的隐性税以及怎么躲schema 本身就是一段 prompt 该怎么设计、以及当 100% schema 保证依然失败时的容错栈。每一条都假设你已经在 API 里调过 tool use。

// 01

四种拿 JSON 的机制:可靠性不在一个量级

论断:「prompt 求 JSON / JSON mode / tool use / 约束解码」是四种不同强度的保证,混用是大多数解析崩溃的根因。

背景与原理

同样是「让模型输出 JSON」,底层机制天差地别,保证强度从弱到强:

关键的工程直觉:前两种是「事后祈祷」,后两种是「事前约束」。约束解码的原理是把 JSON Schema 转成 CFG/FSM,在每个生成步用语法 mask 掉所有非法 token——所以它在物理上不可能产出违反结构的 token。这也是为什么它比 retry-until-valid 更省钱:不是生成完再校验,而是根本不让错误 token 出生。

拿 JSON 的四档机制 · 保证强度 / 成本 弱保证 ───────────────────────────────────▶ 强保证 ┌───────────┬───────────┬───────────┬──────────────┐ │ prompt+ │ JSON mode │ tool use │ 约束解码 │ │ prefill { │ │ │(structured │ │ │ │ │ outputs) │ ├───────────┼───────────┼───────────┼──────────────┤ │合法JSON? │ 不保证 │ ✅ │ ✅ │ ✅ │符合schema?│ 不保证 │ ✗ │ ~86% │ ✅ 100% │机制 │ 文本祈祷 │ 文本祈祷 │ 训练偏好 │ 解码层mask └───────────┴───────────┴───────────┴──────────────┘ 每步: schema→语法→ mask非法token→采样

实战示例

同一个抽取任务,用 Anthropic 严格结构化输出(解码层保证),而不是 prompt 求 JSON:

import anthropic
client = anthropic.Anthropic()

schema = {
  "type":"object",
  "properties":{
    "sentiment":{"type":"string","enum":["pos","neg","neutral"]},
    "key_entities":{"type":"array","items":{"type":"string"}}
  },
  "required":["sentiment","key_entities"],
  "additionalProperties":False
}
r = client.messages.create(
  model="claude-sonnet-4-5", max_tokens=1024,
  messages=[{"role":"user","content": review_text}],
  output_format={"type":"json_schema","schema": schema}  # 解码层强约束
)
data = r.content[0].input  # 已是 dict,不用 json.loads + try/except

注意 additionalProperties:false——严格模式通常要求它,否则模型会自作主张加字段。

失败模式:用 JSON mode 却以为它管 schema。JSON mode 只承诺「能 parse」,模型把 sentiment 拼成 sentyment、或返回 {"result": {...}} 多包一层,全是合法 JSON——你的 data["sentiment"] 直接 KeyError。要 schema 级保证就上约束解码,别指望 JSON mode。
进阶资源 · Anthropic Structured Outputs 文档, platform.claude.com/.../structured-outputs · OpenAI Introducing Structured Outputs, openai.com/.../structured-outputs
// 02

约束解码的隐性税:先想,再约束

论断:把强 schema 直接套在需要推理的输出上会压低准确率;解法不是放弃结构,是把「推理」和「结构」分两段。

背景与原理

2024 年 EMNLP 那篇 Let Me Speak Freely?(Tam 等)扔下一枚炸弹:在多个推理基准上,强制模型用 JSON/XML 格式作答,推理准确率显著下降,约束越严降得越多。直觉解释:约束解码 mask 掉了模型本想用来「边写边想」的 token 空间,等于变相剥夺了 chain-of-thought。

但故事没完。Outlines 团队(.txt)发了 Say What You Mean 反驳:他们复现后发现,只要 schema 设计得当、给好 few-shot 结构示例,结构化生成不仅不掉分,还能略升。两边其实不矛盾,合起来给了一条可落地的工程结论:不是结构本身有害,是「把推理字段挤掉」有害

所以真正的 tactic 是字段顺序工程:JSON 是自回归生成的,模型写后面字段时能看到前面已写的。把 reasoning 字段放在 answer 之前,模型就在结构内完成了 CoT,再产出答案;反过来把 answer 放最前面,等于逼它没想就先答。需要更彻底时就两段式:第一段自由 CoT 不加约束,第二段只做「把结论抽成 schema」的轻任务。

实战示例

# ❌ answer 在前:模型还没推理就被迫先填答案
{"answer": ..., "reasoning": ...}

# ✅ reasoning 在前:结构内嵌 CoT,answer 基于它生成
schema = {"type":"object",
  "properties":{
    "reasoning":{"type":"string",
      "description":"Think step by step before answering."},
    "answer":{"type":"number"}
  },
  "required":["reasoning","answer"]}  # required 顺序 = 生成顺序

# ✅✅ 推理重的任务:两段式,CoT 段不加约束
cot = ask(prompt)                       # 自由文本,full reasoning
out = ask(f"基于以下分析抽成 JSON:\n{cot}", schema=schema)
失败模式:在 reasoning 模型(带 thinking 的)上又套整段强约束 JSON,等于双重剥夺——thinking 已经在结构外了,正文又被 mask。这类模型用「thinking 自由 + 最终 answer 走 structured output」最稳,别让语法约束伸进 thinking 段。
进阶资源 · Tam 等 Let Me Speak Freely? (EMNLP 2024), arXiv:2408.02442 · .txt Say What You Mean: A Response, blog.dottxt.ai/say-what-you-mean
// 03

Schema 即 Prompt:字段设计决定填充质量

论断:schema 不只是给解析器的约束,它同时是模型读的指令——字段名、描述、enum、嵌套深度都在改变输出质量。

背景与原理

约束解码保证「结构合法」,但填进去的内容对不对,仍取决于 schema 怎么写。这和 Day 4 tool use 的结论同源:description 比 field name 更重要。几条经过反复验证的设计纪律:

实战示例

用 Pydantic 定义带描述与 enum 的扁平 schema(OpenAI/Anthropic SDK 都能直接吃):

from pydantic import BaseModel, Field
from enum import Enum

class Priority(str, Enum):
    low="low"; med="med"; high="high"

class Ticket(BaseModel):
    """Extract a support ticket from a user message."""
    summary: str = Field(description="One-line problem, <=80 chars")
    priority: Priority = Field(description="high only if blocking/data-loss")
    due_date: str | None = Field(description="ISO 8601 or null")
    # 扁平 + 每字段 description + enum 收敛 + 显式 null

这份 schema 同时干了三件事:约束结构、用 description 做字段级指令、用 enum 把 priority 物理钉死在三个值上。读它的不只是 parser,是模型本身。

失败模式:(1)把决策逻辑全堆进字段名(shouldEscalateToTier2BecauseSLA)却不写 description——模型只能猜语义。(2)深度嵌套 + 满屏 optional:模型乱填、约束引擎变慢,调试时根本分不清是模型错还是 schema 太刁钻。
进阶资源 · Anthropic Cookbook extracting structured json, github.com/anthropics/anthropic-cookbook · OpenAI Structured Outputs 指南, developers.openai.com/.../structured-outputs
// 04

容错栈:当「100% schema」依然失败时

论断:约束解码保证结构合法,但保证不了语义对、不被截断、不被拒答——生产可靠性来自外层的 validate-repair-fallback 栈。

背景与原理

「100% schema 合规」是个容易被误读的承诺。它保证产出的 token 满足语法,但下面这些它管不了:

所以生产里真正的形态不是「调一次拿 dict」,而是一条校验-修复-降级链——和 Day 3 harness 里「错误回给模型」是同一套韧性思想:把 validation error 喂回模型让它自修,比直接抛异常强得多。

实战示例

from pydantic import ValidationError

def extract(text, max_repair=2):
    msgs = [{"role":"user","content": text}]
    for _ in range(max_repair+1):
        r = call(msgs, schema=Ticket)
        if r.stop_reason == "max_tokens":      # 1) 截断: 不 parse, 扩 budget
            raise Truncated("raise max_tokens / 切小任务")
        try:
            return Ticket.model_validate(r.data)  # 2) 语义层校验
        except ValidationError as e:
            msgs += [{"role":"assistant","content": str(r.data)},
                     {"role":"user",
                      "content": f"校验失败,修正后重发:{e}"}]  # 3) 错误回喂自修
    raise Unrepairable()                          # 4) 降级: 人工/默认值

四层依次兜底:截断检测 → 语义校验(Pydantic 的 validator 管邮箱、范围、跨字段约束这些 schema 表达不了的)→ 把错误回喂让模型自修 → 修不动则降级。约束解码只覆盖了「结构」这一层,其余三层得你自己搭。

失败模式:盲信「structured output 不会错」,省掉校验和截断检测,直接把 r.data 灌进下游。某天一条超长输入触发截断,半截 JSON 进了数据库,或 "无" 被当邮箱发了告警——结构合法掩盖了语义垃圾,故障还特别难追。
进阶资源 · Outlines(FSM 约束解码,Willard & Louf 2023), github.com/dottxt-ai/outlines · Anthropic Prefill 文档, platform.claude.com/.../prefill

// 综合实战 · 把一个脆弱抽取器升级成生产级

拿你手里任何一个「让模型返回 JSON」的脚本,按本期四点逐层加固,半小时就能从 demo 级提到生产级:

  1. 换机制(§1):从 prompt 求 JSON / JSON mode 升到约束解码(output_format / response_format),删掉那段 json.loads + 正则剥围栏的祖传代码。
  2. 排字段(§2):若任务带推理,把 reasoning 字段放进 schema 且排在答案前;纯推理重任务改两段式,CoT 段不约束。
  3. 修 schema(§3):每个字段补 description、分类字段换 enum、拍平嵌套、可选字段改「显式 null」。
  4. 加容错(§4):包一层 stop_reason 检测 + Pydantic 语义校验 + 错误回喂自修 + 降级。
  5. 建 eval:出 20 条带 ground truth 的输入,对比加固前后的「结构合规率 × 语义正确率」两个指标——你会发现结构合规早就 100% 了,真正提升的是语义正确率。

做完这套,你对「结构化输出」的心智模型会从「求模型给 JSON」变成「在解码层钉死结构、在 schema 里下指令、在外层兜语义」——这正是 demo 和生产的分界线。

// ENGLISH GLOSSARY

Structured Output
让模型产出符合预定义 schema 的输出(而非自由文本)。本期主角。
JSON Mode
API 保证输出是合法 JSON,但不保证符合你的 schema 的弱模式。
Constrained Decoding
解码层按语法 mask 非法 token,物理保证结构合法。又称 guided/grammar-constrained decoding。
Schema Compliance
输出符合给定 JSON Schema 的比例。约束解码可达 100%,function calling 实测约 86%。
CFG / FSM
上下文无关文法 / 有限状态机。约束解码把 schema 编译成它们来生成 token mask。
Prefill
预填 assistant 开头(如 {)逼模型直接进入 JSON,弱保证手段。
Field Ordering
schema 字段的生成顺序;把 reasoning 排在 answer 前可在结构内保留 CoT。
Refusal Path
模型拒答时走的分支,此时不返回目标 schema,需单独处理。
Validate-Repair Loop
校验失败时把错误回喂模型令其自修的容错环,呼应 harness 的 recovery。
additionalProperties
JSON Schema 字段,设 false 禁止模型自加字段;严格模式常要求。

// 深入思考

既然约束解码能 100% 保证结构合法,为什么 tool use / function calling 不直接全用约束解码,还容忍 86% 的合规率?
历史与权衡。早期 function calling 靠训练让模型「倾向」输出 schema,是文本层偏好而非解码层硬约束,所以有漏网。约束解码需要在推理引擎里编译 schema 成语法、维护 token mask,有工程成本和延迟开销,且对动态/递归 schema 支持有限。如今主流平台已把严格约束作为可选模式叠加在 tool use 之上(OpenAI 的 strict、Anthropic 的 structured outputs),方向就是让二者合一;但默认不强制,是为了兼容老接口和复杂 schema 场景。
《Let Me Speak Freely》和 .txt 的反驳看似对立。如果让你设计一个实验一锤定音,你会怎么控制变量?
核心混淆变量是「约束本身」vs「约束附带的 prompt/字段顺序变化」。我会固定模型与温度,构造三臂对照:(A) 自由 CoT + 自由答案;(B) 自由 CoT 字段在前 + 约束答案字段在后;(C) 答案字段在前的纯约束。若 B≈A>C,说明伤害来自挤掉 reasoning 而非约束本身(支持 .txt);若 A>B≈C,说明约束本身有损(支持原论文)。再加一臂控制 few-shot 结构示例的有无,分离「模型没见过该结构」这个因素。多数复现指向:是字段编排和示例质量在主导,不是约束的存在与否。
约束解码在每步 mask 非法 token。这会不会把模型本来高概率但「暂时不合法」的 token 抹掉,反而逼它走进低质量路径?
会,这是约束解码的真实代价,叫 distribution distortion。FSM 只看「语法是否合法」,不看「语义是否更优」。若最优 token 此刻不合法,它被 mask,模型被迫在剩下的合法 token 里采样,可能滑向低概率分支并「滚雪球」越走越偏。缓解:schema 别过度限制(少用窄 pattern/超长 enum)、给足结构示例让模型的高概率分布天然落在合法区、以及把推理放到约束之外。这也解释了为什么「schema 越严越好」是错觉——过严会放大这种扭曲。
如果下游系统能接受两种格式,你会让 agent 在 tool call 里返回结构化结果,还是在最终消息里返回 structured output?二者工程含义有何不同?
tool call 是「agent 想调用外部能力」的语义,结果会回灌进 loop 让 agent 继续;最终 structured output 是「这一轮的交付物」。若结果要被 agent 自己消费、参与后续决策,用 tool call 更自然(也天然带 schema 校验);若结果是给外部消费者的终态交付(API 响应、写库),用最终 structured output 更直接,少一次绕回模型的开销。混用的坑:把终态交付硬塞进 tool call,会让 loop 多转一圈、还要额外逻辑判断「这次 tool call 是真要调用还是只是返回结果」。
结构化输出把 LLM 变成了「类型安全的函数」。这对系统架构意味着什么——它会改变我们在哪里画 AI 与确定性代码的边界吗?
意味着 LLM 可以被当成一个有类型签名的不纯函数嵌进传统类型系统:输入文本、输出经 schema 校验的 typed object。边界因此可以画得更细——不再是「AI 模块 vs 代码模块」的粗粒度隔离,而是在每个函数调用点做类型契约。但要清醒:类型安全≠语义安全,schema 挡得住 KeyError,挡不住「类型对、值是幻觉」。所以新边界是:用 structured output 把「形状不确定性」消灭在解码层,把省下的精力集中投到「值正确性」的校验与 eval 上。架构上这会推动 AI 调用越来越像 RPC——有 schema、有重试、有降级、有 SLA。

// 延伸阅读