☰
LangChain结构化输出实战:with_structured_output从原理到生产级代码
2026/9/28 14:57:09 网站建设 项目流程

做RAG和Agent开发这两年,我踩过最难受的坑就是大模型输出不可控。明明调一个json.loads()就能拿到结果,偏偏模型的response里总带几句"好的,我已经分析了您的请求...",或者code block包裹着残缺不全的JSON字段,再或者字段名说变就变。后来把with_structured_output用顺了,这些问题才算真正解决。这篇文章我想把这套LangChain结构化输出实践从头到尾讲透——包括底层是怎么跑通的、生产级代码怎么写、以及那些不跑一次根本发现不了的坑。

这篇内容适合谁?正在用LangChain做应用开发、RAG流程、Agent工具接入,或者需要对大模型输出做严格格式校验的人。文里会有完整代码和排查思路,新手能跟上,有基础的也能补充一些细节。

1. 先搞清楚:为什么大模型输出需要“结构化”

很多人一开始觉得结构化输出是“锦上添花”——反正大模型返回文本,我拿到手再清洗不就行了?真做起来就知道这个想法太理想化了。大模型的自由文本输出对机器来说就是一团混沌,下游程序怎么判断哪个字段是姓名、哪个字段是金额?靠正则硬匹配?字段顺序一变、措辞一变,正则就碎了。

1.1 没有结构化输出时的真实痛点

我见过最典型的三个崩溃现场:

第一个是简历解析。模型输出一段话:"张三,2019年毕业于XX大学计算机专业,曾在字节跳动担任算法工程师,负责推荐系统。"看起来信息都在,但你想提取“教育经历”这个结构化字段,可能要从一句话里抠出学校、专业、起止时间,每份简历写法和措辞都不同,写解析逻辑的人会疯掉。

第二个场景是Agent的工具调用入参。我的Agent需要调用一个“创建工单”的工具,入参要type、level、content三个字段。模型自由发挥写出来:{类型: 'bug', 严重程度: 'P1', 问题描述: 'xxx'},工具层拿到手直接懵了——字段名全不对。这种问题在Agent场景里尤其致命,入参错了工具就调用失败,整个Agent工作流直接中断。

第三个场景是RAG检索增强。你对文档做摘要、抽取主题和关键词,如果输出不是统一的JSON结构,后续做元数据过滤、向量化索引、分类管理全都寸步难行。

1.2 核心目标:Schema 驱动一切

LangChain结构化输出的核心思想很简单:先定义好输出的格式模型(Schema),再让模型直接产出符合这个模型约束的数据。

一个理想的输出应该像这样:

{ "name": "张三", "education": [ { "school": "XX大学", "major": "计算机科学与技术", "degree": "本科", "start_year": 2015, "end_year": 2019 } ], "skills": ["Python", "机器学习"] }

字段名固定、类型固定、嵌套结构固定。下游程序不需要猜,直接消费这个JSON即可。这就是结构化输出比“自然语言+事后清洗”更可靠的根本原因——把解析压力前移给了模型,而不是留给下游代码。

2. with_structured_output 的底层逻辑与选型

LangChain里实现结构化输出的核心入口就是with_structured_output这个方法。我第一次用的时候还以为它是什么魔法,后来翻了源码才算明白,它本质上是根据你给的Schema,根据不同模型的能力,帮你在底层选用不同的策略。

2.1 三种实现模式的本质

with_structured_output背后其实对应三种不同的“约束方式”:

第一种是Function Calling / Tool Calling,这是最推荐的方式。LangChain会把你定义的Pydantic模型转成JSON Schema,然后包装成“工具”传给模型。模型调用这个“工具”,参数就是你的Schema。因为各家大模型都对Function Calling做了专门训练,这种方式的稳定性和准确率最高。

第二种是JSON Mode / Response Format,常见于OpenAI、通义千问等模型的接口参数。它只约束模型输出必须是合法JSON,但不约束字段名和字段类型。所以LangChain拿到JSON后还要再经过Pydantic校验一次,如果模型输出的JSON字段不匹配Schema,就会报错。这个模式适合不支持Function Calling的模型。

第三种是Pydantic + OutputFixingParser 兜底,当模型输出不合法时,用一个小模型去修复解析错误。这是“最后防线”,速度慢,但能兜住绝大多数问题。

三种方式对比如下:

实现模式底层机制输出稳定性速度适用场景
Function/Tool CallingSchema转为工具参数,模型工具调用最高稍慢首选,几乎兼容主流模型
JSON Mode仅约束JSON合法,字段靠Pydantic校验中高快不支持Function Calling的模型
Pydantic+修复器解析失败时二次修复中最慢兜底方案,非必要不用

2.2 为什么首选 Pydantic 模型

很多刚接触的人会问:“我直接写个JSON当Schema不行吗?”技术上可行,但在生产环境你会立刻感受到不方便。Pydantic模型能定义字段类型、默认值、枚举约束,还能嵌套子模型,最重要的是它能对模型输出自动做反序列化和校验,输出直接就是一个Python对象,不用手动json.loads()再去get字段。

我举个简单例子。你想让模型返回工单信息,字段包含优先级的合法取值和摘要的必填要求。用Pydantic模型定义,一目了然:

from typing import Literal from langchain_core.pydantic_v1 import BaseModel, Field class Ticket(BaseModel): title: str = Field(description="工单标题") priority: Literal["low", "medium", "high", "critical"] = Field( description="工单优先级" ) description: str = Field(description="问题详细描述")

如果模型输出了一个priority: "urgent",Pydantic校验会直接拒绝,因为urgent不在枚举里。这种约束能力,普通JSON Schema是做不到的。

2.3 Pydantic v1 还是 v2:版本选型细节

这里有个特别容易踩的坑:LangChain目前很多底层组件依然基于Pydantic v1,所以社区里最稳妥的写法是:

from langchain_core.pydantic_v1 import BaseModel, Field

而不是从pydantic直接导入。如果你用from pydantic import BaseModel(默认是v2),在部分LangChain版本下,工具Schema生成和解析时会出现不兼容问题,报错信息还挺迷惑,经常是TypeError或者ValidationError混着出。

建议:跟着官方文档走,用langchain_core.pydantic_v1。等你的依赖树里LangChain全家桶整体迁移到v2再切换,别急着做第一个吃螃蟹的人。

2.4 顺手回应:LangChain是不是过时了

这几年社区里“LangChain过时了吗”的问题隔三差五就上热榜。就结构化输出这个能力而言,with_structured_output和各家模型的Function Calling接口直接对应,没有任何过时的迹象。相反,它在LangGraph里依然承担着工具入参定义和状态数据校验的重要职责。该关心的是“怎么用好”,而不是“要不要学”。

3. 实操:从模型定义到生产级结构化输出

讲完原理,直接上手。这一节我完整展示一个“智能客服工单分类”的例子,从定义模型到调用输出,再到生产级封装,一步到位。

3.1 定义一个足够“具体”的模型

模型定义是整个结构化输出的灵魂。我给所有新人的建议都是:在模型字段的 description 里把你要的细节写清楚,能多细就多细。模型本身的指令遵循能力是有限的,你要靠字段描述引导它填正确的值。

from typing import List, Optional from langchain_core.pydantic_v1 import BaseModel, Field from langchain_openai import ChatOpenAI class SupportTicket(BaseModel): """客服工单分类与摘要模型""" category: str = Field( description="问题分类,可选值:账号问题、支付问题、产品使用问题、投诉建议、其他" ) priority: str = Field( description="优先级,可选值:high(紧急)、medium(普通)、low(低优)", default="medium" ) one_line_summary: str = Field(description="一句话概括用户问题,不超过30字") detail_summary: str = Field(description="完整的问题描述摘要,保留关键信息") action_items: List[str] = Field( description="建议的操作步骤列表,每条不超过20字" ) customer_satisfaction: Optional[int] = Field( description="用户情绪评分,1-5分,5表示非常满意,1表示强烈不满", ge=1, le=5, default=3 )

一些字段我专门加了约束:priority字段的description里直接给了可选值,比在代码里写Literal更灵活,因为模型能直接读到说明文字;customer_satisfaction用了ge=1le=5做数值范围约束,模型万一抽风输出6分,Pydantic在校验阶段就会挡住。

3.2 绑定模型并调用

核心就一行:

llm = ChatOpenAI( model="gpt-4o-mini", temperature=0, api_key="sk-xxx", base_url="https://api.xxx.com/v1" ) structured_llm = llm.with_structured_output(SupportTicket) user_input = """ 我昨天在你们平台下单买了双鞋,今天发现扣了两次款,订单号是20240915, 但是只收到一个发货通知。我想知道这笔钱什么时候能退给我。 """ result = structured_llm.invoke(user_input) print(type(result)) # <class '__main__.SupportTicket'> print(result.category) # 支付问题 print(result.action_items) # ['核实重复扣款原因', '确认退款到账时间', '同步发送处理结果']

注意,此时result已经是一个SupportTicket实例了,可以直接通过属性访问字段,不需要["category"]这种字典式访问。如果一定要转成JSON送下游,用:

result.model_dump_json()

这是Pydantic v1的方法,输出是JSON字符串,方便存数据库或做日志。

3.3 temperature 和采样参数:稳定性的第一道闸门

我在ChatOpenAI里设了temperature=0,这是结构化输出场景里最重要的一组参数。温度越低,模型输出越保守、越倾向于选择概率最高的内容,也就越稳定。虽然temperature=0不是100%保证输出一致,但对比0.7以上的效果,字段缺失和格式漂移的概率会大幅下降。

如果用的是 DeepSeek、Qwen 这类模型,也一样把temperature调到0,不要用默认值。有些模型还有top_p,默认1.0的情况下建议收敛到0.8~0.9,进一步减少随机性。

3.4 生产级封装:不要裸奔调用

裸调用跑demo没问题,上生产就得在周边补很多工程细节。我现在的习惯是单独建一个模块放所有Schema,再封装一个统一调用函数:

# schemas.py from typing import List, Optional from langchain_core.pydantic_v1 import BaseModel, Field class SupportTicket(BaseModel): # ... 同前面定义 ... class RefundRequest(BaseModel): order_id: str = Field(description="订单号") refund_amount: float = Field(description="退款金额,单位元") reason: str = Field(description="退款原因")
# structured_client.py import json import logging from typing import Type, TypeVar from langchain_core.pydantic_v1 import BaseModel from langchain_openai import ChatOpenAI logger = logging.getLogger(__name__) T = TypeVar("T", bound=BaseModel) class StructuredClient: def __init__(self, llm: ChatOpenAI, max_retries: int = 2): self.llm = llm self.max_retries = max_retries def invoke(self, schema: Type[T], user_input: str) -> T: structured_llm = self.llm.with_structured_output(schema) for attempt in range(self.max_retries + 1): try: result = structured_llm.invoke(user_input) logger.info( "structured output raw result: %s", result.model_dump_json() ) return result except Exception as e: logger.warning( "structured output failed, attempt=%s, error=%s", attempt + 1, e ) if attempt >= self.max_retries: raise # 不可达,仅为类型提示 raise RuntimeError("unreachable")

这个封装做了三件事:统一入口、失败重试、原始输出落日志。为什么要记原始输出?因为结构化输出偶尔还会失败,如果没有日志,排障时根本不知道模型到底返回了什么鬼东西。有了日志,把那次model_dump_json()打印出来,一眼就能定位是模型的问题还是Schema定义的问题。

3.5 模块与函数级最佳实践清单

生产级代码里我总结过几条硬标准,分享出来供参考:

  • Schema定义独立成模块,不要嵌在业务逻辑文件里,方便复用和单测。
  • 所有结构化输出走同一个入口函数,统一加日志、超时、重试、限流。
  • Schema版本管理:字段一旦上线尽量只增不删,避免下游消费方大面积报错。
  • 对结构化输出结果做单元测试:用一个固定样例跑invoke,断言字段类型和枚举值,每次改Schema都能回归。
  • 只在真正需要约束的环节用with_structured_output。比如普通闲聊、草稿生成就别套,既慢又费token。

4. 常见问题与排查实录

这块是我积累踩坑最多的地方。下面这些问题,我在不同模型上全都遇到过,逐个写出来供你对照排查。

4.1 问题速查表

问题表现排查思路解法
输出被markdown包裹结果是一段带```json的字符串,json.loads报错模型把结构化输出当成“回答”了检查是否真的走了with_structured_output,而不是普通invoke
字段缺失模型没返回某个必填字段Schema字段描述不够清晰,或模型上下文太长导致截断缩短输入文本,检查字段description,必要时给模型few-shot示例
枚举值非法模型输出不在允许列表里的值枚举约束在代码里,模型不一定读得到在description里明确写“可选值:...”,再用Literal兜底
嵌套结构解析失败内层对象字段被截断或类型错误嵌套级别太深,单次生成token溢出拆分多个结构化输出步骤,每次输出一层
流式输出不可用stream()拿不到分片对象结构化输出本质是“等完整结果再解析”不要对流式接口做结构化输出,需要流式就分开走

4.2 JSON解析失败修复实录

我遇到最蠢也最典型的失败是:模型在JSON前面加了一句“好的,这是您要的结果:”,然后才是{...}。这种case用json.loads直接废掉。

网上有些民间方案是先截第一个{到最后一个}再做正则提取,能用但很脆弱。LangChain推荐用OutputFixingParser兜底:

from langchain.output_parsers import OutputFixingParser from langchain_core.output_parsers import PydanticOutputParser parser = PydanticOutputParser(pydantic_object=SupportTicket) fixing_parser = OutputFixingParser.from_llm( parser=parser, llm=ChatOpenAI(model="gpt-4o-mini", temperature=0) ) try: result = fixing_parser.parse(raw_llm_output) except Exception as e: logger.error("parse failed after fixing: %s", e)

这个组件的原理是:先让PydanticOutputParser解析,失败时把错误消息和原始输出一起交给一个小模型,让它修复成合法JSON,再解析一遍。代价是慢,但作为兜底足够可靠。上线前建议测一次修复成功率,确认哪些模型的失败率特别高。

4.3 长文本与Token截断的隐形杀手

结构化输出和普通生成共享同一个token窗口。如果输入文本很长,比如喂了一份5000字的文档做合同信息抽取,模型的生成空间会被压缩,出现输出截断——最常见的是嵌套列表只生成了前半段,后半段直接消失,Pydantic校验立刻失败。

针对这种情况,我现在的做法是先做文档切块,再做结构化抽取。具体流程是:把长文档按章节切块,每个块独立跑一次结构化输出,再把所有结构块合并。这样单次生成压力小,输出失败率降了一个量级。

4.4 不同模型的兼容性差异

我在实践里测过OpenAI、DeepSeek、Qwen和本地Ollama模型。体验是:OpenAI系列和Qwen的Function Calling稳定性明显好,DeepSeek也可以;本地小模型在复杂Schema下经常少字段或者踩枚举约束,建议上Llama-3-8B级别以上的模型,别用3B的小模型硬扛复杂Schema。

with_structured_output在不支持Function Calling的模型上会自动退化到JSON Mode,两种模式的输出质量差距是肉眼可见的,所以模型的选型一定程度上决定了你能不能在复杂Schema上放肆。

5. 进阶:从 LangChain 到 LangGraph 的结构化输出

如果你已经在玩LangGraph,会发现结构化输出的战场扩大了——它不再只是“拿到一个解析后的对象”,而是嵌入到了Agent的状态机和工具调用流程里。

5.1 用结构化输出定义工具入参

在LangGraph里写Agent,工具节点的入参校验本质上就是结构化输出。我常用@tool装饰器,让Pydantic模型直接定义工具参数:

from langchain_core.tools import tool class SearchInput(BaseModel): query: str = Field(description="检索关键词") top_k: int = Field(default=5, description="返回条数,最大20") filters: Optional[dict] = Field(default=None, description="元数据过滤条件") @tool(args_schema=SearchInput) def search_documents(query: str, top_k: int = 5, filters: dict = None): """基于向量数据库的文档检索工具""" ...

这样Agent在调用工具时,LLM生成什么参数、参数类型是什么,都由模型管束住了。LangGraph的Node会在调用工具前自动完成参数校验,非法入参直接拦截,不会污染下游状态。

5.2 状态对象里保留结构化数据

我在LangGraph的StateSchema里会直接放Pydantic模型对象,而不是把JSON字符串塞进来。好处是每个节点从状态里取出来就是强类型对象,不需要做二次解析和判空。尤其是做多轮Agent对话时,把上一轮抽取出的实体保存成对象,后续节点直接引用属性,比反复json.loads干净很多。

5.3 实战扩展:长文档结构化解析

顺着结构调整的主题,再说一个我很常用的实际业务模式:OCR文本块 -> 结构化抽取 -> 元数据入向量库。

很多OCR服务(比如文档解析工具拿到的一堆文本块)本身没有结构,就是一页页的文字。你直接全文塞向量库,检索时按什么过滤?如果能把文档拆成“页码、章节、段落、表格”这些结构化元素,RAG的精确度会提升一个台阶。

我实践中的代码如下:

class DocumentChunk(BaseModel): page_number: int = Field(description="内容所在页码") chapter: str = Field(description="所属章节标题") paragraph_type: str = Field( description="段落类型,可选值:正文、表格、图表标题、页眉页脚、列表" ) content: str = Field(description="段落原文内容") table_headers: Optional[List[str]] = Field( default=None, description="如果是表格类型,列出表头字段" ) # 对每一页OCR文本块调用结构化输出 structured_llm = llm.with_structured_output(DocumentChunk) page_result = structured_llm.invoke(page_text)

项目里用langchain-multivectorretriever或者自建元数据过滤时,chapter、page_number字段直接作为索引元数据,检索时可以只查“第三章”或“第12页”,效果比纯向量相似度高一个档次。

5.4 当文档涉及合同与招标文件时

给合同或招标文件做解析时,抽取字段一定要细。我做过一个法律文本抽取模型,字段包括条款编号、条款标题、义务主体、金额、日期、生效条件。这类场景中有一个隐形坑:合同条款间常有交叉引用,比如“详见第2.3条”。你只抽取当前段落是不够的,得在Schema里加一个related_clauses字段,让模型把交叉引用的条款号一并输出,后续才能拼出完整的条款网络。

最后分享一个经验

消灭“结构解析地狱”,最根源的办法不是加更多解析函数,而是把Schema定义做扎实。我见过太多团队在“先用自由文本,出问题再补规则”的循环里打转,越到后面维护成本越高。第一版Schema宁可多花30分钟把字段边界、枚举值、嵌套关系和描述都想清楚,后面几个月的收益都能覆盖这个成本。结构化输出的本质,不是逼模型输出JSON,而是让模型和程序用一个大家都能理解的方式沟通。理清这一层,你写出来的代码自然能稳定跑在线上。

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

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

立即咨询