做 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-09",Zod 的.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 runStructured<T>( schema: z.ZodType<T>, prompt: string ): Promise<T> { 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.infer<typeof BookSchema>推导出 TypeScript 类型,避免写两遍类型定义。这一点是手写 JSON Schema 很难比的。
6.3 下一步可以怎么玩
当你掌握 Zod + LangChain 结构化输出之后,可以继续扩展的方向包括:把多个结构化调用拼成多步骤工作流,用 Zod 校验不同阶段的结果;在 LangGraph 里用条件分支让机器自己判断该走重试还是交给人类;或者把校验失败的数据收集起来,作为后续 prompt 优化的训练样例。
我个人在实际操作中的体会是,结构化输出并不是“限制模型能力的枷锁”,反而是让 AI 应用从“demo 玩具”走向“生产工具”的关键一步。模型负责发挥理解能力,Schema 负责兜底,各干各的,项目才能稳稳跑起来。最后再分享一个小技巧:每次上线前,拿 3-5 个真实业务文本跑一遍抽取,把所有校验失败的错误保存下来,你会发现自己对 Schema 的描述能力比什么都重要。