做AI Agent开发的这半年,我越来越认可一句话:HTTP那套请求-响应模型,放到Agent场景里就是拧巴。你要实时拿大模型的流式回复,要推送工具调用状态,要多端同步会话上下文——这些需求用HTTP硬做,只能轮询、长轮询、SSE来回折腾,连接管理一团乱麻。换用WebSocket之后,一条长连接双向跑,Agent和前端、Agent和Agent、Agent和编排框架之间的通信一下清爽了。
这篇文章想聊的就是Agent场景下的WebSocket服务全景:从HTTP为什么不够用,到WebSocket握手原理、心跳机制、连接管理,再到高并发下的稳定性实战,最后把常见报错和排查思路一起列出来。适合正在做Agent服务端、做前端接入、或者手动搭Agent框架的兄弟们参考,不涉及具体平台,尽量把底层逻辑讲透。
1. Agent为什么离不开WebSocket:HTTP的单向壁垒
1.1 Agent交互的三个反常特性
先说一个反直觉的地方:Agent不是传统意义上的“问答接口”。用户发一条消息之后,Agent可能要做工具调用、查数据库、读文件、调大模型,再根据结果继续推理。这个过程是异步的,而且中间状态特别多——正在思考、正在调用工具、工具返回、正在生成回复,每个状态用户都想知道。WebSocket正好就是为这种“服务端有话说就能随时说”的场景设计的。
反观HTTP,它天生是“客户端一问、服务端一答”。服务端没有办法主动把一条消息推到客户端。你说可以用客户端频繁轮询来模拟推送,但Agent场景下,一次完整任务可能持续十几秒甚至几分钟。如果每2秒轮询一次,算下来一个用户一次对话就要产生几十个请求;如果用户同时开着多个Agent会话,光轮询请求就能把服务端压垮。这不是HTTP不优秀,是它不合适。
第二个特性是双向控制。用户看到Agent在跑一个长任务,他不一定只想干等,他还可能想中断、改参数、追加指令。WebSocket同一连接上,用户能发消息,服务端也能发消息,双向直接对话。这个体验用HTTP做,通常得开两个连接:一个短轮询或者SSE接收状态,一个POST发指令。两个连接还不好保证顺序,经常出现“指令发了,服务端还没收到前一条上下文”的错乱。
第三个特性是会话连续性。Agent的多轮对话要维护上下文,而Agent的编排进程本身就是一个有状态的长时运行单元。HTTP接口是无状态的,每次请求都要拼参数、带sessionId、重建上下文,非常繁琐。WebSocket连接天然带“连接即会话”的含义,连接建立时的鉴权、会话绑定可以一次完成,后续所有消息默认属于这个会话。
1.2 轮询、长轮询、SSE都在绕路
在进入WebSocket实战前,先看一下大家在Agent项目里最常见到的几种替代方案,以及它们各自的问题。
轮询是最简单粗暴的。前端每隔几秒向服务端发一个“有结果了吗”的请求。实现很简单,但问题也最明显:实时性取决于轮询间隔,间隔太短浪费带宽,间隔太长用户感觉卡顿。最难受的是,服务端大多数时候根本没有新数据,白白烧了一堆并发请求和数据库压力。Agent场景如果你做的是多用户在线,把并发都耗在空轮询上,后面真实请求反而排队。
长轮询是对轮询的改良:客户端发请求之后,服务端先hold住,等有数据了再返回,客户端收到后立刻发下一个请求。实时性比普通轮询好不少,但本质上还是一次请求一次响应,连接是断断续续的。在Agent这种高频消息推送场景,每来一条消息就要重新走一遍HTTP建连和header解析,效率很低。而且长轮询在Nginx网关下容易触达超时,又要调一堆timeout参数,很烦。
SSE看起来是最接近的替代品:服务端可以单向往客户端推消息,还是基于HTTP,兼容性好。做Agent的流式输出时,很多人第一反应就是SSE。但SSE有一个致命短板:客户端不能通过同一条连接给服务端发消息。在Agent场景里,用户要“停止生成”“换一个工具”“修改参数”,这些控制指令只能再开一条HTTP通道。两条通道容易乱序,也多了不少复杂度。另外SSE在部分浏览器和代理环境下有连接缓冲限制,实时性打折。
用一张表总结这几个方案在Agent场景下的表现:
| 维度 | 普通轮询 | 长轮询 | SSE | WebSocket |
|---|---|---|---|---|
| 实时性 | 差,取决于间隔 | 较好,但有延迟 | 好,单向 | 最好,双向即时 |
| 双向通信 | 支持 | 支持 | 不支持 | 支持 |
| 连接数量 | 大量短连接 | 大量半开连接 | 一条长连接 | 一条长连接 |
| 服务端推送能力 | 无,只能等请求 | 弱,需hold | 强 | 强 |
| Agent控制指令 | 可以,靠请求 | 可以,靠请求 | 需另开通道 | 同一条连接 |
| 网关超时风险 | 低 | 高 | 较高 | 需心跳保活 |
看完这个表,你就明白为什么我的结论是:Agent项目里,如果消息频率高、需要双向交互,直接上WebSocket;如果只是简单把大模型的流式结果推给前端,SSE也够用,但千万别用SSE做双向Agent控制。
1.3 WebSocket的本质:升级后的全双工通道
WebSocket不是一个全新的协议,它巧妙地借用了HTTP的握手,然后升级成独立的全双工协议。握手阶段,客户端发送一个普通HTTP GET请求,带上Upgrade: websocket和Sec-WebSocket-Key。服务端验证通过后,返回101 Switching Protocols。从这一刻起,这条TCP连接不再按“请求-响应”来约束,客户端和服务端可以随时往连接里写数据。
我常给团队打一个比方:HTTP像寄快递,你每次都要填单子、称重、打包,拿到了包裹这个链路就结束了;下次再寄又要重新来。WebSocket像通电话,两边拨通之后,谁想说话就什么时候说,不用每次重新拨号。Agent场景里,大模型每生成一个token、工具每次返回一个中间结果,服务端就可以直接通过这条“电话线”推到前端,完全不用等客户端来“领取”。
这里还要澄清一个关键点:WebSocket使用的是HTTP的握手端口(通常是80/443),但握手完成之后,它不再受HTTP语义约束。这个设计让WebSocket可以轻松穿过防火墙和大部分代理,因为代理看到的只是一次普通的HTTP升级。但反过来也说明,代理如果配置不当,可能会把“升级”请求按普通GET处理,导致握手失败。这个问题后面第5节会细讲。
2. WebSocket服务端搭建:从握手到帧解析
2.1 握手过程的关键细节
虽然现在主流语言都有WebSocket库,不需要自己写握手逻辑,但理解握手细节对排查问题很有帮助。握手的核心是验证合法性:客户端生成一段随机的Sec-WebSocket-Key,服务端拿到后用固定GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)拼接,再做SHA1哈希,最后Base64编码,得到Sec-WebSocket-Accept返回给客户端。如果这个值和浏览器本地计算的结果不一致,浏览器会直接断开连接。
这个设计的目的,主要是防止普通的HTTP缓存代理把WebSocket握手请求当普通GET缓存成静态资源。如果不做校验,一个存了响应缓存的代理可能把旧响应返回给新客户端,连接就错乱了。
服务端代码我在Node.js里常用ws库,最小化实现如下:
const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', (ws, req) => { console.log('客户端已连接:', req.url); ws.on('message', (data) => { const msg = JSON.parse(data.toString()); console.log('收到消息:', msg); // 回一条消息 ws.send(JSON.stringify({ type: 'ack', timestamp: Date.now() })); }); ws.on('close', () => { console.log('连接关闭'); }); });用浏览器直接连接:
const ws = new WebSocket('ws://localhost:8080/agent/ws?sessionId=abc'); ws.onopen = () => { ws.send(JSON.stringify({ type: 'ping' })); }; ws.onmessage = (event) => { console.log('来自服务端:', event.data); };这个过程看起来简单,但有个容易忽略的点:URL里sessionId如果直接跟在query上,会被Nginx日志和网关访问日志打出来,容易泄露业务ID。更稳的做法是把sessionId放到Path里,比如/agent/ws/abc,再配合鉴权token放在Sec-WebSocket-Protocol头里。这样既方便路由,又能减少日志泄露面。
2.2 帧格式与消息边界
WebSocket传输数据的基本单位是帧。每一帧包含控制信息和数据载荷,控制信息里最关键的是FIN、opcode、mask和payload length。
opcode决定帧类型:文本帧是0x1,二进制帧是0x2,ping是0x9,pong是0xA。FIN表示这一帧是不是消息的最后一帧。如果FIN=0,说明一个完整的业务消息被拆成了多帧,客户端要拼接完才能解析。浏览器和大多数库会自动处理分片,但如果你自己写协议解析或者用一些轻量级库,就一定要处理分片状态,否则收到的JSON是残缺的,直接解析会报错。
还有一个细节:客户端发往服务端的帧必须加掩码(mask),服务端发往客户端的帧不要求掩码。这是协议明文规定的。有些自研网关没注意这个,直接把收到的客户端数据转发给另一个客户端,结果对方按无掩码解析,数据全乱。同理,如果你写了一个客户端SDK,必须记得给发送帧加mask,否则服务端会按协议错误断开连接。
消息边界的问题在Agent场景特别明显。Agent一条系统消息可能几KB,拆分后跨多个帧,库虽然自动处理,但你如果在中途把消息切给第三方服务,比如丢进消息队列,未等分片结束就发送,接收方就会拿到半截JSON。我的建议是,在WebSocket服务端入口统一做一次完整的“消息重组”,重组完再往业务层丢,不要等到业务层再处理。
2.3 连接状态与优雅关闭
长连接不能像HTTP那样直接断开TCP完事,要走WebSocket协议里的关闭帧。客户端或服务端任一方向对方发送一个Close帧,携带状态码(1000表示正常关闭,1001表示服务端关机,1008表示策略违规等),对方回一个Close帧,然后TCP才关闭。
这里最常见的坑是:服务端在清理Agent会话时,直接调用ws.terminate()强制拽断连接。虽然也能断开,但客户端那边收不到Close帧,只能看到“连接被异常断开”,可能触发不必要的重连逻辑。更稳妥的做法是先ws.close(1000, 'agent session end'),给客户端一个体面的退出信号,让前端知道这是主动关闭,不要再自动重试。
另外,服务端在关闭连接时要彻底清理连接管理器中的数据,否则大量的close事件触发后,Map里残留一堆引用,内存泄漏会一点点积累。这个问题在长连接服务里尤其隐蔽,我会在下一节详细讲连接管理器怎么做。
3. Agent场景下的连接管理与消息推送
3.1 连接管理器的设计
Agent服务端不是只有一个WebSocket连接,而是有成百上千个连接。每个连接对应一个会话、一个Agent实例或者一个用户。直接裸用WebSocket库的connection事件,代码会很快失控,所以我会在WebSocket之上加一层连接管理器。
连接管理器要做三件事:注册、心跳、清理。注册就是维护一个clientId -> ws的映射。心跳负责周期检查和剔除死连接。清理是当连接关闭时,把对应的Agent任务状态、会话资源一并释放。
一个Node.js的示例:
class ConnectionManager { constructor() { this.connections = new Map(); } add(clientId, ws) { // 如果已有老连接,先关掉再换新的,避免双连接 const old = this.connections.get(clientId); if (old && old.readyState === WebSocket.OPEN) { old.close(1000, 'duplicate connection'); } this.connections.set(clientId, ws); ws.on('close', () => { if (this.connections.get(clientId) === ws) { this.connections.delete(clientId); } }); } send(clientId, payload) { const ws = this.connections.get(clientId); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(payload)); return true; } return false; } broadcast(type, payload) { const msg = JSON.stringify({ type, payload }); this.connections.forEach((ws) => { if (ws.readyState === WebSocket.OPEN) { ws.send(msg); } }); } }这个类看起来简单,但有一个容易被忽略的细节:重复连接的覆盖。Agent用户可能在多个标签页打开同一个会话,旧的连接如果不主动关闭,消息就会乱。比如旧连接用sessionId A连上,新连接也用sessionId A连上,服务端如果不把旧连接踢掉,用户会看到消息在旧标签页刷新、新标签页收不到。所以连接管理器要在加入新连接时检查旧连接。
3.2 心跳机制为什么是刚需
很多Agent开发者都会迷惑:我的WebSocket连接明明好好的,过一会儿就自动断了,Nginx日志里也没看到错误。十有八九是没做心跳。
问题根源在于:WebSocket连接是长连接,而网络中间层(Nginx、云负载均衡、运营商网关)通常会为“空闲连接”设一个超时时间。默认情况下,Nginx的proxy_read_timeout是60秒,如果60秒内这条连接上没有数据流动,代理层就会主动把它断开。你可能觉得WebSocket建连后应该一直活着,但对代理层来说,“没有数据流动”的TCP连接是资源浪费,是可以回收的。
心跳就是用来伪造“数据流动”的。最简单的办法是服务端每隔一段时间发一个ping帧,客户端自动回pong帧。浏览器原生WebSocket虽然不能手动发ping,但收到ping会自动回pong,所以服务端只需要定时ping,就能保证连接上有流量。
这里有三个关键参数需要注意:
- 心跳间隔要小于代理超时时间的一半。Nginx如果默认60秒超时,心跳至少30秒一次,建议25秒,留足余量。
- 服务端发ping之后,要记录该连接的最后pong时间,超过阈值就主动close。
- 心跳和业务消息不能冲突,要区分类型,客户端收到ping不要当业务消息处理。
服务端实现:
// 每30秒扫描一次所有连接 const HEARTBEAT_INTERVAL = 30 * 1000; const MAX_PONG_WAIT = 15 * 1000; setInterval(() => { connectionManager.connections.forEach((ws, clientId) => { if (ws.readyState !== WebSocket.OPEN) return; if (ws.isAlive === false) { ws.terminate(); connectionManager.delete(clientId); return; } ws.isAlive = false; ws.ping(); // 15秒内没有收到pong则自动被置为false }); }, HEARTBEAT_INTERVAL);前端要配合处理重连。如果前端发现连接断开,或者一段时间没收到任何消息,就要主动重连。重连时最好带上指数退避,避免所有用户同时断线重连造成服务端压力峰值。
3.3 Agent流式输出的服务端推送
Agent调用大模型时的流式输出,是WebSocket在Agent场景下最典型的价值。传统HTTP接口要等大模型完整生成完才返回,用户看到的就是长时间白屏;用WebSocket,服务端把上游流式的token一个个转发到前端,用户可以看到打字机效果。
服务端的逻辑大致是:
- 前端发一条
start_task消息,携带任务ID和参数。 - Agent服务端收到消息后,启动大模型调用,拿到一个可读流。
- 服务端遍历这个可读流,每读到一段文本,就通过WebSocket发一条
task_stream消息。 - 全部完成后,发一条
task_complete消息,附带最终结果。
伪代码大概是:
ws.on('message', async (data) => { const msg = JSON.parse(data); if (msg.type === 'start_task') { const stream = await callLLM(msg.params); for await (const chunk of stream) { if (ws.readyState !== WebSocket.OPEN) break; ws.send(JSON.stringify({ type: 'task_stream', taskId: msg.taskId, content: chunk })); } ws.send(JSON.stringify({ type: 'task_complete', taskId: msg.taskId, data: result })); } });这个实现有一个需要特别处理的问题:客户端中断。用户看到某个tool调用结果不满意,点了“停止”,前端发一条cancel_task消息,服务端如果还傻傻地继续遍历上游流,就会产生大量无用的生成,浪费token。所以服务端必须在收到cancel_task时,及时中断上游流,并清理相关任务资源。具体做法是使用AbortController,在for await循环内判断信号,收到取消信号后主动break。
我踩过这个坑,有一次用户取消后,大模型调用已经发出去了,服务端虽然不再转发,但上游生成还在继续,账单照跑。后来我在调用大模型SDK时直接传入signal,取消就立刻断开上游连接,这个问题才彻底解决。
3.4 多Agent之间的消息路由
Agent和Agent之间的通信,严格来说不一定要走WebSocket,因为Agent服务端之间通常内网互通,用消息队列更合适。但如果你在做一个本地开发调试工具,或者一个可视化的Agent编排平台,想实时把Agent状态推给前端并让前端操作Agent,WebSocket反而是更轻量的方案。
这种情况下,我会把WebSocket服务端设计成一个轻量消息路由。每个Agent实例启动后,用它自己的agentId建一个WebSocket连接;前端也用同一个服务建一个连接。服务端维护agentId -> ws和userId -> ws两个映射,同一边连接起来的不需要走HTTP,所有消息都从服务端转发。
比如Agent A要调用工具X,它把请求发到WebSocket服务端,服务端再转发给用户前端显示“Agent A正在调用工具X”。这个路由也需要一个消息类型字段,我用的是kind:
function route(ws, msg) { switch (msg.kind) { case 'agent_to_user': connectionManager.send(msg.userId, { from: msg.agentId, ...msg }); break; case 'user_to_agent': connectionManager.send(msg.agentId, { from: msg.userId, ...msg }); break; case 'agent_to_agent': connectionManager.send(msg.targetAgentId, { from: msg.agentId, ...msg }); break; default: ws.send(JSON.stringify({ error: 'unknown kind' })); } }这里有一个重要的经验:不要在两个Agent之间直接建立WebSocket连接。因为Agent数量一多,连接数是平方级增长的。正确的做法是星形连接,所有连接都连到一个中心服务,由中心做路由。中心服务一方面可以做权限校验,另一方面可以统一监控和限流,避免Agent之间互相刷消息把网络打满。
4. 高并发与稳定性:扛得住才是硬道理
4.1 连接数冲击下的内存评估
Agent服务一上线,最现实的问题就是能扛多少并发。WebSocket是长连接,每个连接都要占用内存。不同语言、不同框架下,单条连接的内存占用不同。以Node.js为例,一条WebSocket连接(包含TLS)大约占用几十KB到一百多KB。一个简单估算:假设单连接平均50KB,那么1万连接就是500MB,10万连接就是5GB。这还不包括连接管理器里的业务对象和Agent会话上下文。
所以我在设计Agent服务时,不会一上来就追求“无限并发”,而是先做容量评估:预估同时在线数 × 单连接内存 + Agent会话内存 × 并发会话数 = 内存预算。如果一个普通4GB服务器想扛5万连接,就算纯连接内存能凑合,Agent上下文的叠加也可能直接吃满。这时候就要考虑分布式部署了。
避免单个节点被打爆,有两个好习惯:
- 在所有WebSocket服务入口加连接数上限,超出后直接拒绝新建连接或排队等待。
- 对不活跃的Agent会话做超时回收,不能因为用户没有关闭页面就让连接永远挂着。
“AI Agent怎么扛并发”这个问题,表面是技术参数,背后其实是资源预算。先把容量算清楚,再优化代码,不然调一堆参数也是白搭。
4.2 心跳频率与资源消耗的平衡
心跳能保活连接,但如果做得太频繁,在高并发下会变成另一种压力。我以前见过一个团队把心跳间隔设成5秒,连接数到了2万,光心跳流量每秒钟就是几千个ping/pong,白白消耗了带宽和CPU。合理的心跳频率取决于你的网络链路和代理超时配置,不是越快越好。
一个简单经验公式:心跳间隔 = 代理超时时间 / 3。如果Nginxproxy_read_timeout是60秒,心跳间隔设为20秒;如果网关超时是120秒,心跳可以放慢到40秒,这样心跳流量降到一半。
同时要注意,心跳消息不要用业务JSON格式,直接用WebSocket协议层的ping/pong帧。业务JSON体积大,解析开销也大。协议层的ping帧只有几个字节,客户端浏览器内核自动处理pong,完全不需要手动改业务代码,这是性价比最高的方案。
4.3 断线重连的幂等设计
Agent和普通IM不一样。用户断线重连之后,Agent任务可能还在后台跑,也可能已经跑完了。如果前端重连后不管三七二十一,重新发一遍start_task,任务就重复执行了,可能重复调用支付接口、重复发消息、重复改数据库。所以WebSocket重连必须做幂等。
一个实用方案是:每次创建任务时生成一个taskId,前端发送start_task时带上clientMessageId或者taskId。服务端收到消息后,先检查这个taskId是否已经存在:
- 如果不存在,创建新任务。
- 如果已存在且还在执行,忽略本次
start_task,只补发当前进度。 - 如果已存在且已经完成,直接把最终结果再推一次。
前端重连后,不需要主动补发任务,只需要发一条sync_task_status,带上它自己记录的任务列表。服务端把每个任务的最新状态拉出来推给它。这样即使中间丢了消息,重连后也能恢复一致性。
前端断线重连代码示例:
function connectWebSocket() { const ws = new WebSocket(`ws://localhost:8080/agent/ws/${sessionId}`); let retry = 0; ws.onclose = () => { const delay = Math.min(1000 * Math.pow(2, retry), 10000); retry += 1; setTimeout(connectWebSocket, delay); }; ws.onopen = () => { retry = 0; ws.send(JSON.stringify({ type: 'sync_task_status', tasks: myTaskList })); }; }重连间隔用指数退避,最大不超过10秒,既避免频繁重试,也不会让用户等太久。这里面还有一个细节:不要把重连逻辑写在onerror里,否则网络抖动时会触发多次重连,反而加重服务端压力。只监听onclose,等连接彻底关闭后再重连。
4.4 多实例部署的连接一致性问题
当单个WebSocket服务扛不住连接数,就得横向扩展,多实例部署。这时会遇到一个新的问题:同一个用户连接的是不同实例,但Agent的任务状态可能只存在其中一台机器上。如果这台机器挂了,用户的WebSocket就被断了,即使另一台实例能接受连接,也拿不到原始会话。
常见的解法有两种。
第一种是Sticky Session。负载均衡层通过IP Hash或Cookie把同一个用户固定到同一台实例。这个方案简单,适合中小规模,也能保住会话内存。代价是做多实例时如果不处理容灾,某台实例一挂,落在它上面的用户全部掉线,还不能转移会话。
第二种是外部存储 + 发布订阅。把Agent会话状态放到Redis,每个实例维护自己本地的WebSocket连接。实例之间通过Redis Pub/Sub同步消息。比如用户A连接在实例1,用户B连接在实例2,Agent A要给用户B发消息,实例1把消息publish到Redis channel,实例2收到后转发给用户B。这个方案把“连接位置”和“业务状态”解耦,扩展性最好。
第二种方案的搭建成本高一些,但对Agent这种有状态、长连接、多实例的场景,是更稳妥的做法。实际项目里,我先用Sticky Session快速上线,等用户规模上来后再平滑迁移到Redis Pub/Sub,避免一开始过度设计。
5. 实战踩坑记录与问题速查
5.1 握手请求头过大导致400
有一次同事反馈,Agent调试页面打开后WebSocket连不上,报错是HTTP error 400. A request header field is too long。我一开始怀疑是服务端证书问题,后来抓包发现,问题出在握手请求携带的Cookie上。因为前端把所有用户信息都塞进了Cookie,导致总共几个KB的Cookie直接让Nginx报错。
排查思路:先看请求头总体大小,再逐项排查。服务端对请求头大小有限制,Nginx默认large_client_header_buffers是4个8KB,所以单个请求头不能超过8KB;Node.js默认maxHeaderSize是16KB。如果整体超了,也会直接被拒。
解决办法有三条路:
- 压缩Cookie,把不需要的前端状态移到localStorage,只在Cookie里留sessionId。
- 把token放到
Sec-WebSocket-Protocol头,但这个头也有长度限制,不能放太多。 - 调大服务端header限制。
我个人强烈推荐第一条。因为这不是为WebSocket调大的问题,即使现在调大了,以后Cookie还会继续膨胀,迟早变成隐患。不如趁早从数据上做减法。
5.2 连接假死与心跳失效
另一个常见问题是:客户端看着连接是绿色的,但服务端已经把它判定为死连接了。最典型的情况是用户电脑休眠或者网络切换,TCP连接在客户端那边已经断了,但服务端不知道,因为TCP没有主动通知机制。
我在一个Agent管理后台遇到过:用户开着浏览器挂着页面,睡了一觉回来,页面显示“在线”,但Agent状态怎么刷都更新不了。后来发现是心跳逻辑写错了——服务端发ping后没有检查pong超时,客户端虽然已离线,服务端却一直把它的连接当成有效连接,继续往里面发消息。
正确的做法是,服务端记录每个连接的最后pong时间,心跳检测时不仅发ping,还要检查超过阈值未回复pong的连接,直接terminate()并清理业务状态。我在前面第3节已经给了示例,这里不再重复。核心就是:发ping是手段,收不到pong就是结论,不要给假死连接留任何空间。
5.3 代理层没配置Upgrade导致握手失败
WebSocket握手的HTTP升级请求,如果经过Nginx代理,而Nginx配置里没有设置Upgrade相关头,连接会在代理层就断掉。症状是浏览器控制台显示WebSocket握手失败,返回404或者400,但直接访问服务端IP又是好的。
Nginx里正确的WebSocket代理配置一般是这样的:
location /agent/ws/ { proxy_pass http://backend_ws; 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_http_version必须设为1.1,Connection必须显式设置为upgrade,否则Nginx不会把后续连接数据透传到后端。proxy_read_timeout和proxy_send_timeout建议调大,配合前端心跳。如果这两个超时设成默认60秒,就算WebSocket连接本身没断,代理层也会把空闲连接杀掉。
5.4 HTTP连接复用与WebSocket的误区
热词里能看到很多人在搜“http连接复用”和“WebSocket服务”,说明不少人把HTTP Keep-Alive和WebSocket搞混了。HTTP Keep-Alive只是让TCP连接可以复用,它依然是“客户端发起一个请求、服务端返回一个响应”,服务端不能主动往这条连接里塞消息。所以Keep-Alive解决的是连接重建的开销,解决不了单向壁垒。
WebSocket则是把这条连接从“请求-响应”模式升级成了“任意时刻双向收发”模式,它是协议层面的变化,不是简单的连接复用。
这个误会在Agent场景里容易导致错误决策:有人为了省事,用HTTP Keep-Alive加上轮询,表面看“连接是长久的”,其实消息推送还是轮询拿的,实时性并没有提升。我的建议是,做Agent服务端时把需求先分清楚:如果只需要服务端单向通知,SSE更轻量;如果需要双向交互,直接用WebSocket;只在“复用请求”上打转,根本解决不了“服务端主动推消息”的核心问题。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 握手失败,返回400 | 请求头过大,服务端header限制 | 压缩Cookie,调大header限制 |
| 握手失败,返回404 | Nginx代理未配置Upgrade | 添加Upgrade与Connection头 |
| 连接建好后几十秒自动断 | 代理空闲超时 | 添加心跳机制,缩短心跳间隔 |
| 客户端显示在线但收不到消息 | 服务端未检测假死连接 | 记录pong超时,主动terminate |
| 消息丢失或乱序 | 分片未重组 | 在服务端入口统一重组消息 |
| 重连后任务重复执行 | 幂等逻辑缺失 | 用taskId去重,补发任务状态 |
平时排障,我习惯抓包看WebSocket帧,而不是只看业务日志。WebSocket的ping/pong、close、分片在业务日志里不一定完全体现。抓包能直接看到协议层发生了什么,很多看起来莫名奇妙的问题,其实都是协议层细节没处理好。
6. 说点个人经验
如果现在让我重新做一个Agent项目,我不会一上来就全站上WebSocket。我会先想清楚:是谁给谁发消息,频率多高,需不需要双向控制。如果只是把大模型结果推给前端,SSE够用;如果要做一个会话式的Agent调试台,要展示工具调用过程、要支持用户中断、要实时修改参数,那WebSocket就是正解。
还有一个亲身经验:WebSocket服务的日志一定要和普通HTTP接口日志分开。Agent场景消息量很大,如果混在一个日志系统里,排障时要翻半天,而且普通接口日志会把WebSocket的帧信息冲掉。分开之后,WebSocket服务单独记连接生命周期和消息类型统计,一查一个准。
最后分享一个小技巧:开发阶段用Chrome DevTools里的Network面板,可以看到WebSocket帧列表,非常直观。部署之后,可以用命令行工具wscat做连接测试,也可以用Node脚本模拟客户端发消息,比每次打开浏览器快得多。把这些工具用熟,WebSocket的调试效率能提升一个台阶。