LangChain withStructuredOutput 实战:让大模型稳定输出结构化数据
2026/9/23 6:06:27 网站建设 项目流程

1. 为什么大模型输出总像“开盲盒”

做过大模型应用的人大概都有过这种体验:你精心写好一段提示词,要求模型返回一个 JSON,字段名、类型、嵌套结构都交代得清清楚楚,结果它给你返回一段带 markdown 代码块包裹的文本,或者字段名拼错、类型对不上、多塞了一个解释性句子。更离谱的是,同样的输入,跑十次能给你五种不同的格式。这不是模型不听话,而是它本质上是个“概率文本生成器”,它输出的是 token 序列,不是数据结构。

早期大家怎么解决这个问题?两条路。一条是“提示词工程硬扛”——在 prompt 里反复强调“只返回 JSON,不要任何解释”,然后在代码里用正则去抠、用json.loads去试,失败了就重试。另一条是“后处理兜底”——写一堆解析函数,处理各种边界情况。这两条路我都走过,说实话,能跑,但极其脆弱。模型稍微换个版本、温度调高一点、输入复杂一点,解析就崩。维护成本高得吓人,而且你永远不知道下一次线上报错会从哪个角落冒出来。

withStructuredOutput这个东西出现的意义,就是把“让模型输出结构化数据”从“求它配合”变成“强制它遵守”。它不是提示词技巧,而是把输出格式约束下沉到了模型调用层。LangChain 在这块做了不少工作,配合 Zod 做 schema 定义,配合 tool call 机制做底层约束,让结构化输出从“玄学”变成了“工程”。这篇内容我就围绕这个主题,把它的原理、用法、坑点和实战经验完整拆一遍。

提示:这篇内容假设你对 LangChain 的基本调用方式有了解,至少用过ChatOpenAI或者类似的 chat model 类。如果完全没接触过,建议先跑通一个最简单的invoke再回来看。

2. withStructuredOutput 到底在底层做了什么

2.1 它不是“更聪明的提示词”,而是换了调用协议

很多人第一次用withStructuredOutput的时候,会以为它就是在 prompt 末尾自动追加了一段“请返回 JSON”的指令。这个理解是错的,而且错得挺关键。如果你以为它只是提示词层面的封装,那你就不会理解为什么它比手写 prompt 稳定那么多。

实际上,withStructuredOutput的核心机制是把 schema 转换成模型原生支持的“工具调用”或“函数调用”协议。以 OpenAI 系列模型为例,它走的是tools参数(以前叫functions)。你传给模型的不是一个“请你返回这种格式”的自然语言请求,而是一个正式的 tool 定义,模型在解码阶段就被约束只能从这个 tool 的参数结构里生成内容。

这两者的区别有多大?打个比方:手写 prompt 像是你跟一个实习生说“帮我填个表,表头是姓名、年龄、部门”,他可能填对,也可能把“年龄”写成“年纪”,还可能顺手加一句“我觉得这个表设计得不错”。而 tool call 像是你直接把一张带数据校验的电子表单推到他面前,他只能在格子里填,填错类型系统直接拒绝提交。

2.2 schema 是怎么变成模型能理解的约束的

LangChain 的withStructuredOutput接受两种 schema 定义方式:一种是 Zod schema,一种是 JSON Schema。Zod 是 TypeScript 生态里的运行时类型校验库,写起来很直观。比如你要模型返回一个“人物信息”,可以这样定义:

import { z } from "zod"; const PersonSchema = z.object({ name: z.string().describe("人物的全名"), age: z.number().describe("人物的年龄,整数"), skills: z.array(z.string()).describe("技能列表"), address: z.object({ city: z.string(), country: z.string(), }).optional(), });

LangChain 拿到这个 Zod schema 之后,会做几件事:第一,把它转换成 JSON Schema 格式;第二,把 JSON Schema 塞进 tool 定义的parameters字段;第三,在调用模型时带上tool_choice参数,强制模型必须调用这个 tool。模型返回的就不再是自由文本,而是一个符合 schema 的 JSON 对象。

.describe()这个方法值得单独说一句。它不只是给人看的注释,LangChain 会把描述文本一起传给模型,作为字段语义的补充说明。这在字段名不够直观的时候特别有用。比如你有个字段叫score,模型不知道是 0-100 还是 0-1,加一句.describe("评分,范围 0 到 100 的整数")就能大幅降低歧义。

2.3 tool call 和 JSON mode 的区别,别搞混

这里要澄清一个容易混淆的点。OpenAI 提供了两种结构化输出能力:一种是response_format: { type: "json_object" },也就是所谓的 JSON mode;另一种是 tool call。两者都能让模型返回 JSON,但约束强度完全不同。

JSON mode 只保证“返回的是合法 JSON”,但不保证字段名、类型、嵌套结构符合你的预期。你让它返回{name, age},它可能返回{fullName, years},语法上没问题,但你的代码解析就炸了。而 tool call 是带 schema 约束的,模型在生成时就被限制在 schema 定义的字段和类型里,字段名不会跑偏,类型也不会乱来。

withStructuredOutput默认走的是 tool call 路径。这也是它比手写 JSON mode 更可靠的根本原因。当然,不同模型提供商的支持程度不一样,有些模型不支持 tool call,LangChain 会退回到 JSON mode 加提示词约束的方案,这时候稳定性就会打折扣。所以选模型的时候,优先选原生支持 tool call 的。

3. 从零跑通一个结构化输出:完整代码链路

3.1 环境准备和依赖选择

先把环境搭起来。LangChain 的 JS/TS 版本迭代很快,建议用较新的稳定版。核心依赖是@langchain/core和对应的模型包,比如@langchain/openai。Zod 是必须的,因为 schema 定义靠它。

npm install @langchain/core @langchain/openai zod

如果你用 Python,对应的是langchain-corelangchain-openaipydantic。Python 那边用 Pydantic 做 schema 定义,思路和 Zod 一样,只是语法不同。这篇主要用 TS 举例,Python 用户把 Zod 换成 Pydantic 的BaseModel就行,概念完全对应。

注意:LangChain 的包拆分比较细,@langchain/core提供基础抽象,具体模型在各自的包里。别只装一个langchain就以为万事大吉,模型包不装是跑不起来的。

3.2 定义 schema 的几个实战原则

schema 定义看着简单,但实际项目里踩坑最多的就是这一步。我总结了几条原则,都是被线上问题教育出来的。

第一,字段名用英文,描述用中文。字段名是给代码用的,保持英文命名规范,避免编码问题。描述是给模型看的,用中文把语义说清楚。模型对中文描述的理解能力已经足够好,不用担心。

第二,能扁平就别嵌套。嵌套结构虽然 schema 支持,但模型在深层嵌套上的准确率会下降。如果业务允许,把嵌套拍平成address_cityaddress_country这种形式,稳定性会明显提升。如果非要嵌套,层级别超过两层。

第三,枚举值一定要用enum约束。比如状态字段只有“待处理、处理中、已完成”三种,别用z.string(),用z.enum(["pending", "processing", "done"])。这样模型只能在三个值里选,不会给你编出第四个。

第四,数组元素类型要明确z.array(z.string())z.array(z.object({...}))的稳定性差异很大。如果数组里是对象,确保每个字段都有描述。

const TaskSchema = z.object({ title: z.string().describe("任务标题,不超过 50 字"), priority: z.enum(["low", "medium", "high"]).describe("优先级"), tags: z.array(z.string()).describe("标签列表,每个标签不超过 10 字"), estimated_hours: z.number().describe("预估工时,单位小时,可以是小数"), assignee: z.string().optional().describe("负责人姓名,没有则为空"), });

3.3 绑定模型并调用

schema 定义好之后,绑定和调用其实很简洁:

import { ChatOpenAI } from "@langchain/openai"; const model = new ChatOpenAI({ modelName: "gpt-4o-mini", temperature: 0, }); const structuredModel = model.withStructuredOutput(TaskSchema); const result = await structuredModel.invoke( "帮我把这句话拆成任务:下周三之前完成用户登录模块的重构,优先级高,涉及前端和后端,大概需要 16 小时,交给张三。" ); console.log(result); // { title: "用户登录模块重构", priority: "high", tags: ["前端", "后端"], estimated_hours: 16, assignee: "张三" }

注意temperature: 0这个设置。结构化输出场景下,你不需要模型的“创造力”,你需要的是稳定和一致。温度调高只会增加格式跑偏的概率,没有任何好处。这一点很多人会忽略,觉得温度低输出太死板,但结构化输出要的就是死板。

3.4 返回值类型和错误处理

withStructuredOutput返回的是一个 Runnable,invoke之后拿到的是已经解析好的对象,不需要你再手动JSON.parse。这是它比手写解析舒服的地方。但别以为这样就万事大吉了,错误处理还是要做。

常见的失败情况有三种:模型返回了不符合 schema 的内容(虽然概率低但存在)、网络超时、模型拒绝回答。LangChain 在解析失败时会抛异常,你需要 catch 住做降级处理。我的做法是包一层重试逻辑,第一次失败后把 temperature 再压低、把 prompt 再精简一次重试,连续失败两次就走人工兜底或者返回默认值。

async function safeInvoke(input: string, retries = 2) { for (let i = 0; i < retries; i++) { try { return await structuredModel.invoke(input); } catch (e) { if (i === retries - 1) throw e; console.warn(`第 ${i + 1} 次结构化输出失败,重试中...`); } } }

4. 那些文档里不会写的坑

4.1 可选字段的“薛定谔状态”

.optional()看起来很美,但实际用起来有个微妙的问题:模型有时候会返回null,有时候会直接省略这个字段,有时候会返回空字符串。这三种情况在你的代码里处理方式可能完全不同。如果你用result.assignee直接判断,nullundefined的行为差异可能导致 bug。

我的建议是,在 schema 层面尽量少用 optional。如果某个字段业务上允许为空,用z.string().nullable()明确允许 null,然后在代码里统一做?? ""的兜底。这样至少行为是可预期的。optional 留给那些“真的可能完全不存在”的字段,比如某些条件分支下才有的属性。

4.2 数字类型的精度陷阱

模型返回数字的时候,有时候会给你返回字符串形式的数字,比如"16"而不是16。虽然 tool call 协议理论上会做类型约束,但在某些模型和某些边界情况下,这个约束不是 100% 可靠的。特别是当数字出现在描述性文本里被模型“顺手”提取出来的时候。

防御性做法是在 schema 里用z.number()的同时,在拿到结果后做一次显式转换和校验。如果业务对数字精度敏感(比如金额),建议在 schema 里用字符串接收,然后在代码里用专门的 decimal 库解析。浮点数在 JSON 里的精度问题是个老话题,别在这里栽跟头。

4.3 长文本字段被截断

如果你有个字段需要模型返回较长的文本,比如“摘要”或者“详细描述”,要注意模型的输出长度限制。tool call 的参数生成也受 max tokens 约束,字段内容太长会被截断,而且截断后的 JSON 可能直接解析失败。

应对方式有两个:一是把长文本字段单独拆出来,不要和结构化字段混在一次调用里;二是在 schema 描述里明确限制字数,比如.describe("摘要,不超过 200 字"),给模型一个明确的边界。实测下来,加了字数限制之后,截断问题会少很多。

4.4 不同模型提供商的行为差异

withStructuredOutput是个抽象层,底层走的是各家模型自己的能力。OpenAI 的 tool call 支持最成熟,Anthropic 的 Claude 系列也支持得不错,但一些开源模型或者国内模型的 tool call 实现质量参差不齐。有的模型虽然声称支持 function calling,但实际调用时 schema 遵循度很差,字段名乱写、类型乱给的情况时有发生。

选型的时候,别只看“支持不支持”,要看“支持得好不好”。我的经验是,涉及结构化输出的核心链路,优先用 tool call 支持成熟的模型。如果成本敏感必须用便宜模型,那就在测试阶段多跑一些边界 case,把失败率摸清楚,再决定要不要加人工兜底。

模型类型tool call 支持schema 遵循度建议
GPT-4o 系列原生支持核心链路首选
Claude 系列原生支持可作为备选
部分开源模型声称支持中低需充分测试
老版本模型不支持依赖提示词不推荐用于结构化场景

5. 把结构化输出接进真实业务链路

5.1 信息抽取场景:从非结构化文本到数据库记录

结构化输出最直接的应用就是信息抽取。比如你有一堆用户提交的工单文本,需要提取出“问题类型、紧急程度、涉及产品、联系方式”这些字段存进数据库。以前的做法是写正则或者训练专门的 NER 模型,现在用withStructuredOutput几行代码就能搞定,而且泛化能力比正则强得多。

关键在于 schema 的设计要贴合业务表结构。数据库里是什么字段、什么类型,schema 就怎么定义。这样模型输出直接就能入库,中间不需要再做映射转换。我做过一个工单分类的项目,schema 里直接把category定义成 enum,把数据库里所有分类枚举值列进去,模型输出的分类结果直接就是合法的数据库值,省掉了一层校验。

5.2 Agent 工具调用的参数构造

在 Agent 场景里,模型需要决定调用哪个工具、传什么参数。这个“决定”的过程,本质上就是结构化输出。LangChain 的 Agent 实现里,工具的调用参数就是通过类似withStructuredOutput的机制生成的。理解了这个,你就能明白为什么工具的参数 schema 定义得越清晰,Agent 的表现就越好。

如果你在自定义工具,参数定义一定要用 Zod 或 Pydantic 写清楚,每个参数加.describe()。模型是靠这些描述来理解参数含义的。一个没有描述的query: z.string()和一个有描述的query: z.string().describe("搜索关键词,用空格分隔多个词"),模型的使用准确率差距很明显。

5.3 多步推理中的中间结果结构化

复杂任务往往需要多步推理,每一步的中间结果如果都是自由文本,后续步骤就很难可靠地消费。把每一步的中间结果都用结构化输出固定下来,整个链路就变得可追踪、可调试。比如一个“合同审核”的流程:第一步抽取合同关键条款(结构化),第二步比对条款和标准模板的差异(结构化),第三步生成审核意见(结构化)。每一步的输出都是下一步的输入,格式稳定,链路就稳。

这种设计还有个好处:中间结果可以落库、可以人工复核、可以做审计。自由文本做不到这些,结构化数据天然适合做流程管理。

6. 性能、成本和稳定性的平衡

6.1 结构化输出会不会更慢更贵

会,但幅度可控。tool call 相比普通文本生成,会多消耗一些 token 在 schema 定义和 tool 描述上。如果你的 schema 很复杂,字段很多,这部分开销不能忽略。实测下来,一个中等复杂度的 schema(10 个字段左右),每次调用的额外 token 开销大概在几百个 token 的量级。

但换个角度算账:手写 prompt 加解析加重试的方案,失败重试的 token 消耗和工程维护成本加起来,往往比结构化输出更高。而且结构化输出减少了大量“格式不对导致的下游报错”,这部分隐性成本才是大头。所以从总账来看,结构化输出通常是更划算的。

6.2 什么时候不该用结构化输出

不是所有场景都适合。如果你的任务本身就是开放式的,比如“写一段文案”“生成一个故事”,强行套 schema 反而会限制模型的表现。结构化输出适合的是“信息提取、分类、参数构造”这类有明确目标结构的任务。判断标准很简单:如果你能用表格或者 JSON 把期望的输出描述清楚,那就适合;如果你自己都说不清输出应该长什么样,那就别用。

另外,如果对延迟极度敏感(比如实时对话场景),tool call 的额外开销可能成为瓶颈。这时候可以考虑把结构化输出放在异步链路里,或者用更轻量的模型专门做结构化抽取,主对话链路还是走普通生成。

6.3 缓存和批处理的优化空间

结构化输出的结果天然适合缓存。因为输入相同、schema 相同的情况下,输出应该是确定的(temperature 为 0 时)。如果你的业务里有大量重复的抽取请求,加一层缓存能省不少钱。LangChain 本身支持 cache 机制,可以接内存缓存或者 Redis。

批处理方面,如果有一大批文本需要抽取,不要一条一条调,尽量用 batch 接口。LangChain 的 Runnable 支持batch方法,能并发处理多条输入。但要注意控制并发数,别把 API 限流打爆了。我一般设 5 到 10 的并发,根据模型的 rate limit 调整。

7. 我踩过的几个真实坑和最终方案

说几个具体的。第一个坑是 schema 里的字段描述写得太模糊,导致模型在边界 case 上判断摇摆。比如有个字段叫type,我描述写的是“类型”,结果模型有时候返回“咨询”,有时候返回“问题咨询”,有时候返回“inquiry”。后来把描述改成“问题类型,只能是以下之一:咨询、投诉、建议、其他”,并且用 enum 约束,问题立刻消失。描述要具体到模型不需要“猜”的程度,这是血泪教训。

第二个坑是嵌套数组的稳定性。有个 schema 需要返回一个“订单列表”,每个订单里有“商品列表”,商品里有“规格”。三层嵌套,模型在深层字段上的准确率明显下降,经常把规格信息塞到商品层级。后来我把结构拍平,改成一次调用只抽取一层,分多次调用再在代码里组装。虽然多了一次调用,但准确率从 70% 多提升到了 95% 以上,总体成本反而更低,因为重试少了。

第三个坑是模型版本升级导致的 schema 遵循度变化。有一次模型小版本更新,同样的 schema 和 prompt,之前跑得好好的,更新后开始出现字段缺失。排查了半天才发现是新版本对 optional 字段的处理逻辑变了。所以模型版本要锁定,升级前必须跑回归测试,别用latest这种浮动标签。

第四个坑是错误处理里的无限重试。早期我写的重试逻辑没有上限,遇到模型持续返回异常格式的情况,直接死循环把额度烧光了。后来改成最多重试两次,第二次失败就走降级路径。降级路径可以是返回一个默认结构、可以是转人工、也可以是抛异常让上游处理,但绝不能无限重试。

8. 结构化输出之后,下一步往哪走

withStructuredOutput解决的是“单次调用的输出格式”问题。但真实业务里,一次调用往往不够,你需要多步、多工具、多轮交互。这时候就涉及到 LangGraph 这类编排框架了。LangGraph 和 LangChain 的关系,简单说就是 LangChain 提供组件(模型、工具、解析器),LangGraph 提供流程编排(状态机、条件分支、循环)。结构化输出是 LangGraph 节点之间传递数据的基础,没有稳定的结构化输出,图里的边就没法可靠地做条件判断。

所以我的建议是,先把withStructuredOutput用熟,把 schema 设计、错误处理、降级策略这套东西摸透,再去上 LangGraph。否则你会发现图搭起来了,但每个节点之间的数据传递都在出问题,调试起来非常痛苦。结构化输出是地基,地基不稳,上层建筑越高越危险。

另外,Zod schema 本身也可以复用在 API 的输入校验上。同一套 schema,既用来约束模型输出,又用来校验前端传参,还能生成 TypeScript 类型,一举三得。这种“schema 即契约”的思路,在工程上很值得推广。我现在做新项目,第一步就是先把核心数据结构的 Zod schema 定义出来,模型调用、API 校验、类型生成全都围绕它来,一致性好了很多。

最后分享一个我常用的调试技巧:把withStructuredOutput的调用结果和原始返回都打日志。LangChain 在某些情况下会做内部重试或者格式修复,你看到的最终结果可能和模型第一次返回的不一样。把原始返回打出来,能帮你判断问题出在模型层还是解析层。这个日志在排查线上问题时特别有用,建议默认打开。

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

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

立即咨询