别再把 SSE 当普通流式了!从 EventSource 到 LangChain,一次讲透实时推送与流式输出

副标题:基于一个 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 的几个关键点:

  1. 基于 HTTP:不需要协议升级,普通 HTTP 请求即可。
  2. 单向:只能服务器推给浏览器,浏览器不能通过这条连接发消息。
  3. 文本协议:传输的是 UTF-8 文本,格式固定。
  4. 自动重连EventSource 断开后会自动重连。
  5. 事件 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-TypeCache-ControlConnection 是不是缺一不可?

这是历史对话里最容易被误解的点。直接给结论:

响应头 是否必须 作用 缺了会怎样
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。

其实不是。

完整链路是:

  1. 浏览器请求 GET /
  2. 服务端返回 index.html
  3. 浏览器解析 HTML,执行 <script>
  4. 遇到 new EventSource('http://localhost:3000/stream')
  5. 浏览器发起第二个请求 GET /stream
  6. 服务端返回 text/event-stream,SSE 连接建立
  7. 服务端每秒 res.write('data: xxx\n\n')
  8. 浏览器触发 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 服务器
  • reqres
  • 流对象
  • 定时器、进程等

.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 更强,它自动处理:

  1. 数据流动:读到一块就写一块。
  2. 背压 :如果浏览器接收慢,res.write() 返回 falsepipe 会暂停读取,避免内存爆掉。
  3. 结束 :读完后自动调用 res.end()
  4. 错误传递 :虽然 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,也不能自定义请求头。

整体架构图:

五、避坑指南 / 最佳实践

  1. 不要轻易 res.end()

    除非你确定事件流已经结束。否则 EventSource 会自动重连,导致重复推送。

    如果确实要结束,前端可以调用 eventSource.close()

  2. 监听 req.on('close') 清理资源

    客户端断开时,清理定时器、取消模型调用、释放数据库连接。

    js

    javascript 复制代码
    req.on('close', () => {
      clearInterval(timer);
      // 清理其他资源
    });
  3. Nginx 反代要关闭缓冲

    Nginx 默认会缓冲响应,导致 SSE 数据不能实时到达浏览器。需要:

    nginx

    ini 复制代码
    proxy_buffering off;
    proxy_cache off;

    并加上:

    http

    yaml 复制代码
    X-Accel-Buffering: no
  4. Cache-Controlno-cacheno-store

    不要省略,否则可能被缓存。

  5. HTTP/2 下不要手动加 Connection

    HTTP/2 禁止该头,写了可能报错。

  6. EventSource 只支持 GET

    需要 POST、自定义头、body 时,用 fetch + ReadableStream 自己解析 SSE。

  7. SSE 数据格式要严格

    每个事件以空行结束。data 中有换行时,要拆成多个 data: 行:

    text

    kotlin 复制代码
    data: 第一行
    data: 第二行
  8. 错误处理

    前端加 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-streamEventSource 如何解析?

回答要点:

  • 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 是一个非常值得掌握的轻量级方案。

相关推荐
CappuccinoRose1 小时前
专项事件体系
前端·学习·网络状态·表单事件·剪贴板事件·拖放事件·页面可见性
李剑一1 小时前
各位产品经理求你们优化一下吧,暗黑模式最大的BUG!文中附解决方案
前端
huali1 小时前
vue-split-screen:让 Vue Router 的导航轨迹变成两个页面
前端·javascript·vue.js
bug总结2 小时前
uniapp总结
开发语言·前端·javascript
云运维笔记2 小时前
华为VRP系统文件管理全攻略
前端·华为
走到天涯海角2 小时前
pinia和Vuex的区别以及使用pinia
开发语言·前端·javascript
电梯界知识分子2 小时前
江西抚州临川九尊府五层别墅:受限楼梯间里,全黑铝合金观光井道配曳引龙门架的落地记录
大数据·前端·网络·算法·家用电梯
晴天162 小时前
Web 页面内存溢出与 CPU 溢出:区别、诊断与最佳实践
前端·javascript
摸鱼仙人~3 小时前
需求深度分析提示词-grill me
前端·chrome