
做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: urgentPydantic校验会直接拒绝因为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低优, defaultmedium ) 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表示强烈不满, ge1, le5, default3 )一些字段我专门加了约束priority字段的description里直接给了可选值比在代码里写Literal更灵活因为模型能直接读到说明文字customer_satisfaction用了ge1le5做数值范围约束模型万一抽风输出6分Pydantic在校验阶段就会挡住。3.2 绑定模型并调用核心就一行llm ChatOpenAI( modelgpt-4o-mini, temperature0, api_keysk-xxx, base_urlhttps://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里设了temperature0这是结构化输出场景里最重要的一组参数。温度越低模型输出越保守、越倾向于选择概率最高的内容也就越稳定。虽然temperature0不是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, boundBaseModel) 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_objectSupportTicket) fixing_parser OutputFixingParser.from_llm( parserparser, llmChatOpenAI(modelgpt-4o-mini, temperature0) ) 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(default5, description返回条数最大20) filters: Optional[dict] Field(defaultNone, description元数据过滤条件) tool(args_schemaSearchInput) 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( defaultNone, 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而是让模型和程序用一个大家都能理解的方式沟通。理清这一层你写出来的代码自然能稳定跑在线上。