做 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 轮询 | SSE | WebSocket |
|---|---|---|---|
| 服务端主动推送 | 不支持 | 单向支持 | 双向支持 |
| 客户端上行命令 | 每次都要新请求 | 可发但别扭 | 天然支持 |
| 实时进度/工具回调 | 延迟高 | 实时 | 实时 |
| 连接开销 | 高 | 低(单路) | 低(单连接) |
| 双向同时通信 | 不支持 | 不支持 | 全双工 |
轮询是"客户端去问",SSE(Server-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 值,服务端拿到后拼接固定 GUID(258EAFA5-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 次。心跳太频繁会增加无谓流量;太稀疏则不能及时发现死连接。以下是常用的配置参考:
| 场景 | 心跳间隔 | 超时判定 |
|---|---|---|
| 公网浏览器客户端 | 30s | 90s(连续3次无Pong) |
| 内网服务间通信 | 15s | 60s |
| 移动端弱网环境 | 20s | 120s(容忍抖动) |
服务端实现上,注意心跳和业务消息要分开:不要用人手一条业务消息来充当心跳,因为业务消息可能长时间没有,Ping/Pong 是协议层机制,优先级更高、不受业务阻塞影响。Node.js 的 ws 库提供了ws.ping()方法,你只需要维护一个isAlive标记,配合setInterval定期清理即可。
3. 实操:搭建 Agent 场景下的 WebSocket 服务
3.1 服务端选型与最小实现
Node.js 生态里最常用的 ws 库,它 API 简洁、性能足够,还能配合 HTTP Server 实现同一个端口同时提供 REST 和 WebSocket。我这边生产环境也踩过一些框架的路数,最终选型逻辑很简单:团队本来就用 Node.js,ws 库无额外依赖、可定制能力强,握手校验、连接管理、消息路由全都可以自己控制。
一个最小可用的服务端长这样:
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 的能力才能真正"活"起来,实时性和交互性才体现得出来。