写在前面:上篇讲了流式输出------AI 怎么"边说边听"。下篇换个话题------AI 说出来的东西怎么按规矩说 。LLM 默认输出是自由文本------你问爱因斯坦的信息,它能给你写一篇散文,也能给你列一个列表,还能给你一段 markdown。但下游业务需要的是结构化 JSON ------
name字段是字符串,birth_year是数字,achievements是数组。如果 LLM 返回的格式不对,JSON.parse()直接炸。今天的课程从最原始的手搓正则,讲到 LangChain 的JsonOutputParser、StructuredOutputParser,再到 Zod Schema 约束和 Tool Call 取巧方案------四级进化,逐步把"自由发挥的 AI"驯服成"按格填空的好学生"。以下所有代码均来自课堂真实文件。
一、问题:LLM 的输出你控不住
假设你让 LLM 介绍爱因斯坦,要求返回 JSON。你写了这样的 prompt:
javascript
请介绍一下爱因斯坦的信息。请以 JSON 格式返回,
包含以下字段:name、birth_year、nationality...
LLM 可能返回什么?
情况一------标准 JSON:
json
{"name": "阿尔伯特·爱因斯坦", "birth_year": 1879, ...}
情况二------markdown 包裹的 JSON(最常见):
markdown
```json
{"name": "阿尔伯特·爱因斯坦", "birth_year": 1879, ...}
javascript
**情况三**------带前后解释的 JSON:
好的,以下是爱因斯坦的信息: {"name": "阿尔伯特·爱因斯坦", ...} 希望对你有帮助!
markdown
**情况四**------格式跑偏:
姓名:爱因斯坦 出生年份:1879
ruby
readme 总结了这个问题:
> "大模型按照我们的格式要求返回一个 JSON。失败了------json 固定格式输出,被 markdown 格式包裹,llm 输出常是 markdown 格式,这是展示的需要。"
LLM 是语言模型,它的"舒适区"是自然语言和 markdown。你让它吐 JSON,它经常顺手包一层 ` ```json ``` `------因为它觉得这样"好看"。但对 `JSON.parse()` 来说,` ```json ` 是非法语法,直接报错。
---
## 二、第一级:手搓正则------剥掉 markdown 外衣
`normal.mjs` 里有一段被注释掉的代码------这是最原始的解决方案:
```javascript
// 使用正则提取 markdown 代码块中的 JSON 内容
// const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
// const jsonStr = jsonMatch ? jsonMatch[1] : response.content;
// const jsonResult = JSON.parse(jsonStr);
readme 解释了这个思路:
"移除
json包裹,正则 replace 方法。prompt output 技巧 → llm 返回 markdown 格式 → 正则业务去除 md 格式 → JSON.parse()。"
三步走:
- LLM 返回 markdown 包裹的 JSON
- 正则把 ```````json```` 和 ``````````` 剥掉
JSON.parse()解析成对象
正则 /```json\s*([\s\S]*?)\s*```/ 的含义:
| 正则片段 | 含义 |
|---|---|
| ```````json```` | 匹配开头的标记 |
\s* |
匹配可能的空白字符 |
([\s\S]*?) |
捕获组------匹配中间所有内容(含换行) |
| ``````````` | 匹配结尾的标记 |
jsonMatch[1] 就是捕获组的内容------纯 JSON 字符串,没有 markdown 外衣。
手搓正则的问题
readme 的注释还说了:
"分组。正则业务。"
这行注释点明了------正则提取是"业务代码"。每次调 LLM 都要写一遍正则、处理异常、兜底各种格式变体。LLM 有时用 json````,有时用 JSON````,有时用 ``````````` 不带 json 标记------正则覆盖不全。
readme 紧接着给出了答案:
"每次调用 AI 的常见业务,langchain 提供相应的业务 API,省去开发的复杂度。"
别手搓了,LangChain 有现成的工具。
三、第二级:JsonOutputParser------LangChain 的基础解析器
normal.mjs 实际使用的方案------JsonOutputParser:
javascript
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 result = await parser.parse(response.content);
console.log(result, result.name);
readme 说的:
"JsonOutputParser------langchain 用来解析 json 结果的。约束返回格式 json,JSON.parse()。"
两个关键 API
parser.getFormatInstructions() --- 生成格式约束指令,自动拼到 prompt 里。
readme 说的:
"parser.getFormatInstructions() 空,json 太常见的格式需求。"
意思是------getFormatInstructions() 返回的约束文本对 JSON 来说比较"空"(简单),因为 JSON 格式太通用了。它大概会在 prompt 里加一句类似"请返回合法的 JSON 格式"的指令。
parser.parse(response.content) --- 解析 LLM 的输出。
readme 说的:
"本质就是通过 getFormatInstructions() 在 prompt 里添加对 output 的结构化格式约定,parser.parse() 去除 markdown 拿到 json。"
parse() 内部做了两件事:
- 剥掉 markdown 的
json外衣(不用你写正则了) JSON.parse()解析成对象
JsonOutputParser 的局限
JsonOutputParser 只保证"返回的是合法 JSON"------但不保证有哪些字段、字段是什么类型。你让 LLM 返回 name 和 birth_year,它可能返回 姓名 和 出生年份------JSON 是合法的,但字段名不对。
readme 点出了升级方向:
"JsonOutputParser 格式化的升级。"
需要更强的约束------不仅要是 JSON,字段名和类型也得对。
四、第三级:StructuredOutputParser --- 按格填空
structured-output-parser.mjs 展示了 StructuredOutputParser 的第一种用法------fromNamesAndDescriptions:
javascript
import { StructuredOutputParser } from '@langchain/core/output_parsers';
const parser = StructuredOutputParser.fromNamesAndDescriptions({
name: '姓名',
birth_year: '出生年份',
nationality: '国籍',
major_achievement: '主要成就,用逗号分隔的字符串',
famous_theory: '著名理论',
});
const question = `
请介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}
`;
const response = await model.invoke(question);
const result = await parser.parse(response.content);
console.log(`姓名:${result.name}`);
console.log(`出生年份:${result.birth_year}`);
fromNamesAndDescriptions:字段名 + 描述
fromNamesAndDescriptions 接收一个对象------key 是字段名,value 是描述。
javascript
{
name: '姓名', // 字段名:name,描述:姓名
birth_year: '出生年份', // 字段名:birth_year,描述:出生年份
nationality: '国籍',
major_achievement: '主要成就,用逗号分隔的字符串',
famous_theory: '著名理论',
}
getFormatInstructions() 会把这些字段名和描述转化成 prompt 约束------LLM 看到的指令大概是"你必须返回一个 JSON 对象,包含以下字段:name(姓名)、birth_year(出生年份)..."。
字段名固定了。 LLM 不能自作主张用"姓名"代替"name"------prompt 里明确要求用 name。
JsonOutputParser vs StructuredOutputParser
readme 的对比:
| 特性 | JsonOutputParser | StructuredOutputParser |
|---|---|---|
| 保证 JSON 合法 | 是 | 是 |
| 固定字段名 | 否 | 是 |
| 字段描述 | 无 | 有 |
| 类型约束 | 无 | 无(只有描述) |
fromNamesAndDescriptions 比 JsonOutputParser 进了一步------字段名固定了 。但类型还是靠描述约束------"出生年份"是字符串还是数字?fromNamesAndDescriptions 只写了描述"出生年份",没说是数字。LLM 可能返回 "1879"(字符串)也可能返回 1879(数字)。
readme 注释也暗示了这一点:
"json, name, description 更靠谱。"
字段名 + 描述,比纯 JSON 靠谱------但还不够。
五、第四级:Zod Schema --- 类型级别的约束
structured-output-parser2.mjs 是今天的重头戏------用 Zod Schema 做类型约束。
什么是 Zod?
Zod 是 TypeScript 生态的运行时数据验证库------你定义一个 Schema(模式),它能验证数据是否符合这个模式。
javascript
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('科学家的国籍'),
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('主要成就列表'),
famous_theory: z.array(
z.object({
name: z.string().describe('理论名称'),
year: z.number().describe('理论年份'),
description: z.string().describe('理论描述'),
})
).describe('著名理论列表'),
biography: z.string().describe('简短传记,100字以内'),
});
Zod 的类型系统
| Zod 方法 | TypeScript 类型 | 含义 |
|---|---|---|
z.string() |
string |
字符串 |
z.number() |
number |
数字 |
z.array(z.string()) |
string[] |
字符串数组 |
z.object({...}) |
{...} |
对象 |
.optional() |
`T | undefined` |
.describe('xxx') |
--- | 描述(给 LLM 看的) |
对比 fromNamesAndDescriptions------那个只有"字段名 + 文字描述",Zod 有精确的类型 :birth_year 是 number 不是 string,fields 是 string[] 不是 string,awards 是嵌套对象数组。
嵌套结构
javascript
awards: z.array(
z.object({
name: z.string().describe('奖项名称'),
year: z.number().describe('获奖年份'),
reason: z.string().describe('获奖原因'),
})
).describe('获得的重要奖项列表'),
awards 不只是字符串数组------是对象数组 ,每个对象有 name、year、reason 三个字段。这种复杂嵌套结构,fromNamesAndDescriptions 根本表达不了。
fromZodSchema:把 Schema 变成 Parser
javascript
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);
readme 注释说:
"fromZodSchema 是静态工厂方法(返回 parser 实例),不能 new,且要传入 zod schema。"
fromZodSchema 是静态工厂方法------直接在类上调用,不需要 new。传入一个 Zod Schema,返回一个 parser 实例。
使用方式跟之前一样
javascript
const question = `请介绍一下居里夫人的详细信息,
${parser.getFormatInstructions()}`;
const response = await model.invoke(question);
const result = await parser.parse(response.content);
console.log(`姓名:${result.name}`);
console.log(`出生年份:${result.birth_year}`);
getFormatInstructions() + parse()------API 没变。但 getFormatInstructions() 生成的约束文本更详细了------包含了每个字段的类型信息。LLM 看到的指令大概是"返回一个 JSON 对象,birth_year 必须是数字,awards 是一个数组,每个元素包含 name(字符串)、year(数字)、reason(字符串)..."。
parse() 也不只是剥 markdown 和 JSON.parse 了------它还会用 Zod Schema 做运行时验证。如果 LLM 返回的 birth_year 是字符串 "1867" 而不是数字 1867,Zod 会报验证错误。
六、四级进化对比
| 级别 | 方案 | 字段名 | 类型约束 | 嵌套结构 | 课堂文件 |
|---|---|---|---|---|---|
| 1 | 手搓正则 | 无 | 无 | 无 | normal.mjs(注释) |
| 2 | JsonOutputParser | 无 | 无 | 无 | normal.mjs |
| 3 | fromNamesAndDescriptions | 固定 | 无(仅描述) | 无 | structured-output-parser.mjs |
| 4 | fromZodSchema | 固定 | 精确 | 支持 | structured-output-parser2.mjs |
从"啥都不保证"到"字段名固定"到"类型精确"到"嵌套结构"------每升一级,LLM 的输出就更可靠。
readme 的总结:
"下游业务用上靠谱的 JSON 输出。"
最终目的就是这八个字------下游业务能用 。JSON 不是给人看的,是给程序消费的。程序要求字段名对、类型对、结构对------差一个 number vs string 就 crash。
七、第五种方案:Tool Call --- 偏门但好用
tool-call-args.mjs 展示了一个"偏门"方案------用 Tool Call 实现结构化输出。
readme 第一行注释就说了灵感来源:
"从 tool-call zod schema 得到灵感,可以直接 tool-call?"
思路
Tool Call 本来是让 LLM 调用外部工具的------你给 LLM 一堆工具定义,LLM 选择合适的工具并生成参数。但"生成参数"这件事,本质上就是结构化输出------工具的参数是有 Schema 约束的。
那如果我只定义一个"工具",但不真的调用它------只利用 LLM 生成参数的能力来拿到结构化数据呢?
代码
javascript
import { z } from "zod";
const scientistSchema = z.object({
name: z.string().describe('姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
// llm 调用的上下文
const modelWithTool = model.bindTools([
{
name: 'extract_scientist_info',
description: '提取和结构化科学家的详细信息',
schema: scientistSchema,
}
]);
const response = await modelWithTool.invoke('介绍一下爱因斯坦');
console.log(response.tool_calls[0].args);
关键点:
model.bindTools([...])--- 给模型绑定一个"工具",工具的 schema 用 Zod 定义modelWithTool.invoke('介绍一下爱因斯坦')--- 正常调用,但 LLM 会以 tool call 的形式返回response.tool_calls[0].args--- 直接拿到结构化的参数对象
不需要 getFormatInstructions(),不需要 parser.parse() --- LLM 直接在 tool_calls[0].args 里返回结构化数据。
为什么好用?
readme 注释解释了:
"这个工具不是为了直接调用,只做 schema 校验,而是为了方便后续的解析和处理。"
这个"工具"根本不会被执行------它的存在只是为了让 LLM 按照 schema 生成参数。Tool Call 是 LLM 原生能力,模型在训练时就学会了按 schema 输出参数,比 prompt 约束更可靠。
对比 OutputParser
readme 最后抛出了一个问题:
"这种方式 output parser 更好。output parser 模块还有存在的必要吗?"
这是个好问题。Tool Call 方案的优势:
| 特性 | OutputParser | Tool Call |
|---|---|---|
| 原理 | prompt 约束 + 后处理解析 | LLM 原生能力 |
| 可靠性 | 中等(LLM 可能不听 prompt) | 高(模型训练时就学了) |
| 额外调用 | 需要 parse 步骤 | 直接从 tool_calls 取 |
| 通用性 | 任何模型 | 需要模型支持 tool call |
Tool Call 更可靠、更简洁------但它依赖模型支持 tool calling 能力。大部分主流模型(GPT-4、Claude、DeepSeek)都支持了,但一些小模型可能不行。
OutputParser 的优势在于通用性 ------任何能返回文本的 LLM 都能用。所以两者不是替代关系,而是不同场景的选择:
- 模型支持 tool call → 用 Tool Call,更可靠
- 模型不支持 tool call → 用 OutputParser,prompt 约束
八、完整方案选型指南
javascript
需要 LLM 返回结构化 JSON?
│
├── 模型支持 Tool Call?
│ ├── 是 → bindTools + tool_calls[0].args(最可靠)
│ └── 否 → 继续 ↓
│
├── 需要精确类型和嵌套结构?
│ ├── 是 → StructuredOutputParser + fromZodSchema
│ └── 否 → 继续 ↓
│
├── 需要固定字段名?
│ ├── 是 → StructuredOutputParser + fromNamesAndDescriptions
│ └── 否 → 继续 ↓
│
└── 只需要合法 JSON?
└── JsonOutputParser
从上到下,约束越来越松------选哪个取决于你的需求精度和模型能力。
九、结构化输出的本质:prompt 约束 + 后处理
不管哪一级方案,核心逻辑都是 readme 说的这两步:
"本质就是通过 getFormatInstructions() 在 prompt 里添加对 output 的结构化格式约定,parser.parse() 去除 markdown 拿到 json。"
第一步:约束 --- 在 prompt 里告诉 LLM "你必须返回什么格式"。getFormatInstructions() 自动生成这段约束文本。
第二步:解析 --- LLM 返回后,剥掉 markdown 外衣,JSON.parse() 成对象。parser.parse() 自动完成。
Tool Call 方案跳过了这两步------LLM 直接在 tool_calls 里返回结构化数据,不需要 prompt 约束,也不需要后处理解析。所以 readme 才会问"output parser 模块还有存在的必要吗"------在支持 tool call 的模型上,OutputParser 确实可以省掉。
十、从个人经验到通用 API
回头看整条进化线------从手搓正则到 Tool Call,每一步都在做同一件事:让 LLM 的输出从"自由文本"变成"可靠数据"。
readme 说了一句很到位的话:
"每次调用 AI 的常见业务,langchain 提供相应的业务 API,省去开发的复杂度。"
手搓正则是"个人经验"------每个开发者自己写、自己维护、自己兜底。LangChain 把这些"常见业务"封装成了通用 API:
| 你手搓的 | LangChain 的 API |
|---|---|
| 写正则剥 markdown | parser.parse() 自动剥 |
| 写 prompt 约束格式 | parser.getFormatInstructions() 自动生成 |
| 手动 JSON.parse + try/catch | parser.parse() 内部处理 |
| 手动验证字段名和类型 | Zod Schema 运行时验证 |
不要重复造轮子。 LangChain 的 OutputParser 模块就是把"让 LLM 输出可靠 JSON"这个重复性工作标准化了。
PS:LLM 是个话痨------你让它吐 JSON,它非要裹一层 markdown、加一段开场白、偶尔还跑偏格式。从手搓正则到 JsonOutputParser 到 StructuredOutputParser 到 Zod Schema 到 Tool Call------五级进化,每一级都是给 AI 多加一道紧箍咒。下次 LLM 又给你裹了一层 ```````json````,别手搓正则了------parser.parse() 一行搞定。