Skip to content

结构化输出 (Structured Outputs):JSON、Schema 验证 (Schema Validation)、约束解码 (Constrained Decoding)

你的 LLM 返回的是字符串,而应用真正需要的是 JSON。这个落差导致的生产事故,比任何模型幻觉都更多。结构化输出是自然语言与类型化数据之间的桥梁。做对了,LLM 就会变成可靠的 API;做错了,你就会在凌晨 3 点用 regex 解析自由文本。

类型: 构建 语言: Python 前置要求: 第 10 阶段,第 01-05 课(从零开始实现 LLM) 时长: ~90 分钟 相关内容: 第 5 阶段 · 20(Structured Outputs & Constrained Decoding)讲解了解码器层面的理论(FSM/CFG logit processors、Outlines、XGrammar)。本课聚焦生产级 SDK 接口层(OpenAI response_format、Anthropic tool use、Instructor)——如果你想理解 API 之下实际发生了什么,建议先读第 5 阶段 · 20。

学习目标

  • 使用 OpenAI 和 Anthropic 的 API 参数实现 JSON 模式 (JSON mode) 与受 Schema 约束的输出
  • 构建基于 Pydantic 的验证层,拒绝格式错误的 LLM 输出,并结合错误反馈进行重试
  • 解释约束解码如何在无需后处理的情况下,从 token 层面强制生成有效 JSON
  • 设计稳健的抽取提示词,让模型可靠地把非结构化文本转换为类型化数据结构

问题

你向 LLM 提问:“从这段文本中提取产品名、价格和库存状态。” 它返回:

The product is the Sony WH-1000XM5 headphones, which cost $348.00 and are currently in stock.

这个答案完全正确,但对你的应用毫无用处。库存系统真正需要的是 {"product": "Sony WH-1000XM5", "price": 348.00, "in_stock": true}。你需要的是一个拥有特定键、特定类型以及特定取值约束的 JSON 对象,而不是一句自然语言描述。

最天真的做法,是在提示词里加上一句“Respond in JSON”。这样 90% 的时候确实能工作。但剩下 10% 里,模型会把 JSON 包进 markdown code fence,或者先来一句 “Here's the JSON:”,又或者因为括号提前闭合而生成语法无效的 JSON。于是 JSON parser 崩了,整条 pipeline 跟着断掉。然后你加 try/except 和重试循环。结果重试时模型又可能给出不同数据。现在你不仅有解析问题,还有一致性问题。

这不是提示词工程问题,而是解码问题。模型按从左到右的顺序生成 token。每个位置上,它都要从一个拥有 10 万+ 候选项的词表中选出下一个最可能的 token。而在任意给定位置,其中绝大多数选择都会导致无效 JSON。比如模型刚输出了 {"price":,那么下一个 token 必须是数字、引号(表示字符串)、nulltruefalse,或者负号。其他任何东西都会让 JSON 非法。如果没有约束,模型完全可能选出一个在英语语义上合理、但在语法上灾难性的词。

核心概念

结构化输出光谱

结构化输出控制大致可以分为四个层级,后者总比前者更可靠。

mermaid
graph LR
    subgraph Spectrum["结构化输出光谱"]
        direction LR
        A["基于提示词\n'Return JSON'\n~90% 有效"] --> B["JSON 模式\n保证输出有效 JSON\n不保证符合 Schema"]
        B --> C["Schema 模式\nJSON + 匹配 Schema\n保证合规"]
        C --> D["约束解码\nToken 级约束\n100% 合规"]
    end

    style A fill:#1a1a2e,stroke:#ff6b6b,color:#fff
    style B fill:#1a1a2e,stroke:#ffa500,color:#fff
    style C fill:#1a1a2e,stroke:#51cf66,color:#fff
    style D fill:#1a1a2e,stroke:#0f3460,color:#fff

基于提示词 (Prompt-based)(“Respond in valid JSON”):没有任何强制机制。模型大多数时候会照做,但偶尔不会。可靠性大约是 90%。常见失败方式包括:markdown fence、前置说明文字、输出被截断、结构错误。

JSON 模式 (JSON mode):API 保证输出一定是合法 JSON。OpenAI 的 response_format: { type: "json_object" } 就能开启这个模式。输出一定可以被正常解析,但未必符合你预期的 Schema——可能有额外键、类型错误,或缺失字段。

Schema 模式 (Schema mode):API 接收一个 JSON Schema,并保证输出与之匹配。到 2026 年,所有主流提供商都原生支持这一能力:OpenAI 的 response_format: { type: "json_schema", json_schema: {...} }(也可通过 tool_choice="required" 使用)、Anthropic 的 tool use 配合 input_schema,以及 Gemini 的 response_schema + response_mime_type: "application/json"。输出会严格遵循你指定的键、类型和约束。

约束解码:在生成过程中的每一个 token 位置,解码器都会屏蔽所有会导致无效输出的 token。如果 Schema 要求当前位置必须是数字,而模型正准备输出字母,那么这个 token 的概率就会被直接设为 0。模型只能生成那些会通向有效输出的 token。这正是 OpenAI 的 structured output 模式,以及 Outlines、Guidance 这类库在底层实现的机制。

JSON Schema:契约语言

JSON Schema 就是你告诉模型(或者验证层)“输出必须长成什么样”的语言。所有主流的结构化输出系统都依赖它。

json
{
  "type": "object",
  "properties": {
    "product": { "type": "string" },
    "price": { "type": "number", "minimum": 0 },
    "in_stock": { "type": "boolean" },
    "categories": {
      "type": "array",
      "items": { "type": "string" }
    }
  },
  "required": ["product", "price", "in_stock"]
}

这个 Schema 的意思是:输出必须是一个对象,包含字符串类型的 product、非负数类型的 price、布尔类型的 in_stock,以及一个可选的字符串数组 categories。任何不满足这些条件的输出都会被拒绝。

Schema 还能处理更棘手的情况:嵌套对象、带有类型约束的数组项、枚举(把字符串限制在固定取值集合中)、模式匹配(对字符串使用 regex),以及组合器(oneOfanyOfallOf,用于多态输出)。

Pydantic 模式

在 Python 里,你通常不手写 JSON Schema。你只需要定义一个 Pydantic 模型,它会自动为你生成对应的 Schema。

python
from pydantic import BaseModel

class Product(BaseModel):
    product: str
    price: float
    in_stock: bool
    categories: list[str] = []

它生成的 JSON Schema 与上面的示例等价。Instructor 库(以及 OpenAI 的 SDK)都可以直接接收 Pydantic 模型:传入模型类,返回经过验证的实例。如果 LLM 输出不匹配,Instructor 会自动重试。

函数调用 (Function Calling) / 工具使用 (Tool Use)

这是解决同一问题的另一种接口形式。你不再直接要求模型输出 JSON,而是定义一组带类型参数的 “tools”(函数)。模型输出的是一次函数调用及其结构化参数。OpenAI 把它叫作 “function calling”,Anthropic 把它叫作 “tool use”。最终结果是一样的:你拿到结构化数据。

mermaid
graph TD
    subgraph ToolUse["工具使用流程"]
        U["用户:从这段评论文本中\n提取产品信息"] --> M["模型处理输入"]
        M --> TC["工具调用:\nextract_product(\n  product='Sony WH-1000XM5',\n  price=348.00,\n  in_stock=true\n)"]
        TC --> V["根据函数 Schema\n进行验证"]
        V --> R["结构化结果:\n{product, price, in_stock}"]
    end

    style U fill:#1a1a2e,stroke:#0f3460,color:#fff
    style TC fill:#1a1a2e,stroke:#e94560,color:#fff
    style V fill:#1a1a2e,stroke:#ffa500,color:#fff
    style R fill:#1a1a2e,stroke:#51cf66,color:#fff

当模型不仅要填写参数,还需要先判断“该调用哪个函数”时,tool use 会更合适。如果你手头有 10 种不同的抽取 Schema,而模型必须根据输入挑出正确的一种,那么 tool use 同时解决了 Schema 选择和结构化输出两个问题。

常见失败模式

即使有 Schema 强制约束,结构化输出依然会以一些微妙的方式失败。

幻觉值 (Hallucinated values):输出符合 Schema,但里面的数据是编造的。比如文本明明写的是 $348,模型却输出 {"price": 299.99}。Schema 验证抓不住这种问题——类型是对的,值却是错的。

枚举混淆 (Enum confusion):你把字段限制为 ["in_stock", "out_of_stock", "preorder"],模型却输出了 "available"——语义上没错,但不在允许集合里。好的约束解码可以防止这种情况;基于提示词的方法做不到。

嵌套对象深度:Schema 嵌套层级很深(4 层以上)时,错误会明显增多。每多一层嵌套,模型就多一个可能丢失结构感的位置。

数组长度:模型可能在数组里生成过多或过少的元素。Schema 支持 minItemsmaxItems,但不是所有提供商都会在解码层面真正强制执行它们。

可选字段缺失:某些字段在技术上是 optional,但在你的业务语义里却非常重要,模型可能会把它们省略。对于这种字段,即使数据有时缺失,也应当在 Schema 里把它们标成 required——强制模型显式输出 null

动手构建

第 1 步:JSON Schema 验证器

从零实现一个验证器,用来检查 Python 对象是否符合 JSON Schema。这就是输出侧用来验证合规性的那一层。

python
import json

def validate_schema(data, schema):
    errors = []
    _validate(data, schema, "", errors)
    return errors

def _validate(data, schema, path, errors):
    schema_type = schema.get("type")

    if schema_type == "object":
        if not isinstance(data, dict):
            errors.append(f"{path}: expected object, got {type(data).__name__}")
            return
        for key in schema.get("required", []):
            if key not in data:
                errors.append(f"{path}.{key}: required field missing")
        properties = schema.get("properties", {})
        for key, value in data.items():
            if key in properties:
                _validate(value, properties[key], f"{path}.{key}", errors)

    elif schema_type == "array":
        if not isinstance(data, list):
            errors.append(f"{path}: expected array, got {type(data).__name__}")
            return
        min_items = schema.get("minItems", 0)
        max_items = schema.get("maxItems", float("inf"))
        if len(data) < min_items:
            errors.append(f"{path}: array has {len(data)} items, minimum is {min_items}")
        if len(data) > max_items:
            errors.append(f"{path}: array has {len(data)} items, maximum is {max_items}")
        items_schema = schema.get("items", {})
        for i, item in enumerate(data):
            _validate(item, items_schema, f"{path}[{i}]", errors)

    elif schema_type == "string":
        if not isinstance(data, str):
            errors.append(f"{path}: expected string, got {type(data).__name__}")
            return
        enum_values = schema.get("enum")
        if enum_values and data not in enum_values:
            errors.append(f"{path}: '{data}' not in allowed values {enum_values}")

    elif schema_type == "number":
        if not isinstance(data, (int, float)):
            errors.append(f"{path}: expected number, got {type(data).__name__}")
            return
        minimum = schema.get("minimum")
        maximum = schema.get("maximum")
        if minimum is not None and data < minimum:
            errors.append(f"{path}: {data} is less than minimum {minimum}")
        if maximum is not None and data > maximum:
            errors.append(f"{path}: {data} is greater than maximum {maximum}")

    elif schema_type == "boolean":
        if not isinstance(data, bool):
            errors.append(f"{path}: expected boolean, got {type(data).__name__}")

    elif schema_type == "integer":
        if not isinstance(data, int) or isinstance(data, bool):
            errors.append(f"{path}: expected integer, got {type(data).__name__}")

第 2 步:Pydantic 风格的 Model 到 Schema 转换

实现一个最小版的类到 Schema 转换器。定义一个 Python 类,然后自动生成它的 JSON Schema。

python
class SchemaField:
    def __init__(self, field_type, required=True, default=None, enum=None, minimum=None, maximum=None):
        self.field_type = field_type
        self.required = required
        self.default = default
        self.enum = enum
        self.minimum = minimum
        self.maximum = maximum

def python_type_to_schema(field):
    type_map = {
        str: "string",
        int: "integer",
        float: "number",
        bool: "boolean",
    }

    schema = {}

    if field.field_type in type_map:
        schema["type"] = type_map[field.field_type]
    elif field.field_type == list:
        schema["type"] = "array"
        schema["items"] = {"type": "string"}
    elif isinstance(field.field_type, dict):
        schema = field.field_type

    if field.enum:
        schema["enum"] = field.enum
    if field.minimum is not None:
        schema["minimum"] = field.minimum
    if field.maximum is not None:
        schema["maximum"] = field.maximum

    return schema

def model_to_schema(name, fields):
    properties = {}
    required = []

    for field_name, field in fields.items():
        properties[field_name] = python_type_to_schema(field)
        if field.required:
            required.append(field_name)

    return {
        "type": "object",
        "properties": properties,
        "required": required,
    }

第 3 步:约束 token 过滤器

模拟约束解码。给定一段不完整的 JSON 字符串和一个 Schema,判断在当前位置哪些 token 类别是合法的。

python
def next_valid_tokens(partial_json, schema):
    stripped = partial_json.strip()

    if not stripped:
        return ["{"]

    try:
        json.loads(stripped)
        return ["<EOS>"]
    except json.JSONDecodeError:
        pass

    last_char = stripped[-1] if stripped else ""

    if last_char == "{":
        return ['"', "}"]
    elif last_char == '"':
        if stripped.endswith('":'):
            return ['"', "0-9", "true", "false", "null", "[", "{"]
        return ["a-z", '"']
    elif last_char == ":":
        return [" ", '"', "0-9", "true", "false", "null", "[", "{"]
    elif last_char == ",":
        return [" ", '"', "{", "["]
    elif last_char in "0123456789":
        return ["0-9", ".", ",", "}", "]"]
    elif last_char == "}":
        return [",", "}", "]", "<EOS>"]
    elif last_char == "]":
        return [",", "}", "<EOS>"]
    elif last_char == "[":
        return ['"', "0-9", "true", "false", "null", "{", "[", "]"]
    else:
        return ["any"]

def demonstrate_constrained_decoding():
    partial_states = [
        '',
        '{',
        '{"product"',
        '{"product":',
        '{"product": "Sony"',
        '{"product": "Sony",',
        '{"product": "Sony", "price":',
        '{"product": "Sony", "price": 348',
        '{"product": "Sony", "price": 348}',
    ]

    print(f"{'Partial JSON':<45} {'Valid Next Tokens'}")
    print("-" * 80)
    for state in partial_states:
        valid = next_valid_tokens(state, {})
        display = state if state else "(empty)"
        print(f"{display:<45} {valid}")

第 4 步:抽取流水线

把前面的内容整合成一条完整的抽取流水线:定义 Schema,模拟 LLM 生成结构化输出,验证输出,并处理重试。

python
def simulate_llm_extraction(text, schema, attempt=0):
    if "headphones" in text.lower() or "sony" in text.lower():
        if attempt == 0:
            return '{"product": "Sony WH-1000XM5", "price": 348.00, "in_stock": true, "categories": ["audio", "headphones"]}'
        return '{"product": "Sony WH-1000XM5", "price": 348.00, "in_stock": true}'

    if "laptop" in text.lower():
        return '{"product": "MacBook Pro 16", "price": 2499.00, "in_stock": false, "categories": ["computers"]}'

    return '{"product": "Unknown", "price": 0, "in_stock": false}'

def extract_with_retry(text, schema, max_retries=3):
    for attempt in range(max_retries):
        raw = simulate_llm_extraction(text, schema, attempt)

        try:
            data = json.loads(raw)
        except json.JSONDecodeError as e:
            print(f"  Attempt {attempt + 1}: JSON parse error -- {e}")
            continue

        errors = validate_schema(data, schema)
        if not errors:
            return data

        print(f"  Attempt {attempt + 1}: Schema validation errors -- {errors}")

    return None

product_schema = {
    "type": "object",
    "properties": {
        "product": {"type": "string"},
        "price": {"type": "number", "minimum": 0},
        "in_stock": {"type": "boolean"},
        "categories": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["product", "price", "in_stock"],
}

第 5 步:运行完整流水线

python
def run_demo():
    print("=" * 60)
    print("  Structured Output Pipeline Demo")
    print("=" * 60)

    print("\n--- Schema Definition ---")
    product_fields = {
        "product": SchemaField(str),
        "price": SchemaField(float, minimum=0),
        "in_stock": SchemaField(bool),
        "categories": SchemaField(list, required=False),
    }
    generated_schema = model_to_schema("Product", product_fields)
    print(json.dumps(generated_schema, indent=2))

    print("\n--- Schema Validation ---")
    test_cases = [
        ({"product": "Test", "price": 10.0, "in_stock": True}, "Valid object"),
        ({"product": "Test", "price": -5.0, "in_stock": True}, "Negative price"),
        ({"product": "Test", "in_stock": True}, "Missing price"),
        ({"product": "Test", "price": "ten", "in_stock": True}, "String as price"),
        ("not an object", "String instead of object"),
    ]

    for data, label in test_cases:
        errors = validate_schema(data, product_schema)
        status = "PASS" if not errors else f"FAIL: {errors}"
        print(f"  {label}: {status}")

    print("\n--- Constrained Decoding Simulation ---")
    demonstrate_constrained_decoding()

    print("\n--- Extraction Pipeline ---")
    texts = [
        "The Sony WH-1000XM5 headphones are priced at $348 and currently available.",
        "The new MacBook Pro 16-inch laptop costs $2499 but is sold out.",
        "This is a random sentence with no product info.",
    ]

    for text in texts:
        print(f"\n  Input: {text[:60]}...")
        result = extract_with_retry(text, product_schema)
        if result:
            print(f"  Output: {json.dumps(result)}")
        else:
            print(f"  Output: FAILED after retries")

投入使用

OpenAI Structured Outputs

python
# from openai import OpenAI
# from pydantic import BaseModel
#
# client = OpenAI()
#
# class Product(BaseModel):
#     product: str
#     price: float
#     in_stock: bool
#
# response = client.beta.chat.completions.parse(
#     model="gpt-5-mini",
#     messages=[
#         {"role": "system", "content": "Extract product information."},
#         {"role": "user", "content": "Sony WH-1000XM5, $348, in stock"},
#     ],
#     response_format=Product,
# )
#
# product = response.choices[0].message.parsed
# print(product.product, product.price, product.in_stock)

OpenAI 的 structured output 模式在内部使用了约束解码。模型生成的每一个 token 都保证最终输出符合 Pydantic Schema。无需重试,也无需额外验证。约束已经直接嵌入了解码过程。

Anthropic Tool Use

python
# import anthropic
#
# client = anthropic.Anthropic()
#
# response = client.messages.create(
#     model="claude-opus-4-7",
#     max_tokens=1024,
#     tools=[{
#         "name": "extract_product",
#         "description": "Extract product information from text",
#         "input_schema": {
#             "type": "object",
#             "properties": {
#                 "product": {"type": "string"},
#                 "price": {"type": "number"},
#                 "in_stock": {"type": "boolean"},
#             },
#             "required": ["product", "price", "in_stock"],
#         },
#     }],
#     messages=[{"role": "user", "content": "Extract: Sony WH-1000XM5, $348, in stock"}],
# )

Anthropic 通过 tool use 来实现结构化输出。模型会发出一次带结构化参数的工具调用,并保证这些参数符合 input_schema。结果相同,只是 API 形式不同。

Instructor Library

python
# pip install instructor
# import instructor
# from openai import OpenAI
# from pydantic import BaseModel
#
# client = instructor.from_openai(OpenAI())
#
# class Product(BaseModel):
#     product: str
#     price: float
#     in_stock: bool
#
# product = client.chat.completions.create(
#     model="gpt-5-mini",
#     response_model=Product,
#     messages=[{"role": "user", "content": "Sony WH-1000XM5, $348, in stock"}],
# )

Instructor 可以包装任意 LLM 客户端,并在其上增加“验证失败自动重试”的能力。如果第一次结果没有通过验证,它会把错误信息作为上下文发回给模型,并要求模型修正输出。它适用于任何模型提供商,不只是 OpenAI。

交付

本课会产出 outputs/prompt-structured-extractor.md——一个可复用的提示词模板,只要给它一个 Schema 定义和一段非结构化文本,它就能抽取并返回经过验证的 JSON。

它还会产出 outputs/skill-structured-outputs.md——一个决策框架,帮助你根据模型提供商、可靠性要求以及 Schema 复杂度,选择合适的结构化输出策略。

练习

  1. 扩展这个 Schema 验证器,使其支持 oneOf(数据必须且只能匹配多个 Schema 中的一个)。这能处理多态输出——例如某个字段既可能是 Product,也可能是 Service,两者结构不同。

  2. 构建一个 “schema diff” 工具,对比两个 Schema,并识别破坏性变更(移除 required 字段、修改类型)与非破坏性变更(新增 optional 字段、放宽约束)。这对于在生产环境中给抽取 Schema 做版本管理至关重要。

  3. 实现一个更接近真实情况的约束解码模拟器。给定 JSON Schema 和一个包含 100 个 token 的词表(字母、数字、标点、关键字),逐步模拟生成过程,在每个位置屏蔽非法 token。统计每一步中词表里有多少比例是合法的。

  4. 构建一套抽取评测集。创建 50 条产品描述,并手工标注对应的 JSON 输出。让你的抽取流水线在这 50 条数据上全部跑一遍,测量 exact match、字段级准确率,以及类型合规率。找出最难正确抽取的字段。

  5. 给抽取流水线增加置信度分数 (confidence scores)。对于每个抽取出的字段,估计模型的置信度(可以基于 token probabilities,或者将抽取运行 3 次并比较一致性)。把低置信度字段标记出来,交给人工复核。

关键术语

术语人们常说的话实际含义
JSON mode“返回 JSON”一个 API 开关,保证输出在语法上是合法 JSON,但不强制符合任何特定 Schema
Structured output“类型化 JSON”与特定 JSON Schema 匹配的输出,具备正确的键、类型和约束
Constrained decoding“引导式生成”在每个 token 位置屏蔽会导致非法输出的 token——保证 100% 符合 Schema
JSON Schema“JSON 模板”一种声明式语言,用于描述 JSON 数据的结构、类型和约束(OpenAPI、JSON Forms 等都在使用)
Pydantic“Python dataclasses+”一个 Python 库,用于定义带类型验证的数据模型;FastAPI 和 Instructor 都会用它生成 JSON Schema
Function calling“Tool use”LLM 输出的是结构化函数调用(名称 + 类型化参数),而不是自由文本——OpenAI 和 Anthropic 都支持
Instructor“面向 LLM 的 Pydantic”一个 Python 库,用来包装 LLM 客户端,使其返回经过验证的 Pydantic 实例,并在验证失败时自动重试
Token masking“过滤词表”在生成过程中把特定 token 的概率设为 0,从而让模型无法输出它们
Schema compliance“形状匹配”输出包含全部 required 字段、类型正确、取值满足约束,且没有额外不允许的字段
Retry loop“一直重试直到成功”把验证错误发回给模型,并要求其修正输出——Instructor 会自动完成这件事,并且支持可配置的最大重试次数

延伸阅读