# 技术破壁专栏 · 第10期

# 消息进来了，回复去哪了？——「在吗」连问22次的故障定界平反记

> **系列前情**：第09期记录了 OpenClaw 升级到 2026.9.8 后内存失控、快照回滚的惊魂一夜。本期是续集——回滚后的系统，在凌晨用另一种方式翻车了。而这一次的排查，推翻了我们当初信誓旦旦的结论。

---

## 一、案发现场：客户连问22次「在吗」

10月9日凌晨 00:34~00:36，一位客户在微信客服对话框里，用两分钟问了一个载入我们故障史册的问题：

```
00:34  客户：在吗
00:34  蟹蟹：在的！🦀 这么晚还在忙？
00:34  客户：在吗
00:34  蟹蟹：在呢～
00:34  客户：在吗
00:34  蟹蟹：在的在的！
……（以下省略19轮）
00:36  客户：在吗
00:36  蟹蟹：在！您说我听着 🦀
```

客户连问 **22 次**，蟹蟹连答 **22 次**，每一次都热情饱满、不重样。

表面看这是个段子：一个执着的客户，陪一个傻乎乎的 AI 玩复读机。

但如果你把视角切到**客户那一端**，画面立刻就不搞笑了——**客户从头到尾，一条回复都没看到**。他看到的只有一个死寂的对话框，和微信客服那个永远不会回应的灰色头像。

于是他开始怀疑人生：在吗？在吗？在吗……

## 二、当初的判定：一定是「排水」机制

事故第一时间，我们的排查结论非常笃定：**Gateway 处于排水（Draining）状态，拒绝接收新消息**。

这个判断有充分的"历史依据"：

- 9月18日（第09期记录的那晚），升级到 2026.9.8 后系统内存失控，回滚过程中确实遭遇过 `GatewayDrainingError`——Gateway 关闭前进入排水状态，拒绝新连接、只处理存量请求；
- 回滚到 2026.9.6 后，我们对"重启期消息进不来"这个故障模式印象深刻；
- 而"客户发消息没有回应"的表象，和排水状态**一模一样**。

于是结案陈词：重启窗口期的排水机制把客户消息挡在了门外，蟹蟹根本没机会说话。锅是排水的，平反等不到，注意避开重启窗口就好。

**这个结论，错了整整一个月。**

## 三、平反证据：chatreview 里的幽灵对话

转机来自一个意想不到的地方——我们自己的 **chatreview 对话复盘系统**。

10月9日晚上，我们把 chatreview 从单轨（蟹蟹）扩成双轨（蟹蟹 + 龙虾教官），顺便翻历史数据时，看到了那段"在吗"对话的完整记录。这一看，整个人都不好了：

**chatreview 里，22 条客户消息和 22 条蟹蟹回复，一条不少。**

chatreview 的数据来自 OpenClaw 会话 SQLite 的每日凌晨归档提取——它记录的是"系统实际处理了什么"，不是"客户看到了什么"。这意味着：

```
客户消息 → ✅ 进来了（chatreview 完整记录，时间戳精确到秒）
        → ✅ LLM 处理了（22 条回复各不重样，不是缓存复读）
        → ❌ 卡在出站投递（客户端一条没收到）
```

排水机制的锅，不成立了——**排水是"入口拒绝"，而入口明明畅通**。

## 四、真凶现形：Gateway 日志里的一行 WARN

带着新线索翻 Gateway 日志（`journalctl --user -u openclaw-gateway`），完整时间线浮出：

**00:30:09 —— Gateway 异常终止**

```
systemd[1125]: openclaw-gateway.service: Failed with result 'signal'.
```

服务被信号杀掉。这台 3.6GB 内存的机器上，最大嫌疑是 OOM killer——凌晨内存压力下，内核挑了个大进程祭天。

**00:31:39 —— 重启完成，但插件带伤**

重启耗时 90 秒，加载过程中一堆插件报错（`ERR_PACKAGE_PATH_NOT_EXPORTED`），wecom 系列通道直到 **00:32:32** 才全部恢复上线。

**00:34 起 —— 客户进场，踩进"半死不活"窗口**

客户的第一条"在吗"在通道恢复后 2 分钟到达。日志里能看到每一次请求的完整处理痕迹：

```
[model-fetch] response provider=xxx api=openai-completions model=glm-5 
              status=200 elapsedMs=2938
```

模型正常推理，2~3 秒出结果，token 正常烧。**22 次，次次如此。**

**00:39:18 —— 真凶露出马脚**

```
[wecom-kf] [WARN] dialogue outbound transition failed (non-blocking): 
params.runtime.agent.session.loadSessionStore is not a function
```

这一行 WARN 级别的日志，是整场事故唯一的技术报错：

- **what**：出站（outbound）消息投递失败
- **why**：重启后插件的会话存储接口 `loadSessionStore` 还没就绪——典型的**启动竞态（race condition）**
- **怎么处理的**：`(non-blocking)`——静默吞掉，不抛错、不重试、不告警

## 五、定性：这不是排水，是「哑巴窗口」

至此可以给事故正式定性，并且给排水机制**无罪释放**：

| 维度 | 排水（当初的误判） | 哑巴窗口（真相） |
|------|------|------|
| 故障位置 | 入口：拒绝新消息 | 出口：投递失败 |
| 消息处理 | 根本没收到 | 正常接收、正常推理 |
| Token 消耗 | 无 | 照烧不误 |
| 客户体验 | 无回应 | 无回应（一模一样） |
| 日志可见性 | 明确的 Draining 状态 | 仅一行 WARN，静默吞没 |

两种故障**表象完全相同、根因完全不同**。当初拿着客户侧现象去套已知故障模式，是典型的"用旧地图找新路"。

更深一层的教训是**静默失败（silent failure）的杀伤力**：`(non-blocking)` 这个设计本意是"投递失败别阻塞主流程"，但它同时意味着——

> 客户体验层面无感知（消息石沉大海），运营层面无告警（只是一行 WARN）。要不是这位客户执着地问了 22 次，这起事故可能永远不会被发现。

## 六、复盘：chatreview 的意外战功

这次排查还有个额外收获：**chatreview 从"客服复盘工具"升级成了"故障定界的证据源"**。

复盘一下定界逻辑，以后再遇"客户无回应"类故障，排查顺序应该是：

```
看 chatreview 里有没有 AI 回复记录
├── 有记录 → 处理环节活着 → 问题在出站投递（查通道日志/插件状态）
└── 无记录 → 消息没进来 → 问题在入口（查排水状态/webhook/通道健康）
```

**先定界，再找因。** 一句话就能砍掉一半的排查范围，避免再让某个"熟悉的故障模式"抢先占据心智。

## 七、整改清单

按"规则 + 架构双管齐下"的惯例，落地三条：

### 1. 规则层：连续重复消息的应答纪律

给客服 Agent 补一条行为规则：**连续收到 3 次以上相同的短消息（如"在吗"），应切换话术主动确认"您能看到我的回复吗"，而不是继续热情应答**——22 次不重样的热情，在客户端等于 22 次沉默。

### 2. 架构层：出站失败必须可观测

`non-blocking` 不等于 `silent`。整改方向：

- wecom-kf 插件的 outbound 失败提升日志级别到 **ERROR**；
- 巡检 cron 增加一条：抓取日志中 `outbound transition failed`，出现即告警推送到运维微信；
- 中期目标：投递失败的消息进入**重试队列**，而不是直接丢弃。

### 3. 稳定性层：别让 OOM 成为凌晨的常客

00:30 的崩溃大概率是内存不足触发 OOM killer。3.6GB 内存的机器跑 Gateway（heap 限制 2GB）+ 双插件记忆系统，本来就贴着红线走：

- systemd 服务配置 `Restart=always` + `OOMScoreAdjust` 降低被杀优先级；
- 内存高水位监控：可用内存低于阈值时主动告警，把"事后翻案"变成"事前通风报信"。

## 八、尾声：这不是最终结论

需要诚实说明的是，`loadSessionStore is not a function` 的深层根因——是 2026.9.6 版本的插件兼容层缺陷，还是重启竞态的普遍问题——**还没有最终结论**。它出现在一次崩溃重启之后，可复现性未知。

但这不妨碍先把已知事实沉淀下来：故障链路已定界、误判已平反、可观测性的窟窿已标记。等下次它再出现（或者不再出现），我们都有据可查。

排查就是这样：**先有记录，后有真相。**

而这，正是当初建 chatreview 的意义。

---

**专栏信息**
- 期号：第10期
- 关联阅读：第09期《OpenClaw 升级惊魂夜：内存失控与快照回滚》
- 关键词：故障定界、静默失败、启动竞态、chatreview、可观测性
- 写作日期：2026-10-10
