
如果你最近也在用 LangChain 做 AI 应用一定见过这种场面模型一顿输出猛如虎结果到了下游一解析不是少了字段就是类型对不上甚至直接把一段 markdown 格式的废话塞进 JSON 里。这时候你才意识到让大模型自由发挥是很不靠谱的得给它套上“类型枷锁”。这也就是 LangChain 结构化输出的价值所在。结合 Zod 和 Schema 这套方案我们可以把模型的输出从“一段文字”变成“一个符合预期的、可校验的、直接能用的对象”。今天这篇我就从实际踩坑的角度把 LangChain Zod Schema 的玩法完整拆一遍包括为什么它有用、怎么配、以及那个让无数人崩溃的 400 invalid schema 是怎么一回事。1. 为什么非要给 AI 输出加“类型枷锁”1.1 大模型输出的真实面目大模型本质上是个概率模型它并不知道什么是 JSON Schema也不知道你定义的字段类型是什么。它默认的输出只是“看起来像”一段结构化的文本。你说“请返回一个 JSON 对象”它确实会尽量给你 JSON但这个 JSON 里可能多一个字段可能少一个字段可能把 price 写成了字符串 99.0甚至可能因为输入里出现了中英文混排导致引号断裂。在真实业务里这些看似微小的偏差会让下游代码直接抛异常。我见过不少项目团队花了两三天封装 LLM 调用最后却发现 30% 的情况下解析失败。根本原因就是他们只把提示词写成了“请返回 JSON”而没有用 schema 约束模型的输出格式。大模型的上下文窗口再大也只靠概率推断你的要求除非你把格式定义成它无法逃脱的结构。LangChain 的 structured output 机制本质上就是把这个“无法逃脱的结构”固化成可执行的调用链。1.2 用 Schema 输出到底解决了什么问题结构化输出解决的核心问题有三个字段缺失、字段类型错误、字段多余。字段缺失最直观模型心情不好某个非必填字段它就不返了下游一读就报 KeyError。字段类型错误更常见数字对象全给你用引号包起来日期时间格式五花八门。字段多余则是另一个麻烦模型把一些隐含信息也塞进来如果你用严格的解析器多出来的字段就是“多余属性”直接抛异常。LangChain 的解决方案是把“输出结构”从提示词里独立出来变成一个正式的约束。无论后端用的是 OpenAI 的 function calling、还是 JSON mode、还是其他推理引擎的 format 参数LangChain 都在上层封装了一套统一的接口。你在代码里定义好 schemaLangChain 负责把它翻译成目标模型能理解的东西再把模型的输出解析成 schema 定义的对象。1.3 为什么我在 TypeScript 项目里选了 Zod如果你用 Python 写 LangChain官方首选是 Pydantic这个没争议。但如果你在 TypeScript / Node.js 生态里Zod 几乎是唯一体验最好的选择。Zod 本身就是一个专注于运行时校验的 schema 声明库代码写起来很自然类型推导也做得很好。你用z.object({...})定义一个结构它既能在编译期给 TypeScript 推导出对应的类型又能在运行时对数据进行校验。LangChain JS 版提供了withStructuredOutput这个方法可以直接接收一个 Zod schema 作为输出格式描述。LangChain 内部会把 Zod schema 转成目标模型能识别的 JSON Schema在调用结束后再拿同一个 schema 对模型输出做解析和校验。这样一套走下来你在业务代码里拿到的就是一个已经被验证过的、类型安全的对象这比“解析 JSON 字符串 手工断言”爽太多了。2. 先搞懂 Schema 设计的关键细节2.1 Zod 基础模式从字符串到对象在深入 LangChain 之前先把 Zod 的基础用法过一遍。z.string()代表字符串z.number()代表数字z.object({...})代表对象。例子import { z } from zod; const ArticleSchema z.object({ title: z.string().describe(文章标题), summary: z.string().describe(文章摘要控制在50字以内), tags: z.array(z.string()).describe(文章标签最多5个), readingTimeMinutes: z.number().describe(预计阅读时间单位分钟), wordCount: z.number().describe(文章字数), });每个字段上的.describe()很重要因为 LangChain 在把 Zod schema 转成模型能理解的 function schema 时会把这里的中文描述一并带过去。模型看到summary字段的注释是“控制在50字以内”生成结果时就会更倾向于遵守这个长度约束。你在定义 schema 时对字段描述得越细模型的输出越稳定。2.2 LangChain 里的 withStructuredOutput 到底做了什么以 LangChain.js 为例withStructuredOutput是对不同模型提供商输出能力的统一封装。传入一个 Zod schema它内部会做三件事先调用一个转换方法把 Zod schema 转换成 JSON Schema再把 JSON Schema 塞进模型调用的参数里对于 OpenAI 系模型就是以 function 或者 response_format 的形式传过去拿到模型原始输出后再用这个 schema 做解析和校验返回最终对象。你不需要关心 OpenAI 的 function calling 细节也不用关心某个开源模型是不是支持 tools。LangChain 帮你适配了这些差异你只专注定义 schema。但这件事也有代价就是当你遇到外面套了一层复杂正则或特殊类型的 schema 时LangChain 生成的中间结构可能不符合目标模型的规范于是就会出现后面你要重点关注的 400 错误。2.3 设计 Schema 的三条铁律第一字段宁少勿多。模型不是数据库它能“理解”结构但不擅长高度复杂的嵌套。你把 schema 设计成三层嵌套、六个数组、一堆可选字段模型很容易顾此失彼生成结果经常缺胳膊少腿。我一般建议对象层级控制在两层以内数组字段每个对象最多四五个属性。第二描述要具体且带约束条件。z.string()和z.string().describe(只允许返回字母数字)对模型来说完全是两个字段。描述里加入“限制长度”“使用枚举值”“单位是什么”这类信息能大幅提升准确率。第三避免过度依赖正则表达式。Zod 的.regex()很好用但 OpenAI 等平台的 function schema 对正则支持很有限。很多正则写法在这个场景下不生效甚至直接抛 400。3. 实操给 AI 输出套上 Zod Schema3.1 环境准备与依赖安装先说环境。我建议 Node.js 18 以上因为很多依赖对较新的 API 有要求。新建一个 TypeScript 项目mkdir langchain-structured-output-demo cd langchain-structured-output-demo npm init -y npm install langchain langchain/openai zod npm install -D typescript tsx types/node npx tsc --initlangchain/openai是 LangChain.js 里对接 OpenAI 模型的标准包。如果你用的是国产模型或者兼容 OpenAI 协议的网关也可以换成一个自定义的 ChatModel结构类似。重点是我们需要langchain核心包、langchain/openai模型适配层和zod三件套。3.2 定义结构化输出的 Schema我们要做一个“文章助手”输入一个主题让它帮我们生成一篇短文并输出结构化元信息。逻辑不复杂但包含了字符串、数组、枚举、数字足以看出 schema 的作用。// schemas/article.ts import { z } from zod; export const ArticleOutputSchema z.object({ title: z.string().describe(文章标题15字以内), slug: z .string() .describe(文章URL别名只允许小写字母、数字和连字符不超过50个字符), summary: z.string().describe(一句话摘要不超过30字), tags: z .array(z.string()) .min(1) .max(3) .describe(文章标签1到3个), category: z .enum([技术, 生活, 随笔]) .describe(文章分类), wordCount: z.number().describe(正文字数整数), paragraphs: z .array(z.string()) .min(3) .describe(正文段落每一段是一段完整文字总共3到5段), });这个 schema 定义了模型必须返回的字段、每个字段的类型、取值范围和约束。注意slug字段的约束不要写成正则我在这里只写了“只允许小写字母、数字和连字符不超过50个字符”。如果你一时手快用.regex(/^(?!-)[a-z0-9-]{1,50}$/)去定义运气不好就可能踩到那个经典的 400 报错后面我会专门讲。3.3 用 LangChain 调用模型接下来用ChatOpenAI包装模型然后调用withStructuredOutput再 invoke 一个普通消息。import { ChatOpenAI } from langchain/openai; import { ArticleOutputSchema } from ./schemas/article; const model new ChatOpenAI({ model: gpt-4o-mini, temperature: 0.2, apiKey: process.env.OPENAI_API_KEY, }); const structuredModel model.withStructuredOutput(ArticleOutputSchema, { name: article_output, method: functionCalling, }); const result await structuredModel.invoke( 帮我写一篇关于如何提升sleep质量的文章面向程序员 ); console.log(result.title); console.log(result.tags); console.log(result.wordCount);跑完这段代码result是经过 ArticleOutputSchema 校验的对象不是 JSON 字符串也不是一个模棱两可的响应。你可以直接result.title、result.paragraphsTypeScript 会给你完整的类型提示。如果模型返回的数据不符合 schemaLangChain 会抛出 OutputParserException 并附带原因。这里的temperature: 0.2是刻意调低结构化输出场景需要更稳定、更少随机性高 temperature 会让字段和格式漂移概率变大。3.4 参数选择背后的考量很多初学者会纠结 temperature 设多少、model 选哪个。我的经验是结构化输出场景temperature 设在 0 到 0.3 之间比较合适。0 太死板容易让文字类内容显得机械但结构化输出的正确率最高0.5 以上输出内容更丰富但字段缺失、类型错误的概率也会明显上升。模型选型上如果追求稳定和比较好的指令遵循能力OpenAI 的 GPT-4o mini 性价比很高如果想省成本且下游对字面质量要求不高用轻量模型也行。但任何模型结构化输出都比自由文本输出需要更精确的指令和约束所以不要为了省几个 token 把模型压到“刚够聊天”的水平否则你会花更多时间排查解析错误。3.5 一个更复杂的嵌套示例上面的例子算入门。真实业务里多的是嵌套结构比如电商场景里“订单 商品列表 收货地址”const OrderSchema z.object({ orderId: z.string().describe(订单号), customerName: z.string().describe(客户姓名), items: z .array( z.object({ productName: z.string().describe(商品名), quantity: z.number().describe(数量), unitPrice: z.number().describe(单价单位元), }) ) .describe(商品列表), address: z.object({ province: z.string(), city: z.string(), detail: z.string(), }), });这种嵌套结构模型也能处理但你需要给每个字段写清描述尤其是items和address这种复合字段。层级越深描述就要越详细。另外我强烈建议给嵌套对象也写上.describe()因为很多模型在转换 schema 时会把 codeblock 里的注释信息也带过去描述越清晰输出越符合预期。4. 踩坑实录那个让你怀疑人生的 400 invalid schema4.1 报错长什么样最近网上大批人反馈在 LangChain 或一些 AI 代理工具里使用 schema 结构化输出时遇到了类似这样的报错api error: 400 invalid schema for function artifact: ^(?!.*$)[^\p{cc}\p{cf}\p{zl}\p{zp}\./\[\]]{1,200}$ is not a regex我最初看到也是懵的。这个报错表面上是说某个函数这里是 artifact的 schema 里有一个字段它的正则表达式不合法。但你扫一眼自己代码可能压根没写过这么复杂的正则。问题的根源在于某个底层库或代理工具在生成 schema 时默认给一个name或url字段塞了一大段正则作为约束。这段正则用的是 PCRE 风格还带负向前瞻(?!...)和 Unicode 属性\p{cc}而目标大模型平台的 JSON Schema 实现只支持子集不支持这些高级写法于是直接抛 400。4.2 根本原因拆解这个报错可以从三个层面来看第一正则语法兼容性。OpenAI 等平台的 function schema 里的 pattern 字段使用的是一个相对保守的正则引擎大致遵循 ECMAScript 正则规范。ECMAScript 正则原则上不支持反向预查lookbehind也不支持类似\p{cc}的 Unicode 属性直接这样写。而上面那段正则^(?!__.*__$)[^\\p{cc}...]用了负向前瞻还使用了\p{cc}这类属性简写很多在线校验工具能通过但大模型平台的校验器直接拒绝。第二schema 的生成方式。这个报错里的函数名是artifact不少用户在 LangChain 之外的 AI 辅助工具里也见过。它大概率不是你自己定义的 schema 直接崩的而是某个封装层自动给“生成文件/生成作品”类功能生成的一个默认 schema。这个默认 schema 里给文件名或 ID 字段挂了上面的正则结果发送到 API 端对方说不认识。第三转义和大小写问题。\p{cc}、\p{cf}、\p{zl}、\p{zp}这些是 Unicode 类别代码平台方可能只支持某些类别的子集或者把它当成普通字母 p 加花括号形式直接判定为非法。一旦某个字段的 pattern 非法整个 function schema 就作废API 层直接 400。4.3 怎么办修复与规避要解决这个问题最直接的办法是不要依赖那种复杂的正则约束。回到我们的 Zod schema避免使用带有前瞻、Unicode 属性、嵌套量词的正则。像上面slug字段直接用.describe()描述约束规则让模型自己遵循不要写正则。如果业务上必须校验可以把校验放到拿到输出之后的“后处理阶段”也就是说在模型输出后你在代码里用 Zod 的.regex()二次校验而不是把它塞给 API 端的 function schema。如果你控制不了底层工具自动生成的 schema那就换一种输出方式。LangChain 的withStructuredOutput支持多种method除了functionCalling还有jsonMode、jsonSchema等。不同的底层机制对正则的支持略有差异。改成jsonMode往往能绕过 function schema 里的 pattern 校验。4.4 排查 400 错误的通用清单如果哪天你遇到类似 400别急着怀疑模型 key 错了按这个顺序排查是不是 schema 里带有regex、pattern字段有的话先全部注释掉或删掉。是不是把某个字符串字段写成了枚举但值里带正则字符例如z.string().regex(...)最容易触发。是不是字段名称里带特殊字符OpenAI 对字段命名也有限制不能用-开头不能包含点、空格。是不是 schema 里某个description过长或包含尖括号等特殊组合个别平台会做 HTML 转义超长描述也可能导致问题。是不是模型本身不支持 function calling换一个支持的工具或模型。我把这些整理成一个速查表。排查项典型的错误示例修复建议正则字段z.string().regex(/^(?!a)/)删除正则改用描述或后置校验Unicode 类别\p{cc}、\p{cf}替换为 ASCII 字符集或数字范围字段名特殊字符字段名包含-、.、空格改成小驼峰或下划线描述过长description 超过 1024 字符精简描述建议 200 字符以内嵌套过深对象嵌套超过 4 层拍平结构或拆分多次调用4.5 我在实际项目里的处理经验遇到这种 400我通常会从一个最小可复现的 schema 开始一点点加字段找到是谁触发的。半天排查不出来的话就直接放弃复杂正则。后来团队定的规范是schema 里一律不写pattern只写min、max、enum、describe这类安全的约束正则校验全部放到“输出解析”之后。这样虽然多写几行代码但几乎不会再被 API 层的 schema 校验卡住。5. 从 LangChain 到 LangGraph结构化输出的边界与进阶5.1 LangChain 与 LangGraph 的分工很多人会问LangChain 和 LangGraph 到底什么关系。简单说LangChain 偏向提供“组件”比如模型封装、文档加载、输出解析、Prompt 模板LangGraph 偏向提供“编排能力”比如图状态、节点流转、循环、条件分支。结构化输出本身是 LangChain 的好活但在 LangGraph 的节点里同样可以用withStructuredOutput来约束每个节点的输出内容。我在一个实际项目里做过一个多步骤 Agent第一步从一个话题里抽取关键词第二步生成文章大纲第三步产出完整段落。每一步都不希望自由文本传给下一步因为自由文本会让状态变得混乱。我在 LangGraph 的每个节点里都挂了不同的 Zod schema相当于每个节点的输出都先被校验过状态对象的类型是确定的。这让整个 Agent 的可控性提升了一个档次。5.2 在 LangGraph 节点里使用 Zod如果你要在一个 LangGraph 节点里用结构化输出代码模式和前面类似只是把invoke包在节点的函数里import { Annotation, StateGraph } from langchain/langgraph; const StateAnnotation Annotation.Root({ topic: Annotationstring, outline: Annotationstring[], article: Annotationstring, }); async function generateOutline(state: typeof StateAnnotation.State) { const structuredModel model.withStructuredOutput( z.object({ outlinePoints: z.array(z.string()).describe(大纲要点列表), }) ); const result await structuredModel.invoke(根据主题${state.topic}生成大纲); return { outline: result.outlinePoints }; } const graph new StateGraph(StateAnnotation) .addNode(generateOutline, generateOutline) .addEdge(__start__, generateOutline) .addEdge(generateOutline, __end__) .compile();这样 Graph 里的状态outline就是一个真正经过 schema 校验的字符串数组。比起让模型直接输出“1. xxx\n2. xxx”再手工 split省心太多了。5.3 什么时候不该用结构化输出结构化输出不是万能药。如果你的场景是自由聊天、头脑风暴、写小说强行套一个 schema 会把模型的表现力压得太死。给模型戴太紧的“类型枷锁”它生成的内容会变得模式化、干巴巴。我一般只在“机器要消费这个输出”时才用结构化输出。人读的内容让它自然表达就好机器要读的内容才上 schema。如果你做的是 API 中间层给下游开发者用那就更应该用结构化输出。否则下游接手的同事会因为你没有 schema 而面临无数解析 bug。这里其实已经不只是技术选型而是团队协作的工程质量问题。5.4 再分享一个小技巧很多人不知道withStructuredOutput还能传一个includeRaw选项。当模型输出偶尔不满足 schema 时LangChain 默认会抛异常。如果你打开了includeRaw: true返回结果里会有raw字段保存模型原始的未处理输出。你可以在解析失败时用原始输出做兜底或者打印日志定位问题。const structuredModel model.withStructuredOutput(ArticleOutputSchema, { name: article_output, includeRaw: true, }); const response await structuredModel.invoke(帮我写一篇关于Node.js的文章); if (!response.parsed) { console.error(结构化解析失败原始输出, response.raw); }这个技巧在调试复杂 schema 时非常好用。有时候模型其实输出对了只是某个字段有一个空字符串不符合min(1)约束你看原始输出一眼就能找到原因而不是对着一个笼统的OutputParserException发呆。我在实际项目中的体会是结构化输出最大的价值不是“让输出好看”而是“让下游代码不再脆弱”。你花二十分钟定义的 schema省下的是未来无数个深夜排查解析异常的宝贵时间。如果你也正在被模型输出不稳定困扰强烈建议马上用 Zod Schema 把你的 AI 输出管起来。