先别急着把 SSE 换成 WebSocket。在 AI 流式输出中,AI Chat 通常由客户端提交一次问题,服务端持续返回 token 或结构化事件,单向流先按 SSE 或 Fetch Stream 处理;遇到 idle timeout,先按时间点、响应头、首个 chunk、心跳和各层 timeout 查链路。至于什么时候换 WebSocket,要等数据流向和断开位置都有明确证据后再决定。
stream disconnected before completion: idle timeout waiting for sse
从这行文字能确认的只是等待 SSE 的一侧在完成前触发了超时;它没有指出是模型上游没产出、应用没转发、代理清理了连接,还是浏览器/SDK 没解析到数据。先确认请求、首个 chunk、转发、代理响应和客户端消费能否在时间线上对应起来,再决定调超时、发心跳还是换协议。
先判断 idle timeout 断在哪
排查时把“模型上游 → 应用转发 → 反向代理/负载均衡器 → 浏览器/SDK”放在同一条时间线上:上游何时接受请求、何时产生首个 chunk;应用何时把数据写入响应;代理何时返回响应头和转发首个 chunk;客户端何时真正读到可解析事件。记录这些时间点以及每一层的 idle timeout,才能区分是上游未产出、转发未及时 flush、连接被中间层清理,还是前端未消费。
如果上游一直没有首字节,先查模型调用和请求是否被取消;如果上游已经有数据而应用或代理迟迟没有首字节,再查异步转发、响应缓冲和压缩。代理如果把 chunk 攒到结束才转发,客户端就会经历长时间空白,随后一次性收到内容。浏览器已经收到字节但界面不动时,查 SSE 帧能否完整拼接、UTF-8 解码是否正确处理跨 chunk 的字符,以及 SDK 的解析和等待逻辑。不要先把所有超时统一调大;不同代理和 SDK 对 idle 的计时对象可能不同,超时值和空闲判定要按实际代理、负载均衡器、框架及 SDK 文档核对。
SSE 的响应头和心跳需要结合实际部署核对。资料中的实现会检查 Content-Type、缓存控制和代理缓冲控制;Nginx 场景还可能使用 X-Accel-Buffering: no。
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
这组头只是示例,不能跨代理照抄;X-Accel-Buffering 是特定代理的核对项,是否需要关闭缓冲、怎样设置心跳,要以实际部署方式为准。心跳可以用 SSE 注释或空事件维持通道,但心跳至多表明传输路径仍有机会保持打开,不能替模型生成 token;如果心跳本身被缓冲,或心跳间隔大于实际 idle timeout,仍然会断。还要核对 SDK 是否把注释或空事件算作有效进展。
按数据流向决定 SSE 还是 WebSocket
AI Chat 通常通过 POST 提交问题、上下文和鉴权信息。浏览器原生 EventSource 以 GET 建立连接,不能直接承载这类复杂 POST;可以改用 fetch 方案发起 POST,设置自定义请求头并直接读取响应体的 chunk。
这里要分清“读取 HTTP chunk”和“消费 SSE 事件”。Fetch Stream 解决的是响应体分块读取;应用仍需处理分片边界、JSON/文本解析、取消和重试。SSE 是服务端到客户端的单向事件流,约定 text/event-stream,用 data、event、id、retry 字段组织事件,并以空行结束一条事件;原生 EventSource 还会尝试自动重连。
如果依赖浏览器原生断线重连,原生 EventSource 的语义更直接;使用 fetch 或 SDK 时,要确认相应实现是否具备同样的恢复机制。反过来,服务端只单向推送 token 或结构化事件时,先沿用现有 HTTP 链路和 SDK 默认实现。通常只有客户端需要持续、高频地上行,并且服务端必须在协作或信令场景中即时回应时,才值得把 WebSocket 纳入方案。
WebSocket 是全双工通道,通过 HTTP Upgrade 建立连接,成功时服务端返回 101 Switching Protocols。改用它之前,先核对代理是否允许下面的握手头;若使用 subprotocol,也要确认双方协商一致。
Upgrade: websocket
Connection: Upgrade
握手成功后再记录 close code 和 reason。资料中以 1001(endpoint is going away)作为例子,但 close code 必须结合服务端、客户端库和代理的语义解释,不能看到一个数字就下结论。
让取消、重连和结束都有明确状态
连接管理不能只靠一个 loading 布尔值。把一次流建模为 connecting、streaming、reconnecting、cancelling、completed、failed:连接建立后进入 streaming;网络中断后是否进入 reconnecting,取决于断开是否可恢复。只有收到应用约定的 done 才能进入 completed;收到 error 后,按是否可重试进入 reconnecting 或 failed;无法恢复的断开则进入 failed。done 和 error 是业务可以约定的终态事件名,不是 SSE 自动替应用生成的状态。
fetch 方案用 AbortController 取消,原生 EventSource 则显式 close();用户点了停止以后,不应再触发自动重连,也不应把取消记成模型错误。网络错误、用户取消、正常完成要分开记录可用的结束原因、最后事件 ID 和当前状态。原生 EventSource 的自动重连语义不能直接套到自定义 fetch 解析器上;后者不能假设自动重试、去重和恢复。
按 SSE 的事件边界解析,而不是把每个网络 chunk 直接当成一条消息。用增量 UTF-8 解码,处理被拆开的事件;记录 retry 提示、最后处理的 id 或业务序号,重连时只请求服务端明确支持的恢复区间,并丢弃重复事件。retry 影响的是重连等待,不是模型心跳;这样才能区分“暂时没收到数据”和“已经完成但终态事件丢了”。
错误处理还要区分首个响应之前和流开始之后:前者按实际 HTTP 错误路径返回,后者在还能写入时发送结构化 error 事件并以终态关闭。可重试与不可重试分开处理,并保留请求或连接标识、最后事件 ID、时间戳和已收事件数;不要把所有断开都自动重试,尤其不要重试用户主动取消。服务端也要在终态释放对应的 emitter 或 WebSocket 资源。
如果后端使用 Spring SseEmitter,把 complete()、completeWithError() 的调用与 onTimeout、onCompletion、onError 的日志关联起来,分别核对正常完成、异常结束和超时。若实际使用 AWS API Gateway WebSocket API,可按资料把 $context.error.validationErrorString 和 $context.integrationErrorMessage 纳入日志格式,用来区分 API Gateway 校验错误和后端集成错误;字段是否可用仍要按当前 API Gateway 配置核对。
所以,先把连接生命周期、超时和证据链对齐,再决定是否换协议;idle timeout 本身不足以证明 SSE 不适合。









暂无评论内容