前言
做 LLM 应用开发,两个核心问题绕不开:一是流式输出的打字机体验 ,总不能让用户盯着空白屏等全部生成完才看到内容;二是结构化数据输出,程序要做后续业务逻辑,必须拿到标准 JSON,而不是带 Markdown 包裹的自由文本。
很多新手踩过无数坑:prompt 里明明写了 "返回 JSON",结果大模型总把 JSON 包在代码块里,直接 JSON.parse() 直接报错。今天我们从底层原理到实战代码,把这件事彻底讲透,所有方案均附完整可运行代码。
一、流式输出的底层:SSE 服务器推送事件
很多人说的 "流式输出""打字机效果",底层本质就是 SSE(Server-Sent Events) 。
1.1 传统 HTTP vs SSE
- 传统 HTTP:一次请求 → 一次响应 → 连接断开。要拿新数据必须重新发请求。
- SSE :浏览器发起一次请求,建立长连接,服务器可以持续、多次地向客户端推送数据块(chunk),连接不主动断开。
就像水管的比喻:一头连 LLM 服务器,一头连客户端,token 源源不断流过来,客户端边收边展示,本地做 buffer 拼接。
SSE 响应的三个核心标识:
css
Content-Type: text/event-stream;
Cache-Control: no-cache;
Connection: keep-alive;
text/event-stream:告诉浏览器这是 SSE 事件流,按流式协议解析no-cache:不缓存数据,保证实时性keep-alive:保持 TCP 长连接不中断
1.2 原生 SSE 最小实现(Node.js + 浏览器)
服务端(Node.js 原生 HTTP 模块,模拟逐字推送):
javascript
const http = require('http');
const server = http.createServer((req, res) => {
// 跨域处理
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('hello world');
}
// SSE 流式接口
else if (req.url === '/stream') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'cache-control': 'no-cache',
'connection': 'keep-alive'
});
// 模拟逐字推送数据
const words = ['你', '好', '欢', '迎'];
let index = 0;
const timer = setInterval(() => {
if (index >= words.length) {
clearInterval(timer);
res.end();
return;
}
// SSE 固定数据格式:data: 内容\n\n
res.write(`data: ${words[index]}\n\n`);
index++;
}, 1000);
}
});
server.listen(3000, () => {
console.log('server is running on port 3000');
});
前端(浏览器原生 EventSource API 接收 SSE):
xml
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE 流式演示</title>
</head>
<body>
<h1>SSE DEMO</h1>
<div id="result"></div>
<script>
const resultEle = document.getElementById('result');
// 浏览器原生API,专门建立SSE长连接
const eventSource = new EventSource("http://localhost:3000/stream");
// 每次有新的chunk到达,触发onmessage事件
eventSource.onmessage = (e) => {
console.log('收到数据:', e.data);
resultEle.innerHTML += e.data;
};
</script>
</body>
</html>
关键点:SSE 是服务器单向推送,只能服务端给客户端发数据。LLM 流式输出刚好是单向场景,所以 SSE 是行业最主流的方案。
二、LangChain 中的流式输出
在 LangChain 里,不用自己手写 SSE 拼接和解析,调用模型的 .stream() 方法就能拿到流式异步迭代器,用 for await...of 逐块读取。
javascript
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
baseURL: process.env.OPENAI_BASE_URL
});
const prompt = '你是谁';
console.log('普通流式输出演示:(无结构化)\n');
try {
// invoke:同步等待全部结果返回
// stream:返回异步迭代器,逐块接收token
const stream = await model.stream(prompt);
let chunkCount = 0;
let fullContent = '';
// 遍历流式数据块
for await (const chunk of stream) {
chunkCount++;
const content = chunk.content;
fullContent += content;
// 逐字打印到控制台,模拟打字机效果
process.stdout.write(content);
}
console.log('\n\n数据块总数:', chunkCount);
console.log('完整拼接内容:', fullContent);
} catch (error) {
console.error('模型调用失败:', error);
}
本质上,LangChain 底层也是通过 HTTP 分块编码 / SSE 接收模型的流式响应,封装成了易用的迭代器接口,不用我们手动处理协议细节。
三、结构化输出的核心痛点
业务开发中我们经常需要大模型返回 JSON,用来做后续业务逻辑。但直接在 prompt 里写 "返回 JSON",大概率会踩三个坑:
- 大模型习惯输出 Markdown 格式,会把 JSON 包在
json ...代码块里 - 经常附带解释文字,比如 "好的,这是为你生成的 JSON:"
- 字段名、数据类型不固定,可能少字段、类型错误(比如数字写成字符串)
新手最常见的解决办法 ------ 手写正则剥离:
javascript
// 从模型返回文本中提取 JSON 字符串
function extractJson(text) {
// 去掉 markdown 代码块标记(```json ... ```)
const cleaned = text.replace(/```json|```/g, '').trim()
// 截取第一个 { 到最后一个 } 之间的内容,兼容前后附带说明文字的情况
const start = cleaned.indexOf('{')
const end = cleaned.lastIndexOf('}')
if (start === -1 || end === -1) {
throw new Error('未在模型输出中找到 JSON 内容')
}
return cleaned.slice(start, end + 1)
}
这种写法能解决简单问题,但维护成本高,边界情况多(比如嵌套 JSON、代码里有反引号)。LangChain 的 OutputParser 系列就是专门解决这个问题的:自动剥离 Markdown 包裹、解析 JSON、甚至校验字段类型。
四、方案一:JsonOutputParser 基础 JSON 解析
核心作用:只告诉大模型 "输出 JSON 格式,可以用 ```json 代码块包裹",自动剥离标记并解析成 JS 对象。
- 优点:最简单,最轻量,兼容所有模型
- 缺点:不会自动定义字段,字段要求必须你自己写在 prompt 里
完整可运行代码
javascript
import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'
import { JsonOutputParser } from '@langchain/core/output_parsers'
const model = new ChatOpenAI({
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
baseURL: process.env.OPENAI_BASE_URL
})
// 1. 创建解析器实例
const parser = new JsonOutputParser();
// 2. 拼接prompt:自己写字段要求 + parser生成的格式指令
const prompt = `
请介绍一下爱因斯坦的信息,请以JSON格式返回,
包含以下字段:name(姓名) birth_year(出生年份)
nationality(国籍),major_achievements(主要成就,数组)
famous_theory(著名理论)
${parser.getFormatInstructions()}
`
try {
console.log("正在调用大模型...\n");
const response = await model.invoke(prompt);
console.log('模型原始返回:', response.content);
// 3. 解析文本,自动剥离markdown、转成JS对象
const result = await parser.parse(response.content)
console.log('\n✅ 解析后的JSON对象:');
console.log(result);
} catch (error) {
console.error('解析失败:', error.message);
}
关键注意点
parser.getFormatInstructions()只是生成一段 "输出 JSON,不要多余文字" 的提示词,不会帮你定义字段,字段名和要求必须自己写。parser.parse()是异步函数,必须加await。- 它替代的就是上面手写的
extractJson + JSON.parse两步,内置了更完善的边界处理。
五、方案二:StructuredOutputParser 自动字段生成
核心作用:你预先定义字段名和描述,它自动生成完整的字段规则提示词,不用你手写一大段字段要求。 根据定义方式不同,分为两个版本。
5.1 快捷版:fromNamesAndDescriptions
直接传键值对对象,适合扁平单层 JSON 结构,代码最简洁。
完整可运行代码
javascript
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { StructuredOutputParser } 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,
}
});
// 1. 直接传入 {字段名: 字段描述},快速创建解析器
const parser = StructuredOutputParser.fromNamesAndDescriptions({
name: "姓名",
birth_year: "出生年份",
nationality: "国籍",
major_achievements: "主要成就, 用逗号分隔的字符串",
famous_theory: "著名理论"
});
// 2. 自动生成包含全部字段说明的格式指令
const question = `
请介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}
`;
try {
console.log('正在调用大模型 (使用 StructuredOutputParser) \n');
const response = await model.invoke(question);
console.log('模型原始返回:', response.content);
// 3. 解析得到对象
const result = await parser.parse(response.content);
console.log(`\n姓名:${result.name}`);
console.log(`国籍:${result.nationality}`);
} catch (error) {
console.error('解析错误:', error);
}
和 JsonOutputParser 的核心区别:
- JsonOutputParser:只管输出 JSON 格式,字段描述全靠你手写
- StructuredOutputParser:你定义字段,它自动生成完整的字段说明提示词
5.2 强类型版:fromZodSchema 嵌套结构
直接传入 Zod Schema,支持嵌套对象、数组、可选字段、类型约束,适合复杂结构化场景。
完整可运行代码
css
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
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,
}
});
// 1. 用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字以内'),
});
// 2. 从Zod Schema创建解析器
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);
const question = `请介绍一下居里夫人的详细信息,
${parser.getFormatInstructions()}`;
try {
console.log('正在调用大模型 (使用 ZodSchema) \n');
const response = await model.invoke(question);
console.log('模型原始返回:', response.content);
const result = await parser.parse(response.content);
console.log(`\n姓名:${result.name}`);
console.log(`国籍:${result.nationality}`);
console.log(`获奖数量:${result.awards.length}`);
} catch (error) {
console.error('解析错误:', error);
}
Zod 常用语法速记
z.string()/z.number():基础类型z.array(z.string()):字符串数组z.object({...}):嵌套对象.optional():可选字段,可以不返回.describe('xxx'):字段描述,会生成到提示词里给大模型看
六、进阶方案:用 ToolCall 实现强结构化输出
还有一种更巧妙的生产级思路:借用大模型的工具调用(Tool Call)能力来做结构化输出。
6.1 原理
大模型的工具调用能力,天生要求调用参数必须严格符合定义好的 Schema。我们可以 "伪造" 一个工具,不真的执行它的逻辑,只是让模型把结果塞进工具参数里,直接从 tool_calls 里拿到结构化数据。
完整可运行代码
php
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { z } from 'zod';
const model = new ChatOpenAI({
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
baseURL: process.env.OPENAI_BASE_URL
});
// 定义数据结构Schema
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
// 绑定工具到模型
const modelWithTool = model.bindTools([
{
name: "extract_scientist_info",
description: "提取和结构化科学家的详细信息",
schema: scientistSchema,
}
], {
// 强制模型必须调用这个工具,避免直接输出自然语言
tool_choice: { type: "function", function: { name: "extract_scientist_info" } }
});
// 调用模型
const response = await modelWithTool.invoke("介绍一下爱因斯坦");
// 直接拿到结构化参数,不需要再parse
const data = response.tool_calls[0].args;
console.log('✅ 工具调用返回的结构化数据:', data);
核心优势
模型原生层面遵守 Schema,格式稳定性远高于 "prompt 引导 + 文本解析" 的 OutputParser 方案,几乎不会出现格式错误。
七、方案对比与选型建议
表格
| 方案 | 格式稳定性 | 依赖模型能力 | 适用场景 |
|---|---|---|---|
| 手写正则 + JSON.parse | 差 | 无要求 | 临时调试、极简单场景 |
| JsonOutputParser | 一般 | 无要求,任何模型都能用 | 简单 JSON 输出,模型不支持工具调用 |
| StructuredOutputParserfromNamesAndDescriptions | 较好 | 无要求 | 扁平结构,字段多,不想手写提示词 |
| StructuredOutputParserfromZodSchema | 好 | 无要求 | 复杂嵌套结构、需要类型约束 |
| ToolCall 强制调用 | 最好 | 必须支持工具调用 | 生产环境、强格式要求、高稳定性需求 |
八、总结
- 流式输出底层是 SSE:单向长连接,逐 chunk 推送数据,实现打字机体验。
- OutputParser 解决的核心问题:自动剥离 Markdown 代码块,解析 JSON,不用手写正则。
- 选型思路:简单场景用 JsonOutputParser;字段多用 StructuredOutputParser;复杂嵌套用 ZodSchema;生产环境强稳定优先用 ToolCall 方案。
- 两个必记关键点 :
getFormatInstructions()是给大模型看的提示词;parse()是给代码用的解析函数,必须加await