LangChain结构化输出实战:告别正则,让模型按契约输出
2026/9/12 12:58:23 网站建设 项目流程

做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把这套东西抽象成组件,和模型、提示词、解析器这些概念并列。它的核心思路是:你定义一个输出模型(通常是Pydantic),LangChain负责把模型描述翻译成模型能理解的指令(比如函数参数Schema、JSON Schema),生成完再自动解析成你的目标类型,中间任何一步失败都能被捕获重试。

在老版本里,这个能力分散在PydanticOutputParserStructuredOutputParserJsonOutputParser这些类里,每个人都要手动往提示词里塞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_object=OrderInfo) prompt = PromptTemplate.from_template( "从以下文本中抽取订单信息。\n{input}\n{format_instructions}" ).partial(format_instructions=parser.get_format_instructions()) model = ChatOpenAI(model="gpt-4o-mini", temperature=0) 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字段,传给模型,模型返回参数JSON,LangChain再校验成对象。

这条路线的优势很明显:模型原生经过专门训练,输出格式稳定得多;不需要在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.x,Pydantic是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_factory=list, description="相关标签列表")

字段名要短而明确,description要写清楚“什么时候为空”。很多人忽略Optional字段的语义,结果模型把“没有出版社要求”也硬填一个值进去,下游一匹配就出事。所以description里我一定要写明“如果没有则返回null”。

3.3 一行代码接入结构化输出

接下来就是用.with_structured_output()包装模型:

from langchain_openai import ChatOpenAI model = ChatOpenAI(model="gpt-4o-mini", temperature=0) 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是整数,字段名跟定义完全一致。整个调用过程完全没有手动解析的代码,也没有正则。

在这个例子里,有几个参数值得额外说明。temperature=0是我写结构化输出时的固定习惯,因为抽取、参数生成这类任务不需要创造力,越高越容易跑偏。.with_structured_output()还可以传一个include_raw=True参数,返回的是一个字典,包含raw(原始消息)、parsed(解析对象)、parsing_error(错误信息),适合你想自己处理错误或记录日志的场景。

3.4 method参数与手动指定输出模式

with_structured_output()的第二个关键参数是method。默认情况下LangChain自动选择,但有时候自动选择不一定最优。我处理过几个情况:

如果模型支持函数调用,但你发现JSON输出更干净,可以手动指定:

structured_model = model.with_structured_output(BookSearch, method="json_mode")

如果模型不支持函数调用,也不支持JSON模式,那就只能用老办法:

structured_model = model.with_structured_output(BookSearch, method="json_mode") # 不支持的模型会退回提示词注入方式,但需要你在prompt里带上format_instructions

这里其实藏着一个坑:method="json_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,模型可能给出“已配送”“完成”这类语义相同但字面不同的值,下游只能再做一层映射。

正确的做法是用LiteralEnum

from 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( meeting=meeting_model, todo=todo_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_raw=True,拿到原始输出,看模型到底回了什么:

resp = structured_model.invoke("帮我查一下python的异常处理", include_raw=True) 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没有强调用method="function_calling"或加修正prompt
调用报错提示不支持function calling模型不支持函数调用换method="json_mode"或退回解析器
token消耗明显变大字段太多或提示词塞了过长格式说明精简字段;考虑拆成多个并行结构化模型
抽取结果不稳定,时好时坏temperature过高设置temperature=0,必要时用确定性采样

写在最后

从最开始用正则硬抠,到用PydanticOutputParser拼提示词,再到现在一行.with_structured_output(),我最大的感触是:结构化输出这件事,真正的瓶颈从来不是LangChain的API怎么调,而是你怎么定义数据模型。字段名、类型、可选性、取值空间、description,每一个细节都在替下游所有代码做决策。字段设计偷的懒,最后都会变成解析错误和生产事故找回来。所以我的习惯是先花时间把Pydantic模型推敲清楚,再动手写调用代码——模型定义好了,后面全是水到渠成的事。如果你也正在被模型输出的随机性折磨,不妨从今天开始,把“让模型说人话”改成“让模型按契约说话”。

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

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

立即咨询