LLM 返回的是自由文本,但你的业务需要 JSON。本文从 SSE 流式传输讲起,逐层拆解四种结构化输出方案,附完整代码和面试高频问。
前言
用 LLM 做业务时,你一定遇到过这个问题:大模型返回的是一段话,但你要的是一个 JSON 对象。
比如让它"介绍一下爱因斯坦",它返回的是:
erlang
莫扎特是奥地利作曲家,出生于1756年......
但你的代码需要的是:
json
{"name": "爱因斯坦", "birth_year": 1879, "nationality": "德国"}
怎么让 LLM 按格式输出?输出了又怎么解析?本文从最原始的方案讲到 LangChain 封装的终极方案,四种姿势,层层递进。
你将会收获:
- 理解 SSE 协议与流式传输的底层原理
- 掌握正则提取、JsonOutputParser、StructuredOutputParser、Tool Calling 四种方案
- 搞懂 Zod schema 与结构化输出的关系
- 面试中遇到"LLM 结构化输出"不再慌
技术栈: Node.js + LangChain + OpenAI API + Zod
一、SSE:大模型"打字机效果"的底层协议
1.1 为什么需要 SSE?
你用 ChatGPT 时,回答是一个字一个字蹦出来的,不是等 5 秒后一次性全出来。这背后就是 SSE(Server-Sent Events)。
传统 HTTP: 请求 → 等5秒 → 一次性返回 → 断开
SSE: 请求 → 保持连接 → 一点一点推送 → 结束后断开
类比一下:传统 HTTP 是你点了一桌菜,厨师全部做完才端上来;SSE 是厨师每做完一道菜,服务员就端一道上来,你边吃边等。
1.2 SSE 响应头
js
res.writeHead(200, {
'Content-Type': 'text/event-stream', // 告诉浏览器:这是事件流
'Cache-Control': 'no-cache', // 禁止缓存
'Connection': 'keep-alive', // 保持长连接
});
对比普通 HTTP:
| 普通 HTTP | SSE | |
|---|---|---|
| Content-Type | text/html |
text/event-stream |
| 连接 | 发完就断 | 保持不断 |
| 数据 | 一次性 | 分批推送 |
| 方向 | 请求-响应 | 服务器单向推送 |
1.3 SSE 消息格式
kotlin
data: 你好\n\n
data: 世界\n\n
data: [DONE]\n\n
data:是固定前缀(注意有空格)\n\n是消息分隔符(双换行)- 浏览器的
EventSource收到后会自动剥掉data:,只把内容放到e.data
1.4 手写 SSE 服务
js
const http = require('http');
const fs = require('fs');
const path = require('path');
const server = http.createServer((req, res) => {
if (req.url === '/') {
// 返回 HTML 页面
const filePath = path.join(__dirname, 'index.html');
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(data);
});
} else if (req.url === '/stream') {
// SSE 流式推送
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);
res.end(); // 关闭连接
return;
}
res.write(`data: ${words[index]}\n\n`); // SSE 格式发送
index++;
}, 1000);
}
});
server.listen(3000, () => {
console.log('server is running on port 3000');
});
1.5 前端接收
html
<div id="result"></div>
<script>
const resultEle = document.getElementById('result');
const eventSource = new EventSource('http://localhost:3000/stream');
eventSource.onmessage = (e) => {
console.log(e.data); // "你"、"好"、", " ...
resultEle.innerText += e.data; // 逐字拼接
};
</script>
效果:页面上一个字一个字蹦出 "你好, 欢迎了解sse"。
💡 一句话记住 SSE :HTTP 长连接 +
data:前缀 +EventSource自动解析 = 服务器单向推送。
1.6 SSE 不只用于 LLM
| 场景 | 说明 |
|---|---|
| LLM 聊天 | ChatGPT、DeepSeek 的打字机效果 |
| 股票行情 | 实时推送价格变动 |
| 消息通知 | 站内信、系统告警 |
| 日志流 | 实时查看构建/部署日志 |
二、outputparser:让 LLM 返回 JSON 的第一个思路
2.1 问题:LLM 返回的是自由文本
让 LLM "以 JSON 格式返回",它确实会返回 JSON,但往往被 markdown 代码块包裹:
json
```json
{"name": "爱因斯坦", "birth_year": 1879}
go
你直接 `JSON.parse()` 会报错,因为字符串是:
```js
'```json\n{"name": "爱因斯坦", "birth_year": 1879}\n```'
前面多了 ```````json````,后面多了 ```````````,不是合法 JSON。
2.2 原始方案:正则提取
js
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
const jsonStr = jsonMatch ? jsonMatch[1] : response.content;
const result = JSON.parse(jsonStr);
正则拆解:
| 片段 | 含义 |
|---|---|
| ``````````` | 匹配三个反引号 |
json |
匹配字面量 json |
\s* |
匹配空白(含换行) |
([\s\S]*?) |
捕获组:任意字符,非贪婪 |
| ``````````` | 匹配结尾的三个反引号 |
问题 :每次都要手写正则,太麻烦。而且 LLM 有时不写 ```````json````,只返回裸 JSON,正则就匹配不到了。
2.3 Prompt Output 技巧
更靠谱的做法是从源头约束:在 prompt 里明确告诉 LLM 要什么格式。
js
const prompt = `
请介绍一下爱因斯坦的信息。请以 JSON 格式返回,
包含以下字段:name(姓名)、birth_year(出生年份)、
nationality(国籍)、major_achievements(主要成就, 数组)、
famous_theory(著名理论)
`;
三个技巧:
- 明确说"以 JSON 格式返回"
- 列出每个字段的 key 和含义
- 用
temperature: 0减少随机性
⚠️ 面试考点 :LLM 结构化输出的核心思路是prompt 约束 + 事后解析,两手都得有。prompt 约束是源头治理,解析是兜底。
三、JsonOutputParser:LangChain 的第一个封装
3.1 是什么
JsonOutputParser 是 LangChain 提供的 JSON 解析器,把"正则提取 + JSON.parse"封装好了。
3.2 怎么用
js
import { ChatOpenAI } from '@langchain/openai';
import { JsonOutputParser } from '@langchain/core/output_parsers';
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 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.name); // "爱因斯坦"
3.3 两个关键方法
js
// ① 在 prompt 末尾追加格式说明
${parser.getFormatInstructions()}
// 输出类似:"Return a JSON object with the following schema: ..."
// ② 解析 LLM 返回的文本
const result = await parser.parse(response.content);
// 内部:正则提取 markdown → JSON.parse → 返回 JS 对象
3.4 本质
scss
getFormatInstructions() → 在 prompt 里追加格式约束(源头)
parser.parse() → 正则提取 markdown + JSON.parse(兜底)
💡 一句话记住:JsonOutputParser = prompt 约束 + 正则提取 + JSON.parse,三合一。
四、StructuredOutputParser:约束字段结构
4.1 JsonOutputParser 的问题
JsonOutputParser 只管"返回 JSON",不管"JSON 里面有哪些字段、什么类型"。LLM 可能返回:
json
{"name": "爱因斯坦", "year": 1879} // key 和你要求的不一样
4.2 方式一:fromNamesAndDescriptions
js
import { StructuredOutputParser } from '@langchain/core/output_parsers';
const parser = StructuredOutputParser.fromNamesAndDescriptions({
name: '姓名',
birth_year: '出生年份',
nationality: '国籍',
major_achievements: '主要成就, 用逗号分隔的字符串',
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.nationality}`);
比 JsonOutputParser 多了什么? 每个字段都指定了 key 和描述,LLM 会严格按这些 key 返回。
4.3 方式二:fromZodSchema(更强约束)
fromNamesAndDescriptions 只能定义扁平的 key-value,不支持类型、嵌套、可选字段。这时候就需要 Zod。
js
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字以内'),
});
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}`);
4.4 两种方式对比
| fromNamesAndDescriptions | fromZodSchema | |
|---|---|---|
| 定义方式 | key: "描述" |
z.object({...}) |
| 类型约束 | ❌ 只有描述 | ✅ z.string()、z.number() |
| 嵌套结构 | ❌ 扁平 | ✅ 支持嵌套对象、数组 |
| 可选字段 | ❌ 不支持 | ✅ .optional() |
| 校验强度 | 弱 | 强 |
4.5 Zod 是什么?
Zod 是 TypeScript 的 schema 校验库,用来定义"数据应该长什么样"。
js
z.string() // 字符串
z.number() // 数字
z.array(z.string()) // 字符串数组
z.object({...}) // 对象
z.number().optional() // 可选数字
三者关系:
arduino
Schema(概念) → "数据长什么样" 的定义
↑
Zod(工具) → 用代码写 schema 的库
↑
StructuredOutputParser(消费者) → 拿着 schema 去约束 LLM
💡 一句话记住:Zod 是画图纸的笔,StructuredOutputParser 是拿着图纸去验收的工人。
五、Tool Calling:终极方案
5.1 从 tool-call schema 得到的灵感
前面三种方案都是让 LLM 返回文本,再解析文本 。但 LLM 有一个原生能力:Tool Calling(工具调用)。
工具调用的参数本身就是结构化的 JSON。所以我们可以:
不把 schema 用来解析文本输出
而是把 schema 定义成工具参数,让 LLM 通过工具调用返回结构化数据
5.2 实现
js
import { ChatOpenAI } from '@langchain/openai';
import { z } from 'zod';
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
// 同样的 Zod schema
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
// 把 schema 绑定为工具参数
const modelWithTool = model.bindTools([
{
name: 'extract_scientist_info',
description: '提取和结构化科学家的详细信息',
schema: scientistSchema,
},
]);
// 注意:这个工具不是真的要执行,只是借用 tool calling 的结构化能力
const response = await modelWithTool.invoke('介绍一下爱因斯坦');
console.log(response.tool_calls[0].args);
// 直接拿到结构化数据,不需要 JSON.parse
5.3 为什么比 OutputParser 好?
javascript
OutputParser 方案:
LLM 返回文本 → 正则提取 → JSON.parse → 可能失败
(文本 → 解析,有信息损耗)
Tool Calling 方案:
LLM 直接返回工具调用参数 → 天然就是结构化 JSON
(模型原生能力,不依赖文本解析)
| OutputParser | Tool Calling | |
|---|---|---|
| 数据来源 | 从文本中提取 | 模型原生输出 |
| 解析风险 | 可能 JSON 不合法 | 天然结构化 |
| 类型校验 | 依赖 Zod 事后校验 | 模型参数自带类型 |
| 嵌套支持 | 需要 Zod 定义 | 原生支持 |
💡 一句话记住 :Tool Calling 不是为了调用工具,而是借用工具调用的结构化能力拿到靠谱的 JSON。
5.4 那 OutputParser 还有必要存在吗?
有。Tool Calling 要求模型支持 function calling(大多数主流模型都支持),而 OutputParser 是纯文本解析,兼容性更好。而且有些场景你只需要简单的 JSON 解析,杀鸡不用牛刀。
选择建议:
javascript
简单场景(只要 JSON) → JsonOutputParser
需要字段约束 → StructuredOutputParser + Zod
需要最强结构化保证 → Tool Calling + Zod
六、四种方案全景对比
| 方案 | 复杂度 | 约束强度 | 原理 |
|---|---|---|---|
| 正则提取 | ⭐ | 最弱 | prompt 约束 + 正则 + JSON.parse |
| JsonOutputParser | ⭐⭐ | 弱 | prompt 格式说明 + 正则 + JSON.parse |
| StructuredOutputParser | ⭐⭐⭐ | 中 | prompt 字段约束 + 正则 + JSON.parse |
| Tool Calling | ⭐⭐⭐⭐ | 强 | 模型原生结构化输出 |
正则提取 → JsonOutputParser → StructuredOutputParser → Tool Calling
↓ ↓ ↓ ↓
手动正则 封装正则 加字段约束 模型原生能力
最弱 最强
七、面试高频问
Q1:LLM 返回的 JSON 被 markdown 包裹怎么办?
两种方案:
- 正则提取 :
text.match(/```json\s*([\s\S]*?)\s*```/)取捕获组 - LangChain Parser :
JsonOutputParser.parse()内部自动处理
本质都是"先清洗再 parse",推荐用 LangChain 封装,省去手写正则的麻烦。
Q2:JsonOutputParser 和 StructuredOutputParser 的区别?
JsonOutputParser:只管返回 JSON 格式,不管字段结构StructuredOutputParser:除了 JSON 格式,还约束字段名、类型、描述
升级路径:fromNamesAndDescriptions(简易版)→ fromZodSchema(完整版,支持嵌套、可选、类型校验)。
Q3:什么是 Zod?和 StructuredOutputParser 什么关系?
Zod 是 TypeScript 的 schema 校验库,用来定义数据结构。StructuredOutputParser.fromZodSchema(zodSchema) 把 Zod 定义的 schema 传给解析器,解析器用它来约束 LLM 输出并校验返回结果。
类比:Zod 是"图纸",StructuredOutputParser 是"验收员"。
Q4:Tool Calling 为什么比 OutputParser 更靠谱?
OutputParser 是从 LLM 返回的文本中提取 JSON ,有截断、格式错误等风险。Tool Calling 是 LLM 的原生结构化输出能力,返回的工具调用参数本身就是合法 JSON,不依赖文本解析。
类比:OutputParser 是"从作文里找答案",Tool Calling 是"直接填表格"。
Q5:getFormatInstructions() 做了什么?
在 prompt 末尾追加一段格式说明,告诉 LLM "请按以下 JSON schema 返回"。这是prompt 约束的核心------通过在 prompt 中嵌入格式要求,引导 LLM 按指定结构输出。
Q6:SSE 和 WebSocket 的区别?
- SSE:单向(服务器→客户端),基于 HTTP,浏览器内置
EventSource自动重连 - WebSocket:双向,独立协议(ws://),需要手动处理重连
LLM 流式输出只需要服务器推数据,用 SSE 更简单。
总结
核心概念速查表
| 概念 | 一句话 |
|---|---|
| SSE | 服务器单向推送,data: 前缀 + \n\n 分隔 |
| EventSource | 浏览器内置 SSE 客户端,自动解析、自动重连 |
| JsonOutputParser | LangChain 的 JSON 解析器,prompt 约束 + 正则 + parse |
| StructuredOutputParser | 在 JsonOutputParser 基础上加字段约束 |
| Zod | TypeScript schema 库,定义数据结构 |
| Tool Calling | 模型原生结构化输出,不依赖文本解析 |
getFormatInstructions() |
在 prompt 里追加格式约束说明 |
parser.parse() |
正则提取 markdown + JSON.parse |
一句话总结
markdown
LLM 结构化输出 = prompt 约束(源头)+ 解析兜底(事后)
从正则 → Parser → Zod → Tool Calling,层层升级。
核心代码骨架
js
// 终极方案:Tool Calling + Zod
const schema = z.object({
name: z.string().describe('姓名'),
age: z.number().describe('年龄'),
});
const modelWithTool = model.bindTools([{ name: 'extract', schema }]);
const response = await modelWithTool.invoke('介绍一下xxx');
const result = response.tool_calls[0].args; // 天然结构化,不用 parse
希望这篇文章对你有帮助!有问题欢迎在评论区交流 🔥