让模型「吐出合法 JSON」是入门;让它在不掉智商的前提下吐出你要的 schema 才是工程。
结构化输出是 agent 与外部系统之间的类型边界——tool call 的参数、抽取的字段、路由的标签,全是结构化输出在兜底。很多人以为加一句「请返回 JSON」就解决了,结果在生产里被三件事反复教育:模型偶尔返回带 markdown 围栏的「几乎合法」JSON、强制 schema 之后推理质量莫名下滑、以及 max_tokens 截断时拿到半截 JSON 直接 json.loads 崩溃。这一期不讲「JSON 是什么」,讲四件决定可靠性的工程事:四种拿 JSON 的机制为何不等价、约束解码对推理的隐性税以及怎么躲、schema 本身就是一段 prompt 该怎么设计、以及当 100% schema 保证依然失败时的容错栈。每一条都假设你已经在 API 里调过 tool use。
同样是「让模型输出 JSON」,底层机制天差地别,保证强度从弱到强:
{ 逼模型跳过寒暄。零保证——模型仍可能加围栏、加注释、字段拼错。input_schema 描述结构,模型按 schema 填参。比 JSON mode 强,但实测仍非 100%——OpenAI 自己给的数据是 function calling 约 86% schema 合规。关键的工程直觉:前两种是「事后祈祷」,后两种是「事前约束」。约束解码的原理是把 JSON Schema 转成 CFG/FSM,在每个生成步用语法 mask 掉所有非法 token——所以它在物理上不可能产出违反结构的 token。这也是为什么它比 retry-until-valid 更省钱:不是生成完再校验,而是根本不让错误 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——严格模式通常要求它,否则模型会自作主张加字段。
sentiment 拼成 sentyment、或返回 {"result": {...}} 多包一层,全是合法 JSON——你的 data["sentiment"] 直接 KeyError。要 schema 级保证就上约束解码,别指望 JSON mode。
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)
约束解码保证「结构合法」,但填进去的内容对不对,仍取决于 schema 怎么写。这和 Day 4 tool use 的结论同源:description 比 field name 更重要。几条经过反复验证的设计纪律:
"due_date" 配上「ISO 8601,无则填 null」比裸字段名靠谱得多。required,可选语义靠「类型 union null」表达;大量 anyOf 联合类型在多数约束引擎里支持有限。「缺值就显式填 null」比一堆可选字段更稳。用 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,是模型本身。
shouldEscalateToTier2BecauseSLA)却不写 description——模型只能猜语义。(2)深度嵌套 + 满屏 optional:模型乱填、约束引擎变慢,调试时根本分不清是模型错还是 schema 太刁钻。
「100% schema 合规」是个容易被误读的承诺。它保证产出的 token 满足语法,但下面这些它管不了:
max_tokens,stop_reason == "max_tokens",你拿到的是结构上还没闭合的半截输出。务必先查 stop_reason,别盲目 parse。email: string,模型填 "无"——合法字符串,但不是邮箱。结构对、语义错。所以生产里真正的形态不是「调一次拿 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 表达不了的)→ 把错误回喂让模型自修 → 修不动则降级。约束解码只覆盖了「结构」这一层,其余三层得你自己搭。
r.data 灌进下游。某天一条超长输入触发截断,半截 JSON 进了数据库,或 "无" 被当邮箱发了告警——结构合法掩盖了语义垃圾,故障还特别难追。
拿你手里任何一个「让模型返回 JSON」的脚本,按本期四点逐层加固,半小时就能从 demo 级提到生产级:
json.loads + 正则剥围栏的祖传代码。reasoning 字段放进 schema 且排在答案前;纯推理重任务改两段式,CoT 段不约束。做完这套,你对「结构化输出」的心智模型会从「求模型给 JSON」变成「在解码层钉死结构、在 schema 里下指令、在外层兜语义」——这正是 demo 和生产的分界线。
{)逼模型直接进入 JSON,弱保证手段。