从 AI 对话应用理解 SSE 流式传输:一项 "老技术" 的新生
最近在开发一个类似 ChatGPT 的 AI 对话应用,深入学习了 SSE(Server-Sent Events)流式传输技术。本文记录我的学习过程和理解,希望对你有帮助。
一、为什么 AI 应用需要流式传输?
如果你用过 ChatGPT、Claude 等 AI 对话产品,一定注意到它们的回复是逐字显示的,而不是等待几十秒后一次性显示完整答案。
这种体验差异巨大:
| 方式 | 用户体验 |
|---|---|
| 普通接口 | 发送消息 → 等待 10-30 秒 → 一次性显示完整回答 😴 |
| 流式接口 | 发送消息 → 0.5 秒后开始显示 → 逐字输出 → 完成 🤩 |
同样的等待时间,流式输出让用户感觉 AI "在思考",而非 "卡死了"。
这背后的技术就是 SSE(Server-Sent Events)。
二、SSE 是什么?
一句话定义
SSE 就是在一次 HTTP 请求会话结束前,服务端多次向客户端推送数据。
与普通 HTTP 请求的对比
普通 HTTP 请求:
客户端 ──请求──► 服务端
客户端 ◄──响应── 服务端(一次性返回,连接关闭)
SSE 流式请求:
客户端 ──请求──► 服务端
客户端 ◄──数据1── 服务端
客户端 ◄──数据2── 服务端
客户端 ◄──数据3── 服务端
...
客户端 ◄──结束── 服务端(连接关闭)
核心特点
- 单向通信:服务端 → 客户端(如果需要双向,用 WebSocket)
- 基于 HTTP:不需要特殊协议,复用现有基础设施
- 长连接:一个请求保持打开,直到服务端主动关闭
三、服务端实现:其实很简单
SSE 服务端的核心就三步:
// 1. 设置 SSE 响应头
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// 2. 多次写入数据(不关闭连接)
res.write("data: 第1块数据\n\n");
res.write("data: 第2块数据\n\n");
res.write("data: 第3块数据\n\n");
// 3. 结束连接
res.end();
SSE 消息格式
event: message
data: {"content": "你好"}
event: done
data: {"content": "", "done": true}
每条消息由 event(可选)和 data 组成,消息之间用 \n\n 分隔。
Express 完整示例
import express from "express";
const app = express();
app.post("/api/chat/stream", async (req, res) => {
const { message } = req.body;
// 1. 设置 SSE 响应头
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// 2. 模拟逐字输出
const reply = `你好!你说的是:"${message}",这是一个流式响应示例。`;
for (const char of reply) {
// 每个字符作为一条消息发送
res.write(`data: ${JSON.stringify({ content: char })}\n\n`);
// 模拟打字延迟
await new Promise((resolve) => setTimeout(resolve, 50));
}
// 3. 发送结束标记
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
});
app.listen(3001);
四、客户端实现:理解 ReadableStream
浏览器如何感知流式响应?
当浏览器收到响应头 Content-Type: text/event-stream 时,会将响应体包装为一个 ReadableStream 对象,允许我们边接收边处理。
核心代码
async function fetchSSE(message: string) {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
// response.body 是一个 ReadableStream
const reader = response.body.getReader();
const decoder = new TextDecoder();
// 循环读取,直到流结束
while (true) {
const { done, value } = await reader.read();
if (done) break; // 流结束
// 解码并处理数据
const text = decoder.decode(value);
console.log("收到:", text);
}
}
关键问题:await reader.read() 会阻塞吗?
这是我学习时的一个疑惑:while (true) 循环不会卡死吗?
答案是:不会!
reader.read() 是一个 Promise,它会:
- 有数据时:立即返回
{ done: false, value: ... } - 没数据时:挂起等待,直到服务端发送数据
- 连接关闭时:返回
{ done: true }
这是异步等待,不是忙轮询,不会占用 CPU。
时间轴:
────────────────────────────────────────────────────────►
前端: await read() await read() await read()
│ 挂起等待... │ 挂起等待... │
▼ ▼ ▼
服务端: ──● res.write() ──● res.write() ──● res.end()
五、接入真实 LLM API
如果要接入 OpenAI、Claude 等真实 AI 服务,你的服务端需要:
- 接收 三方 API 的 SSE 响应
- 转发 给前端
前端 ◄──(SSE)── 你的服务端 ◄──(SSE)── LLM API
OpenAI 示例
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: "sk-xxx" });
app.post("/api/chat/stream", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
// 调用 OpenAI,开启流式模式
const stream = await openai.chat.completions.create({
model: "gpt-4",
messages: [{ role: "user", content: req.body.message }],
stream: true, // 关键:开启流式
});
// 遍历流式响应,逐个转发
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
}
res.end();
});
本质就是:LLM 给你一滴水,你就往前端倒一滴。
六、不同环境的流式处理
SSE 不是浏览器专属,任何支持 HTTP 的环境都能实现:
| 环境 | 流式 API |
|---|---|
| 浏览器 | response.body.getReader() |
| Node.js | response.on('data', callback) |
| Dart/Flutter | response.stream.listen() |
| Go | io.Reader |
| Python | response.iter_content() |
原理都一样:收到一块数据 → 处理一块 → 等待下一块 → 直到结束
七、一项 25 年前的 "老技术"
SSE 背后的核心技术——HTTP 分块传输(Chunked Transfer Encoding)——早在 1999 年 就被纳入 HTTP/1.1 标准(RFC 2616)。
HTTP/1.1 响应头:
Transfer-Encoding: chunked ← 告诉客户端这是分块传输
这不是什么新发明,而是一项 20+ 年的成熟技术,只是 AI 时代让它重新成为焦点。
AI 之前的应用场景
- 大文件下载:边读边发,不用先加载到内存
- 动态网页:边生成边返回,用户先看到框架
- 实时日志:
tail -f式的持续输出 - 股票行情:实时推送价格变动
你打开任意一个网站,在开发者工具中大概率能看到 Transfer-Encoding: chunked——这技术一直在默默工作。
八、常见误区澄清
误区 1:分片上传也是 HTTP Chunked
错! 前端大文件分片上传是应用层方案,多个 HTTP 请求,每个传一片。
HTTP Chunked 是协议层功能,一个请求内分块传输。
| 对比 | HTTP Chunked | 分片上传 |
|---|---|---|
| 方向 | 服务端 → 客户端 | 客户端 → 服务端 |
| 请求数 | 1 个 | 多个 |
| 谁来分块 | 协议自动处理 | 前端 JS 手动切分 |
误区 2:SSE 是新技术
错! SSE 规范(EventSource API)2006 年就有了,底层的 Chunked 更是 1999 年的标准。
AI 只是给老技术找到了新的杀手级应用场景。
九、总结
| 概念 | 一句话解释 |
|---|---|
| SSE | 一次请求内,服务端多次推送数据 |
| 服务端 | res.write() 多次,res.end() 结束 |
| 客户端 | reader.read() 循环,done 判断结束 |
| await read() | 异步等待,有数据才返回,不是忙轮询 |
| 底层原理 | HTTP/1.1 Transfer-Encoding: chunked |
| 历史 | 1999 年标准,2023 年因 AI 翻红 |
核心认知
技术本身没变,场景变了。很多 "新技术" 只是老技术 + 新包装。
学技术时,理解底层原理比追逐新概念更重要——因为原理不变,概念会反复翻新。