前言
做 LLM 应用开发,两个核心问题绕不开:一是流式输出的打字机体验 ,总不能让用户盯着空白屏等全部生成完才看到内容;二是结构化数据输出,程序要做后续业务逻辑,必须拿到标准 JSON,而不是带 Markdown 包裹的自由文本。
很多新手踩过无数坑:prompt 里明明写了 "返回 JSON",结果大模型总把 JSON 包在代码块里,直接JSON.parse()直接报错。今天我们从底层原理到实战代码,把这件事彻底讲透。
一、流式输出的底层:SSE 服务器推送事件
很多人说的 "流式输出""打字机效果",底层本质就是 SSE(Server-Sent Events) 。
1.1 传统 HTTP vs SSE
- 传统 HTTP:一次请求 → 一次响应 → 连接断开。要拿新数据必须重新发请求。
- SSE :浏览器发起一次请求,建立长连接,服务器可以持续、多次地向客户端推送数据块(chunk),连接不主动断开。
就像水管的比喻:一头连 LLM 服务器,一头连客户端,token 源源不断流过来,客户端边收边展示。
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.parse
业务开发中我们经常需要大模型返回 JSON,用来做后续业务逻辑。但直接在 prompt 里写 "返回 JSON",大概率会踩三个坑:
- 大模型习惯输出 Markdown 格式,会把 JSON 包在
json ...代码块里 - 经常附带解释文字,比如 "好的,这是为你生成的 JSON:"
- 字段名、数据类型不固定,可能少字段、类型错误(比如数字写成字符串)
新手最常见的错误写法:
javascript
// 理想:直接parse得到对象
const data = JSON.parse(response.content);
// 现实:直接报错,因为JSON外面包了一层markdown代码块
笨办法是手写正则剥离 ```json 标记,再执行 parse。但每个接口都写一遍,维护成本高,还容易漏掉各种边界情况。
LangChain 的 OutputParser 系列就是专门解决这个问题的:自动剥离 Markdown 包裹、解析 JSON、甚至校验字段类型。
四、LangChain 三类 OutputParser 全对比
4.1 JsonOutputParser:最基础的 JSON 解析
核心作用:只告诉大模型 "输出 JSON 格式,可以用 ```json 代码块包裹",自动剥离标记并解析成 JS 对象。
- 优点:最简单,最轻量
- 缺点:不会自动定义字段,字段要求必须你自己写在 prompt 里
核心用法:
javascript
import { JsonOutputParser } from '@langchain/core/output_parsers';
// 1. 创建解析器实例
const parser = new JsonOutputParser();
// 2. 拿到格式指令,塞进prompt里传给大模型
const prompt = `
介绍爱因斯坦的信息,包含字段:name(姓名) birth_year(出生年份) nationality(国籍)
${parser.getFormatInstructions()}
`;
// 3. 解析模型返回的原始文本
const result = await parser.parse(response.content);
console.log(result); // 直接得到JS对象
注意:
getFormatInstructions()只是生成一段 "输出 JSON,不要多余文字" 的提示词,不会帮你定义字段,字段名和要求必须自己写。
4.2 StructuredOutputParser:自动生成字段说明
核心作用:你预先定义字段名和描述,它自动生成完整的字段规则提示词,不用你手写一大段字段要求。
两种创建方式:
方式 1:fromNamesAndDescriptions(快捷写法)
直接传键值对对象,适合扁平单层 JSON 结构。
php
import { StructuredOutputParser } from 'langchain/output_parsers';
const parser = StructuredOutputParser.fromNamesAndDescriptions({
name: "科学家的姓名",
birth_year: "出生年份,数字",
nationality: "国籍",
fields: "研究领域列表,字符串数组"
});
// 自动生成包含全部字段说明的格式指令
const formatInstructions = parser.getFormatInstructions();
方式 2:fromZodSchema(强类型)
直接传入 Zod Schema,支持嵌套结构、类型约束。
css
import { z } from 'zod';
import { StructuredOutputParser } from 'langchain/output_parsers';
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);
和 JsonOutputParser 的核心区别:
- JsonOutputParser:只管输出 JSON 格式,字段描述全靠你手写
- StructuredOutputParser:你定义字段,它自动生成完整的字段说明提示词
4.3 补充:ZodOutputParser 最强校验
如果需要严格校验字段类型 (比如年份必须是数字、数组不能是字符串),用ZodOutputParser。解析时如果类型不匹配、字段缺失,会直接抛出明确的校验错误,适合生产环境强校验场景。
五、进阶玩法:用 ToolCall 实现强结构化输出
还有一种更巧妙的生产级思路:借用大模型的工具调用(Tool Call)能力来做结构化输出。
5.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);
5.2 方案对比表
表格
| 方案 | 格式稳定性 | 依赖模型能力 | 适用场景 |
|---|---|---|---|
| JsonOutputParser | 一般 | 无要求,任何模型都能用 | 简单场景,模型不支持工具调用 |
| StructuredOutputParser | 较好 | 无要求 | 字段较多,不想手写提示词 |
| ToolCall 方案 | 最好 | 必须支持工具调用 | 强约束、复杂 Schema,生产环境优先 |
ToolCall 的核心优势:模型原生层面遵守 Schema,格式稳定性远高于 "prompt 引导 + 文本解析" 的 OutputParser 方案。
六、总结与选型建议
- 只需要流式打字机效果 :用
model.stream(),服务端配合 SSE 推送给前端 - 简单 JSON 输出,模型不支持工具调用 :用
JsonOutputParser - 字段多,想自动生成字段说明 :用
StructuredOutputParser.fromNamesAndDescriptions - 需要类型校验、嵌套结构 :用
StructuredOutputParser.fromZodSchema或ZodOutputParser - 生产环境、强格式要求、模型支持工具调用 :优先用ToolCall 强制调用方案,稳定性最高