文章目录
-
- 前言
- 一、为什么需要流式输出?
-
- [1. 传统 HTTP 响应到底有多折磨人?](#1. 传统 HTTP 响应到底有多折磨人?)
- [2. 流式输出:把"一整盘"改成"一道一道上"](#2. 流式输出:把"一整盘"改成"一道一道上")
- [3. 一个经典比喻:水管](#3. 一个经典比喻:水管)
- [二、SSE 是啥?一句话给你讲明白](#二、SSE 是啥?一句话给你讲明白)
-
- [1. 定义](#1. 定义)
- [2. SSE 长什么样?先看三个响应头](#2. SSE 长什么样?先看三个响应头)
- [3. SSE 数据的格式:`data:` 前缀](#3. SSE 数据的格式:
data:前缀)
- [三、后端怎么搞一个 SSE 接口?看 server.js](#三、后端怎么搞一个 SSE 接口?看 server.js)
-
- [1. 回顾:Node 写 HTTP 服务器](#1. 回顾:Node 写 HTTP 服务器)
- [2. 核心:异步生成器 + 逐个吐字](#2. 核心:异步生成器 + 逐个吐字)
- [3. 把生成器接到 SSE 上](#3. 把生成器接到 SSE 上)
- [四、前端怎么收?看 index.html 和 EventSource](#四、前端怎么收?看 index.html 和 EventSource)
-
- [1. `new EventSource(url)`](#1.
new EventSource(url)) - [2. `onmessage` 事件](#2.
onmessage事件)
- [1. `new EventSource(url)`](#1.
- 五、把流式接上大模型:stream-normal.mjs
-
- [1. `invoke` 和 `stream` 的对比](#1.
invoke和stream的对比) - [2. 用 `for await` 逐块读取](#2. 用
for await逐块读取)
- [1. `invoke` 和 `stream` 的对比](#1.
- [六、会流式了,怎么让大模型输出结构化 JSON?](#六、会流式了,怎么让大模型输出结构化 JSON?)
-
- [1. 用 prompt 约束大模型](#1. 用 prompt 约束大模型)
- [2. 大模型输出的"坑":JSON 被 markdown 包住了](#2. 大模型输出的"坑":JSON 被 markdown 包住了)
- [七、SSE 和其他方案的对比,以及它的局限](#七、SSE 和其他方案的对比,以及它的局限)
-
- [1. SSE vs WebSocket](#1. SSE vs WebSocket)
- [2. 为什么 AI 场景常用 SSE?](#2. 为什么 AI 场景常用 SSE?)
- [3. SSE 的局限](#3. SSE 的局限)

P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看, 传送门https://blog.csdn.net/HHX_01
前言
先别急着往下划,我有个灵魂问题想问你:你给 ChatGPT 发了一句话,屏幕上一个字都没有,光标在那儿闪了八秒------这八秒你脑子里在想什么?
反正我想的是:完了,是不是欠费了。
这种"等了半天,啥都没有,最后哗啦一下全出来"的体验,就是传统 HTTP 的优良传统。今天咱们就聊聊,怎么把这种"便秘式"输出,改成"涓涓细流"式的输出------也就是 SSE。
一、为什么需要流式输出?
1. 传统 HTTP 响应到底有多折磨人?
传统的"请求---响应"模型是这样的:你发请求,服务器吭哧吭哧算完,把完整的结果 打包好,一次性 response 给你,然后连接咔嚓一断。
像极了你去餐厅点菜:点完单,厨房开火,你在座位上干瞪眼,等十五分钟,服务员端上来一整盘。菜是好菜,问题是等待的过程里你连个响都听不到,只能反复刷手机,怀疑厨师是不是在厨房里睡着了。
同步等待(也叫阻塞)最大的问题就俩字:干等。你要是拿它去问大模型,模型思考 8 秒才把答案一次性甩出来,这 8 秒屏幕一动不动,你大概率会做出三件事:刷新页面、检查网络、问候产品经理。
2. 流式输出:把"一整盘"改成"一道一道上"
流式输出的思路特别朴素:服务器别憋大招了,算出来一点就发一点。
这就好比餐厅改成了流水席------厨师炒好一小碟就端上来,你边吃边等,嘴不闲着,心里也踏实。你看屏幕上的文字一个一个往外蹦,非但不着急,反而觉得:"哦,它在干活,行,我不慌。"
这个思路其实特别顺理成章:大模型生成回答,本来就是一个 Token 一个 Token 往外蹦的,它自己都是一字一字"说"的,你非让它攒成一大坨再一次性交给你,这不是硬逼着哪吒憋大招吗?既然它天生是流式的,服务器为什么不边收边推?
3. 一个经典比喻:水管
把大模型想象成一个水龙头 ,stream 就是一根水管,token 就是里面的"水珠",一滴一滴往客户端流。而普通的一次性响应,相当于拿整桶水兜头浇下来------爽是爽,就是容易呛着。
(我第一次看到这个比喻的时候,就感觉写这个 demo 的人,一定是在修水管的时候顿悟的。)
好,画面有了。那问题来了:流式数据在网络上跑,到底用的什么协议、什么格式?答案就是今天的主角------SSE。
二、SSE 是啥?一句话给你讲明白
1. 定义
SSE(Server-Sent Events,服务器推送事件),基于 HTTP,服务器单向地、持续地往浏览器推消息。三个特点:
- 服务器主动推,浏览器不用一遍遍问。
- 只发不收,单向流,浏览器不往回发。
- 长连接,建立了就不断开,服务器想塞多少塞多少。
你品品,"只发不收"这个设定,像不像你那个只会在群里发通知、从来不回消息的部门群?
2. SSE 长什么样?先看三个响应头
SSE 表面上就是个普通 HTTP 响应,但响应头跟普通网页完全不同。看 sse-demo/server.js 里的核心代码:
js
res.writeHead(200, {
"Content-Type": "text/event-stream", // ① 告诉浏览器:这是 SSE!
"Cache-Control": "no-cache", // ② 不要缓存,每次都要最新
Connection: "keep-alive" // ③ 保持长连接,别断
});
这三个响应头就是 SSE 的身份证:
Content-Type: text/event-stream:最关键的一个。浏览器一看到这个,就知道"哦,服务器要给我推事件了",会触发后面要讲的EventSource的逻辑。对比一下,普通网页是text/html,普通文本是text/plain。Cache-Control: no-cache:告诉浏览器别缓存。SSE 是实时推送,你缓存一个过期版本有啥意义?总不能 AI 都讲到第三句了,你还在循环第一句吧。Connection: keep-alive:HTTP 的长连接开关,意思是"这连接活着,别发完就断"。
传统 HTTP 是"请求→响应→断线",像打电话,说完就挂。SSE 是"请求→响应→保持通话",服务器顺着这条线一直说,说到嗓子哑为止。
3. SSE 数据的格式:data: 前缀
服务器推数据时,每条消息都有固定的格式。server.js 里这么写:
js
res.write(`data: ${word}\n\n`);
格式拆开看:data: + 一个空格 + 数据内容 + 两个换行符 (\n\n)。
data:是固定前缀,表示"这是一条数据"。- 末尾的两个
\n\n是整个 SSE 协议里最重要的分隔符,它告诉浏览器:"这条消息发完了,可以触发一次onmessage了。"
一句话:SSE 的每一帧就是 data: 内容\n\n,多个帧拼在一起,就成了源源不断的流。你就把它理解成摩斯电码的 HTTP 版,只不过不是"滴滴答",是"data data data"。
三、后端怎么搞一个 SSE 接口?看 server.js
理论说完了,上代码。sse-demo/server.js 完整演示了怎么用 Node.js 原生代码搭一个 SSE 接口。
1. 回顾:Node 写 HTTP 服务器
开头引入 Node 内置模块:
js
const http = require("http");
const fs = require("fs");
然后 http.createServer 创建服务器,按 req.url 分流。这个 demo 有两个路由:
/:返回 HTML 页面(前端index.html)。/stream:返回 SSE 流(核心内容)。
2. 核心:异步生成器 + 逐个吐字
/stream 接口用了一个"异步生成器"(async function*),每 yield 一个词,就发一个 chunk:
js
async function* streamWords() {
const words = ["你", "好", ",", "欢", "迎", "了", "解", "see"];
for (const word of words) {
await sleep(1000); // 模拟耗时,每 1 秒吐一个字
yield word;
}
}
两个关键点:
async function*(异步生成器) :像个流水线,你每次调用它,它就yield一个值,然后暂停,等你下次再要。await sleep(1000):每生产一个字故意停 1 秒,模拟"实时生成"的效果。真实的大模型也是这样,算一个 token 要几十毫秒,所以你看到文字蹦出来是有节奏的------不是打字机,是"挤牙膏机"。
3. 把生成器接到 SSE 上
最后把生成器产出的每个词,用 SSE 格式写出去:
js
(async () => {
for await (const word of streamWords()) {
res.write(`data: ${word}\n\n`); // 逐个以 SSE 帧发送
}
res.end(); // 全部发完,关闭连接
})();
这段代码等价于:服务器一边产词,一边 res.write 推给浏览器,最后 res.end() 收尾。注意,整个过程 HTTP 连接一直是开着的,只有所有词发完了才真正结束。
到这里,你已经用原生 Node 写出了能跑的 SSE 后端。是不是比想象中简单?本质就是"一个 HTTP 响应,但不断开,持续写数据"。说穿了,比某些同事的周报还简单。
四、前端怎么收?看 index.html 和 EventSource
后端会推了,前端怎么接?SSE 在浏览器里有个专门的类叫 EventSource 。看 sse-demo/index.html 的 script 部分:
js
const resultEle = document.getElementById("result");
// ① 用 EventSource 建立连接,传入 SSE 接口的 URL
const eventSource = new EventSource("http://localhost:3000/stream");
// ② 有数据到达时,触发 onmessage 事件
eventSource.onmessage = (e) => {
console.log(e.data);
resultEle.textContent += e.data + "\n"; // 把新内容追加到页面
};
1. new EventSource(url)
浏览器用 new EventSource(".../stream") 向服务器发起连接。两个注意点:
- 要传 SSE 接口的 URL(也就是后端那个
text/event-stream的地址)。 - 这个类只负责收,不负责发------所以 SSE 是单向的。你就当它是个只收快递不退货的收货员,反正服务器也不接受退货。
2. onmessage 事件
服务器每推过来一条 data: xxx\n\n,浏览器就自动触发一次 onmessage,数据塞进 e.data 里。
所以每次 onmessage 触发,我们就拿到一个 word,然后用 resultEle.textContent += e.data + "\n" 追加到页面。就这样,页面上的字一个一个出现,完美还原"打字机"效果。
生活类比:EventSource 就像给你派了一个"永远在线"的快递员,服务器一到点就送来一个小包裹(chunk),你拆开,把里面的东西贴到墙上(页面),一直贴到他说"送完了"。区别是:这个快递员不要钱,也不问你有没有空。
五、把流式接上大模型:stream-normal.mjs
讲了半天 Node 和浏览器,有人可能要问了:这和 AI 聊天到底怎么连上?别急,核心业务来了------让大模型流式输出,并把结果解析成结构化数据。
看 src/stream-normal.mjs:
js
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 = `详细介绍莫扎特的信息。`;
const stream = await model.stream(prompt); // 流式:拿到一个"数据流"
1. invoke 和 stream 的对比
这里有个非常重要的对比:
model.invoke(prompt):同步。等大模型把完整答案都算完,一次性返回。就像前面说的"一整盘菜端上来"。model.stream(prompt):流式。返回一个数据流(stream),你可以for await循环,一个 chunk 一个 chunk 地读。就像"一道道菜端上来"。
一句话总结:invoke 是自助餐结账后一次性端齐,stream 是吃回转寿司,转过来一盘拿一盘。
2. 用 for await 逐块读取
代码里演示了怎么消费这个流:
js
for await (const chunk of stream) {
const content = chunk.content;
fullContent += content;
process.stdout.write(content); // 实时打印
}
for await...of:一个一个读流里的 chunk。- 每读到一个 chunk,取出
content拼进fullContent,同时process.stdout.write立刻打印。
这样终端里莫扎特的介绍就是一句一句蹦出来的,而不是卡了半天哗啦全出来。我第一次跑这段代码的时候盯着终端看了半天,觉得莫扎特比我还话痨。
六、会流式了,怎么让大模型输出结构化 JSON?
现在来到目录里的另一个核心概念------output_parser(输出解析器)。大模型输出的是自然语言,但程序想要的是格式固定、能直接处理的数据(比如 JSON),好拿去存数据库、画图表、做后续逻辑。
1. 用 prompt 约束大模型
最直接的办法:在提示词里把格式要求写死。看 src/normal.mjs:
js
const prompt = `
请介绍一下爱因斯坦的信息。
请以 JSON 格式返回, 包含以下字段:
name(姓名)、birth_year(出生年份)、
nationality(国籍)、major_achievement(主要成就, 数组)、
famous_theory(著名理论)
`;
这句 prompt 明确告诉大模型:"请以 JSON 格式返回,必须包含这几个字段。"大模型很听话,就按模板输出 JSON。这就是prompt 约束------靠语言把大模型框进你要的格式里。说好听点叫提示工程,说难听点叫跟 AI 反复确认"你到底听懂了没"。
2. 大模型输出的"坑":JSON 被 markdown 包住了
理想情况下大模型直接给纯 JSON,但现实中它经常"好心"地加一层包装。比如它输出:
json
```json
{
"name": "爱因斯坦",
"birth_year": 1879
}
注意,它是被 ```json ... ```(markdown 代码块)**包裹**的。为什么?因为大模型默认是"给人看"的,所以会顺手用 markdown 美化一下。这就像你去餐厅点菜,菜本身没问题,结果服务员非要给你盖个盖子、系个蝴蝶结------你拆包装的时间比吃的时间还长。
但对程序来说,`JSON.parse()` 解析不了带 ```json 前缀的东西,会直接报错。程序看到带代码块的 JSON,大概率和看到带注释的 JSON 一样崩溃。
### 3. 用正则"剥掉"包装
所以 output_parser 的职责之一,就是把 markdown 包裹去掉,只留下纯 JSON。`normal.mjs` 里用正则实现了这一点:
```js
// 用正则取出 markdown 中的 ```json ... ```代码块内容
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
const jsonStr = jsonMatch ? jsonMatch[1] : response.content;
const jsonResult = JSON.parse(jsonStr);
console.log(jsonResult);
拆开看这几行:
match(/```json\s*([\s\S]*?)\s*```/):找到json 开头、结尾的中间那段。其中([\s\S]*?)是非贪婪地"抓到中间的所有字符"(\s\S表示匹配任意字符,包括换行)。jsonMatch[1]:取正则捕获到的第一组,也就是真正的 JSON 文本。- 如果正则没匹配到(
jsonMatch为 null),就退而求其次,直接拿原始response.content。 - 最后
JSON.parse(jsonStr):把字符串解析成真正的 JS 对象。
之后你就可以 jsonResult.name、jsonResult.birth_year 这样访问数据了。这就是 output_parser 的核心价值:把大模型的"自然语言输出",清洗成程序能直接用的"结构化 JSON"。换句话说,大模型负责"说人话",我们负责"把话翻译成机器听得懂的"。
七、SSE 和其他方案的对比,以及它的局限
学到这里,你可能想问:SSE 和 WebSocket 啥区别?为啥大模型聊天不直接用 WebSocket 这个"全能选手"?简单说说,帮你建立全局观。
1. SSE vs WebSocket
| 对比项 | SSE(Server-Sent Events) | WebSocket |
|---|---|---|
| 方向 | 单向:服务器→浏览器 | 双向:互相收发 |
| 协议 | 基于普通 HTTP | 独立协议 ws:// / wss:// |
| 能否发数据回服务器 | 不能(另发请求) | 能 |
| 断线重连 | 自动重连(EventSource 内置) | 需要自己实现 |
| 使用难度 | 简单,纯 HTTP 就能跑 | 较复杂,需要专门的库 |
2. 为什么 AI 场景常用 SSE?
AI 聊天主要是"机器单方面往用户输出文字",用户不需要频繁往服务器发消息(发请求另用普通 POST 就行)。这种"服务器往客户端单向发"的场景,SSE 正好最合适:
- 直接用 HTTP,前后端理解成本都低。
EventSource浏览器内置,一行代码就能连。- 支持自动断线重连,体验更稳。
说白了,SSE 就像公司里那种只负责群发消息的机器人,而 WebSocket 是两个人面对面唠嗑------AI 聊天这个场景,本来就是你听 AI 说,用 SSE 就够了。
3. SSE 的局限
SSE 也有短板:只能单向,只适合"服务器发起"的推送。如果需要"用户边打字,服务器边实时回应"这种双向互动(比如在线协同编辑、游戏),SSE 就不够用了,得用 WebSocket。
选哪个,取决于你的场景是"单向发"还是"双向聊"。别问哪种更好,问你的需求是听广播还是打电话------广播用 SSE,电话用 WebSocket,你要是拿广播的方式打电话,对方只会以为你在演独角戏。
P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看,传送门https://blog.csdn.net/HHX_01