AI 流式输出断线后怎么续接:事件 ID、重连窗口与重复片段去重
来源:17golang原创
时间:2026-08-25 10:58:07 231浏览 收藏
AI 聊天页最容易出现的一类故障,用户常常误以为是模型生成卡壳了,实际上大部分中断都发生在浏览器、代理转发或者移动网络切换的环节。要让用户不用重复发请求,就能接续看到同一条未完成的回答,服务端必须预留可回放的事件窗口,客户端本地记录最后一个已经确认展示的事件 ID,重连后直接从这个位置继续取流,再对可能收到的重复片段做一次幂等过滤就可以正常续接。
可靠的续接不是把上一次请求原封不动重发一遍,而是给每个流事件配一个稳定的定位游标:断线前确认到哪条,重连后就从哪条开始补,已经展示过的内容不会被二次追加。
- 用单调递增的
event_id或自增序号定位当前接收进度。 - 服务端只需在短时间窗口内留存可回放的事件序列。
- 客户端按事件 ID 做全局去重,同时把
completed、failed、expired三类状态做明确区分。
先判断:断线发生在生成端还是传输端
排查时先看同一个 request_id 的服务端日志。如果模型调用已经写下 response.completed,但浏览器只收到前半段,问题多半在连接链路;如果服务端也没有完成事件,则要继续看上游生成状态。两类故障的恢复动作不一样,不能看到空白就立即重复请求。
OpenAI Responses API 的流式事件包含事件类型、响应 ID 和 sequence_number;文档列出的 response.completed 表示响应已经完成。这个字段适合做观测和一致性核对,但是否能按序号向供应商重新拉取历史事件,仍取决于你自己的中间层是否保存了事件。

最小续接协议:游标、短窗口和完成标记
建议把一次回答拆成三类记录:请求元数据、流事件、最终状态。事件至少包含 request_id、event_id、kind、delta 和创建时间。event_id 必须在同一请求内单调递增,不能用客户端当前时间临时拼出来。
event: delta
id: 1042
data: {"request_id":"r_7f2","delta":"先检查游标","seq":1042}
event: completed
id: 1043
data: {"request_id":"r_7f2","status":"completed","seq":1043}
服务端可以只保留最近几分钟或最近几百条事件,具体取值看回答长度和并发量。窗口过短,用户刚切到地铁网络就无法续接;窗口过长,内存和清理成本会一起上涨。窗口失效时应该返回明确的 resume_expired,让客户端选择显示已收内容或发起一条新的回答,而不是静默拼接两次结果。
客户端重连:先补事件,再恢复实时监听
浏览器原生 EventSource 会理解 SSE 的 id 字段,并维护最后一次事件 ID;服务端也可以通过 retry 提供建议的重连等待时间。但自动重连不等于业务续接:你的服务仍需要依据游标回放历史事件,并在回放结束后切换到实时事件。
let lastEventId = null;
const seen = new Set();
function accept(event) {
if (seen.has(event.id)) return;
seen.add(event.id);
lastEventId = event.id;
renderDelta(event.data);
}
function reconnect() {
const url = `/api/answers/r_7f2/stream?after=${encodeURIComponent(lastEventId || "")}`;
return new EventSource(url);
}
示例里的 seen 只适合短流和演示。生产环境更稳妥的做法是记录“最后连续确认的序号”,只接受下一个期望序号,遇到跳号先请求补齐;否则网络重排或服务端重复发送时,集合会不断增长,也可能掩盖真正的丢事件。

重复片段怎么处理:按事件幂等,不按文字猜
不要用“当前文本是否以这段文字结尾”来判断重复。模型输出可能连续产生相同词语,按文字截断会误删合法内容。更可靠的规则是:相同 request_id + event_id 只应用一次;如果事件 ID 相同但内容摘要不同,直接记录冲突并停止拼接,交给服务端检查。
还要区分“事件已收到”和“内容已渲染”两个状态。比如浏览器标签页卡顿的时候,事件可能已经进入内存队列,但还没来得及绘制到页面上。可以在对应内容渲染成功之后再更新本地的确认游标,刷新或者重连时从最后一个已经渲染完成的事件开始补取,宁可出现极短的内容重复,也不要把用户实际没看到的内容标记为已完成。
异常分支:四种状态要分开记录
completed:收到最终事件,回答可以落库,重连只返回完成状态。failed:上游或服务端明确失败,保留错误类别和最后游标,避免客户端无限重连。resume_expired:事件窗口已清理,提示用户重新生成或保留当前已显示文本。idle_timeout:长时间没有新事件,先关闭连接并等待一次有限重连,不要把它直接当成模型失败。
日志里至少关联 request_id、最后确认序号、连接次数、回放条数、重复条数和最终状态。监控可以关注续接成功率、窗口过期率、重复事件率和从断线到恢复的耗时。只看 HTTP 200 没有意义,因为流中途断开时响应头可能早已返回。
上线前的验收与回滚检查
测试不要只在稳定的宽带环境下点刷新验证。要人为让连接分别在第一个输出片段、回答中段、完成事件下发前断开,覆盖浏览器刷新、Wi-Fi/5G切换、代理超时和服务端轻量重启四种常见场景。每次验收都核对最终显示的完整文本、最后一条事件序号和服务端落库的存储状态是否完全匹配。
如果后续迭代新版本的游标格式或者事件类型发生变更,先兼容旧版本客户端,让旧客户端可以继续解析旧格式的返回,同时在服务端保留一段足够长的兼容回放窗口。线上发现重复率、窗口过期率出现明显升高时,优先回滚事件协议和客户端重连逻辑,不要直接把重连次数调大;重连请求次数越多,越容易把同一次回答拆成多次重复生成的结果。
常见问题
重连时可以直接重新发起一次 AI 请求吗?
只有原回答已经明确标记失败、或者对应事件窗口已经超出可恢复范围时,才考虑触发重新生成逻辑。正常场景下的断线应该先按请求 ID 和已确认游标补取事件,否则会生成两段完全不同的回答,用户也没法判断哪一段才是之前没看完的内容。
为什么收到 completed 还要保存最后一个事件 ID?
完成事件本身也是事件流的组成部分。把它纳入流序列里存储,不仅可以让重连请求拿到明确的终态标记,也能方便排查“内容其实已经全部生成,但前端没显示完成标识”的隐性问题。
事件窗口应该保存多久?
从用户日常网络切换、页面后台恢复的实际耗时倒推计算窗口时长,再预留一部分抖动余量。先用线上实际指标统计窗口过期率,再按需调整服务端存储容量;不要直接拍脑袋写一个固定分钟数替代实际观测结果。
把续接能力变成可观察的可靠性指标
流式输出的核心体验从来不是“连接永远不会断”,而是连接断开之后仍能准确定位进度、补回传输缺口,同时保持单次回答的边界完整性。把事件 ID、连续确认游标、回放窗口和最终状态这几个环节一起纳入设计,断线就会从用户感知里的“回答卡住不动”,变成工程层面可定位、可恢复、可验收的常规异常路径。
-
284 收藏
-
387 收藏
-
328 收藏
-
426 收藏
-
147 收藏
-
418 收藏
-
313 收藏
-
466 收藏
-
398 收藏
-
269 收藏
-
460 收藏
-
科技周边 · 人工智能 | 8小时前 | 人工智能 · gemini · function calling · 结构化输出 · 接口测试 · 结构化输出 JSON Schema Gemini 3 Function Calling 工具调用验收346 收藏
-
289 收藏
-
229 收藏
-
104 收藏
-
394 收藏
-
113 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习