线上排查了三天才搞定这个:用户网络一抖,AI 正在打字打到一半就卡住,前端转圈圈,再也不动了。SSE 这玩意儿默认是不会自己续上的。下面是我最后落地的那套自动重连 + 断点续传方案,纯前端能做的部分。
先认清 SSE 的脾气
浏览器原生 EventSource 其实自带重连------断了会按 retry 间隔重发请求。但问题有俩:第一,它只支持 GET,AI 对话要 POST 一大坨 messages,用不了;第二,就算用了,它重连是从头开始,不知道你已经收到哪儿了,模型会把答案从头再吐一遍。
所以大部分 AI 应用是用 fetch + ReadableStream 自己手撸的。手撸就得自己管重连。
核心思路:记住已收到的位置
每一帧带一个递增的序号,前端记下最后收到的序号。断线重连时把这个序号回传给后端,后端从那之后接着发。
后端每帧大概长这样:
css
data: {"seq": 42, "delta": "我们"}
data: {"seq": 43, "delta": "可以"}
前端收流的主循环:
ini
async function streamOnce(url, body, lastSeq, onDelta, signal) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, resumeFrom: lastSeq }),
signal,
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) return { finished: true };
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const chunk = buf.slice(0, idx).trim();
buf = buf.slice(idx + 2);
if (!chunk.startsWith('data:')) continue;
const payload = JSON.parse(chunk.slice(5).trim());
onDelta(payload); // 这里更新 lastSeq
}
}
}
注意 decode(value, { stream: true })------中文是多字节的,一帧可能切在一个汉字中间,不带 stream: true 会出现乱码方块。我就是被这个坑了半天,以为是后端编码问题。
外层包一个带退避的重连循环
javascript
async function chatWithRetry(url, body, onDelta) {
let lastSeq = -1;
let attempt = 0;
const ac = new AbortController();
while (attempt < 6) {
try {
const r = await streamOnce(url, body, lastSeq, (p) => {
if (p.seq <= lastSeq) return; // 去重:重连后可能重发
lastSeq = p.seq;
onDelta(p.delta);
}, ac.signal);
if (r.finished) return; // 正常收完,退出
} catch (e) {
if (ac.signal.aborted) return; // 用户主动取消
attempt++;
const wait = Math.min(1000 * 2 ** attempt, 15000);
await new Promise(r => setTimeout(r, wait + Math.random() * 300));
}
}
throw new Error('重连多次仍失败');
}
几个我踩过坑后加上的细节:
- 去重那行
if (p.seq <= lastSeq) return不能省。后端续传的边界经常会多发一两帧重叠的,不去重屏幕上就重复一段。 - 退避加随机抖动,不然一批用户同时断网会在同一秒一起重连,把后端打挂。
AbortController一定要透传,用户切走页面或点停止,得能真正掐断 fetch,否则后台还在偷偷收流烧 token。
一个没法纯前端解决的坑
续传能不能真的「接着说」,取决于后端肯不肯把生成到一半的结果缓存住、并支持 resumeFrom。如果后端是无状态转发,断了就是断了,前端再聪明也只能整段重发。我们后端为此专门加了一层短时缓存,按会话 ID 存最近 N 秒的 delta。
说实话这块后端逻辑挺烦的,我们后来没自己扛。直接用讯飞 Agent发布出来的对话 API------它是 MaaS,模型和流式生成都在它那边,我们没自建算力,断线续传这种状态管理交给它的服务端,前端只管认 seq、重连、去重,省心不少。