资讯动态

Agent场景下的WebSocket服务设计:握手原理、心跳机制与落地排坑指南

发布时间:2026/10/6 3:10:46 来源:尧图企业网站定制
做 Agent 项目的人迟早会在通信层卡一次壳。我这边最初搭建 Agent 服务的时候第一版全部走 HTTP 轮询服务端跑任务、客户端等结果一开始觉得挺简单等 Agent 任务多了以后问题全冒出来了任务状态要反复查询、工具调用进度没法实时推给前端、多 Agent 协作时的中间消息全靠数据库中转。后来把 WebSocket 引入架构才真正把 HTTP 的单向壁垒给打破。这篇文章就围绕Agent 场景下 WebSocket 服务怎么设计、怎么落地、怎么排坑展开讲清楚 WebSocket 的握手原理、心跳机制、并发参数和常见故障适合正在做 Agent 开发、想给系统加实时通信能力的开发者参考。1. 项目概述HTTP 的单向模型为什么撑不住 Agent 场景1.1 请求-响应模型的本质限制HTTP 从设计之初就是一个请求-响应模型客户端发请求服务端给响应一次会话结束。这个模型在网页浏览、REST API 这类场景下非常合适但放到 Agent 场景里就有明显的不匹配。拿我最初踩坑的例子来说一个 Agent 正在执行一个需要多步骤工具的复杂任务比如查询数据 → 调用外部 API → 解析结果 → 生成报告。如果走 HTTP客户端只能每隔几秒来轮询一次任务状态。轮询间隔短了服务端压力大、请求堆积轮询间隔长了用户体验差任务已经跑完了客户端还在傻等。而且轮询拿到的往往是一个个瞬间的快照中间的过程信息——工具调用了哪一步、这一步卡了多久、返回了什么中间结果——全都拿不到。另一个问题是连接本身的开销。HTTP/1.1 虽然支持 keep-alive 连接复用但每个请求仍然需要完整的请求头、响应头数据面是文本化的。HTTP/2 的多路复用解决了部分问题但仍然是客户端主动、服务端被动的单向模型。对 Agent 这种服务端在持续产生事件的场景本质上方向是拧着的。1.2 Agent 的实时通信需求清单把 Agent 场景下的通信需求拉一个清单其实很清晰需求HTTP 轮询SSEWebSocket服务端主动推送不支持单向支持双向支持客户端上行命令每次都要新请求可发但别扭天然支持实时进度/工具回调延迟高实时实时连接开销高低单路低单连接双向同时通信不支持不支持全双工轮询是客户端去问SSEServer-Sent Events是服务端单向推只有 WebSocket 是真正意义上把一问一答变成了随时说、随时听。Agent 场景里服务端要推送的不只是最终结果还有中间状态工具调用日志、任务进度百分比、等待人工确认的提示、子 Agent 的回报消息。这些消息如果全部靠客户端轮询不仅延迟大而且协议设计会非常别扭——因为你本质上是在用一个拉的协议去实现推的能力。而且 Agent 项目还有一个容易被忽略的需求人工介入human-in-the-loop。某些高危操作需要用户实时确认确认动作本身又要走一个交互流程。HTTP 轮询实现这个非常痛苦而 WebSocket 的双向通道让服务端推送审批请求 → 客户端回传审批结果变成一个自然的消息交换过程。1.3 为什么 SSE 替代不了 WebSocket很多同学会问既然只是服务端推消息SSE 就够了为什么要上 WebSocket我实际对比过。SSE 基于 HTTP踩在现有协议栈上部署简单、自动重连也有现成方案但它只有一条下游通道上游客户端到服务端要么另开 HTTP 接口要么用 fetch 发请求等于还是拆成两个通道。而 WebSocket 是一条全双工连接上下游消息走同一个连接、同一个帧序列顺序性、关联性都更好。对于一个工具调用频繁、交互密集的 Agent 系统消息之间的因果关系很重要前端收到工具A开始执行就应该能关联到工具A返回结果。如果上游走 HTTP、下游走 SSE两条链路的消息就存在天然的对齐问题。WebSocket 单连接模型你可以在同一连接上定义 requestId 来关联消息出问题也好排查。当然 SSE 适合轻量推送场景但论 Agent 这种强交互系统的主通信管道WebSocket 是更合适的底座。2. 核心机制拆解从一次握手到一条全双工链路2.1 HTTP Upgrade 握手到底发生了什么WebSocket 不是一个全新的协议它是借 HTTP 的壳完成升级的。客户端先发一个普通的 HTTP 请求带 Upgrade 头服务端同意后返回 101 Switching Protocols双方就切换到 WebSocket 协议了。具体的握手请求长这样GET /ws/agent HTTP/1.1 Host: api.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13 Origin: https://console.example.com这里最关键的是 Sec-WebSocket-Key它是一个随机 Base64 值服务端拿到后拼接固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11做 SHA-1 哈希再 Base64 编码得到 Sec-WebSocket-Accept 返回给客户端。这个机制的作用是防缓存代理普通 HTTP 代理可能缓存 Upgrade 请求导致连接被错误复用加入挑战-应答之后可以确认服务端确实支持 WebSocket。实际开发中你不会手写这段握手但理解它的意义在于排障。比如你看到服务端返回 426 Upgrade Required说明网关/代理层不支持 Upgrade 头返回 400 说明 Sec-WebSocket-Key 缺失或格式不对。这些状态码是排查 WebSocket 接入问题最直接的线索。另外如果客户端和服务端需要协商子协议比如约定消息格式或者鉴权方式可以在握手时带上Sec-WebSocket-Protocol头两边都支持才会继续握手这个也是我推荐的身份传递方式之一。2.2 帧协议消息是怎么在连接上跑的握手完成后通信就不再是 HTTP 报文了而是 WebSocket 帧。一个帧由固定头2字节起步加负载组成首字节里 FIN、OPCODE 决定消息边界和类型第二个字节的 MASK 标志位区分客户端到服务端必须掩码和服务端到客户端不掩码。OPCODE 里 0x1 是文本帧、0x2 是二进制帧、0x8/0x9/0xA 分别是关闭帧、Ping 帧和 Pong 帧。理解帧结构对做 Agent 服务有什么实际帮助两点一是消息大小限制每个帧的负载长度可以用 7 位、16 位或 64 位扩展表示但实践里大多数服务和代理都会设置单帧/单消息上限比如 1MB。Agent 工具返回大文本、大 JSON 时很容易撞上这个上限所以设计消息协议时要考虑到分片或者压缩。二是在浏览器端监听消息时一个大消息可能被拆分到多个帧WebSocket API 会帮你重新组装服务端如果自己解析帧边界必须处理 FIN 位和续帧。2.3 心跳机制怎么设计才不死连接又不浪费资源WebSocket 连接稳定性的核心问题之一TCP 连接可能已经断开但双方都不知道。比如客户端手机切网、服务端机器重启、中间 NAT 超时回收映射连接就变成半开状态。这时候如果你只靠 TCP 层面的机制可能要等很久才报错。所以心跳机制是必须做的。标准姿势是协议自带的 Ping/Pong 帧服务端每隔 N 秒发一个 Ping 帧客户端必须回一个 Pong 帧浏览器端 WebSocket API 会自动回 Pong不需要你手动处理如果在超时时间内没收到 Pong服务端就可以判定连接已死主动断开并触发清理。心跳间隔怎么选我实践下来的经验公式间隔取网络最差 RTT 的 10 倍以上但小于IaaS/代理空闲回收时间的一半。比如服务部署在云上很多负载均衡器空闲连接回收是 300 秒到 600 秒那心跳间隔可以取 30~60 秒超时重试取 2~3 次。心跳太频繁会增加无谓流量太稀疏则不能及时发现死连接。以下是常用的配置参考场景心跳间隔超时判定公网浏览器客户端30s90s连续3次无Pong内网服务间通信15s60s移动端弱网环境20s120s容忍抖动服务端实现上注意心跳和业务消息要分开不要用人手一条业务消息来充当心跳因为业务消息可能长时间没有Ping/Pong 是协议层机制优先级更高、不受业务阻塞影响。Node.js 的 ws 库提供了ws.ping()方法你只需要维护一个isAlive标记配合setInterval定期清理即可。3. 实操搭建 Agent 场景下的 WebSocket 服务3.1 服务端选型与最小实现Node.js 生态里最常用的 ws 库它 API 简洁、性能足够还能配合 HTTP Server 实现同一个端口同时提供 REST 和 WebSocket。我这边生产环境也踩过一些框架的路数最终选型逻辑很简单团队本来就用 Node.jsws 库无额外依赖、可定制能力强握手校验、连接管理、消息路由全都可以自己控制。一个最小可用的服务端长这样const { WebSocketServer } require(ws); const http require(http); const { verifyToken } require(./auth); const server http.createServer((req, res) { res.writeHead(200, { Content-Type: text/plain }); res.end(agent ws service running); }); const wss new WebSocketServer({ server, // 以路径区分不同的 Agent 通道 path: /ws/agent, maxPayload: 1024 * 1024 }); wss.on(connection, (ws, req) { // 这里可以拿到握手时的 token 参数 const token new URL(req.url, http://localhost).searchParams.get(token); const session verifyToken(token); if (!session) { ws.close(4401, unauthorized); return; } ws.isAlive true; ws.session session; // 业务消息 ws.on(message, (data, isBinary) { // 这里做消息分发 handleAgentMessage(ws, data.toString()); }); ws.on(close, () { // 清理会话、释放 Agent 任务资源 }); ws.on(error, (err) { console.error(ws error, err); }); }); server.listen(8080);几个细节值得注意。第一maxPayload必须设否则一个客户端可以发超大消息把你的内存打爆。第二连接建立后马上做 token 校验把未授权连接用close 4401拒绝而不是放任它进来后再断。第三req.url在升级场景下能拿到查询参数token 放查询参数虽然简单但生产上更推荐放在Sec-WebSocket-Protocol子协议里或者 cookie 里避免 token 被日志系统记下来。3.2 客户端接入与消息协议设计客户端我用浏览器原生 WebSocket 加自己封装的重连逻辑。先看基础接入class AgentSocket { constructor(url) { this.url url; this.ws null; this.reconnectTimes 0; this.maxReconnect 5; } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { this.reconnectTimes 0; this.send({ type: subscribe, taskId: task-123 }); }; this.ws.onmessage (evt) { const msg JSON.parse(evt.data); this.handleMessage(msg); }; this.ws.onclose () this.scheduleReconnect(); this.ws.onerror (err) console.warn(ws error, err); } scheduleReconnect() { if (this.reconnectTimes this.maxReconnect) return; const delay Math.min(1000 * 2 ** this.reconnectTimes, 30000); this.reconnectTimes 1; setTimeout(() this.connect(), delay); } send(msg) { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify(msg)); } } }指数退避重连是必须的第一次重连等 1 秒、第二次 2 秒、第三次 4 秒封顶 30 秒。这样服务端重启、网络抖动恢复后客户端能自己爬回来又不会在故障期间把服务端冲垮。还有一个容易被忽略的点重连成功后要把之前订阅的 Agent 任务重新订阅一遍因为新连接上服务端还不知道你要听什么。消息协议设计我推荐一个统一的信封格式所有消息都走同一个结构{ id: msg-uuid-001, type: agent.tool.progress, ts: 1700000000000, taskId: task-123, payload: { tool: http_client, status: running, detail: calling external api } }id用于客户端和服务端对账type是消息类型ts是时间戳taskId用来把消息挂到具体任务下payload放业务数据。这个信封定下来之后订阅、取消订阅、工具回调、任务结果、错误通知全都可以塞进去不用为每种消息单开一套接口。3.3 Agent 消息与并发一个服务能扛多少连接这是 Agent 项目逃不开的问题AI Agent 任务本身是异步的服务端要给几百上千个在跑的 Agent 任务推送消息一个 WebSocket 服务能扛住吗先说结论WebSocket 连接本身很轻单机扛几万连接很正常瓶颈通常不在连接数而在业务处理链路和消息吞吐。你每收到一条消息就要解析 JSON、查库、调 Agent 编排这些操作才是吃 CPU 和 I/O 的部分。在并发设计上我建议做三层解耦。第一层连接层只负责维持连接和解析消息不直接做重活。第二层把业务消息丢进一个带缓冲的消息队列本地可用 Node.js 内置的异步队列规模大了可以换 Redis Stream 或 RabbitMQ。第三层Agent 执行引擎作为消费者从队列取任务任务完成或需要推送进度时把结果写回对应连接。这样一个连接对应一个 Agent 任务会话任务的耗时和阻塞不会拖垮连接线程。具体的扛并发参数我的经验值是Node.js ws 服务单机、默认事件循环20 万条/分钟的小消息1KB推送没有太大压力如果你要推 100KB 以上的大消息建议先做 Gzip 压缩或者干脆推消息通知让客户端主动拉取大内容。压缩 JSON 在 Agent 场景里收益明显工具返回的文本往往有大量重复结构gzip 能压到 1/5 到 1/10。另外如果你的服务端是单实例可以考虑用 Node.js 的 cluster 模块开多进程。但要注意cluster 模式下每个进程各自维护 WebSocket 连接广播消息时需要把消息分发到所有 worker通常用进程间通信或者 Redis Pub/Sub 实现。这个后面在横向扩展里细说。3.4 Agent 任务状态机与消息对齐WebSocket 通道打通之后下一个问题就是Agent 任务的状态怎么跟消息对齐。我见过不少团队把 WebSocket 当成垃圾桶什么事件都往上丢结果客户端收到的消息乱成一锅粥。我的做法是在服务端维护一个任务状态机一个 Agent 任务至少经历pending → running → waiting_input → completed/failed这几个状态。状态变更时往 WebSocket 推送一条agent.task.state_changed消息同时把这条消息持久化到任务记录里。客户端重连之后先调用一个状态查询接口拉取当前任务的最新快照再回到 WebSocket 上接收后续增量事件。这样做的好处是WebSocket 只负责实时增量不负责全量恢复。连接断了、消息丢了客户端重连后从快照补齐消息本身的可靠性压力就小了很多。这是一个很重要的架构取舍——不要把 WebSocket 当成可靠消息管道它就是一条低延迟的事件通道。4. 常见问题与排查技巧实录4.1 连接悄悄断开半开连接与心跳失效我遇到过最典型的故障客户端界面显示已连接服务端连接列表里也有这条连接但消息怎么都推不到客户端。这就是半开连接——TCP 链路已经被中间设备NAT 网关、移动基站、云负载均衡悄悄断掉了但两端都没有感知。排查套路很固定先看服务端有没有在跑心跳再看心跳间隔是否比中间设备的空闲回收时间短最后看客户端是否正确处理了close事件并触发重连。这里有个坑浏览器端的 WebSocket 对服务端 Ping 会自动回 Pong但如果服务端收不到 Pong浏览器那边什么都不知道它不会主动报错。所以服务端必须靠连续 N 次未收到 Pong 就主动断开来触发客户端的close事件客户端再走重连逻辑这套链路才算闭环。另外要注意代理层的超时。很多 Nginx 默认的proxy_read_timeout是 60 秒如果你的心跳间隔超过这个值代理会先断你的连接。我当时排查一个连接老是被断的问题最后发现就是 Nginx 的代理超时配置没调心跳 60 秒、代理 60 秒回收两边掐得刚刚好连接每隔 60 秒必断一次。把proxy_read_timeout改成 300 秒再配合 30 秒心跳就稳定了。4.2 安全与会话治理WebSocket 不是免检通道WebSocket 连接建立后就在防火墙内部长期存活这本身就是安全治理的挑战。做 Agent 服务的 WebSocket 时我会坚持三件事连接前的鉴权、消息层面的校验、会话生命周期的管理。连接前的鉴权除了握手时校验 token还要做 Origin 校验。浏览器端的 WebSocket 会带 Origin 头恶意网站可以用你的合法 token 建立连接但 Origin 校验能挡住一部分跨站滥用。注意Origin 校验只能作为辅助手段真正的防线还是 token 和后续的消息校验。消息层面要做的是每条业务消息都要带身份上下文不能因为连接已经握手鉴权过就信任后续所有消息。我看到过因为 WebSocket 连接没有做消息级权限校验导致一个低权限用户在一个已授权连接里发送高权限操作指令的事故。对 Agent 系统尤其如此Agent 往往有工具调用权限如果一个会话被劫持就等于攻击者拿到了 Agent 的工具箱。所以权限判断应该落在消息层而不是连接层。会话生命周期管理上连接断开时要及时清理会话状态、终止在跑的 Agent 任务、释放资源。否则连接断了任务还在跑要么白耗资源要么任务结果推给一个已经不存在的连接状态永远对不上。我这边用 Redis 存会话映射表连接断开时做一次异步清理把与该会话关联的 Agent 任务标记为中断并通知编排引擎。4.3 从 HTTP 到 WebSocket 的升级失败与状态码排查WebSocket 接入最常见的问题都是握手阶段就失败的排查时先看 HTTP 状态码状态码含义常见原因101切换协议成功正常400请求格式错误Sec-WebSocket-Key 缺失、Upgrade 头格式不对426需要升级协议服务端要求 Upgrade客户端没带403拒绝连接Origin 不在白名单、IP 限制401/4401未授权token 校验失败500服务端内部错误升级处理逻辑抛异常我在迁移一个老 Agent 服务时遇到过400 Request Header Fields Too Large错误因为把整个用户上下文塞进了 URL 查询参数URL 太大在网关层就被拦了。后来改成握手时只传一个短 token上下文信息统一放消息里问题立刻解决。还有一个高频坑反向代理没开启 Upgrade 支持。Nginx 默认会丢弃 Upgrade 头必须在 location 里显式配置location /ws/agent { proxy_pass http://agent-ws-backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 300s; proxy_send_timeout 300s; }这个配置踩过的人都知道忘掉proxy_set_header Connection upgrade这一行WebSocket 就永远建立不起来。顺便提醒一句如果你的反向代理是 Caddy 或者 HAProxy对应的配置语法不同但思路一样要把 Upgrade 和 Connection 头原样透传过去。4.4 消息积压与背压别让内存先炸还有一个容易被忽视的问题服务端一条消息推出去客户端消费不过来怎么办WebSocket 的消息是即时推送的push 的速度大于客户端处理速度消息就会堆积在发送缓冲区。我见过推送大任务结果时Node.js 进程内存涨到几百 MB 最后 OOM 的。解决思路是引入背压机制。发送前检查ws.bufferedAmount如果缓冲区超过阈值就暂停发送等客户端消费一部分再继续。或者做采样推送对进度类消息哪怕底层产生 100 条真正推给客户端的也不超过每秒钟 1 条中间状态聚合后再推。毕竟进度条的消息多推几条少推几条用户感知不大内存暴涨才要命。服务端整体还有一层保险在连接对象上挂一个待发送队列队列上限比如 1000 条超过上限就主动断开让客户端重连并从任务状态接口恢复。这个策略看起来粗暴但比让内存无限增长要可靠得多。断线后客户端会按 3.4 说的方式从快照补数据对用户来说只是进度条卡了一下。5. 性能扩展与实战心得5.1 压测与资源测算别凭感觉估并发我自己的流程是先把服务端代码稳定下来然后做一轮 WebSocket 压测。工具上用 ws 自带的小脚本或者 Artillery 都行指标主要看三样每秒能建立多少连接握手吞吐、稳定状态下的消息吞吐、单连接内存占用和 CPU 趋势。通常你会得到一个规律连接数从 1 万加到 5 万CPU 变化不大但消息吞吐一旦上去CPU 立刻飙升。这说明消息处理才是第一瓶颈连接管理反而是小事。压测结果可以用来定集群规格比如单机预计 8 万连接、每秒处理 50 万条小消息那 100 万连接的规模就规划 12~15 台。别只按连接数去扩容那样大概率预算花了不少真实瓶颈一个没解决。压测的时候我还会故意制造一些异常场景杀掉一个后端进程看客户端重连速度、断掉一个中间设备看心跳能不能及时发现、把推送方速度拉到正常值的 10 倍看背压机制是否生效。这些场景往往比正常压测更能暴露问题。5.2 横向扩展多实例的路由与消息分发单机扛不住的场景就得横向扩展。WebSocket 横向扩展最大的坑是粘性会话一台 WebSocket 服务挂了坠在上面的客户端必须重连到另一台但如果你的 Agent 任务状态存在本机内存里重连后的新机器根本不知道这个任务的状态。推荐的做法是把连接和状态分离Agent 任务状态放 Redis 或者数据库WebSocket 服务只做消息转发。消息分发可以用 Redis Pub/Sub 或者消息队列做扇出连接 A 归属实例 1连接 B 归属实例 2当某个 Agent 任务需要同时推送进度给 A 和 B 时任务引擎把消息发到 Redis Channel每个实例订阅同一个 Channel收到后只推给本机持有的相关连接。这套模式我实测下来是标准的扩展姿势。还有一种方案是给 WebSocket 服务加一层 L7 网关或者负载均衡客户端重连时带上同一个会话 ID负载均衡根据会话 ID 做一致性哈希路由保证同一客户端的重连请求尽量打到同一实例上。好处是连接迁移成本低坏处是负载均衡的会话保持本身也有超时长 WS 连接要自己配好空闲超时。5.3 个人体会先把通信模型设计对再谈高并发最后说点实在的体会。我见过很多 Agent 项目一上来就谈高并发、十万连接结果连最基础的消息协议都没设计明白消息没带 ID、没有超时重试、服务端推完就忘。通信层一旦乱后面 Agent 的编排、记忆、工具调用全都依赖这一层的不确定性整个系统就变得很难调试。对于 Agent 这种异步、多阶段、强交互的系统我的建议是先把中间态和事件流当成一等公民用 WebSocket 把整个事件流完整地送到客户端剩下的业务逻辑怎么编排都好说。我把自己的实践整理成一个清单消息必带 ID 和时间戳心跳必须做且间隔要选对连接断开必须触发会话清理消息推送必须带背压鉴权必须在消息层做二次校验。这套清单在多个 Agent 项目里都验证过照着抄基本不会出大问题。如果你正在做 Agent 服务端WebSocket 这一层值得花一个完整迭代期来打磨别等项目上线了被在线状态、消息丢失、连接耗尽这类问题追着跑。先把这条双向通道打通、做稳Agent 的能力才能真正活起来实时性和交互性才体现得出来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑