流式输出解决了“等待太久”的体验问题,却没有解决另一个更重要的工程问题:模型返回的是自然语言,而业务系统真正需要的通常是字段明确、类型稳定的对象。
例如,“介绍一下爱因斯坦”可以得到一段很流畅的文字。但如果接下来要把姓名显示在卡片标题中、把出生年份写入数据库、把研究领域渲染成列表,继续从自然语言中截取信息会非常脆弱。
更合理的目标是直接得到下面这样的 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.13 与 zod 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 的两项职责:
- 通过
getFormatInstructions()告诉模型应该返回什么格式; - 通过
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不一定是文本,也不应未经判断就当作最终校验对象。
至此,两条链路就完整了:上篇解决“数据如何尽早到达”,下篇解决“到达的数据如何稳定地被程序使用”。真正可靠的大模型应用,需要同时处理好传输时机和数据契约。
价值在于把自然语言输出变为可校验、类型稳定的对象;适合需要落库、渲染卡片或调用下游的工程场景,尤其推荐用 Zod 与 withStructuredOutput 建立契约。