如何实现基于 WebSocket Agent 的断线重连与状态恢复

前言

最近在做业务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 编排、人工审批等复杂场景的可靠通信。

相关推荐
树獭非懒3 小时前
从N-gram到Transformer:大语言模型架构演进之路
程序员·llm·agent
李燚3 小时前
流式消费:从 StreamReader 到 SSE 推送
agent·ai编程·stream·开发框架·sse·agent框架·streamreader
copyer_xyf4 小时前
Agent Tool 调用
后端·python·agent
copyer_xyf4 小时前
Agent 结构化输出
后端·python·agent
玉鸯5 小时前
理解 Agent 的运行时心脏--从零写一个 Agent Loop
agent
HIT_Weston5 小时前
115、【Agent】【OpenCode】项目配置(SemVer)
人工智能·agent·opencode
啾啾Fun5 小时前
【LLM应用可靠性】2-RAG 生产失败模式:如何避免检索生成系统的性能退化
ai·llm·系统设计·rag