把模型回答交给程序继续处理时,最容易踩的坑是把一段看起来像 JSON 的文本当成真正的结构化数据。
模型可能在 JSON 前后加一段解释,也可能把数字写成带单位的字符串,把缺失字段写成「未知」,甚至在同一个字段里混用多个格式。人工阅读时,这些答案似乎都能理解,程序却会在 json.loads、数据库写入或条件分支处突然失败。
结构化输出解决的不是让模型变得更聪明,而是给输出增加一份可验证的契约。你先定义字段、类型、枚举和嵌套关系,再让 LangChain 把契约转换为模型供应商支持的结构化调用方式,最后把结果解析为 Pydantic 对象或字典。格式校验失败时,程序可以明确报错、重试或转人工,而不是继续传播一份半可信文本。
本文以一个客服工单抽取器为例,完整介绍 with_structured_output、Pydantic 字段设计、可选字段、默认值、枚举、嵌套模型、include_raw=True 和失败处理。示例使用 OpenAI 集成,读者也可以替换为其他支持结构化输出的模型。
本文解决什么,建立一份能被程序消费的工单 Schema,跑通结构化抽取,并区分解析成功、业务正确和事实可信这三个层次。
本文不展开什么,不展开 Agent 的多步骤编排、检索增强和数据库写入实现,本文只把「模型输出如何变成可验证对象」讲清楚。
读完能完成什么,读者可以独立定义 Pydantic 模型,调用 with_structured_output,处理可选字段和解析错误,并为关键字段留下后续校验入口。
本文把「可直接运行的完整示例」和教学用局部片段分开标注。局部片段会复用前文定义,独立复制时请同时带上对应的 Schema、模型和导入。
自然语言和结构化数据的分界
自然语言适合直接展示给人,结构化数据适合被程序消费。两者不是谁替代谁,而是处在流水线的不同位置。
#mermaid-svg-1ts2B8HPxsGK3bPV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1ts2B8HPxsGK3bPV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1ts2B8HPxsGK3bPV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1ts2B8HPxsGK3bPV .error-icon{fill:#552222;}#mermaid-svg-1ts2B8HPxsGK3bPV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1ts2B8HPxsGK3bPV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1ts2B8HPxsGK3bPV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1ts2B8HPxsGK3bPV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1ts2B8HPxsGK3bPV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1ts2B8HPxsGK3bPV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1ts2B8HPxsGK3bPV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1ts2B8HPxsGK3bPV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1ts2B8HPxsGK3bPV .marker.cross{stroke:#333333;}#mermaid-svg-1ts2B8HPxsGK3bPV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1ts2B8HPxsGK3bPV p{margin:0;}#mermaid-svg-1ts2B8HPxsGK3bPV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1ts2B8HPxsGK3bPV .cluster-label text{fill:#333;}#mermaid-svg-1ts2B8HPxsGK3bPV .cluster-label span{color:#333;}#mermaid-svg-1ts2B8HPxsGK3bPV .cluster-label span p{background-color:transparent;}#mermaid-svg-1ts2B8HPxsGK3bPV .label text,#mermaid-svg-1ts2B8HPxsGK3bPV span{fill:#333;color:#333;}#mermaid-svg-1ts2B8HPxsGK3bPV .node rect,#mermaid-svg-1ts2B8HPxsGK3bPV .node circle,#mermaid-svg-1ts2B8HPxsGK3bPV .node ellipse,#mermaid-svg-1ts2B8HPxsGK3bPV .node polygon,#mermaid-svg-1ts2B8HPxsGK3bPV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1ts2B8HPxsGK3bPV .rough-node .label text,#mermaid-svg-1ts2B8HPxsGK3bPV .node .label text,#mermaid-svg-1ts2B8HPxsGK3bPV .image-shape .label,#mermaid-svg-1ts2B8HPxsGK3bPV .icon-shape .label{text-anchor:middle;}#mermaid-svg-1ts2B8HPxsGK3bPV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1ts2B8HPxsGK3bPV .rough-node .label,#mermaid-svg-1ts2B8HPxsGK3bPV .node .label,#mermaid-svg-1ts2B8HPxsGK3bPV .image-shape .label,#mermaid-svg-1ts2B8HPxsGK3bPV .icon-shape .label{text-align:center;}#mermaid-svg-1ts2B8HPxsGK3bPV .node.clickable{cursor:pointer;}#mermaid-svg-1ts2B8HPxsGK3bPV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1ts2B8HPxsGK3bPV .arrowheadPath{fill:#333333;}#mermaid-svg-1ts2B8HPxsGK3bPV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1ts2B8HPxsGK3bPV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1ts2B8HPxsGK3bPV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1ts2B8HPxsGK3bPV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1ts2B8HPxsGK3bPV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1ts2B8HPxsGK3bPV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1ts2B8HPxsGK3bPV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1ts2B8HPxsGK3bPV .cluster text{fill:#333;}#mermaid-svg-1ts2B8HPxsGK3bPV .cluster span{color:#333;}#mermaid-svg-1ts2B8HPxsGK3bPV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1ts2B8HPxsGK3bPV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1ts2B8HPxsGK3bPV rect.text{fill:none;stroke-width:0;}#mermaid-svg-1ts2B8HPxsGK3bPV .icon-shape,#mermaid-svg-1ts2B8HPxsGK3bPV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1ts2B8HPxsGK3bPV .icon-shape p,#mermaid-svg-1ts2B8HPxsGK3bPV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1ts2B8HPxsGK3bPV .icon-shape .label rect,#mermaid-svg-1ts2B8HPxsGK3bPV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1ts2B8HPxsGK3bPV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1ts2B8HPxsGK3bPV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1ts2B8HPxsGK3bPV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 通过
失败
用户文本
聊天模型
原始 AIMessage
协议解析
Pydantic 验证
类型化对象
错误处理与重试
数据库、路由或工作流
面向用户的自然语言回答
结构化输出的目标是让 D 这一层稳定。它不保证事实一定正确,也不保证模型一定理解了用户意图。字段类型正确,只能说明数据符合契约,不能说明抽取内容没有幻觉。因此,格式验证、业务验证和事实核验要分开设计。
为什么不只用 Prompt 加 json.loads
最初的做法通常是这样。
python
prompt = "请只返回 JSON,字段包括 title 和 priority。"
text = model.invoke(prompt).content
data = json.loads(text)
这段代码的问题不在 json.loads,而在它假设模型会永远输出严格 JSON。真实输入包含缺失信息、歧义表达、长文本和特殊字符时,模型可能返回 Markdown 代码块、额外解释、错误类型或缺失字段。你可以不断增加 Prompt 规则和清洗正则,但规则越多,维护成本越高,仍然没有一个明确的类型契约。
with_structured_output 把输出模式作为模型调用的一部分,通常通过供应商原生 JSON Schema、函数调用或工具调用实现。具体方式由模型和集成包决定,调用方拿到的是解析后的对象,而不是自己从文本猜结构。
LangChain 1.x 的安装与初始化
本文按 LangChain 1.x 编写。结构化输出能力依赖模型供应商,某些模型支持原生 JSON Schema,某些模型通过工具调用模拟,老版本或特殊网关可能只支持 JSON mode。请固定依赖后在目标模型上验证。
shell
pip install -U "langchain>=1.0,<2" "langchain-openai>=1.0,<2" "pydantic>=2,<3" python-dotenv
环境变量示例。
dotenv
OPENAI_API_KEY=replace_with_your_key
MODEL_NAME=gpt-4o-mini
密钥只放在运行环境,不能进入文章、代码仓库、Prompt、工具参数和日志。若使用其他供应商,请安装对应集成包,并确认其 with_structured_output 支持的方法和限制。
从一个 Pydantic 模型开始
先定义客服工单需要的字段。字段描述是给模型看的提示,类型和约束是给解析器和业务代码看的契约。
python
from typing import Literal
from pydantic import BaseModel, Field
class Ticket(BaseModel):
"""客服工单的结构化结果。"""
customer_name: str | None = Field(
default=None,
description="客户姓名,原文没有明确提及时填写 null。",
)
category: Literal["物流", "退款", "产品", "账户", "其他"] = Field(
description="工单所属类别,只能选择给出的五个值之一。",
)
urgency: Literal["低", "中", "高"] = Field(
default="中",
description="紧急程度,涉及付款失败或账号无法使用时通常为高。",
)
summary: str = Field(
min_length=5,
max_length=120,
description="用一句中文概括用户需要解决的问题,不要添加原文没有的事实。",
)
needs_human: bool = Field(
description="是否需要转人工,涉及身份核验、争议退款或模型无法确认的信息时为 true。",
)
这里有几个设计决定。
customer_name 是可选字段,因为用户可能只说「订单还没发货」。category 使用 Literal,下游路由器无需处理模型自创的类别。urgency 有默认值,但默认值只处理字段缺失,不应覆盖原文中明确的高风险信号。summary 限制长度,避免把整段聊天复制到数据库。描述里明确写出「不能添加原文没有的事实」,可以减少抽取任务中的自由发挥。
调用 with_structured_output
下面是一份可直接运行的完整示例。它接收一条用户消息,返回 Ticket 对象,并打印类型化字段。
python
import os
from typing import Literal
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
class Ticket(BaseModel):
"""客服工单的结构化结果。"""
customer_name: str | None = Field(
default=None,
description="客户姓名,原文没有明确提及时填写 null。",
)
category: Literal["物流", "退款", "产品", "账户", "其他"] = Field(
description="工单所属类别,只能选择给出的五个值之一。",
)
urgency: Literal["低", "中", "高"] = Field(
default="中",
description="紧急程度,涉及付款失败或账号无法使用时通常为高。",
)
summary: str = Field(
min_length=5,
max_length=120,
description="用一句中文概括用户需要解决的问题,不要添加原文没有的事实。",
)
needs_human: bool = Field(
description="是否需要转人工,涉及身份核验、争议退款或模型无法确认的信息时为 true。",
)
load_dotenv()
def build_ticket_extractor():
"""创建结构化抽取器,模型名称通过环境变量控制。"""
model_name = os.getenv("MODEL_NAME", "gpt-4o-mini")
model = init_chat_model(f"openai:{model_name}", temperature=0)
return model.with_structured_output(Ticket)
def extract_ticket(text: str) -> Ticket:
"""从客服文本抽取并返回经过 Schema 校验的工单。"""
extractor = build_ticket_extractor()
result = extractor.invoke(
"请从下面的客服消息中提取工单信息。只能依据原文,不要补充不存在的事实。\n\n"
f"用户消息\n{text}"
)
if not isinstance(result, Ticket):
raise TypeError(f"模型返回了意外类型 {type(result).__name__}")
return result
if __name__ == "__main__":
ticket = extract_ticket("王丽说订单 2026A019 已经五天没有物流更新,希望尽快处理。")
print(ticket)
print(ticket.category, ticket.urgency, ticket.needs_human)
调用成功后,ticket.category 是受限的字符串字面量,ticket.needs_human 是布尔值,代码不需要再用字符串切片或正则猜测格式。Pydantic 对结果进行实例化和校验,错误会以验证异常的形式暴露。
结构化输出并不要求你把最终回复也变成 JSON。常见做法是先抽取为 Ticket,再由业务代码决定是否写数据库、触发人工队列或调用另一个 Prompt 生成面向用户的解释。
嵌套结构和列表字段
真实业务经常需要层级数据。例如,把联系人和地址分开,把一条工单拆成多个问题项。
python
from typing import Literal
from pydantic import BaseModel, Field
class Contact(BaseModel):
name: str | None = Field(default=None, description="联系人姓名")
phone: str | None = Field(default=None, description="联系人电话,没有则为 null")
class Issue(BaseModel):
title: str = Field(description="问题标题")
evidence: list[str] = Field(
default_factory=list,
description="原文中支持该问题的短句列表,不要编造证据。",
)
class DetailedTicket(BaseModel):
contact: Contact = Field(description="联系人信息")
issues: list[Issue] = Field(description="用户明确提出的问题列表")
category: Literal["物流", "退款", "产品", "账户", "其他"]
嵌套模型的优点是边界清楚,缺点是模型需要同时满足更多约束。层级过深、字段过多或描述互相冲突都会增加失败率。经验上,先保持两到三层以内;如果一个 Schema 已经像数据库完整表结构,通常应该拆成多个抽取步骤或先做候选识别再做细节抽取。
#mermaid-svg-ZhHbc0hK9TMWuLYw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZhHbc0hK9TMWuLYw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZhHbc0hK9TMWuLYw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZhHbc0hK9TMWuLYw .error-icon{fill:#552222;}#mermaid-svg-ZhHbc0hK9TMWuLYw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZhHbc0hK9TMWuLYw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZhHbc0hK9TMWuLYw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZhHbc0hK9TMWuLYw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZhHbc0hK9TMWuLYw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZhHbc0hK9TMWuLYw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZhHbc0hK9TMWuLYw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZhHbc0hK9TMWuLYw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZhHbc0hK9TMWuLYw .marker.cross{stroke:#333333;}#mermaid-svg-ZhHbc0hK9TMWuLYw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZhHbc0hK9TMWuLYw p{margin:0;}#mermaid-svg-ZhHbc0hK9TMWuLYw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZhHbc0hK9TMWuLYw .cluster-label text{fill:#333;}#mermaid-svg-ZhHbc0hK9TMWuLYw .cluster-label span{color:#333;}#mermaid-svg-ZhHbc0hK9TMWuLYw .cluster-label span p{background-color:transparent;}#mermaid-svg-ZhHbc0hK9TMWuLYw .label text,#mermaid-svg-ZhHbc0hK9TMWuLYw span{fill:#333;color:#333;}#mermaid-svg-ZhHbc0hK9TMWuLYw .node rect,#mermaid-svg-ZhHbc0hK9TMWuLYw .node circle,#mermaid-svg-ZhHbc0hK9TMWuLYw .node ellipse,#mermaid-svg-ZhHbc0hK9TMWuLYw .node polygon,#mermaid-svg-ZhHbc0hK9TMWuLYw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZhHbc0hK9TMWuLYw .rough-node .label text,#mermaid-svg-ZhHbc0hK9TMWuLYw .node .label text,#mermaid-svg-ZhHbc0hK9TMWuLYw .image-shape .label,#mermaid-svg-ZhHbc0hK9TMWuLYw .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZhHbc0hK9TMWuLYw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZhHbc0hK9TMWuLYw .rough-node .label,#mermaid-svg-ZhHbc0hK9TMWuLYw .node .label,#mermaid-svg-ZhHbc0hK9TMWuLYw .image-shape .label,#mermaid-svg-ZhHbc0hK9TMWuLYw .icon-shape .label{text-align:center;}#mermaid-svg-ZhHbc0hK9TMWuLYw .node.clickable{cursor:pointer;}#mermaid-svg-ZhHbc0hK9TMWuLYw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZhHbc0hK9TMWuLYw .arrowheadPath{fill:#333333;}#mermaid-svg-ZhHbc0hK9TMWuLYw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZhHbc0hK9TMWuLYw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZhHbc0hK9TMWuLYw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZhHbc0hK9TMWuLYw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZhHbc0hK9TMWuLYw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZhHbc0hK9TMWuLYw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZhHbc0hK9TMWuLYw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZhHbc0hK9TMWuLYw .cluster text{fill:#333;}#mermaid-svg-ZhHbc0hK9TMWuLYw .cluster span{color:#333;}#mermaid-svg-ZhHbc0hK9TMWuLYw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZhHbc0hK9TMWuLYw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZhHbc0hK9TMWuLYw rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZhHbc0hK9TMWuLYw .icon-shape,#mermaid-svg-ZhHbc0hK9TMWuLYw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZhHbc0hK9TMWuLYw .icon-shape p,#mermaid-svg-ZhHbc0hK9TMWuLYw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZhHbc0hK9TMWuLYw .icon-shape .label rect,#mermaid-svg-ZhHbc0hK9TMWuLYw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZhHbc0hK9TMWuLYw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZhHbc0hK9TMWuLYw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZhHbc0hK9TMWuLYw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} DetailedTicket
Contact
Issue 列表
证据短句
固定类别枚举
CRM 写入
问题路由
业务队列
可选字段、默认值和缺失信息
这三个概念经常被混用。
可选字段表示结果允许没有值,通常用 str | None 并设置 default=None。默认值表示字段缺失时由解析模型填充一个预设值。必填字段表示结果必须包含它,无法从原文确定时应让模型返回明确的未知状态,或者让调用失败并进入补问流程。
不要把所有字段都设置成可选。字段越宽松,下游越需要猜测,结构化输出的价值就越低。可以用业务动作反推字段约束,如果没有类别就无法路由,那么类别应为必填;如果没有电话仍然可以先保存工单,电话可以是可选。
供应商对 JSON Schema 中 default 的处理可能不同。有的模型会主动填默认值,有的模型只保证字段类型。关键业务默认策略应在 Python 侧再次落实,不要只依赖模型。
include_raw,调试解析失败
排查结构化输出时,通常既想看解析后的对象,也想看模型原始消息。include_raw=True 会返回一个包含 raw、parsed 和 parsing_error 的结果。
python
import os
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
load_dotenv()
model = init_chat_model(
f"openai:{os.getenv('MODEL_NAME', 'gpt-4o-mini')}",
temperature=0,
)
debug_extractor = model.with_structured_output(Ticket, include_raw=True)
result = debug_extractor.invoke("用户说退款申请被拒绝,想知道原因。")
print("parsed", result["parsed"])
print("parsing_error", result["parsing_error"])
print("raw_type", type(result["raw"]).__name__)
生产日志可以记录错误类型、模型版本、Schema 版本和 Trace ID,但不要原样记录包含个人信息的 raw。对敏感字段应脱敏,或者只保存哈希和字段级错误。
解析失败后的策略不能一概而论。
#mermaid-svg-rNErTtZnAKBoeEWv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rNErTtZnAKBoeEWv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rNErTtZnAKBoeEWv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rNErTtZnAKBoeEWv .error-icon{fill:#552222;}#mermaid-svg-rNErTtZnAKBoeEWv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rNErTtZnAKBoeEWv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rNErTtZnAKBoeEWv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rNErTtZnAKBoeEWv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rNErTtZnAKBoeEWv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rNErTtZnAKBoeEWv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rNErTtZnAKBoeEWv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rNErTtZnAKBoeEWv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rNErTtZnAKBoeEWv .marker.cross{stroke:#333333;}#mermaid-svg-rNErTtZnAKBoeEWv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rNErTtZnAKBoeEWv p{margin:0;}#mermaid-svg-rNErTtZnAKBoeEWv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rNErTtZnAKBoeEWv .cluster-label text{fill:#333;}#mermaid-svg-rNErTtZnAKBoeEWv .cluster-label span{color:#333;}#mermaid-svg-rNErTtZnAKBoeEWv .cluster-label span p{background-color:transparent;}#mermaid-svg-rNErTtZnAKBoeEWv .label text,#mermaid-svg-rNErTtZnAKBoeEWv span{fill:#333;color:#333;}#mermaid-svg-rNErTtZnAKBoeEWv .node rect,#mermaid-svg-rNErTtZnAKBoeEWv .node circle,#mermaid-svg-rNErTtZnAKBoeEWv .node ellipse,#mermaid-svg-rNErTtZnAKBoeEWv .node polygon,#mermaid-svg-rNErTtZnAKBoeEWv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rNErTtZnAKBoeEWv .rough-node .label text,#mermaid-svg-rNErTtZnAKBoeEWv .node .label text,#mermaid-svg-rNErTtZnAKBoeEWv .image-shape .label,#mermaid-svg-rNErTtZnAKBoeEWv .icon-shape .label{text-anchor:middle;}#mermaid-svg-rNErTtZnAKBoeEWv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rNErTtZnAKBoeEWv .rough-node .label,#mermaid-svg-rNErTtZnAKBoeEWv .node .label,#mermaid-svg-rNErTtZnAKBoeEWv .image-shape .label,#mermaid-svg-rNErTtZnAKBoeEWv .icon-shape .label{text-align:center;}#mermaid-svg-rNErTtZnAKBoeEWv .node.clickable{cursor:pointer;}#mermaid-svg-rNErTtZnAKBoeEWv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rNErTtZnAKBoeEWv .arrowheadPath{fill:#333333;}#mermaid-svg-rNErTtZnAKBoeEWv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rNErTtZnAKBoeEWv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rNErTtZnAKBoeEWv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rNErTtZnAKBoeEWv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rNErTtZnAKBoeEWv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rNErTtZnAKBoeEWv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rNErTtZnAKBoeEWv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rNErTtZnAKBoeEWv .cluster text{fill:#333;}#mermaid-svg-rNErTtZnAKBoeEWv .cluster span{color:#333;}#mermaid-svg-rNErTtZnAKBoeEWv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rNErTtZnAKBoeEWv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rNErTtZnAKBoeEWv rect.text{fill:none;stroke-width:0;}#mermaid-svg-rNErTtZnAKBoeEWv .icon-shape,#mermaid-svg-rNErTtZnAKBoeEWv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rNErTtZnAKBoeEWv .icon-shape p,#mermaid-svg-rNErTtZnAKBoeEWv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rNErTtZnAKBoeEWv .icon-shape .label rect,#mermaid-svg-rNErTtZnAKBoeEWv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rNErTtZnAKBoeEWv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rNErTtZnAKBoeEWv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rNErTtZnAKBoeEWv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
参数或格式问题
信息缺失
外部服务或模型故障
是
否
模型响应
结构化解析
校验是否成功
写入类型化结果
错误是否可恢复
带错误摘要重试一次
向用户补问
降级或转人工
重试成功
重试次数应该有限。把完整异常堆栈塞进 Prompt 可能泄露内部信息,也会增加上下文长度。只向模型提供可操作的字段级错误,例如「category 必须是五个枚举值之一」。如果是业务信息缺失,继续重试通常没有意义,应该向用户补问。
什么时候使用输出解析器
JsonOutputParser 和其他输出解析器仍然有价值,尤其是旧模型、只支持普通文本的网关,或者你需要把解析器作为独立 Runnable 插入已有链路时。但在支持结构化调用的现代模型上,优先使用 with_structured_output,因为契约会参与模型请求,而不是只在响应回来后被动清洗。
一个兼容性回退示例。
python
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
parser = JsonOutputParser(pydantic_object=Ticket)
prompt = ChatPromptTemplate.from_messages(
[
("system", "根据用户消息提取工单,{format_instructions}"),
("human", "{text}"),
]
).partial(format_instructions=parser.get_format_instructions())
# 只有在目标模型无法使用结构化调用时,才考虑这条兼容链路。
# chain = prompt | model | parser
解析器无法消除自然语言输出的不确定性。它应该是兼容手段,而不是新项目的默认方案。无论采用哪种方法,下游都应再次做业务校验。
常见错误
字段描述太短
status: str 只告诉模型这是字符串,没有告诉它允许哪些值、空值如何处理和各个值代表什么。对枚举和容易混淆的字段,描述应该包含取值集合、单位和边界。
把结构化等同于事实正确
一个合法的 Ticket 可能包含模型猜出来的客户姓名或错误类别。对关键字段保留原文证据、置信度或人工审核状态,必要时使用检索结果进行事实核验。
嵌套模型层级过深
层级越深,模型需要满足的约束越多。遇到持续失败时,先减少字段和层级,再检查提示词和供应商能力,不要第一时间叠加更多格式指令。
直接把结果写入数据库
Pydantic 校验通过不代表数据库约束、权限和业务状态都满足。写入前仍需检查唯一键、字段脱敏、资源归属和事务边界。
没有处理 None
可选字段返回 None 是正常结果。下游代码应显式处理缺失值,不能直接调用 .strip() 或拼接成用户可见文本。
生产设计建议
把 Schema 当成版本化接口。为每个模型记录 Schema 版本、模型版本、Prompt 版本和解析方法。变更字段前先跑固定样本集,比较字段缺失率、枚举错误率、重试率、延迟和成本。
抽取任务最好保留来源证据。例如 summary 旁边保存原文片段或字符位置,人工审核时可以快速确认模型有没有添加事实。对个人信息、支付信息和内部文档,应在进入模型前做最小化和脱敏处理,Trace 平台也要设置访问权限和保留期限。
结构化输出适合分类、抽取、路由和工作流状态,不适合把整篇开放式回答硬塞进几十个字段。字段设计应服务于下一步动作,而不是追求「一次提取所有信息」。如果某个字段不会被下游使用,优先删除它。
一份独立的验证脚本
以下检查不调用网络,可以放进 CI 验证 Schema 的基本不变量。
python
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class Ticket(BaseModel):
"""用于离线契约测试的客服工单模型。"""
customer_name: str | None = None
category: Literal["物流", "退款", "产品", "账户", "其他"]
urgency: Literal["低", "中", "高"] = "中"
summary: str = Field(min_length=5, max_length=120)
needs_human: bool
def validate_ticket_contract() -> None:
valid = Ticket(
category="物流",
summary="订单长时间没有物流更新",
needs_human=False,
)
assert valid.urgency == "中"
assert valid.customer_name is None
try:
Ticket(
category="不存在的类别",
summary="太短",
needs_human=False,
)
except ValidationError as exc:
print("契约拒绝非法数据", len(exc.errors()))
else:
raise AssertionError("非法类别和过短摘要应该被拒绝")
if __name__ == "__main__":
validate_ticket_contract()
官方文档
本文示例以 LangChain 1.x 为基线。模型供应商对原生 JSON Schema、工具调用、默认值、枚举和嵌套结构的支持并不完全一致,正式上线前应锁定依赖、用目标模型验证真实响应,并为解析失败准备有限重试和人工兜底。