拓冰建站拓冰建站
首页 / 资讯中心 / 正文

LangChain结构化输出实战:用Zod给大模型JSON结果加上类型枷锁

如果你写过几个 LangChain 应用早晚会碰到这个尴尬模型能力很强但返回给你的 JSON 总有点“叛逆”。字段名大小写飘忽不定该是数组的时候给你逗号分隔的字符串甚至直接回一句“好的这是您要的信息”收尾。用户在线等代码却只能面对一个 unknown。这种时候你才明白大模型输出不能靠祈祷得靠类型约束。所谓 LangChain 结构化输出简单说就是在调用大模型之前先把输出格式定义成一个“契约”让模型按 Schema 返回而不是自由发挥。LangChain 的withStructuredOutput就是干这个的。配合 TypeScript 生态里的 Zod你可以在运行前定义契约、运行后校验结果把“模型说啥是啥”变成“不符合格式就拒绝”。如果你是 Python 背景可以把 Zod 理解成 pydantic 在 TypeScript 里的亲戚只不过它更轻、声明式更强。下面我会从为什么、怎么做、踩坑三个方面展开以 LangChain.js 和 OpenAI 风格接口为例代码可以直接复制到项目里试用。1. 为什么需要给 AI 输出“上枷锁”1.1 没有约束的模型输出有多难用大模型本质上是一个“下一个 Token 预测器”它输出的每一个词都是按概率采样出来的。你让它抽取一条联系人信息它可能给你规规矩矩的 JSON也可能给你一段 Markdown甚至来一句“根据您提供的内容我提取到以下信息”然后后面跟着一个格式不完整的列表。这种不确定性对下游代码是灾难。假设你做一个信息抽取服务模型返回了姓名张三 年龄28 城市杭州你的第一反应是用JSON.parse结果直接抛异常。于是你开始写正则先把“姓名”替换成双引号字段再把换行拆开。第一次能用第二次模型输出“张三男28岁住在杭州”正则又废了。这不是模型蠢而是你压根没给它一个明确的“输出格式契约”。大模型不知道下游要的是一个严格 JSON 对象它只知道你在问它信息。所以第一步要建立一种机制让模型从“自由写作”切换到“填表格”。1.2 结构化输出的本质把概率生成变成可编程接口LangChain 结构化输出并不是什么黑魔法它只是组合了几种模型能力一是 function calling / tool calling。模型本身支持“调用工具”这种训练模式我们传入一个工具函数工具的参数由 JSON Schema 描述。模型会返回一个结构化的参数对象而不是自由文本。二是 JSON mode / response_format。部分模型服务支持强制模型输出合法 JSON我们只需要把 JSON Schema 规范传过去模型就在这个框架内生成内容。三是提示词加解析器。我们在 prompt 里写下“必须只输出 JSON字段包括 xx”再用一个 parser 把文本解析成对象。这三种路线可靠性不一样后面我会详细拆。但核心思想是一致的与其事后用正则擦屁股不如在生成之前就用 Schema 锁死边界。Schema 就像给 AI 输出套上了类型枷锁你能在编译期知道字段名在运行期校验字段值程序才不会因为一个换行符崩掉。1.3 适用场景和选型总览结构化输出适合的场景很多我挑几个最常见的场景不结构化的痛苦结构化后的价值信息抽取模型返回一段话还得二次解析直接得到字段对象入库或展示意图识别判断逻辑靠关键词漏一堆返回意图枚举值路由稳工具调用 / Agent参数传不进去工具链经常断参数天然符合函数签名ETL / 数据清洗文本规则天天改格式收敛规则只维护 Schema选型上我的建议是能用withStructuredOutput就不要手写 JSON 咒语。如果你在 TypeScript 项目里第一步先引入 Zod 定义 Schema如果你在 Python 项目里对应的就是 pydantic。这套思路是通用的区别只是语法。2. 方案选型Zod 凭什么和 LangChain 搭档2.1 Schema 不只是“类型”而是一份运行时契约TypeScript 的interface只在编译期存在编译完就消失了。你没法把一个interface传给运行时函数也没法让它去校验真实数据。但 Zod 的 Schema 是一个真实的对象它既描述了结构又具备运行时解析能力。import { z } from zod; const PersonSchema z.object({ name: z.string(), age: z.number(), });这个PersonSchema可以在运行时调用const data PersonSchema.parse({ name: 张三, age: 28 }); // 抛错因为 age 不是数字更重要的是Zod Schema 可以被转换成 JSON Schema。LangChain 和模型服务能理解 JSON Schema所以你把 Zod 定义交给 LangChainLangChain 内部会把它转换成模型能读懂的约束描述。这样你只需要维护一份 TypeScript 侧的类型不需要手写两套定义。2.2 LangChain 结构化输出的几条技术路线我把 LangChain 生态里常用的几条路线放在一起对比路线原理可靠性成本适合场景提示词 解析器prompt 写死 JSON 格式再用 parser 解析中低看模型心情最低本地小模型、老的兼容接口withStructuredOutput内部走 function calling 或 response_format高稍高会占一点 token大部分生产场景自定义 tool calling手动传 tools自己处理返回高需要写更多代码需要精细控制工具参数withStructuredOutput是 LangChain 封装得最好的一层。你传给它一个 Zod Schema它会帮你判断当前模型支持哪种机制把 Schema 转成对应的工具参数或响应格式然后把模型返回的内容直接给你。对业务代码来说调用方式非常干净。需要注意withStructuredOutput的重点是“约束”并不保证数据一定经过 Zod 的parse。也就是说如果模型漏了一个字段返回的对象里可能就没有这个字段。Zod 的默认值、校验要生效需要你在拿到结果后再显式PersonSchema.parse(result)。这个区别很多人会忽略后面我会再讲。2.3 为什么 JavaScript 生态我首选 ZodTypeScript 项目里做运行时校验选择不少Zod、Yup、TypeBox、Valibot都能定义 Schema。但我现在基本固定用 Zod理由是它对 LangChain 的适配最顺社区示例多类型推断也自然。Zod 的.describe()是一个被低估的杀手级功能。比如你要让模型输出一个地区字段region: z.enum([CN, US]).describe(ISO 3166-1 alpha-2 国家代码中国为 CN美国为 US)这个 description 会被转成 JSON Schema 里的字段说明最终变成模型看到的提示词。字段名region信息量不够但那段描述会把边界说清楚。模型是根据这个描述生成内容的所以写 description 实际上是在写提示词只不过它长在 Schema 里。不要用 Yup不是它不行而是 LangChain 官方对 Zod 的支持更常见。你想搜问题时中文和英文的答案基本都是 Zod 版本。3. 从零开始LangChain Zod 最小可运行示例3.1 环境准备与依赖安装先建一个 Node 项目建议 Node 18 以上。安装依赖npm install langchain/openai langchain/core langchain zod zod-to-json-schema这里langchain/openai负责接入 OpenAI 风格的模型服务zod-to-json-schema负责把 Zod 转成模型需要的 JSON Schema。如果你用的是 Anthropic、Ollama 等把模型包换掉调用的方式基本不变。然后配置环境变量export OPENAI_API_KEY你的密钥如果你用的是国内云厂商的 OpenAI 兼容接口也可以通过baseURL参数指定服务地址。只要协议兼容下面代码基本不用改。3.2 用 Zod 定义输出契约我们做一个最简单的例子让模型讲一个程序员冷笑话并且要求输出套路、笑点和评分三个字段。import { z } from zod; const JokeSchema z.object({ setup: z.string().describe(笑话的铺垫部分陈述背景或情境), punchline: z.string().describe(笑点负责反转并发笑), rating: z.number().min(1).max(10).describe(从 1 到 10 给这个笑话打分), });注意rating用了z.number().min(1).max(10)Zod 会在运行时校验范围同时模型生成的 JSON 也更可能在合理区间内。这里我给每个字段都加了.describe()这不是废话。没有描述时模型只能靠字段名猜语义加了描述后模型才知道setup到底要写什么内容。描述越具体输出越稳。3.3 调用 withStructuredOutput 拿稳定结果接下来创建模型并绑定 Schemaimport { ChatOpenAI } from langchain/openai; const model new ChatOpenAI({ model: gpt-4o-mini, temperature: 0, }); const structuredModel model.withStructuredOutput(JokeSchema); const result await structuredModel.invoke(讲一个程序员的冷笑话); console.log(result);temperature: 0是为了减少随机性。结构化输出虽然已经约束了格式但温度太高仍然会影响内容质量所以我一般直接设 0。运行后result是一个普通对象{ setup: 为什么程序员分不清万圣节和圣诞节, punchline: 因为 Oct 31 和 Dec 25 在程序员眼里都是 oct dec。, rating: 7 }这个对象可以直接用于业务逻辑不需要再写正则从字符串里抠字段。如果你用的是 IDEresult还会因为z.infer自动有类型提示写代码的时候非常舒服。4. 进阶字段描述、嵌套结构和默认值4.1 describe 才是真正的“提示词”我见过很多项目Schema 里只有字段名没有 description然后抱怨模型输出不稳定。其实问题不在模型而在你给模型的“说明书”不够清楚。看一个对比。假设你要抽取地址address: z.string()模型可能给你“杭州市西湖区文三路 100 号”也可能给你“浙江杭州西湖区文三路 100 号”你不能确定它会不会带省市区前缀。改成address: z.string().describe(用户当前所在城市只写到城市级别例如杭州)模型就会尽量按“杭州”这种粒度输出。不是百分百但概率大增。写 enum 也一样risk: z.enum([LOW, MEDIUM, HIGH]).describe(贷款申请风险等级)别只写z.enum([LOW, MEDIUM, HIGH])模型不一定能猜到这几个值的完整含义。补一句 description就是告诉模型“这几个字符串代表什么、什么时候用哪个”。还有一个经验不要在 description 里用团队内部缩写。模型没见过你们公司的BGB、SNC这些缩写对它只是噪声。4.2 嵌套对象与数组的实践真实业务很少只有一个扁平对象更多是嵌套结构。Zod 对嵌套支持很好const PersonSchema z.object({ name: z.string().describe(联系人姓名), age: z.number().describe(年龄), contacts: z.array( z.object({ type: z.enum([email, phone]).describe(联系方式类型), value: z.string().describe(具体的联系方式内容), }) ).describe(联系列表至少一项), address: z.object({ city: z.string().describe(城市), street: z.string().optional().describe(街道可能为空), }).describe(住址信息), });模型在生成时contacts会变成数组里面的对象也按type、value两个字段走。比“电话xxx邮箱yyy”这种自由文本好解析一万倍。嵌套不是越深越好。我个人的经验是不超过三层超过之后模型容易漏层级而且 token 消耗会明显上升。如果需求真的很复杂优先拆成多个模型调用而不是逼模型一次生成一个巨大的 JSON。4.3 默认值、可选字段与后处理Zod 里optional()表示字段可以不存在default()表示缺失时填充默认值。这两个东西在结构化输出里非常实用因为模型漏字段是常态。const ContactSchema z.object({ name: z.string().describe(姓名), phone: z.string().optional().describe(手机号没有则不填), tags: z.array(z.string()).default([]).describe(用户标签没有则返回空数组), });如果用StructuredOutputParserZod 的默认值和校验会自动生效但如果用的是withStructuredOutput模型返回的是一个普通对象Zod 的default()并不会自动补上去。这个时候最好显式做一次解析const raw await structuredModel.invoke(text); const safeResult ContactSchema.parse(raw);这样tags如果缺失safeResult.tags就会是[]而不是undefined。这也是我强调“运行时契约”的含义Schema 不只是约束模型也是在约束你的代码。不需要所有字段都 optional。关键字段必须 required否则模型会越来越放飞自我。我的原则是业务必须使用的字段设为必填辅助字段或可能缺失的字段再 optional。5. 兜底方案提示词 ZodOutputParser5.1 不依赖模型 function calling 的解析流程不是所有模型都支持 function calling 或 JSON mode尤其是本地部署的一些小模型。这时候可以退回最朴素的路线提示词 解析器。LangChain 提供了StructuredOutputParser.fromZodSchema可以让 parser 生成一段格式指令你把它塞进 prompt再让模型按这个格式返回import { StructuredOutputParser } from langchain/output_parsers; import { PromptTemplate } from langchain/core/prompts; import { ChatOpenAI } from langchain/openai; import { z } from zod; const JokeSchema z.object({ setup: z.string().describe(笑话铺垫), punchline: z.string().describe(笑点), }); const parser StructuredOutputParser.fromZodSchema(JokeSchema); const prompt PromptTemplate.fromTemplate( 回答用户的请求。\n{format_instructions}\n请求{input} ); const chain prompt.pipe(model).pipe(parser); const result await chain.invoke({ input: 讲一个程序员冷笑话, });parser.getFormatInstructions()会生成一段类似“必须只返回 JSON 对象字段包括 setup、punchline”的说明LangChain 在组装 prompt 时自动把它填进去。最终chain.invoke返回的是已经用 Zod 解析过的对象所以默认值和校验都会生效。这条路更“软”因为它靠模型听话而不是靠工具调用强制约束。5.2 两种方案怎么选对比项withStructuredOutput提示词 ZodOutputParser约束强度高模型内部按参数结构返回中低依赖模型理解 prompt兼容性需要模型支持 function calling / json mode几乎所有模型都能跑校验与默认值需要自己再 parseparser 内部会处理灵活性受限于模型能力prompt 可以随意调成本略高schema 会占 token格式说明也占 token量差不多我的看法是如果你用的是主流大模型 API优先用withStructuredOutput别手写“请以 JSON 返回”这种咒语。不是因为 prompt 完全不行而是它不稳定出问题排查也麻烦。如果你在公司内网部署了一个不支持 function calling 的模型那就用提示词 parser。这个方案至少让解析逻辑统一模型返回的文本坏了也能快速定位是 prompt 的问题还是解析的问题。6. 常见问题与排查技巧实录6.1 模型就是不按 schema 返回我遇到最多的原因排序如下第一模型能力不够。有些本地小模型对复杂 Schema 的理解很差数组套对象基本必乱。别硬撑要么换大模型要么简化 Schema。第二Schema 太复杂。字段十几个嵌套三四层模型生成的 token 一多就容易漏。这时候把 Schema 拆小一次让模型处理一件事。第三缺少 description。模型猜不准字段语义就会自己发挥。给每个字段补上中文描述效果立竿见影。排查时建议把转换后的 JSON Schema 打出来看看import { zodToJsonSchema } from zod-to-json-schema; console.log(JSON.stringify(zodToJsonSchema(JokeSchema), null, 2));看一眼实际传给模型的 Schema 是什么样很多时候问题就暴露了。比如你可能忘了zodToJsonSchema在碰到z.record或z.union时会生成额外的anyOf模型看到这种结构容易犯迷糊。6.2 解析失败与运行时校验正常情况下模型输出不会每次都对。Zod 的parse失败会抛出ZodError里面是一堆 issues直接看很容易懵。我一般这么处理import { z } from zod; function parseSafe(schema: z.ZodTypeAny, data: unknown) { const result schema.safeParse(data); if (!result.success) { console.error( result.error.issues.map((item) ({ path: item.path.join(.), message: item.message, })) ); return null; } return result.data; }还有一种做法是让模型自己改错。捕获到ZodError后把错误信息和原始输出一起拼进下一次模型调用让模型重新生成。这个方法我叫它“一轮纠错”实操中很管用。注意要控制重试次数最多两轮。模型连续两次修不好多半是初始 Schema 或模型能力的问题重试只是浪费 token。6.3 成本、Token 和超时控制很多人低估了 Schema 对 token 的消耗。一个字段带 description大约会多出十几个 token十个字段就是几百 token。如果只是普通聊天无所谓如果是批量抽取服务成本会明显上涨。所以 description 要写“有效信息”不要写废话。比如name: z.string().describe(姓名)这句基本多余字段名已经很清楚了但region: z.enum([CN, US]).describe(国家代码CN 代表中国US 代表美国)这种就有价值。大数组输出也容易超时。假设你让模型一次返回 100 条记录生成时间会很长接口很容易超时。我现在的做法是分页一次只让模型生成 10 到 20 条循环处理。这样每次调用时间短失败重试的代价也小。如果模型服务支持建议配置timeout和maxRetries。LangChain 的模型初始化参数里有这两个配置设置一个合理的超时时间能避免某个请求把整个服务拖死。7. 实战案例从一段文本里抽取联系人信息7.1 需求与 Schema 设计假设用户发来一段文字帮我记一个朋友李娜电话 13812345678她是我大学同学喜欢摄影。我们要抽取以下字段姓名、手机号、邮箱、标签。邮箱大概率没有手机号也不是每个人都有所以两个字段都做成 optional标签如果没有就返回空数组。const ContactSchema z.object({ name: z.string().describe(联系人姓名必填), phone: z.string().optional().describe(手机号没有则不填), email: z.string().email().optional().describe(邮箱地址没有则不填), tags: z.array(z.string()).default([]).describe(描述这个人属性的标签没有则返回空数组), });如果你的 Zod 版本比较新z.string().email()的 API 可能调整请以你实际安装的版本为准。核心逻辑是一样的只是写法有差异。7.2 完整代码与运行结果import { ChatOpenAI } from langchain/openai; import { z } from zod; const ContactSchema z.object({ name: z.string().describe(联系人姓名必填), phone: z.string().optional().describe(手机号没有则不填), email: z.string().email().optional().describe(邮箱地址没有则不填), tags: z.array(z.string()).default([]).describe(描述这个人属性的标签没有则返回空数组), }); const model new ChatOpenAI({ model: gpt-4o-mini, temperature: 0, }); const extractor model.withStructuredOutput(ContactSchema); const text 帮我记一个朋友李娜电话 13812345678她是我大学同学喜欢摄影。; const raw await extractor.invoke(text); const result ContactSchema.parse(raw); console.log(result);运行结果大致是{ name: 李娜, phone: 13812345678, email: undefined, tags: [大学同学, 摄影] }如果模型没有返回email字段ContactSchema.parse会正常工作因为它是 optional。tags如果缺失会被默认值补成空数组。7.3 接进 Agent 前的最后一步拿到结构化结果后一般我会做一次“清洗”把undefined字段处理掉因为JSON.stringify会忽略undefined可能导致下游字段对不上const cleanResult JSON.parse(JSON.stringify(result));然后就可以把这个对象写进数据库或者作为下一个 Agent 节点的输入。LangGraph 里的条件路由、工具调用非常依赖稳定结构。你给 Agent 的每个工具参数都定义好 Schema等于给整条链路装了一圈护栏。最后分享一点个人经验我之前做过一个信息抽取项目最开始是“模型输出 正则清洗”正则写了十几条换一个文本格式就崩一次。后来全部改成 Schema 驱动先定义 Zod再写 prompt再让模型走结构化输出关键路径再safeParse一次。从那以后解析逻辑几乎没怎么改过新需求只是换 Schema。现在我的标准做法是一个功能入口先写 Schema再写代码。模型的问题模型兜不住时就用 Zod 的错误信息去喂下一次调用让模型自己修正。整个过程不依赖玄学每一步都可验证、可重试。如果你刚开始用 LangChain建议先在简单场景把withStructuredOutput跑通再逐步加嵌套、数组、多轮纠错。Zod 和 LangChain 这套组合看起来只是给输出加了个类型实际上是把大模型从一个“文本生成器”变成了一个可以预期的函数。这个转变对工程化的价值远比表面看着大。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门