Skip to content

结构化输出:为什么 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-0909/09/20262026年9月9日 三种都可能出现,schema 用 format: date + 在描述里写死一个格式。
  • 嵌套数组为空:模型倾向给 [] 而不是 null,字段是「可选」还是「可空数组」要定义清楚。
  • 可选字段和 null 的区别:缺字段是「没有这个信息」,显式 null 是「明确知道是空」,下游逻辑处理方式不同。
  • 中文枚举值:"已发货""已发出" 同义但不同值,下游 == 比较直接漏判,枚举值用英文 code 或数字,展示层再映射中文。

Function Calling 其实是同一件事

很多人觉得 Function Calling 是另一套东西,其实它就是带工具 schema 的结构化输出。你给模型的 tools 参数里那个 parameters 就是一个 JSON Schema,模型在生成参数时同样要遵守它。所以本文讲的约束解码、校验重试,对工具调用的参数解析同样生效。

区别只在语义:结构化输出是你自己定义字段让模型填,Function Calling 是多给了模型一个「决定是否调用、调哪个工具」的选择。底层机制一样,出问题的场景也一样——工具参数照样会枚举瞎编、类型跑偏。

动手实操:完整封装

pydantic 定义 schema,调 response_formatjson_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_formatjson_schemastrict
  • Outlines、xgrammar 的 GitHub README(grammar-constrained decoding 实现)
  • pydantic 官方文档:model_json_schemamodel_validate_json

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1