结构化输出:JSON、模式校验与约束解码 本节摘要:你的 LLM 返回的是字符串,你的应用要的是 JSON——这道鸿沟搞垮过的生产系统,比任何模型幻觉都多。结构化输出是自然语言与类型化数据之间的桥梁:搞对了,你的 LLM 就是一个可靠的 API;搞错了,你凌晨三点在用正则解析自由文本。本节带你吃透结构化输出的完整光谱:从「请在 JSON 中回答」这种提示法(约 90% 可靠),到 JSON 模式(保证语法合法),再到模式模式(Schema Mode,保证字段类型合规),最后到约束解码(Constrained Decoding,在每个 token 位置屏蔽非法 token,做到 100% 合规)。
本节摘要:你的 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 层面(OpenAIresponse_format、Anthropic 工具使用、Instructor);解码器层面的理论(FSM/CFG、logit 处理器、Outlines、XGrammar)见原课程 Phase 5·20。
阅读完本节,你应当能够:
你让 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 必须是数字、引号(字符串)、null、true、false 或负号,别的都会产出非法 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 是你告诉模型(或校验层)输出形状的方式。每个主流结构化输出系统都用它。
{ "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 处理多态输出)。
在 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 层以上)错误更多,每多一层嵌套就是模型多一个丢结构的地方。
数组长度:数组里项数可能过多或过少。模式支持 minItems、maxItems,但不是所有厂商都在解码层强制。
可选字段遗漏:模型省略了技术上可选、但语义上重要的字段。即便数据有时缺,也在模式里设成必填——逼模型显式产出 null。
从零写一个校验器,检查 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 同理
写一个最小化的「类到模式」转换器:定义 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}
模拟约束解码。给定一段部分 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。
把一切组合成抽取管线:定义模式、模拟 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 库自动做的事。
# 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 模式的输出,无需重试、无需校验,约束烤进了解码过程。
# 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 表面不同。
# 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 即可投产。
支持 oneOf:扩展模式校验器,支持 oneOf(数据必须精确匹配几个模式之一)。这处理多态输出——比如一个字段可以是 Product 或 Service 对象,形状不同。
模式 diff 工具:写一个工具比较两个模式,识别破坏性变更(删了必填字段、改了类型)与非破坏性变更(加了可选字段、放宽约束)。这对生产环境下的抽取模式版本管理至关重要。
更真实的约束解码模拟器:给定 JSON Schema 与一个 100 token 的词表(字母、数字、标点、关键字),逐步走完生成,在每个位置屏蔽非法 token。测每一步有多少比例的词表是合法的。
抽取评估套件:造 50 条产品描述,手工标注 JSON 输出。跑你的抽取管线,测精确匹配率、字段级准确率、类型合规率,找出哪些字段最难抽对。
置信度分数:给抽取管线加置信度——每个字段估计模型有多确定(基于 token 概率,或跑 3 次看一致性),把低置信字段标出来送人工复核。
null)。下一节,我们将进入「嵌入与向量表示」——把文本、图像变成高维空间里的向量,这是检索、聚类、推荐的数学地基,也是 RAG 的第一块砖。