一、先理解问题:为什么需要"输出解析器"?
大模型(LLM)的本质是"根据输入文字预测下一段文字"。所以你问它任何问题,它给你的永远是一段字符串。比如你让它"介绍一下爱因斯坦",它可能回:
好的,爱因斯坦是著名的物理学家......(一大段文字)
如果这只是给人看的,没问题。但如果是程序要接着处理 呢?比如你想把"爱因斯坦的信息"存进数据库、渲染成卡片、或者传给下一个接口,程序就需要 JSON 对象,而不是一段人话。
这时候矛盾就来了:
- 模型输出的是"文本"(往往是字符串)。
- 程序需要的是"结构化数据"(对象、数组、字段明确的 JSON)。
怎么把模型吐出来的文本,安全、可靠地变成程序能用的结构?这就是 输出解析器 的职责。整个 src 目录,其实就是一条"越来越聪明"的解决方案演进路线。下面我们按演进顺序逐个拆解。
二、最原始的方式:让模型输出 JSON,再手动 parse(normal.mjs)
先看 normal.mjs。它的思路非常简单粗暴:在提示词里明确告诉模型"请以 JSON 格式返回",然后代码里用 JSON.parse 解析。
js
const prompt = `
请介绍一下爱因斯坦的信息。 请以 JSON 格式返回,
包含以下字段: name(姓名)、birth_year(出生年份)、
nationality(国籍)、major_achievement(主要成就, 数组)、
famous_theory(著名理论)
`;
const response = await model.invoke(prompt);
const jsonResult = JSON.parse(response.content);
这段代码能跑,但有两个大坑:
坑一:模型经常"不听话",在 JSON 外面包一层 markdown
模型很"爱美",常常会好心地把 JSON 包进代码块里:
json
```json
{ "name": "爱因斯坦", ... }
ruby
这时候你直接 `JSON.parse` 就会报错,因为字符串前后多了 ```json 和 ```。
### 坑二:模型偶尔会顺着话头说废话
即使你让它"只返回 JSON",它可能还是加上一句"好的,以下是有关爱因斯坦的信息:"。多出来的文字会让 `JSON.parse` 直接崩掉。
---
## 三、第一招手动化解:用正则剥掉 markdown 外壳
这个坑太常见了,所以上一轮我们就在 `normal.mjs` 里加了正则,先把代码块剥掉再解析:
```js
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
const jsonStr = jsonMatch ? jsonMatch[1] : response.content; // 剥掉外壳,取内部 JSON
const jsonResult = JSON.parse(jsonStr);
正则背后的逻辑:
/```json\s*([\s\S]*?)\s*```/:寻找json 到之间的内容。\s*用来吃掉开头和结尾的多余空白。[\s\S]*?是非贪婪 匹配,意思"尽量少地匹配任意字符(包括换行)",为的是精确框住第一对json之间的部分。[1]取出括号里捕获的那段------也就是真正的 JSON 字符串。
这个办法能解决 90% 的问题,但它太"手动"了 :正则本身写得复杂,而且如果模型输出根本不含代码块,还要做兜底(jsonMatch ? ... : response.content)。于是 LangChain 提供了专门的工具。这正是它存在的原因:把"从模型输出里可靠地拿到 JSON"这件事封装成标准组件。
四、LangChain 的 JsonOutputParser(normal.mjs 升级版)
normal.mjs 里其实已经用上了 LangChain 给的解析器 JsonOutputParser:
js
import { JsonOutputParser } from "@langchain/core/output_parsers";
const parser = new JsonOutputParser();
const prompt = `
请介绍一下爱因斯坦的信息。 请以 JSON 格式返回,
包含以下字段: ...
${parser.getFormatInstructions()}
`;
const jsonResult = await parser.parse(response.content);
console.log(jsonResult, jsonResult.name);
这里有三个关键点要记住:
getFormatInstructions():解析器会自动"教"模型该输出什么格式,并把这套说明追加到提示词末尾。这样模型更听话,输出的 JSON 更规范。parse(content):把模型返回的文本"解析"成真正的 JS 对象,这一步内部就处理了各种脏数据(比如前面说的 markdown 外壳)。jsonResult.name:解析成功后,就可以像用普通对象一样访问字段了。
JsonOutputParser 是"基础版":它只知道"你要 JSON 对象",但不知道 JSON 长什么样------字段叫什么、是数字还是字符串、哪些必填哪些可选,它一概不管。所以它适合"宽松一点"的场景。
五、进阶:StructuredOutputParser------用"字段清单"约束格式
structured-output-parser.mjs 用到了更严格的 StructuredOutputParser:
js
const parser = StructuredOutputParser.fromNamesAndDescriptions({
name: "姓名",
birth_year: "出生年份",
nationality: "国籍",
major_achievements: "主要成就, 用逗号分隔的字符串",
famous_theory: "著名理论",
});
const question = `
请介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}
`;
const result = await parser.parse(response.content);
console.log(`姓名: ${result.name}`);
fromNamesAndDescriptions 的意思是:我告诉你"有哪些字段、每个字段是什么意思",你(解析器)帮我把它翻译成给模型的格式说明,并保证输出符合这份清单。
相比 JsonOutputParser,它更进一步------明确了字段名和含义 ,但还不够强:它只是"命名并描述",不能约束字段的类型 (比如 birth_year 是数字还是字符串?major_achievements 是数组还是单词串?)。
六、最强约束:zod 模式 + StructuredOutputParser.fromZodSchema
当需要精确约束类型、必填项、嵌套结构 时,就该用 zod 上场了。structured-output-parser2.mjs 展示了最完整的写法:
js
import { StructuredOutputParser } from "@langchain/core/output_parsers";
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_of_study: z.array(z.string()).describe("研究领域"),
awards: z.array(z.object({
award_name: z.string().describe("奖项名称"),
award_year: z.number().describe("奖项年份"),
award_reason: z.string().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("理论描述"),
})),
biography: z.string().describe("简短传记, 100字以内"),
});
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);
const question = `
请介绍一下居里夫人的详细信息,
${parser.getFormatInstructions()}
`;
const result = await parser.parse(response.content);
console.log(`姓名: ${result.name}`);
为什么用 zod?
zod 是一个校验库,它能用可读的语法定义"数据结构长什么样"。这段 schema 声明了:
name是字符串(z.string())。birth_year是数字(z.number())。death_year是可选的(.optional()),因为有些人还在世。fields_of_study是字符串数组 (z.array(z.string()))。awards是对象数组,每个对象里有奖项名、年份、原因。.describe(...)给每个字段加说明,解析器会把这些说明翻译成给模型的提示。
这种写法把"字段、类型、必填性、嵌套关系 "全都定义清楚了。解析时,LangChain 会严格按照 schema 校验并转换成 JS 对象,一旦字段不合规就能及时发现。这是生产环境最推荐的方式。
面试重点:它能自动处理"字段类型对不上、缺字段、有多余字段"等问题 ,比手写 JSON.parse 可靠得多。
七、更省心的终极方案:withStructuredOutput(with-structured-output.mjs)
看到这里你可能想问:又要写 prompt、又要拿 getFormatInstructions()、又要手动 parse,能不能一步到位? 能。with-structured-output.mjs 就是答案:
js
const scientistSchema = z.object({
name: z.string().describe("科学家的姓名"),
birth_year: z.number().describe("科学家的出生年份"),
nationality: z.string().describe("科学家的国籍"),
fields_of_study: z.array(z.string()).describe("科学家的研究领域"),
});
const structuredModel = model.withStructuredOutput(scientistSchema);
const result = await structuredModel.invoke("介绍一下爱因斯坦");
console.log(JSON.stringify(result, null, 2));
注意这里的巨大变化:
- 不再需要手动拼 prompt 和调 parse 。
withStructuredOutput(schema)把模型"包装"成一个"结构化输出模型",直接在背后自动处理格式指令和解析。 invoke返回的就是一个干净的 JS 对象 ,可以直接result.name访问。
可以说 withStructuredOutput 是"输出解析器"的高级封装,它把上面那些繁琐步骤全都藏起来了,是如今开发 AI 应用最常用的写法之一。
八、再深一层:用 bindTools 让模型"产出"结构化参数(tool-call-args.mjs)
除此之外,还有一条"曲线救国"的路线------利用模型**调用工具(Tool Call)**的能力。tool-call-args.mjs:
js
const modelWithTool = model.bindTools([
{
name: "extract_scientist_info",
description: "提取和结构化科学家的详细信息",
schema: scientistSchema,
},
]);
const response = await modelWithTool.invoke("请介绍一下爱因斯坦");
console.log(response.tool_calls[0].args);
模型本身支持"调用工具":当它发现你问了符合某个工具描述的问题,就会"准备调用"这个工具,并把工具的入参 (参数)以 JSON 形式填好。model.bindTools([...]) 就是把工具定义绑定到模型上,response.tool_calls[0].args 就能拿到这份结构化的参数。
这套逻辑的巧妙之处在于:它跳过了"让模型输出 JSON 再解析"这一步,而是利用模型与生俱来的"工具参数"能力来产出结构化数据。 不过目录里的注释也点出:这条"偏门"路线适合特定场景,大多数情况下还是 withStructuredOutput 更顺手。
九、大杀器:流式输出 + 结构化(stream 相关文件)
前面所有例子都是 invoke------一次性 把最终结果给你(同步等到底),体验上是"憋半天,然后一次性蹦出全部答案"。但像 ChatGPT 那样一个字一个字"蹦出来",靠的是 流式输出(Stream)。
1. 普通流式输出(stream-normal.mjs)
js
const stream = await model.stream(prompt);
for await (const chunk of stream) {
const content = chunk.content;
fullContent += content;
process.stdout.write(content); // 实时显示流式文本
}
关键点:
model.stream()返回一个异步可迭代对象("水流")。for await (const chunk of stream)逐个读取"数据块(chunk)"。每个 chunk 只是答案的一小部分。- 把每个 chunk 累积(
fullContent += content)就能得到完整内容。
好处:用户不用干等,答案一边生成一边显示,体验好。
2. 流式 + 结构化(stream-with-structured-output.mjs)
那能不能一边流式输出,一边又是结构化的 JSON?可以:
js
const schema = z.object({ ... });
const structuredModel = model.withStructuredOutput(schema);
const stream = await structuredModel.stream(prompt);
for await (const chunk of stream) {
console.log(JSON.stringify(chunk, null, 2));
}
这里把 withStructuredOutput 和 stream 结合:流式地收到结构化片段(partial),前端可以边生成边渲染。
十、顺带了解:XML 解析器(xml-output-parser.mjs)
JSON 是当今数据交换的"事实标准",但在老一代,XML 才是主流。xml-output-parser.mjs 展示了 LangChain 也支持解析 XML 格式的输出:
js
import { XMLOutputParser } from "@langchain/core/output_parsers";
const parser = new XMLOutputParser();
const question = `
请提取以下文本中的人物信息: 爱因斯坦生于1879年3月14日...
${parser.getFormatInstructions()}
`;
const result = await parser.parse(response.content);
console.log(result);
只要把 JsonOutputParser 换成 XMLOutputParser,模型就会按 XML 格式输出,解析器把 XML 转成 JS 对象。这告诉我们要点:输出解析器是可插拔、可替换的,只要模型输出什么格式,就换对应的解析器。