前言
最近在做业务Agent开发时,遇到这样一个问题,当用户在 AI 流式输出过程中关掉页面、切换网络、或者刷新浏览器后,我们如何保证对话不丢失、状态可恢复。本文分享一下我们的实现方案。
前置调研:主流 AI 产品是怎么做的?
ChatGPT / Claude(SSE 方案)
SSE 是"半程高速路"------服务端只能往客户端推,客户端不能反向说话。
bash
正常流程:
浏览器 POST /api/chat → 服务端保持连接不断推送 text/event-stream → 完成
断连流程:
用户刷新页面 → SSE 连接断开 → 服务端那边其实还在继续生成
→ 但浏览器已经拿不到了
→ 用户重新打开页面 → 通过 HTTP API 加载历史消息
→ 如果后端落库了 - 用户能直接看到完整消息,但是没有流式渲染,以及thinking等部分,会是直接出结果
如果后端部分落库 - 已生成的部分能恢复(因为服务端持久化了),正在生成的那部分?看运气,可能丢失最后几个 token
核心思路 :SSE 断了就断了,不搞花哨的重连。靠的是服务端持久化------每条消息生成完就存数据库,前端刷新后从数据库拉历史。简单粗暴但有效。
豆包(WebSocket 方案)
WebSocket 是"全程高速公路"------双向车道,随时可以收发。
csharp
正常流程:
浏览器建立 wss:// 连接 → 双向通信 → 服务端推送二进制帧
断连流程(抓包分析):
用户刷新页面 → ws.on('close') 触发
→ 客户端记录最后收到的消息序号(seq)
→ 重新建立 wss:// 连接
→ 握手时带上 lastSeq:"我从第 47 条之后没收到"
→ 服务端查找 seq > 47 的消息 → 批量补发
→ 前端回放补发的消息 → 无缝衔接 ✅
豆包的优势 :因为有 WebSocket 双向通道 + Protobuf 序号机制,理论上可以实现真正的断点续传------就像视频缓冲一样,断网几秒重连后,中间的内容自动补上,用户几乎无感知。
一、先说问题:为什么断线这么麻烦?
1.1 一个真实的痛点场景
想象一下用户在使用我们的 AI Agent:
makefile
时间线:
00:00 用户发送:"帮我创建一个xxx"
00:01 Agent 开始推理(调用 LLM)
00:02 LLM 决定先查项目列表 → 调用工具 → 返回结果
00:03 LLM 决定创建项目 → 调用工具 → 返回结果
00:04 LLM 开始填写表单 → 流式输出 "好的,正在为您填写..."
00:05 ❌ 用户不小心关闭了浏览器标签页
(或者手机锁屏、或者从 WiFi 切到 4G)
00:06 Agent 还在后台继续运行!LLM 继续输出、工具继续执行...
00:07 Agent 完成了,返回最终结果
但是前端已经收不到了 😱
00:10 用户重新打开页面
→ 看到的是什么?
这就是我们要解决的核心问题。它背后其实隐藏了一堆需要回答的子问题:
| 问题 | 为什么难 |
|---|---|
| 断连期间 Agent 产生的消息去哪了? | 前端不在了,但服务端还在跑,消息需要有地方暂存 |
| 重连后怎么知道"从哪接着"? | 需要一个标记位告诉服务端"我最后看到哪了" |
| Agent 还在跑吗?还是已经跑完了? | 前端需要知道该"等待新消息"还是"拉取历史" |
| 如果同时开了多个 Tab 怎么办? | 关掉一个 Tab 不应该影响其他 Tab |
| Agent 跑了很久都没结束,要不要杀掉? | 需要超时机制防止资源泄漏 |
1.2 我们要达到的目标
理想情况下,用户的体验应该是这样的:
- 短暂断网(几秒):完全无感知,就像什么都没发生
- 刷新页面:对话历史完整保留,正在生成的内容自动补上
- 离开很久再回来:能看到之前的完整对话,可以继续聊新的
- 多设备 / 多 Tab:消息同步,不会重复或丢失
二、整体方案一览:三层防护
我们的方案可以概括为**"三层防护"**,每一层解决不同时长范围的断连问题:
arduino
断连时长 解决方案 一句话解释
─────────────────────────────────────────────────────────────
0秒 ~ 几秒 Ring Buffer 补发 "你没看到的消息我先帮你存着,
(内存级缓存) 回来后一次性给你"
几秒 ~ 1分钟 同上 + DisconnectTimer "你还没回来,但我再等你一会儿;
(等待重连) 超时了我就先把 Agent 休眠"
1分钟 ~ 10分钟+ 快照恢复 (SnapshotStore) "你太久没回来了,我把 Agent 的
(持久化 + 重建 Worker) 状态存盘了;你回来时我重新启动"
除了这三层,还有三个定时器在做后台守护:
arduino
┌─ DisconnectTimer (60s) ──→ "WS 全断了,Agent 空闲的话就休眠吧"
├─ IdleTimeout (10min) ────→ "Agent 太久没干活了,休眠释放资源"
└─ Watchdog (30s 间隔) ───→ "检查有没有 Agent 卡死了,卡死就强制回收"
下面分层详解。
三、第一层:Ring Buffer ------ 你的消息快递暂存点
3.1 它是什么?
Ring Buffer(环形缓冲区)就是一个固定大小的内存队列,用来临时存放"已经发给前端但前端可能没收到"的消息。
你可以把它想象成一个传送带:
bash
服务端产生消息
│
▼
┌─────────────────────────────────────────┐
│ 📦 Ring Buffer (环形缓冲区) │
│ │
│ [seq:3] [seq:4] [seq:5] [seq:6] │
│ "你好" "," "我" "来" │
│ │
│ ↑ 新消息从这里写入(覆盖最旧的) │
│ ↑ 读指针从这里读取(找 lastSeq 之后) │
└─────────────────────────────────────────┘
│
▼
推送到前端的 WebSocket(如果还连着的话)
关键点:每条消息写入时都会分配一个递增的序号(seq),就像快递单号一样。前端知道自己最后收到的是第几号,重连时告诉服务端"我从第 N 号之后没收到了",服务端就把 N 号之后的所有消息一次性返回。
3.2 发送消息时的逻辑
每次服务端要往前端发消息,都走这个流程:
typescript
send(sessionId, message) {
// 第一步:给这条消息分配一个递增的序号(相当于快递单号)
const seq = nextSeq++; // 0, 1, 2, 3, 4, ...
const msgWithSeq = { ...message, seq };
// 第二步:先存入 Ring Buffer(不管前端在不在线,先存下来)
ringBuffer.push(msgWithSeq);
// 第三步:再尝试推送到当前在线的前端
for (const socket of activeSockets) {
if (socket.isConnected) {
socket.send(msgWithSeq);
}
}
// 注意:如果前端断连了,第二步照做(消息存下来了),
// 第三步跳过(没人可推)。等前端重连后再从 Buffer 里取。
}
这就是"写后即缓存"原则------宁可多存一条,不可漏掉一条。
3.3 重连时怎么补发?
前端重连时的握手过程大概是这样的:
css
前端 服务端
│ │
│ ← 建立 WebSocket 连接 ──────→ │
│ │
│ 发送 connect 消息: │
│ { │
│ sessionId: "abc123", │
│ lastSeq: 47 │ ← "我最后收到的是第 47 号"
│ } │
│ ────────────────────────────→ │
│ │
│ ┌──────────────────────┐
│ │ Ring Buffer.findAfter(47)│
│ │ 找到: [48, 49, 50, 51] │
│ └──────────────────────┘
│ │
│ ←── connected 响应 ────────── │
│ { │
│ missedMessages: [ │
│ { seq:48, "好" }, │ ← 这 4 条是你断连期间错过的
│ { seq:49, "的" }, │
│ { seq:50, ",我在" }, │
│ { seq:51, "帮您查询..." } │
│ ], │
│ workerActive: true │ ← Agent 还在跑呢
│ } │
│ │
│ 前端按顺序回放这 4 条消息 → │
│ 用户看到完整的输出 ✅ │
3.4 不是所有消息都需要缓存
有些消息是"控制信号",不需要补发(比如心跳响应、错误提示等)。我们只缓存业务消息:
arduino
✅ 需要缓存(断线后补发给用户看的):
text_delta --- AI 生成的文字片段
thinking --- AI 的思考过程
tool_use / tool_start / tool_result --- 工具调用相关
turn_end --- 本轮结束
hitl_request --- 人工审批请求
skill_activated --- Skill 激活通知
❌ 不需要缓存(控制面信号,不需要补发):
connected --- 握手响应(你都在握手了,不需要补发自己)
pong --- 心跳响应
error --- 错误提示(重连后会重新判断是否还需要报错)
task_interrupted --- 中断通知(重连后重新检测状态)
四、第二层:心跳与断连检测 ------ 怎么知道前端挂了?
4.1 三层心跳机制
我们有三套独立的"心跳"在跑,层层递进:
arduino
第 1 层:WS 协议层 ping/pong(最快,30s 一次)
─────────────────────────────────────────────
如果 90 秒内都没收到 pong → 认为连接死了 → 主动关闭
第 2 层:握手超时(5s)
─────────────────────────────────────────────
WS 连接建立后,5 秒内没收到前端的 connect 握手消息
→ 认为是恶意连接或异常客户端 → 直接踢掉
第 3 层:业务层 DisconnectTimer(最慢,60s 起)
─────────────────────────────────────────────
该会话的所有 WS 连接都断开后启动
等 60 秒看用户回不回来
回来了 → 取消 timer,一切正常
没回来 + Agent 空闲 → 休眠 Agent 释放资源
没回来 + Agent 还在忙 → 再等等,忙完再说
4.2 断连发生的完整链路
objectivec
用户关闭浏览器标签页
│
▼
浏览器的 WS 连接自动关闭
│
▼
WsGateway 收到 close 事件
│
├─ 1. 把这个连接从"活跃列表"里移除
│ (停止它的心跳计时器)
│
└─ 2. 这个会话还有其他连接吗?(比如用户开了两个 Tab)
│
├─ YES → 还有别的 Tab 在线,什么都不做 ✅
│
└─ NO → 所有连接都断了 ⚠️
│
▼
通知 SessionManager:"WS 全断了!"
│
▼
启动 DisconnectTimer(倒计时 60 秒)
│
┌────┴────┐
│ │
60s 内 60s 到了
用户回来了 用户没回来
(重连成功) (Worker 空闲?)
│ │
▼ ▼
取消 Timer 休眠 Worker
一切如常 保存快照 → 释放资源
五、第三层:快照恢复 ------ 长时间断连怎么办?
5.1 什么时候触发"长期恢复"?
有两种情况会让 Worker 被"休眠"(保存状态后退出):
| 触发条件 | 超时时间 | 说明 |
|---|---|---|
| WS 全断 + Agent 空闲 | 60 秒 (DisconnectTimer) | 最常见:用户关掉页面走了 |
| Agent 太久没活干 | 10 分钟 (IdleTimeout) | 用户开着页面但没发消息 |
一旦 Worker 被休眠,它的完整状态会被保存为一份快照(Snapshot):
typescript
// 快照里存了什么?
{
sessionId: "abc123",
messages: [...], // 完整对话历史(包括所有工具调用的细节)
turnCount: 5, // 已经聊了几轮
userContext: {...}, // 用户信息(角色、团队、权限)
activatedSkillNames: [...], // 已激活的 Skill 列表
originalUserMessage: "...", // 用户原始输入(恢复时可重新注入意图)
}
这份快照会存到 OSS 或数据库里,即使整个服务器重启也不会丢。
5.2 用户回来后怎么恢复?
scss
用户重新打开页面(距离上次断连已经过了 5 分钟)
│
▼
前端建立 WS 连接 → 发送 connect({ lastSeq: 47 })
│
▼
WsGateway 处理握手
│
├─ Ring Buffer.findAfter(47) → 可能是空的(太久了,Buffer 已被覆盖或清理)
│
├─ isWorkerActive() → false(Worker 早被休眠了)
│
▼
返回 connected({
missedMessages: [], // 没有可补发的消息了
workerActive: false, // Agent 不在了
})
│
▼
前端发现 workerActive = false
│
▼
调用 HTTP API: POST /api/sessions/abc123/resume
│
▼
SessionManager.resumeSession()
│
├─ 1. 从数据库加载之前保存的快照 snapshot
│
├─ 2. fork 一个全新的 Worker 子进程
│
├─ 3. 把快照发给新 Worker:"这是你之前的状态,接着干"
│ → 新 Worker 用快照重建 AgentLoop(messages / skills / context 全部还原)
│
└─ 4. 推送 recovery_start / recovery_complete 事件通知前端
对用户来说,这个过程大概是 1~3 秒(主要是 fork 子进程 + 加载快照的时间),体验上就是"页面闪一下然后对话就回来了"。
5.3 特殊情况:等待审批时断连
还有一种比较特殊的场景------Agent 在等用户审批(HITL),这时候用户关了页面:
c
Agent 执行敏感操作(比如要用沙箱跑命令)
│
▼
弹出审批请求(hitl_request)→ 显示在前端
│
▼
Agent 进入阻塞等待...(就像打电话等对方接听)
│
❌ 此时用户关闭了页面
│
▼
正常情况下 DisconnectTimer 会启动,但我们做了特殊处理:
│
├─ HITL_WAITING 状态下,标记 isActive = true(假装还在忙)
│ → DisconnectTimer 检测到"还在忙" → 不回收 ✅
│ → IdleTimer 同理 → 不回收 ✅
│
├─ 启动离线通知兜底:
│ → 60 秒后通过大象 IM(企业通讯工具)推送审批卡片到用户手机
│ → 用户在手机上点"批准"
│ → 大象回调 → 写入 hitl_result 到 Worker stdin
│ → Agent 恢复执行 ✅
│
└─ 或者用户重新打开页面(WS 重连)
→ 补推 hitl_request 弹窗
→ 用户在 Web 端点"批准" ✅
六、一张图看全所有场景
| 你干了什么 | 过了多久 | Agent 怎么样了 | 你回来后看到什么 |
|---|---|---|---|
| 网络抖了一下 | 几秒 | 还在跑 | 无缝衔接,中间内容自动补上 |
| 刷新页面 | 立即回来 | 还在跑 | 自动补上断连期间的内容 |
| 关掉 Tab 去开会 | 1~2 分钟 | 还在跑 | 补发遗漏的消息 |
| 关掉 Tab 去吃午饭 | 30 分钟 | 已休眠(快照存盘) | 恢复会话,历史完整,可继续聊 |
| 关掉 Tab 第二天才来 | >10 分钟 | 已休眠 | 同上 |
| 正在等审批时关掉 | 任意时长 | 保持等待(特殊保护) | 手机上审批 / 回来后补推审批弹窗 |
| 同时开了 3 个 Tab | --- | 正常工作 | 所有 Tab 同步收到消息 |
| Agent 卡死了 | --- | Watchdog 检测到 | 自动回收,不影响其他会话 |
七、设计决策:为什么这样设计?
7.1 为什么用 Ring Buffer 而不是别的?
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Ring Buffer(我们的选择) | 内存操作极快 O(1),自动覆盖旧数据,不占磁盘 | 容量有限,重启丢失 | 短期补发(秒~分钟级) ✅ |
| 无限数组 | 简单 | 内存可能爆掉 | 不推荐 |
| 每条消息写数据库 | 重启不丢 | 太慢,IO 开销大 | 长期持久化(我们有另一套 SnapshotStore 干这事) |
| Redis / Kafka | 专业消息队列 | 引入新依赖,增加架构复杂度 | 超高并发场景 |
我们的选择是 Ring Buffer(短期)+ SnapshotStore(长期) 的组合:Ring Buffer 负责秒级的快速补发,SnapshotStore 负责分钟级的完整恢复。各司其职,互不冲突。
7.2 为什么有三层超时而不是一个?
如果只有一个超时机制(比如"断连 10 分钟后就销毁"),会遇到两难:
- 设短了(比如 1 分钟):用户只是切出去回个消息,回来发现 Agent 被杀了 😤
- 设长了(比如 30 分钟):用户早就走了,Agent 还占着内存和连接不放 🗑️
所以我们是由短到长、逐层升级:
scss
DisconnectTimer (60s)
↓ 超时且空闲 → 快速回收(大部分场景在这里就解决了)
↓ 超时但在忙 → 不管它,等它忙完
↓
IdleTimeout (10min)
↓ 忙完也超过 10 分钟没活了 → 回收(兜底)
↓
Watchdog (30s 间隔巡检)
↓ 检测到卡死(异常情况)→ 强制回收(最后一道防线)
7.3 和豆包方案对比
| 维度 | 豆包(推测) | 我们的方案 |
|---|---|---|
| 协议 | WebSocket + Protobuf | WebSocket + JSON NDJSON |
| 序号机制 | 可能有(Protobuf 天然支持 seq) | ✅ 显式 seq 单调递增 |
| 消息缓存 | 可能有内部 Buffer | ✅ Ring Buffer(显式管理) |
| 进程模型 | 不明(可能是无状态服务端) | ✅ Worker 进程隔离 + 快照持久化 |
| 多 Tab 支持 | 可能有(IM 基因) | ✅ 1:N 广播模型 |
| 长断连恢复 | 服务端持久化 | ✅ 快照恢复 + Worker 重建 |
| 超时保护 | 不明 | ✅ 三层超时 + Watchdog |
| HITL 离线兜底 | 不明(纯对话产品无需) | ✅ 大象 IM 通知 + 断连保持 |
总结
用一句话概括:"写后即缓存、序号定位断点、分层超时保护、快照兜底恢复"
四个关键词对应四个问题:
| 关键词 | 回答的问题 |
|---|---|
| 写后即缓存 | 断连期间的消息去哪了?→ Ring Buffer 存着 |
| 序号定位断点 | 重连后从哪接着?→ lastSeq 精确找到边界 |
| 分层超时保护 | 资源什么时候释放?→ 60s 快速回收 / 10min 兜底 / Watchdog 防卡死 |
| 快照兜底恢复 | 很久才回来怎么办?→ 重建 Worker + 还原完整状态 |
这套方案在我们的系统上已经稳定运行,支撑了包含 LLM 流式输出、工具调用、子 Agent 编排、人工审批等复杂场景的可靠通信。