WebSocket Agent断线重连与状态恢复实战指南

2026-06-15阅读 0热度 0
其他

前言

最近在捣鼓业务Agent开发时,碰到一个挺棘手的问题:当用户正在看AI流式输出,突然关掉页面、切换网络或者刷了个浏览器,对话内容怎么保证不丢、状态怎么恢复?这个问题看起来简单,实际落地才发现坑不少。这里分享一下我们当时的实现思路,希望能给遇到类似问题的同学一些参考。

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


前置调研:主流 AI 产品是怎么做的?

ChatGPT / Claude(SSE 方案)

SSE 有点像“半程高速路”——服务端只能单向往客户端推数据,客户端没法反向发送请求。

正常流程:
  浏览器 POST /api/chat → 服务端保持连接不断推送 text/event-stream → 完成断连流程:
  用户刷新页面 → SSE 连接断开 → 服务端那边其实还在继续生成
  → 但浏览器已经拿不到了
  → 用户重新打开页面 → 通过 HTTP API 加载历史消息
  → 如果后端落库了 - 用户能直接看到完整消息,但是没有流式渲染,以及thinking等部分,会是直接出结果
    如果后端部分落库 - 已生成的部分能恢复(因为服务端持久化了),正在生成的那部分?看运气,可能丢失最后几个 token

SSE的哲学很干脆——断了就是断了,不搞什么花哨的重连协议。核心依赖的是服务端持久化:每条消息生成完就写进数据库,前端刷新后从数据库拉历史。简单粗暴,但确实有效。

豆包(WebSocket 方案)

WebSocket 就不一样了,它是“全程高速公路”——双向车道,随时可以收发。

正常流程:
  浏览器建立 wss:// 连接 → 双向通信 → 服务端推送二进制帧断连流程(抓包分析):
  用户刷新页面 → ws.on('close') 触发
  → 客户端记录最后收到的消息序号(seq)
  → 重新建立 wss:// 连接
  → 握手时带上 lastSeq:"我从第 47 条之后没收到"
  → 服务端查找 seq > 47 的消息 → 批量补发
  → 前端回放补发的消息 → 无缝衔接 

豆包的优势在于,WebSocket双向通道配合Protobuf的序号机制,理论上可以实现真正的断点续传——就像视频缓冲一样,断网几秒重连后中间的内容自动补上,用户几乎无感知。这个思路给了我们很大启发。

一、先说问题:为什么断线这么麻烦?

1.1 一个真实的痛点场景

不妨想象一个具体的场景——用户正在使用我们的AI Agent:

时间线: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:消息同步,不会重复或丢失

二、整体方案一览:三层防护

我们的方案可以概括为"三层防护",每一层解决不同时长范围的断连问题:

断连时长                解决方案                  一句话解释
─────────────────────────────────────────────────────────────0秒 ~ 几秒              Ring Buffer 补发          "你没看到的消息我先帮你存着,
                         (内存级缓存)             回来后一次性给你"几秒 ~ 1分钟            同上 + DisconnectTimer   "你还没回来,但我再等你一会儿;
                         (等待重连)               超时了我就先把 Agent 休眠"1分钟 ~ 10分钟+         快照恢复 (SnapshotStore)    "你太久没回来了,我把 Agent 的
                         (持久化 + 重建 Worker)     状态存盘了;你回来时我重新启动"

除了这三层,还有三个定时器在做后台守护:

┌─ DisconnectTimer (60s) ──→ "WS 全断了,Agent 空闲的话就休眠吧"
├─ IdleTimeout (10min) ────→ "Agent 太久没干活了,休眠释放资源"
└─ Watchdog (30s 间隔) ───→ "检查有没有 Agent 卡死了,卡死就强制回收"

下面分层详解。


三、第一层:Ring Buffer —— 你的消息快递暂存点

3.1 它是什么?

Ring Buffer(环形缓冲区)本质上是一个固定大小的内存队列,专门用来临时存放"已经发给前端但前端可能没收到"的消息。

你可以把它想象成一个传送带

服务端产生消息
    │
    ▼
┌─────────────────────────────────────────┐
│           Ring Buffer (环形缓冲区)      │
│                                         │
│   [seq:3] [seq:4] [seq:5] [seq:6]      │"你好"  ","    "我"    "来"
│                                         │
│   ↑ 新消息从这里写入(覆盖最旧的)        │
│   ↑ 读指针从这里读取(找 lastSeq 之后)   │
└─────────────────────────────────────────┘
    │
    ▼
 推送到前端的 WebSocket(如果还连着的话)

关键点在于:每条消息写入时都会分配一个递增的序号(seq),就像快递单号一样。前端知道自己最后收到的是第几号,重连时告诉服务端"我从第 N 号之后没收到了",服务端就把 N 号之后的所有消息一次性返回。

3.2 发送消息时的逻辑

每次服务端要往前端发消息,都走这个流程:

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 里取。
}

这就是"写后即缓存"原则——宁可多存一条,不可漏掉一条。毕竟对于AI对话这种场景,少一个token都可能导致上下文不完整。

3.3 重连时怎么补发?

前端重连时的握手过程大概是这样的:

前端                              服务端
 │                                  │
 │  ← 建立 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 不是所有消息都需要缓存

当然,有些消息是"控制信号",不需要补发(比如心跳响应、错误提示等)。我们只缓存业务消息

 需要缓存(断线后补发给用户看的):
   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 三层心跳机制

我们设计了三套独立的心跳机制,层层递进,互为补充:

1 层:WS 协议层 ping/pong(最快,30s 一次)
─────────────────────────────────────────────
  
  如果 90 秒内都没收到 pong → 认为连接死了 → 主动关闭2 层:握手超时(5s)
─────────────────────────────────────────────
  WS 连接建立后,5 秒内没收到前端的 connect 握手消息
  → 认为是恶意连接或异常客户端 → 直接踢掉第 3 层:业务层 DisconnectTimer(最慢,60s 起)
─────────────────────────────────────────────
  该会话的所有 WS 连接都断开后启动
  等 60 秒看用户回不回来
  
  回来了 → 取消 timer,一切正常
  没回来 + Agent 空闲 → 休眠 Agent 释放资源
  没回来 + Agent 还在忙 → 再等等,忙完再说
  

这三层设计其实有讲究:第一层解决网络层面的断开检测,第二层防止恶意连接浪费资源,第三层才是真正和业务逻辑耦合的断连处理。各司其职,互不干扰。

4.2 断连发生的完整链路

用户关闭浏览器标签页
    │
    ▼
浏览器的 WS 连接自动关闭
    │
    ▼
WsGateway 收到 close 事件
    │
    ├─ 1. 把这个连接从"活跃列表"里移除
    │     (停止它的心跳计时器)
    │
    └─ 2. 这个会话还有其他连接吗?(比如用户开了两个 Tab)
          │
          ├─ YES → 还有别的 Tab 在线,什么都不做 
          │
          └─ NO → 所有连接都断了 ️
                │
                ▼
           通知 SessionManager:"WS 全断了!"
                │
                ▼
           启动 DisconnectTimer(倒计时 60 秒)
                │
           ┌────┴────┐
           │         │
      60s 内     60s 到了
      用户回来了   用户没回来
      (重连成功)   (Worker 空闲?)
           │         │
           ▼         ▼
      取消 Timer  休眠 Worker
      一切如常     保存快照 → 释放资源

这里有一个值得注意的细节:当用户开了多个Tab时,关掉其中一个Tab不会触发DisconnectTimer,因为还有其他Tab在线。这个设计对多设备或多Tab用户非常友好。


五、第三层:快照恢复 —— 长时间断连怎么办?

5.1 什么时候触发"长期恢复"?

有两种情况会让 Worker 被"休眠"(保存状态后退出):

触发条件超时时间说明
WS 全断 + Agent 空闲60 秒 (DisconnectTimer)最常见:用户关掉页面走了
Agent 太久没活干10 分钟 (IdleTimeout)用户开着页面但没发消息

一旦 Worker 被休眠,它的完整状态会被保存为一份快照(Snapshot)

// 快照里存了什么?
{
  sessionId: "abc123",
  messages: [...],              // 完整对话历史(包括所有工具调用的细节)
  turnCount: 5,                // 已经聊了几轮
  userContext: {...},          // 用户信息(角色、团队、权限)
  activatedSkillNames: [...],   // 已激活的 Skill 列表
  originalUserMessage: "...",  // 用户原始输入(恢复时可重新注入意图)
}

这份快照会存到 OSS 或数据库里,即使整个服务器重启也不会丢。这也意味着即使用户第二天才回来,之前的对话上下文依然可以完美还原。

5.2 用户回来后怎么恢复?

用户重新打开页面(距离上次断连已经过了 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),这时候用户关了页面:

Agent 执行敏感操作(比如要用沙箱跑命令)
    │
    ▼
弹出审批请求(hitl_request)→ 显示在前端
    │
    ▼
Agent 进入阻塞等待...(就像打电话等对方接听)
    │
     此时用户关闭了页面
    │
    ▼
正常情况下 DisconnectTimer 会启动,但我们做了特殊处理:
    │
    ├─ HITL_WAITING 状态下,标记 isActive = true(假装还在忙)
    │   → DisconnectTimer 检测到"还在忙" → 不回收 
    │   → IdleTimer 同理 → 不回收 
    │
    ├─ 启动离线通知兜底:
    │   → 60 秒后通过大象 IM(企业通讯工具)推送审批卡片到用户手机
    │   → 用户在手机上点"批准"
    │   → 大象回调 → 写入 hitl_result 到 Worker stdin
    │   → Agent 恢复执行 
    │
    └─ 或者用户重新打开页面(WS 重连)
        → 补推 hitl_request 弹窗
        → 用户在 Web 端点"批准" 

这个特殊处理的原因很直接:Agent正在等审批时,用户可能是无意中关掉页面,但审批需求还在。让Agent保持等待状态,同时通过企业通讯工具推送到手机,既不会丢失审批请求,也给了用户灵活选择的余地。


六、一张图看全所有场景

你干了什么过了多久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 还占着内存和连接不放

所以我们是由短到长、逐层升级

DisconnectTimer (60s)
  ↓ 超时且空闲 → 快速回收(大部分场景在这里就解决了)
  ↓ 超时但在忙 → 不管它,等它忙完
    ↓
IdleTimeout (10min)
  ↓ 忙完也超过 10 分钟没活了 → 回收(兜底)
    ↓
Watchdog (30s 间隔巡检)
  ↓ 检测到卡死(异常情况)→ 强制回收(最后一道防线)

这套机制的核心思想是"宽进严出":给用户足够的宽容时间,但一旦确定用户真的走了,就果断回收资源。Watchdog作为最后一道防线,主要是为了防止一些极端情况下的资源泄露。

7.3 和豆包方案对比

维度豆包(推测)我们的方案
协议WebSocket + ProtobufWebSocket + 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 编排、人工审批等复杂场景的可靠通信。当然,技术方案没有银弹,每一条设计决策背后都是场景和要求驱动的取舍。如果你也在做类似的系统,希望这些思考能给你一些参考。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策