结构化输出:JSON、模式校验与约束解码


文档摘要

结构化输出:JSON、模式校验与约束解码 本节摘要:你的 LLM 返回的是字符串,你的应用要的是 JSON——这道鸿沟搞垮过的生产系统,比任何模型幻觉都多。结构化输出是自然语言与类型化数据之间的桥梁:搞对了,你的 LLM 就是一个可靠的 API;搞错了,你凌晨三点在用正则解析自由文本。本节带你吃透结构化输出的完整光谱:从「请在 JSON 中回答」这种提示法(约 90% 可靠),到 JSON 模式(保证语法合法),再到模式模式(Schema Mode,保证字段类型合规),最后到约束解码(Constrained Decoding,在每个 token 位置屏蔽非法 token,做到 100% 合规)。

结构化输出:JSON、模式校验与约束解码

本节摘要:你的 LLM 返回的是字符串,你的应用要的是 JSON——这道鸿沟搞垮过的生产系统,比任何模型幻觉都多。结构化输出是自然语言与类型化数据之间的桥梁:搞对了,你的 LLM 就是一个可靠的 API;搞错了,你凌晨三点在用正则解析自由文本。本节带你吃透结构化输出的完整光谱:从「请在 JSON 中回答」这种提示法(约 90% 可靠),到 JSON 模式(保证语法合法),再到模式模式(Schema Mode,保证字段类型合规),最后到约束解码(Constrained Decoding,在每个 token 位置屏蔽非法 token,做到 100% 合规)。你会用 Pydantic 建模、手写一个模式校验器与约束 token 过滤器,并用 OpenAI、Anthropic、Instructor 三套 SDK 对比落地。

对应原课程:Phase 11 · Lesson 03 · structured-outputs(原英文 phases/11-llm-engineering/03-structured-outputs/docs/en.md)。本节聚焦生产 SDK 层面(OpenAI response_format、Anthropic 工具使用、Instructor);解码器层面的理论(FSM/CFG、logit 处理器、Outlines、XGrammar)见原课程 Phase 5·20。

学习目标

阅读完本节,你应当能够:

  1. 用 OpenAI 与 Anthropic 的 API 参数实现 JSON 模式与模式约束输出
  2. 搭建一层 Pydantic 校验,拒绝畸形 LLM 输出并把错误反馈回去重试。
  3. 解释约束解码如何在 token 层面强制产出合法 JSON,而无需后处理。
  4. 设计稳健的抽取提示,把非结构化文本可靠地转成类型化数据结构。

一、问题与直觉

你让 LLM「从这段文本里抽取产品名、价格、库存」,它答:

该产品是索尼 WH-1000XM5 头戴耳机,售价 348.00 美元,目前有货。

这是个完全正确的答案,对你的应用也完全没用。你的库存系统要的是 {"product": "Sony WH-1000XM5", "price": 348.00, "in_stock": true}——特定键、特定类型、特定值约束的 JSON 对象,不是一句话。

天真的解法是在提示里加「请用 JSON 回答」。这法子 90% 的时候管用,剩下 10% 模型会把 JSON 包在 markdown 代码围栏里,或加个前缀「这是 JSON:」,或因为提前闭合括号产出语法非法的 JSON。你的 JSON 解析器崩溃,管线中断。你加 try/except 与重试循环,重试有时又给出不同的数据——这下除了解析问题,你还有一致性问题。

这不是提示工程问题,是解码问题。 模型从左到右生成 token,每个位置从 10 万+词表中挑最可能的下一个 token。在大多数位置,这些选项都会产出非法 JSON。若模型刚吐出 {"price":,下一个 token 必须是数字、引号(字符串)、nulltruefalse 或负号,别的都会产出非法 JSON。没有约束时,模型可能挑一个语法上完全合理、但 JSON 语法上灾难性错误的英文单词。

结构化输出光谱

结构化输出有四级控制,逐级更可靠。

提示法(「请用合法 JSON 回答」):无强制,模型通常照办,偶尔不照办。可靠度约 90%。失败模式:markdown 围栏、前缀文本、截断输出、结构错误。

JSON 模式:API 保证输出是合法 JSON。OpenAI 的 response_format: { type: "json_object" } 即此。输出能无错解析,但未必匹配你期望的模式——多键、错类型、缺字段。

模式模式:API 接收一个 JSON Schema,保证输出匹配它。到 2026 年,每家大厂都原生支持:OpenAI 的 response_format: { type: "json_schema", json_schema: {...} }(也作 tool_choice="required")、Anthropic 工具使用的 input_schema、Gemini 的 response_schema + response_mime_type: "application/json"。输出有精确的键、类型、约束。

约束解码:生成时每个 token 位置,解码器把会产出非法输出的所有 token 屏蔽掉。模式要数字、模型要吐字母时,那个 token 的概率被置零——模型只能产出导向合法输出的 token。这正是 OpenAI 结构化输出模式与 Outlines、Guidance 库的底层机制。

JSON Schema:契约语言

JSON 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"] }

这个模式说:输出必须是一个对象,有字符串 product、非负数 price、布尔 in_stock,以及可选的字符串数组 categories。任何不匹配的输出都被拒。

模式能处理硬骨头:嵌套对象、带类型元素的数组、枚举(把字符串限定到特定值)、模式匹配(字符串上的正则)、组合子(oneOf、anyOf、allOf 处理多态输出)。

Pydantic 模式

在 Python 里,你不必手写 JSON Schema——定义一个 Pydantic 模型,它自动生成模式。

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 自动重试。

函数调用 / 工具使用

同一问题的另一种接口。你不让模型直接产 JSON,而是定义带类型参数的「工具」(函数),模型产出一个带结构化参数的函数调用。OpenAI 叫「函数调用」(function calling),Anthropic 叫「工具使用」(tool use),结果一样:结构化数据。

当模型需要选择调用哪个函数而非只填参数时,工具使用更合适。如果你有 10 种抽取模式,模型要按输入挑对的那个,工具使用同时给了你模式选择与结构化输出。

常见失败模式

即便有模式强制,结构化输出仍会在细微处翻车。

幻觉值:输出匹配模式,但含编造数据。文本说 348 美元,模型却给 {"price": 299.99}。模式校验抓不到——类型对,值错。

枚举混淆:你把字段限定到 ["in_stock", "out_of_stock", "preorder"],模型吐 "available"——语义对,但不在允许集。好的约束解码能防,提示法防不了。

嵌套深度:深嵌套模式(4 层以上)错误更多,每多一层嵌套就是模型多一个丢结构的地方。

数组长度:数组里项数可能过多或过少。模式支持 minItemsmaxItems,但不是所有厂商都在解码层强制。

可选字段遗漏:模型省略了技术上可选、但语义上重要的字段。即便数据有时缺,也在模式里设成必填——逼模型显式产出 null

二、从零实现

步骤 1:JSON Schema 校验器

从零写一个校验器,检查 Python 对象是否匹配 JSON Schema。这是输出侧跑的合规验证。

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}: 期望 object,实际 {type(data).__name__}"); return for key in schema.get("required", []): if key not in data: errors.append(f"{path}.{key}: 缺少必填字段") for key, value in data.items(): if key in schema.get("properties", {}): _validate(value, schema["properties"][key], f"{path}.{key}", errors) elif schema_type == "array": if not isinstance(data, list): errors.append(f"{path}: 期望 array,实际 {type(data).__name__}"); return if len(data) < schema.get("minItems", 0): errors.append(f"{path}: 数组 {len(data)} 项,最少 {schema['minItems']}") 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}: 期望 string"); return if schema.get("enum") and data not in schema["enum"]: errors.append(f"{path}: '{data}' 不在允许值 {schema['enum']} 中") elif schema_type == "number": if not isinstance(data, (int, float)): errors.append(f"{path}: 期望 number"); return if "minimum" in schema and data < schema["minimum"]: errors.append(f"{path}: {data} 小于下限 {schema['minimum']}") # boolean / integer 同理

步骤 2:Pydantic 风格的类转模式

写一个最小化的「类到模式」转换器:定义 Python 类,自动生成 JSON Schema。

class SchemaField: def __init__(self, field_type, required=True, enum=None, minimum=None): self.field_type = field_type self.required = required self.enum = enum self.minimum = minimum 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"} if field.enum: schema["enum"] = field.enum if field.minimum is not None: schema["minimum"] = field.minimum return schema def model_to_schema(name, fields): properties, required = {}, [] for fname, field in fields.items(): properties[fname] = python_type_to_schema(field) if field.required: required.append(fname) return {"type": "object", "properties": properties, "required": required}

步骤 3:约束 token 过滤器

模拟约束解码。给定一段部分 JSON 与模式,判断当前位置哪些 token 类别合法。

def next_valid_tokens(partial_json, schema): stripped = partial_json.strip() if not stripped: return ["{"] try: json.loads(stripped) return ["<EOS>"] # 已是完整 JSON,可结束 except json.JSONDecodeError: pass last = stripped[-1] if stripped else "" if last == "{": return ['"', "}"] elif last == ":": return [' ', '"', "0-9", "true", "false", "null", "[", "{"] elif last == ",": return [' ', '"', "{", "["] elif last in "0123456789": return ["0-9", ".", ",", "}", "]"] elif last == "}": return [",", "}", "]", "<EOS>"] # ... 其余分支同理 return ["any"]

这正反映了真实约束解码器的逻辑:每个位置只允许产出导向合法 JSON 的 token。生产级的 Outlines、XGrammar 把 JSON Schema 编译成有限状态机或下推自动机,以约每 token 100 纳秒的速度屏蔽非法 token。

步骤 4:抽取管线

把一切组合成抽取管线:定义模式、模拟 LLM 产出结构化输出、校验、处理重试。

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+1} 次: JSON 解析错误 -- {e}"); continue errors = validate_schema(data, schema) if not errors: return data print(f" 第 {attempt+1} 次: 模式错误 -- {errors}") return None

💡 重试时要把错误反馈给模型:不只是「再试一次」,而是「上次你说 price 是字符串,要 number」。这正是 Instructor 库自动做的事。

三、框架对比

OpenAI 结构化输出

# 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":"抽取产品信息。"}, # {"role":"user","content":"Sony WH-1000XM5, 348 美元, 有货"}], # response_format=Product) # product = response.choices[0].message.parsed

OpenAI 结构化输出模式内部用约束解码:模型生成的每个 token 都保证产出匹配 Pydantic 模式的输出,无需重试、无需校验,约束烤进了解码过程。

Anthropic 工具使用

# import anthropic # client = anthropic.Anthropic() # response = client.messages.create( # model="claude-opus-4-7", max_tokens=1024, # tools=[{"name":"extract_product", # "description":"从文本抽取产品信息", # "input_schema":{"type":"object", # "properties":{"product":{"type":"string"}, # "price":{"type":"number"}, # "in_stock":{"type":"boolean"}}, # "required":["product","price","in_stock"]}}], # messages=[{"role":"user","content":"抽取: Sony WH-1000XM5, 348 美元, 有货"}])

Anthropic 通过工具使用实现结构化输出:模型吐出一个工具调用,参数匹配 input_schema。结果相同,API 表面不同。

Instructor 库

# 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 美元, 有货"}])

Instructor 包装任意 LLM 客户端,加上带校验的自动重试。首次校验失败时,它把错误作为上下文回传模型,让它修。对任何 provider 都适用,不止 OpenAI。

三家对比

特性 OpenAI Anthropic Instructor
强制机制 约束解码(response_format) 工具使用(input_schema) 重试 + Pydantic
token 级保证 否(应用层重试)
跨厂商
重试 内置 内置 自动,可配次数

⚠️ 何时用哪种:能用厂商原生的约束解码就用它(OpenAI 结构化输出、Anthropic 工具使用),100% 合规;需要跨厂商统一接口或用国产开源模型时,用 Instructor 的重试模式兜底。

四、可复用产物

本节产出两个可复用文件(位于原课程 outputs/):

  • prompt-structured-extractor.md:一个可复用提示模板,给定模式定义从任意文本抽取结构化数据。喂给它 JSON Schema 与非结构化文本,返回校验过的 JSON。
  • skill-structured-outputs.md:一个决策框架,按你的 provider、可靠度要求、模式复杂度选合适的结构化输出策略。

Python 代码(code/structured_outputs.py)是独立校验与抽取管线,把 simulate_llm_extraction 换成真实 API 即可投产。

五、练习

  1. 支持 oneOf:扩展模式校验器,支持 oneOf(数据必须精确匹配几个模式之一)。这处理多态输出——比如一个字段可以是 ProductService 对象,形状不同。

  2. 模式 diff 工具:写一个工具比较两个模式,识别破坏性变更(删了必填字段、改了类型)与非破坏性变更(加了可选字段、放宽约束)。这对生产环境下的抽取模式版本管理至关重要。

  3. 更真实的约束解码模拟器:给定 JSON Schema 与一个 100 token 的词表(字母、数字、标点、关键字),逐步走完生成,在每个位置屏蔽非法 token。测每一步有多少比例的词表是合法的。

  4. 抽取评估套件:造 50 条产品描述,手工标注 JSON 输出。跑你的抽取管线,测精确匹配率、字段级准确率、类型合规率,找出哪些字段最难抽对。

  5. 置信度分数:给抽取管线加置信度——每个字段估计模型有多确定(基于 token 概率,或跑 3 次看一致性),把低置信字段标出来送人工复核。

本节要点回顾

  1. 结构化输出是自然语言与类型化数据的桥:LLM 返字符串,应用要 JSON,这道鸿沟比幻觉搞垮过更多系统。
  2. 四级光谱:提示法(约 90%)→ JSON 模式(保证语法)→ 模式模式(保证字段类型)→ 约束解码(token 级 100% 合规)。
  3. JSON Schema 是契约语言:描述对象、数组、枚举、嵌套、组合子;Pydantic 模型自动生成它。
  4. 约束解码在每个 token 位置屏蔽非法 token:模型只能产出导向合法输出的 token;Outlines、XGrammar 把模式编译成 FSM,约 100 纳秒/token。
  5. 函数调用 / 工具使用是同一问题的另一接口:OpenAI 叫函数调用,Anthropic 叫工具使用,都要模型选函数时用它。
  6. 常见失败:幻觉值(类型对值错,模式抓不到)、枚举混淆、深嵌套、数组长度、可选字段遗漏(把语义重要的设必填,逼模型显式 null)。
  7. 重试要反馈错误:不只「再试」,而是「上次 price 是字符串,要 number」——Instructor 自动做这事。
  8. 厂商原生优先:能用 OpenAI 结构化输出、Anthropic 工具使用的约束解码就用,100% 合规;跨厂商或国产开源模型用 Instructor 兜底。
  9. Pydantic 是 Python 侧的利器:定义模型即得模式,Instructor 与 OpenAI SDK 直接接收模型类返回校验实例。
  10. 生产心法:提示法只够原型,生产必须上模式模式或约束解码,否则你在凌晨三点写正则。

下一节,我们将进入「嵌入与向量表示」——把文本、图像变成高维空间里的向量,这是检索、聚类、推荐的数学地基,也是 RAG 的第一块砖。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U