从 AI 对话应用理解 SSE 流式传输:一项 "老技术" 的新生

45 阅读4分钟

从 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 服务,你的服务端需要:

  1. 接收 三方 API 的 SSE 响应
  2. 转发 给前端
前端 ◄──(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.jsresponse.on('data', callback)
Dart/Flutterresponse.stream.listen()
Goio.Reader
Pythonresponse.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 翻红

核心认知

技术本身没变,场景变了。很多 "新技术" 只是老技术 + 新包装。

学技术时,理解底层原理比追逐新概念更重要——因为原理不变,概念会反复翻新。