前言:LLM 输出结构化数据,为什么这么难?
做 AI 应用的时候,你会发现一个非常高频的需求:让大模型返回 JSON。
不管是提取实体、解析表单、还是做下一步的业务分支判断,我们都希望 LLM 的输出是"机器可读"的,而不是一段自然语言。
但现实往往很骨感。你精心写好了 prompt:
javascript
请介绍一下爱因斯坦的信息。请以 JSON 格式返回,包含以下字段:name、birth_year...
大模型也很乖地返回了:
json
```json
{
"name": "阿尔伯特·爱因斯坦",
"birth_year": 1879,
...
}
```
然后你 JSON.parse(response.content) 就直接原地爆炸------因为返回的字符串被 markdown 代码块包裹了。
说白了,大模型是"展示型选手",它天然倾向于把结构化数据渲染成 markdown 方便人看。但我们的代码要的是"数据",不是"展示"。
这篇文章就从这里开始,把 LangChain 的 OutputParser 三件套讲透:JsonOutputParser → StructuredOutputParser → tool_call 方案,最后回答一个灵魂拷问:有了 tool_call,output parser 还有存在必要吗?
一、手动挡:正则 + JSON.parse
在引入 LangChain 之前,很多人(包括我)第一版代码都是这么写的:
ini
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。
ini
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(轻量版)
php
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:
css
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?
于是有了第三种思路:
css
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_call 的强制结构化能力,拿到解析好的对象。
为什么这种方式"更香"?对比一下:
| 维度 | OutputParser | tool_call |
|---|---|---|
| 结构约束 | 靠 prompt 约定,模型可能不听话 | 模型原生支持的 function calling,强制遵守 schema |
| 解析步骤 | 去 markdown + JSON.parse | 直接拿到对象 |
| 类型保证 | 可能返回字符串"1879" | Schema 严格校验 |
| 兼容性 | 所有模型都行 | 需要模型支持 tool calling |
这才是真正的"降维打击":不是让模型"照着格式写",而是让模型"必须填这个 schema"。
五、灵魂拷问:有了 tool_call,OutputParser 还有必要吗?
答案是:有,但场景变了。
tool_call 更适合:
- 模型支持 function calling(OpenAI、Claude、Qwen、DeepSeek 等都支持)
- 需要强 schema 约束、类型严格的场景
- 你不在乎返回的是 tool_calls 而不是 content
OutputParser 更适合:
- 使用不支持 tool_call 的模型(很多开源小模型、私有化部署场景)
- 你只想要个 JSON 结果,不需要强类型校验(
JsonOutputParser足矣) - 流式输出场景:OutputParser 可以增量解析 JSON 流,tool_call 目前对此支持一般
简单决策树:
javascript
需要结构化输出?
├─ 模型支持 tool_call → 用 bindTools + Zod schema(推荐)
└─ 不支持 / 只是简单 JSON
├─ 只想要个 JSON 对象 → JsonOutputParser
└─ 想要字段约束 → StructuredOutputParser.fromZodSchema
六、总结
回到开头那个踩坑故事,我们其实走完了三条路线:
- 正则 + JSON.parse:手搓轮子,能跑但脆
- JsonOutputParser / StructuredOutputParser:LangChain 把"格式约定注入 prompt + 去 markdown + 解析" 封装成了 API
- tool_call + Zod:绕过解析这一层,直接用 function calling 的强 schema 约束
核心心法一句话:
OutputParser 是"君子协定"------我在 prompt 里请求模型按格式来;
tool_call 是"合同条款"------模型必须按 schema 填,否则它自己都过不去。
所以,如果你的模型支持 tool_call,优先选它 ;否则 StructuredOutputParser.fromZodSchema 是你的好朋友。
至于 JsonOutputParser?它更像是入门的第一课 ------理解了它,你才能理解 getFormatInstructions() 和 parse() 这两个接口设计的精妙之处。