这件事为什么值得单独写一期
6 月底至今,Hermes Gateway 出现了两次级联故障:第一次是 Telegram 超时/403/polling conflict,紧接着是二次崩溃把 gateway 进程本身也退出了。很多人的处理方式是“重启、祈祷、别再触发”,然后写一句“环境抖动”。这次我们没有接受这个结论。
这篇文章只记录两件事:根因是什么,以及 修复过程中哪些结论是已验证的,哪些只是代码级完成但尚未被真实流量碰过。
什么值得吹、什么硬证据支持、什么其实还没验证,全部分开列。这是这次最重要的方法论:证据纪律比重启次数更重要。
2. 连续 crash 时间线
2.1 trigger 1:首次故障(gateway 还活着)
gateway.error.log中记录到 Telegram 侧Timed out- 波特消息发送退化为“只回不发送”,此时 gateway 进程仍存活
- 同时
errors.log中出现Pool timeout/403 Authorization failed
2.2 trigger 2:二次崩溃(gateway 进程直接退出)
- 在首次故障未恢复时,
gateway setup和gateway stop操作在极短时间窗口内并行触发 - 多个 gateway 实例同时启停,导致 Telegram 单 bot 多实例 polling conflict
- TUI 侧伴随
write EIO错误及SIGHUP/SIGTERM信号 - gateway 自行退出
根因分层: “第一次崩溃”不是 gateway 退出,“第二次退出”才是;而第二步不是独立故障,是“第一次故障还没完全处理时,又叠加了一个并行启停动作”。
3. 四类叠加根因
这四件事不是互相替代,而是同时成立:
3.1 多实例并发启停触发 Telegram polling 互踢
- 关键证据:gateway.error.log 中存在多组
hermes-gateway_0、hermes-gateway_1同时出现 - Telegram 单 bot 被多个实例同时轮询,触发
403 Authorization failed/Polling heartbeat probe failed/Pool timeout
3.2 fallback provider freellmapi 链路不可用
freellmapi-freellmapi-1docker 容器存活,服务健康- 但本地测试返回
Invalid API key,当前配置的 key 已失效 - fallback 命中后会返回
NO_REPLY拉长响应超时,但不产生真实回复
3.3 config.yaml fallback_providers YAML 拼接残留
- 存在一条错误拼接:
- nvidia/deepseek-ai/deepseek-v4-flash- nvidia/z-ai/glm-5.1- 这不是有意的第三个 provider,而是两条 provider 被错误拼贴成一行
- 修改后
fallback_providers长度为 3,YAML 语法校验通过
3.4 Cloudflare qwen3-30b max_tokens 超限
- 报错原始信息:
max_tokens=65536 cannot be greater than max_model_len=max_total_tokens=32768- 同时伴随:
RuntimeError: Context length exceeded (320 tokens). Cannot compress further.- 根因:
qwen-oauth/custom插件的default_max_tokens=65536在无用户配置时生效,超出 Cloudflare Workers 模型上限
4. 修复与验证状态
4.1 ✅ STEP 1:fallback_providers 拼接清理
- 操作: 删除错误拼接条目,保留三条独立 provider 定义
- 验证:
yaml.safe_load通过;fallback_providers长度=3 ✅ - 状态: 已验证完成
4.2 ⚠️ STEP 2:freellmapi fallback 可用性——不可验证
这个状态不是“失败”,而是可观测性不足。
gateway.error.log中有 provider/base_url/model 的请求级证据,但这些日志行本身没有行首时间戳freellmapi无公开/metrics访问日志,服务侧计数无法作为证据- 因此无法做“主 provider 改坏 → 触发 fallback → 服务侧计数 +1 → 时间窗口对齐”的双侧闭环验证
结论: 服务本身可用,但当前两边都缺时间戳,无法验证 fallback 路径是否被真实命中过。记为证据缺口,不标记为已验证。
额外发现: Hermes 日志系统缺行首时间戳,这个问题比 STEP 2 本身更基础,会持续拖累未来的所有排障。
4.3 🔧 STEP 3:max_tokens 65,536 → 28,000
代码级修复已完成,但存在新的判定问题:
- 修改内容:
/Users/macos/.hermes/hermes-agent/plugins/model-providers/qwen-oauth/__init__.py:79+/Users/macos/.hermes/hermes-agent/plugins/model-providers/custom/__init__.py:71 - 将
default_max_tokens从 65,536 改为 28,000
但这里的验证前提发生了变化:
- 我们确认
@cf/qwen/qwen3-30b-a3b-fp8在近期日志中只有 watchdog 一个调用方 - STEP 3.5 已经把这个唯一调用方改成了 no_agent 脚本,不再走 LLM 调用链
- 因此这个 provider 当前没有真实流量可以验证 28,000 是否生效
状态: 代码级修复完成,运行时刻验证缺流量。这不是“修复无效”,而是“配置正确但从未被真实流量触碰”。这和 STEP 2 的可观测性缺口是同一类问题。
补一句配置漂移风险: qwen3-30b 目前唯一调用方已经被移除,28000 这个值可能永远不会被验证。无人使用的 provider 配置在后续改动中容易被忽略或写错。
4.4 ✅ STEP 3.5:watchdog 去 agent 化改造
这是优先级最高的一次改动,直接切断了“watchdog 走 LLM → 触发 context length exceeded → cron 失败”的链路。
改动:
- cron job
da2b5692cb9b已设置为no_agent=true - 指向
/Users/macos/.hermes/scripts/watchdog_no_agent.sh - 脚本只做三件事:
- 读
gateway.pid判断进程是否存活 - 读
gateway.error.log最近 10 分钟内容 - 统计
Pool timeout/403/polling conflict关键词数量并决策
- 读
证据链闭环:
| 验证点 | 证据 |
|---|---|
| 脚本不是空壳 | 正常执行 recent_errors=0,注入已知错误后变为 recent_errors=1 |
| 执行期间无 LLM 调用 | 手动测试窗口内 provider= 路由日志为 0 条 |
| cron 入口无并行风险 | cronjob list 确认仅 da2b5692cb9b 指向该脚本 |
状态: 已验证完成
4.5 ✅ STEP 4:重复启停保护——已有实现更优
原计划要求“加 PID 文件锁”,实际核查后发现 Hermes 已有更完善的保护机制:
现有实现:
/Users/macos/.hermes/hermes-agent/gateway/run.py:19383的start_gateway入口一开始就做了 duplicate-instance guard- 使用
fcntl.flock(..., LOCK_EX | LOCK_NB)做跨进程互斥 - 配合
gateway.pid里的pid + start_time做 PID reuse 防护
真实并发测试结果(无 --replace 场景):
第二次无 --replace 启动被拒绝:
❌ Gateway already running (PID 499).- 退出码 1
- 原 PID 499 未变
- 新进程未产生
生产 restart 路径补充测试:
实际执行 hermes gateway restart 一次,exit 0,新 PID 8355 接管成功。但窗口内捕获到 Telegram 侧记录:
This Updater is already running!
这说明 restart 过程中存在短暂双实例在线窗口,不是理想中的“旧实例完全退出 → 新实例上线”。这是已知残余风险,需单独评估,不归入当前排障范围。
结论:不加重复实现,当前已有可工作的 duplicate-start prevention;生产 restart 路径存在短暂 overlap,暂不标记为“已验证根治”。
5. 一项未解的遗留问题
在 HERMES 系统中,以下两类问题仍未进入已验证状态:
- STEP 2:日志无行首时间戳 → 任何 fallback/路由类故障都卡在“两边对不上时间”
- STEP 3:qwen3-30b = 零调用 → 28000 这个修复处于“配置正确但从未被真实流量碰过”的状态
这两类问题的共同特征:代码级修复 ≠ 已验证修复,中间差一次真实流量命中。
如果以后再次出现同类故障,会是“老修复本来就没生效过”还是“新 bug”,这个区分很重要。建议尽快评估最小修复面:
只给
conversation_loop的请求级日志加 monotonic timestamp + request_id,不改路由逻辑,不需要上 Prometheus/ELK。
6. 这是一篇方法论文章
总结这篇复盘真正想强调的三件事:
- 双层时间戳缺失是最隐蔽的障碍——不是今天这个排查不会卡住,是未来任何类似排障都会继续卡
- “代码已改”和“已被真实流量验证”是两种状态,必须分开记录——混淆这两者会导致日后误判
- 不要为了完成 STEP 的字面任务,在已工作的东西上叠加同名机制——
flock + PID reuse已经比裸 PID 文件更强,此时正确动作是“记录已有实现”,不是“再造轮子”