
网络断开时,界面可能已经显示三段文字,第四段只收到半个 JSON。重新连接若从头追加,会重复前三段;若从“已收到字节数”继续,又可能从中文字符或 JSON 中间开始。
恢复的单位应该是协议里已经完整验证并应用的业务事件。字节到了、SSE 帧结束了、业务事件已持久化,是三个不同阶段。应用游标也需要服务端保留相应日志,浏览器无法单方面发明续传能力。
先把四种停止分开
断网是连接状态未知;服务端错误是请求失败;拒绝是供应商或应用明确不接受该请求;用户取消是用户要求停止。它们不应都显示成“继续生成”。
用户取消后,客户端不得自动重连旧任务。取消请求是否让模型停算、是否产生费用,需要服务端确认与实际 usage,关闭 Reader 或连接不能证明这些结果。

游标只跟随完整事件前进
下面自定义连续递增的 seq。真实 SSE 的 ID 本身不要求是数字;这里为了示范间隙检查,额外约定了业务序号。相同序号内容不同要报冲突,不能当普通重复吞掉。
保存为 resume.mjs,执行 node resume.mjs:
import assert from "node:assert/strict";
function session(snapshot = { cursor: 0, text: "", input: "", seen: [], cancelled: false }) {
const s = structuredClone(snapshot);
const seen = new Map(s.seen);
return {
apply(e) {
if (s.cancelled) return "cancelled";
if (!Number.isSafeInteger(e.seq) || e.seq < 1 || typeof e.text !== "string")
throw new Error("invalid complete event");
const payload = JSON.stringify({ text: e.text });
if (seen.has(e.seq)) {
if (seen.get(e.seq) !== payload) throw new Error("conflicting replay");
return "duplicate";
}
if (e.seq !== s.cursor + 1) throw new Error("event gap");
// 在此示例中,应用与游标更新是同步内存操作。
s.text += e.text; seen.set(e.seq, payload); s.cursor = e.seq;
return "applied";
},
edit(value) { s.input = value; },
cancel() { s.cancelled = true; },
snapshot() { return { ...s, seen: [...seen] }; }
};
}
const a = session();
a.edit("用户尚未发送的输入");
a.apply({ seq: 1, text: "你好" });
const b = session(a.snapshot());
assert.equal(b.apply({ seq: 1, text: "你好" }), "duplicate");
assert.throws(() => b.apply({ seq: 1, text: "另一段" }), /conflicting/);
assert.throws(() => b.apply({ seq: 3, text: "间隙" }), /gap/);
b.apply({ seq: 2, text: ",继续" });
assert.equal(b.snapshot().text, "你好,继续");
assert.equal(b.snapshot().input, "用户尚未发送的输入");
b.cancel();
assert.equal(b.apply({ seq: 3, text: "迟到" }), "cancelled");
console.log("cursor/replay/cancel fixtures passed");
此例仅在内存保存日志。真实持久化需要把可见内容与游标作为同一提交单元;若先保存游标、后保存内容,中途崩溃会丢字。若先保存内容而游标未提交,又需要可重放、可去重的结构。长会话还需要快照和有界历史,不能让 seen 集合无限增长。
恢复失败不应伪装成续写
服务端可能不支持游标、日志已过期,或无法验证会话所有权。此时应返回明确状态,保留用户输入与已显示内容,提供“重新发起”操作,并说明可能得到不同内容、可能产生新的调用成本。
不要拿已显示文本拼接到新 prompt,自动发起新请求后还称“原任务续传”。这会改变上下文、供应商任务身份及计费边界。业务需要这样做时,应该作为新任务显示并让用户确认。
验收看内容一致性,不只看能否重连
离线夹具要覆盖:每个事件之间断线、半帧截断、重复事件、冲突 ID、游标过期、缺终态,以及取消后旧事件迟到。刷新页面后还要检查草稿和选区是否按产品约定保留。
本文没有实现 UTF-8/SSE 解析器;解析层的分片不变量应独立测试。也没有接真实模型或断网浏览器实验。已在 Node v26.3.0 验证的是完整业务事件的恢复规则;不能据此声称“无损续传、模型停算或费用退回”。
参考资料:AI SDK 流式协议,滚动文档;Streams 概念,滚动文档;Playwright 断言,滚动文档。采用前复核实际服务的游标与终态约定。
本文探讨SSE流式传输中断后如何正确恢复界面状态,强调以完整业务事件为单位推进游标,避免字节级续传导致的乱码或重复;区分断网、错误、拒绝和用户取消四类终止场景;指出服务端需持久化游标与内容原子提交,恢复失败应明确提示而非伪装续传;验收需覆盖半帧截断、重复事件、ID冲突等边界情况,确保内容一致性而非仅连接可用性。

8734

被折叠的 条评论
为什么被折叠?



