一句没法排查的症状

凌晨,我盯着本机跑的 AI Agent(Hermes)桌面版,说出了一句自己都没法执行的话:“启动的时候网关总是不太行。”

不太行是什么意思?慢?报错?没反应?消息发不出去?这句话连搜索都没法搜。而此时最容易走的路是:重启一遍——诶,好像好了?那就继续用。

重启在什么时候是够用的?进程真的只是偶发卡死、服务管理器还托管着它的时候。这次重启确实"好了一部分",但如果你不知道它为什么好,问题一定会在你最需要它的时候回来。事后证明,这次重启碰巧重新加载了 launchd 任务定义,治好的只是四个病因里的半个。

所以第一步不是找原因,是把"不太行"拆成可以被证伪的陈述。这篇复盘讲的就是这个拆解过程:怎么用三层事实核对定位"服务到底在不在",怎么用日志时间戳把"慢"钉死在精确窗口里,以及怎么给每个病因配一个不会说谎的观测点。

第一层判断:谁在说谎

排查从三个互相矛盾的"事实"开始。

Hermes 自己的状态文件 gateway_state.json 写着:gateway_state: running,PID 74522,平台连接正常。看起来一切安好。但 ps 里根本没有这个进程——PID 74522 不存在。再问 macOS 的服务管理器:launchctl print gui/501/ai.hermes.gateway,回答是 Could not find service——launchd 里压根没加载这个任务,也就是说开机自启和崩溃拉起全部失效。

三个观测点,三个答案。状态文件没有撒谎,它只是一块化石:最后更新时间停在 10 月 3 日——那晚网关收到 SIGTERM 正常退出,之后三个星期,没有任何机制把它拉起来,也没有任何机制更新这块化石。

排障的第一步不是找原因,是给每一层事实找权威来源。 进程在不在,问内核(ps);服务认不认,问服务管理器(launchctl / systemctl);应用自己的话,只作参考。当三层对不齐时,以时效最强的为准。

这里有个容易放松警惕的反例:如果状态文件的 updated_at 很新鲜呢?它仍然可能滞后——进程被 kill -9 时没有机会写遗言,状态就会停在"活着"。所以权威性有一个稳定的排序:内核进程表 > 服务管理器 > 应用自报状态。应用自报状态只在与前两者一致时才有价值,它的价值是"细节"(比如各平台的连接情况),不是"真伪"。

修复这一层只花了一条命令:hermes gateway start,它自动检测到任务未加载并重新注册。网关起来了,日志干净了。如果故事到这里结束,这篇复盘就只是一次"重启治百病"。但那句"启动的时候总是不太行"还在——因为真正的病人还没出场。

第二层判断:日志时间戳是免费的调用链

桌面 App 启动还是慢。慢在哪里?没有 APM,没有 tracing,但日志的时间戳自己会说话。

gui.log 里,最近五次启动每一次都出现了同一行告警:

1
event loop stalled 38.4s (GIL pressure suspected)

时间从 11.9 秒到 77.9 秒不等。把这一次启动(02:14)的关键事件按时间排开,卡顿的边界自己浮出来了:

1
2
3
4
5
6
7
8
02:14:43  cron scheduler 启动
02:14:46 前端 WebSocket 接入
02:15:00 ws write slow (loop stalled >10s) ← 开始卡
02:15:16 ws write slow(还在卡)
02:15:30 event loop stalled 38.4s ← 卡顿被确认
02:15:48 agent 初始化完成
02:15:53 用户消息 'hi' 进入对话
02:16:55 API call failed: Request timed out ← 62 秒超时

这张时间线直接把"启动慢"拆成了两段:**前 62 秒是后端 Python 事件循环卡死(GIL 压力),窗口精确到"前端接入之后、agent 就绪之前";后面是发出去的消息在等一个永远不通的端点。**这是两个病因,不是同一个"慢"。

多份日志用时间戳对齐,就是一套不需要埋点的分布式追踪。gui.log 负责窗口边界,agent.log 负责业务事件(消息什么时候进来的、走的哪个 provider),errors.log 负责噪音清单。三份日志拼起来,"体感慢"变成了带时间边界的待解释区间。

第三层判断:每个病因配一个不会说谎的观测

把时间线继续读下去,"不太行"最终拆成了四个独立的病因。关键是:每一个都有一条独立命令可以验证,而不是靠猜。

病因 观测点 命令 结论
launchd 任务未加载 服务管理器 launchctl print 服务停摆三周
事件循环卡死 38s 日志时间戳 gui.log 时间线 每次启动必卡,窗口固定
默认模型端点不通 TCP 连接 curl --max-time 15 连接超时,0 字节到达
DNS 污染 域名解析 nslookup 解析到错误 IP 段
cron 任务 401 端点 + token 分开测 curl + 日志 网络通(200/0.1s),token 无效

最有意思的是端点那条。默认模型走的是我自己部署在 Cloudflare Workers 上的代理:glm-free-api-neo.richardyangjiayu.workers.dev。curl 十五秒连不上,像是"服务挂了";但 nslookup 给出了指纹级的证据——它解析到了 104.244.43.128,这是 Twitter 的 IP 段。一个 Cloudflare Workers 域名解析到 Twitter 的机器,这不是配置错误,这是 workers.dev 整个域被 DNS 污染了。连接超时 + 解析到毫不相干的 IP 段 = 污染的指纹,此时去检查 Worker 部署、查密钥、看日志,全是在给错误的病人挂号。

对照组同样重要:同一时刻 openrouter.ai 返回 200(1.3 秒),api.reallyshort.cn 返回 200(0.1 秒)。网络没有问题,key 没有问题,坏的只是那一个域名。而 cron 任务的 401 恰好是反向案例:端点 0.1 秒返回 200,说明网络无恙,坏的是 token——把"网络通不通"和"凭据对不对"拆成两次观测,401 就不再是一团浆糊。

顺带一提,采样(macOS 的 sample 命令)是抓"活的卡顿"的利器,但有时效:我赶到现场时进程已经空闲,采样全是一排空闲栈。性能问题要在现场抓,事后只有日志能作证——这也是为什么值得在日志里留下足够多的时间戳。

修复顺序的权衡

四个病因都确认了,修哪个?全修?我的排序标准是影响面除以修复成本,从商最高的开始:

  1. 默认模型切到 openrouter(影响面最大:每条消息都三分钟才报错;成本最低:改两行配置)。切换前先实测:旧的两个模型竟然都已下架,新选的免费模型 nvidia/nemotron-3.5-super-vl:free 先 curl 验证能出 token、1M 上下文,再写进配置——先验证,后切换,不然只是把死端点换成另一个死端点。
  2. 暂停 401 的 cron 任务(每天固定噪音;一条 pause 命令,任务定义保留,随时可恢复)。
  3. launchd 服务重新加载(恢复自愈能力;一条命令)。
  4. GIL 卡顿——先不修。 它是 Hermes 自身的性能问题,嫌疑在内存插件初始化和旧会话恢复(状态库 252MB),修复成本高,且影响小于死端点。留成遗留项,等下次冷启动时现场采样定位。

这个顺序未必适合所有场景,但方法可以迁移:修复顺序不是按发现顺序,也不是按严重程度,是按"影响面 ÷ 修复成本"。严重但难修的问题(GIL)可以带着病生存,不严重但吵的问题(cron 401)值得顺手关掉,因为它污染日志,而下一次排障时你还要在这堆噪音里找信号。

最后一件事:把整个排查过程写成了一份带时间戳的记录文档——每步的命令、发现、备份位置、恢复方法、遗留项。这不是仪式感,下次它再出问题时,这份文档就是最快的观测点。

带走什么

一个判断:模糊的症状先拆解,每层事实找权威观测点(内核 > 服务管理器 > 应用自报),每个病因配一条独立的验证命令,修复按影响面÷成本排序。

一个可以今天就做的动作:给你手上那个"总是不太行"的服务写下三行——进程在吗(ps)、服务管理器认吗(launchctl/systemctl)、日志最新时间戳是什么。三行对齐,它才是真的在跑;对不齐,你就已经找到第一个病人了。

留一道题:如果下次 nslookup 返回的是正确的 Cloudflare IP 段,curl 却依然连不上——你该怎么区分是 SNI 阻断、IP 黑名单,还是本机代理捣乱?提示:curl --resolve 可以把"IP 对不对"和"握手里带了什么域名"拆成两次观测——又是同一个方法。