ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LangChain+Pydantic结构化输出问答器实战:让大模型返回可控JSON

LangChain+Pydantic结构化输出问答器实战:让大模型返回可控JSON 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购买数量正整数, gt0) class OrderQuery(BaseModel): order_id: str Field(description订单编号通常是数字字符串) status: str Field(description订单状态如待付款、已发货、已完成) items: List[OrderItem] Field(description订单包含的商品列表) estimated_arrival: Optional[date] Field( defaultNone, description预计到达日期格式YYYY-MM-DD未知则留空 )这里有几个关键点。第一字段描述一定要写清楚。很多人偷懒不写description结果模型不知道这个字段该填什么输出质量直线下降。我做过对比测试加了详细描述的Schema字段填充准确率能提升20%以上。第二善用类型约束。比如quantity用了gt0这样模型如果填了0或负数Pydantic会直接报错而不是让脏数据流到下游。这比事后校验省事得多。第三可选字段用Optional并给默认值。不是所有信息模型都能推断出来强制要求反而会让它瞎编。给个默认值模型不确定时留空比编造一个假数据强。第四嵌套模型要控制层级。我建议嵌套不要超过三层太深了模型容易迷路。如果业务确实复杂拆成多个问答器分步处理比一个巨型Schema靠谱。3.2 把Schema绑定到模型的正确姿势定义好模型后下一步是绑定。LangChain的写法很简洁from langchain_openai import ChatOpenAI llm ChatOpenAI(modelgpt-4o-mini, temperature0) structured_llm llm.with_structured_output(OrderQuery) result structured_llm.invoke(帮我查订单12345买了两个苹果预计明天到)这里有个容易忽略的点temperature建议设为0。结构化输出追求的是稳定和准确不需要创造性。我试过temperature0.7同样的输入偶尔会返回不同的字段值虽然都合法但一致性差不适合生产。另外with_structured_output默认用的是Function Calling模式。如果你的模型不支持Function Calling可以加参数methodjson_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(modeafter) 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 langchain0.2.16 langchain-openai0.1.23 pydantic2.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购买数量正整数, gt0) class OrderQuery(BaseModel): order_id: str Field(description订单编号) status: str Field(description订单状态) items: List[OrderItem] Field(default_factorylist, description商品列表) estimated_arrival: Optional[date] Field(defaultNone, description预计到达日期) model_validator(modeafter) 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, modelgpt-4o-mini): self.schema schema llm ChatOpenAI( modelmodel, temperature0, api_keyos.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_retries2实测足够。第二返回统一格式。不管成功失败都返回带success字段的字典调用方不用写try-except逻辑更干净。第三model_dump()序列化。Pydantic v2用model_dump()而不是dict()这是版本差异用错了会报错。4.3 参数选择与性能调优模型选择上我对比过几个。gpt-4o-mini在结构化输出任务上性价比最高准确率和gpt-4o差距不大但成本低一个数量级。如果你的场景对准确率要求极高比如金融数据提取那上gpt-4o更稳妥。超时设置也很关键。默认超时可能太长用户等不及。我一般设timeout30秒超过就走降级。LangChain里可以这样设llm ChatOpenAI(modelgpt-4o-mini, temperature0, timeout30, max_retries1)注意这里的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 几个我踩过的坑和独家技巧坑一忘了设temperature0。默认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不要一次定义到位先跑通最小可用版本再根据实际失败案例逐步加字段和约束这样迭代效率最高。
返回列表