Hermes QQbot websocket Problems

一次 QQBot "灵魂不在线"问题排查记录

最近在使用 Hermes Agent 接入 QQBot 时,遇到了一个比较典型的问题:Hermes 本体看起来是正常的,gateway 进程也没有直接挂掉,但 QQ 端机器人运行一段时间后就会突然无法响应,表现为"灵魂不在线"。

一开始这个问题很容易被误判成网络波动,或者 QQ 平台偶发不稳定。但继续看日志后发现,问题并不是简单的进程异常,而是 QQBot 的 WebSocket 长连接进入了一种"进程还活着,但平台链路不健康"的状态。

1. 现象:Hermes 正常,但 QQBot 不响应

从服务状态看,Hermes gateway 仍然在运行,微信 bot 也没有出现同样的问题。因此可以先排除 Hermes 主体、模型服务、服务器整体资源这类公共依赖问题。

但 QQBot 日志里反复出现类似内容:

yaml 复制代码
2026-05-10 06:43:23,025 WARNING gateway.platforms.qqbot.adapter: [QQBot:1903666320] WebSocket error: WebSocket closed
2026-05-10 06:43:23,032 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Reconnecting in 2s (attempt 1)...
2026-05-10 06:43:25,234 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] WebSocket connected to wss://api.sgroup.qq.com/websocket
2026-05-10 06:43:25,238 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Reconnected
2026-05-10 06:43:25,239 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Resume sent (session_id=810b1d61-ef0d-4188-b803-6f18dfa9a97e, seq=405)
2026-05-10 06:43:25,288 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Session resumed
2026-05-10 06:44:25,285 WARNING gateway.platforms.qqbot.adapter: [QQBot:1903666320] WebSocket error: WebSocket closed

更关键的是,异常后期不是偶发断线,而是进入了一个比较固定的循环:

yaml 复制代码
2026-05-10 06:43:23,025 WARNING gateway.platforms.qqbot.adapter: [QQBot:1903666320] WebSocket error: WebSocket closed
2026-05-10 06:43:23,032 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Reconnecting in 2s (attempt 1)...
2026-05-10 06:43:25,234 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] WebSocket connected to wss://api.sgroup.qq.com/websocket
2026-05-10 06:43:25,238 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Reconnected
2026-05-10 06:43:25,239 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Resume sent (session_id=810b1d61-ef0d-4188-b803-6f18dfa9a97e, seq=405)
2026-05-10 06:43:25,288 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Session resumed
2026-05-10 06:44:25,285 WARNING gateway.platforms.qqbot.adapter: [QQBot:1903666320] WebSocket error: WebSocket closed
2026-05-10 06:44:25,290 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Reconnecting in 2s (attempt 1)...
2026-05-10 06:44:27,502 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] WebSocket connected to wss://api.sgroup.qq.com/websocket
2026-05-10 06:44:27,507 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Reconnected
2026-05-10 06:44:27,508 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Resume sent (session_id=810b1d61-ef0d-4188-b803-6f18dfa9a97e, seq=406)
2026-05-10 06:44:27,572 INFO gateway.platforms.qqbot.adapter: [QQBot:1903666320] Session resumed
2026-05-10 06:45:27,544 WARNING gateway.platforms.qqbot.adapter: [QQBot:1903666320] WebSocket error: WebSocket closed

也就是说,QQBot 并不是完全连不上,而是每次看起来恢复了,随后又很快断开。

2. 关键判断:Session resumed 不等于链路恢复

在源码逻辑中,当 WebSocket 收到 Hello 事件后,adapter 会根据当前是否保存了 session_idseq 来决定发送 Identify 还是 Resume。Resume 成功后,QQ Gateway 会下发 RESUMED 事件,adapter 记录 Session resumed 日志。

但这里的关键在于:Session resumed 只说明服务端接受了这次 Resume,并不代表后续 heartbeat 任务一定还在正常运行。真正维持长连接的是 _heartbeat_loop()。如果在前面的 4009 session timeout 或 WebSocket 断开过程中,adapter 的状态变量处理不当,导致 heartbeat loop 退出,那么后续即使 _listen_loop() 重新打开了 WebSocket,并且 Resume 成功,新连接也可能没有持续发送 heartbeat。

① Heartbeat task 只在初始连接时创建一次

ini 复制代码
adapter.py L299:  self._listen_task = asyncio.create_task(self._listen_loop())
adapter.py L300:  self._heartbeat_task = asyncio.create_task(self._heartbeat_

_reconnect()不会重建 heartbeat task。

_mark_disconnected()_running 置为 False

python 复制代码
base.py L1309:  def _mark_disconnected(self):
base.py L1310:     self._running = False   ← 这是问题根源

③ WebSocket 断开时,_listen_loop 调用 _mark_disconnected()

python 复制代码
adapter.py L592:  logger.warning("[%s] WebSocket error: %s", ...)
adapter.py L593:  self._mark_disconnected()   ← _running = False
adapter.py L600:  if await self._reconnect(backoff_idx):

_reconnect() 恢复 _running = True,但中间存在 race window

python 复制代码
adapter.py L621:  await self._open_ws(gateway_url)
adapter.py L622:  self._mark_connected()   ← _running = True

⑤ Heartbeat loop 的唯一退出条件就是 while self._running

python 复制代码
adapter.py L655:  while self._running:          ← 此时 _running = False 就永久退出
adapter.py L656:      await asyncio.sleep(...)   ← 正好在此醒来就完蛋
adapter.py L661:      await self._ws.send_json(...)

所以这次问题不能简单理解为"Resume 失败",而是"Resume 表面成功,但恢复后的连接没有被 heartbeat 稳定保活"。这是 _mark_disconnected()_mark_connected() 之间的 race condition,根因是 _running 承担了"适配器是否运行"和"WebSocket 是否连接"两个语义。

3. 为什么微信 bot 没有同样的问题

微信 bot 正常,是排查中比较重要的对照点。

QQBot 依赖 WebSocket 长连接,需要维护 session、seq、heartbeat、resume、reconnect 等状态。微信 bot 用的是HTTP 长轮询(Long Polling),和 QQ Bot 的 WebSocket 完全不同。

微信 QQ 协议 HTTP Long Polling WebSocket 心跳 ❌ 不需要(poll 本身就是保活) ✅ 必须(~33s 发一次 op 1) 断连恢复 poll 循环自带重试 需要 _reconnect() + _mark_con Race condition 无(没有独立心跳 task) 有(_mark_disconn

微信 QQ
协议 HTTP Long Polling WebSocket
心跳 ❌ 不需要(poll 本身就是保活) ✅ 必须(~33s 发一次 o
断连恢复 poll 循环自带重试 需要 _reconnect() + _m
Race condition 无(没有独立心跳 task) 有(_mark_disconnect

4. 和 GitHub Issue #24357 的对应关系

Bug: QQBot gateway can stop heartbeating after reconnect and loop on 4009 Session timed out · Issue #24357 · NousResearch/hermes-agent

后来查到 Hermes Agent 的 GitHub Issue #24357,里面的描述和这个现象基本一致:QQBot 运行一段时间后停止响应,日志中先出现 4009 Session timed out,随后反复出现 WebSocket closedSession resumed

这个 issue 中提到的根因大致是:QQBot adapter 内部的 _running 变量被混用了。

它既被当成"adapter 是否继续运行"的长期状态,又被当成"当前 WebSocket 是否连接"的短期状态。这样在 WebSocket 断开和重连的过程中,_running 可能被置为 False,导致 heartbeat 心跳循环退出。

后续虽然 WebSocket 可以重新连接,也可以发送 Resume,并且日志显示 Session resumed,但此时负责保活的 heartbeat 任务可能已经不在了。没有持续心跳后,QQ 网关会继续判定连接超时,最后再次关闭 session。

那么就和上面所述的是相符合的了。

6. 临时处理方案

在官方修复合入之前,比较实际的做法是增加一个基于日志的 watchdog(或者直接先用微信bot,或者叫openclaw帮忙重启一下)。

普通的 health check 只检查 Hermes gateway 是否存活是不够的,因为这次问题恰恰是: "gateway 活着,QQBot adapter 不健康"

因加一个定时任务(那肯定使用GPT生成一个啦),周期性检查 QQBot 日志;它不再只检查 Hermes gateway 进程是否存活,而是观察最近一段时间内 QQBot WebSocket 是否出现高频 closed / resumed 循环。当 10 分钟内出现多次 WebSocket closed 时,脚本会判定 QQBot 链路不健康,并优先通过 systemd 重启 gateway。同时脚本设置了冷却时间,避免频繁重启,并将触发原因写入独立 watchdog 日志,便于后续回溯。

bash 复制代码
#!/usr/bin/env bash
# QQBot Watchdog
# Detects QQBot WebSocket reconnect loop and restarts Hermes gateway.
# Cron example:
# */5 * * * * /home/tsukiyomi/.hermes/scripts/qqbot-watchdog.sh

set -euo pipefail

LOG="$HOME/.hermes/logs/gateway.log"
WATCHDOG_LOG="$HOME/.hermes/logs/watchdog.log"

WINDOW_MIN=10
CLOSED_THRESHOLD=8
RESUMED_THRESHOLD=5
RESTART_COOLDOWN=300

STATE_FILE="/tmp/qqbot-watchdog-last-restart"

if [[ ! -f "$LOG" ]]; then
    exit 0
fi

mkdir -p "$(dirname "$WATCHDOG_LOG")"

since=$(date -d "-${WINDOW_MIN} minutes" '+%Y-%m-%d %H:%M' 2>/dev/null || date -v-"${WINDOW_MIN}"M '+%Y-%m-%d %H:%M')

closed_count=$(awk -v since="$since" '$0 >= since && /WebSocket.*closed/' "$LOG" | wc -l | tr -d ' ')
resumed_count=$(awk -v since="$since" '$0 >= since && /Session resumed/' "$LOG" | wc -l | tr -d ' ')
inbound_count=$(awk -v since="$since" '$0 >= since && /(inbound|C2C message|Received.*message|handle_message)/' "$LOG" | wc -l | tr -d ' ')

if (( closed_count < CLOSED_THRESHOLD )); then
    exit 0
fi

# 如果只有 closed,没有 resumed,也可能是更严重的连接异常,同样可以重启
if (( resumed_count < RESUMED_THRESHOLD )); then
    echo "[$(date '+%F %T')] QQBot watchdog: ${closed_count} closed, ${resumed_count} resumed in ${WINDOW_MIN}min, inbound=${inbound_count}; restart gateway" >> "$WATCHDOG_LOG"
else
    echo "[$(date '+%F %T')] QQBot watchdog: reconnect loop detected: closed=${closed_count}, resumed=${resumed_count}, inbound=${inbound_count}; restart gateway" >> "$WATCHDOG_LOG"
fi

now=$(date +%s)
if [[ -f "$STATE_FILE" ]]; then
    last=$(cat "$STATE_FILE" 2>/dev/null || echo 0)
    if (( now - last < RESTART_COOLDOWN )); then
        echo "[$(date '+%F %T')] QQBot watchdog: cooldown active, skip restart" >> "$WATCHDOG_LOG"
        exit 0
    fi
fi

date +%s > "$STATE_FILE"

if systemctl --user list-unit-files | grep -q '^hermes-gateway\.service'; then
    systemctl --user restart hermes-gateway.service
    echo "[$(date '+%F %T')] QQBot watchdog: restarted by systemd" >> "$WATCHDOG_LOG"
else
    pkill -f "hermes_cli.main gateway run" 2>/dev/null || true
    sleep 3
    cd "$HOME"
    nohup hermes gateway run >> "$LOG" 2>&1 &
    echo "[$(date '+%F %T')] QQBot watchdog: restarted by nohup, pid=$(pgrep -f 'hermes_cli.main gateway run' || echo '?')" >> "$WATCHDOG_LOG"
fi
相关推荐
Byron__12 小时前
SpringBoot 核心面试知识点(自动配置/启动流程/注解/Starter)
spring boot·后端·面试
程序员cxuan12 小时前
这个插件,直接让 Java 小白秒变资深开发
人工智能·后端·程序员
ZengLiangYi13 小时前
Prompt 工程:让 LLM 输出结构化 JSON
前端·javascript·后端
Oo_行者_oO13 小时前
MyBatis-Plus 字段数学计算封装
后端
bandaoyu13 小时前
【AMD】HDP(Host Data Path)是什么
java·后端·spring
用户21816970493013 小时前
golang socket(一) TCP协议 简单的socket服务器和客户端
后端
ZengLiangYi13 小时前
MCP 协议从零实现:手写最简 MCP Server
前端·javascript·后端
yspwf13 小时前
Node.js 本地下载并使用 Hugging Face 中文向量模型:以 bge-base-zh-v1.5 为例
javascript·后端