我彻底搞懂了 SSE,原来流式响应效果还能这么玩的?(附 JS/Dart 双端实战)

前言

大家好,我是【小林】

说起来有点意思,最近我在做 AI 项目的时候,突然对 "流式响应效果" 产生了浓厚兴趣也就是所谓的打字机效果。你知道那种感觉吧,AI 回答的时候,文字像被人敲出来一样,一个字一个字地蹦出来。

以前我以为是前端用 setTimeout 模拟的,直到有一次网络抖动,我发现它居然能从断开的地方继续输出,而不是重新开始。这就像看直播卡顿后,会自动从卡住的地方继续播放,而不是重播一遍。

这不就是断点续传吗?但 HTTP 请求不是无状态的吗?

带着这个疑问,我开始深入研究,才发现这背后藏着一套完整的流式传输协议------SSE(Server-Sent Events)

更让我意外的是,我发现业界对于"AI 流式响应该用 SSE 还是 WebSocket"这个话题,争议还挺大。有人说 WebSocket 功能更强大,有人说 SSE 更简单。

到底该选哪个?

我干脆从零开始实现了一套完整的 Demo,包含后端服务、JavaScript 客户端、Dart 客户端,甚至还实现了断线重连、指数退避、粘包处理等生产级特性。

这篇文章,我就把这背后的原理、坑点、实战经验分享出来。


篇章一:为什么 AI 聊天首选 SSE 而非 WebSocket?

在讲代码之前,我们先搞清楚一个核心问题:为什么 ChatGPT、Claude 这些 AI 助手都选 SSE,而不是看起来更强大的 WebSocket?

1.1 场景分析:AI 对话的"一问多答"模式

我们先看 AI 对话的典型特征:

复制代码
用户:如何学好 Flutter?
AI:  【开始一段一段地输出,持续十几秒甚至更长】

这就是典型的**"一问多答"模式**:

  • 用户发送的 Prompt 通常很短(几个字到几百字)
  • AI 的回复可能很长(几千字,甚至更长)
  • 数据流向是单向的:Server → Client

1.2 SSE vs WebSocket 核心对比

我们用一个表格来看两者的差异:

特性 SSE WebSocket
通信方向 单工(Server → Client) 全双工(双向通信)
协议基础 HTTP 标准 自定义 WS 协议
连接方式 标准 HTTP 请求 需要握手升级
鉴权方式 ✅ 自定义 Header(如 Authorization) ❌ 只能在握手时带 Header
断线重连 ✅ 内置 Last-Event-ID 机制 ❌ 需要手动实现
浏览器调试 ✅ DevTools 直接查看 EventStream ⚠️ 需要在 WS Frames 面板查看
服务端实现 ✅ 简单,标准 HTTP 响应 ⚠️ 需要维护连接状态
AI 场景契合度 ✅ 完美匹配"一问多答" ❌ 过度设计

1.3 一个餐厅大厨的比喻

让我用一个好懂的比喻来解释:

SSE 就像"自助餐厅的传菜口"

  • 你点完菜(发送 HTTP 请求)
  • 厨师开始炒菜,炒好一道就传出来一道(Server 持续推送数据)
  • 你坐在那里等,菜一道一道地上来(Client 接收流式数据)
  • 如果突然停电了,来电后厨师会问你:"刚才上到第几道了?"然后继续上(断线重连)

WebSocket 就像"打电话订外卖"

  • 你和骑手保持通话(双向通信通道)
  • 骑手一边送一边向你汇报位置(实时双向交互)
  • 如果电话断了,你得重新打过去,还得从头说(需要手动重连)

对于 AI 聊天这种"我点菜,你上菜"的场景,SSE 的传菜口模式显然更合适。WebSocket 更适合"我和骑手实时沟通位置"这种需要频繁交互的场景。

1.4 为什么不用原生 EventSource?

看到这里你可能会问:浏览器不是有原生 EventSource API 吗?为什么还要自己实现?

问题在于,原生 EventSource 有几个致命限制:

javascript 复制代码
// 原生 EventSource 的问题
const eventSource = new EventSource('/stream');  // ❌ 只支持 GET

// ❌ 无法自定义 Header(比如 Authorization)
// ❌ 无法发送请求体(AI 场景的 Prompt 可能很长)
// ❌ 只能在 URL 里传参数,不安全也不优雅

在 AI 场景下,我们需要:

  • POST 请求发送长 Prompt
  • 在 Header 里带 Authorization Token
  • 支持自定义错误处理和重连策略

所以,我们需要基于 fetch + ReadableStream 自己实现一个 SSEManager。


篇章二:直击底层:SSE 协议原理剖析

2.1 SSE 协议格式

SSE 是基于 HTTP 的,协议格式非常简单:

vbnet 复制代码
event: message
id: 1234567890
data: {"type": "content", "payload": "我"}

event: message
id: 1234567891
data: {"type": "content", "payload": "喜"}

event: close
data: [DONE]

协议要点

  • 每条消息由 event:id:data: 三个字段组成
  • 字段顺序不重要,但每条消息后必须有一个空行作为分隔符
  • event: 表示事件类型(message、error、close 等)
  • id: 用于断线重连时恢复(客户端会记录 Last-Event-ID)
  • data: 是实际数据,通常是 JSON 字符串

2.2 核心挑战:粘包和半包问题

这是 SSE 实现中最容易踩的坑。

什么是粘包?

vbnet 复制代码
服务器一次发送:
event: message\ndata: {"type":"content","payload":"我"}\n\nevent: message\ndata: {"type":"content","payload":"喜"}\n\n

客户端可能收到:
event: message
data: {"type":"content","payload":"我"}
event: message    ← 两条消息粘在一起了
data: {"type":"content","payload":"喜"}

什么是半包?

csharp 复制代码
服务器发送一条完整消息:
event: message\ndata: {"type":"content","payload":"我是中文"}\n\n

客户端可能分两次收到:
第一次:event: message\ndata: {"type":"content","payload": "我
第二次:是中文"}\n\n                              ← JSON 被截断了!

解决方案 : 维护一个 buffer 缓冲区,每次收到 chunk 后:

  1. 追加到 buffer
  2. \n\n 分割出完整消息
  3. 剩下的部分留在 buffer,等下次 chunk 到来

篇章三:实战实现

3.1 后端实现(Node.js + Express)

先看后端怎么实现 SSE 接口:

javascript 复制代码
app.get('/stream-sse', async (req, res) => {
  // 设置 SSE 必需的 HTTP Headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache, no-transform');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // 禁用 Nginx 缓冲

  const query = req.query.query || '默认问题';
  const text = '这是 AI 的回复内容...';
  const chars = text.split('');

  // 逐字发送
  for (let i = 0; i < chars.length; i++) {
    const message = `event: message\nid: ${Date.now()}\ndata: ${JSON.stringify({
      type: 'content',
      payload: chars[i],
      index: i,
      total: chars.length
    })}\n\n`;

    res.write(message);

    // 模拟 AI 生成延迟(打字机效果)
    await new Promise(resolve => setTimeout(resolve, 100));
  }

  // 发送完成信号
  res.write('event: close\ndata: [DONE]\n\n');
  res.end();
});

关键点

  • Content-Type: text/event-stream 告诉浏览器这是 SSE 流
  • Cache-Control: no-cache 禁止缓存,确保实时性
  • Connection: keep-alive 保持长连接
  • 逐字符发送,模拟 AI 打字机效果
  • 最后发送 [DONE] 信号告诉客户端流结束了

3.2 JavaScript 客户端:手写 SSEManager

这是核心部分。我们基于 fetch + ReadableStream 实现一个完整的 SSEManager:

javascript 复制代码
class SSEManager {
  constructor(url, options = {}) {
    this.url = url;
    this.options = {
      headers: {},
      body: null,
      maxRetries: 5,
      initialRetryDelay: 1000,
      enableRetry: true,
      ...options
    };

    this.onMessageCallback = null;
    this.onErrorCallback = null;
    this.onCompleteCallback = null;

    this.abortController = null;
    this.retryCount = 0;
    this.lastEventId = null;
    this.isConnecting = false;
  }

  async connect() {
    if (this.isConnecting) return;

    this.isConnecting = true;
    this.abortController = new AbortController();

    try {
      const fetchOptions = {
        method: this.options.body ? 'POST' : 'GET',
        headers: {
          'Content-Type': 'application/json',
          ...this.options.headers
        },
        signal: this.abortController.signal
      };

      if (this.options.body) {
        fetchOptions.body = JSON.stringify(this.options.body);
      }

      // 如果有 Last-Event-ID,带上(用于断线重连)
      if (this.lastEventId) {
        fetchOptions.headers['Last-Event-ID'] = this.lastEventId;
      }

      const response = await fetch(this.url, fetchOptions);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');

      // 🔥 关键:消息缓冲区(处理粘包和半包)
      let buffer = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });
        buffer += chunk;

        // 解析缓冲区中的完整消息
        buffer = this._parseBuffer(buffer);
      }

      // 如果不是主动断开,尝试重连
      if (this.options.enableRetry && !this.abortController.signal.aborted) {
        this._scheduleRetry();
      }

    } catch (error) {
      if (error.name === 'AbortError') return;

      if (this.onErrorCallback) {
        this.onErrorCallback(error);
      }

      if (this.options.enableRetry && this.retryCount < this.options.maxRetries) {
        this._scheduleRetry();
      }
    } finally {
      this.isConnecting = false;
    }
  }

  // 🔥 核心方法:解析缓冲区中的 SSE 消息
  _parseBuffer(buffer) {
    const lines = buffer.split('\n');
    let currentEvent = { event: null, id: null, data: null };

    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];

      // 空行表示一条消息结束
      if (line === '') {
        if (currentEvent.data !== null) {
          this._handleEvent(currentEvent);
          currentEvent = { event: null, id: null, data: null };
        }
        continue;
      }

      // 解析字段
      if (line.startsWith('event:')) {
        currentEvent.event = line.substring(7).trim();
      } else if (line.startsWith('id:')) {
        currentEvent.id = line.substring(4).trim();
        this.lastEventId = currentEvent.id;
      } else if (line.startsWith('data:')) {
        currentEvent.data = line.substring(6).trim();
      }
    }

    // 返回未处理的缓冲区(最后一条不完整的消息)
    return lines[lines.length - 1] === '' ? '' : lines[lines.length - 1];
  }

  _handleEvent(event) {
    if (event.event === 'close') {
      if (this.onCompleteCallback) this.onCompleteCallback();
      return;
    }

    if (event.event === 'error') {
      if (this.onErrorCallback) {
        this.onErrorCallback(new Error(event.data));
      }
      return;
    }

    if (this.onMessageCallback) {
      try {
        const data = JSON.parse(event.data);
        this.onMessageCallback(data);
      } catch (e) {
        console.error('Failed to parse SSE data:', e);
      }
    }
  }

  // 🔥 指数退避重连算法
  _scheduleRetry() {
    this.retryCount++;
    const delay = Math.min(
      this.options.initialRetryDelay * Math.pow(2, this.retryCount - 1),
      30000 // 最大 30 秒
    );

    console.log(`[SSE] Retry ${this.retryCount}/${this.options.maxRetries} after ${delay}ms`);

    setTimeout(() => {
      this.connect();
    }, delay);
  }

  onMessage(callback) {
    this.onMessageCallback = callback;
    return this;
  }

  onError(callback) {
    this.onErrorCallback = callback;
    return this;
  }

  onComplete(callback) {
    this.onCompleteCallback = callback;
    return this;
  }

  disconnect() {
    if (this.abortController) {
      this.abortController.abort();
    }
    this.isConnecting = false;
  }
}

使用示例

javascript 复制代码
const sse = new SSEManager('http://localhost:3000/stream-sse', {
  body: { query: '如何学好 Flutter?' },
  headers: { 'Authorization': 'Bearer token123' },
  enableRetry: true,
  maxRetries: 5
});

sse.onMessage((data) => {
  console.log('收到数据:', data.payload);
  // 逐字显示到界面上
})
.onError((error) => {
  console.error('发生错误:', error);
})
.onComplete(() => {
  console.log('传输完成');
})
.connect();

3.3 Dart 客户端:UTF-8 安全处理

Dart 端有个特殊问题:中文字符的 UTF-8 编码问题

中文字符在 UTF-8 中占 3 个字节,如果流正好把一个字符的 3 个字节截断了,就会出现乱码。

解决方案 :使用 utf8.decoder + LineSplitter() 的流转换链:

dart 复制代码
class SSEManager {
  final String url;
  final Map<String, String> headers;
  final Map<String, dynamic> body;

  int _retryCount = 0;
  String? _lastEventId;
  bool _isConnecting = false;

  SSEManager({
    required this.url,
    this.headers = const {},
    this.body = const {},
  });

  Future<void> connect() async {
    if (_isConnecting) return;
    _isConnecting = true;

    try {
      final client = HttpClient();
      final request = await client.postUrl(Uri.parse(url));

      // 设置 Headers
      headers.forEach((key, value) {
        request.headers.set(key, value);
      });

      if (_lastEventId != null) {
        request.headers.set('Last-Event-ID', _lastEventId!);
      }

      // 设置 Body
      if (body.isNotEmpty) {
        request.add(utf8.encode(jsonEncode(body)));
      }

      final response = await request.close();

      // 🔥 核心流转换链
      response
        .transform(utf8.decoder)      // ByteStream → String
        .transform(const LineSplitter()) // String → Lines
        .listen(_parseLine);

    } catch (e) {
      _scheduleRetry();
    } finally {
      _isConnecting = false;
    }
  }

  void _parseLine(String line) {
    // 解析 SSE 协议...
    // (类似 JS 版本的逻辑)
  }

  void _scheduleRetry() {
    _retryCount++;
    final delay = min(1000 * pow(2, _retryCount - 1), 30000).toInt();

    Future.delayed(Duration(milliseconds: delay), () {
      connect();
    });
  }
}

3.4 实际运行效果

让我们看看实际运行的效果:

传输中状态

  • AI 响应区域逐字显示
  • 系统日志实时滚动
  • 性能指标动态更新

连接错误

  • 当服务器未启动时,显示红色错误提示
  • 自动触发重连机制

重试中状态

  • 显示当前重试次数和延迟时间
  • 使用指数退避算法(1s → 2s → 4s → 8s...)

传输完成

  • 显示完整的输出内容
  • 性能指标:总字数、总耗时、平均延迟

篇章四:踩坑总结

做这个 Demo 的过程中,我踩了不少坑。这里挑几个最经典的分享给你。

4.1 粘包/半包处理

:一开始我直接用 split('\n\n') 分割消息,结果经常出现 JSON 解析错误。

原因:一个 chunk 可能包含半个 JSON,或者两条消息粘在一起。

解决:维护 buffer,每次解析后把剩余部分留给下次:

javascript 复制代码
let buffer = '';
buffer += chunk;           // 追加新数据
const messages = buffer.split('\n\n');
buffer = messages.pop();   // 保留最后一个(可能不完整)
// 处理前面的完整消息
messages.forEach(msg => parseMessage(msg));

4.2 UTF-8 字符截断

:Dart 端经常出现乱码,特别是中文字符。

原因:中文字符在 UTF-8 中占 3 字节,流可能把 3 字节截断。

解决 :使用 utf8.decoder 自动处理字节边界:

dart 复制代码
response
  .transform(utf8.decoder)      // ✅ 自动处理 UTF-8 边界
  .transform(const LineSplitter())
  .listen(_parseLine);

4.3 重连时机判断

:服务器正常结束时也触发重连,导致死循环。

原因:没区分"正常结束"和"异常断开"。

解决 :检查 [DONE] 信号:

javascript 复制代码
_handleEvent(event) {
  if (event.event === 'close' && event.data === '[DONE]') {
    // 正常结束,不重连
    this.onCompleteCallback();
    return;
  }
  // ... 其他处理
}

// 在流关闭时判断
while (true) {
  const { done, value } = await reader.read();
  if (done) {
    // 如果收到了 [DONE],说明正常结束
    if (receivedDoneSignal) return;
    // 否则可能是异常断开,触发重连
    this._scheduleRetry();
    break;
  }
}

4.4 Nginx 缓冲问题

:部署到生产环境后,SSE 流半天不输出。

原因:Nginx 默认会缓冲响应,等积累到一定大小才发送。

解决 :在响应头添加 X-Accel-Buffering: no

javascript 复制代码
res.setHeader('X-Accel-Buffering', 'no');

或者在 Nginx 配置中:

nginx 复制代码
proxy_buffering off;

最终章:总结与展望

5.1 技术选型建议

什么时候用 SSE?

  • ✅ AI 聊天助手(一问多答)
  • ✅ 实时通知推送
  • ✅ 股票/加密货币价格推送
  • ✅ 服务器日志实时监控

什么时候用 WebSocket?

  • ✅ 即时通讯(IM、聊天室)
  • ✅ 在线协作(多人同时编辑)
  • ✅ 游戏直播(需要高频双向交互)
  • ✅ 远程桌面/控制

5.2 本项目的核心特性

我实现的这个 Demo,包含了以下生产级特性:

  • ✅ 支持 POST 请求(可以发送长 Prompt)
  • ✅ 自定义 Header(支持 Authorization)
  • ✅ 粘包/半包处理(buffer 缓冲区)
  • ✅ 指数退避重连(1s → 2s → 4s → 8s...)
  • ✅ Last-Event-ID 机制(断线续传)
  • ✅ UTF-8 安全处理(Dart 端)
  • ✅ 错误处理和日志

5.3 开源地址

项目已完全开源,欢迎 Star 和 PR:

🔗 GitHub : github.com/xinqingaa/s...

包含:

  • Node.js 后端(Express)
  • JavaScript 客户端(原生 JS,无依赖)
  • Dart 客户端(Flutter 友好)
  • 交互式演示界面

5.4 写在最后

回看这一周的学习,我发现:

技术选型没有银弹。SSE 不是万能的,WebSocket 也不是过时的。关键是要理解你的场景需求。

对于 AI 聊天这种"一问多答"的单向流式场景,SSE 就像量身定做的一样:

  • 简单(基于 HTTP)
  • 可靠(内置重连)
  • 高效(没有全双工的开销)
  • 可调试(DevTools 直接看)

而 WebSocket 的强大在于双向实时交互,但这在 AI 聊天场景下是"杀鸡用牛刀"。

最后,如果这篇文章对你有帮助,点个赞吧~

(完)


往期文章回顾

LangGraph + React + Nest 全栈Agent

掘金文章 | github

Flutter 图片编辑器

掘金文章 | pub.dev | github

Flutter 全链路监控 SDK

掘金文章 | pub.dev | github

Flutter 全场景弹框组件

掘金文章 | pub.dev | github


关于作者

大家好,我是【小林】,一名 Flutter 开发工程师。近期在研究 AI Agent 和流式传输技术,欢迎关注我的掘金账号,获取更多技术分享。

相关推荐
不倒翁玩偶7 小时前
npm : 无法将“npm”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。请检查名称的拼写,如果包括路径,请确保路径正确,然后再试一次。
前端·npm·node.js
奔跑的web.7 小时前
UniApp 路由导航守
前端·javascript·uni-app
EchoEcho7 小时前
记录overflow:hidden和scrollIntoView导致的页面问题
前端·css
Cache技术分享7 小时前
318. Java Stream API - 深入理解 Java Stream 的中间 Collector —— mapping、filtering 和 fla
前端·后端
竟未曾年少轻狂7 小时前
Vue3 生命周期钩子
前端·javascript·vue.js·前端框架·生命周期
TT哇7 小时前
【实习】数字营销系统 银行经理端(interact_bank)前端 Vue 移动端页面的 UI 重构与优化
java·前端·vue.js·ui
蓝帆傲亦7 小时前
Web前端跨浏览器兼容性完全指南:构建无缝用户体验的最佳实践
前端
晴殇i7 小时前
【前端缓存】localStorage 是同步还是异步的?为什么?
前端·面试