上个月,我们的 AI 客服助手全量上线,第三天运维就找上门:问答接口的 QPS 莫名翻了一倍,客服还收到一堆投诉,说回答半天不吐字,用户以为卡死,点一次发一次,重试越点越多。
查了一圈,根因不在大模型,而在传输层。Nginx 默认把流式响应缓冲起来攒一波再吐,首字硬生生拖到 8 秒;前端用的还是最省事的裸 EventSource,断了不报错、慢了不提示,用户只能靠狂点续命。重试流量反过来又压垮网关,恶性循环。
这就是流式场景最真实的场景:模型没问题,管子先爆了。今天就把这根管子讲透,什么时候 SSE 够用,什么时候必须换 WebSocket,以及各自的坑怎么填。
裸 EventSource 和 WebSocket
出问题后,组里出现两种声音。一种说 EventSource 一行代码就能跑,别折腾;另一种说干脆全量切 WebSocket,一劳永逸。这两条路我都踩过,都有坑,只是结果不同。
裸 EventSource 的坑在哪?
EventSource 的 API 是只读的:只支持 GET,不支持自定义请求头。token 只能拼在 URL 上,网关日志、CDN 日志、浏览器历史全是明文,安全评审直接打回。断线重连看似自动,实则裸奔:没有服务端配合回放 Last-Event-ID,重连后要么丢消息,要么从头发完整流,用户会看到回答闪一下重打一遍。更隐蔽的是超时,Nginx 默认 proxy_buffering on,流式响应会被攒批,首字时间完全不可控。
WebSocket 的坑呢?
WebSocket 是长连接,网关要为每个连接维持状态,连接数直接变成成本。我们的客服页是低频使用的页面,用户问完就关,为这种低频、单向、下行主导的流量维持全双工连接,实例数要翻几倍。移动端更惨,切后台、锁屏、进电梯,连接一断就得整套重连、重鉴权、重订阅,心跳和退避策略全要自己写。
结论很清晰了👉:下行主导、低频、低延迟要求不极致,用 SSE;双向实时、高频上行、需要服务端主动推且客户端也要频繁说话,才上 WebSocket。比如多轮问答、摘要生成是 SSE 的主场;协作编辑、语音对讲、实时游戏状态同步,才是 WebSocket 的主场。
生产级的 SSE 客户端长什么样
先看大多数人写的版本,十五分钟能跑,三个月后必返工:
// 这个能跑,但迟早返工
export function streamAnswer(url: string, onToken: (t: string) => void) {
const es = new EventSource(url); // token 只能拼 URL,GET 裸奔
es.onmessage = (e) => onToken(e.data);
es.onerror = () => {
es.close(); // 断了就关,没有重连,没有续接
};
}
生产级版本必须同时解决四件事:POST + token 请求头鉴权、可取消、断线按 Last-Event-ID 续接、退避重试。直接上代码:
// 真正的 fetch + ReadableStream 的生产级 SSE 客户端
interface SSEMessage {
id: string | null;
event: string;
data: string;
}
interface StreamOptions {
headers?: Record<string, string>;
lastEventId?: string | null;
maxRetries?: number;
baseDelayMs?: number;
signal?: AbortSignal;
onMessage?: (msg: SSEMessage) => void;
onDone?: () => void;
}
function parseSSEBlock(block: string): SSEMessage | null {
let id: string | null = null;
let event = 'message';
const dataLines: string[] = [];
for (const line of block.split('\n')) {
if (line.startsWith('id:')) id = line.slice(3).trim();
else if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart());
else if (line.startsWith(':')) continue; // 服务端心跳注释行
}
if (dataLines.length === 0 && id === null) return null;
return { id, event, data: dataLines.join('\n') };
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException('aborted', 'AbortError'));
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export async function streamSSE(url: string, body: unknown, options: StreamOptions = {}): Promise<void> {
const { headers = {}, maxRetries = 5, baseDelayMs = 1000, signal, onMessage, onDone } = options;
let lastEventId = options.lastEventId ?? null;
let attempt = 0;
while (true) {
signal?.throwIfAborted();
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', ...headers },
body: JSON.stringify({ ...(body as Record<string, unknown>), ...(lastEventId ? { resume_from: lastEventId } : {}) }),
signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return; // 用户主动取消,不算失败
if (++attempt > maxRetries) throw err;
await sleep(Math.min(baseDelayMs * 2 ** attempt + Math.random() * 500, 30000), signal);
continue;
}
if (!res.ok || !res.body) {
if (++attempt > maxRetries) throw new Error(`stream failed: HTTP ${res.status}`);
await sleep(Math.min(baseDelayMs * 2 ** attempt + Math.random() * 500, 30000), signal);
continue;
}
attempt = 0; // 连上一次,清零计数
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
const msg = parseSSEBlock(block);
if (!msg) continue;
if (msg.id) lastEventId = msg.id; // 记住位点,断线从这里续
if (msg.event === 'done') {
onDone?.();
await reader.cancel().catch(() => undefined);
return;
}
onMessage?.(msg);
}
}
onDone?.();
return; // 服务端正常关流,结束
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
await reader.cancel().catch(() => undefined);
return;
}
if (++attempt > maxRetries) throw err; // 读一半断了,带位点重试
await sleep(Math.min(baseDelayMs * 2 ** attempt + Math.random() * 500, 30000), signal);
} finally {
reader.releaseLock();
}
}
}
用法上配合 AbortController 做取消,组件卸载或用户点停止时直接 abort,不再有幽灵请求:
const controller = new AbortController();
streamSSE('/api/chat/stream', { messages }, {
headers: { Authorization: `Bearer ${token}` }, // 头部鉴权,不拼 URL
signal: controller.signal,
onMessage: (msg) => appendToken(msg.data),
onDone: () => setLoading(false),
}).catch((err) => {
if (err instanceof DOMException && err.name === 'AbortError') return;
toast('网络抖动,已自动重试仍失败,请再试一次');
report(err); // 上报监控,带 lastEventId 便于复盘
});
// 用户点停止 / 组件卸载
controller.abort();
讲一讲遇到的一些坑
客户端写结实了,线上照样可能翻车。下面 👇 四个全是生产里真金白银换来的,每个都给兜底。
Nginx 吞流,首字永远慢
Nginx 默认 proxy_buffering on,会把上游的流攒成块再吐,流式直接退化成轮询。兜底要在 location 里显式关掉,并让上游声明不缓冲:
location /api/chat/stream {
proxy_pass http://upstream;
proxy_buffering off; # 关键:禁止攒批
proxy_cache off;
proxy_read_timeout 300s; # 流式需要长超时
proxy_http_version 1.1;
chunked_transfer_encoding on;
}
服务端再补一个响应头 X-Accel-Buffering: no,双保险。验收标准只有一个:首字时间进监控,超过 3 秒直接告警,别等用户投诉🤷♂️。
浏览器同域连接上限,SSE 会断开其他请求
HTTP/1.1 下浏览器对同一域名只有 6 个并发连接,SSE 一旦连上就长期占一个。用户开三个问答标签页,再加载页面资源就开始排队,页面转圈的锅最后全算在前端头上。兜底策略:全局限制同页 SSE 并发数为 1,后开的流先 abort 前一个;切 HTTP/2 则多路复用天然缓解,但网关的 stream 并发上限同样要核对,别从浏览器瓶颈换成网关瓶颈👋。
断线重连导致消息重复
网络抖动是常态,重连后服务端从 Last-Event-ID 重放,客户端如果直接追加,用户会看到半截回答突然重来一遍。正确做法是按 ID 去重 + 按位点替换:维护已收 ID 集合,重放的消息只用于补位不重复渲染;服务端必须保证 ID 单调递增,这是前后端要对齐的契约,不是前端单方面能解决的🫡。
网关空闲超时切断 和 移动端切后台直接失联
多数网关默认 60 秒无数据就掐连接,大模型思考超过 60 秒不吐字,连接就被静默掐掉。兜底是服务端每 15 秒发一条注释心跳(:\n\n),既保活又不污染业务流;客户端加一层兜底计时器,30 秒无任何字节就主动重连。同时统计要洗干净:重连次数、首字时间、完整率单独上报,别把重连算进接口成功率里,否则监控一片祥和,线上全是断流。
什么时候必须换 WebSocket
最后给一张决策表,贴在组里能少吵很多架。出现下面任意一条,SSE 不要硬撑,直接上 WebSocket!
WebSocket 也不是换过去就完事,心跳、重连退避、token 过期重鉴权,一行都不能少:
// WebSocket 保活与重连骨架:心跳 + 指数退避,token 过期单独处理
interface WSOptions {
heartbeatMs?: number;
maxRetries?: number;
getToken: () => Promise<string>;
onMessage: (data: string) => void;
}
export function connectWS(url: string, options: WSOptions): () => void {
const { heartbeatMs = 25000, maxRetries = 8, getToken, onMessage } = options;
let ws: WebSocket | null = null;
let retries = 0;
let timer: ReturnType<typeof setInterval> | null = null;
let closed = false;
const open = async () => {
const token = await getToken();
ws = new WebSocket(`${url}?token=${encodeURIComponent(token)}`);
ws.onopen = () => { retries = 0; };
ws.onmessage = (e) => {
if (typeof e.data !== 'string') return;
if (e.data === 'pong') return; // 心跳回包,直接丢弃
onMessage(e.data);
};
ws.onclose = () => {
if (timer) clearInterval(timer);
if (closed || ++retries > maxRetries) return;
setTimeout(open, Math.min(1000 * 2 ** retries, 30000)); // 退避重连
};
};
timer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) ws.send('ping');
}, heartbeatMs);
void open();
return () => { closed = true; if (timer) clearInterval(timer); ws?.close(); };
}
注意 token 过期时不要无脑重连,先刷新 token 再建连,否则重连风暴里全是 401,网关日志会被打爆。
流式传输的技术选型,从来都是成本多少的问题。SSE 便宜、简单、和 HTTP 基础设施处得来,配上可取消、可续接的客户端,能覆盖九成 AI 问答场景;WebSocket 留给真正双向的场景。但是别让低频页面替长连接买单!
好了,今天就分享到这里吧🙌
喜欢我的文章,也欢迎关注我的微信公众号:【前端技术官】。
主要分享:前端架构 · AI 编程 · 职场认知 · 开发者成长
微信扫码关注 👆
不定期更新,不刷屏,聊点真正有用的干货。