☰
LangChain结构化输出实战:从文档解析到Agent稳定落地
2026/9/28 8:58:11 网站建设 项目流程

做LangChain应用的人,大概率都踩过同一个坑——模型什么都答得出来,就是答得不像人能直接用的样子。明明要一个合同金额,它给你回一句“根据第三条规定,金额为人民币壹佰贰拾万元整”;明明要一个JSON数组,它给你来个“以下是您需要的列表:”开头。这种问题在开发环境跑通Demo时感觉不到,一旦接进生产链路、后面要挂数据库或自动化流程,会立刻变成事故。

LangChain的结构化输出(Structured Output)就是专门解决这个问题的。它不是简单地让模型“输出JSON”,而是从模型调用协议层面约束返回格式,让你拿到的东西能直接json.loads、能直接塞进Pydantic模型、能直接作为下一个Agent的输入。我处理过不少文档解析、RAG知识库和Agent编排的项目,可以负责任地说:结构化输出是生产级LangChain应用的底盘,底盘不稳,上面跑什么都会颠。

这篇文章不打算写成官方文档的翻译,就讲实际操作中真正有用的东西:三种结构化方案怎么选、with_structured_output的每个参数在什么场景下才需要动、文档解析场景(特别是合同、招标文件这类带页码和章节的文本)怎么设计字段和上下文,以及在LangGraph里怎么做输出校验与纠错回路。全程是我自己在项目中反复验证过的做法。

1. 设计方案:先想清楚结构化输出的边界与形态

1.1 三种主流方案的选型逻辑

很多人一上来就纠结with_structured_output和“提示词里写死JSON”哪个好,其实这两件事根本不在一个维度上。我建议把结构化的手段分成三层:

第一层是纯提示词约束:在System Prompt里写“请严格输出JSON格式,字段包括xxx”。这在概念验证阶段够用,但缺点很明显——模型对Prompt末尾的服从度通常高于中间部分,只要上下文一长、或者用户输入里出现类似JSON的干扰文本,格式就会跑偏。另外你还要自己处理Markdown代码块包裹、前后缀文字、转义符号这些脏东西。不是不能用,而是不该作为生产主力。

第二层是函数调用(Function Calling / Tool Calling):LangChain的bind_tools或者with_structured_output底层走的Function Calling模式。模型在生成时会收到一份“工具清单”,每个工具的参数就是JSON Schema,模型决定调用哪个工具、参数填什么。它输出的不是自由文本,而是一个结构化的工具调用对象。这一层的可靠性比纯提示词高一个量级,因为格式约束发生在模型推理的机制层面,而不是“希望它照着做”。

第三层是Schema约束模式:直接给模型一个JSON Schema,要求输出严格符合这个Schema的内容。在没有原生Function Calling支持的模型上,这是最好的替代方案;在支持Function Calling的模型上,它通常作为method="json_schema"存在。

在生产项目里,我的默认选择是with_structured_output,原因很简单:它对Pydantic模型的本地支持好,字段校验、嵌套模型、枚举约束都比手写JSON Schema舒服很多,而且切换模型时改动最小。如果你用的是OpenAI、Claude、Gemini这类支持Function Calling的模型,推荐直接用默认的method="function_calling";如果用的是Llama 3.1、Qwen这类开源模型在本地部署,可能要考虑method="json_mode",但前提是模型本身经过了指令微调。

1.2 生产级字段设计的标准

这个部分很多人不在乎,但恰恰是项目后期最费钱、最费时间的地方。字段设计得好不好,直接决定模型返回的准确率。我的经验可以浓缩成四条:

字段宁少勿多。一个结构化抽取任务,字段控制在10个以内,模型的表现会非常稳定;超过20个,漏字段、合并字段、凭空捏造字段的概率显著上升。如果确实需要很多输出项,就把它们拆成子模型,用嵌套而不是扁平。

能用枚举的别用自由文本。比如合同状态,不要定义成str,而是用Literal["待签署", "签署中", "已生效", "已终止"],模型从四个选项里挑,出错概率远低于让它自己生成字符串。同样的道理用在文档类型、条款类别这些有边界的字段上。

字段描述不是摆设。Pydantic里Field(description="...")的内容会被带进发给模型的JSON Schema,模型会用它来理解你想要的语义。我在实际项目中体会特别深:不加描述时“甲方”字段经常被填成公司全称,加了描述“合同中的甲方名称,通常位于合同首部或签署页,取全称”之后,准确率提升非常明显。描述写得好不好,比调temperature影响大多了。

嵌套层级不超过三层。结构化输出最终要落库或序列化,嵌套太深会让后续处理变得非常痛苦,而且模型在深层嵌套的字段上容易产生幻觉。如果在文档解析场景里必须保留层级关系(比如章节-条款-子条款),建议最多三层,再深就拆表。

我见过一个招标文件解析的项目,最初设计了四十多个扁平字段,模型返回的JSON经常缺胳膊少腿,后来改成分段抽取:先抽“招标编号+项目名称+采购人”,再抽“开标时间+预算金额+资质要求”,每个子任务只负责少量字段,准确率从勉强能用上升到可以直接入库。

2. LangChain结构化输出的核心细节与调用要点

2.1with_structured_output的参数与底层逻辑

with_structured_output是这个领域里LangChain最核心的接口。不少人就传一个Pydantic类进去,能用就完事,但真出问题时不知道从哪查起。我拆一下关键参数,以及什么情况下才会动到它们。

首先是model参数。不传的话用当前llm对象默认的模型,传了可以临时覆盖。有些模型对Function Calling的支持有版本差异,切换的时候建议显式传一下,省得排查半天。

然后是method参数,这个最重要,可选值有"function_calling"、"json_mode"、"json_schema",默认是根据模型推断。我的选型逻辑是:

模型类型推荐 method原因
OpenAI / Gemini / Claude 等商业APIfunction_calling原生Function Calling稳定,输出解析由SDK保证
Llama 3.1 / Qwen / ChatGLM 等开源模型json_mode大部分本地部署场景不支持稳定的Function Calling
任何模型但要求严格格式json_schema显式提供JSON Schema,模型按约束生成

真实项目里我还遇到过一种情况:公司的业务数据合规要求不能把Schema明文传给外部服务,这时候可以自己在提示词里描述字段含义,然后用with_structured_output却传一个只包含content字段的str模型,再把原始文本拿回来二次解析。这种方式绕,但我在一个涉敏项目里确实只能这么干。

再说include_raw参数。它默认False,只返回解析后的Pydantic对象;设为True时返回一个字典,里面包含raw(LLM原始返回的完整对象)、parsed(解析成功的模型实例)和parsing_error(解析失败时的异常)。这个参数在生产环境非常有用——你希望在解析失败时不至于让整个链路崩掉,把原始输出留一份用来排查,或者进入重试逻辑。

2.2 Pydantic模型怎么设计才不会踩坑

在LangChain 0.2和0.3时代,from pydantic import BaseModel, Field是最常见的写法。但有两个坑非常典型。

坑一:版本兼容问题。Pydantic v1和v2的字段类型行为不一致,如果你用的LangChain版本是基于v2编译的,模型类却用v1的方式写(比如class Config: schema_extra = ...),就会在构建JSON Schema时报错。项目里统一用pydantic.v1或者pydantic二选一,别混用。查这个坑的时候看报错尾巴就行,一般会提示AttributeError: 'Config' object has no attribute 'schema_extra'。

坑二:Field描述里放星号。我在一个项目里发现,某几个字段的抽取准确率明显低于其他字段,把生成的JSON Schema拉出来对比才发现,代码里某人在描述里写了一大段话,中间有一堆换行和特殊符号,导致Schema里的描述被模型理解得支离破碎。后来统一规范:描述写一两句干净的话,不加Markdown符号,不写排比,实测抽取稳定性提升特别明显。

如果你的结构化输出要作为后续Agent的输入,我建议同时实现一个to_dict()方法或者用model_dump()来转成字典。很多人在链路上传参时直接传Pydantic对象,在某些模型上会被隐式转成JSON字符串,结果下一跳的Agent拿到的是一个字符串而不是结构化对象,排查起来很耗时间。

2.3 指令注入:上下文和提示词的平衡

结构化输出的上下文不是越全越好,而是“够用且不偏”最好。举个例子:从合同里抽取“付款节点”,如果你把整份合同全文都塞给模型,它会因为信息量过大而把无关条款也当成付款节点。反过来,只给合同标题和正文第一段,它又可能漏掉分阶段付款的信息。

我实践下来的做法是:先做一次粗粒度的内容过滤(比如通过关键词定位条款范围),再把命中范围的文本块传给结构化抽取模型。这样模型面对的是“已缩小范围的信息”,抽取准确率和稳定性都能提升。

在LangChain里做这件事,可以用RunnableLambda写一个前置处理函数,把原始文档切成块、用检索器筛出相关块、拼接成上下文。这一步不是在模型调用外部做的,而是作为Chain的一部分跑,这样整体流程可以复用、可以替换、可以加缓存。

3. 实操链路:从文档解析到结构化输出的完整搭建

3.1 文档解析场景的结构化输出设计

最近好多人在做文档解析的结构化输出,输入是PDF,输出是包含页码、章节、段落的结构化文本。这类项目有几个共同难点:PDF里的文档结构不一致、表格和页眉页脚干扰、OCR识别结果带坐标噪声。这里我拿一个具体的合同解析项目来拆解。

先明确目标:从一份PDF格式的合同里抽取出:

  • contract_id:合同编号
  • parties:甲方和乙方名称
  • amount:合同金额(统一成数字)
  • effective_date:合同生效日期
  • payment_clauses:付款条款,每条要带页码和章节标题

这里最有技术含量的部分不是“让模型提取字段”,而是“让模型知道每个字段来自文档的哪个位置”。我的方案是:先解析PDF成带坐标和页码的块,然后将块与标题映射起来,再把这些信息作为上下文喂给结构化输出模型。

具体来说,用PDF解析工具(比如PyMuPDF或者我项目里用的PaddleOCR流程)拿到每个文本块的文本、所在page、bbox坐标。通过坐标判断它是不是标题、是不是表格、是不是页脚。这个环节的产出是一个按页码排序的文档块列表。

然后把文档块按长度窗口切分成overlap片段,每个片段带上元数据:

{ "page": 12, "section": "三、付款方式", "text": "在本合同生效之日起 15 个工作日内,甲方向乙方支付合同总金额的 30%……" }

这里的section字段不是模型生成的,而是通过文档的层级结构(标题检测和大小判断)预先标好的。模型要做的是在给定这些元数据的情况下抽取字段,而不是自己推断页码和章节。这一步非常关键——让模型做结构推断,它会一本正经地编造页码;让模型只做字段抽取,页码信息严格来自你提供的上下文,就不会有幻觉。

3.2 基于OCR的文档结构化链路

有些PDF是扫描件,没有文本层,这时候OCR是绕不开的一步。我项目里用的是PaddleOCR类的工具链,识别出文字后,每一条识别结果都带一个四边形坐标。坐标信息在结构化输出里非常有用,因为版面结构可以通过坐标推算出来。

我通常这样组织OCR后的处理流程:

  1. 按行做坐标排序,过滤掉页眉页脚等高置信度噪声块。
  2. 通过坐标重叠关系合并成段落块,记录起始页码和段落序号。
  3. 做标题识别:字体大小和加粗程度通常反映在检测框的宽高和文本密度上,设定阈值之后把标题行挑出来。
  4. 把结构化块列表喂给LangChain的上下文组装函数,再调用with_structured_output。

这个链路里最容易翻车的点是OCR会把表格线识别成字符,或者把两列文本的行搞乱。这时候如果直接抽取,模型给出的字段值很可能是串行的错位文本。我的经验是:在处理OCR文本块时,根据坐标x方向做分栏检测,左右两栏分开排序,再送进模型。

用MultVectorRetriever这类工具时,要注意它本质上是为了解决“文档既能按词检索又能按摘要检索”的问题,跟结构化抽取是两回事。但可以结合使用:先用多向量检索找到相关文档片段并带着页码章节信息,再用结构化抽取从片段里拿字段,两条工具链各干各的,组合起来正好解决“找不到信息”和“格式不规整”两个痛点。

3.3 在LangGraph里做输出校验与纠错回路

聊到LangGraph是因为最近太火了,很多人在问它和LangChain的区别。LangChain是组件和链的集合,适合线性流程;LangGraph则是图状态编排框架,适合有分支、循环和状态流转的场景。结构化输出本身不是LangGraph的专属功能,但LangGraph让“校验失败重试”这个逻辑变得非常干净。

结构化输出最大的隐性bug是:模型会对同一个字段在不同的重试轮次里给出互相矛盾的值。比如第一次抽取“合同金额”是120万,校验发现格式不对打回重来,第二次模型可能把金额改成121万——不是它算错了,而是它在重新读上下文时被新的干扰信息带偏了。

LangGraph的纠错回路可以这样设计:

一个节点负责调用with_structured_output,拿到返回结果后进入“校验节点”。校验节点里写业务逻辑,比如检查金额是否大于0、日期格式是否合法、必填字段是否为空。如果校验失败,就把错误信息作为新的提示词注入到上下文里,再让模型重抽一次。

这个“校验失败信息”的注入方式很有讲究。不要直接报“格式错误”,而要告诉模型“你返回的金额字段缺失,请根据合同三级条款中的第4.1条内容重新提取”。如果系统里有专门的校验日志,把日志的关键错误信息映射成小段提示语,重试效果会比空洞的“请重新生成”好得多。

我用LangGraph完整搭过的流程大概是这样:文档输入 -> 预处理节点 -> 检索节点 -> 结构化抽取节点 -> 业务校验节点 -> (失败回边到抽取节点) -> 成功输出。整个过程的核心就是靠图的可控性把“抽取+校验+纠错”串成闭环。

3.4 RAG场景下结构化输出的整合方式

RAG流程里,很多人把结构化输出当成最后一步的“格式化皮革”,其实结构化输出完全可以在RAG的多个节点中发挥作用。

在文档切分节点,可以用结构化输出判断一个段落属于哪个章节、是否包含条款列表。在检索节点,可以先让模型生成一个“查询意图对象”(包括查询关键词、期望的文档类型、时间范围),再拿这个对象去检索,比直接embedding效果好,尤其是面对“第二期付款条件是什么”这种带指代的query。

在答案生成节点,结构化输出能让RAG最终产出的不是一段自然语言,而是“答案摘要+引用页码+原文片段”的对象。这样前端可以直接渲染引用来源,而不是让用户面对一段不知道从哪来的文字。

我做过一个招标文件问答系统,最终输出结构是:

{ "answer": "投标截止时间为2025年8月30日17:00", "confidence": 0.94, "evidence": [ { "page": 8, "section": "投标须知", "text": "投标文件递交截止时间:2025年8月30日17:00" } ] }

这个结构不是模型自由发挥出来的,而是我用Pydantic模型明确定义出来的。效果是每个回答后面都能附上原文出处,业务方看到这个结构后非常满意,因为“有据可查”在整个合规流程里至关重要。

4. 常见问题排查与避坑实录

4.1 典型错误速查表

结构化输出的坑大部分集中在“模型返回的内容无法解析”和“模型返回了能解析但业务上不合法”两层。我做了一个速查表,基本覆盖了生产项目里95%的问题:

现象原因操作
输出里出现“```json”包裹走的是普通文本生成而不是Function Calling检查method参数,确认被正确设置
必填字段偶尔缺失Schema字段定义太复杂或上下文被截断减少字段数量,增加字段描述,启用include_raw保留现场
金额被返回到错误字段字段语义重叠给每个字段写清晰描述,或用Literal枚举限定候选
重试N次仍然解析失败模型返回的是拒绝信息而非数据检查提示词和上下文长度,必要时对模型做防护性指令
日期格式不统一缺少格式约束用Field(pattern=...)或validator统一格式
Pydantic解析报validation errors返回字符串和Schema不匹配开启include_raw=True看原始返回,再定位是哪个字段

4.2 Agent场景下结构化输出的坑

在Agent场景里使用结构化输出,大多数坑来自Agent内部调用了多个工具,而让Agent“结构化输出”时它可能会在回复中把工具调用的信息也混进来。

我自己在做一个读取测试用例、自动生成UI自动化脚本的Agent时踩过一个坑:Agent需要输出“测试步骤列表”和“每条步骤对应的选择器和操作”。我定了Pydantic模型,模型也确实输出了一个看起来很合理的测试脚本,但执行时发现,它把步骤里的期望结果字段给“脑补”了——原测试用例里根本没有“期望结果”这个字段,模型自己从步骤描述里推断了一段文字出来。

这类问题的根源在于:结构化输出约束了格式,但没有约束内容的边界。生产级做法是:在Pydantic字段描述里明确“只能从输入中提取,不得推断或补充”,并在校验节点做字段级别的“是否在输入片段中存在”检查。如果发现模型填了原文没有的内容,就打回重抽。这个原则在合同、法律文书类场景里尤其重要。

4.3 关于DeepAgents与LangChain技术选型的几句实话

最近很多人问DeepAgents能力怎么样,跟Claude比差距在哪,以及LangChain是不是过时了。我的看法是:Agent编排层的东西更新换代很快,但结构化输出不会过时——因为不管Agent多聪明,最终跟数据库打交道、跟业务流程对接、跟外部系统集成,都需要一个稳定的结构。

DeepAgents这类框架把人机协作风筝放得很高,确实能做复杂的任务分解和路线规划,但在真实生产环境,我发现决定一个Agent能不能上线,反而不是它拆解任务多厉害,而是它的每一步输出能不能被校验、被追踪、被回滚。LangChain在“结构化输出”这个环节依然是最成熟、文档最全的,这正是它在这个阶段最大的价值。如果你在做文档解析、RAG问答、合同审查这样的项目,结构化输出本身就是让你稳定落地的最短路径。

5. 关于LangChain为何“难用”的深层认知

我会经常收到一种反馈:LangChain难用、API太抽象、同一个功能有好几种写法,学起来让人头大。说这些话的人大概率是在拿LangChain当简单封装工具用,然后发现封装层太厚,出了问题不好定位。这个感受很真实。

但换个角度看,LangChain本质上是“把LLM应用做成可组合的流程”。结构化输出只是其中一环,它上面叠Runnable、LCEL、Agent、Memory、Callback,每一层都是可替换的。难用的本质是概念多、层与层之间松耦合,不像普通函数调用那样一条直线。可一旦你理解了“Runnable → Chain → Graph”的抽象递进关系,再回头看结构化输出,就会觉得它已经算整个框架里最直观的部分了。

我自己的学习路径是:先从最简单的llm | output_parser开始跑通一次带JSON解析的调用,然后换成with_structured_output,感受一下它帮你省了什么。再手写一次JSON Schema解析,体会一下最底层的工作量。最后再去碰LangGraph。这样一路下来,你对每一步的能力边界和代价会非常清楚,不至于在排查问题时两眼一抹黑。

坑踩多了之后,我养成了一个习惯:任何结构化输出项目,第一版永远先定义一个最少的Pydantic模型,只放三个必填字段,跑通全链路,再加字段。理由无他——结构化输出的复杂度是随着字段数量非线性增长的,先用最小闭环验证整体设计,再逐步充实,能省掉大量在字段上反复调试的时间。

最后再说说个人偏好:我一般把include_raw=True在生产环境常开。有人觉得它浪费token,因为要额外返回原始内容,但排查问题的时候那点token花得绝对值。有了raw,你可以精确知道模型到底说了什么,是字段名变了,还是类型不对,是截断了,还是压根没走Schema——这种可观测性在结构化输出链路里千金不换。

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

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

立即咨询