FEATURED · 精选文章

LangChain.js 结构化输出实战:用 Zod 给模型输出加上类型枷锁

发布时间 / 2026/9/20 5:15:20
来源 / 创域科博编辑部
栏目 / 资讯中心
LangChain.js 结构化输出实战:用 Zod 给模型输出加上类型枷锁 做 LLM 应用的都知道模型输出像一匹野马你问它要 JSON它给你一段带 json 代码块的 Markdown你让它给一个数字它给你“大概是 5 吧”。这种不确定性在原型阶段还能忍一旦进入生产环境下游系统做数据入库、接口对接、自动化决策时任何一点格式跑偏都可能引发连锁事故。这也是我为什么一直强调在 LangChain 里做结构化输出Zod Schema 这套组合是必须尽早掌握的基础功。这篇文章从一个能直接跑起来的 LangChain.js 示例出发讲清楚为什么结构化输出不只是“让模型按照 prompt 返回 JSON”以及如何用 Zod 定义 Schema把它变成 AI 输出的严格类型枷锁。适合刚开始接触 LangChain 结构化输出、或者已经在写 Agent 但老被 JSON 解析坑哭的开发者。看完你就能理解 withStructuredOutput 的底层逻辑并学会在真实项目里给模型输出加上一层可靠的校验护栏。1. 为什么 AI 输出需要“类型枷锁”1.1 没有结构约束时你会遇到什么先还原一个最典型的翻车场景。我早期做内容抽取工具时直接在 prompt 里写“请返回 JSON 格式”模型也确实返回了 JSON但偶尔会在 JSON 外面包一层 Markdown 代码块偶尔在结束位置多一句话“以上就是提取结果”。当时我天真地写了JSON.parse(text.replace(/^json|$/g, ).trim())结果被“json\n{...}\n\n希望这个答案对你有帮助”这种组合拳教做人。这还只是纯文本解析问题。更麻烦的是字段缺失、字段类型错误、枚举值跑到可接受范围之外。例如让模型返回一个星级评分期望是 0 到 5 的整数结果它给 4.8或者写了一个不在枚举列表里的情感标签。这类问题靠正则和JSON.parse永远解决不了因为根不在字符串格式而在生成过程缺少约束。真正稳定的做法是让模型本身在生成之前就“知道”自己必须输出符合某个 Schema 的 JSON。这就是结构化输出的核心意义不是事后补救而是在生成阶段就套上枷锁。1.2 JSON Schema 与 Zod 的关系Zod 和 JSON Schema 不是二选一的竞争品它们的关系很像 TypeScript 和接口文档的关系。JSON Schema 是一个跨语言的 JSON 结构描述标准它用一套 JSON 对象描述“某个 JSON 应该长什么样”比如字段类型、是否必填、数组长度、字符串格式。OpenAI 的 function calling、LangChain 的 withStructuredOutput 底层都依赖这种标准描述。Zod 则是一个 TypeScript 生态里的运行时校验库。它最大的特点是“声明式”你可以用接近 TypeScript 类型语法的方式写z.object({ name: z.string() })然后这个对象既能在编译期给 TS 类型提示也能在运行时通过.parse()校验数据。在 LangChain.js 里你只需要写好 Zod Schema框架会负责把它转换成模型能理解的 JSON Schema。也就是说我们只需要维护 Zod 这一份真源LangChain 帮我们处理下游兼容。这比手写 JSON Schema 少了大量重复且容易出错的 boilerplate也比在 prompt 里字符串拼接强一万倍。1.3 结构化输出不是“格式化输出”很多人以为“结构化输出”就是把 prompt 写成“Return the result as JSON”这其实是个误区。格式化输出只是要求模型“尽量这样返回”模型可能遵守也可能不遵守结构化输出则是把 Schema 作为硬约束交给模型模型在生成时就会被函数调用机制限制在合法的输出范围内。LangChain 的withStructuredOutput(schema)底层做了什么它会把 schema 打包成一个 tool/function 定义让模型以工具调用的方式“调用”这个输出工具工具参数必须是符合 schema 的 JSON。由于工具调用本身是模型协议里相对稳定的机制比起纯文本生成后的正则提取可靠性高了好几个量级。用一个生活类比普通 prompt 像你在餐厅口头说“少放辣”厨师听不听看心情结构化输出像你在点单系统里选“不加辣”系统不允许提交超出选项的需求。这就是类型枷锁的价值。2. 环境准备与基础概念2.1 工具链选择LangChain.js Zod因为 Zod 是 TypeScript 生态的库所以这套实操基于 LangChain.js而不是 Python 版 LangChain。Python 生态常用的校验库是 Pydantic思路完全一致但今天只讲 Zod。建议 Node.js 18 以上TypeScript 5 以上。用一个干净的 npm 项目安装依赖npm install langchain langchain/openai zod npm install -D tsx如果你的项目里已经有dotenv可以在入口文件加载环境变量。没有也没关系直接给ChatOpenAI传入apiKey参数也行。个人推荐dotenv因为本地调试时常换 key写在.env里更清爽。npm install dotenv2.2 LangChain 结构化输出的两种姿势LangChain 里有两种常见做法。第一种是withStructuredOutput(schema)这是推荐的方式。传入 Zod Schema框架自动完成 schema 转换、函数绑定和结果解析。因为输出本身就是对象不需要再手动JSON.parse也天然规避了代码块包裹问题。第二种是“prompt parser”的方式即你自己在 prompt 里要求返回 JSON然后用StructuredOutputParser之类的东西做解析。这种方式对模型输出质量要求高稍微复杂的 schema 就容易翻车适合在没有工具调用能力的模型上做降级方案。我建议优先掌握第一种。只有当你使用的模型不支持 function calling或者模型太老、输出稳定性太差时才退回去用 prompt parser。2.3 你的第一个 Zod Schema先看一个最小 Schema 长什么样import { z } from zod; const SummarySchema z.object({ title: z.string().describe(文章标题), summary: z.string().describe(不超过200字的摘要), keywords: z.array(z.string()).min(1).max(5).describe(关键词列表), });这里有三个关键点。第一z.object里的每个字段都是必填的除非用.optional()。默认情况下如果模型漏掉字段LangChain 在解析时会把缺字段当作错误处理这样就不会出现“一半字段有值、一半字段是 undefined”的脏数据。第二.describe()是写给模型看的注释。模型没有读你的 TypeScript 类型它只能看到 JSON Schema 里的 description 字段。你写得越具体模型越容易生成符合预期的内容。第三z.array(z.string()).min(1).max(5)是长度约束。它告诉模型这个数组至少 1 个元素最多 5 个。如果你不写约束模型可能返回空数组也可能返回几十个标签下游处理时就会出现边界问题。3. 从 Zod 到 LangChain实操全流程3.1 定义业务 Schema纸上谈兵没意思直接拿一个图书信息抽取场景来跑。假设我们要让模型从一段图书介绍文本里提取结构化信息包括书名、作者、ISBN、标签、定价、出版日期。import { z } from zod; const BookSchema z.object({ title: z.string().describe(完整书名), author: z.string().describe(作者姓名多个作者用顿号分隔), isbn: z.string().regex(/^\d{13}$/, ISBN必须是13位数字).describe(13位ISBN号纯数字), tags: z.array(z.string()).min(1).max(6).describe(图书标签2-4个字为宜), price: z.number().positive().describe(图书定价单位是元可以是小数), publishedAt: z.string().date().describe(出版日期格式YYYY-MM-DD), });为什么把 ISBN 做成字符串而不是数字因为 ISBN 虽然由数字组成但它的语义是“编号”不是“数值”。如果用z.number()模型可能会把前导零吃掉比如“9780123456789”没问题但某些极端编号会出问题。更重要的是用正则约束 13 位数字能大幅度降低模型乱编概率。.date()是 Zod 3.x 里针对YYYY-MM-DD字符串的校验器。我用它强制日期格式比单纯写z.string()更能防止模型返回“2024年5月30日”或者时间戳这类格式。3.2 使用 withStructuredOutput 绑定模型有了 Schema接下来把它绑到 ChatOpenAI 模型上import { ChatOpenAI } from langchain/openai; import { dotenv } from dotenv; dotenv.config(); const llm new ChatOpenAI({ model: gpt-4o-mini, temperature: 0, apiKey: process.env.OPENAI_API_KEY, }); const structuredLLM llm.withStructuredOutput(BookSchema, { name: book_info_extractor, }); const rawText 《三体》是刘慈欣创作的长篇科幻小说重庆出版社出版定价28元ISBN 9787536692930讲述地球文明和三体文明的信息交流、生死搏杀。; const result await structuredLLM.invoke(rawText); console.log(result);运行后result不是字符串也不是JSON.parse的结果而是一个已经通过 Zod 校验的普通对象。LangChain 内部做了三件事把 Zod Schema 转成 JSON Schema把 JSON Schema 绑定成 function calling 的参数定义调用模型后把工具参数解析出来再交回给 Zod 做最终校验。temperature: 0在这里很有意义。结构化输出场景下我们通常希望模型尽量“压抑创造力”老老实实按约束生成。温度越低输出越稳定越不会出现 schema 以内的字段乱填。当然这不是绝对的对某些创意性字段可以适当调高温度但核心结构字段必须保持低温。3.3 校验失败与容错处理虽然withStructuredOutput内部会做解析但在真实项目里我依然建议在拿到结果后主动调用一次 Zod 的 safeParse。为什么因为模型服务偶发情况下可能返回不符合 schema 的工具参数LangChain 的解析层可能因为各种上游兼容问题静默放过部分错误。稳妥起见自己再加一道锁。const parsed BookSchema.safeParse(result); if (!parsed.success) { console.error(结构化输出未通过校验, parsed.error); // 这里可以做重试或者调用另一个模型重新抽取 } else { console.log(parsed.data); }safeParse不会抛异常而是返回一个带有success标记的对象。这是一个很好的防御性编程习惯外部依赖不可信模型输出更不可信只有自己代码里主动校验过的数据才允许进入业务层。还有一个常用技巧在 Zod 里给部分字段设置默认值或允许.nullish()避免单个次要字段的缺失导致整个输出被拒。比如const FlexibleBookSchema BookSchema.extend({ subtitle: z.string().optional().describe(副标题没有则不返回), });这样模型如果没提取到副标题也不会把整个结果搞挂。结构要严格但没必要为了一个非核心字段把整条链路堵死。4. 类型约束的技巧与参数细节4.1 describe 是给模型的说明书很多人写 Zod Schema 时日了 dog只写类型不写描述。例如z.string()模型拿到这个字段时只知道“这里是字符串”不知道这个字符串应该是什么语义就只能靠猜。如果你写成z.string().describe(书名去掉书名号保持原样)模型的准确性会明显提升。我实测下来的经验是描述越具体字段填充错误率越低。尤其是遇到歧义字段时比如“author”到底是作者还是出版社一个清晰的描述就能避免一半的错误。描述里最好包含三类信息这个字段的语义是什么期望的格式是什么特殊限制是什么。例如“价格数字单位元保留两位小数”和“价格”的差别在生产环境里非常明显。4.2 嵌套对象与数组真实业务里很少有纯扁平结构更多是嵌套对象。Zod 对这种场景支持很完善const ProductSchema z.object({ name: z.string().describe(商品名), category: z.object({ id: z.string().describe(分类ID), name: z.string().describe(分类名), }).describe(商品分类信息), specs: z.array(z.object({ key: z.string().describe(规格名比如颜色), value: z.string().describe(规格值比如黑色), })).describe(商品规格列表), });嵌套对象里最容易出的问题是模型把某个子对象整个漏掉。解决方法是给这个子对象写一个详尽的 describe并且在父字段上说明“该字段是必填的如果原文没有信息用空对象返回”。别小看这句说明它可以显著降低字段丢弃率。数组情况更麻烦。模型有时候会为了满足 min 约束强行塞入重复内容比如 tags 要求至少 1 个它可能会把同一个标签复制两遍。如果你发现这种问题可以在调用后做一次去重或者用.transform(val [...new Set(val)])清洗。4.3 用枚举约束模型的选择范围当业务里存在固定分类或固定状态时枚举是最好用的约束之一。const ReviewSchema z.object({ rating: z.number().int().min(1).max(5).describe(评分只能是1到5的整数), sentiment: z.enum([positive, neutral, negative]).describe(情感倾向), recommend: z.boolean().describe(是否推荐), });z.enum会把合法选项写进 JSON Schema 的enum字段里模型在生成时会优先从这些选项里选而不是随意发明新词。这个设计特别适合做内容审核、意图分类、标签归一化。不过要注意枚举值不要设计得太多太复杂。如果某个字段给了 20 个候选值模型还是会混乱。我一般建议枚举不超过 10 个如果你有更细的分类需求可以考虑多级子分类字段。4.4 Schema 复杂度的代价无限制地增加字段和嵌套确实能提高信息的完整性但也会让模型更累。每个字段都会占用模型输出的 token 预算字段越多单次调用延迟越高、成本越高而且模型出错的概率也会上升。我踩过的坑是一个抽取出 40 个字段的 schema模型经常在某 2-3 个边角字段上出现类型绕过或内容瞎编。后来我把这些字段改成可选、或者拆分成两个子任务分别抽取稳定性立刻上来了。所以设计 Schema 时要克制。能用 8 个字段解决的问题不要扩展到 20 个。结构化不是越细越好而是够用就好。毕竟 AI 输出再严格它也是在“猜”信息不是在做数据库迁移。5. 常见问题与排查实录5.1 模型输出校验失败的四个原因我在自己项目里遇到过不少校验失败的情况归纳起来无非四类。第一describe 没写清楚。字段语义模糊时模型会自由发挥最常见的表现就是“ISBN 字段返回了带连字符的字符串”或者“日期返回了时间戳”。检查 schema 的 description把它改成“纯数字、13 位、无连字符”这种明确指令。第二temperature 设置过高。温度高于 0.7 时模型生成随机性增强JSON 里的字段顺序、类型、格式都更容易出界。结构化输出场景建议温度调到 0 到 0.2 之间。第三schema 过于复杂。嵌套太深、枚举太多、数组长度范围太宽都会导致模型为了完成生成而牺牲约束。解决方式是把大 schema 拆成多个小 schema分别做结构化抽取。第四模型本身不支持 function calling。部分开源模型或旧版嵌入模型没有稳定的工具调用能力这时候withStructuredOutput会退化为 prompt 拼接效果自然一般。升级模型、或者切换成效果更好的闭源模型是最直接的解法。5.2 报错速查表常见报错可能原因排查方向ZodError字段缺失模型没返回必填字段检查 describe 是否写清“必填”考虑用.optional()或重试JSON.stringify结果无法 parse模型返回内容被截断检查 max tokens增大输出上限枚举值不在z.enum内模型自创了合法值之外的选项检查枚举描述必要时添加“只能从给定选项中选择”数组为null模型把空数组写成了 null用.array().default([])兜底日期字段格式错误模型没理解YYYY-MM-DD用.date()并加强 describe调用链超时schema 过长或模型响应太慢拆分 schema缩小输出范围5.3 实战踩坑日期字段总返回字符串有一次我让模型抽取“publishedAt”schema 里写了z.string().date().describe(发布日期格式YYYY-MM-DD)。结果模型偶尔返回2024-5-9而不是2024-05-09Zod 的.date()直接报错。原因很简单模型在生成时没有严格遵守补零规则。我虽然写了YYYY-MM-DD但没有明确说“月份和日期都必须两位数不够补零”。加上这句话之后问题消失。类似的情况也出现在金额字段。模型可能把price返回成28元但你定义的是z.number()。解决方式是在 describe 里加“只要数字不要单位”或者干脆用z.string()接收原始文本再转换成数字。后者更稳妥因为模型在处理“28元”这种自然表达时让你转类型的成本比自己硬生生塞进 number 低得多。5.4 把结构化校验放进 Agent 流程这套结构化输出不仅可以用于单次调用也可以作为 Agent 节点里的重要一环。如果你在做 LangChain 或 LangGraph 流程完全可以把“输出校验 二次修正”做进 workflow。我的做法是Agent 的某个节点负责抽取信息拿到的结果先过 Zod 校验校验失败就把错误信息拼进重试 prompt让模型重新生成。这其实就是常见的人机协作Human-in-the-Loop雏形机器能自动校验就自动重试自动搞不定再交给人工处理。LangGraph 里可以把这个逻辑拆成两个节点一个节点负责“抽取”一个节点负责“校验修复”。校验失败时通过 condition edge 回到抽取节点并在 prompt 里带上具体的 ZodError 信息。反复几次后模型会学会避开之前踩过的雷。6. 生产环境里的扩展经验6.1 统一封装结构化调用当项目里多个地方都需要结构化输出时建议封装一个通用函数避免每个业务模块里重复写withStructuredOutput和校验逻辑。async function runStructuredT( schema: z.ZodTypeT, prompt: string ): PromiseT { const llm new ChatOpenAI({ model: gpt-4o-mini, temperature: 0, }); const structuredLLM llm.withStructuredOutput(schema); const raw await structuredLLM.invoke(prompt); const parsed schema.safeParse(raw); if (!parsed.success) { throw new Error(结构化校验失败: ${JSON.stringify(parsed.error)}); } return parsed.data; }这样的好处是你可以在入口统一处理重试、统一加日志也能方便地替换模型。我在生产项目里通常会给这个函数增加指数退避重试因为大模型服务偶尔会超时或者返回 5xx重试两次能解决大部分偶发问题。6.2 让 Schema 成为团队协作的契约当结构化输出被多个团队复用时不要只把 Zod 文件放在项目角落。把它当成接口协议一样维护最好单独建一个schemas目录并且配上字段说明注释。模型输出的字段也许会变但你的 Schema 是唯一稳定的契约。我在实际协作中发现一个清晰的 Zod Schema 比一份 Word 文档指标说明有用得多。前端、后端、算法团队都能直接看代码理解字段含义甚至可以直接用z.infertypeof BookSchema推导出 TypeScript 类型避免写两遍类型定义。这一点是手写 JSON Schema 很难比的。6.3 下一步可以怎么玩当你掌握 Zod LangChain 结构化输出之后可以继续扩展的方向包括把多个结构化调用拼成多步骤工作流用 Zod 校验不同阶段的结果在 LangGraph 里用条件分支让机器自己判断该走重试还是交给人类或者把校验失败的数据收集起来作为后续 prompt 优化的训练样例。我个人在实际操作中的体会是结构化输出并不是“限制模型能力的枷锁”反而是让 AI 应用从“demo 玩具”走向“生产工具”的关键一步。模型负责发挥理解能力Schema 负责兜底各干各的项目才能稳稳跑起来。最后再分享一个小技巧每次上线前拿 3-5 个真实业务文本跑一遍抽取把所有校验失败的错误保存下来你会发现自己对 Schema 的描述能力比什么都重要。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻