前言:LLM 输出结构化数据,为什么这么难?
做 AI 应用的时候,你会发现一个非常高频的需求:让大模型返回 JSON。
不管是提取实体、解析表单、还是做下一步的业务分支判断,我们都希望 LLM 的输出是"机器可读"的,而不是一段自然语言。
但现实往往很骨感。你精心写好了 prompt:
请介绍一下爱因斯坦的信息。请以 JSON 格式返回,包含以下字段:name、birth_year...
大模型也很乖地返回了:
```json
{
"name": "阿尔伯特·爱因斯坦",
"birth_year": 1879,
...
}
```
然后你 JSON.parse(response.content) 就直接原地爆炸——因为返回的字符串被 markdown 代码块包裹了。
说白了,大模型是"展示型选手",它天然倾向于把结构化数据渲染成 markdown 方便人看。但我们的代码要的是"数据",不是"展示"。
这篇文章就从这里开始,把 LangChain 的结构化输出方案一路讲透:JsonOutputParser → StructuredOutputParser → tool_call → withStructuredOutput,最后回答两个灵魂拷问:
- 有了 tool_call,output parser 还有必要吗?
output_parsers这个模块是不是可以丢了?
一、手动挡:正则 + JSON.parse
在引入 LangChain 之前,很多人(包括我)第一版代码都是这么写的:
const rawContent = response.content;
const match = rawContent.match(/```(?:json)?\s*([\s\S]*?)```/);
const jsonString = match ? match[1].trim() : rawContent.trim();
const jsonResult = JSON.parse(jsonString);
思路非常朴素:
- 大模型喜欢用
```json ... ```包裹输出 - 那就用正则把外层 markdown 扒掉
- 剩下的内容再
JSON.parse
这套方案能用,但很脆:
- 大模型有时包
```json,有时包```,有时干脆不包 - 有时会在 JSON 前后加一句"好的,以下是结果:"
- 字段名大小写、是否缺字段、类型对不对,全靠运气
一个业务每次都要写一遍正则 + try/catch,纯属重复劳动。
LangChain 的价值就在这:把"每次调用 AI 都要处理的通用逻辑"抽成 API。
二、JsonOutputParser:官方标配的"手动挡升级版"
LangChain 给出的第一个答案就是 JsonOutputParser。
import { JsonOutputParser } from '@langchain/core/output_parsers';
const parser = new JsonOutputParser();
const prompt = `请介绍一下爱因斯坦的信息。请以 JSON 格式返回,
包含以下字段:name(姓名)、birth_year(出生年份)、
nationality(国籍)、major_achievements(主要成就, 数组)、
famous_theory(著名理论)
${parser.getFormatInstructions()}`;
const response = await model.invoke(prompt);
const jsonResult = await parser.parse(response.content);
console.log(jsonResult, jsonResult.name);
看起来很简单,但它背后做了两件事,你要理解透:
1. getFormatInstructions():往 prompt 里塞格式约束
有意思的是,JsonOutputParser 的 getFormatInstructions() 返回的是空字符串。
为什么?因为 JSON 太常见了,模型早就被训练得"你说要 JSON 就会给 JSON",不需要额外的格式说明。
所以这一步的本质是——如果未来换一个解析器(比如带 schema 的),这个接口就是用来把格式约定注入到 prompt 里的钩子。
2. parse():去 markdown + JSON.parse
parse() 内部会:
- 去掉可能的
```json包裹 - 调用
JSON.parse - 返回对象
你可以把它理解为官方帮你写好了正则 + try/catch,省得每次重复。
小结:
JsonOutputParser= 通用 JSON 解析器 + 空格式提示- 适合"字段不多、格式不严格"的快速场景
- 缺点:没有 schema 约束,字段类型对错全靠模型自觉
三、StructuredOutputParser:让格式约束更"严苛"
JsonOutputParser 的痛点很明确:它只能"期望"模型返回 JSON,但无法"约束"结构。
StructuredOutputParser 就是来解决这个问题的。它有两种构造方式。
方式一:fromNamesAndDescriptions(轻量版)
const parser = StructuredOutputParser.fromNamesAndDescriptions({
name: '姓名',
birth_year: '出生年份',
nationality: '国籍',
major_achievements: '主要成就, 用逗号分隔的字符串',
famous_theory: '著名理论',
});
const question = `请介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}`;
跟 JsonOutputParser 最大的区别:这次 getFormatInstructions() 不再返回空串了。
它会把每个字段名 + 描述组装成一段格式说明,注入到 prompt 里。大模型拿到这段说明后,返回的 JSON 结构会明显更稳定。
一句话总结:
getFormatInstructions()的作用 = 把"结构约定"翻译成 prompt 语言,喂给大模型。parse()的作用 = 把"大模型乱涂的输出"翻译回结构化对象。
方式二:fromZodSchema(重装版)
字段多了、嵌套深了,fromNamesAndDescriptions 就不够用了。这时候上 Zod:
import { z } from 'zod';
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
death_year: z.number().optional().describe('死亡年份,如果未死亡则不填'),
nationality: z.string().describe('国籍'),
field_of_study: z.string().describe('研究领域'),
awards: z.array(
z.object({
award: z.string().describe('奖项名称'),
year: z.number().describe('获奖年份'),
reason: z.string().describe('获奖原因'),
})
).describe('获得的重要奖项列表'),
major_achievements: z.array(z.string()).describe('科学家主要成就'),
famous_theory: z.array(z.object({
theory: z.string().describe('理论名称'),
year: z.number().describe('理论年份'),
description: z.string().describe('理论简要描述'),
})).describe('著名理论列表'),
biography: z.string().describe('简短传记: 100字以内'),
});
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);
Zod schema 的三大好处:
- 类型约束:
birth_year必须是 number,模型不会给你返回字符串"1879" - 结构约束:数组、嵌套对象都能表达清楚
- 描述可读:
.describe()里的内容会自动进入getFormatInstructions(),直接指导模型
对于下游业务来说,这就很爽了——拿到 result.birth_year 时,你可以放心地当 number 用,不用写一堆 Number(...) 转换。
四、思路拐个弯:为什么不用 tool_call?
写到这里,我脑子里突然蹦出一个想法:
既然已经用 Zod schema 定义结构了,为什么不直接让模型走 tool_call?
于是有了第三种思路:
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
const modelWithTools = model.bindTools([ { name: "extract_scientist_info", description: "提取和结构化科学家的详细信息", schema: scientistSchema, }]);
const response = await modelWithTools.invoke("请介绍一下爱因斯坦");
console.log(response.tool_calls[0].args);
// { name: '阿尔伯特·爱因斯坦', birth_year: 1879, nationality: '德国/美国', fields: [...] }
这段代码里,藏着三个你必须想清楚的点
① tool 参数的 schema 约束,顺手完成了 LLM 输出的格式化、结构化
我们原本是想"用工具",结果意外发现:工具参数的 schema,本身就是最好的输出格式约束。也就是说,你只要定义了工具的参数长什么样,LLM 就会按那个样子给你吐数据。
格式化不是靠"请求",而是靠"schema 契约"顺带完成的。
② 这份约束,来自 LLM 原生的工作机制
tool_call 不是 LangChain 的发明,是模型厂商在训练阶段就注入的能力(OpenAI 的 function calling、Claude 的 tool use、Qwen/DeepSeek 的 tools 参数都是)。它比 prompt 里的"请返回 JSON"要严苛且准确得多——因为模型在生成 tool_call 时,是逐 token 按 schema 填槽的,语法错误率天然更低。
③ 工具函数其实没有执行
这一点特别容易让人误解。上面那段代码里:
- 我们只是
bindTools,把工具注册给了模型 - 模型返回的是一个 "意图" :
tool_calls[0].args,表示"我想调用这个工具,参数是这些" - 真正的函数体一次都没有跑
args 就是我们要的结构化数据。工具函数只是一个"诱饵",专门用来钓出 LLM 内部已经结构化的那份参数。
说白了:我们不是在调工具,我们是在借"调工具"这个动作,让 LLM 乖乖交出一份按 schema 填好的 JSON。
那这个方案是不是完胜 OutputParser?
从约束强度上看,是的。因为 tool_call 是 LLM 自身机制带来的强约束,比 prompt 里写"请返回 JSON"要硬核得多。所以我的笔记里才写了那句话:
没有必要用 output parser 了,tool calls 的参数,也能拿到结构化的数据,而且因为 tool_calls 是 LLM 自身的机制,会更严格、更好。
但是—— "更严格更好"不等于"可以无脑替代" 。下一节就来说为什么。
五、再上一台阶:model.withStructuredOutput(schema)
虽然 tool_call 好用,但每次都要 bindTools([{ name, description, schema }]) 也太啰嗦了。LangChain 显然也想到了这一点,于是给了一个语义化更好的高阶 API:
// tool call 讨巧的做法,升级为语义化更好地 WithStructuredOutput
// 底层 tool call
const structuredModel = model.withStructuralOutput(scientistSchema);
// 如果 llm 不支持 tool 会自动降级为提示词 + json 输出
const result = await structuredModel.invoke("请介绍一下爱因斯坦");
console.log(JSON.stringify(result, null, 2));
相比手写 bindTools,它有三个明显的好处:
- 语义清晰:
withStructuredOutput(schema)一眼就能看出"我要结构化输出" - 返回值直接是对象:不用再从
response.tool_calls[0].args里挖一层 - 自动降级:如果模型不支持 tool_call,它会自动退化为 Prompt + JSON 描述的方案(等价于
StructuredOutputParser那套逻辑)
这就是 LangChain 的"托底"思维:能用 tool_call 就用,不能用就用 prompt 兜底,你只管调 API。
到这里,我们可以把整条演进路线画出来了:
JsonOutputParser
↓ 加了字段描述
StructuredOutputParser (fromNamesAndDescriptions)
↓ 加了类型约束
StructuredOutputParser (fromZodSchema)
↓ 换成 LLM 原生机制,约束更硬
tool_call (response.tool_calls[0].args)
↓ 语义化封装,自动降级
model.withStructuredOutput(schema) ← 推荐优先使用
每一步都不是替代,而是在约束强度、可读性、通用性之间做权衡。
六、别急着删 output_parsers:XML、YAML 还得靠它
写到这里,很容易产生一个错觉: "有了 withStructuredOutput,output_parsers 模块是不是可以丢了?"
我的答案是:不可以。
原因很简单——结构化输出不只有 JSON 一种格式。XML、YAML 也是常见的结构化数据表达,而且在某些场景里还更合适:
- XML:老一代的数据交换标准(想想
XMLHttpRequest和早期的 Ajax),层级嵌套表达力强,很多爬虫/文档解析场景还在用 - YAML:配置文件的事实标准,可读性比 JSON 更好
LangChain 里就有对应的 XmlOutputParser:
import { XmlOutputParser } from '@langchain/core/output_parsers';
const parser = new XmlOutputParser();
const question = `
请提取一下文本中的任务信息:爱因斯坦生于1879年,是一位伟大的物理学家。
${parser.getFormatInstructions()}
`;
const response = await model.invoke(question);
console.log(response.content);
// <response>
// <name>爱因斯坦</name>
// <birth_year>1879</birth_year>
// <occupation>物理学家</occupation>
// </response>
const result = parser.parse(response.content);
// { response: { name: '爱因斯坦', birth_year: '1879', occupation: '物理学家' } }
你看,getFormatInstructions() 依然在发挥作用——它会告诉模型"请用 XML 输出"。parse() 依然在发挥作用——它会帮你把 XML 转成 JS 对象。
所以最终的推荐策略是:
| 场景 | 推荐方案 |
|---|---|
| 标准 JSON 结构化输出 | model.withStructuredOutput(schema) (首选) |
| 特殊格式(XML / YAML / 自定义) | 对应的 OutputParser |
| 模型不支持 tool_call | withStructuredOutput 自动降级,或手动用 StructuredOutputParser |
一句话记住:JSON 走
withStructuredOutput,特殊格式走output_parser。两者不是替代关系,是分工关系。
七、顺带说说流式下的结构化输出
withStructuredOutput 还有一个很实用的能力——流式输出:
const structuredOutput = model.withStructuredOutput(schema);
const stream = await structuredOutput.stream("详细介绍莫扎特的信息。");
for await (const chunk of stream) {
console.log(JSON.stringify(chunk, null, 2));
}
你会发现,每来一个 chunk,返回的都是部分组装好的对象(已解析、已成型),而不是一段裸 JSON 字符串。
这背后的机制是:LangChain 在流式接收 tool_call 参数的过程中,会增量地拼接 + 增量地解析,让下游能实时拿到"半成品对象"。
对比一下普通流式(没有结构化):
const stream = await model.stream(prompt);
for await (const chunk of stream) {
process.stdout.write(chunk.content); // 只能一段段地拼裸文本
}
一个是"边流边出对象",一个是"边流边出字符"。做前端实时渲染、进度提示、边生成边校验的业务时,结构化流式的价值就体现出来了。
八、灵魂拷问与总结
回到开头那个踩坑故事,我们其实走完了四条路线:
- 正则 + JSON.parse:手搓轮子,能跑但脆
- JsonOutputParser / StructuredOutputParser:LangChain 把"格式约定注入 prompt + 去 markdown + 解析"封装成 API
- tool_call + Zod:绕过解析层,直接用 function calling 的强 schema 约束,工具函数不执行,只取
args model.withStructuredOutput(schema):语义化封装,底层自动选 tool_call 或降级 prompt 方案
核心心法两句话:
OutputParser 是"君子协定"——我在 prompt 里请求模型按格式来;
tool_call 是"合同条款"——模型必须按 schema 填,否则它自己都过不去。
最后回答两个灵魂拷问:
Q1:有了 tool_call,OutputParser 还有必要吗?
- JSON 场景:优先
withStructuredOutput(底层就是 tool_call) - 非 JSON 格式(XML/YAML/自定义):仍然需要 OutputParser
- 不支持 tool_call 的模型:
withStructuredOutput会自动降级为 prompt 方案,你甚至不用感知
Q2:output_parsers 模块可以丢了吗?
- 不能丢。它承载了 JSON 之外的结构化格式解析,也是降级方案的地基。
JsonOutputParser更像是入门的第一课——理解了它,你才能理解getFormatInstructions()和parse()这两个接口设计的精妙之处。
最终的推荐用法:
// 90% 的场景,一行搞定
const structuredModel = model.withStructuredOutput(schema);
// 剩下的 10%,特殊格式上 output_parser
const xmlParser = new XmlOutputParser();
工具不要用来"执行",schema 才是真正的杀器。记住这句话,你的 AI 应用会稳很多。