技术破壁专栏 · 第10期
系列前情:第09期记录了 OpenClaw 升级后内存失控、快照回滚的惊魂一夜。本期是续集——回滚后的系统,在凌晨用另一种方式翻车了。而这一次的排查,推翻了我们当初信誓旦旦的结论。
核心挑战:客户连发22条「在吗」毫无回应,真相藏在一行 WARN 日志和一份对话归档里
关键词:排水机制误判、出站静默失败、启动竞态、chatreview 证据源、可观测性
10月9日凌晨 00:34~00:36,一位客户在微信客服对话框里,用两分钟问了一个载入我们故障史册的问题:
00:34客户:在吗
00:34蟹蟹:在的!🦀 这么晚还在忙?
00:34客户:在吗
00:34蟹蟹:在呢~
00:35客户:在吗
00:35蟹蟹:在的在的!
……(以下省略19轮,热情不减,花样不重复)……
00:36客户:在吗
00:36蟹蟹:在!您说我听着 🦀
客户连问 22 次,蟹蟹连答 22 次,每一次都热情饱满、不重样。
表面看这是个段子:一个执着的客户,陪一个傻乎乎的 AI 玩复读机。
但如果你把视角切到客户那一端,画面立刻就不搞笑了——客户从头到尾,一条回复都没看到。他看到的只有一个死寂的对话框,和微信客服那个永远不会回应的灰色头像。
于是他开始怀疑人生:在吗?在吗?在吗……
事故第一时间,我们的排查结论非常笃定:Gateway 处于排水(Draining)状态,拒绝接收新消息。
这个判断有充分的"历史依据":
GatewayDrainingError——Gateway 关闭前进入排水状态,拒绝新连接、只处理存量请求;于是结案陈词:重启窗口期的排水机制把客户消息挡在了门外,蟹蟹根本没机会说话。锅是排水的,平反等不到,注意避开重启窗口就好。
这个结论,错了整整一个月。
转机来自一个意想不到的地方——我们自己的 chatreview 对话复盘系统。
10月9日晚上,我们把 chatreview 从单轨(蟹蟹)扩成双轨(蟹蟹 + 龙虾教官),顺便翻历史数据时,看到了那段"在吗"对话的完整记录。这一看,整个人都不好了:
chatreview 里,22 条客户消息和 22 条蟹蟹回复,一条不少。
chatreview 的数据来自 OpenClaw 会话 SQLite 的每日凌晨归档提取——它记录的是"系统实际处理了什么",不是"客户看到了什么"。这意味着:
客户消息 → ✅ 进来了(chatreview 完整记录,时间戳精确到秒)
→ ✅ LLM 处理了(22 条回复各不重样,不是缓存复读)
→ ❌ 卡在出站投递(客户端一条没收到)
排水机制的锅,不成立了——排水是"入口拒绝",而入口明明畅通。
带着新线索翻 Gateway 日志(journalctl --user -u openclaw-gateway),完整时间线浮出:
systemd[1125]: openclaw-gateway.service: Failed with result 'signal'.
服务被信号杀掉。这台 3.6GB 内存的机器上,最大嫌疑是 OOM killer——凌晨内存压力下,内核挑了个大进程祭天。
重启耗时 90 秒,加载过程中一堆插件报错(ERR_PACKAGE_PATH_NOT_EXPORTED),wecom 系列通道直到 00:32:32 才全部恢复上线。
客户的第一条"在吗"在通道恢复后 2 分钟到达。日志里能看到每一次请求的完整处理痕迹:
[model-fetch] response provider=xxx api=openai-completions model=glm-5
status=200 elapsedMs=2938
模型正常推理,2~3 秒出结果,token 正常烧。22 次,次次如此。
[wecom-kf] [WARN] dialogue outbound transition failed (non-blocking):
params.runtime.agent.session.loadSessionStore is not a function
这一行 WARN 级别的日志,是整场事故唯一的技术报错:
loadSessionStore 还没就绪——典型的启动竞态(race condition)(non-blocking)——静默吞掉,不抛错、不重试、不告警至此可以给事故正式定性,并且给排水机制无罪释放:
| 维度 | 排水(当初的误判) | 哑巴窗口(真相) |
|---|---|---|
| 故障位置 | 入口:拒绝新消息 | 出口:投递失败 |
| 消息处理 | 根本没收到 | 正常接收、正常推理 |
| Token 消耗 | 无 | 照烧不误 |
| 客户体验 | 无回应 | 无回应(一模一样) |
| 日志可见性 | 明确的 Draining 状态 | 仅一行 WARN,静默吞没 |
两种故障表象完全相同、根因完全不同。当初拿着客户侧现象去套已知故障模式,是典型的"用旧地图找新路"。
(non-blocking) 这个设计本意是"投递失败别阻塞主流程",但它同时意味着——客户体验层面无感知(消息石沉大海),运营层面无告警(只是一行 WARN)。要不是这位客户执着地问了 22 次,这起事故可能永远不会被发现。
这次排查还有个额外收获:chatreview 从"客服复盘工具"升级成了"故障定界的证据源"。
复盘一下定界逻辑,以后再遇"客户无回应"类故障,排查顺序应该是:
看 chatreview 里有没有 AI 回复记录
├── 有记录 → 处理环节活着 → 问题在出站投递(查通道日志/插件状态)
└── 无记录 → 消息没进来 → 问题在入口(查排水状态/webhook/通道健康)
按"规则 + 架构双管齐下"的惯例,落地三条:
给客服 Agent 补一条行为规则:连续收到 3 次以上相同的短消息(如"在吗"),应切换话术主动确认"您能看到我的回复吗",而不是继续热情应答——22 次不重样的热情,在客户端等于 22 次沉默。
non-blocking 不等于 silent。整改方向:
outbound transition failed,出现即告警推送到运维微信;00:30 的崩溃大概率是内存不足触发 OOM killer。3.6GB 内存的机器跑 Gateway(heap 限制 2GB)+ 双插件记忆系统,本来就贴着红线走:
Restart=always + OOMScoreAdjust 降低被杀优先级;需要诚实说明的是,loadSessionStore is not a function 的深层根因——是 2026.9.6 版本的插件兼容层缺陷,还是重启竞态的普遍问题——还没有最终结论。它出现在一次崩溃重启之后,可复现性未知。
但这不妨碍先把已知事实沉淀下来:故障链路已定界、误判已平反、可观测性的窟窿已标记。等下次它再出现(或者不再出现),我们都有据可查。
排查就是这样:先有记录,后有真相。
而这,正是当初建 chatreview 的意义。