SSE 能很快做出逐字输出的 AI 对话,但“能流出来”和“中断后仍然正确”是两个阶段。移动网络切换、浏览器休眠、代理超时和页面刷新都会关闭连接;自动重连又可能让同一事件被消费两次。
可靠性不能只靠前端把字符串继续拼接。系统需要知道一次执行由哪些有序事件组成、客户端已经确认到哪里、重复事件怎样被识别,以及哪一份数据代表最终完成的回答。
把 Event 当成可以回放的事实
每次 Agent 执行产生一条有序事件流。事件至少需要稳定的 agentId、递增 eventId、类型、内容和产生时间。开始、文本片段、工具调用、错误与完成都属于事件,而不只是临时网络包。
如果事件只存在于当前连接的内存里,断线就意味着历史消失。可恢复方案需要在发送前或发送同时,把事件写入一个能够按 agentId 和 eventId 查询的短期存储。保留时间可以有限,但必须覆盖合理的重连窗口。
{
agentId: "A-999",
eventId: 42,
type: "text_delta",
payload: { text: "..." },
createdAt: "..."
}Last-Event-ID 只是恢复入口
浏览器重连 SSE 时可以携带 `Last-Event-ID`。服务端收到以后,应查询这个编号之后的事件并按顺序补发,再切换到实时流。关键不是是否使用这个 Header,而是事件编号是否稳定、查询是否包含正确边界。
恢复通常采用严格的大于关系:客户端确认到 42,就从 43 开始。若服务端无法找到对应执行、事件已经过期或编号超出范围,应该返回明确的不可恢复状态,让客户端转为读取最终消息或提示重新执行,而不是静默从头拼接。
幂等边界放在状态变化上
网络采用至少一次交付时,重复事件是正常情况。前端可以用 `(agentId, eventId)` 去重,但服务端的工具调用、消息写入和完成处理也必须有独立幂等键,否则一次重试可能重复扣费、重复发送通知或产生两条最终消息。
一个实用边界是:事件追加以 eventId 唯一,工具执行以 toolCallId 唯一,最终消息以 agentId 唯一。写入操作先检查同一键是否已经完成,再决定执行或返回已有结果。幂等不是去掉所有重复请求,而是让重复请求不会改变最终事实。
流式片段和最终消息分开保存
text_delta 服务于即时体验,最终消息服务于会话历史。执行完成时,后端应根据确定的结果写入一条 final message,再发布 completed 事件。页面刷新后读取的是最终消息,而不是要求客户端重新拼接所有 delta。
如果连接在 completed 之前断开,客户端先尝试补事件;如果事件窗口已经失效,再查询 Agent 状态。状态为 completed 时读取最终消息,状态为 failed 时展示可理解的失败,仍在运行时重新订阅。这条回退路径比无限重连更可控。
连接中断
→ 携带 Last-Event-ID 重连
→ 补发缺失事件
→ 若无法回放,查询 Agent 状态
→ completed: 读取最终消息
→ failed: 展示失败并允许重试先定义失败,再选择基础设施
小规模系统可以从数据库事件表和进程内广播开始;更高并发时再引入 Redis Streams、消息队列或专门的事件存储。基础设施选择应由并发量、保留时间、跨实例广播和恢复目标决定。
无论使用什么组件,都应能回答四个问题:事件是否有序、重复是否安全、完成状态由谁写入、事件过期后怎样恢复。SSE 的稳定性不来自长连接本身,而来自连接背后那套明确、可回放且可验证的状态模型。
测试也应围绕失败路径组织:在随机事件后断开并重连,重复发送同一 eventId,令完成写入执行两次,让事件窗口过期,再检查页面和数据库是否仍然得到同一份最终结果。只有这些场景能够稳定复现,重连策略才不只是正常网络下的乐观设计,也才能成为可以写进产品承诺的能力,并在版本变化后持续回归。