
说出来你可能不信我最早用 LangChain 调大模型接口的时候最痛苦的不是写 Prompt也不是调温度参数而是处理模型吐回来的那一坨text字段。你让它返回 JSON它给你在 JSON 外面套一层 Markdown 代码块你让它给个数组它返回一个用中文逗号分隔的字符串你要是运气再差一点字段名给你从userName悄悄改成username整个解析链路瞬间崩掉。后来我把目光放到了结构化输出Structured Output上配合 Zod 给 AI 的输出套上一层严格的类型枷锁这才算真正解决了问题。这篇文章我就用 TypeScript 生态里的 LangChain从零讲清楚 Zod Schema 这套东西到底怎么用。内容包括为什么需要结构化输出、Zod 在其中的角色定位、Schema 怎么设计才算严谨、实际接入 LangChain 的完整步骤以及我实测踩过的各种坑。适合正在做 AI 应用开发、被模型自由输出折磨过的朋友也适合刚接触 LangChain 想少走弯路的初学者。1. 为什么大模型需要类型枷锁从一次翻车说起1.1 一次真实的翻车现场之前我做过一个小工具让大模型帮忙从用户输入里抽取招聘信息需要输出岗位名称、薪资范围、工作城市三个字段。第一次调用我用的是最朴素的写法const prompt 请从下面这段话里提取招聘信息以 JSON 格式返回\n userInput; const res await model.invoke(prompt);看着没毛病对吧模型也确实听懂了返回内容长这样json { 岗位名称: 前端工程师, 薪资范围: 20k-35k, 工作城市: 上海 }问题来了这玩意外面包了一层 Markdown 代码块标记。我如果用 JSON.parse(res.content) 直接解析立刻报错。当时我的处理方式是先正则去掉 json 再 parse接着发现更离谱的事——下次调用时模型本身就把代码块去掉了但字段顺序变了再下次它又给你塞了个 说明: 以上信息仅供参考 进来。我花了大半天时间在写各种兜底逻辑剥离代码块、容错字段缺失、兼容中英文键名……忙完一圈我意识到这条路走反了。我需要的不只是让它尽量返回 JSON而是从接口层面就强制输出一个固定结构。 ### 1.2 结构化输出真正解决的四个问题 LangChain 里的 withStructuredOutput 就是为了这个场景设计的。它的目标是把模型输出从自由文本变成可编程的结构化数据具体解决四类问题 - **格式不固定**有时带代码块、有时不带有时字段乱序。结构化输出会让模型严格按 Schema 定义生成。 - **字段缺失**自由 Prompt 下模型经常忘记非核心字段。结构化输出配合描述、枚举和必填约束能大幅提高字段召回率。 - **类型混乱**模型可能把数字 7 和字符串 7 混着来把数组对象当成单个对象返回。类型定义直接消除这类歧义。 - **下游解析脆弱**你为兼容各种奇怪返回写的解析代码本质上都是在给模型的不确定性买单。结构化之后解析代码可以做到一行搞定。 ### 1.3 为什么说这正是 Zod 的主场 在 TypeScript 生态里做类型枷锁这件事Zod 几乎是最顺手的选择。它跟 TypeScript 的类型系统是天然打通的你定义了一个 z.object({...})它的 type 推导几乎零成本地对应一个 TS 接口。这意味着你在编译期能拿到完整的类型提示在运行期又有真实的校验对象可以调用 safeParse。用一句话概括**Zod 让你在写 Schema 的那一刻就同时拥有了编译期类型和运行期校验器。** 后面我们接入 LangChain 时这个 Schema 会直接决定模型输出的形状也会决定你拿到结果后 TS 自动推导出的类型。 ## 2. Zod 在 LangChain 里的角色不是有没有 Schema而是 Schema 好不好用 ### 2.1 LangChain 支持的几种 Schema 方案 LangChain 的 withStructuredOutput 并不是只能配 Zod。在我实测过的版本里它至少支持以下几种传入方式 | 方式 | 类型 | 适用场景 | 我的感受 | | --- | --- | --- | --- | | Zod Schema | z.ZodObject | TypeScript 项目首选 | 类型推导最舒服推荐 | | JSON Schema 对象 | 普通对象 | 已有现成 JSON Schema | 可用但手写很累且易错 | | 函数调用参数声明 | 类工具定义结构 | 想直接复用工具函数 | 适合 Agent 场景灵活 | | Class 装饰器/schema | 带校验的类 | 从旧代码迁移 | 能跑但不如 Zod 直观 | 如果你本来就是 TypeScript 技术栈选 Zod 基本没有争议。因为 withStructuredOutput 在解析和生成时都会依赖 Schema 的结构信息Zod 提供的方法链式表达、类型推导能力让你定义一个复杂嵌套 Schema 比手写 JSON Schema 省一半时间还不会写错类型。 ### 2.2 withStructuredOutput 的设计逻辑 withStructuredOutput 本质上是一个包装器。它接收你传入的 Schema然后根据你选择的 method默认是 functionCalling把 Schema 转成当前模型能理解的格式。对于 OpenAI 系模型它会把 Schema 编译成一个 function/tool 定义或者 response_format让模型在推理时把输出直接对准这个形状。换句话说**提示词里写请返回 JSON是软约束而 Schema 是硬约束。** 你把话说得再清楚模型也可能发挥但一旦走了工具调用路径模型的输出格式就是可枚举的至少不会是 Markdown 里嵌代码块这种鬼样子。 ### 2.3 传 Schema 和传 Prompt 的本质区别 刚开始你可能觉得我在 Prompt 里把字段一个个列明白不就行了。但实测下来两者差距很大Prompt 描述字段时模型对字段内要填什么和字段本身如何命名的把握是模糊的而 Schema 带过去以后模型底层拿到的是结构化定义对空值、枚举、数组元素类型都有明确约束。尤其当你需要模型去填充一个枚举值时如果枚举合法性不在 Schema 层卡住光靠 Prompt 说只能从这几个值里选连 GPT-4 都会偶尔翻车。 所以我的结论特别直接**在 LangChain 应用里定义好 Schema这件事本身就占了结构化输出 60% 的成败。** 选型层面大家都会选 Zod但 Schema 写得好不好才是你和别人代码质量的真实分水岭。 ## 3. Schema 设计入门从能跑到严谨的三个层次 ### 3.1 第一层字段 类型 描述 Zod 的入门很简单一个最小可用的结构化输出 Schema 长这样 typescript import { z } from zod; const JobSchema z.object({ jobTitle: z.string().describe(岗位名称例如前端工程师), salaryRange: z.string().describe(薪资范围例如20k-35k), city: z.string().describe(工作城市例如上海), });这段代码定义了三个必填字段全部是字符串。LangChain 会把.describe()里的文字作为字段说明传给模型。我强烈建议每个字段都写上 describe因为模型能看到的信息里jobTitle这个键名只是暗示describe 里的内容才是明示。这个阶段你能跑通链路但离严谨还有距离。因为字段全是字符串时模型完全可能把city填成中国上海把salaryRange填成面议——从类型上看没错业务上却不好用。3.2 第二层枚举、数组、嵌套对象接下来我建议把业务里可以列举的信息全部固化成枚举把一对多的信息固化成数组把有关联信息的部分固化成嵌套对象。还是拿招聘信息抽取举例一个合格的 Schema 至少是这样const JobSchema z.object({ jobTitle: z.string().describe(岗位名称), salaryMin: z.number().describe(最低月薪单位千元例如 20 表示 20k), salaryMax: z.number().describe(最高月薪单位千元例如 35 表示 35k), city: z.string().describe(工作城市直辖市或地级市名称), jobType: z.enum([全职, 兼职, 实习]).describe(工作类型), tags: z.array(z.string()).describe(岗位标签最多三个), company: z.object({ name: z.string().describe(公司名称), industry: z.string().describe(所属行业), scale: z.enum([少于50人, 50-500人, 500-2000人, 2000人以上]).describe(公司规模), }).describe(公司信息), });这段 Schema 的严谨性比第一层提升了一个档次薪资从自由文本变成了两个数值字段后续可以做计算和比较jobType和scale用枚举锁死了可选范围tags明确是数组公司信息变成了嵌套对象。模型输出如果缺了任何一个必填字段Zod 在生成后的校验阶段能立刻发现。3.3 第三层可选、可空、默认值的正确用法如果你开始处理真实业务数据一定会遇到这个字段有时候没有的情况。Zod 提供了几种写法选错的话会有坑。const ProfileSchema z.object({ name: z.string().describe(姓名), age: z.number().optional().describe(年龄不知道时可不填), email: z.string().email().nullable().describe(邮箱没有则填 null), bio: z.string().default(这个人很懒什么都没写).describe(个人简介), });.optional()表示这个键可有可无模型输出里没有这个字段也合法。.nullable()表示模型必须输出这个键但值可以是null。.default(...)表示如果模型没输出这个字段Zod 解析时会用默认值兜底。我踩过的一个坑是一开始为了图省事把所有可填可不填的字段全用.default()处理以为这样模型输出缺失也无所谓。但实测发现.default()不会让模型更倾向于输出这个字段它只是在解析阶段兜底。如果你希望模型尽量提供这个字段应该用.nullable()而不是.default()因为 nullable 至少会促使模型生成一个键。你定义的 Schema 不只是校验规则它同时是发给模型的指令。这一点很多教程不会讲。3.4 设计 Schema 时的实操心得再分享几个我个人的 Schema 设计心得字段名尽量使用英文或拼音标识符用 describe 写中文含义。键名如果用中文虽然模型也能理解但在 JSON 序列化和日志排查时很别扭。数字类字段要明确单位。只写salary: z.number()的话模型可能返回 20000也可能返回 20还有可能返回 20k。我在 describe 里加单位描述后模型基本能按约定输出。限制数组长度。比如.array().max(3)模型生成超长数组的概率不低加个max能防止下游被撑爆。避免复杂的联合类型。z.union([z.string(), z.number()])这种模型经常处理不好能拆就拆成两个字段。4. 核心操作LangChain Zod 实战接入与代码走读4.1 环境准备在开始之前你需要一个能跑 TypeScript 的 Node.js 项目并安装以下依赖npm install langchain langchain/openai zod我的运行环境是 Node.js 20langchain版本0.3.xzod版本3.23.x。新版本 API 如果有变动以官方文档为准。4.2 最直接的用法withStructuredOutput 传入 Zod Schema先给你看一段完整的可运行代码这是withStructuredOutput最经典的使用方式import { ChatOpenAI } from langchain/openai; import { z } from zod; const JobSchema z.object({ jobTitle: z.string().describe(岗位名称), salaryRange: z.string().describe(薪资范围例如 20k-35k), city: z.string().describe(工作城市), jobType: z.enum([全职, 兼职, 实习]).describe(工作类型), }); const model new ChatOpenAI({ model: gpt-4o-mini, temperature: 0, }); const structuredModel model.withStructuredOutput(JobSchema); const result await structuredModel.invoke( 从这句话中提取职位信息字节跳动招聘前端工程师月薪30k到50k工作地点北京可以接受实习。 ); console.log(result); // 输出示例 // { // jobTitle: 前端工程师, // salaryRange: 30k-50k, // city: 北京, // jobType: 实习 // }这里最关键的一行是model.withStructuredOutput(JobSchema)。它返回一个新的模型实例调用invoke时返回的直接就是一个符合 Schema 的 JS 对象不再是字符串。你不需要再写 JSON.parse也不用担心 Markdown 代码块。4.3 底层到底发生了什么method 参数的两种核心模式withStructuredOutput的第二个参数可以传{ method: functionCalling }或{ method: jsonMode }不同模式对应完全不同的底层实现。// 模式一函数调用模式默认 const structuredModel1 model.withStructuredOutput(JobSchema, { method: functionCalling, }); // 模式二JSON 模式 const structuredModel2 model.withStructuredOutput(JobSchema, { method: jsonMode, });functionCalling 模式LangChain 会把 Zod Schema 转换成一个 function/tool 定义模型在推理时不是直接生成 JSON 文本而是调用一个函数。实际上模型返回的是一个结构化的参数对象只是中间过程对开发者透明。这种模式兼容性最好GPT 系列和大多数国产大模型的 OpenAI 兼容接口都支持。jsonMode 模式LangChain 会为 OpenAI 设置response_format: { type: json_object }强制模型返回合法 JSON。这种模式更直接但模型仍然可能返回一个结构正确但字段缺失的 JSON所以后续必须自己校验。对于大多数场景我推荐先用默认的 functionCalling。原因是函数调用模式下模型的输出格式由工具定义强制约束远比 生成一段 JSON 来得稳定。你可以在 LangSmith 的 trace 里看到模型实际调用工具的完整链路排查问题也方便。4.4 解析与校验结果到手后的第一件事withStructuredOutput返回的是一个符合 Zod Schema 的对象但它没有经过 Zod 的safeParse校验。这里有一个很容易被忽略的点即使走了结构化输出模型偶尔也会在极端情况下返回不合法的内容。比如我遇到过用 jsonMode 时模型返回了{ jobType: 临时 }的情况而jobType枚举里根本没有这个值。所以稳妥的做法是在拿到结果后显式调用一次校验const parsed JobSchema.safeParse(result); if (!parsed.success) { console.error(模型输出不符合 Schema, parsed.error.issues); // 你可以在这里实现重试逻辑比如带错误信息重新调用模型 } else { console.log(校验通过数据可用, parsed.data); }把这个校验逻辑放在你的数据接入层相当于双保险模型输出先过一次强制格式约束再过一次业务校验。一旦safeParse失败你就可以把具体的 error issue 拼进新的 Prompt 让模型自我纠错而不是直接把脏数据扔给下游。4.5 把结构化输出接入 Agent 工具调用如果你想在 ReAct Agent 或 LangGraph 里用结构化输出也需要先withStructuredOutput拿到结构化模型再通过bindTools或者自定义工具把模型能力暴露给 Agent。这里有一个我在 LangGraph 项目里经常用的组合技巧结构化输出负责把模型自由文本变成规范化工具入参LangGraph 负责编排这些工具的调用顺序。两者是互补关系。如果你的应用里有判断用户意图和抽取业务实体两步可以先用结构化输出把后者变成一个稳定的子任务再放到 LangGraph 的节点里。5. 源码级理解LangChain 是怎么把 Zod 变成 Prompt 的5.1 从 Zod 到 JSON Schema 的转换链路很多开发者不理解为什么传一个 Zod Schema 进去LangChain 就能让模型照着结构输出。其实中间有一层关键转换LangChain 内部使用zodToJsonSchema这个库把z.object(...)转换成标准的 JSON Schema 对象。转换完成后JSON Schema 会被放到请求体的不同位置取决于 methodfunctionCallingJSON Schema 会作为工具函数的parameters字段传给模型。jsonModeJSON Schema 用于控制输出格式但请求体里主要靠response_format约束。这一步说明了为什么 describe 里的内容那么重要——因为zodToJsonSchema会把description原样带进 JSON Schema 的字段描述里而模型在推理时读到的就是这些描述。5.2 从 LangSmith 看真实请求体如果你有 LangSmith 或者能打印底层 HTTP 请求日志你会发现调withStructuredOutput后的真实请求体和你裸调model.invoke完全不一样。以 functionCalling 模式为例OpenAI 请求体里会多出一个tools数组里面有一个type: function的工具它的name是 LangChain 自动生成的parameters就是你那坨 JSON Schema。模型最终会返回一个tool_calls字段里面携带的就是符合 Schema 的参数对象。这就是为什么结构化输出比指定 JSON 格式的 Prompt 稳定得多——模型的任务从自由文本生成变成了填充一个参数对象后者的约束强度完全不是一个量级。5.3 理解这些细节能帮你解决什么问题知道底层机制有一个很实际的好处排查问题时你能判断到底是哪一环出了问题。比如模型输出莫名缺字段你就能回头检查是不是 JSON Schema 里.describe()写得含糊再比如某个模型对 functionCalling 兼容性差你就可以果断切到 jsonMode 试试。你不是在黑盒子上瞎调参数而是在有地图的情况下做决策。6. 实测踩坑模型不配合时的完整排查过程6.1 小模型总是缺字段怎么办我用gpt-4o-mini和几款国产模型实测过同一个 Schema差异非常明显。模型参数小时经常会把数组字段直接忽略或者把嵌套对象输出成空对象{}。我的排查过程是固定的先看 LangSmith 里的原始返回确认模型到底缺了哪个字段。检查这个字段的类型是不是太复杂。如果一个字段是z.array(z.object({...}))小模型确实容易偷懒我一般会在 describe 里补充这个字段必须包含至少一个对象不允许返回空数组之类的话术。如果加了描述还没用我会把 Schema 拆小一次只让模型输出两个字段再通过多次调用合并结果。虽然增加了调用次数但稳定性提升明显。一个很多人没用过的小技巧ChatOpenAI 支持配置model_kwargs: { response_format: { type: json_object } }来强制 JSON 输出这个和 withStructuredOutput 搭配不冲突但优先级上 LangChain 内部会自己管理你自己别手动重复设置容易报冲突错误。6.2 模型返回了null或多余字段该怎么兜底有时候模型会把一个必填字段返回成null尤其当你的 Schema 没有为它定义.nullable()时。我之前排查过一个 case模型把city返回成null我只定义了z.string()结果safeParse直接报错。解法有两种在 Schema 里对可空字段显式声明.nullable()。在safeParse失败后把错误信息回传给模型让它补一次。比如这次输出缺失字段 city不能为null请重新输出。实测这种错误反馈重试法在第二、三次调用后基本能纠正过来。6.3 refine 写在 Schema 里但不要指望生成阶段会执行Zod 的refine、superRefine允许你写自定义校验逻辑但我要提醒你一个容易踩的坑这些 refine 逻辑在 LangChain 的 withStructuredOutput 转换阶段基本不会被执行。因为 LangChain 用的是zodToJsonSchema做结构转换refine 是纯运行时的校验器无法完整表达成 JSON Schema 的约束。所以 refine 只在你自己手动safeParse时才生效。换句话说不要试图用 refine 去约束模型不要输出某些值。想在模型生成阶段就禁止某个内容出现唯一可靠的办法是用describe说明或enum限制。如果业务上需要薪资范围最大值必须大于最小值这类跨字段校验你只能在safeParse之后手动判断。6.4 Token 消耗与 Schema 长度一个容易被忽视的成本问题Schema 越长传给模型的工具定义或 response_format 就越长。这些内容每次调用都会计入 token 消耗。我实测过一个中等复杂度的 Zod Schema大约 30 个字段转成工具定义后每次请求大概多消耗 500-1000 token。如果你的应用每天调用上万次这是一笔不小的费用。优化方向有两种精简 Schema只保留业务必须的字段把锦上添花的字段去掉或合并。按需动态生成 Schema每次都根据当前任务构造最小可用的 Schema而不是全局套用一个大而全的 Schema。LangChain 里同一个模型实例对相同 Schema 会有缓存优化但如果你每次动态创建新的 Schema 对象缓存就失效了。6.5 常见错误与解决速查表错误现象可能原因解决办法Expected object, received string模型把整体输出成了纯字符串确认用了 withStructuredOutput而不是裸 invoke字段缺失Schema 太复杂 / describe 不明确拆小 Schema补描述小模型降低复杂度枚举值不合法模型自己造了一个枚举项用 enum 限制并加 describe 列举可选项输出带 Markdown 代码块没走结构化输出还在用文本生成换成结构化输出链路返回空对象{}模型理解失败或参数传递出错检查 method切换 jsonMode 试试safeParse失败但格式正常模型返回了 null 或多余数组元素显式声明 nullable加上重试逻辑这张表是我实际项目里整理出来的特别是最后一行那个 case 当时排查了很久最后发现是模型返回的数组里混进了一个非法对象Zod 对整个数组执行safeParse时直接失败。后来我在清洗阶段先过滤非法元素再走校验问题就解决了。6.6 一个完整的带校验 重试封装最后分享一个我目前项目里在用的封装函数它把结构化输出、校验、自动重试三件事合成了一步async function structuredInvokeWithRetryT extends z.ZodTypeAny( model: ChatOpenAI, schema: T, input: string, maxRetries 2 ): Promisez.inferT { const structuredModel model.withStructuredOutput(schema); let lastError ; for (let i 0; i maxRetries; i) { const raw await structuredModel.invoke( lastError ? ${input}\n\n上一次输出校验失败错误信息\n${lastError}\n请修正后重新输出。 : input ); const parsed schema.safeParse(raw); if (parsed.success) { return parsed.data; } lastError parsed.error.issues .map((issue) ${issue.path.join(.)}: ${issue.message}) .join(; ); } throw new Error(模型输出多次校验失败: ${lastError}); }这个封装的核心逻辑很简单拿到模型输出后先safeParse失败就把错误信息回传给模型再来一轮。我在真实项目中用下来第一轮成功率大概 90%第二轮能到 98% 以上对于大部分业务场景已经够用了。如果两轮还失败说明要么 Schema 设计有问题要么这个模型能力确实支撑不了这么严格的结构建议换模型或者简化 Schema而不是无限重试烧钱。我个人在实际项目里的体会是结构化输出这套能力真正考验人的地方不是 API 怎么调而是你怎么把一个业务问题拆解成模型能够稳定执行的 Schema。Schema 设计得足够细、描述足够明确模型的输出自然稳定反过来Schema 写得模棱两可后面写再多兜底代码都是在给模型补窟窿。你把这套 Zod 的用法吃透之后下次再遇到模型胡说八道的场景第一反应就不是加提示词了而是先想想——是不是该给它的输出加上一把更合适的锁。