大模型只会吐文本,而且张口就是 Markdown------可下游业务代码要的是
JSON.parse()之后的对象。为了拿到靠谱的 JSON,你手写过正则吗?被「明明要了 JSON 却包了一层 ```json 代码块」坑过吗?本文用一个完整 demo 讲清一件事:如何让 LLM 的输出从「人能看的文本」变成「程序能直接用的结构化数据」 。技术栈:LangChain.js + @langchain/openai + Zod,会顺着从手写正则到原生 Function Calling 的升级路线走一遍,每一层解决什么问题一目了然。
一、先看问题:LLM 输出为什么「不听话」
调用大模型,一句话就回来了:
javascript
请介绍一下爱因斯坦的信息,以 JSON 返回,包含 name、birth_year、nationality、major_achievements...
LLM 的回答大概率长这样:
markdown
好的,以下是爱因斯坦的相关信息:
```json
{
"name": "阿尔伯特·爱因斯坦",
"birth_year": 1879,
"nationality": "德国",
"major_achievements": ["狭义相对论", "质能方程 E=mc²", "1921 年诺贝尔物理学奖"],
"famous_theory": "相对论"
}
希望对你有帮助!
javascript
问题全在这一坨里:
1. **它是给「人展示」的 Markdown**,前后有寒暄语、外面还包着一层 ` ```json ` 代码块;
2. 你想 `JSON.parse()`,它**先得把壳剥掉**才能 parse;
3. 万一这次 LLM 心情不好没包代码块、字段名写错、顺序乱了、字符串里带了换行...... `JSON.parse()` 直接抛异常。
**本质矛盾**:LLM 是文本生成模型,它的原生输出是「给人读的字符串」;而下游业务(存库、渲染、当函数参数)要的是「给机器读的结构化数据」。
Output Parser(输出解析器)就是来解决这个矛盾的。**它做两件事:在 prompt 里注入「格式要求」,在拿到回复后把 JSON 解析出来。** 下面从最笨的手写方式开始,一层层进化到生产可用的方案。
---
## 二、前置背景:流式输出与 SSE(输出为什么是「流」)
讲解析之前,先补一个前置概念------为什么我们常听说 LLM 的输出是「流」?这决定了我们拿到内容的方式。
### 2.1 `invoke` 同步 vs `stream` 流式
```js
// stream-normal.mjs(节选)
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
const prompt = `详细介绍莫札特的信息。`;
// invoke:同步,一口气等完整答案
// const response = await model.invoke(prompt);
// stream:流式,像水管一样,token 一段一段往客户端流
const stream = await model.stream(prompt);
理解 stream 最好的比喻是 水管:
- 一头接着 LLM server,一头是客户端;
- LLM 生成一个 token 就顺着管子流过来一个
chunk(数据块); - 客户端这边用
for await...of逐个接住,收到一个字先展示一个字,不用等整篇生成完。
js
let fullContent = '';
let chunkCount = 0;
for await (const chunk of stream) {
chunkCount++;
fullContent += chunk.content;
process.stdout.write(chunk.content); // 边收边打字,这就是「打字机效果」
}
console.log(`\n\n共接收 ${chunkCount} 个数据块`);
流式的价值:首字延迟(TTFT)大幅下降,用户不用盯着空白转圈。ChatGPT / 各种 Chat 界面的逐字输出,本质都是这么实现的。
2.2 HTTP 响应不是只能同步返回------还有 SSE
那服务器怎么把内容「分多次」推给浏览器?这就要提到 SSE(Server-Sent Events,服务器推送事件)。
传统 HTTP 是「请求 → 响应 → 断开」的简单同步模型,一次只能答一次:
arduino
Content-Type: text/plain / text/html
而 SSE 换了一套响应头,服务器单向、持续地往浏览器推消息,可以推很多次,连接不断开:
yaml
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
三行头,每一行都有讲究------尤其是 Cache-Control: no-cache:
| 响应头 | 作用 | 不写会怎样 |
|---|---|---|
Content-Type: text/event-stream |
告诉浏览器「这是事件流」,按 SSE 协议逐条解析后续 data: 消息 |
浏览器当成普通文本/HTML,EventSource 无从解析 |
Cache-Control: no-cache |
关键:告诉浏览器和中间代理「这条响应别缓存、别缓冲」 | 见下面详述 👇 |
Connection: keep-alive |
明确要求「连接别用完就断」,这条长连接要一直复用 | 某些代理/网关可能主动掐断空闲连接 |
no-cache 为什么是 SSE 的命根子?SSE 和普通响应最大的区别是:它是一条长期不关闭的连接,数据是一个一个 chunk 到达的 。浏览器和中间的 CDN / 网关都有「响应缓冲」的本能------如果允许缓存,它们可能把收到的数据整个攒起来,直到连接关闭才一口气吐给你。对普通页面这没影响,但对 SSE 就是灾难:
makefile
普通响应(可缓存): 服务器 ──攒到结束──► 代理 ──一次性──► 浏览器
SSE 响应(要 no-cache): 服务器 ──chunk到达即转发──► 浏览器 ← 逐字实时
设了 no-cache,代理和浏览器就不敢滞留数据 ,每个 chunk 到达即转发给页面------「打字机逐字输出」的效果就是这么保住的。如果去掉它,你看到的很可能不再是逐字蹦字,而是攒了十几秒后整段闪出来 ,实时性荡然无存。(某些场景还要加 no-transform、X-Accel-Buffering: no 来防 nginx 层缓冲,本质同一个问题。)
用 Node 内置 http 模块就能写一个迷你 SSE 服务(sse-demo/server.js):
js
const http = require('http');
const server = http.createServer((req, res) => {
if (req.url === '/stream') {
res.writeHead(200, {
'Content-Type': 'text/event-stream', // 关键:事件流
'Cache-control': 'no-cache',
'Connection': 'keep-alive',
});
let words = ["你", "好", ", ", "欢", "迎", "了", "解", "sse"];
let index = 0;
const timer = setInterval(() => {
if (index >= words.length) { clearInterval(timer); return res.end(); }
// SSE 数据格式:每条消息以 data: 开头,两个换行结尾
res.write(`data: ${words[index]}\n\n`);
index++;
}, 1000);
}
});
server.listen(3000, () => console.log('server is running on port 3000'));
SSE 每条消息的格式 :data: <内容>\n\n。一次连接里可以 write 无数次,每两秒蹦一个字出去。
2.3 浏览器端:EventSource,白嫖一个长连接
浏览器有个原生类 EventSource,专门用来连 SSE------给它一个 URL 就行:
html
<!-- sse-demo/index.html -->
<div id="result"></div>
<script>
const resultEle = document.getElementById('result');
// 连上服务器 /stream,长连接建立
const eventSource = new EventSource("http://localhost:3000/stream");
// 服务器每推来一个 data,就触发一次 onmessage
eventSource.onmessage = (e) => {
console.log(e.data);
resultEle.innerText += e.data; // 一个字一个字往上拼
};
</script>
跑法:在 sse-demo/ 下 node server.js,浏览器打开 http://localhost:3000,就能看到「你好, 欢迎了解sse」一个字一个字蹦出来。
SSE 不止 LLM 用------股票行情、实时通知、日志推送都是它的主场。一句话:HTTP 的响应可以是「流式的」,SSE 是其中最简单的一种服务端推送协议。
三、第 0 层:手写正则,把 JSON 从 Markdown 里抠出来
回到结构化输出。最原始的做法,LLM 吐出来的内容直接 JSON.parse 是会炸的,因为外面包了 Markdown 代码块。所以第一步是剥壳:
js
const response = await model.invoke(prompt);
// 手写正则:把 ```json ... ``` 中间的部分抠出来
// 用分组 (...) 捕获代码块内容
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
const jsonStr = jsonMatch ? jsonMatch[1] : response.content;
// 剥完壳,才是真的 JSON 字符串,可以 parse 了
const jsonResult = JSON.parse(jsonStr);
console.log(jsonResult.name);
为什么不能省掉剥壳这一步? 因为 LLM 输出 JSON 时习惯性地包一层 Markdown 代码块------这本质上是它「展示给人类看」的惯性,对程序却是纯负担。
这层正则会遇到一堆边界情况:
- 字符串内容里恰好有 ``` 符号?
- LLM 没包代码块(直接裸 JSON)?→ 正则匹配不到,走
else分支,还行; - 万一它包了但前面还带一段寒暄文字 ?→ 匹配能中,但你要的是干净的结果;
- 字段顺序、缺失字段、中文引号......
JSON.parse一个都忍不了。
手写正则能跑,但它把「LLM 生成的不可控文本」硬塞进「必须严格合法的 JSON」这条缝里------每调一次 AI,都要赌一次运气。
想一想:每次调用都要做「约束格式 → 等它输出 → 剥 Markdown → parse」这套流程,太常见了。LangChain 把它封装成了开箱即用的 Output Parser,省掉重复开发的复杂度。
四、第 1 层:JsonOutputParser,把「约束 + 解析」外包给框架
js
// normal.mjs(节选)
import { JsonOutputParser } from "@langchain/core/output_parsers";
// 1. 建解析器
const parser = new JsonOutputParser();
// 2. 在 prompt 里追加格式要求(getFormatInstructions 生成的指令)
const prompt = `
请介绍一下爱因斯坦的信息。请以 JSON 格式返回,
包含以下字段:name(姓名)、birth_year(出生年份)、
nationality(国籍)、major_achievements(主要成就, 数组)、
famous_theory(著名理论)
${parser.getFormatInstructions()}
`;
// 3. 让模型回答
const response = await model.invoke(prompt);
// 4. 直接 parse:内部自动剥掉 Markdown 壳,返回 JS 对象
const result = await parser.parse(response.content);
console.log(result, result.name);
JsonOutputParser 就干两件事,正好对应我们第 0 层手写的活:
| 方法 | 干什么 | 对应手写代码 |
|---|---|---|
getFormatInstructions() |
在 prompt 里注入「请只输出 JSON」的结构化约定 | 你自己写的「请以 JSON 返回...」 |
parse() |
剥掉 ```json 包裹 → JSON.parse() |
那段正则 + parse |
注意第 20 行,我们自己在 prompt 里用中文描述了一遍字段 ------这依然是必要的。JsonOutputParser 的 getFormatInstructions() 输出很简(毕竟 JSON 是最常见需求),真正定义「字段有哪些、各自是啥含义」的是我们在 prompt 里写的约束。
这一层解决了「剥壳 + parse」的重复劳动,但还有两个大隐患:
- 字段类型全靠嘴说 。你说
birth_year是出生年份,LLM 可能给你字符串"1879"而不是数字1879; - parse 出来的东西没有结构保证 。
result是个自由对象,result.major_achievements可能压根不存在。
想要更「靠谱」的 JSON,进入第 2 层。
五、第 2 层:StructuredOutputParser,每个字段都配 name + description
js
// structured-output-parser.mjs(节选)
import { StructuredOutputParser } from "@langchain/core/output_parsers";
// 把「字段清单」结构化地交给 parser:name + description
const parser = StructuredOutputParser.fromNamesAndDescriptions({
name: "姓名",
birth_year: "出生年份",
nationality: "国籍",
major_achievements: "主要成就, 用逗号分隔的字符串",
famous_theory: "著名理论",
});
const question = `
请介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}
`;
const response = await model.invoke(question);
console.log(response.content);
const result = await parser.parse(response.content);
console.log(`姓名:${result.name}`);
console.log(`国籍:${result.nationality}`);
和 JsonOutputParser 的差别就一个:你给每个字段声明了名字(name)和含义(description)。
于是 getFormatInstructions() 生成的 prompt 指令不再是空泛的「输出 JSON」,而是带着字段名、字段含义、字段类型的明确 JSON 结构要求。这比光靠中文自然语言约束强得多------模型更不容易自由发挥。
这一层你其实在用「对象结构」描述期望输出,让模型照着填:
json
{
"name": "阿尔伯特·爱因斯坦",
"birth_year": 1879,
"nationality": "德国",
"major_achievements": "狭义相对论,质能方程",
"famous_theory": "相对论"
}
注意上面对 major_achievements 我们描述成了「用逗号分隔的字符串 」------因为 fromNamesAndDescriptions 只能描述扁平的 key/value,表达不了「数组」「嵌套对象」。字段一复杂就露怯了。
想要真正严格的类型约束?上 Zod。
六、第 3 层:Zod Schema,类型化、嵌套、可选一个都不能少
js
// structured-output-parser2.mjs(节选)
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import { z } from "zod";
// 用 Zod 定义一套「严苛的 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("主要成就列表"),
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 Schema 构建解析器
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);
const question = `请介绍一下居里夫人的详细信息,
${parser.getFormatInstructions()}`;
const response = await model.invoke(question);
const result = await parser.parse(response.content);
console.log(`姓名:${result.name}`);
console.log(`国籍:${result.nationality}`);
对比第 2 层,Zod 带来了几个质的提升:
| fromNamesAndDescriptions | fromZodSchema(Zod) | |
|---|---|---|
| 字段类型 | 不能声明,靠描述文字猜 | z.number() 写死 |
| 数组 / 嵌套对象 | ❌ 做不到 | ✅ z.array(z.object(...)) |
| 可选字段 | ❌ | ✅ death_year.optional() |
| 运行时校验 | parse 只剥壳 | parse 时校验类型,不符合就报错 |
parse() 不再只是「剥壳」,它真的在运行时校验结构 :如果模型把 birth_year 返回成字符串,Zod 会直接抛解析错误,绝不会把脏数据悄悄交给下游------出错要早,别带病上线。
到这一层,你能描述任意复杂的输出结构,交给下游的 JSON 也足够靠谱了。但故事还没完------demo 的最后一个文件抛出了一个更「狠」的思路。
七、第 4 层:杀手锏------与其让 LLM 写 JSON,不如让它「调函数」
js
// tool-call-args.mjs(节选)
import { ChatOpenAI } from "@langchain/openai";
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("研究领域列表"),
});
// bindTools:给模型挂上一个「工具」
const modelWithTool = model.bindTools([
{
name: "extract_scientist_info",
description: "提取和结构化科学家的详细信息",
schema: scientistSchema,
},
]);
const response = await modelWithTool.invoke("介绍一下爱因斯坦");
// 关键:不用 parse,模型自己把参数按 Schema 填好返回
console.log(response.tool_calls[0].args);
// => { name: "阿尔伯特·爱因斯坦", birth_year: 1879, nationality: "德国", fields: [...] }
思维反转 :前面几层都是「让模型以文本形式写 一个 JSON,我们再费劲去解析」。但很多模型(GPT、DeepSeek、豆包、Kimi、通义......)本身支持 原生 Function Calling / Tool Calling ------你给它声明一个函数签名,它会在协议层面 按这个 Schema 生成结构化的 arguments。
于是模型不再是「在文本里写一个 JSON 让你猜」,而是「直接调用 extract_scientist_info 这个工具,参数已经按 Schema 填好」,参数格式由 API 协议保证 ,天然就是干净的结构化对象,不需要任何剥壳、parse、赌运气:
js
response.tool_calls[0].args // 直接就是结构化对象
可靠性对比,一目了然:
javascript
写文本 JSON(OutputParser 路线):
prompt 约束 → LLM 自由发挥写文本 → 剥 Markdown 壳 → JSON.parse → 校验
↑ 全程不可控,全靠模型「自觉」
Function Calling(bindTools 路线):
Schema 声明工具 → LLM 按协议填参数 → 直接拿结构化 args
↑ 格式由协议强制保证,稳定可靠
那
output_parser模块还有存在的必要吗?------ 这是 demo 作者留在代码里的灵魂发问。我的看法 :如果你的模型支持 Function Calling,优先用 Tool Calling,它又快又稳。但 Output Parser 依然有存在价值:
- 很多纯文本模型 / 老模型、或通过某些网关接入时不支持 function calling;
- 你在做的是「让模型生成一段要落库/渲染的数据」,不想为了拿个 JSON 就引入工具调用的心智。
两者不是替代关系,而是同一目标的两条路线:协议层支持就抄近道,不支持就用 parser 兜底。
八、四层横向对比与选型建议
| 手段 | 原理 | 可靠性 | 表达能力 | 适用场景 |
|---|---|---|---|---|
| 手写正则 | 自己约束 + 剥壳 + parse | ⭐ 随缘 | 低 | 一次性脚本、最不济的兜底 |
JsonOutputParser |
自动注入「输出 JSON」+ 自动剥壳 parse | ⭐⭐ | 中 | 只要一个「大 JSON」,字段不挑剔 |
StructuredOutputParser.fromNamesAndDescriptions |
每个字段声明 name + description | ⭐⭐⭐ | 中(扁平结构) | 字段固定的简单表单类输出 |
StructuredOutputParser.fromZodSchema |
Zod Schema 定义类型/嵌套/可选 | ⭐⭐⭐⭐ | 高(任意嵌套) | 复杂、需要运行时校验的业务结构 |
model.bindTools() Function Calling |
按协议填函数参数 | ⭐⭐⭐⭐⭐ | 高(Schema 同样强大) | 模型支持时首选,最稳 |
选型口诀:
- 能用 Function Calling 就别用 Parser------协议层保证的格式,比提示词和正则靠谱一个量级;
- 下游只是要个对象、结构不复杂 →
JsonOutputParser足够轻; - 下游要强类型、嵌套、要防止脏数据落库 →
fromZodSchema配上 Zod,parse 即校验。
九、踩过的坑 & 关键点复盘
9.1 LLM 输出的 JSON 常被 Markdown 包裹,别直接 parse
这是最容易炸的一步。模型默认「给人展示」,习惯输出:
markdown
```json
{ ... }
```
Output Parser 的 parse() 之所以能稳,就是内置了剥壳逻辑。自己写正则时,一定要处理「没包代码块」和「代码块前后有寒暄语」两种分支。
9.2 getFormatInstructions() 到底是个啥?
它不是魔法,就是返回一段 prompt 文本------把它拼到你的问题后面,等于在提示词里注入格式约定。想看清它说了什么,直接打印:
js
console.log(parser.getFormatInstructions());
你会发现它生成的是类似「你的响应应该是一个 JSON 对象,结构为 ...,只输出 JSON,不要其他文本」的指令。理解这一点,你就明白 Output Parser = 提示词注入器 + 响应清洗器。
9.3 流式 + 结构化:注意缓冲与输出流
如果开了 stream,chunk 是一块块 的,一个完整 JSON 往往被拆成几十块。别在每一块上单独 parse,要先在内存里拼 fullContent,等流结束后再交给 parser:
js
let fullContent = "";
for await (const chunk of stream) {
fullContent += chunk.content; // 先累积
}
const result = await parser.parse(fullContent); // 流结束了再解析
9.4 拉高稳定性的几个小习惯
-
temperature: 0:结构化抽取场景下,别让模型「发挥」; -
统一走
baseURL指向你的模型网关(DeepSeek / 豆包 / Kimi / 通义......只要是 OpenAI 兼容接口都能用ChatOpenAI接):env# .env OPENAI_API_KEY=你的key OPENAI_BASE_URL=https://你的模型服务地址/v1 MODEL_NAME=你的模型名 -
生产要求零容错(落库、扣费、发指令)时,别依赖文本 JSON,直接用 Function Calling + Zod 双保险。
十、总结
一句话串起全文:
LLM 原生只吐「给人看的 Markdown」,程序要的是「能直接用的结构」。Output Parser 负责在 prompt 里注入格式约定、在响应里把壳剥掉;而当模型支持 Function Calling 时,连「让模型写 JSON」这步都可以省掉,直接按 Schema 拿结构化参数。
四个层级的升级脉络:
- 手写正则 ------ 明白了痛点:剥壳 + parse 的重复劳动;
- JsonOutputParser ------ 约束和解析外包,但字段类型无保证;
- StructuredOutputParser ------ 字段配 name + description,更听话了;
- fromZodSchema ------ 类型、嵌套、可选、运行时校验,严苛到极致;
- bindTools Function Calling(隐藏关)------ 别让模型写 JSON,让它填函数参数,最稳。
记住两句:
- Output Parser = getFormatInstructions()(注入约束) + parse()(剥壳解析),理解这一点,换任何框架都不慌;
- 能调函数就别写文本 JSON------让模型「干活」而不是让它「写字」,结构化输出的尽头是 Function Calling。
如果你也正在为「大模型返回的 JSON 又脏又乱」头疼,希望这篇能帮你把「解析靠运气」变成「格式靠协议」。有问题欢迎评论区交流 👋