LLM 返回自由文本,但你的业务需要 JSON。本文从 Output Parser 讲到 Tool Calling,带你理解结构化输出的完整进化路径,附完整代码和面试高频问。
前言
用 LLM 做业务时,你一定遇到过这个问题:大模型返回的是一段话,但你要的是一个 JSON 对象。
比如让它"介绍一下爱因斯坦",它返回的是:
erlang
爱因斯坦是德裔物理学家,出生于1879年......
但你的代码需要的是:
json
{"name": "爱因斯坦", "birth_year": 1879, "nationality": "德国"}
怎么让 LLM 按格式输出?输出了又怎么解析?本文带你搞懂从 Output Parser 到 Tool Calling 的完整进化路径。
你将会收获:
- 理解 Output Parser 的三种方案及其局限
- 理解 Tool Calling 为什么是结构化输出的终极方案
- 掌握
model.withStructuredOutput()的用法和原理 - 面试中遇到"LLM 结构化输出"不再慌
技术栈: Node.js + LangChain + OpenAI API + Zod
一、Output Parser 三件套:从文本中"抠"出 JSON
1.1 问题的根源
LLM 的输出本质是自由文本,不是结构化数据。即使你让它"以 JSON 格式返回",它也可能返回:
json
```json
{"name": "爱因斯坦", "birth_year": 1879}
ruby
前面多了 `` ```json ``,后面多了 `` ``` ``,直接 `JSON.parse()` 会报错。
### 1.2 方案一:正则提取(最原始)
```js
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
const jsonStr = jsonMatch ? jsonMatch[1] : response.content;
const result = JSON.parse(jsonStr);
问题:每次手写正则,LLM 格式一变就挂。
1.3 方案二:JsonOutputParser(LangChain 封装)
js
import { JsonOutputParser } from '@langchain/core/output_parsers';
const parser = new JsonOutputParser();
const prompt = `介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}`; // 追加格式说明
const response = await model.invoke(prompt);
const result = await parser.parse(response.content); // 自动提取 + JSON.parse
两个关键方法:
getFormatInstructions()→ 在 prompt 末尾追加格式约束parser.parse()→ 正则提取 markdown + JSON.parse
1.4 方案三:StructuredOutputParser(约束字段结构)
JsonOutputParser 只管"返回 JSON",不管"JSON 里面有什么字段"。升级方案:
js
import { StructuredOutputParser } from '@langchain/core/output_parsers';
import { z } from 'zod';
const schema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
const parser = StructuredOutputParser.fromZodSchema(schema);
const prompt = `介绍一下爱因斯坦。${parser.getFormatInstructions()}`;
const response = await model.invoke(prompt);
const result = await parser.parse(response.content);
1.5 Output Parser 的本质
scss
prompt 约束(源头)+ 正则提取 + JSON.parse(兜底)
↑ ↑
getFormatInstructions() parser.parse()
💡 一句话记住 :Output Parser = 从文本中"抠"出 JSON,是事后解析。
二、Tool Calling:结构化输出的终极方案
2.1 换个思路
Output Parser 的问题是:先让模型自由发挥,再从文本里提取结构。这就像从一篇作文里找答案,总有遗漏的风险。
但 LLM 有一个原生能力:Tool Calling(工具调用)。工具调用的参数本身就是结构化的 JSON。
javascript
Output Parser: LLM → 自由文本 → 解析文本 → 提取 JSON(有损耗)
Tool Calling: LLM → 直接返回结构化参数 → 天然就是 JSON(零损耗)
2.2 关键洞察:工具不需要真的执行
js
const modelWithTool = model.bindTools([{
name: 'extract_scientist_info',
description: '提取和结构化科学家的详细信息',
schema: scientistSchema,
}]);
const response = await modelWithTool.invoke('介绍一下爱因斯坦');
console.log(response.tool_calls[0].args);
// 直接拿到结构化数据,工具根本没有执行!
我们不是要调用工具,而是借用 Tool Calling 的结构化能力。 这是 Tool Calling 最巧妙的用法。
2.3 为什么比 Output Parser 更严格?
| Output Parser | Tool Calling | |
|---|---|---|
| 数据来源 | 从文本中提取 | 模型原生输出 |
| 约束方式 | prompt 暗示(模型可能不遵守) | schema 强制(模型必须遵守) |
| 解析风险 | JSON 可能不合法 | 天然结构化,无需解析 |
| 类型校验 | 依赖 Zod 事后校验 | 模型参数自带类型 |
| 嵌套支持 | 需要 Zod 定义 | 原生支持 |
💡 一句话记住 :Tool Calling 是模型的原生结构化输出能力 ,Output Parser 只是从文本中抢救数据。
2.4 代码演示
js
import { ChatOpenAI } from '@langchain/openai';
import { z } from 'zod';
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
// 方式一:手动绑定工具
const modelWithTool = model.bindTools([{
name: 'extract_scientist_info',
description: '提取和结构化科学家的详细信息',
schema: scientistSchema,
}]);
const response = await modelWithTool.invoke('介绍一下爱因斯坦');
console.log(response.tool_calls[0].args);
// { name: "爱因斯坦", birth_year: 1879, nationality: "德国", fields: ["物理学"] }
三、withStructuredOutput:LangChain 的终极封装
3.1 手动 bindTools 的问题
上面的方式虽然好用,但有两个问题:
- 需要手动定义工具的 name、description、schema(重复劳动)
- 调用后还要从
tool_calls[0].args里取数据(样板代码)
3.2 一步到位:withStructuredOutput
js
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
// 一行搞定:绑定 + 解析 + 校验
const structuredModel = model.withStructuredOutput(scientistSchema);
const result = await structuredModel.invoke('介绍一下爱因斯坦');
console.log(result);
// { name: "爱因斯坦", birth_year: 1879, nationality: "德国", fields: ["物理学"] }
对比一下:
js
// 手动 Tool Calling(繁琐)
const modelWithTool = model.bindTools([{ name: '...', description: '...', schema }]);
const response = await modelWithTool.invoke(prompt);
const result = response.tool_calls[0].args;
// withStructuredOutput(简洁)
const structuredModel = model.withStructuredOutput(schema);
const result = await structuredModel.invoke(prompt);
3.3 底层原理
withStructuredOutput 做了什么?
markdown
1. 把 Zod schema 转成 tool definition
2. 绑定到模型(等价于 bindTools)
3. 设置 tool_choice 为强制调用
4. 从 tool_calls 中提取 args
5. 用 Zod schema 校验返回结果
本质还是 Tool Calling,只是包装成语义更好的 API。
3.4 兼容性:不支持 Tool Call 怎么办?
有些模型(老模型或某些国产模型)不支持 Tool Calling。withStructuredOutput 会自动降级:
javascript
支持 Tool Calling → 用原生能力(效果最好)
不支持 → 降级为 prompt 引导 + JSON 解析(兜底)
这也是它比手动 bindTools 更健壮的原因------bindTools 遇到不支持的模型直接报错,withStructuredOutput 会降级处理。
四、进化路径总结
javascript
JsonOutputParser → StructuredOutputParser → Tool Calling → withStructuredOutput
↓ ↓ ↓ ↓
只管 JSON 格式 加字段约束 模型原生能力 终极封装
从文本提取 从文本提取 零损耗 零损耗 + 自动降级
核心演进逻辑:
| 阶段 | 方案 | 约束方式 | 本质 |
|---|---|---|---|
| 1.0 | JsonOutputParser | prompt 暗示 | 事后解析文本 |
| 2.0 | StructuredOutputParser + Zod | prompt + schema 描述 | 事后解析文本 |
| 3.0 | Tool Calling + bindTools | 模型原生 schema 约束 | 原生结构化输出 |
| 4.0 | withStructuredOutput | 模型原生 + 自动封装 | 原生 + 降级兜底 |
五、Output Parser 还有必要存在吗?
有,但场景变窄了。
推荐用 withStructuredOutput 的场景
- 需要 JSON 格式的结构化数据(90% 的场景)
- 需要严格的类型校验
- 需要支持不支持 Tool Call 的模型(自动降级)
Output Parser 仍然有用的场景
- 需要 XML 格式输出(
XmlOutputParser) - 需要 YAML 格式输出
- 模型不支持 Tool Call 且你不想依赖 LangChain 的降级逻辑
js
// XML 格式 → 用 XmlOutputParser
import { XmlOutputParser } from 'langchain/output_parsers';
const parser = new XmlOutputParser();
const result = await parser.parse(response.content);
// JSON 格式 → 用 withStructuredOutput(推荐)
const structuredModel = model.withStructuredOutput(schema);
const result = await structuredModel.invoke(prompt);
💡 一句话记住 :JSON 结构化用
withStructuredOutput,非 JSON 格式(XML/YAML)用 Output Parser。
六、面试高频问
Q1:LLM 结构化输出有哪些方案?
四种,按进化顺序:
- 正则提取:prompt 约束 + 正则 + JSON.parse(最弱)
- JsonOutputParser:LangChain 封装,自动处理 markdown 包裹
- StructuredOutputParser + Zod:约束字段名、类型、嵌套结构
- Tool Calling / withStructuredOutput:模型原生结构化输出(最强)
推荐用 withStructuredOutput,它是 Tool Calling 的高级封装,兼容性最好。
Q2:Tool Calling 为什么比 OutputParser 更靠谱?
OutputParser 是从 LLM 返回的文本中提取 JSON ,有截断、格式错误等风险。Tool Calling 是 LLM 的原生结构化输出能力,返回的工具调用参数本身就是合法 JSON,不依赖文本解析。
类比:OutputParser 是"从作文里找答案",Tool Calling 是"直接填表格"。
Q3:withStructuredOutput 和 bindTools 的区别?
withStructuredOutput 是 bindTools 的高级封装:
bindTools:手动定义工具 → 调用 → 手动取tool_calls[0].argswithStructuredOutput:传入 Zod schema → 直接返回结构化对象
底层都是 Tool Calling,withStructuredOutput 省去了样板代码,还支持自动降级(模型不支持 Tool Call 时用 prompt 引导)。
Q4:如果模型不支持 Tool Calling 怎么办?
withStructuredOutput 会自动降级为 prompt 引导 + JSON 解析。而手动 bindTools 会直接报错。这也是推荐用 withStructuredOutput 的原因之一。
Q5:getFormatInstructions() 做了什么?
在 prompt 末尾追加一段格式说明,告诉 LLM "请按以下 JSON schema 返回"。这是 Output Parser 的核心------通过在 prompt 中嵌入格式要求,引导 LLM 按指定结构输出。
但这种方式只是"暗示",模型可能不遵守。Tool Calling 的 schema 约束才是"强制"。
Q6:Output Parser 还有必要学吗?
有必要,但不是重点。了解其原理(prompt 约束 + 事后解析)有助于理解结构化输出的演进。实际项目中,JSON 结构化优先用 withStructuredOutput,只有处理 XML/YAML 等非 JSON 格式时才用 Output Parser。
总结
核心概念速查表
| 概念 | 一句话 |
|---|---|
| Output Parser | 从 LLM 文本输出中提取结构化数据(事后解析) |
| Tool Calling | LLM 原生结构化输出能力,参数天然就是 JSON |
| withStructuredOutput | LangChain 对 Tool Calling 的高级封装,一步到位 |
| getFormatInstructions() | 在 prompt 里追加格式约束说明(Output Parser 专用) |
| bindTools | 手动绑定工具到模型(底层 API) |
一句话总结
javascript
结构化输出的进化:从文本中"抠"JSON → 让模型直接"吐"JSON。
推荐用 withStructuredOutput,底层是 Tool Calling,兼容性最好。
核心代码骨架
js
// 推荐方案:withStructuredOutput(Tool Calling 的高级封装)
const schema = z.object({
name: z.string().describe('姓名'),
age: z.number().describe('年龄'),
});
const structuredModel = model.withStructuredOutput(schema);
const result = await structuredModel.invoke('介绍一下xxx');
// result 直接就是结构化对象,不用解析
希望这篇文章对你有帮助!有问题欢迎在评论区交流 🔥