用户在售后窗口里发来一句话:
A1001的退款申请还没处理,麻烦帮我跟进一下,我挺着急。
模型可以回复:“你想跟进A1001的退款进度,而且比较着急。”人读得懂,但程序接下来要创建工单草稿、分配处理队列,还要把订单号显示到对应字段里。此时,知道一句话的意思还不够,程序需要拿到稳定的字段。
结构化输出要解决的,就是让模型按约定返回数据,使程序能够读取和检查结果。这份约定可以说明有哪些字段、每个字段是什么类型、允许哪些值,以及缺少信息时怎么表示。
这篇用同一条售后描述走完一段处理:先看自由文本与JSON各自能做到什么,再定义工单字段,最后用LangChain取得经过校验的对象,交给普通Python决定下一步。这里整理的是用户诉求草稿;订单是否存在、退款是否真的未处理,仍需业务系统核验。
| 要解决的问题 | 本文怎样展开 |
|---|---|
| 为什么“请返回JSON”还不够 | 区分文字、合法JSON和符合字段约定的数据 |
| 字段约定具体怎么写 | 用一个工单类说明类型、枚举、长度和未知值 |
| LangChain封装了哪部分工作 | 对照手工解析与结构化模型调用 |
| 抽取不完整或格式错误怎么办 | 保留原始响应、有限重试,再进入失败分支 |
| 取得对象以后能不能直接执行业务 | 继续核对来源,并与订单事实检查分开 |
面对用户原话,普通文本回答可能是“退款进度,用户很着急”,也可能是“建议交给售后跟进,订单为A1001”。两句话都合理,但如果程序依赖“订单为”后面的文字,稍微换种说法就得重新解析。
一种直接改进是让模型返回JSON。JSON用固定语法表达对象、数组、字符串和数值等内容,Python可以把JSON文本解析成字典、列表等数据。
期望这条输入变成:
{
"category": "退款进度",
"order_id": "A1001",
"urgency": "着急",
"summary": "用户催促跟进退款申请"
}
程序可以读取data["order_id"],不再从一句回复里截取订单号。
但“合法JSON”只是一项语法要求。下面也是合法JSON:
{
"问题": "退款",
"订单": "A1001",
"紧急程度": "特别特别急"
}
它能被解析,却没有程序期待的category和order_id,紧急程度也使用了另一个词。甚至一个JSON数组或字符串,也可以在语法上成立,却完全不符合工单接口。
所以,要分别回答两个问题:这段文字能不能解析成JSON?解析后得到的数据,是否符合我们约定的字段和取值?后者需要一份明确的数据结构约定,通常叫Schema。
| 检查层次 | 要回答什么 | 本例中的失败 |
|---|---|---|
| JSON语法 | 能不能解析 | 缺引号、少括号、前后夹了解释文字 |
| 字段结构 | 有没有所需字段,类型与取值是否符合要求 | 缺少分类,或紧急程度填“特别特别急” |
| 业务与来源 | 数据有没有依据,是否可以用于当前业务 | 订单号格式正确,却不是用户提供的订单 |
结构化输出围绕前两层建立稳定交接;第三层仍要根据真实来源和业务规则继续检查。
我们希望得到一份工单草稿,先控制在四个字段:
| 字段 | 用途 | 约定 |
|---|---|---|
category |
进入哪类处理队列 | 退款进度、退货申请、其他 |
order_id |
关联用户提及的订单 | A加4位数字;没有提供时允许为空 |
urgency |
标记用户是否明确催促 | 普通、着急 |
summary |
在列表中概括诉求 | 1~60个字符,不添加新事实 |
字段少一些,是为了让每个字段都对应一个明确用途。“退款是否到账”不放进这份抽取结构,因为用户原话没有给出可以核验的到账事实。
Python里可以用 Pydantic定义并校验这些字段。它是数据校验库;这里的BaseModel是数据类的基类,与生成文字的大语言模型是两件事。
下面是配套schemas.py中的完整Ticket定义,文件开头导入了Literal、BaseModel、ConfigDict和Field:
class Ticket(BaseModel):
"""从用户原话整理的售后工单草稿,不表示订单事实已核验。"""
model_config = ConfigDict(extra="forbid", strict=True)
category: Literal["退款进度", "退货申请", "其他"] = Field(description="用户主要诉求")
order_id: str | None = Field(
default=None,
pattern=r"^A[0-9]{4}$",
description="只提取原话明确出现的订单号,未提供时为null,不猜测",
)
urgency: Literal["普通", "着急"] = Field(description="仅在用户明确表达着急或催促时填着急")
summary: str = Field(min_length=1, max_length=60, description="概括原话,不添加退款状态等新事实")
Ticket描述一份工单应该长什么样。定义这个类,不会调用模型,也不会创建真实工单。
可以逐项阅读:Literal[...]列出允许的分类值;str | None表示订单号既可以是字符串,也可以是Python空值;pattern限制字符串格式;min_length和max_length检查摘要长度。description解释字段含义,后面会随结构说明一起提供给模型。
extra="forbid"让未约定字段报错。例如模型额外返回一个refund_status="已到账",本例不会静默接收。strict=True使本地类型校验更严格,减少自动类型转换;它与模型服务接口里的严格输出开关不是同一个设置。Pydantic模型、严格模式
还要区分“文字说明”和“可执行约束”:description写“不要添加新事实”,并不能让Pydantic判断摘要是否真实;而max_length=60是本地确实会执行的检查。两者共同使用,但提供的保证不同。
Schema可以进一步表达成 JSON Schema,也就是用JSON描述数据约定。Pydantic通过Ticket.model_json_schema()生成这种表示,框架再根据调用方式将它交给模型服务。它描述的是结构,不是一份已经填好的工单。
| Python里的约定 | JSON Schema里的含义 |
|---|---|
category等字段 |
properties,定义对象属性 |
Literal["普通", "着急"] |
enum,限定候选值 |
| 没有默认值的必填字段 | required,要求字段出现 |
max_length=60 |
maxLength,限制字符串长度 |
| 禁止额外字段 | additionalProperties: false |
模型服务对JSON Schema的支持范围可能不同,服务端能接受哪些约束,要看具体接口;收到结果后仍按本地约定校验。JSON Schema对象说明
先不接模型,假设已经取得一段JSON文本。可以用标准库解析,再让Pydantic检查:
def parse_ticket(raw_text):
data = json.loads(raw_text)
return Ticket.model_validate(data)
这段函数来自validation_demo.py,文件导入了json和Ticket。传入前面的工单JSON后,返回一个Ticket对象,程序可以用ticket.category和ticket.order_id读取字段。
这里的两行检查职责不同:json.loads()把文本解析成Python数据;Ticket.model_validate()检查字段和取值,成功后创建数据对象。对于后者,输入不符合约定时会抛出ValidationError。
运行配套验证实验,可以直接观察这些情况:
| 返回内容 | JSON解析 | Ticket校验 |
|---|---|---|
| 四个字段符合本例约定 | 通过 | 通过 |
少了必填的urgency |
通过 | 拒绝 |
urgency填“非常急” |
通过 | 拒绝 |
summary超过60个字符 |
通过 | 拒绝 |
多出refund_status字段 |
通过 | 拒绝 |
order_id填JSON空值null |
通过,转成None |
通过 |
这些能力普通Python配合校验库就能完成。可是,它们发生在回复到达之后,并没有告诉生成模型应该填哪些字段。
如果只在提示里写“返回JSON”,仍要另外说明字段、取得回复、解析、检查并处理失败。LangChain的结构化输出入口把Schema带进模型调用,再把返回数据接到解析与校验步骤。它复用的是这段调用前后的连接工作。
这些连接也可以用厂商SDK和普通Python完成:按服务接口发送工具结构或原生Schema,再接上解析与校验。LangChain提供的是可复用入口和数据交接,结构化输出并不以使用LangChain为前提。它也不会替我们决定怎样设计工单,或凭空提高模型对每条描述的理解能力。
先给模型准备输入。extract_demo.py里的make_prompt()把固定要求和每次变化的用户原话放在两条消息中:
def make_prompt():
return ChatPromptTemplate.from_messages([
("system", "从用户原话整理售后工单草稿,只抽取诉求,不核定退款事实。"
"订单号未提供时使用JSON null,不生成订单号。{validation_hint}"),
("human", "用户原话:{text}"),
])
其中text是本次原话,validation_hint开始为空,后面只在结构校验失败需要再尝试时加入提醒。用户原话是待分析资料,系统要求说明怎样抽取。
接着,把模型配置成按Ticket返回结果,再和提示模板连接:
def build_chain(model):
extractor = model.with_structured_output(
Ticket, method="function_calling", include_raw=True,
)
return make_prompt() | extractor
这里的model是已经准备好的聊天模型对象。with_structured_output()接收类Ticket,表示“按这份结构填写”;传的不是一份工单实例。method="function_calling"选择本例的工具调用格式,具体过程下一节展开。
include_raw=True让调用后保留三项信息:
| 字段 | 保存什么 | 本例成功时 |
|---|---|---|
raw |
原始模型消息 | AIMessage,带工具调用参数 |
parsed |
解析并校验后的结果 | Ticket对象 |
parsing_error |
解析或校验时捕获的错误 | None |
如果使用本例的Pydantic结构,并关闭include_raw,成功时通常直接返回Ticket对象;打开后,外层变成含上述字段的字典。因此不能把整个结果字典当作工单读取。模型层结构化输出
build_chain()只是配置处理关系。真正运行chain.invoke(...),才会准备提示、调用模型并处理回复。
配套目录已经准备了离线模型替身ScriptedModel。它按预先写好的内容返回一次Ticket工具请求,不理解自然语言,也不请求网络;模板、模型接口、解析器和Pydantic校验使用真实组件。这样可以先看清输入输出,在线效果另行验证。
在配套目录安装依赖后,可用下面的调用观察结果:
from extract_demo import build_chain
from fixtures import DEMO_TEXT, GOOD_ARGS, ScriptedModel, tool_reply
model = ScriptedModel(responses=[tool_reply(GOOD_ARGS)])
chain = build_chain(model)
result = chain.invoke({"text": DEMO_TEXT, "validation_hint": ""})
print(type(result["parsed"]).__name__)
print(result["parsed"].order_id)
输出是:
Ticket
A1001
这已经完成了从用户原话输入到可读取字段的程序路径。离线响应预先写好,所以这次运行证明的是接口和校验能衔接,不是模型已经学会抽取。
先看本例一次成功调用的内部过程:
模型返回的调用名字是Ticket,参数包括category、order_id等字段。LangChain读取这些参数,再创建Ticket对象。当前路径里,raw.content可以为空,数据在raw.tool_calls中;只打印正文内容,可能误以为模型什么也没返回。
这里借用了工具调用的参数格式,没有执行查单函数。Ticket是提交抽取结果的结构名,不是能读取订单数据库的业务工具。取得Ticket(order_id="A1001", ...),只能说明输出给出了这个订单号,还不能说明系统查过A1001。
实际的结构化输出不只有这一条路径:
| 方式 | 在哪里提出格式要求 | 仍需注意什么 |
|---|---|---|
| 提示要求输出JSON | 提示内容 | 可能附带文字或出现字段漂移,需自行解析校验 |
JSON模式,常见名json_mode |
模型服务要求合法JSON;字段说明仍需提供 | 合法JSON不等于符合Ticket |
工具调用,常见名function_calling |
用工具参数结构表达所需字段 | 需读取工具参数,并处理缺失或不符合约定的结果 |
原生结构化输出,常见名json_schema |
支持该功能的服务接收Schema并施加约束 | 支持的结构子集、拒绝或中断情况仍依赖服务 |
这些名称描述不同的约束与传输方式,不是四种新的模型。method参数也不是所有模型集成都支持同样的值。本文明确选择工具调用路线,换模型时先核对对应集成。方式与支持范围
本地使用Pydantic类时,LangChain会接入相应运行时校验;传入TypedDict或JSON Schema字典的路径,返回字典并不自动具有相同的本地校验保证。配套测试专门验证了“字典能返回,但非法枚举仍需自己拒绝”的情况。需要同等检查时,应补充明确的验证步骤。
现在把原话换成:
退款申请还没处理,我挺着急,帮我跟进一下。
用户仍然要催退款,只是没有订单号。合理的工单可以保留分类和摘要,把order_id写为JSON空值null,再由程序询问订单号。
空值与缺字段是两种不同情况。在Pydantic 2中,下面两个定义就能看出差别,摘自schemas.py:
class RequiredNullable(BaseModel):
order_id: str | None
class OmittableNullable(BaseModel):
order_id: str | None = None
| 输入 | RequiredNullable | OmittableNullable |
|---|---|---|
{"order_id": "A1001"} |
通过 | 通过 |
{"order_id": null} |
通过 | 通过 |
{},省略字段 |
拒绝 | 使用默认值None |
str | None回答“值可以是什么”,= None提供省略字段时的默认值。要求字段必须出现,同时允许值为null,是一种完全合理的接口设计。不应把“必填”直接等同于“必须编一个非空字符串”。某些模型服务的严格Schema也要求字段都出现,再用空值表达未知。Pydantic默认值、JSON Schema的null
本例的Ticket.order_id采用允许省略、默认None的设计,也明确提示没有订单号就使用JSON空值。换到原生严格输出服务时,仍要按照该服务支持的Schema规则适配。
还要区分null与字符串"null"。前者解析后是None;后者是四个字符组成的字符串。若字段只允许str | None而没有更多约束,字符串"null"可能通过类型校验。本例同时规定订单号必须符合A加4位数字,所以"null"、空字符串和“未知”都会被拒绝。
有的接口会把历史数据中的空字符串等归一化为空值。可以这样设计,但要明确转换规则;不要把任何无效订单号都自动改成None,否则模型输出错误会被掩盖成“用户没提供”。当前实现保留错误,交给失败处理。
模型可能返回了不合法的参数,也可能只输出一段文字而没有提供工单。在include_raw=True下,本例会检查两个条件:没有解析错误,而且parsed确实是Ticket对象。
后一个条件不能省略。本地测试中,模型只返回普通文字时,可能得到parsed=None、parsing_error=None;“没有异常”并不能单独代表取得了有效工单。
extract_demo.py使用下面的有限尝试逻辑:
def extract_with_retry(model, text, max_attempts=2):
if max_attempts < 1:
raise ValueError("max_attempts必须至少为1")
chain = build_chain(model)
hint = ""
for attempt in range(1, max_attempts + 1):
result = chain.invoke({"text": text, "validation_hint": hint})
result = {**result, "attempts": attempt}
if result["parsing_error"] is None and isinstance(result["parsed"], Ticket):
return result
hint = "上次未得到有效工单。检查必填字段、分类取值、摘要长度和订单号格式,再提交Ticket数据。"
return result
默认最多尝试两次,包含首次。第一次没得到有效工单,下一次重新从原始用户描述发起请求,加一条字段检查提醒;两次仍失败,就把最后的响应和失败信息交回上层,不制造一份“看起来正常”的空工单。
每次尝试重新准备系统消息与用户原话,没有把上一轮带工具请求的模型消息直接塞回去。若采用继续工具对话的方式修正,则必须保持工具请求与结果的正确配对,不能留下未处理的调用记录。
为什么不直接给整条链套普通重试?因为include_raw=True可以把解析错误放进结果字典,调用本身正常返回。依赖“抛异常才重试”的包装未必会触发。本例显式检查结果,保证停止条件看得见。
这也不是对所有失败都重复生成:
| 失败 | 当前处理或需要的处理 |
|---|---|
| 字段缺失、枚举错误、没有结构结果 | 本例最多两次模型尝试,仍失败则转核对 |
| 凭证无效、服务不支持该调用方式 | 修正配置与能力选择,不能靠重复同一请求解决 |
| 网络或模型执行异常 | 本例向上抛出,由调用方处理;未伪装成解析失败 |
| 订单号格式合格但没有来源 | 后面的来源检查拒绝草稿,不自动重复生成 |
| 用户本来就没提供订单号 | 用合法空值保留缺口,再向用户询问 |
include_raw便于定位错误,但它不是“捕获所有错误”的开关。排查时先区分服务调用失败、回复解析失败,以及业务核对不通过,才能决定应修正哪一步。
拿到Ticket以后,普通Python就可以依据分类和订单号安排下一步。配套next_step()的规则是:其他诉求交人工分拣;退款或退货诉求没有订单号时先追问;有订单号时再进入对应处理队列。
在进入这个判断之前,schemas.py还做一项有限的来源检查:抽取的订单号必须在用户原话中独立出现。例如输出A9999,而原话只有A1001,即使格式完全正确,也拒绝继续生成可用草稿。
下面的prepare_draft()把结构与来源检查接到一起。文件中已定义Ticket、EvidenceMismatch、check_order_evidence()和next_step():
def prepare_draft(envelope, original_text):
ticket = envelope["parsed"]
if envelope["parsing_error"] is not None or not isinstance(ticket, Ticket):
return {"state": "needs_review", "reason": "invalid_structure", "draft": None}
try:
check_order_evidence(ticket, original_text)
except EvidenceMismatch:
return {"state": "needs_review", "reason": "order_not_in_source", "draft": None}
return {"state": "ready", "draft": ticket.model_dump(), "next_step": next_step(ticket)}
成功路径运行完整extract_demo.py,会打印:
{
"state": "ready",
"draft": {
"category": "退款进度",
"order_id": "A1001",
"urgency": "着急",
"summary": "用户催促跟进退款申请"
},
"next_step": "进入退款跟进队列"
}
ready在这里仅表示草稿通过了本例检查,可以供后续分派使用;脚本并未写入工单库或执行退款。如果没有订单号,合法结果仍可保留为草稿,但next_step会变为“请用户补充订单号”。来源或结构不符时,state变为needs_review,draft为null。
整段处理关系是:
订单号出现在原话里,也只说明有文本依据,不证明用户有权访问它。用户可能写错订单号,或提到他人的订单。后续仍需认证、核对归属,并查询实时业务记录。
同样,本例的字段校验无法证明摘要忠实,也无法完整判断“着急”标记是否合理。这些需要用有预期标注的真实问法做评估。若一句话提到多个订单,当前单订单结构也可能丢信息,应先明确拆单或列表设计,再扩展Schema。
结构化输出让字段可被稳定消费,来源与业务检查决定字段能被用于什么动作。两者接起来,程序才有可解释的处理路径。
完整代码在21-structured-output。在该目录打开PowerShell,使用Python 3.12:
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe validation_demo.py
.\.venv\Scripts\python.exe extract_demo.py
.\.venv\Scripts\python.exe agent_demo.py
.\.venv\Scripts\python.exe -m unittest -v
validation_demo.py不调用模型,直接观察JSON与字段检查;extract_demo.py默认使用固定响应,运行模板、结构化接口、校验和草稿判断;agent_demo.py单独对照Agent入口。它们不查询真实订单,也不执行写入。
配套环境固定langchain==1.4.6、langchain-core==1.6.10、langchain-deepseek==1.1.1和pydantic==2.14.1。14项本地检查覆盖Schema传递、缺失值、无效参数、原始响应保留、重试次数、来源拒绝,以及Agent返回字段等行为。
若要使用自己的在线模型,在同一会话设置凭证与支持本例工具调用方式的可用模型名:
$env:DEEPSEEK_API_KEY = "填写自己的密钥"
$env:LC_CHAT_MODEL = "填写账户可用的模型名"
.\.venv\Scripts\python.exe extract_demo.py --live
在线脚本不自动加载.env,最多两次生成尝试,单次请求超时45秒,模型客户端自动重试关闭。它采用DeepSeek集成的非思考模式配置;换供应商或模式时应核对接口支持,不能原样照搬。DeepSeek集成
本次没有使用真实密钥请求线上模型,因此没有把离线结果当作抽取效果验证。在线请求使用自己的服务额度,不要把真实密钥写入代码或Git。
如果已经在使用Agent,结构化输出放在哪里?模型层的入口处理一次生成;Agent层的入口约束一段运行最终返回的数据。后者使用create_agent(response_format=...),结果从structured_response读取,而不是把整个运行状态当作工单。
配套agent_demo.py显式使用ToolStrategy(Ticket, handle_errors=False),通过工具参数承载最终数据,关闭策略内的自动修正,便于单独观察返回结果;模型调用也设有上限。支持原生结构化输出的服务可使用ProviderStrategy,自动选择行为与支持范围则要结合实际模型和框架版本核对。Agent结构化输出
这个Agent实验没有订单业务工具,轨迹最后可以是确认结构结果的ToolMessage。它表示框架接收了工单数据,不表示已创建业务工单。完整入口代码和更详细的返回类型对照放在实现与接口说明。
回到最初的诉求:程序需要的不是从回复中猜出“用户挺着急”,而是拿到可检查的分类、订单号和摘要。Schema把约定写出来,模型按约定填写,框架连接请求与解析,本地校验和业务规则决定怎样继续。这样既能让下游稳定读取,也能在信息不足或结果有误时留下明确出口。
还没有公开评论
欢迎留下第一条想法,评论会在博主审核后显示。