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

从 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 请求的对比

erlang 复制代码
普通 HTTP 请求:
客户端 ──请求──► 服务端
客户端 ◄──响应── 服务端(一次性返回,连接关闭)

SSE 流式请求:
客户端 ──请求──► 服务端
客户端 ◄──数据1── 服务端
客户端 ◄──数据2── 服务端
客户端 ◄──数据3── 服务端
...
客户端 ◄──结束── 服务端(连接关闭)

核心特点

  • 单向通信:服务端 → 客户端(如果需要双向,用 WebSocket)
  • 基于 HTTP:不需要特殊协议,复用现有基础设施
  • 长连接:一个请求保持打开,直到服务端主动关闭

三、服务端实现:其实很简单

SSE 服务端的核心就三步:

typescript 复制代码
// 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 消息格式

vbnet 复制代码
event: message
data: {"content": "你好"}

event: done
data: {"content": "", "done": true}

每条消息由 event(可选)和 data 组成,消息之间用 \n\n 分隔。

Express 完整示例

typescript 复制代码
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 对象,允许我们边接收边处理。

核心代码

typescript 复制代码
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。

css 复制代码
时间轴:
────────────────────────────────────────────────────────►

前端:       await read()     await read()     await read()
                 │ 挂起等待...    │ 挂起等待...    │
                 ▼                ▼                ▼
服务端:   ──● res.write() ──● res.write() ──● res.end()

五、接入真实 LLM API

如果要接入 OpenAI、Claude 等真实 AI 服务,你的服务端需要:

  1. 接收 三方 API 的 SSE 响应
  2. 转发 给前端
scss 复制代码
前端 ◄──(SSE)── 你的服务端 ◄──(SSE)── LLM API

OpenAI 示例

typescript 复制代码
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)。

makefile 复制代码
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 翻红

核心认知

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

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


相关推荐
鞋带松了2 小时前
openclaw + ollama本地模型 + 飞书平台 windows平台部署教程
人工智能
dobym2 小时前
里程碑五:Elpis框架npm包抽象封装并发布
前端
全栈老石2 小时前
手写无限画布4 —— 从视觉图元到元数据对象
前端·javascript·canvas
牛奶2 小时前
React 底层原理 & 新特性
前端·react.js·面试
啥都学点的程序员2 小时前
关于langchain调用MCP确保稳定性的小经验
人工智能
parade岁月2 小时前
Tailwind CSS v4 — 当框架猜不透你的心思
前端·css
小明9132 小时前
基于Rokid CXR-M SDK的AI饮食健康助手开发实战
前端
一枚前端小姐姐2 小时前
低代码平台表单设计系统技术分析(实战三)
前端·vue.js·低代码
牛奶2 小时前
ts随笔:面向对象与高级类型
前端·面试·typescript