做AI对话产品的人几乎都绕不开一个问题:模型逐字输出的效果,到底是用SSE推送还是用WebSocket长连接?有人觉得WebSocket功能更强就无脑选它,也有人发现主流大模型API(比如OpenAI的Chat接口)清一色用的都是SSE,又搞不清原因。这两种方案各有明确的适用边界,选错了不仅浪费服务器资源,还可能踩上一堆代理转发、断线重连的坑。这篇文章把两者的协议原理、实现代码和选型逻辑讲清楚。

一、先搞清楚两者的协议本质差异
SSE(Server-Sent Events)本质上还是一个普通的HTTP响应,只是响应头里带了Content-Type: text/event-stream,并且连接不主动关闭,服务端可以持续往响应体里写数据。它是严格单向的:只能服务端推给客户端,客户端想发消息就得另起一个普通的HTTP请求。而WebSocket是在HTTP握手之后通过Upgrade头升级成的一个独立协议,升级完成后连接变成全双工的TCP通道,双方都可以随时主动发数据,不再受HTTP请求-响应模型的约束。
这个本质差异决定了两者的能力边界。SSE走的是标准HTTP端口(80/443),所以经过Nginx反向代理、CDN、企业防火墙时基本不会出问题,只需要注意关闭代理的响应缓冲即可。WebSocket虽然也走80/443端口,但握手阶段的Upgrade请求经常被一些老旧的代理或者未正确配置的Nginx拦下来,报出奇怪的错误。另外,浏览器的EventSource API原生支持断线自动重连和Last-Event-ID续传,WebSocket断了就得自己写重连逻辑和消息补发机制。
还有一个容易被忽视的点:连接数限制。基于HTTP/1.1的SSE,浏览器对同一域名只允许6个并发连接,如果用户开了多个标签页同时接收流式输出,就可能占满连接池导致页面其他请求卡死。不过只要服务端启用了HTTP/2,多个SSE连接会复用同一条TCP连接,这个问题就自然消失了。WebSocket没有这个限制,每个连接都是独立的。
二、各自怎么实现:服务端与前端完整代码
1. SSE的服务端与浏览器端实现
SSE的数据格式非常简单,每条消息由若干行组成,字段之间用空行分隔,data:后面是消息体。下面用Node.js演示一个模拟大模型逐字输出的服务端:
// Node.js 原生实现 SSE
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
const answer = '你好,我是AI助手,这是一段流式输出的回答。';
let i = 0;
const timer = setInterval(() => {
if (i < answer.length) {
// 每次推送一个字符,data:开头,双换行结束
res.write(`data: ${JSON.stringify({ token: answer[i] })}\n\n`);
i++;
} else {
// 发送结束标记后关闭连接
res.write('data: [DONE]\n\n');
res.end();
clearInterval(timer);
}
}, 50);
// 客户端断开时清理定时器
req.on('close', () => clearInterval(timer));
}).listen(3000);浏览器端用原生的EventSource就够了,不需要引入任何库:
const es = new EventSource('http://127.0.0.1:3000/chat');
es.onmessage = (e) => {
if (e.data === '[DONE]') {
es.close(); // 输出完成,主动关闭
return;
}
const { token } = JSON.parse(e.data);
document.getElementById('output').textContent += token;
};
es.onerror = () => {
// EventSource 会自动重连,这里可以更新界面提示
console.log('连接中断,正在自动重连...');
};如果需要携带自定义请求头(比如鉴权的Authorization),原生EventSource做不到,可以改用fetch配合ReadableStream读取,这也是目前很多AI产品前端的做法,因为fetch还能在需要时用AbortController随时中断生成。
2. WebSocket的实现
WebSocket适合的是多轮交互、语音输入这种客户端也要高频发消息的场景。以Python的websockets库为例:
import asyncio, json, websockets
async def handle(ws):
# 先接收用户的问题(客户端可以随时发)
async for message in ws:
question = json.loads(message)['text']
answer = f'关于{question}的回答,逐字返回给你。'
for ch in answer:
await ws.send(json.dumps({'token': ch}, ensure_ascii=False))
await asyncio.sleep(0.05)
await ws.send(json.dumps({'done': True}))
async def main():
async with websockets.serve(handle, '0.0.0.0', 8765):
await asyncio.Future() # 永久运行
asyncio.run(main())前端对应代码:
const ws = new WebSocket('ws://127.0.0.1:8765');
ws.onopen = () => ws.send(JSON.stringify({ text: '什么是SSE' }));
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.done) { ws.close(); return; }
document.getElementById('output').textContent += data.token;
};
// 注意:WebSocket 断线不会自动重连,必须自己处理
ws.onclose = () => console.log('连接已关闭');三、AI流式场景到底该怎么选
回到最初的问题。大模型API几乎清一色选择SSE,不是偶然的。AI对话的数据流向有非常明显的特征:请求阶段用户发一次问题(一个普通POST请求就够了),响应阶段模型逐Token往外吐。整个过程只需要服务端单向推送,WebSocket引以为傲的全双工能力在这里完全用不上,反而要多付出协议升级、心跳保活、重连状态管理的复杂度。
SSE的优势在这个场景下被放大到极致:基础设施兼容性好,不需要改Nginx配置去支持Upgrade;浏览器自动重连;服务端实现成本极低,甚至一个普通的Servlet、PHP脚本都能做流式输出。对于"用户提问、AI流式回答"这一类典型交互,直接选SSE,没有第二个答案。
但WebSocket也有它不可替代的场景。第一是实时语音对话,客户端要持续上传音频流,同时接收服务端返回的音频和字幕,双向都是高频流,SSE根本撑不住。第二是协同类AI应用,比如多人同时在一个画布上和AI交互,消息广播、状态同步都需要双向通道。第三是长会话多轮交互,如果每次提问都重建HTTP连接开销太大,一条持久的WebSocket连接反而更省资源。
实际的工程实践中还有一条折中路线值得参考:主体走SSE做流式输出,同时保留一个普通HTTP接口处理用户的打断、重新生成等操作。这样既享受了SSE的简单可靠,又解决了单向通道无法中途干预的问题,很多开源的ChatGPT类项目(如LobeChat)早期就是这么做的。
总结一句:纯流式输出选SSE,双向实时交互选WebSocket,混合需求可以考虑SSE加普通接口的组合。选型时别被WebSocket的"功能强大"迷惑,协议越简单,运维成本越低,出问题的概率也越小。