Node.js/Express 实现 AI 流式输出 (SSE) 踩的坑:为什么客户端会“瞬间断开连接”?

28 阅读1分钟

Node.js/Express 实现 AI 流式输出 (SSE) 的深坑:为什么客户端会“瞬间断开连接”?

1. 背景与现象

最近在做一个基于 Node.js 和 Express 的 AI 爆款文案生成器(接入了类似 OpenAI/硅基流动的 API)。为了实现打字机效果,我使用了 Server-Sent Events (SSE) 技术将 AI 的返回结果流式推送到前端。

然而,在测试时遇到了一个极其诡异的 Bug:

  • 后端日志 :每次收到请求后,刚准备推送数据,瞬间就打印出 客户端提前断开连接,终止 AI 请求 。
  • 前端现象 :浏览器 Network 里的请求瞬间变成 Canceled (取消),或者终端用 curl 测试时直接卡死(Hanging),收不到任何数据。
  • 迷惑性 :一开始以为是前端组件刷新导致请求中断,或者网络代理拦截,排查了一圈发现都不是。

2. 罪魁祸首排查过程

问题出在后端代码里判断“客户端是否断开”的逻辑上。在处理长连接时,我们通常需要在客户端断开时停止向外推流,以节省服务器资源。最初的代码是这样的:

// 错误写法 1:依赖 Express 的 req.
closed 属性
for await (const chunk of stream) {
  if (req.closed) { 
    console.log('客户端提前断开');
    break; 
  }
  res.write(`data: ${content}\n\n`);
  res.flush?.(); // 强制刷新
}

或者这样的:

// 错误写法 2:依赖 HTTP 请求的 close 事件
req.on('close', () => {
  console.log('客户端断开');
  isDisconnected = true;
});

为什么会翻车?

  1. req.closed 的假阳性 :在 Express 5 及某些 Node.js 版本中, req.closed 属性在长连接流式响应下非常不可靠。它有时会在请求体(Body)被解析完成或者发送了第一批响应头后, 错误地将状态标记为 true ,导致后端误杀正常的请求。
  2. req.on('close') 提前触发 :同理,HTTP 层的 close 事件有时代表的是“请求接收完毕”,而不是“连接彻底断开”。
  3. res.flush() 杀手 :在没有正确引入压缩中间件的情况下,盲目调用 res.flush() 会破坏底层的 chunked 数据流状态,甚至直接导致底层 Socket 异常关闭。

3. 终极解决指南(正确姿势)

为了完美实现 SSE 并准确监听客户端断开,需要做以下三个关键的调整:

关键点一:放弃 req.closed,监听底层 TCP Socket

不要监听 HTTP 请求层的 close ,而是直接监听最底层的网络 Socket。只有 Socket 关了,才是真的断开了。

let isClientDisconnected = false;

// 正确姿势:监听底层的 socket 断开
req.socket.on('close', () => {
  isClientDisconnected = true;
  console.log('底层 socket 真实断开');
});

关键点二:一次性规范地设置 SSE Header

使用 res.writeHead 一次性下发所有头部,并务必加上 X-Accel-Buffering: no ,这能防止 Nginx 等反向代理层因为缓冲而导致数据卡顿。

// 正确姿势:使用 writeHead 并禁用代理缓冲
res.writeHead(200, {
  'Content-Type': 'text/event-stream; charset=utf-8',
  'Cache-Control': 'no-cache, no-transform',
  'Connection': 'keep-alive',
  'X-Accel-Buffering': 'no' 
});

关键点三:移除所有手动的 res.flush()

Node.js 只要设置了正确的流式头部,在调用 res.write() 时底层会自动处理数据分块传输(Chunked Encoding),不需要、也不应该再手动调用 res.flush() 。

for await (const chunk of stream) {
  if (isClientDisconnected) break;
  
  const content = chunk.choices[0]?.delta?.content || '';
  if (content) {
    res.write(`data: ${JSON.stringify({ text: content })}\n\n`);
    // 删掉这行:(res as any).flush();
  }
}

4. 总结

在 Node.js 中做大模型流式输出时:

  1. 监听断开请认准 req.socket.on('close') 。
  2. Header 里带上 X-Accel-Buffering: no 。
  3. 相信 Node.js 的流管理,不要乱用 flush() 。