目标:搞清
JsonOutputParser、StructuredOutputParser、withStructuredOutput三者的关系,以及这个模块到底该不该"丢掉"。
🧩 问题:大模型只会说"人话"
LLM 返回的永远是一个字符串。但下游业务要的是能点出来的对象:
result.name // ✅ 能这样用,业务才能继续跑
result.birth_year // ❌ 字符串里没有这种属性
所以我们需要一条链路:
prompt 约束 ──▶ LLM 按格式返回 ──▶ 解析成对象 ──▶ 业务继续执行
这一条链路上有两个可调的位置:
- 📝 约束:怎么让模型乖乖按格式输出?
- 🔍 解析:拿到的字符串怎么安全变成对象?
下面的"三级火箭",本质就是约束的位置在不断升级。
🥉 第 0 层:手写 JSON.parse(),然后翻车
最朴素的做法:在 prompt 里写清楚字段,然后 JSON.parse()。
const jsonStr = response.content;
const jsonResult = JSON.parse(jsonStr);
跑起来大概率是这个结果:
SyntaxError: Unexpected token '`' ...
为什么?因为大模型很爱用 Markdown 输出,这是它展示信息的天性。你问它要 JSON,它给你:
```json
{
"name": "爱因斯坦",
"birth_year": 1879
}
```
JSON 是"对"的,但被 ```json 包裹住了,JSON.parse() 认不出。
于是有了第一版土办法 —— 用正则把外壳剥掉:
// 使用正则提取 markdown 代码块中的 json 字符串
// 分组
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)```/);
// 正则业务逻辑
const jsonStr = jsonMatch ? jsonMatch[1].trim() : response.content;
const jsonResult = JSON.parse(jsonStr);
它能跑通,但问题很明显:
| 隐患 | 说明 |
|---|---|
| 🤕 没有代码块就崩 | jsonMatch 为 null 时才兜底,模型多加一句话就完蛋 |
| 🤕 正则要自己维护 | 各种 json / JSON / 前后有解释文字的变体 |
| 🤕 每个项目重写一遍 | 纯重复劳动 |
💡 每次 AI 调用都会遇到这种常见业务,所以 LangChain 提供了对应的 API,省去开发复杂度。
🥈 一级火箭:JsonOutputParser
LangChain 的 JsonOutputParser 就是把上面那段"正则 + parse"封装好了。
import { JsonOutputParser } from '@langchain/core/output_parsers';
const parser = new JsonOutputParser(); // json 输出解析器
const prompt = `
请介绍一下爱因斯坦的信息,通过 JSON 格式返回,
包含以下字段:name(姓名),birth_year(出生年份),
nationality(国籍),major_achievements(主要成就,数组)
famous_theory(著名的理论)
${parser.getFormatInstructions()}
`;
const response = await model.invoke(prompt);
const result = await parser.parse(response.content);
上面那三行手写正则,被最后一行 parser.parse() 完全替代了 —— 自动剥离 Markdown 包裹,拿到 JSON 字符串再 JSON.parse()。
🤨 但有个奇怪的地方
parser.getFormatInstructions() 拼进 prompt 后,打印出来是这样的:
请介绍一下爱因斯坦的信息,通过 JSON 格式返回,
包含以下字段:name(姓名),birth_year(出生年份)...
← 这里是空的!
它返回的是空字符串。
原因也很直白:JSON 太常见了,格式约定已经写在 prompt 正文里("包含以下字段:name…"),解析器没必要再啰嗦一遍。JsonOutputParser 没有重写 getFormatInstructions(),所以沿用了基类的空实现。
📌 这说明
JsonOutputParser是偏解析的:约束靠你自己在 prompt 里写,它只负责把结果抠出来。
🥇 二级火箭:StructuredOutputParser
JsonOutputParser 的升级版。它的核心变化是:getFormatInstructions() 不再为空了。
方式一:fromNamesAndDescriptions() —— 用「字段 + 描述」描述结构
import { StructuredOutputParser } from '@langchain/core/output_parsers';
// json, name, description 更靠谱
// JsonOutputParser 格式化的升级
const parser = StructuredOutputParser.fromNamesAndDescriptions({
name: '姓名',
birth_year: '出生年份',
nationality: '国籍',
major_achievements: '主要成就,用逗号分隔的字符串',
famous_theory: '著名的理论'
});
const question = `
请介绍一下爱因斯坦的信息
${parser.getFormatInstructions()} // ← 这里会自动生成格式说明
`;
对比一看就明白了:
| prompt 里的格式约束 | 谁来写 | |
|---|---|---|
JsonOutputParser | "包含以下字段:name(姓名)…" | 🙋 手写 |
StructuredOutputParser | 由 getFormatInstructions() 生成 | 🤖 自动 |
字段描述(description)越具体,输出越靠谱 —— 比如"主要成就,用逗号分隔的字符串",就是在用自然语言约束数据形态。
⚠️ 小提醒:readme 里记的是
fromNameAndDescription(),实际 API 是fromNamesAndDescriptions()(带 s),以代码为准。
方式二:fromZodSchema() —— 用 Zod Schema 严苛约束
字段一多、结构一深,自然语言描述就不够用了,这时候上 Schema。
import { z } from 'zod';
// output 结构化输出,再严苛一点,用 Schema 来约束
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('科学家的出生年份'),
death_year: z.number().optional().describe('死亡年份,如果还在世则不填'),
nationality: z.string().describe('科学家的国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
awards: z.array(
z.object({
name: z.string().describe('获奖的名称'),
year: z.number().describe('获奖的年份'),
reason: z.string().describe('获奖的原因'),
}).describe('科学家获得重要奖项列表')
),
major_achievements: z.array(z.string()).describe('科学家的主要成就'),
biography: z.string().describe('简短传记,100字以内'),
});
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);
这一步能表达的东西丰富多了:
- 🔢 类型:
string/number/array - 🪆 嵌套:
awards是"对象数组",每个对象里还有三个字段 - 🚫 可选:
death_year用.optional()标记
.describe() 依然要写 —— 因为最终它还是要被翻译成自然语言提示塞进 prompt 的。
下游业务,终于能用上靠谱的 JSON 输出了 ✅
🚀 三级火箭:tool call —— 约束从 prompt 搬进了模型
上面的方案,无论 Schema 写得多漂亮,约束的本质都是"求模型配合":格式说明写在 prompt 里,模型愿不愿意照做,全看它心情。
而 tool call 换了个思路 —— 借模型的工具调用机制,把参数 schema 直接交给模型。
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); // ← 结构化数据在这里
😱 灵魂拷问:工具函数根本没有执行?
是的,它压根就没打算执行。
我们绑定的这个 extract_scientist_info 没有对应的真实实现,它存在的意义只有一个:提供一份参数 schema。
question ──▶ LLM ──▶ 决定调用工具 ──▶ 按 schema 生成参数 ──▶ tool_calls[0].args
(工具要解决什么问题) ↑
我们要的就是这一步的结构化产物
数据是从 tool_calls[0].args 里拿的,而不是从 response.content 里。工具是假的,参数是真的。
为什么它比 output parser 更好?
| 对比项 | output parser | tool call |
|---|---|---|
| 约束位置 | prompt 里的文字说明 | 模型原生的 function calling 机制 |
| 校验强度 | 靠模型自觉 | 训练时就学会了严格按 schema 填参数 |
| 出错表现 | 少个引号、多段废话 | args 稳定是合法结构 |
| 解析成本 | 需要正则 / 剥离 Markdown | 直接就是对象,不需要解析 |
💡 没必要用 output parser 模块,tool-calls 的参数也能拿到结构化数据,而且因为 tool-calls 是 LLM 自身的工作机制,会更严格、更好。
更好用的封装:withStructuredOutput()
bindTools 属于偏门写法 —— 每次都要 .tool_calls[0].args 取一层,语义上也不清晰(我要的是数据,不是调用工具)。
LangChain 把它包装成了高阶 API:
// tool call 讨巧的做法,升级为 withStructuredOutput 方法
// 底层还是 tool call
const structuredModel = model.withStructuredOutput(scientistSchema);
const result = await structuredModel.invoke('请介绍以下爱因斯坦的详细信息');
console.log(JSON.stringify(result, null, 2)); // 直接就是对象
没有 tool_calls,没有 args,返回的就是业务要的数据。
内部实现依然是 tool call 机制;如果遇到**不支持 tool call 的模型,会自动降级为「Prompt + JSON 描述」**的方案 —— 也就退回到了前面 output parser 那条路。
🎯 三者的关系:一张图看懂
约束在 prompt 里(靠模型配合) 约束在模型机制里
◀───────────────────────────────────▶ ◀──────────────
JsonOutputParser ──▶ StructuredOutputParser ──▶ tool call ──▶ withStructuredOutput
│ │ │ │
│ │ │ └─ 语义化封装
│ │ └─ 模型原生机制,最严格
│ └─ getFormatInstructions() 自动生成格式说明
└─ getFormatInstructions() 为空,只负责解析
解析能力:★★★★★ ────────────────────────▶ 不再需要解析(直接是对象)
约束能力:★☆☆☆☆ ────────────────────────▶ ★★★★★
三者不是相互替代,而是"约束的责任在往上游移动":
- 🥈
JsonOutputParser—— 只管解析,约束你自己在 prompt 写 - 🥇
StructuredOutputParser—— 把约束自动化(描述 / Schema ➜ 格式说明),解析依然靠它 - 🚀
withStructuredOutput—— 把约束交给模型,解析这一步直接消失
它们共享同一套抽象:getFormatInstructions()(约束)+ parse()(解析),只是各自实现的"分量"不同。
🤔 那 output parser 模块是不是可以丢了?
不可以。 两个理由:
1️⃣ 格式化输出不只有 JSON
还有 XML、YAML 等格式。比如 XMLOutputParser:
import { XMLOutputParser } from '@langchain/core/output_parsers';
const parser = new XMLOutputParser();
const question = `
请提取以下文本中的任务信息:爱因斯坦生于1879年,是一位伟大的物理学家。
${parser.getFormatInstructions()}
`;
XML 是老一代的数据交换标准(想想 XMLHttpRequest,AJAX 时代的产物);如今前后端交互的事实标准早就变成了 JSON —— fetch 一个后端 API,返回 JSON。但在某些场景下(长文本包裹、标签语义清晰),XML 依然是更合适的选择。
2️⃣ 格式特殊时,还是得靠它
✅ 推荐:model.withStructuredOutput(schema) ← 常规结构化输出
✅ 备选:output parser 模块 ← 格式特殊 / 模型不支持 tool call
📌 小结
| 方案 | 约束方式 | 拿到数据的路径 |
|---|---|---|
手写正则 + JSON.parse | 无 | match(/```json([\s\S]*?)```/) |
JsonOutputParser | prompt 手写 | parser.parse(content) |
StructuredOutputParser | getFormatInstructions() 生成 | parser.parse(content) |
bindTools + tool call | 模型原生 tool call | tool_calls[0].args |
withStructuredOutput | 模型原生 tool call(可降级) | 直接返回对象 |
演进路线:
JSONOutputParser ─▶ StructuredOutputParser ─▶ tool call(args) ─▶ withStructuredOutput(schema)
(解析) (解析 + 约束) (约束交给模型) (约束交给模型 + 语义化)