
做LangChain项目尤其是做Agent应用时最让人挠头的一件事就是模型吐出来一堆自然语言程序根本没法直接用。比如让LLM从用户聊天记录里抽出一份订单信息它给你来一句“订单号是123456金额98元状态是已发货”下游系统还得写正则去抠抠错了就是线上事故。LangChain的Structured output组件就是专门治这个病的它让模型直接输出符合约束的JSON、Pydantic对象或类型化结构程序拿到手就能干活少掉一整层解析逻辑。这篇博文我会从原理、三种实现路线、完整代码实操和踩坑记录四块来讲适合理清LangChain脉络的入门者也适合已经写过几个Agent但一直被输出稳定性折磨的开发者。1. 为什么需要结构化输出从“假解析”到“真约束”1.1 非结构化输出带来的连锁麻烦先看一个我早期踩过的坑。当时做一个信息抽取Agent用户发一段闲聊“我想找一本机器学习的书出版社最好是电子工业的价格三百以内要去年出版的。”模型给出的回答五花八门有的带“好的我来帮您查找”有的把价格写成“$300”有的直接回答“我有几本书推荐《机器学习》...”。后续代码要同时处理这些格式正则写了十几个今天能跑通明天换个说法就崩。原因很直白LLM本质是一个文本生成模型它没有义务遵守你的字段约束。它的“默认输出”是给人看的自然语言不是给程序看的数据结构。当你的应用需要把模型输出接进数据库、API、任务队列或者另一个函数时每一处“文字转字段”都是潜在的崩溃点。结构化输出要解决的就是这个“LLM到Program”的最后一公里。它不只是调用JSON.parse而是让模型在生成阶段就受到约束字段名固定、类型固定、必填项明确、取值空间明确。约束好以后模型的自由发挥空间被压缩到最小剩下的自然语言风格差异也就不再是问题。1.2 结构化输出的典型应用场景从实际项目来看结构化输出的需求几乎无处不在但最典型的就那么几类。第一类是信息抽取。客服工单、法律文书、简历、聊天记录这些非结构化文本里藏着大量字段比如“投诉类型”“金额”“客户等级”“截止日期”。用结构化输出把字段一次性抽全抽完直接入库后续统计、告警、报表全部省事。第二类是Agent的工具调用。你让Agent决定“是否要搜索、搜索什么关键词、要不要翻页”它就必须产出一个可执行的参数对象。参数不合法工具一调用就报错。结构化输出在这里保证了参数的类型和边界比如页码必须是正整数、搜索关键词不能为空。第三类是下游系统对接。你把LLM当一个服务用给别人提供REST接口人家肯定不想要“一段优美的话”而是想要一个契约清晰的JSON Schema。结构化输出就是天然的数据契约字段改名、类型变动都会被模型约束住接口稳定很多。1.3 LangChain对“结构化输出”的定位LangChain把这套东西抽象成组件和模型、提示词、解析器这些概念并列。它的核心思路是你定义一个输出模型通常是PydanticLangChain负责把模型描述翻译成模型能理解的指令比如函数参数Schema、JSON Schema生成完再自动解析成你的目标类型中间任何一步失败都能被捕获重试。在老版本里这个能力分散在PydanticOutputParser、StructuredOutputParser、JsonOutputParser这些类里每个人都要手动往提示词里塞format_instructions然后自己调.parse()。LangChain新版本收敛成了一个方法.with_structured_output()一行代码搞定这才是真正“组件化”的形态。2. 三条实现路线从解析器到函数调用的演进2.1 输出解析器提示词正则的老路子最早的结构化输出实现是把期望格式写进提示词再把模型输出交给解析器清洗。典型例子是PydanticOutputParser你把Pydantic模型传进去它自动生成一段包含字段说明和示例的指令拼到prompt里模型照着格式回解析器再帮你转成对象。from langchain.output_parsers import PydanticOutputParser from langchain_core.pydantic_v1 import BaseModel, Field from langchain_openai import ChatOpenAI from langchain_core.prompts import PromptTemplate class OrderInfo(BaseModel): order_id: str Field(description订单号) amount: float Field(description订单金额) status: str Field(description订单状态) parser PydanticOutputParser(pydantic_objectOrderInfo) prompt PromptTemplate.from_template( 从以下文本中抽取订单信息。\n{input}\n{format_instructions} ).partial(format_instructionsparser.get_format_instructions()) model ChatOpenAI(modelgpt-4o-mini, temperature0) chain prompt | model | parser result chain.invoke({input: 订单20240118金额98元状态已发货}) print(type(result)) # OrderInfo这个方法现在仍然能用但有几个硬伤。一是完全靠提示词约束有些模型就是会偷偷加注释、加说明文字解析器遇到多出的内容就报错。二是prompt会被塞进一大段格式说明白白浪费token抽取效果还跟提示词写法强相关。三是解析失败时错误信息不友好新手经常看不懂“Expecting value: line 1 column 1”到底哪里错了。所以老项目里常看到有人写完解析器又加了无数次re-prompt重试本质上是在跟模型的无规律对抗。2.2 函数调用把输出约束交给模型原生能力现在主流的实现方式是函数调用也就是Function Calling的思路。模型厂商在训练时就给了模型一个能力根据你提供的函数定义返回一段结构化的JSON作为“要调用的函数参数”。比如你说“现在有一个函数search_books(keyword, publisher, max_price, year)你决定怎么调用它”模型就会直接给你一个合法的参数JSON因为它把这个当成“调用函数”的任务而不是“写一段话”。LangChain的.with_structured_output()在底层默认就走这条路。它把你的Pydantic模型转换成函数定义里的parameters字段传给模型模型返回参数JSONLangChain再校验成对象。这条路线的优势很明显模型原生经过专门训练输出格式稳定得多不需要在prompt里写冗长的格式说明解析失败率大幅下降。这是我在新项目里首选的方式。2.3 统一封装with_structured_output的设计巧思.with_structured_output()的设计思路是把“生成格式说明”“调用模型”“解析结果”“错误处理”四个环节全部封装起来。你只需要做两件事定义好Pydantic模型然后调用方法。structured_model model.with_structured_output(OrderInfo)这一行背后LangChain会根据传入的模型类型自动选择实现方式支持函数调用的模型走函数调用支持JSON模式的模型走JSON模式老的模型就退回提示词解析器。日常使用中你基本不需要关心底层走的是哪条路只需要在特定场景下手动指定method参数。2.4 三条路线怎么选我把三条路线的适用场景整理成一张表方便你们对照着选路线优势劣势推荐场景提示词解析器兼容所有模型不稳定、费token、报错不友好模型不支持任何结构化能力时的兜底方案函数调用最稳定、原生支持需要模型支持function calling绝大多数场景默认首选JSON Mode比提示词稳定不需要函数定义字段描述表达能力弱于函数调用模型不支持函数调用但支持JSON模式时3. 实操用with_structured_output落地一个结构化输出3.1 环境准备与模型选型整个链路需要的依赖包并不多核心是langchain、langchain-openai、pydantic。装最新版本就行pip install -U langchain langchain-openai pydantic版本这里我提一句我现在用的LangChain是0.3.xPydantic是2.x。如果你是老项目停留在0.1.x有些导入路径和参数名会有区别照着代码跑不通就先查版本。模型选型上建议优先用支持函数调用的模型OpenAI的gpt-4o系列、gpt-4o-mini通义的qwen-plus智谱的glm-4等。如果你用的是本地模型比如Ollama拉下来的开源模型需要确认它是否支持function calling不支持就要走JSON模式或解析器兜底。3.2 定义输出模型字段设计是第一步结构化输出的核心其实是Pydantic模型设计不是代码。模型字段设计得好不好直接决定抽取质量和下游代码的复杂度。先来一个实际案例让模型从一句自然语言里抽取出“图书搜索条件”。我定义如下from typing import List, Optional from pydantic import BaseModel, Field class BookSearch(BaseModel): keyword: str Field(description搜索关键词必须提取用户意图中最核心的检索词) publisher: Optional[str] Field(description出版社名称如果没有则返回null) max_price: Optional[float] Field(description最高价格预算单位元) year_after: Optional[int] Field(description出版年份下限如2023表示只找2023及以后出版) tags: List[str] Field(default_factorylist, description相关标签列表)字段名要短而明确description要写清楚“什么时候为空”。很多人忽略Optional字段的语义结果模型把“没有出版社要求”也硬填一个值进去下游一匹配就出事。所以description里我一定要写明“如果没有则返回null”。3.3 一行代码接入结构化输出接下来就是用.with_structured_output()包装模型from langchain_openai import ChatOpenAI model ChatOpenAI(modelgpt-4o-mini, temperature0) structured_model model.with_structured_output(BookSearch) result structured_model.invoke(给我找一本讲大模型的科普书电子工业出版社300块钱以内要去年之后出的) print(type(result)) # class __main__.BookSearch print(result.max_price) # 300.0看一下结果模型已经自动返回一个BookSearch实例不是字符串。max_price是浮点数year_after是整数字段名跟定义完全一致。整个调用过程完全没有手动解析的代码也没有正则。在这个例子里有几个参数值得额外说明。temperature0是我写结构化输出时的固定习惯因为抽取、参数生成这类任务不需要创造力越高越容易跑偏。.with_structured_output()还可以传一个include_rawTrue参数返回的是一个字典包含raw原始消息、parsed解析对象、parsing_error错误信息适合你想自己处理错误或记录日志的场景。3.4 method参数与手动指定输出模式with_structured_output()的第二个关键参数是method。默认情况下LangChain自动选择但有时候自动选择不一定最优。我处理过几个情况如果模型支持函数调用但你发现JSON输出更干净可以手动指定structured_model model.with_structured_output(BookSearch, methodjson_mode)如果模型不支持函数调用也不支持JSON模式那就只能用老办法structured_model model.with_structured_output(BookSearch, methodjson_mode) # 不支持的模型会退回提示词注入方式但需要你在prompt里带上format_instructions这里其实藏着一个坑methodjson_mode在部分模型上需要你手动在prompt里给模型一个“你要输出JSON”的指令否则模型可能不按JSON格式出。相比之下函数调用模式是模型原生能力基本不需要额外提示词。4. 复杂结构输出实战嵌套、列表与联合类型4.1 嵌套对象与列表怎么定义真实项目里很少有单个平铺对象更多是“订单里包含多个商品”“一篇文章里有多条摘要”。Pydantic天然支持嵌套和列表LangChain的转换逻辑也能处理。举个例子让模型从一段客户评论里抽取出“多维度满意度评分”class ScoreItem(BaseModel): dimension: str Field(description评价维度如物流速度、商品质量、客服态度) score: float Field(description该维度的评分1到5分) reason: str Field(description为什么给出这个分数) class ReviewAnalysis(BaseModel): overall_satisfaction: int Field(description总体满意度1到5的整数) pros: List[str] Field(description优点列表) cons: List[str] Field(description缺点列表) scores: List[ScoreItem] Field(description分维度评分列表)定义好之后调用方式跟前面一模一样analysis_model model.with_structured_output(ReviewAnalysis) result analysis_model.invoke(东西收到了物流是真的快隔天就到。但包装有点薄盒子角都压扁了。客服态度挺好的解释了很久。) print(result.pros) # [物流速度很快] print(result.scores[0].dimension) # 物流速度这里注意一个细节scores这个列表模型默认只会输出它认为有把握的维度不会硬凑。如果你希望模型固定输出某些维度比如“必须包含物流速度、商品质量、客服态度三项”有两种做法。第一种是在description里写死description分维度评分列表必须包含物流速度、商品质量、客服态度三个维度。第二种是改用Literal类型强制模型只能在这几个维度里选。4.2 枚举约束用Literal/Enum锁死取值范围抽取任务中经常遇到“状态”这类取值有限的字段。比如订单状态只有“待支付、已支付、已发货、已完成、已取消”。如果只写成str模型可能给出“已配送”“完成”这类语义相同但字面不同的值下游只能再做一层映射。正确的做法是用Literal或Enumfrom typing import Literal class OrderStatus(str, Enum): PENDING 待支付 PAID 已支付 SHIPPED 已发货 COMPLETED 已完成 CANCELLED 已取消 class OrderInfo(BaseModel): order_id: str Field(description订单号) status: OrderStatus Field(description订单状态) amount: float Field(description订单金额)用了Enum之后模型返回的值会被自动转成OrderStatus枚举不再是任意字符串。如果模型输出一个不在枚举里的值解析阶段会直接报错不会带着脏数据往下走。这就是结构化输出“强约束”的价值宁可失败也不容忍错误数据。4.3 联合类型与可选字段处理“可能不存在”的数据还有一种常见情况字段不是稳定存在的。比如抽取简历信息“电话”和“邮箱”至少有一个且可能同时存在。用Optional能解决“单个字段可空”但解决不了“多选一”的语义。Pydantic的Union类型可以处理这种情况class ContactInfo(BaseModel): phone: Optional[str] Field(description手机号没有则null) email: Optional[str] Field(description邮箱没有则null) preferred_method: str Field(description最方便的联系方式只能是电话或邮箱)这里preferred_method其实就是个“二选一”的约束。如果你想让模型更深层理解“电话和邮箱至少有一个”可以加一个模型级别的校验器但实操中我更倾向于在description里写明规则让模型自己判断。字段级别的description是成本最低、效果最明显的约束手段没有之一。4.4 与RunnableParallel配合做多路抽取在复杂Agent项目里一个输入往往要同时抽取多个维度的结构化信息。比如用户发一句“明天下午三点和周总开个会提醒我带上合同”既要抽时间信息又要抽参会人还要抽待办事项。这时候可以配合RunnableParallel并行跑多个结构化输出的模型各抽各的最后合并结果from langchain_core.runnables import RunnableParallel meeting_model model.with_structured_output(MeetingInfo) todo_model model.with_structured_output(TodoInfo) chain RunnableParallel( meetingmeeting_model, todotodo_model, ) result chain.invoke(明天下午三点和周总开个会提醒我带合同) print(result[meeting].time) # 下午三点 print(result[todo].content) # 带上合同这个组合在LangChain里非常常用。有人会问这不就相当于两个请求了吗对并行调用模型确实会消耗两份token但换来的是每个模型只负责一个子任务字段少、约束清晰抽取准确率显著更高。我自己的经验是与其让一个模型输出一个20字段的大JSON不如拆成3个小模型并行每个只管5个字段性价比高很多。5. 常见问题与排查实录再稳定的组件也有翻车时5.1 模型输出不符合Pydantic约束怎么办结构化输出不是100%成功的总会有模型犯浑。常见报错是OutputParserException或者ValidationError错误信息里会指出哪个字段缺失、哪个字段类型不对。最有效的临时处理法是加include_rawTrue拿到原始输出看模型到底回了什么resp structured_model.invoke(帮我查一下python的异常处理, include_rawTrue) if resp[parsing_error]: print(原始输出:, resp[raw]) print(错误信息:, resp[parsing_error])多数情况下模型的原始输出已经接近合法JSON只是犯了“字段名拼错”“多了一个逗号”“把一个数字写成字符串”这类小错。看到具体内容后下一步就好办了要么调prompt要么加重试。我自己的处理习惯是两层外层先正常调with_structured_output捕获到解析异常后把原始输出拼到一个“请你把下面的内容修正为严格JSON不要加任何说明”的修正prompt里让模型重写一遍再走一次解析。这个兜底技巧在实际项目中把成功率从95%拉到了99%以上。5.2 字段抽取不准确问题多半在description如果你发现模型经常把某个字段抽错或者该填空的没填大概率是description写得不够清楚。我举一个反面例子class OrderInfo(BaseModel): order_id: str Field(description订单号)这个description等于没写。模型不知道“订单号”长什么样、从哪里找。更好的写法是order_id: str Field(description订单号通常以字母或数字开头出现在文本中‘订单号’、‘订单编号’等关键词之后)我给读者的建议是每一个字段的description都要回答三个问题这个字段是什么从输入文本的哪里找找不到时怎么处理不要嫌长写给模型看的提示词越具体越稳定。5.3 长文本截断与上下文超限抽取长文档时另一个高频问题是上下文超限。模型一次能处理的token有限文本太长输出质量急剧下降。我的做法是分而治之把长文档按段落切片每片单独抽取再用汇总模型合并。切片大小根据模型上下文窗口来定比如上下文是128k单次抽取输入控制在8k~16k留足空间给输出token和Structed output内部的格式提示。还有一种做法是只抽取关键摘要段落而不是全文。比如简历抽取先让模型做一个“只保留工作经历和技能关键词的摘要”再从摘要里抽取结构化字段效果往往比直接抽全文好因为摘要阶段已经帮模型去噪了。5.4 结构化输出与LangGraph、Agent的配合误区最近常看到有人讨论“LangChain是不是过时了”“LangGraph是不是要取代LangChain”这种说法其实有点误导。LangGraph解决的是“状态流转、节点编排、循环控制”这些Agent编排问题而Structured output解决的是“单个节点怎么把模型输出变成程序数据”的问题。两者根本不是替代关系而是配合关系。我在LangGraph里做Agent时通常会在工具调用节点之前放一个with_structured_output包装的模型用来把用户的自然语言意图转成结构化的执行计划。LangGraph负责把计划分步执行、记录状态、必要时回退重试而结构化输出负责每一轮的“输入到参数”转换。少了任何一环整个流程都跑不顺畅。5.5 问题排查速查表症状可能原因处理方式解析失败报ValidationError模型生成值不在枚举/类型范围加include_raw看原始输出简化字段类型某个字段频繁抽错或为空description写得模糊重写description明确从哪里提取模型输出带Markdown代码块走的是JSON模式且prompt没有强调用methodfunction_calling或加修正prompt调用报错提示不支持function calling模型不支持函数调用换methodjson_mode或退回解析器token消耗明显变大字段太多或提示词塞了过长格式说明精简字段考虑拆成多个并行结构化模型抽取结果不稳定时好时坏temperature过高设置temperature0必要时用确定性采样写在最后从最开始用正则硬抠到用PydanticOutputParser拼提示词再到现在一行.with_structured_output()我最大的感触是结构化输出这件事真正的瓶颈从来不是LangChain的API怎么调而是你怎么定义数据模型。字段名、类型、可选性、取值空间、description每一个细节都在替下游所有代码做决策。字段设计偷的懒最后都会变成解析错误和生产事故找回来。所以我的习惯是先花时间把Pydantic模型推敲清楚再动手写调用代码——模型定义好了后面全是水到渠成的事。如果你也正在被模型输出的随机性折磨不妨从今天开始把“让模型说人话”改成“让模型按契约说话”。