AI智能体通过WebSocket接入服务端时,常遇到一个现象:连接在没有任何请求时静置一段时间,随后发送消息却提示连接已关闭,或者订阅的事件流突然中断。这类问题的根源多数不是应用代码逻辑出错,而是链路中间的空闲超时策略在起作用。要让Agent在长时间运行中保持可用,需要设计心跳保活和重连恢复机制。

一、连接频繁断开的常见原因
WebSocket建立在TCP之上,握手完成后如果没有数据帧传输,链路看起来仍然存在,但网络设备无法区分空闲连接和死连接。NAT表项、防火墙会话、反向代理和云负载均衡器都会为连接设置空闲超时时间。例如Nginx默认的proxy_read_timeout为60秒,某些云负载均衡的TCP空闲超时可能只有300秒,移动网络中的运营商NAT可能更短。一旦空闲超过阈值,设备会主动回收会话资源,客户端和服务端都不会收到明确的关闭握手,只能在下一次写入时发现错误。
AI智能体通常不是每隔几秒就有消息。它可能等待用户指令、观察环境或等待模型推理完成,这段时间没有业务数据。若应用只依赖业务消息而不主动发送心跳,连接就很容易被中间设备静默断开。更隐蔽的是,部分代理会缓冲小数据帧,导致心跳延迟或被合并,因此需要根据部署环境调整心跳周期。
服务端自身的超时配置同样关键。很多WebSocket实现会在设置ReadDeadline后等待下一次读,如果客户端从不发送数据,服务端goroutine会一直阻塞到deadline,然后关闭连接。缺少主动探活会让半开连接残留,影响内存和文件描述符。
二、协议层Ping/Pong与应用层心跳
WebSocket协议自带Ping和Pong控制帧,RFC 6455规定收到Ping的一端必须尽快返回Pong。协议层心跳的优势是开销小,不会被业务逻辑干扰,适合维持中间设备的活动状态。浏览器端WebSocket API不直接暴露Ping帧发送能力,但服务端可以发送Ping,浏览器会自动回Pong。对于非浏览器的Agent客户端,可以使用库提供的Ping方法。
应用层心跳则是业务自定义消息,例如JSON格式的{"type":"heartbeat","ts":1710000000}。它不仅能证明连接活性,还能携带Agent状态、版本和租约信息。对于AI智能体,推荐采用应用层心跳为主、协议层Ping/Pong为辅的混合方案:应用层心跳负责状态同步和租约续期,协议层Ping/Pong负责快速检测链路是否还能传输控制帧。
两者最大的区别在于超时判定位置。协议层Ping/Pong通常在底层库中完成,应用层无法直接感知;应用层心跳则可以在收到响应后更新最后活跃时间,方便业务侧判断是否切换节点或触发告警。设计时不要混用两种间隔,避免出现协议层已判断超时但应用层仍在等待响应的情况。
三、Agent客户端心跳与重连实现
客户端心跳的基本逻辑是启动一个定时器,按固定间隔发送心跳消息。收到服务端响应后重置失败计数;如果连续N次未收到响应,则判定连接已经失效,主动关闭并进入重连流程。以Node.js的ws库为例:
const WebSocket = require('ws');
class AgentConnection {
constructor(url) {
this.url = url;
this.ws = null;
this.heartbeatTimer = null;
this.missedHeartbeats = 0;
this.maxMissed = 3;
this.heartbeatInterval = 15000;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
console.log('agent connected');
this.resetHeartbeat();
});
this.ws.on('pong', () => {
this.missedHeartbeats = 0;
});
this.ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'heartbeat_ack') {
this.missedHeartbeats = 0;
return;
}
this.handleBusinessMessage(msg);
});
this.ws.on('close', () => {
this.stopHeartbeat();
this.scheduleReconnect();
});
this.ws.on('error', (err) => {
console.error('agent ws error', err.message);
});
}
resetHeartbeat() {
this.stopHeartbeat();
this.missedHeartbeats = 0;
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState !== WebSocket.OPEN) return;
this.ws.ping();
this.ws.send(JSON.stringify({
type: 'heartbeat',
ts: Date.now()
}));
this.missedHeartbeats += 1;
if (this.missedHeartbeats >= this.maxMissed) {
console.warn('heartbeat timeout, closing socket');
this.ws.terminate();
}
}, this.heartbeatInterval);
}
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
scheduleReconnect() {
const delay = 1000 + Math.floor(Math.random() * 2000);
console.log('reconnect in', delay, 'ms');
setTimeout(() => this.connect(), delay);
}
handleBusinessMessage(msg) {
console.log('business message', msg);
}
}
这段代码同时使用协议层ping和应用层heartbeat消息。注意定时器回调中先调用ws.ping()发送协议层Ping,再发送业务心跳。计数器每发送一次心跳就加一,收到pong或heartbeat_ack时归零。如果连续三次未收到任何回应,调用terminate强制关闭连接,触发close事件后进入重连。这种双通道探活可以避免单一心跳消息被业务阻塞时误判。
重连策略要引入退避和抖动。如果Agent在连接断开后立即重连,服务端或网关可能尚未恢复,大量Agent同时重连会产生惊群效应。指数退避公式可以写成delay = min(base * 2^retryCount, maxDelay) + random(0, jitter)。上面代码使用了固定随机延迟,生产环境可以根据失败次数动态增加延迟上限,并设最大重连次数或熔断机制。
四、服务端心跳与超时检测
服务端不能只依赖客户端心跳,因为客户端可能崩溃、断网或进入后台被系统挂起。服务端需要为每个连接设置读超时,并在读超时前后发送协议层Ping确认对方是否存活。以Go的gorilla/websocket为例,典型的服务端配置如下:
package main
import (
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func handleAgent(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("upgrade error:", err)
return
}
defer conn.Close()
// 允许读空闲 60 秒,客户端应用层心跳间隔应小于该值
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
ticker := time.NewTicker(25 * time.Second)
defer ticker.Stop()
go func() {
for range ticker.C {
if err := conn.WriteControl(websocket.PingMessage, []byte("keepalive"), time.Now().Add(5*time.Second)); err != nil {
log.Println("ping error:", err)
conn.Close()
return
}
}
}()
for {
messageType, message, err := conn.ReadMessage()
if err != nil {
log.Println("read error:", err)
break
}
log.Println("received:", messageType, string(message))
}
}
上述代码将服务端Ping间隔设为25秒,读超时设为60秒。当服务端发出Ping后,客户端协议栈自动回复Pong,PongHandler会刷新读超时。若客户端因断网无法回复,60秒后ReadMessage会返回超时错误,连接被回收。需要注意WriteControl的写超时不能设置过大,否则可能阻塞心跳goroutine;通常给3到5秒即可。
Java的Spring WebSocket或Tomcat实现也有类似参数。建议不要只依赖容器的默认心跳,因为默认值往往面向浏览器页面,不适合AI智能体的长任务场景。可以在Session基础上包装一个连接管理器,定期遍历所有会话,发送Ping并检查最后活跃时间。
五、代理与负载均衡配置协同
如果Agent通过Nginx或网关接入,客户端和服务端的心跳即使正确,代理超时也可能切断连接。Nginx 1.3.13之后支持WebSocket升级,但需要显式配置proxy_read_timeout和proxy_send_timeout。默认60秒的读超时会让空闲连接被关闭,因此要大于客户端心跳间隔的两倍以上。一个常见配置如下:
location /agent/ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
}
其中proxy_buffering off会减少小帧被缓冲导致的延迟,对心跳意义较大。云负载均衡器通常还有独立的空闲超时设置,需要登录控制台调整为比心跳周期更长。例如心跳15秒,负载均衡空闲超时建议不低于60秒。移动网络和部分企业防火墙可能更严格,必要时将心跳间隔缩短到10秒以内,但需要评估流量成本。
对于边车代理或Kubernetes Ingress,也要检查对应注解。例如Nginx Ingress的proxy-read-timeout和proxy-send-timeout需要设置为足够大的值。不能只修改业务服务端,而忽略入口层,否则仍然会出现连接被入口静默断开的现象。
六、调优与避坑
心跳间隔不是越短越好。过密的心跳会消耗带宽、唤醒移动端芯片、增加服务端CPU负载,而且在流量计费场景下成本可见。经验做法是先确定链路中最短的中间设备超时时间T,然后将心跳间隔设为T的1/3到1/2。例如NAT超时60秒,心跳间隔可设为20至30秒。如果服务端需要更快发现故障,再引入协议层Ping与业务心跳分层,而不是单纯缩短应用层心跳。
还有一个常见的坑是只发送心跳但不处理重连后的状态恢复。Agent重连后需要重新注册、订阅或拉取丢失的事件。建议在连接ID或会话信息中加入客户端生成的唯一标识,服务端据此恢复上下文。若服务端保存了Agent的未消费事件,重连后客户端可以携带最后事件序号拉取,避免空洞。
调试时可以利用浏览器开发者工具或Wireshark查看WebSocket帧。协议层Ping/Pong在浏览器中不直接显示,需要抓包确认。如果发现心跳发出但未收到Pong,优先检查代理和防火墙是否过滤了控制帧。通常先确认单段链路,再逐段排查,可以更快定位是客户端、服务端还是中间设备导致。

