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

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

1. 背景与现象

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

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

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

2. 罪魁祸首排查过程

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

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

或者这样的:

js 复制代码
// 错误写法 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 关了,才是真的断开了。

js 复制代码
let isClientDisconnected = false;

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

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

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

js 复制代码
// 正确姿势:使用 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() 。

js 复制代码
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() 。
相关推荐
牧艺1 分钟前
用 Next.js + React Three Fiber 打造 3D 快递仓储可视化
前端·three.js
锋行天下1 小时前
如何用Vite实现Vue组件的按需打包和远程加载
前端·vue.js·前端框架
光影少年1 小时前
原生DOM操作在React 中的注意事项
前端·javascript·react.js
禅思院3 小时前
前端部署“三层漏斗”完全指南:从CI/CD到自动回滚的工程化实战【开题】
前端·架构·前端框架
快乐肚皮4 小时前
深入理解Loop Engineering
前端·后端
风骏时光牛马4 小时前
VHDL十大经典基础功能设计实例代码合集
前端
hunterandroid4 小时前
Notification 通知:从基础到渠道适配
前端
孟陬4 小时前
Claude Code 巧思 `Ctrl+S` 暂存键
前端·后端
PedroQue995 小时前
V1.6.1性能优化:高频路径提速与代码精简
前端·uni-app
猩猩程序员5 小时前
将 LiteLLM 迁移到 Rust —— 构建最快、最轻量的 AI Gateway
前端