一次 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_id 和 seq 来决定发送 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
| 微信 | ||
|---|---|---|
| 协议 | HTTP Long Polling | WebSocket |
| 心跳 | ❌ 不需要(poll 本身就是保活) | ✅ 必须(~33s 发一次 o |
| 断连恢复 | poll 循环自带重试 | 需要 _reconnect() + _m |
| Race condition | 无(没有独立心跳 task) | 有(_mark_disconnect |
4. 和 GitHub Issue #24357 的对应关系
后来查到 Hermes Agent 的 GitHub Issue #24357,里面的描述和这个现象基本一致:QQBot 运行一段时间后停止响应,日志中先出现 4009 Session timed out,随后反复出现 WebSocket closed 和 Session 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