☰
LangChain+Pydantic结构化输出问答器实战:让大模型返回可控JSON
2026/10/8 4:53:30 网站建设 项目流程

1. 为什么我要做这个结构化输出问答器

做Agent开发的朋友大概率都经历过这样一个阶段:一开始用大模型做问答,直接让它输出一段自然语言,看着挺流畅,但一旦要把结果接到下游系统里,麻烦就来了。比如你想让模型从一段用户描述里提取“姓名、电话、意向产品”三个字段,它可能给你返回一段“好的,根据您的描述,这位客户叫张三,电话是138xxxx,对A产品比较感兴趣”——人看着没问题,但代码怎么解析?正则匹配?那维护成本高得离谱,稍微换个说法就崩了。

这就是结构化输出要解决的核心问题。所谓结构化输出,就是让大模型不再返回自由文本,而是返回符合预定义Schema的数据结构,通常是JSON。这样一来,程序可以直接反序列化成对象,字段类型明确,下游逻辑稳定可靠。而Pydantic恰好是Python生态里做数据校验和Schema定义最顺手的工具,LangChain则提供了把Pydantic模型和大模型绑定起来的现成能力。

我这次做的“Agent实践4-结构化输出问答器”,目标很明确:做一个能接收用户自然语言提问、经过Agent处理、最终返回严格结构化结果的问答器。它不是一个玩具Demo,而是要能实际接入业务系统的组件。适合谁看?如果你已经写过基础的LangChain调用,想进一步把输出变得可控、可测试、可集成,那这篇内容应该对你有用。如果你还没接触过Agent,也没关系,我会把关键概念用生活化的方式讲清楚。

先说清楚这个问答器到底能干什么。举个实际场景:用户输入“帮我查一下订单12345的状态”,传统做法是模型返回一段话,你得再写解析逻辑;而结构化输出问答器会直接返回类似{"order_id": "12345", "status": "已发货", "estimated_arrival": "2024-06-01"}这样的对象,你的前端或后端拿到就能直接用。这就是它的价值——把“理解”和“可用”之间的那道鸿沟填平。

2. 整体设计思路与方案选型拆解

2.1 为什么是LangChain加Pydantic这套组合

市面上做结构化输出的方案不止一种。最原始的是在Prompt里写“请以JSON格式返回”,然后手动解析;进阶一点用JSON Mode或Function Calling;再往上就是用框架把Schema和模型绑定。我选LangChain加Pydantic,理由有三条。

第一,Pydantic的Schema定义能力足够强。它支持嵌套模型、字段校验、默认值、类型转换,甚至可以用Field描述字段含义。这些描述会直接进入Prompt,帮助模型理解每个字段该填什么。比如你定义一个age: int = Field(description="用户年龄,整数"),LangChain会把这个描述传给模型,模型填错的概率明显下降。

第二,LangChain的with_structured_output方法把复杂度封装得很好。它底层会根据模型能力自动选择用Function Calling还是JSON Mode,你不需要关心这些细节。我实测下来,用这个方法比手写Prompt加解析器稳定得多,尤其是在字段多、嵌套深的情况下。

第三,可测试性强。Pydantic模型本身就是可实例化、可校验的,你可以直接写单元测试验证模型输出的合法性,这在生产环境里非常重要。我踩过的坑是:早期用纯文本解析,测试用例写了三十多个还是覆盖不全,换成Pydantic之后,校验逻辑由框架保证,测试量直接砍半。

当然,这套方案也有代价。它要求模型本身支持结构化输出能力,太老的模型可能不支持;另外Pydantic的版本要和LangChain匹配,版本冲突是常见问题。这些后面会细说。

2.2 问答器的核心架构分层

我把整个问答器分成四层,这样职责清晰,出问题也好定位。

  • 输入层:接收用户自然语言问题,做基础清洗(去首尾空格、过滤空输入)。
  • Agent层:核心处理层,负责调用大模型,把问题和Schema一起送进去,拿回结构化结果。
  • 校验层:用Pydantic对返回结果做二次校验,确保类型正确、必填字段不缺失。
  • 输出层:把校验通过的对象序列化成JSON返回给调用方,校验失败则走降级逻辑。

这个分层的好处是,每一层都可以独立替换。比如你以后想换模型,只动Agent层;想换校验规则,只动校验层。我在实际项目里就是这么做的,后来从一家模型供应商换到另一家,只改了几行配置。

2.3 结构化输出的两种实现路径对比

LangChain里实现结构化输出主要有两条路,我两种都试过,这里做个对比。

对比维度with_structured_output手写Prompt加OutputParser
实现难度低,一行方法调用高,需自己写解析和容错
稳定性高,底层用Function Calling中,依赖模型遵循指令
灵活性中,受框架约束高,可自定义任意格式
调试难度低,报错信息清晰高,解析失败难定位
适用场景标准结构化需求特殊格式或老模型

我的建议是:除非你有非常特殊的格式需求,否则优先用with_structured_output。我早期为了“完全控制”手写过一套解析器,结果维护了两个月就放弃了,因为模型输出的小变化太多,解析器永远追不上。

3. 核心细节解析与实操要点

3.1 Pydantic模型怎么定义才合理

定义Pydantic模型看着简单,但里面有不少门道。我先给一个实际用的例子,是一个订单查询问答器的Schema。

from pydantic import BaseModel, Field from typing import Optional, List from datetime import date class OrderItem(BaseModel): product_name: str = Field(description="商品名称") quantity: int = Field(description="购买数量,正整数", gt=0) class OrderQuery(BaseModel): order_id: str = Field(description="订单编号,通常是数字字符串") status: str = Field(description="订单状态,如待付款、已发货、已完成") items: List[OrderItem] = Field(description="订单包含的商品列表") estimated_arrival: Optional[date] = Field( default=None, description="预计到达日期,格式YYYY-MM-DD,未知则留空" )

这里有几个关键点。第一,字段描述一定要写清楚。很多人偷懒不写description,结果模型不知道这个字段该填什么,输出质量直线下降。我做过对比测试,加了详细描述的Schema,字段填充准确率能提升20%以上。

第二,善用类型约束。比如quantity用了gt=0,这样模型如果填了0或负数,Pydantic会直接报错,而不是让脏数据流到下游。这比事后校验省事得多。

第三,可选字段用Optional并给默认值。不是所有信息模型都能推断出来,强制要求反而会让它瞎编。给个默认值,模型不确定时留空,比编造一个假数据强。

第四,嵌套模型要控制层级。我建议嵌套不要超过三层,太深了模型容易迷路。如果业务确实复杂,拆成多个问答器分步处理,比一个巨型Schema靠谱。

3.2 把Schema绑定到模型的正确姿势

定义好模型后,下一步是绑定。LangChain的写法很简洁:

from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) structured_llm = llm.with_structured_output(OrderQuery) result = structured_llm.invoke("帮我查订单12345,买了两个苹果,预计明天到")

这里有个容易忽略的点:temperature建议设为0。结构化输出追求的是稳定和准确,不需要创造性。我试过temperature=0.7,同样的输入偶尔会返回不同的字段值,虽然都合法,但一致性差,不适合生产。

另外,with_structured_output默认用的是Function Calling模式。如果你的模型不支持Function Calling,可以加参数method="json_mode"切换。不过json_mode对Prompt的依赖更强,稳定性略差,能用Function Calling就别用json_mode。

还有一个细节:绑定后的structured_llm返回的直接是Pydantic对象,不是字符串。你可以直接访问result.order_id,不用再json.loads。这个设计很贴心,我第一次用时还习惯性地想解析字符串,结果发现已经是对象了。

3.3 Prompt设计里那些不写会踩坑的细节

虽然with_structured_output帮你处理了大部分格式问题,但Prompt本身还是要认真写。我的经验是,Prompt里要包含三部分:角色设定、任务说明、字段补充说明。

角色设定比如“你是一个订单查询助手,负责从用户描述中提取订单信息”。任务说明要明确“如果某个字段用户没有提供,且无法从上下文推断,请留空或使用默认值,不要编造”。这句话很重要,我早期没写,模型经常自己脑补订单状态,导致数据失真。

字段补充说明是给那些Schema描述里说不清楚的字段做额外解释。比如status字段,我会在Prompt里列出所有合法值:“订单状态只能是以下之一:待付款、已发货、已完成、已取消”。这样模型就不会返回“运输中”这种Schema里没定义的词。

提示:Prompt里不要重复Schema已经说清楚的内容,那样会让Prompt臃肿,反而降低模型注意力。只补充Schema表达不了的业务规则。

3.4 校验层为什么不能省

有人会问,既然with_structured_output已经保证了输出符合Schema,为什么还要单独做校验层?我的回答是:框架保证的是“结构合法”,但保证不了“业务合法”。

举个例子,Schema里order_id是字符串,模型返回了“abc123”,结构上没问题,但你的业务系统里订单号必须是纯数字。这种业务规则Pydantic的Field约束能覆盖一部分,但复杂的跨字段校验就得自己写。比如“如果status是已发货,estimated_arrival不能为空”,这种逻辑用Pydantic的model_validator实现最合适。

from pydantic import model_validator class OrderQuery(BaseModel): # ... 字段定义 ... @model_validator(mode='after') def check_shipped_has_arrival(self): if self.status == "已发货" and self.estimated_arrival is None: raise ValueError("已发货订单必须有预计到达日期") return self

这样校验不通过时,你可以捕获异常,走重试或降级逻辑。我在生产环境里就是这么做的,校验失败率大概在3%左右,主要是用户描述太模糊导致的,重试一次基本能解决。

4. 实操过程与核心环节实现

4.1 环境准备与依赖安装

先把环境搭起来。我用的Python版本是3.10,太老的版本Pydantic v2支持不好。

pip install langchain langchain-openai pydantic python-dotenv

这里要特别注意版本。LangChain的版本迭代很快,with_structured_output在不同版本里行为有差异。我建议锁定版本,比如:

pip install langchain==0.2.16 langchain-openai==0.1.23 pydantic==2.8.2

为什么要锁版本?我踩过的坑是:某次没锁版本,自动升级后with_structured_output的返回类型从对象变成了字典,下游代码全崩。锁版本能避免这种意外。

API Key的管理用.env文件加python-dotenv,不要硬编码在代码里。这是基本的安全习惯,我就不多说了。

4.2 完整问答器的代码实现

下面是我实际用的完整实现,分模块展示。

首先是Schema定义模块:

from pydantic import BaseModel, Field, model_validator from typing import Optional, List from datetime import date class OrderItem(BaseModel): product_name: str = Field(description="商品名称") quantity: int = Field(description="购买数量,正整数", gt=0) class OrderQuery(BaseModel): order_id: str = Field(description="订单编号") status: str = Field(description="订单状态") items: List[OrderItem] = Field(default_factory=list, description="商品列表") estimated_arrival: Optional[date] = Field(default=None, description="预计到达日期") @model_validator(mode='after') def validate_status(self): valid_status = {"待付款", "已发货", "已完成", "已取消"} if self.status not in valid_status: raise ValueError(f"非法状态: {self.status}") return self

然后是问答器主体:

import os from dotenv import load_dotenv from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate load_dotenv() class StructuredQABot: def __init__(self, schema, model="gpt-4o-mini"): self.schema = schema llm = ChatOpenAI( model=model, temperature=0, api_key=os.getenv("OPENAI_API_KEY") ) self.structured_llm = llm.with_structured_output(schema) self.prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个订单查询助手,从用户描述中提取订单信息。" "无法推断的字段请留空,不要编造。" "订单状态只能是:待付款、已发货、已完成、已取消。"), ("human", "{question}") ]) self.chain = self.prompt | self.structured_llm def query(self, question: str, max_retries: int = 2): for attempt in range(max_retries + 1): try: result = self.chain.invoke({"question": question}) return {"success": True, "data": result.model_dump()} except Exception as e: if attempt == max_retries: return {"success": False, "error": str(e)} continue

这段代码里有几个设计决策值得说。第一,重试机制。结构化输出偶尔会因为模型“发挥失常”而失败,重试一次成功率能到99%以上。我设了max_retries=2,实测足够。

第二,返回统一格式。不管成功失败,都返回带success字段的字典,调用方不用写try-except,逻辑更干净。

第三,model_dump()序列化。Pydantic v2用model_dump()而不是dict(),这是版本差异,用错了会报错。

4.3 参数选择与性能调优

模型选择上,我对比过几个。gpt-4o-mini在结构化输出任务上性价比最高,准确率和gpt-4o差距不大,但成本低一个数量级。如果你的场景对准确率要求极高,比如金融数据提取,那上gpt-4o更稳妥。

超时设置也很关键。默认超时可能太长,用户等不及。我一般设timeout=30秒,超过就走降级。LangChain里可以这样设:

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, timeout=30, max_retries=1)

注意这里的max_retries是LangChain层面的网络重试,和我上面写的业务重试是两回事。网络重试处理的是连接问题,业务重试处理的是输出不合法问题,两者配合使用。

关于token消耗,结构化输出的Prompt会比普通问答长一些,因为要传Schema。我实测下来,一个中等复杂度的Schema大概多消耗200到400个token。如果你的调用量很大,这部分成本要算进去。优化方法是精简Schema描述,去掉不必要的字段说明,但别精简过头,描述太短反而会增加失败率。

4.4 实际运行记录与效果验证

我用20条测试用例跑了一轮,覆盖了完整信息、部分信息、模糊信息三种情况。结果如下:

用例类型数量一次成功率重试后成功率
完整信息8100%100%
部分信息785.7%100%
模糊信息560%80%

模糊信息那组失败的主要原因是用户描述里根本没有订单号,模型无法推断,校验时报错。这种情况其实不该算失败,而是应该返回“信息不足”的提示。后来我在Prompt里加了“如果订单号缺失,order_id填unknown”,成功率就上去了。

这个测试让我意识到,结构化输出的成功率不仅取决于技术实现,还取决于你对业务边界的定义。哪些字段可以缺失、缺失时填什么,这些规则要在Prompt和Schema里都体现出来。

5. 常见问题与排查技巧实录

5.1 版本冲突导致的各种报错

这是最高频的问题。Pydantic v1和v2的API不兼容,LangChain不同版本对Pydantic的依赖也不同。典型报错是ImportError: cannot import name 'model_validator',这说明你装的是Pydantic v1,但代码用的是v2语法。

排查方法很简单,先看版本:

pip show pydantic langchain

如果Pydantic是1.x,要么升级到2.x,要么改用v1语法。我建议升级,因为LangChain新版本都在往v2迁移。升级命令:

pip install --upgrade pydantic

升级后如果LangChain报错,再升级LangChain。这两个要配套升级,单独升一个容易出问题。

5.2 模型返回字段缺失或类型错误

有时候模型会漏填字段,或者把数字填成字符串。这种情况先检查Schema描述是否清晰。我遇到过一次,quantity字段模型总是填成字符串“2”,后来发现是description写得太简单,改成“购买数量,必须是整数,例如2”之后就好了。

如果描述改了还是不行,可以在Prompt里加一句“所有数值字段请填数字类型,不要加引号”。双重保险。

还有一种情况是模型返回了Schema里没有的字段。这个with_structured_output会自动过滤掉,不用管。但如果你用的是json_mode,就得自己处理了。

5.3 嵌套模型输出不稳定的处理

嵌套模型是重灾区。我做过一个三层嵌套的Schema,模型经常在第二层就开始出错。后来我做了两个调整:一是把嵌套层级压到两层,二是给每个嵌套模型加独立的description。

如果业务实在需要深层嵌套,我的建议是拆成多次调用。比如先提取订单基本信息,再根据订单号查商品列表。这样每次调用的Schema都简单,稳定性大幅提升。虽然多了一次调用,但省下的调试时间远超那点成本。

5.4 常见问题速查表

问题现象可能原因解决方法
ImportError model_validatorPydantic版本过低升级到2.x
返回类型是dict不是对象LangChain版本问题锁定兼容版本
字段频繁缺失Schema描述不清补充description和Prompt说明
类型错误模型理解偏差加类型约束和Prompt强调
嵌套层出错Schema太复杂减少层级或拆分调用
超时报错网络或模型负载设timeout并加重试
状态值非法未限定枚举Prompt里列出合法值

5.5 几个我踩过的坑和独家技巧

坑一:忘了设temperature=0。默认temperature是0.7,结构化输出会不稳定。这个坑我踩了两次才记住,现在写代码第一件事就是设temperature。

坑二:Schema字段名用了中文。Pydantic支持中文字段名,但模型有时候会混淆。我建议字段名用英文,description用中文,这样既清晰又稳定。

坑三:忽略了空输入处理。用户输入空字符串时,模型会返回一堆默认值,看着合法但没意义。后来我在输入层加了空值检查,直接返回错误提示,省了一次模型调用。

技巧一:用枚举替代字符串。Pydantic支持Enum类型,把status定义成Enum,模型填错直接报错,比事后校验更早发现问题。

技巧二:给Schema加示例。在Field的description里加一个示例值,比如“例如:12345”,模型填充准确率明显提升。这个技巧在字段格式特殊时特别有用。

技巧三:日志要记全。每次调用的输入、输出、耗时、是否重试都记下来。我靠日志发现了一个规律:每周一上午的失败率比其他时段高,后来查出来是那个时段模型负载高,响应慢导致超时。调整了超时时间后就正常了。

6. 这个问答器还能怎么扩展

做完基础版本后,我又想了几个扩展方向,这里分享给有需要的朋友。

方向一:多Schema路由。用户的问题可能涉及订单、物流、售后等多种意图,可以先做一个意图识别,再路由到对应的结构化问答器。这样每个Schema都保持简单,整体稳定性更好。

方向二:流式结构化输出。LangChain支持流式输出,但结构化输出的流式处理比较特殊,需要等完整对象生成后才能校验。我试过用astream,体验一般,适合对实时性要求不高的场景。

方向三:接入向量检索。如果用户的问题需要查知识库,可以在Agent层前加一个检索步骤,把检索结果作为上下文传给模型,再输出结构化结果。这样问答器就变成了一个RAG系统。

方向四:批量处理优化。如果一次要处理大量问题,可以并发调用,但要注意模型的速率限制。我用asyncio做过并发,吞吐量提升了5倍,但需要处理好限流和错误重试。

我个人在实际操作中的体会是,结构化输出问答器的价值不在于技术多炫,而在于它把大模型的不确定性收敛到了可控范围内。你不需要每次都祈祷模型“好好说话”,而是用Schema和校验把它的输出框住。这套思路一旦跑通,后面做任何Agent应用都会顺手很多。最后再分享一个小技巧:Schema不要一次定义到位,先跑通最小可用版本,再根据实际失败案例逐步加字段和约束,这样迭代效率最高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询