让大模型稳定返回可用数据:Output Parser、Zod 与 Tool Calling(下)

文章来源声明: 原文作者:半个落月; 来源站点:掘金; 原文链接:https://juejin.cn/post/7688226072480464934; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

价值在于把自然语言输出变为可校验、类型稳定的对象;适合需要落库、渲染卡片或调用下游的工程场景,尤其推荐用 Zod 与 withStructuredOutput 建立契约。

让大模型稳定返回可用数据:Output Parser、Zod 与 Tool Calling(下) ------------------------------------------------

流式输出解决了“等待太久”的体验问题,却没有解决另一个更重要的工程问题:模型返回的是自然语言,而业务系统真正需要的通常是字段明确、类型稳定的对象。

例如,“介绍一下爱因斯坦”可以得到一段很流畅的文字。但如果接下来要把姓名显示在卡片标题中、把出生年份写入数据库、把研究领域渲染成列表,继续从自然语言中截取信息会非常脆弱。

更合理的目标是直接得到下面这样的 JavaScript 对象:

{
  <span>name</span>: <span>"阿尔伯特·爱因斯坦"</span>,
  <span>birth_year</span>: <span>1879</span>,
  <span>nationality</span>: <span>"..."</span>,
  <span>fields</span>: [<span>"..."</span>, <span>"..."</span>]
}

本文沿着一条逐步增强的路线,讲清结构化输出的四种实现思路:

格式提示 + 解析
        ↓
Zod Schema + 校验
        ↓
Tool Calling 参数
        ↓
withStructuredOutput 高阶接口

最后还会说明结构化输出与流式输出结合时,为什么不能再把每个 chunk 当作普通字符串。

一、先创建统一的模型实例

下面的示例使用 @langchain/core 1.2.12@langchain/openai 1.5.13zod 4.6.5,运行环境为 Node.js 22 或更高版本。

下面的示例都复用同一种模型配置:

<span>import</span> <span>"dotenv/config"</span>;
<span>import</span> { <span>ChatOpenAI</span> } <span>from</span> <span>"@langchain/openai"</span>;

<span>const</span> model = <span>new</span> <span>ChatOpenAI</span>({
  <span>modelName</span>: process.<span>env</span>.<span>MODEL_NAME</span>,
  <span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>,
  <span>temperature</span>: <span>0</span>,
  <span>configuration</span>: {
    <span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span>,
  },
});

temperature: 0 可以降低演示中的随机性,但它不等于结构约束。要让输出具有确定字段,还需要额外的格式说明或 Schema。

二、第一步:StructuredOutputParser.fromNamesAndDescriptions()

最直观的方式,是先声明需要哪些字段,以及每个字段表示什么:

<span>import</span> { <span>StructuredOutputParser</span> } <span>from</span> <span>"@langchain/core/output_parsers"</span>;

<span>const</span> parser = <span>StructuredOutputParser</span>.<span>fromNamesAndDescriptions</span>({
  <span>name</span>: <span>"姓名"</span>,
  <span>birth_year</span>: <span>"出生年份"</span>,
  <span>nationality</span>: <span>"国籍"</span>,
  <span>major_achievements</span>: <span>"主要成就,用逗号分隔的字符串"</span>,
  <span>famous_theory</span>: <span>"著名理论"</span>,
});

这里的键是最终对象的字段名,值是对字段语义的描述。

接着把解析器生成的格式要求拼进问题:

<span>const</span> question = <span>`
  请介绍一下爱因斯坦的信息。
  <span>${parser.getFormatInstructions()}</span>
`</span>;

getFormatInstructions() 的作用,是生成一段面向模型的输出约定。它会要求模型返回符合指定 JSON Schema 的 JSON,并包含相应字段。

调用模型后,response.content 仍然是字符串:

<span>const</span> response = <span>await</span> model.<span>invoke</span>(question);
<span>console</span>.<span>log</span>(response.<span>content</span>);

即使它看起来像 JSON,甚至被 Markdown 代码块包围,它的 JavaScript 类型仍然是字符串。真正把它变成对象的是 parse()

<span>const</span> result = <span>await</span> parser.<span>parse</span>(response.<span>content</span>);

<span>console</span>.<span>log</span>(<span>`姓名:<span>${result.name}</span>`</span>);
<span>console</span>.<span>log</span>(<span>`国籍:<span>${result.nationality}</span>`</span>);

这套流程可以概括为:

getFormatInstructions()
        ↓
在提示词中约定 JSON 格式
        ↓
model.invoke() 返回文本
        ↓
parser.parse() 提取 JSON 并校验
        ↓
得到 JavaScript 对象

解析器不只是简单调用 JSON.parse()。面对模型经常返回的 Markdown JSON 代码块,它会先取出代码块中的 JSON,再完成解析和结构检查。如果文本不是合法 JSON,或字段不符合要求,解析过程就会抛出错误。

这种声明方式的边界

fromNamesAndDescriptions() 简洁,但这些字段会被声明成字符串。于是:

  • birth_year 只能表达为字符串,而不是数字;
  • major_achievements 只能约定为逗号分隔的字符串,而不是数组;
  • 很难准确描述“奖项数组中的每一项又是一个对象”这类嵌套结构。

当输出只是扁平的键值对时,它很方便;当数据要直接进入业务逻辑时,需要更严格的 Schema。

三、第二步:使用 Zod 描述字段类型

Zod 可以同时描述字段名称、数据类型、是否可选以及嵌套关系:

<span>import</span> { z } <span>from</span> <span>"zod"</span>;

<span>const</span> scientistSchema = z.<span>object</span>({
  <span>name</span>: z
    .<span>string</span>()
    .<span>describe</span>(<span>"科学家的姓名"</span>),

  <span>birth_year</span>: z
    .<span>number</span>()
    .<span>describe</span>(<span>"科学家的出生年份"</span>),

  <span>death_year</span>: z
    .<span>number</span>()
    .<span>optional</span>()
    .<span>describe</span>(<span>"科学家的逝世年份,如果还在世则不填写"</span>),

  <span>nationality</span>: z
    .<span>string</span>()
    .<span>describe</span>(<span>"科学家的国籍"</span>),

  <span>fields</span>: z
    .<span>array</span>(z.<span>string</span>())
    .<span>describe</span>(<span>"研究领域列表"</span>),

  <span>awards</span>: z
    .<span>array</span>(
      z.<span>object</span>({
        <span>name</span>: z.<span>string</span>().<span>describe</span>(<span>"获奖名称"</span>),
        <span>year</span>: z.<span>number</span>().<span>describe</span>(<span>"获奖年份"</span>),
        <span>reason</span>: z.<span>string</span>().<span>describe</span>(<span>"获奖原因"</span>),
      }),
    )
    .<span>describe</span>(<span>"获得的重要奖项列表"</span>),

  <span>major_achievements</span>: z
    .<span>array</span>(z.<span>string</span>())
    .<span>describe</span>(<span>"科学家的主要成就列表"</span>),

  <span>famous_theory</span>: z
    .<span>string</span>()
    .<span>describe</span>(<span>"科学家的著名理论"</span>),
});

这份 Schema 中包含四类重要约束。

1. 基本类型

<span>name</span>: z.<span>string</span>()
<span>birth_year</span>: z.<span>number</span>()

姓名必须是字符串,出生年份必须是数字。"1879"1879 在 JSON 中不是同一种类型,严格的下游程序不应混用。

2. 可选字段

<span>death_year</span>: z.<span>number</span>().<span>optional</span>()

.optional() 表示这个字段可以不存在。它适合“只有已去世人物才填写逝世年份”这样的数据关系。

3. 数组

<span>fields</span>: z.<span>array</span>(z.<span>string</span>())
<span>major_achievements</span>: z.<span>array</span>(z.<span>string</span>())

研究领域和主要成就不再是一个用逗号拼接的长字符串,而是字符串数组。下游可以直接遍历,无需再次切割文本。

4. 对象数组

<span>awards</span>: z.<span>array</span>(
  z.<span>object</span>({
    <span>name</span>: z.<span>string</span>(),
    <span>year</span>: z.<span>number</span>(),
    <span>reason</span>: z.<span>string</span>(),
  }),
)

奖项本身有名称、年份和获奖原因,所以每一项都应该是对象,再由外层数组表示多个奖项。

.describe() 补充的是字段语义。z.number() 只能告诉系统“它是数字”,而“科学家的出生年份”才能告诉模型这个数字应该表示什么。类型约束与语义描述承担不同职责。

四、用 Zod Schema 驱动解析器

创建好 Schema 后,把它交给 StructuredOutputParser.fromZodSchema()

<span>import</span> { <span>StructuredOutputParser</span> } <span>from</span> <span>"@langchain/core/output_parsers"</span>;

<span>const</span> parser = <span>StructuredOutputParser</span>.<span>fromZodSchema</span>(scientistSchema);

<span>const</span> question = <span>`
  请介绍一下爱因斯坦的信息。
  <span>${parser.getFormatInstructions()}</span>
`</span>;

调用和解析过程没有改变:

<span>try</span> {
  <span>const</span> response = <span>await</span> model.<span>invoke</span>(question);
  <span>console</span>.<span>log</span>(response.<span>content</span>);

  <span>const</span> result = <span>await</span> parser.<span>parse</span>(response.<span>content</span>);
  <span>console</span>.<span>log</span>(<span>`姓名:<span>${result.name}</span>`</span>);
  <span>console</span>.<span>log</span>(<span>`国籍:<span>${result.nationality}</span>`</span>);
} <span>catch</span> (err) {
  <span>console</span>.<span>error</span>(<span>"错误信息:"</span>, err);
}

变化发生在契约强度上:

字段名与描述
    ↓
字段名 + 描述 + 类型 + 可选性 + 嵌套关系

解析失败进入 catch 是合理行为。结构化输出的目标不是把任何文本都“凑成”一个对象,而是在结果无法满足业务契约时明确失败,避免错误数据悄悄流入后续流程。

五、JSON 之外:XMLOutputParser

Output Parser 不只处理 JSON。需要 XML 输出时,可以使用 XMLOutputParser

<span>import</span> { <span>XMLOutputParser</span> } <span>from</span> <span>"@langchain/core/output_parsers"</span>;

<span>const</span> parser = <span>new</span> <span>XMLOutputParser</span>();

<span>const</span> question = <span>`
  请提取文本中的任务信息:爱因斯坦生于 1879 年,是一位伟大的物理学家。
  <span>${parser.getFormatInstructions()}</span>
`</span>;

处理步骤与 JSON 解析器很相似:

<span>try</span> {
  <span>const</span> response = <span>await</span> model.<span>invoke</span>(question);

  <span>// 此时 content 是 XML 文本</span>
  <span>console</span>.<span>log</span>(response.<span>content</span>);

  <span>// 解析后得到 JavaScript 对象</span>
  <span>const</span> result = <span>await</span> parser.<span>parse</span>(response.<span>content</span>);
  <span>console</span>.<span>log</span>(result);
} <span>catch</span> (err) {
  <span>console</span>.<span>error</span>(err);
}

这里再次体现了 Output Parser 的两项职责:

  1. 通过 getFormatInstructions() 告诉模型应该返回什么格式;
  2. 通过 parse() 把模型返回的文本转换成程序可操作的数据。

当前写法没有预先指定 XML 标签,因此最终对象结构会跟随模型生成的标签。它适合演示 XML 的“提示 + 解析”过程;如果业务依赖固定字段,Zod 描述的 JSON 对象会更直观。

这也是 Output Parser 仍有价值的原因:结构化结果并不只等于 JSON,XML 等文本格式仍然需要相应解析器处理。

六、第三步:借用 Tool Calling 的参数生成机制

StructuredOutputParser 的基本路径是:让模型把 JSON 写进普通文本,再从 response.content 解析。

Tool Calling 提供了另一条路径。先定义一个带有参数 Schema 的工具:

<span>import</span> { z } <span>from</span> <span>"zod"</span>;

<span>const</span> scientistSchema = z.<span>object</span>({
  <span>name</span>: z.<span>string</span>().<span>describe</span>(<span>"科学家的姓名"</span>),
  <span>birth_year</span>: z.<span>number</span>().<span>describe</span>(<span>"科学家的出生年份"</span>),
  <span>nationality</span>: z.<span>string</span>().<span>describe</span>(<span>"科学家的国籍"</span>),
  <span>fields</span>: z.<span>array</span>(z.<span>string</span>()).<span>describe</span>(<span>"研究领域列表"</span>),
});

<span>const</span> modelWithTool = model.<span>bindTools</span>([
  {
    <span>name</span>: <span>"extract_scientist_info"</span>,
    <span>description</span>: <span>"提取和结构化科学家的详细信息"</span>,
    <span>schema</span>: scientistSchema,
  },
]);

然后正常调用模型:

<span>const</span> response = <span>await</span> modelWithTool.<span>invoke</span>(<span>"介绍一下爱因斯坦"</span>);

<span>console</span>.<span>log</span>(response.<span>tool_calls</span>[<span>0</span>].<span>args</span>);
<span>console</span>.<span>log</span>(response.<span>tool_calls</span>);
<span>console</span>.<span>log</span>(response);

关键数据不再放在普通文本 content 中,而是在 tool_calls 中。第一项工具调用的 args 就是模型根据工具 Schema 准备的结构化参数。

模型并没有执行函数

这是理解 Tool Calling 最重要的一点。

模型做的是:

判断要调用哪个工具
        +
按照工具 Schema 生成参数

它不会在模型内部运行名为 extract_scientist_info 的 JavaScript 函数。真正的函数执行应由模型外部的应用完成,然后应用还可以把工具执行结果放回上下文,再发起下一轮模型调用。

在这个例子里,工具并没有工作函数,因为目的只是借用参数生成机制得到结构化数据。模型这一轮只负责发起工具调用,所以普通 content 可能没有最终自然语言答案。

访问 response.tool_calls[0] 也隐含了“模型确实生成了工具调用”的前提。业务代码若无法保证这一点,应先检查数组是否存在并且非空,再读取 args

七、第四步:使用 withStructuredOutput() 表达真实意图

如果目标不是调用某个真实工具,而只是得到符合 Schema 的对象,那么手动 bindTools() 会让代码语义显得绕:明明想要结构化输出,却伪装成一次工具调用。

withStructuredOutput() 把这个目标直接写进 API:

<span>const</span> scientistSchema = z.<span>object</span>({
  <span>name</span>: z.<span>string</span>().<span>describe</span>(<span>"科学家的姓名"</span>),
  <span>birth_year</span>: z.<span>number</span>().<span>describe</span>(<span>"科学家的出生年份"</span>),
  <span>nationality</span>: z.<span>string</span>().<span>describe</span>(<span>"科学家的国籍"</span>),
  <span>fields</span>: z.<span>array</span>(z.<span>string</span>()).<span>describe</span>(<span>"研究领域列表"</span>),
});

<span>const</span> structuredModel = model.<span>withStructuredOutput</span>(scientistSchema);

之后直接调用:

<span>const</span> result = <span>await</span> structuredModel.<span>invoke</span>(<span>"介绍一下爱因斯坦"</span>);

<span>console</span>.<span>log</span>(result);
<span>console</span>.<span>log</span>(<span>"----------------"</span>);
<span>console</span>.<span>log</span>(<span>JSON</span>.<span>stringify</span>(result, <span>null</span>, <span>2</span>));

这里拿到的 result 已经是对象,不再需要手动读取 response.content,也不需要手动调用 parser.parse()

JSON.stringify(result, null, 2) 只是为了把对象格式化成便于阅读的 JSON 文本:第二个参数不使用替换函数,第三个参数用两个空格缩进。它不会改变 result 本身。

不要把高阶 API 等同于唯一一种底层实现

withStructuredOutput() 表达的是“我要符合这个 Schema 的结果”。具体模型可能通过原生 JSON Schema 能力,也可能通过 Function Calling 等机制实现。

因此,更稳妥的理解是:

调用者依赖统一的结构化输出接口
            ↓
模型适配层选择可用的结构化机制
            ↓
返回经过解析的 JavaScript 对象

业务代码应该依赖这个高阶语义,而不是假设所有模型的内部路径都完全相同。如果模型本身不支持所需能力,是否可用以及如何报错仍取决于实际模型服务,不能把“自动降级”当作无条件保证。

八、结构化输出如何与流式调用结合

普通文本流中,每个 chunk.content 都可以追加到字符串。结构化模型的流则不同:

<span>const</span> structuredModel = model.<span>withStructuredOutput</span>(scientistSchema);
<span>const</span> prompt = <span>"详细介绍爱因斯坦的信息。"</span>;

<span>try</span> {
  <span>const</span> stream = <span>await</span> structuredModel.<span>stream</span>(prompt);
  <span>let</span> chunkCount = <span>0</span>;
  <span>let</span> result = <span>null</span>;

  <span>console</span>.<span>log</span>(<span>"正在接收流式数据..."</span>);

  <span>for</span> <span>await</span> (<span>const</span> chunk <span>of</span> stream) {
    chunkCount++;
    <span>console</span>.<span>log</span>(chunk);
    <span>console</span>.<span>log</span>(<span>JSON</span>.<span>stringify</span>(chunk, <span>null</span>, <span>2</span>));

    result = chunk;
  }

  <span>console</span>.<span>log</span>(<span>"最终结果:"</span>, result);
  <span>console</span>.<span>log</span>(<span>"结构化数据块数量:"</span>, chunkCount);
} <span>catch</span> (err) {
  <span>console</span>.<span>error</span>(err);
}

这里没有使用 fullContent += chunk,而是直接把最新的 chunk 赋给 result,因为流中产出的已经不是普通文本片段,而是结构化处理链产生的结果。

需要特别区分三类数据:

数据典型形态是否适合直接进入业务逻辑
原始模型文本分片`"爱"`、`"因斯坦"` 等文本不适合,内容尚未完成
结构化中间结果可能是不完整、逐步增长的对象适合展示进度,不应默认已通过完整 Schema 校验
最终结构化对象字段与类型完整的对象适合在校验成功后交给下游

结构化 stream() 到底会产生一次最终对象,还是产生多次逐步完善的对象,与模型支持的结构化方式和解析链有关。因此,不能只看到方法名是 stream(),就断言一定能按字段实时返回;也不能把中间对象直接当成最终的 Zod 校验结果。

上面的循环写法兼容两种情况:

  • 如果只产生一次,result 就是这次完整结果;
  • 如果产生多次,result 始终保存最后一次结果。

如果业务必须等所有必填字段出现后再写数据库,就应该在流结束后使用最终结果,而不是在每次循环中立刻执行持久化。

九、四种方式应该怎么选

方式输出位置约束能力更适合的场景
`fromNamesAndDescriptions()`普通文本,再解析字段名和字符串描述简单、扁平的数据
`fromZodSchema()`普通文本,再解析类型、可选字段、数组、嵌套对象需要明确校验规则的 JSON
`bindTools()``response.tool_calls[].args`工具参数 Schema确实要进入工具调用流程,或需要直接观察工具参数
`withStructuredOutput()`直接返回对象由 Schema 和模型结构化能力共同保证只关心稳定结构化结果的常规业务
`XMLOutputParser`XML 文本,再解析XML 格式与标签结构明确需要 XML 的输出

这几种方式不是简单的“新 API 淘汰旧 API”。它们解决的问题略有不同:

  • 只需要 JSON 对象时,withStructuredOutput() 语义最直接;
  • 需要显式控制提示词和解析步骤时,StructuredOutputParser 更透明;
  • 真正要让模型选择并请求外部能力时,使用 Tool Calling;
  • 返回格式是 XML 时,使用对应的 Output Parser。

十、容易踩的几个坑

1. 把看起来像 JSON 的字符串当作对象

<span>console</span>.<span>log</span>(response.<span>content</span>.<span>name</span>);

如果 response.content 仍是字符串,这样读取字段得不到想要的结果。应先通过解析器得到对象,或者直接使用 withStructuredOutput()

2. 字段名拼写不一致

Schema 中的字段名就是应用契约。fields 如果被误写成其他拼法,模型即使严格遵守 Schema,也只会返回那个错误字段名,下游读取 result.fields 时仍然会失败。

3. 用字符串模拟本应存在的数组

“主要成就,用逗号分隔”可以快速演示,但当某一项自身包含逗号时,后续拆分就会变得不可靠。既然 Zod 可以直接声明 z.array(z.string()),就应让数据结构表达真实含义。

4. 认为 Tool Calling 会自动执行本地代码

tool_calls 只是模型产生的调用意图和参数。查找函数、执行函数、捕获异常以及把结果交还给模型,都属于模型外部应用的职责。

5. 把每个结构化流片段都当作最终对象

中间结果的字段可能还没有生成完整。用于界面预览没有问题,但要求完整 Schema 的业务操作应该等待流结束和最终校验结果。

十一、小结

结构化输出的本质,是在模型的自然语言能力与程序的确定性要求之间建立契约:

  • getFormatInstructions() 把结构要求放进提示词;
  • parse() 把模型文本转换成 JavaScript 对象,并在不符合约定时明确失败;
  • Zod 为字段补充类型、可选性、数组和嵌套对象约束;
  • Tool Calling 把结构化数据放在工具调用参数中,但模型只生成调用意图,不执行函数;
  • withStructuredOutput() 用更直接的语义返回符合 Schema 的对象;
  • XMLOutputParser 说明 Output Parser 的价值并不限于 JSON;
  • 结构化流中的 chunk 不一定是文本,也不应未经判断就当作最终校验对象。

至此,两条链路就完整了:上篇解决“数据如何尽早到达”,下篇解决“到达的数据如何稳定地被程序使用”。真正可靠的大模型应用,需要同时处理好传输时机和数据契约。