这一课讲一个我在实际项目中反复踩坑后才彻底搞明白的东西:结构化输出转换器。
如果只是做聊天机器人、写文案助手,你可能永远不需要关心模型输出的是不是合法 JSON。但只要你的应用开始涉及"数据抽取""表单自动填充""调用下游接口""写数据库",你迟早会遇到同一个问题:模型回答得头头是道,但你的代码拿到的是一坨措辞华丽、格式随缘的字符串。我最早做简历解析功能时,为了让模型输出的 JSON 能通过 json.loads,正则工具、字符串截取、逐级 try-except 全用上了,还是挡不住某天模型在 JSON 末尾多了一句"希望这些信息能帮到你!"。
从那以后我就明白,LLM 应用要真正变成产品,必须在"模型输出"和"业务代码"之间加一道关卡,这就是结构化输出转换器。这篇文章我会从最底层的原理讲起,然后给出一套可以直接抄作业的实现方案,再把我实际踩过的坑、排查链路的完整过程都铺开讲,最后聊到校验、重试和流式处理。内容不挑模型,OpenAI 的 API、各类兼容平台、本地开源模型都有对应的做法。
1. 为什么 LLM 应用的第一道坎是"非结构化输出"
1.1 手工解析模型输出的崩溃瞬间
先说几个我真实遇到的回传样例,你们感受一下:
- 正常 JSON 后面跟了一句总结:"……
}希望以上信息能帮到您,如有问题欢迎随时咨询!" - 模型把 JSON 包在 Markdown 代码块里,返回内容是
```json\n{...}\n``` - 该是数组的字段返回了单个对象,该是整数的字段返回了
"二十五"这种中文大写 - 字段名被模型擅自翻译成了中文,
name变成"姓名",tags变成"标签"
早期我做抽取类功能,代码长这样:
python复制import json
import re
def extract_json(text: str):
# 先尝试直接解析
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 去掉 markdown 代码块
pattern = r'```(?:json)?\s*(.*?)\s*```'
match = re.search(pattern, text, re.DOTALL)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# 去掉末尾多余的说明文字
for end_char in ['}', ']', '"']:
idx = text.rfind(end_char)
if idx != -1:
try:
return json.loads(text[:idx+1])
except json.JSONDecodeError:
continue
return None
这个函数在一段时间内确实能用,因为我在不断"打补丁":遇到一种新格式问题,就加一段新的清洗逻辑。但问题在于,这种做法是典型的"输入不可控,输出靠猜"。今天能跑通,明天换个模型版本可能就全线崩盘。后来我把这套正则逻辑全删了,这个决定现在看来非常正确。
1.2 重新定义结构化输出转换器
很多人一听到"结构化输出转换器",第一反应是"不就是 JSON Mode 吗"。其实不是。它应该是一个更通用的组件:负责把 LLM 的自由文本输出,转换为符合目标 schema 的结构化数据(通常是 JSON,也可以是 YAML、XML 或自定义格式),并且在转换过程中保证字段齐全、类型正确、枚举合法。
实现方式有两条路线:
- 生成侧约束:在模型生成 token 时直接限定候选范围,让模型根本没机会输出非法 JSON。Function Calling、JSON Mode、GBNF grammar、outlines 库都属于这一类。
- 输出侧转换:模型已经生成完文本,再用解析器 + 校验器把文本转成目标结构。这一步的典型工具是 Pydantic、jsonschema、各类自定义解析器。
真正可靠的生产系统,两条路线都要。生成侧约束大幅降低非法输出的概率,输出侧转换兜住剩余边角料,再配合重试机制把失败率打到极低。这就像一个"同声传译加校对员"的组合:模型负责说人话,转换器负责翻译成机器能直接消费的规范格式,校对员最后核对一遍有没有漏译、错译。
1.3 不是所有场景都需要它
我得先说句公道话,结构化输出不是银弹,别滥用。
需要结构化输出的场景非常明确:下游要入库、要调 API、要渲染表单、要自动化执行流程。比如从合同里抽取甲方乙方和金额,从客服对话里抽用户意图和实体,从简历里抽教育经历和工作经历。这些场景下,数据质量的容错率非常低,一个字段错位可能直接导致下游业务故障。
不需要的场景也很明确:纯聊天、纯内容生成、给人读的文案总结。这种时候硬套 JSON schema 反而让模型束手束脚,回答变得生硬。简单说,结构化输出解决的是"机器可消费"的问题,如果消费方是人,就别折腾了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 结构化输出转换器的底层原理:从"事后解析"到"生成时约束"
2.1 为什么事后解析永远不靠谱
上一节那个不断打补丁的 extract_json 函数,本质上就是纯事后解析。它最大的问题有三个:
第一,它只能处理"格式"问题,处理不了"内容"问题。比如模型输出 {"age": "二十五"},这是个合法的 JSON,解析能成功,但 age 字段类型是错的,你的业务代码拿过去一用就炸。事后解析根本不知道 age 应该是整数。
第二,修复成本随格式复杂度指数上涨。字段嵌套一深,数组套对象,对象再套数组,任何一层格式漂移都需要单独的修复逻辑,代码很快就会膨胀到不可维护。
第三,错误信息价值极低。解析失败时你只知道"这里有个 JSON 解析错误",但完全不知道是哪个位置、因为什么、模型原本想表达什么。没有可操作的反馈,也就没法自动恢复。
所以靠谱的做法是:把约束条件提前到生成阶段,在模型吐字之前就告诉它"这条路径你走不通"。
2.2 Token 级约束的工作原理
大模型生成文本是逐 token 进行的。每一步,模型会根据当前上下文计算下一个 token 的概率分布,然后在这个分布上采样。所谓约束解码,就是在采样这一刻做文章:把不符合目标 schema 的 token 概率直接设为零,相当于给模型划定了一条语法上的合法路线。
举个形象的类比:你用输入法打字,输入"北京欢迎你",候选栏里只会出现与"北京欢迎你"相关的词语,不会出现"好吃"。输入法已经被你的前缀"约束"住了。token 级约束就是这个逻辑,只不过约束条件不是某个前缀,而是整个 JSON Schema 对应的语法规则。
目前比较成熟的实现方式:
| 方案 | 代表工具 | 约束粒度 | 适用场景 |
|---|---|---|---|
| JSON Mode | OpenAI response_format | 只保证合法 JSON | 云端 API,简单可靠 |
| Function Calling | OpenAI、Anthropic 及众多兼容平台 | 顶层字段级约束 | 云端 API,抽取/触发任务 |
| Grammar 约束解码 | llama.cpp GBNF、outlines、LMQL | token 级精约束 | 本地/私有化模型 |
| 后验校验 | Pydantic、jsonschema | 内容级校验 | 必须配合前几种使用 |
这里有个关键认知:严格程度排序是 Grammar > Function Calling > JSON Mode,但易用性和模型兼容性刚好反过来。严格约束的实现成本高,而且不同模型对约束的"配合度"不一样。你在 OpenAI 上跑得好好的 Function Calling,换到某些开源模型上就开始瞎编字段名。这是很正常的,后面我会专门讲应对办法。
2.3 统一的 schema 语言:JSON Schema 与 Pydantic
不管用哪种约束策略,转换器都要有一个"目标格式"的定义,这个定义就是 schema。业界的事实标准是 JSON Schema,它声明了字段名、类型、是否必填、枚举范围、嵌套结构,还能表达"至少有一个字段满足某条件"这类复杂规则。
Python 生态里最常用的是 Pydantic,因为它做两件事:定义 schema + 运行时校验。这非常方便,一个类解决所有问题。来看一个典型例子:
python复制from pydantic import BaseModel, Field
from typing import List
class Person(BaseModel):
name: str = Field(description="人物姓名")
age: int = Field(default=0, description="年龄,未知时填0")
tags: List[str] = Field(default_factory=list, description="标签列表")
这个类可以直接导出为 JSON Schema,也可以用来校验转换后的数据。在转换器里,它既是"目标蓝图",又是"质量检查器"。
我个人的经验是,schema 尽量用字段描述写清楚业务含义。description 不只是给人看的,很多模型的 Function Calling 实现会把字段描述拼进 prompt,描述写得越清楚,模型越不容易填错。这个细节很多人忽略,实际效果差别很大。
3. 动手实现一个可复用的结构化输出转换器
3.1 最简版本:JSON Mode + Pydantic 校验
先从最简单的链路搭起来。假设我们要从一段文本里抽取出人物信息,用 OpenAI 兼容接口的 JSON Mode,配合 Pydantic 做校验。
python复制from openai import OpenAI
from pydantic import BaseModel, ValidationError
from typing import List
client = OpenAI()
class Person(BaseModel):
name: str
age: int
tags: List[str]
def extract_person_with_json_mode(text: str) -> Person:
response = client.chat.completions.create(
model="gpt-4o-mini",
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": "你是数据抽取助手。请从用户输入中抽取信息,并以JSON格式返回,只返回一个JSON对象。"},
{"role": "user", "content": f"抽取Person信息:{text}"}
]
)
content = response.choices[0].message.content
# 转换器核心:把字符串变成 Person 对象
return Person.model_validate_json(content)
注意两个细节:
- JSON Mode 要求 system 提示里必须出现"JSON"字样,否则 API 会报错。这是它官方的限制。
model_validate_json是 Pydantic v2 的方法,它先内部json.loads再校验,一步到位。
这个版本能跑,但只在格式层面约束"必须是合法 JSON",不约束字段。模型完全可能返回 {"姓名": "张三", "年龄": 18},字段名对不上,校验直接挂。所以这个版本适合字段少、提示词控制力强的场景。
3.2 更可靠的方案:Function Calling 当约束用
我实际生产环境用得最多的是把 Function Calling 当成结构化输出的约束工具。原理不复杂:模型在训练时就学会了"调用工具时要按 parameters schema 来组织参数",这比"看一段提示词然后输出 JSON"的对齐程度高得多。
python复制tools = [
{
"type": "function",
"function": {
"name": "extract_person",
"description": "从给定文本中抽取人物结构化信息",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "人物姓名"},
"age": {"type": "integer", "description": "人物年龄,未知填0"},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "人物标签列表"
}
},
"required": ["name", "tags"]
}
}
}
]
def extract_person_with_function(text: str) -> Person:
response = client.chat.completions.create(
model="gpt-4o-mini",
tools=tools,
tool_choice={"type": "function", "function": {"name": "extract_person"}},
messages=[
{"role": "system", "content": "你是一个信息抽取助手。"},
{"role": "user", "content": text}
]
)
msg = response.choices[0].message
if msg.tool_calls:
arguments = msg.tool_calls[0].function.arguments
return Person.model_validate_json(arguments)
raise ValueError("模型没有调用工具")
关键点在于 tool_choice 强制要求模型必须调用这个函数。这样模型的输出会被工具机制约束在 schema 的框架内,字段名、类型、必填项都有保障。我实测在同样模型、同样数据下,Function Calling 的字段准确率比 JSON Mode 高出不少,尤其是嵌套结构。
3.3 自托管开源模型的路线:Grammar 约束解码
如果你用的是本地部署的开源模型,没有云端 API 的 Function Calling 能力,也有办法,就是在推理引擎层做约束解码。llama.cpp 提供了 GBNF 语法定义,outlines 库把这个过程包装得更友好,支持直接从 JSON Schema 转成约束。
以 llama.cpp 为例,语法文件大致长这样:
code复制root ::= "{" ws "name" ws ":" ws string ws "," ws "age" ws ":" ws int ws "," ws "tags" ws ":" ws array ws "}"
array ::= "[" ws (string (ws "," ws string)*)? ws "]"
string ::= "\"" chars "\""
chars ::= [^"\\] | "\\" escape
实际用的时候,不用手写这么底层的语法,outlines 这类库可以直接接收 JSON Schema:
python复制import outlines
model = outlines.models.llamacpp("path/to/model.gguf")
schema = Person.model_json_schema()
generator = outlines.generate.json(model, schema)
result = generator("从这段文本抽取人物信息:...")
这个路线的最大好处是模型根本没有机会输出非法 token,连"末尾多一句话"这类问题都不可能出现。缺点也很明显:约束解码对推理性能有一定影响,且模型越小,被约束后生成质量越可能下降。在本地模型上做结构化输出时,我建议在 prompt 里给一个 few-shot 示例,能明显改善生成效果。
3.4 封装成统一的转换器接口
上面几套方案各有适用场景。我的习惯是封装成一个统一的转换器,业务代码只面向一个接口,底层策略可以随时切换。
python复制from typing import Type, TypeVar
from pydantic import BaseModel
T = TypeVar("T", bound=BaseModel)
class StructuredOutputConverter:
def __init__(self, client, strategy: str = "function_calling"):
self.client = client
self.strategy = strategy
def convert(self, text: str, schema: Type[T]) -> T:
if self.strategy == "function_calling":
return self._convert_via_function(text, schema)
elif self.strategy == "json_mode":
return self._convert_via_json_mode(text, schema)
else:
raise ValueError(f"Unknown strategy: {self.strategy}")
这个设计背后有一个很重要的原则:schema 即契约。prompt 可以随便改,转换器内部逻辑也可以优化,但对外暴露的契约就是这个 Pydantic 类。只要字段不变,下游代码就不用动。换模型、换策略都是内部实现细节。
4. 实测中的意外现场:解析失败、字段缺失与类型陷阱
4.1 常见失败模式大盘点
做得越多,遇到的坑越千奇百怪。我把高频的失败场景整理成了一个表,方便你们排查时对照:
| 现象 | 根因 | 应对 |
|---|---|---|
| JSON 后面跟了段废话 | 模型没完全遵守只输出JSON指令 | 约束解码/Function Calling,或后处理截断 |
| 字段名被翻译成中文 | prompt 没强调字段名不可变 | schema 描述里强调,或使用英文 key + few-shot |
| 枚举字段返回了近似表述 | 模型"意译"了枚举值 | schema enum + description,转换器做模糊匹配 |
| 数组字段被返回成单个对象 | 模型误解了"列表"粒度 | few-shot 示例,schema 里加 items 描述 |
数字字段出现 "二十五" |
模型中文语境习惯 | 校验失败后重试,或在 prompt 里明确"age 必须是阿拉伯数字" |
| 必填字段直接缺失 | 输入文本里确实没这个信息 | 默认值 + 可选字段设计 |
| 多层嵌套时内层结构错乱 | 模型对深层 JSON 的驾驭能力下降 | 拆成多次抽取,一次只抽一层 |
这表里最容易被轻视的是最后一条。我曾让模型从合同文本里抽取一个三层嵌套的结构,外层 parties,中间 company_info,内层 contact_details。模型返回的结构一次对一次错,极其不稳定。后来我把抽取任务拆成两步,先抽 parties 列表,再对每个 party 单独抽 company_info,成功率立刻上去了。
4.2 一个字段从 str 变 dict 的完整排查链路
这是我觉得最有价值的一段。有一次做商品信息抽取,目标结构里有这样一段:
python复制class PriceInfo(BaseModel):
amount: float
currency: str = "CNY"
class Product(BaseModel):
name: str
price: PriceInfo
模型返回的 price 字段直接变成 "99.9",一个字符串。按类型校验必然失败。我当时没有急着改 prompt,而是按下面这个顺序排查:
第一步,先确认是模型问题还是 schema 问题。把同样的输入换成 JSON Mode 跑,还是错。又换成 Function Calling 跑,price 依然返回字符串。说明模型对"嵌套对象字段"的理解偏差不是由策略引起的,而是它认为价格就应该是一个数字字符串。
第二步,看 prompt 里有没有给模型足够的指引。我的 user 输入是纯商品描述,price 就是一行文字"售价99.9元"。模型很可能把"99.9"直接作为原始文本抽取了。于是我调整了抽取指令,改成"将价格信息解析为对象,包含 amount 数值和 currency 货币单位,amount 必须为数字"。
第三步,在 schema 层面补充示例。我在 PriceInfo 字段的 description 里加了一句"该字段是对象,不是字符串,示例:{'amount': 99.9, 'currency': 'CNY'}"。
改完之后重新跑,错误率从接近一半降到了个位数。这个案例告诉我们:模型不是故意作对,它只是缺少足够的信息来理解你的 schema 约定。schema 的 description 不是给人看的注释,是给模型看的说明。
4.3 "模型很倔"时的三个应对技巧
有些模型不管你描述写得多清楚,它就是容易在某个字段上犯错。这种情况下三个技巧配合使用:
技巧一:few-shot 示例进 prompt。在 system 里放一个和当前任务高度相似的成功案例。模型是 few-shot learner,一个例子顶得上十行说明。
python复制system_prompt = """
你是商品信息抽取助手。请严格按 schema 抽取,输出JSON。
参考示例:
输入:商品名称为无线鼠标,售价39.9元,颜色黑色。
输出:{"name": "无线鼠标", "price": {"amount": 39.9, "currency": "CNY"}, "color": "黑色"}
"""
技巧二:转换器做一个宽容层。在校验失败时,做有限的类型软转换。比如字符串 "99.9" 转换成 float(99.9),中文数字映射成阿拉伯数字。这个宽容层不要做得太宽,只处理你能明确预期的模式,否则它会掩盖上游问题。
技巧三:校验失败自动重试,把错误信息回传给模型。这个做法非常有效,模型有时候自己就能意识到"哦,JSON 解析失败是因为我多写了个逗号",然后重新输出正确版本。这一招放到下一节详细说。
5. 从"能跑"到"可靠":校验、重试、流式与成本
5.1 双重校验:业务规则是最后一道防线
schema 校验能保证类型正确,但保证不了业务合理。比如抽取出的 age 是 -5,类型上完全合法,业务上明显是错的。所以我在转换器后面还会加一层业务规则校验,定义在 Pydantic 的 model_validator 里:
python复制from pydantic import model_validator
class Person(BaseModel):
name: str
age: int
@model_validator(mode="after")
def check_age(self):
if self.age < 0 or self.age > 150:
raise ValueError("年龄超出合理范围")
return self
这一步的价值在于把"格式问题"和"业务问题"分开处理。格式问题靠转换器,业务问题靠规则。如果业务规则能直接写在 schema 里,重试时错误信息就越精确,模型修正起来也越快。
5.2 失败重试的正确姿势
重试不是简单地把同一请求再发一遍,那样大概率得到一样的错误。正确做法是把上次的失败信息拼到 prompt 里,让模型在"知道自己刚才错在哪"的前提下重新生成。我在项目里实现了这样一个循环:
python复制def convert_with_retry(self, text: str, schema_type: Type[T], max_retries: int = 2) -> T:
last_error = None
for attempt in range(max_retries + 1):
try:
return self.convert(text, schema_type)
except (ValidationError, ValueError) as e:
last_error = str(e)
# 把错误信息注入上下文,让模型自己修正
text = (
f"{text}\n\n注意:上一次抽取结果校验失败,错误信息如下:\n{last_error}\n"
"请确保输出完全符合JSON格式且字段类型正确,不要添加多余文字。"
)
raise RuntimeError(f"重试失败,最后一次错误: {last_error}")
我实测里有个非常印象深刻的案例:模型第一次输出 {"name": "张三", "age": "28"},我把"age 字段期望 integer 但收到 string"这个错误回传之后,第二次重试它自己就纠正成了 28。不重试的话,这笔数据就要靠人工兜底了。
需要注意,重试不是免费的,会增加延迟和 token 消耗。一般我设置最多重试 1~2 次,如果还失败,就落到人工处理队列。
5.3 流式输出场景下的处理策略
如果你在做流式输出,事情会变得稍微复杂。流式输出的本质是模型边生成边推送 token,用户能感受到"打字机"效果。但对结构化输出来说,流式意味着你要在 JSON 还没写完的时候就尝试解析它,这几乎一定会失败。
我的经验是:非必要不要对流式结构化输出做实时解析。等流式结束后再走一次正常转换流程,体验损失不大,逻辑却简单得多。如果你确实需要在流式过程中就渲染部分内容,可以考虑"懒惰解析"——只在到达终止条件时解析一次,中间状态一律不校验。
还有一种折中方案:先流式输出一段"可读的中间摘要"给用户,同时在后台用非流式方式做完整结构提取。这样用户体验和数据结构化两头都保住了,代价就是多花一次请求的成本。
5.4 不同方案的成本延迟与可靠度对比
选型的时候不要只盯准确率,要把成本、延迟、可靠度放在一张表里看:
| 方案 | 相对延迟 | 成本 | 字段可靠度 | 实现复杂度 |
|---|---|---|---|---|
| 纯提示词要求JSON | 低 | 低 | 低 | 极低 |
| JSON Mode | 低 | 低 | 中 | 低 |
| Function Calling | 中 | 中 | 高 | 中 |
| Grammar 约束解码 | 中 | 低(本地模型) | 极高 | 高 |
| 以上 + 校验重试 | 偏高 | 偏高 | 极高 | 中 |
我的选型原则是:核心业务链路(要入库、要付款、要发消息)用 Function Calling + 校验重试;辅助分析链路用 JSON Mode;本地私有化、对成本敏感的批量任务用 Grammar 约束解码。没有万能方案,关键是让转换器适配你的业务风险等级。
最后分享一个非常实用的维护习惯
做结构化输出转换器不是写完就完事了。业务 schema 会变,模型版本会升级,prompt 会调,这些改动都可能让之前稳定跑的功能突然挂掉。我强烈建议你维护一套"黄金样例集":把历史上翻过车的输入输出固化成测试用例,每次改 schema、改 prompt、换模型后,第一件事就是把这套用例跑一遍回归。
我自己的样例集里有一条是那个"希望这些信息能帮到你!"结尾的案例,有一条是字段名被中文化的案例,还有一条是年龄返回中文数字的案例。每次跑回归,看到这些历史坑都还稳稳当当的,心里才踏实。这比任何 fancy 的工具都管用。
