副标题:基于一个 Node + SSE Demo,拆解协议、EventSource、流、pipe 和大模型流式接入
导语:
很多初中级前端/Node 后端开发第一次接触 SSE 时,都会写一个类似
new EventSource('/stream')的 Demo。代码跑起来了,文字也逐字蹦出来了,但心里还是有一堆问号:
Content-Type: text/event-stream到底是不是必须?Cache-Control: no-cache为什么不能省?访问/和/stream是什么关系?fs.createReadStream().pipe(res)又做了什么?SSE 和普通流式输出到底差在哪?这篇文章就基于一段真实的 SSE + LangChain 流式代码,把这些点一次讲透。适合初中级前端、Node 后端,以及正在做 AI 流式聊天应用的同学。
一、先看全貌:一个最小 SSE Demo 长什么样?
前端页面:
html
xml
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSE Demo</title>
</head>
<body>
<h1>SSE Demo</h1>
<div id="result"></div>
<script>
const resultEle = document.getElementById('result');
// 关键:向 /stream 发起 SSE 连接
const eventSource = new EventSource('http://localhost:3000/stream');
// 每当服务端推送一个事件,就会触发 onmessage
eventSource.onmessage = (event) => {
console.log(event.data);
resultEle.innerText += event.data;
};
</script>
</body>
</html>
Node 原生 HTTP 服务端:
js
ini
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
// 路由 /:返回 HTML 页面
if (req.url === '/') {
const readStream = fs.createReadStream('./index.html');
readStream.on('error', (err) => {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
});
res.writeHead(200, { 'Content-Type': 'text/html' });
readStream.pipe(res);
}
// 路由 /stream:建立 SSE 长连接
else if (req.url === '/stream') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const words = ['你', '好', ', ', '欢', '迎', '了', '解', 'sse'];
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');
});
这段代码虽然短,但已经覆盖了 SSE 的核心链路。先上一张时序图,把浏览器、Node 服务端、SSE 推送之间的关系理清楚。

核心结论:访问 / 和访问 /stream 是两个独立的 HTTP 请求。
/ 只负责返回 HTML,HTML 里的 JS 执行后,浏览器才会发起 /stream 请求,SSE 这时才真正建立。
二、核心概念:SSE 到底是什么?
SSE,全称 Server-Sent Events ,服务器发送事件。
它是一种基于 HTTP 的服务器到浏览器单向实时推送技术。
你可以把它理解成:
浏览器打开一个"水管",服务器不断往里面塞数据,浏览器实时接住并处理。
SSE 的几个关键点:
- 基于 HTTP:不需要协议升级,普通 HTTP 请求即可。
- 单向:只能服务器推给浏览器,浏览器不能通过这条连接发消息。
- 文本协议:传输的是 UTF-8 文本,格式固定。
- 自动重连 :
EventSource断开后会自动重连。 - 事件 ID :支持
Last-Event-ID,方便断点续传。
SSE 的标准数据格式:
text
yaml
data: 第一行
data: 第二行
event: customEvent
data: 自定义事件内容
id: 100
retry: 3000
data: 带 ID 和重试间隔的消息
规则:
- 每个事件以空行结束。
data:后面的内容会拼成event.data。event:指定事件类型,前端用addEventListener监听。id:设置事件 ID,断线重连时浏览器会带Last-Event-ID。retry:指定重连间隔(毫秒)。
前端 EventSource 会自动解析这些格式,所以你在 onmessage 里拿到的 event.data 已经是解析后的内容。
三、痛点与场景:为什么需要 SSE?
在没有 SSE 之前,实时推送通常靠:
- 短轮询:前端每隔几秒请求一次接口。
- 长轮询:服务器 hold 住请求,有数据再返回。
- WebSocket:全双工通信。
它们各有问题:
| 方案 | 痛点 |
|---|---|
| 短轮询 | 延迟高、浪费请求、服务器压力大 |
| 长轮询 | 实现复杂、连接频繁建立断开、代理兼容差 |
| WebSocket | 双向但复杂,需要协议升级,部分代理/防火墙不友好 |
| SSE | 简单、HTTP 原生、自动重连、文本友好,适合服务器单向推送 |
SSE 的典型场景:
- 实时通知、站内信
- 日志实时输出
- 任务进度条
- 股票行情、比分直播
- 大模型逐字输出(ChatGPT 式打字机效果)
尤其是大模型应用,SSE 几乎是标配。因为大模型生成是逐 token 的,用 SSE 可以把每个 token 实时推给前端,用户体验远好于等全部生成完再返回。
四、重难点剖析
难点 1:Content-Type、Cache-Control、Connection 是不是缺一不可?
这是历史对话里最容易被误解的点。直接给结论:
| 响应头 | 是否必须 | 作用 | 缺了会怎样 |
|---|---|---|---|
Content-Type: text/event-stream |
必须 | 告诉浏览器这是 SSE | EventSource 不识别,直接失败 |
Cache-Control: no-cache |
强烈建议 | 防止缓存旧事件 | 可能收到过期数据、延迟、重复 |
Connection: keep-alive |
非必须 | HTTP/1.1 保持长连接 | HTTP/1.1 默认持久连接;HTTP/2 禁止该头 |
所以,最小可用 SSE 只需要:
http
vbnet
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: hello
然后服务器保持连接、持续 res.write、持续 flush,不结束响应。
Cache-Control: no-cache 不是协议硬性要求,但生产环境必须加。
原因:SSE 是实时事件流,如果被浏览器或中间代理缓存,客户端可能收到旧事件,断线重连的 Last-Event-ID 续传也会被破坏。严格不缓存可以用:
http
yaml
Cache-Control: no-store
或者更严格:
http
yaml
Cache-Control: no-cache, no-store, must-revalidate
Connection: keep-alive 在 HTTP/1.1 下其实是默认行为,显式写出来只是提醒服务器和代理不要过早关闭。但 HTTP/2 是多路复用,禁止使用 Connection 头,写了反而可能出错。
真正让 SSE 成立的是:正确的 Content-Type + 合法 SSE 格式 + 服务端持续 flush 不结束响应。

难点 2:访问 / 时,new EventSource 到底有没有生效?
很多同学会误以为:访问 / 就是访问 SSE。
其实不是。
完整链路是:
- 浏览器请求
GET / - 服务端返回
index.html - 浏览器解析 HTML,执行
<script> - 遇到
new EventSource('http://localhost:3000/stream') - 浏览器发起第二个请求
GET /stream - 服务端返回
text/event-stream,SSE 连接建立 - 服务端每秒
res.write('data: xxx\n\n') - 浏览器触发
onmessage,更新页面
所以:
GET /:返回 HTML 页面。GET /stream:建立 SSE 连接,持续推送。- 两个请求是分开的,通过浏览器里的 JS 串联。
如果你直接在地址栏访问 http://localhost:3000/stream,不会看到 HTML 页面,而是看到原始的 SSE 数据流,浏览器可能一直转圈或显示文本。
常见错误示范:
js
arduino
// 错误:以为访问 / 就自动建立 SSE,结果 / 路由里没有返回 SSE
if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
// 但这里没有持续 write,也没有正确处理 EventSource
}
正确做法:
js
arduino
// / 返回 HTML,HTML 里的 JS 再去连接 /stream
if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
fs.createReadStream('./index.html').pipe(res);
} else if (req.url === '/stream') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
// 持续推送...
}
难点 3:Node 流式输出:fs.createReadStream、.on、.pipe 到底在干嘛?
1. fs.createReadStream('./index.html') 返回可读流
fs 是 Node 的文件系统模块,它本身不是流。
但 fs.createReadStream() 会返回一个 可读流对象 ,类型是 fs.ReadStream。
它不会一次性把整个文件读进内存,而是一块一块地读 。
这对于大文件非常友好,避免内存爆掉。
2. .on() 是 EventEmitter 的方法
Node 中很多对象都继承自 EventEmitter,包括:
- HTTP 服务器
req、res- 流对象
- 定时器、进程等
.on(eventName, callback) 用来监听事件。
可读流常见事件:
js
dart
readStream.on('open', () => {}); // 文件打开
readStream.on('data', chunk => {}); // 读到一块数据
readStream.on('end', () => {}); // 读完了
readStream.on('close', () => {}); // 流关闭
readStream.on('error', err => {}); // 出错
如果不监听 error,读取失败时 Node 会抛出未捕获异常,可能导致进程崩溃。
3. readStream.pipe(res) 的目的
pipe 是"管道"的意思:
js
ini
readStream.pipe(res);
把 可读流 的数据,自动写到 可写流。
在这里:
readStream:读取index.html文件内容。res:HTTP 响应对象,也是可写流。pipe:一边读文件,一边把内容写给浏览器。
等价的手动写法:
js
dart
readStream.on('data', (chunk) => {
res.write(chunk);
});
readStream.on('end', () => {
res.end();
});
但 pipe 更强,它自动处理:
- 数据流动:读到一块就写一块。
- 背压 :如果浏览器接收慢,
res.write()返回false,pipe会暂停读取,避免内存爆掉。 - 结束 :读完后自动调用
res.end()。 - 错误传递 :虽然
pipe不会自动处理所有错误,但基本传输和结束它都管了。
错误示范:在 writeHead(200) 之后再 writeHead(500)
js
javascript
const readStream = fs.createReadStream('./index.html');
readStream.on('error', (err) => {
// 如果此时 200 已经发出,这里会报错:Cannot set headers after they are sent
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
});
res.writeHead(200, { 'Content-Type': 'text/html' });
readStream.pipe(res);
正确示范:先确认文件可读,再写 200
js
dart
const readStream = fs.createReadStream('./index.html');
readStream.on('error', (err) => {
// 只有在响应头还没发出时才能写 500
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
}
res.end('Internal Server Error');
});
readStream.on('open', () => {
res.writeHead(200, { 'Content-Type': 'text/html' });
readStream.pipe(res);
});
或者简单点,用 fs.readFile:
js
kotlin
fs.readFile('./index.html', (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);
});
难点 4:SSE 与普通流式输出到底有什么区别?LangChain 的 stream 怎么接进来?
先看一段 LangChain 流式代码:
js
ini
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
stream: true,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const prompt = `简单介绍莫扎特信息100字`;
const stream = await model.stream(prompt);
let fullContent = '';
let chunkCount = 0;
for await (const chunk of stream) {
const content = chunk.content;
fullContent += content;
process.stdout.write(content); // 终端实时显示
chunkCount++;
}
console.log(`\n共接收 ${chunkCount} 个数据块`);
console.log(`完整内容: ${fullContent}`);
这段代码本身不是 SSE ,它只是 Node 进程内部消费大模型的流式响应。
model.stream() 返回一个 异步可迭代对象 ,for await...of 逐块读取。
如果想把它变成浏览器能看的 SSE,需要:
js
javascript
// 在 Node HTTP 服务的 /chat/stream 路由中
const stream = await model.stream(prompt);
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
for await (const chunk of stream) {
res.write(`data: ${chunk.content}\n\n`);
}
res.end();
这样前端 EventSource 就能实时收到大模型的逐字输出。
SSE vs 普通流式输出:
| 维度 | 标准 SSE | 普通流式输出 |
|---|---|---|
| 协议头 | 必须 text/event-stream |
任意 Content-Type |
| 数据格式 | data:、event:、id:、retry:,空行分隔 |
自定义,原始文本/JSON |
| 客户端 | EventSource 自动解析 |
fetch/XHR/ReadableStream 手动解析 |
| 断线重连 | 浏览器自动重连 | 要自己实现 |
| 断点续传 | 支持 Last-Event-ID |
要自己设计 |
| 事件类型 | 支持 event: 命名事件 |
要自己定义 |
| 请求方法 | 原生只支持 GET | 可 POST、自定义头、带 body |
| 方向 | 服务端到客户端单向 | 通常也是响应流,但更底层灵活 |
一句话:SSE 一定是流式输出,但流式输出不一定是 SSE。
很多大模型接口用 fetch 流式而不是 EventSource,就是因为需要 POST、自定义请求头、传 JSON body,而原生 EventSource 只支持 GET,不能带 body,也不能自定义请求头。
整体架构图:

五、避坑指南 / 最佳实践
-
不要轻易
res.end()除非你确定事件流已经结束。否则
EventSource会自动重连,导致重复推送。如果确实要结束,前端可以调用
eventSource.close()。 -
监听
req.on('close')清理资源客户端断开时,清理定时器、取消模型调用、释放数据库连接。
js
javascriptreq.on('close', () => { clearInterval(timer); // 清理其他资源 }); -
Nginx 反代要关闭缓冲
Nginx 默认会缓冲响应,导致 SSE 数据不能实时到达浏览器。需要:
nginx
iniproxy_buffering off; proxy_cache off;并加上:
http
yamlX-Accel-Buffering: no -
Cache-Control用no-cache或no-store不要省略,否则可能被缓存。
-
HTTP/2 下不要手动加
Connection头HTTP/2 禁止该头,写了可能报错。
-
EventSource 只支持 GET
需要 POST、自定义头、body 时,用
fetch+ReadableStream自己解析 SSE。 -
SSE 数据格式要严格
每个事件以空行结束。
data中有换行时,要拆成多个data:行:text
kotlindata: 第一行 data: 第二行 -
错误处理
前端加
onerror,后端流加error监听。
六、面试高频考点
1. SSE 和 WebSocket 的区别?
回答要点:
- SSE 是单向(服务器到浏览器),WebSocket 是全双工。
- SSE 基于 HTTP,不需要协议升级;WebSocket 需要
Upgrade握手。 - SSE 文本协议,自动重连,支持
Last-Event-ID;WebSocket 二进制/文本,重连要自己实现。 - SSE 更适合服务器单向推送,WebSocket 更适合双向实时通信。
- 代理兼容性:SSE 更好,WebSocket 部分代理/防火墙不支持。
2. 为什么 SSE 需要 Content-Type: text/event-stream?EventSource 如何解析?
回答要点:
text/event-stream是 SSE 的 MIME 类型,浏览器只有看到它才会按 SSE 协议解析。EventSource会解析data:、event:、id:、retry:字段。- 每个事件以空行结束,
data:内容拼接后触发onmessage。 - 如果 Content-Type 不对,
EventSource直接失败。
3. Node 中 pipe 的作用和背压机制?
回答要点:
pipe把可读流的数据自动写到可写流。- 自动调用
write,读完后自动end。 - 处理背压:当
write()返回false,暂停读取,防止内存暴涨。 - 适合文件传输、HTTP 响应等场景。
4. 大模型流式输出如何实现?
回答要点:
- LLM 逐 token 生成,SDK 提供
stream()返回AsyncIterable。 - 用
for await...of消费每个 chunk。 - 把 chunk 转成 SSE 格式
data: ...\n\n写给前端。 - 前端
EventSource实时接收并渲染。
七、总结
回到最初的问题:
Content-Type: text/event-stream是 SSE 的身份证,必须。Cache-Control: no-cache不是协议必须,但生产环境强烈建议。Connection: keep-alive在 HTTP/1.1 下非必须,HTTP/2 禁止。- 访问
/只返回 HTML,HTML 里的 JS 再请求/stream,SSE 才建立。 fs.createReadStream返回可读流,.on监听事件,.pipe自动写数据并处理背压。- SSE 是标准协议,普通流式输出只是字节流;SSE 一定是流式,流式不一定是 SSE。
- LangChain 的
model.stream()返回异步可迭代对象,配合 SSE 就能实现浏览器端逐字输出。
如果你正在做 AI 聊天、实时通知、日志推送,SSE 是一个非常值得掌握的轻量级方案。