做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 Calling | Schema转为工具参数,模型工具调用 | 最高 | 稍慢 | 首选,几乎兼容主流模型 |
| 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,而是让模型和程序用一个大家都能理解的方式沟通。理清这一层,你写出来的代码自然能稳定跑在线上。