主题
结构化输出:为什么 prompt 里写「严格输出 JSON」根本不够
本文是 AI 应用系统学习系列的 L2 核心篇。前置:LLM API 开发详解:流式、函数调用与结构化输出。 学完可以配合面试题食用:Function Calling / Tool Use:大模型如何调用外部 API?、LLM API 开发详解:流式、函数调用与结构化输出。
翻车现场:一句「严格输出 JSON」的四个死法
下游代码要的是一个 {"name": "张三", "price": 9.9},你让模型输出 JSON,它在 prompt 里信誓旦旦地回了句「好的」。然后你收到四种东西:
text
1) ```json\n{"name": "张三", "price": 9.9}\n``` ← markdown 代码块包着
2) {"name": "张三", "price": 9.9}{"name": "李四", ...} ← 一次吐两个对象
3) {"name": "张三", "price": "九块九"} ← 枚举/类型瞎编
4) {"name": "张三", "price": 9.9 ← 少个右括号每一种都让 json.loads 直接抛异常。问题在于:prompt 是软约束,模型训练目标是「像人一样续写」,不是「产出能被 parser 吃进的结构」。你越依赖它在 prompt 里求,翻车概率越高。解法是往上加硬约束,一共四层,从弱到强。
四层防线,从软到硬
第一层是 prompt 求它,最弱。第二层是 JSON mode(response_format={"type": "json_object"}),它只保证输出是合法 JSON,不保证字段对、类型对。上面四种翻车里,它只修得好第一和第四——markdown 包着和少括号会修掉,但枚举瞎编、多吐对象照样发生。
第三层是 schema 约束解码:把 JSON Schema 喂给推理引擎,每一步生成 token 时,把所有不符合 schema 的 token 概率直接置 0。这是从根上解决问题,不是事后补救。原理直觉是 grammar-constrained decoding,Outlines、xgrammar、llama.cpp 的 GBNF 做的都是这件事。
以 {"name": string, "price": number} 为例:
mermaid
graph TD
START["生成 '{'"] --> NAME["强制 name 键"]
NAME --> COL["冒号 + 引号"]
COL --> STR["任意字符(向量字符集)"]
STR --> QUOTE["收引号"]
QUOTE --> COMMA["逗号"]
COMMA --> PRICE["price 键"]
PRICE --> NUM["数字字符(0-9 .)约束集"]
NUM --> END["'}'"]
NUM -.非法 token 概率置 0.-> NUM
STR -.枚举值若限定,字符集限定到候选列表.-> STR关键就在「字符集约束」:打字机不是全键盘,而是每一步只剩合法按键。这样模型最差也是在合法边界内乱写,不会炸 parser。
第四层是兜底:不管前面几层做得多好,服务端拿到结果必须用 pydantic 校验一遍,失败就把报错原文喂回去重试。四层全上,才叫生产可用。
校验失败怎么重试:别原样重发
重试里最蠢的操作是把同一个 prompt 原样再发一遍。模型上次给错不是偶然,再问它一次大概率错得一模一样。正确做法是把 pydantic 的报错原文塞回去:
你上次的输出校验失败:
price字段缺失;name字段类型必须是 string,不能是 null。请重试,只返回符合 schema 的 JSON。
模型读得懂具体错误,才知道改哪里。重试两次后仍失败,就记日志、返回 500,别再无限重试烧钱。
高频坑要单独说,这些不是重试能救的,得在设计 schema 的时候就避开:
- 枚举大小写:模型一会儿
pending一会儿Pending,schema 里用enum卡死,别指望 prompt 记住。 - 日期格式:
2026-09-09、09/09/2026、2026年9月9日三种都可能出现,schema 用format: date+ 在描述里写死一个格式。 - 嵌套数组为空:模型倾向给
[]而不是null,字段是「可选」还是「可空数组」要定义清楚。 - 可选字段和 null 的区别:缺字段是「没有这个信息」,显式
null是「明确知道是空」,下游逻辑处理方式不同。 - 中文枚举值:
"已发货"和"已发出"同义但不同值,下游==比较直接漏判,枚举值用英文 code 或数字,展示层再映射中文。
Function Calling 其实是同一件事
很多人觉得 Function Calling 是另一套东西,其实它就是带工具 schema 的结构化输出。你给模型的 tools 参数里那个 parameters 就是一个 JSON Schema,模型在生成参数时同样要遵守它。所以本文讲的约束解码、校验重试,对工具调用的参数解析同样生效。
区别只在语义:结构化输出是你自己定义字段让模型填,Function Calling 是多给了模型一个「决定是否调用、调哪个工具」的选择。底层机制一样,出问题的场景也一样——工具参数照样会枚举瞎编、类型跑偏。
动手实操:完整封装
pydantic 定义 schema,调 response_format 的 json_schema,失败带错误重试:
python
from pydantic import BaseModel, Field, ValidationError
from openai import OpenAI
client = OpenAI()
# 1) pydantic 定义 schema,同时是下游的数据类和约束定义
class Product(BaseModel):
name: str = Field(..., description="商品名")
price: float = Field(..., ge=0, description="价格,单位元")
tags: list[str] = Field(..., description="标签,非空数组")
# 2) 把 pydantic 的 json_schema 传给 response_format
def call(prompt: str) -> Product:
for attempt in range(3):
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
response_format={
"type": "json_schema", # 比 json_object 更强:带 schema 约束
"json_schema": {
"name": "product",
"schema": Product.model_json_schema(),
"strict": True, # strict 下必须 100% 符合 schema
},
},
)
raw = resp.choices[0].message.content
try:
return Product.model_validate_json(raw) # 3) 服务端校验
except ValidationError as e:
# 4) 带错误重试:把报错原文喂回去,不是原样重发
prompt = f"你上次输出校验失败:{e}\n请按 schema 重新输出,只返回 JSON。"
raise RuntimeError("重试 3 次仍失败")三个坑对应这段代码里的三处标注:
python
# 坑 A:不传 response_format,只靠 prompt -> model 输出被 markdown 包住,json.loads 炸
# 坑 B:用了 json_object 但没传 schema -> 字段对了也没保证,price 可能是字符串 "9.9"
# 坑 C:strict=True 但 schema 里字段带默认值 -> 模型可能漏填该字段,直接 400常见误区与小结
- 只写 prompt 不传 response_format。软约束顶不住高 tokens 温度,小模型尤其崩得快。
json_object当银弹。它只管括号配对,字段错、类型错、枚举错全不管。- 校验失败原样重发。模型不会自我纠错,得把报错喂回去。
- 枚举用中文词。同义词让下游
==判断漏掉,枚举用 code,展示层映射。 - 把结构化输出和 Function Calling 当两套系统。底层是同一个 schema 约束机制。
小结:32 篇讲 prompt、33 篇讲 API,本篇把「模型吐出来的字要能被下游程序吃进」这件事做成了硬约束,而不是寄希望于模型自觉。下一篇讲知识库运维:文档天天改,RAG 怎么做增量更新与版本管理。
参考
- OpenAI 官方文档:Structured Outputs(
response_format的json_schema与strict) - Outlines、xgrammar 的 GitHub README(grammar-constrained decoding 实现)
- pydantic 官方文档:
model_json_schema与model_validate_json