当 Bot 开始自言自语:分布式消息系统的方向性陷阱
我遇到过一个诡异的 bug:
Clawdbot 在更新到某个版本后,开始"自己给自己发 WhatsApp 消息"。
行为是这样的:
我: 今天天气怎么样?
Bot: (沉默)
Bot 自己的号码: 收到消息"今天晴天,20度"
我: ???
Bot: (继续沉默)
Bot 自己的号码: 收到消息"有什么可以帮您的吗?"
Bot 进入了一个"自言自语"的循环,像个疯子。
这个 bug 很搞笑,但它暴露的问题很严肃:在双向消息系统里,"谁是发送者"这件事并不像看起来那么简单。
单向 vs 双向:根本性的差异
传统 HTTP API(单向)
Client Server
| |
|---- Request ------->|
| |
|<---- Response ------|
| |
方向永远清晰:
- Request 从 Client 到 Server
- Response 从 Server 到 Client
代码逻辑:
app.post('/api/chat', (req, res) => {
const result = processMessage(req.body);
res.json(result); // 响应自动返回给发起请求的客户端
});
响应的"目标"不需要显式记录,由 HTTP 协议保证。
Clawdbot 的消息系统(双向)
User Clawdbot AI
| | |
|---- 消息1 --------->| |
| |---- 请求 -------->|
| |<---- 响应 --------|
|<---- 回复1 ---------| |
| | |
| |---- 主动消息 ---->| (定时提醒)
|<---- 主动消息 ------| |
问题来了:
- 当 Clawdbot 要发送"回复1"时,目标是谁?
- 当发送"主动消息"时,目标又是谁?
- 如果两种消息的处理逻辑共用代码,会不会搞混?
答案是:会,而且经常搞混。
Bug 的技术根源
场景重现
步骤1:你设置了定时提醒
{
"cron": {
"dailyReminder": {
"schedule": "0 8 * * *",
"action": "sendMessage",
"to": "+8613800138000",
"message": "早安!今天有 3 个会议。"
}
}
}
Clawdbot 执行定时任务时,会创建一个"系统发起"的会话:
const session = {
id: generateSessionId(),
sender: BOT_PHONE_NUMBER, // 发送者是 bot
recipient: USER_PHONE_NUMBER,
type: 'proactive'
};
await sendMessage(session, "早安!今天有 3 个会议。");
步骤2:你回复这条消息
你: 推迟第一个会议到下午
步骤3:Clawdbot 处理回复
代码逻辑(错误的):
async function handleIncomingMessage(message) {
// 获取当前会话
const session = await getSession(message.sessionId);
// 调用 AI 生成回复
const reply = await ai.chat(message.content);
// 发送回复
await sendMessage(session.sender, reply); // ← 问题在这里!
}
session.sender 是谁?是 BOT_PHONE_NUMBER(因为这个会话是 bot 主动发起的)。
所以回复被发到了 bot 自己的号码。
为什么会这样设计?
开发者的思路可能是:
"Session 代表一个对话,sender 是这个对话的发起方。回复应该发给发起方。"
这个逻辑在"人类发起对话"时是对的:
session.sender = 用户
回复发给 session.sender = 正确
但在"bot 主动发起对话"时就错了:
session.sender = bot
回复发给 session.sender = 错误(发给自己了)
核心问题:把"会话发起者"和"消息回复目标"混为一谈了。
正确的设计
分离两个概念
type Session = {
id: string;
initiator: string; // 谁发起的会话(不变)
participants: string[]; // 参与者列表
};
type Message = {
id: string;
sessionId: string;
from: string; // 谁发的这条消息
to: string; // 发给谁
content: string;
direction: 'inbound' | 'outbound';
};
处理回复时,从原始消息中提取"回复目标":
async function handleIncomingMessage(message: Message) {
// 不从 session 读取,从消息本身读取
const replyTo = message.from; // 谁发的消息,回复给谁
const reply = await ai.chat(message.content);
await sendMessage({
from: BOT_PHONE_NUMBER,
to: replyTo, // 明确的目标
content: reply
});
}
幂等性保护
另一个隐患:bot 的回复消息可能被"当成新消息处理"。
Bot 发消息 → WhatsApp 服务器 → Webhook 回调 → Clawdbot 收到
如果 Clawdbot 把自己发的消息又当成"用户消息"处理,会进入死循环。
解决方案:已处理消息 ID 缓存
class MessageDeduplicator {
private processed = new Set<string>();
private maxSize = 10000;
isProcessed(messageId: string): boolean {
return this.processed.has(messageId);
}
markProcessed(messageId: string): void {
// LRU 清理:超过上限删除最老的
if (this.processed.size >= this.maxSize) {
const oldest = this.processed.values().next().value;
this.processed.delete(oldest);
}
this.processed.add(messageId);
}
}
// 使用
if (deduplicator.isProcessed(message.id)) {
return; // 跳过已处理的消息
}
await handleMessage(message);
deduplicator.markProcessed(message.id);
更深层的反思
问题1:状态同步的复杂性
在分布式系统里,"状态"很容易不一致。
Clawdbot 本地状态: "刚发了一条消息"
WhatsApp 服务器状态: "收到一条新消息"
如果两边的"消息 ID"生成规则不一致,就会认为这是"两条不同的消息"。
实际上只是同一条消息的不同视角。
问题2:时序依赖
消息到达顺序可能和发送顺序不一致:
用户发送:
消息A (08:00:00)
消息B (08:00:01)
Clawdbot 接收(因为网络延迟):
消息B (08:00:02)
消息A (08:00:03)
如果处理逻辑依赖顺序,就会出错。
问题3:谁该负责"记住"对话?
传统聊天软件:由服务器维护会话状态。
Clawdbot 模式:本地维护状态,但消息流经过第三方平台(WhatsApp)。
这导致"真相"分散在三个地方:
- WhatsApp 服务器:消息原文
- Clawdbot 数据库:会话历史
- AI API:上下文理解
任何一个环节出错,整个系统就乱了。
我的观点
这个 bug 不是个例,而是整个"AI 消息助手"架构的缩影。
当你试图把"无状态的 AI"(每次调用独立)和"有状态的消息系统"(需要记住对话)结合时,会遇到大量"状态同步"的难题。
更根本的问题是:现在的消息协议(WhatsApp、Telegram)不是为 AI 设计的。
它们假设:
- 消息由人类发起
- 回复是即时的
- 会话上下文由客户端管理
但 AI 助手需要:
- 能主动发起对话
- 回复可能延迟(需要调用 API)
- 上下文需要持久化(跨设备、跨会话)
这是一个结构性矛盾。
短期内只能靠"打补丁"(各种 workaround)解决。
长期来看,可能需要一套"AI-native 的消息协议",从底层设计就考虑这些问题。
但那是另一个话题了。
技术参考:
- 消息幂等性设计: https://blog.pragmaticengineer.com/idempotency-in-messaging/
- 分布式系统时序问题: https://lamport.azurewebsites.net/pubs/time-clocks.pdf
- Clawdbot Issue #904: https://github.com/clawdbot/clawdbot/issues/904