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;
});
为什么会翻车?
- req.closed 的假阳性 :在 Express 5 及某些 Node.js 版本中, req.closed 属性在长连接流式响应下非常不可靠。它有时会在请求体(Body)被解析完成或者发送了第一批响应头后, 错误地将状态标记为 true ,导致后端误杀正常的请求。
- req.on('close') 提前触发 :同理,HTTP 层的 close 事件有时代表的是“请求接收完毕”,而不是“连接彻底断开”。
- 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 中做大模型流式输出时:
- 监听断开请认准 req.socket.on('close') 。
- Header 里带上 X-Accel-Buffering: no 。
- 相信 Node.js 的流管理,不要乱用 flush() 。