Skip to content

Structured Output

上一章讲了如何通过 Prompt 把任务说清楚——角色、目标、上下文、输出要求都交代好。你可能已经发现,光靠 Prompt 里写"请输出 JSON"并不总是管用:模型有时会在 JSON 前后加一堆解释文字,或者字段命名和你要求的对不上。

这说明 Prompt 只解决了一半问题:如果说 Prompt 工程解决的是"模型听懂任务",那结构化输出解决的是"模型输出能不能被系统稳定消费"。

本章目标

  • 理解自由文本和结构化结果的差异
  • 能设计最小可用 Schema,并在程序层做校验
  • 能处理 JSON 不合法、字段缺失、类型错误等常见问题
  • 知道厂商原生结构化输出 API 和 Prompt 方式的区别
  • 能建立"解析失败后怎么办"的完整处理流程

什么是 Structured Output

Structured Output 指的是:不是让模型随便返回一段自然语言,而是要求它按固定结构返回结果,例如:

  • JSON
  • 固定字段对象
  • 数组
  • 枚举值
  • 可直接解析的表格数据

它要解决的问题是:把模型输出从自然语言回答,变成系统之间可传递的契约对象。下游系统不关心结果写得像不像人话,只关心能不能稳定解析、通过校验、继续流转。结构化输出的本质就是契约。

为什么它很重要

很多 AI 功能在"用户看完回答"之后还有下半段——结果要继续流入系统,例如:

  • 提取简历字段后写入数据库
  • 识别用户意图后决定调哪个工具
  • 抽取工单信息后进入业务流程

这时"写得像人"不如"格式稳定"重要。

结构化输出的核心是“可约束性”

很多人一提 Structured Output,脑子里就只剩"让模型吐 JSON"。JSON 当然是常见形式,但更重要的是结果能不能被明确约束。

只要系统能回答这些问题,本质上就在做结构化输出:

  • 字段有哪些
  • 哪些必填
  • 类型是什么
  • 枚举范围是什么
  • 缺失时该怎么表示
  • 多余字段能不能出现

也就是说,Structured Output 的核心在于契约边界,花括号只是表象。

一个自然语言输出为什么不够

设想你让模型从一段候选人简历里抽取信息,供后续写入数据库。

自然语言输出(不稳定,程序难消费):

text
该候选人是一名前端工程师,有大约 3 年工作经验,
主要熟悉 React、Vue 和 Node.js 等技术栈。

每次措辞不同、字段位置不同、数字可能是"约 3 年"也可能是"三年左右",程序拿到这段文字几乎没法直接解析。

结构化输出(稳定,可直接消费):

json
{
  "years_of_experience": 3,
  "skills": ["React", "Vue", "Node"],
  "role_direction": "Frontend Engineer"
}

这种结果可以直接 JSON.parse(),字段有了就用,没有就按 null 处理,程序不需要理解自然语言就能继续流转。

自然语言常见问题:

  • 每次表达方式不同
  • 字段不稳定
  • 程序难解析
  • 很容易混入额外解释

结构化输出常见场景

  • 信息抽取
  • 分类与标签打标
  • 意图识别与路由
  • 工作流决策
  • 前端配置生成
  • Tool Calling 参数组织

Schema 应该怎么设计

字段尽量少而明确

先只保留真正对业务有用的字段,不要一开始设计成"大而全"。每多一个字段,就多一种出错的可能。

Schema 本质上是在定义下游程序准备信任什么。字段一多,模型出错的概率上升,下游处理成本也跟着涨。越靠近业务核心的 Schema,字段越应该克制。

类型要稳定

例如:

  • skills 始终是数组,不要允许字符串("React, Vue")和数组(["React", "Vue"])混用
  • years_of_experience 始终是数字或 null,不要允许字符串"三年"
  • risk_level 始终是固定枚举值("low" | "medium" | "high"),而不是自由文本

允许不确定

对于拿不准的字段,允许:

  • null
  • 空数组 []

比起让模型瞎编一个值,显式的不确定更安全。比如简历里没有工作年限信息,years_of_experience: nullyears_of_experience: 0 更诚实,也更好处理。

这条特别重要,因为很多结构化失败,不是格式坏了,而是语义上模型不愿意承认"我不知道",于是硬填一个看起来合法但根本没依据的值。格式合法但语义虚假,对系统反而更危险。

用嵌套而不是扁平字段堆叠

如果字段多,用嵌套对象,而不是把所有字段摊平在顶层。嵌套结构让模型更容易理解字段之间的关系:

json
{
  "candidate": {
    "name": "Alice",
    "experience_years": 3
  },
  "skills": {
    "frontend": ["React", "Vue"],
    "backend": ["Node.js"]
  }
}

比一股脑列 10 个顶层字段,对模型和程序都更友好。

让模型输出 JSON 仍然可能失败

常见失败类型:

  • 输出了额外解释文字("以下是提取结果:```json ...")
  • JSON 语法不合法(缺少引号、多余逗号)
  • 漏字段
  • 字段类型错误(数字写成了字符串)
  • 枚举值不在预定范围内
  • 编造了原文没有的信息

所以"要求 JSON 输出"只是第一步,不是终点。

结构合法,不等于语义正确

这是 Structured Output 最容易被误解的一点。

一个结果完全可能:

  • JSON 合法
  • 字段齐全
  • 类型正确
  • 甚至通过了 Schema 校验

但语义上仍然是错的。比如日期填错、分类选错、工作年限是编出来的、引用字段对应不到真实资料。

所以 Structured Output 能帮你解决"程序接不住"的问题,但解决不了全部"内容对不对"的问题。后者还需要检索、校验、规则检查甚至人工复核。

一个实用 Prompt 模板

text
你是一个信息抽取助手。
请从输入文本中提取目标字段,并以 JSON 输出。

要求:
1. 只输出 JSON,不要任何额外文字
2. 字段缺失时返回 null
3. 不要输出未定义字段
4. skills 必须是字符串数组

字段定义:
- name: string | null(候选人姓名)
- skills: string[](技能列表,无则返回空数组)
- years_of_experience: number | null(工作年限,无则返回 null)

厂商原生结构化输出 API

靠 Prompt 让模型输出 JSON,本质上是"劝说"——成功率高,但不是 100%。现在主流厂商都提供了原生的结构化输出能力,通过在 API 调用时传入 Schema 来约束输出格式,从语言层面而不仅仅是 Prompt 层面来保证结构。

Prompt 方式是用自然语言告诉模型"请尽量按这个格式输出";原生结构化输出把约束下沉到协议层,模型输出直接受底层格式限制。换句话说,"格式正确"不再完全靠模型自觉,而是部分交给了平台机制。

OpenAI:response_format + JSON Schema

OpenAI 的 response_format 参数接受一个 JSON Schema,模型会严格按照这个结构返回结果:

python
from openai import OpenAI
import json

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "从简历文本中提取候选人信息,返回 JSON。"},
        {"role": "user", "content": "Alice,5年React开发经验,熟悉Node.js"}
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "candidate_info",
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": ["string", "null"]},
                    "skills": {"type": "array", "items": {"type": "string"}},
                    "years_of_experience": {"type": ["number", "null"]}
                },
                "required": ["name", "skills", "years_of_experience"],
                "additionalProperties": False
            },
            "strict": True
        }
    }
)

result = json.loads(response.choices[0].message.content)
print(result)
# {"name": "Alice", "skills": ["React", "Node.js"], "years_of_experience": 5}

strict: True 模式下,模型的输出会严格匹配你的 Schema,不会多字段、不会少字段。

Anthropic:工具调用模式

Anthropic 的 Claude 没有单独的 response_format 参数,但有一个等效方案:把结构化输出当作一个"虚拟工具"来调用,工具的参数 Schema 就是你要的输出格式。

python
import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=1024,
    tools=[{
        "name": "extract_candidate_info",
        "description": "提取候选人信息",
        "input_schema": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "候选人姓名"},
                "skills": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "技能列表"
                },
                "years_of_experience": {
                    "type": "number",
                    "description": "工作年限"
                }
            },
            "required": ["name", "skills", "years_of_experience"]
        }
    }],
    tool_choice={"type": "tool", "name": "extract_candidate_info"},
    messages=[{
        "role": "user",
        "content": "Alice,5年React开发经验,熟悉Node.js"
    }]
)

tool_use = next(b for b in response.content if b.type == "tool_use")
result = tool_use.input
print(result)
# {"name": "Alice", "skills": ["React", "Node.js"], "years_of_experience": 5}

tool_choice 强制模型只能调用这个工具,输出就是工具参数,格式由 Schema 约束。这个模式下结构化输出的可靠性和原生 JSON 模式相当。

两种方式的对比

Prompt 方式厂商原生 API
格式约束语言约束,靠模型遵守底层约束,保证格式合法
适用范围所有模型只有支持的模型和厂商
失败率有一定概率格式层面基本无失败
灵活性高,可以自然语言说明受 JSON Schema 能力限制
推荐场景快速验证、老模型生产系统、结构要求严格

厂商原生方式能保证 JSON 合法,但不能保证语义正确——模型还是可能在字段里填错内容。程序校验依然需要。

为什么原生 API 更适合生产,而 Prompt 方式更适合起步

Prompt 方式的优势是轻、快、兼容面广,适合快速验证任务是否值得做;原生 API 的优势是更稳定,适合真正进入系统链路后作为长期方案。

两者不矛盾,通常是分阶段用的:

  1. 先用 Prompt 低成本试通任务
  2. 确认场景成立后,用原生结构化能力把格式稳定性补起来

用 Pydantic 做程序校验

Pydantic 是 Python 生态里做数据校验的首选工具,在 AI 应用里几乎是标配。它让你用 Python 类定义 Schema,然后直接用来验证模型输出。

python
from pydantic import BaseModel, field_validator
from typing import Optional

class CandidateInfo(BaseModel):
    name: Optional[str] = None
    skills: list[str] = []
    years_of_experience: Optional[float] = None

    @field_validator("years_of_experience")
    @classmethod
    def validate_experience(cls, v):
        if v is not None and (v < 0 or v > 60):
            raise ValueError(f"工作年限 {v} 超出合理范围")
        return v

# 模型输出的 JSON 字符串
raw_output = '{"name": "Alice", "skills": ["React", "Node.js"], "years_of_experience": 5}'

try:
    data = CandidateInfo.model_validate_json(raw_output)
    print(data.name)                  # Alice
    print(data.skills)                # ['React', 'Node.js']
    print(data.years_of_experience)   # 5.0
except Exception as e:
    print(f"校验失败: {e}")
    # 走兜底逻辑:重试或返回默认值

Pydantic 的校验会捕获类型错误、缺失必填字段和自定义约束失败。校验通过的数据是干净的,直接传给下游业务系统。

为什么程序校验是结构化输出的第二道门

只靠模型侧约束还不够,因为模型输出一旦进入业务流程,责任就转移给系统了。

程序校验的意义,不只是再查一遍类型,而是在明确告诉系统:只有通过这一道门的数据,才值得继续被信任。也就是说,模型输出先是候选结果,校验通过后才升级成业务输入。

解析失败后怎么办

解析失败不等于整个功能崩溃。工程上有三种常见处理方式,按成本递增排列:

策略一:清理格式再解析

最低成本的做法是先把模型输出里的多余部分去掉,再尝试解析:

python
import re
import json

def extract_json(raw: str) -> dict:
    """从模型输出里提取 JSON 部分"""
    # 去掉 markdown 代码块标记
    raw = re.sub(r"```json\n?|```\n?", "", raw).strip()
    # 找到第一个 { 和最后一个 }
    start = raw.find("{")
    end = raw.rfind("}") + 1
    if start == -1 or end == 0:
        raise ValueError("没有找到 JSON 内容")
    return json.loads(raw[start:end])

策略二:带错误信息重试

把失败原因带回给模型,让它修正输出:

python
from pydantic import ValidationError

def extract_with_retry(text: str, max_retries: int = 2):
    messages = [
        {"role": "system", "content": "请提取候选人信息,只输出 JSON。"},
        {"role": "user", "content": text}
    ]

    for attempt in range(max_retries + 1):
        response = call_model(messages)
        raw = response.choices[0].message.content

        try:
            data = extract_json(raw)
            return CandidateInfo.model_validate(data)
        except (ValueError, ValidationError) as e:
            if attempt < max_retries:
                messages.append({"role": "assistant", "content": raw})
                messages.append({
                    "role": "user",
                    "content": f"上面的输出有问题:{str(e)}。请重新输出正确的 JSON。"
                })
            else:
                raise RuntimeError(f"重试 {max_retries} 次后仍然失败") from e

策略三:兜底默认值

如果重试也解决不了,不要让整个功能崩溃,返回默认值并记录日志:

python
import logging
logger = logging.getLogger(__name__)

def safe_extract(text: str) -> CandidateInfo:
    try:
        return extract_with_retry(text)
    except Exception as e:
        logger.error(f"提取失败,使用默认值: {e}")
        return CandidateInfo()  # 所有字段为 null / 空

三种策略可以组合使用:先清理格式、再重试、最后兜底。

这三种策略对应的是三种不同层级的问题:

  • 清理格式:模型大致答对了,只是包装坏了
  • 重试修复:模型理解了任务,但第一次输出没守住契约
  • 兜底默认值:系统承认这次提取失败,优先保证整体流程别崩

把这三者分清楚,会比一律"失败就重试"更稳。

典型工程流程

  1. 先定义最小 Schema(Pydantic 类或 JSON Schema)
  2. 用 Prompt 要求模型按 Schema 输出,或者直接用厂商原生 API
  3. 获取模型结果
  4. 清理格式(去掉多余文字、代码块标记)
  5. JSON 解析
  6. Pydantic 校验(类型、必填、枚举范围)
  7. 失败则重试或修复,超过次数则兜底
  8. 校验通过后交给业务系统

Structured Output 和 Tool Calling 的关系

Structured Output 和 Tool Calling 有时会让人困惑,因为两者都在限制模型输出格式。区别在于目的不同

  • Structured Output:目标是让模型返回结构化数据,供程序解析和消费。工具不涉及,只是要数据。
  • Tool Calling:目标是让模型决定调用哪个外部工具,并生成工具需要的参数。参数本身是结构化的,但重点是"决定调用工具"这个动作。

实践上经常一起出现:Tool Calling 要求模型生成工具参数(Structured Output),工具执行完返回的结果也常是结构化数据(再次 Structured Output)。

可以这样记:Structured Output 是技术手段,Tool Calling 是应用场景。Anthropic 用工具调用模式实现结构化输出,就是这两者关系最直接的体现。

Schema 在限制模型的自由度

自由文本输出时,模型几乎可以往任何方向继续生成。给了 Schema 之后,字段名、层级、枚举、类型、是否允许额外属性,都在收紧生成空间,模型能走的路窄了很多。

Schema 是一种比 Prompt 更硬的约束边界。它减少的是模型乱发挥的余地,跟让模型更聪明是两码事。这也是 Structured Output 能显著提升稳定性的直接原因。

落地备忘

Structured Output 的核心是给模型和程序之间建立契约,JSON 只是最常见的载体。null 往往比编出来的值更有价值——它保留了不确定性,让下游能做出更安全的判断。

格式稳定只解决“能不能接住”,不解决“内容一定对”。原生结构化 API 把格式控制下沉到协议层,适合已经跑在生产链路里的场景。

稳的系统不会直接信任模型输出。它们把输出当候选结果,经过校验、重试和兜底之后,才让数据进入业务流程。

你现在最适合做的练习

  • 文本信息提取器(简历、工单、新闻)
  • 多分类打标器(情感、主题、优先级)
  • 用户意图识别器(路由到不同业务处理分支)

这类练习直接为 Tool CallingAgent 基础原理 打基础——工具参数生成本质上就是一种结构化输出。

格式合法不等于语义正确。程序校验解决的是"能不能接住",解决不了"内容对不对"。原生结构化 API 把格式控制下沉到协议层,适合进了生产链路的场景。重试和兜底不是互相替代的——清理格式是包装问题,重试修复是逻辑问题,兜底默认值是系统说"我放弃了这次",分清楚它们才不会一律重试三遍然后崩溃。

下一章:Tool Calling——把"稳定输出数据"推进到"让模型决定并调用外部工具"。

面向开发者系统学习 AI 应用开发、RAG、Agent 与 Vibe Coding。