当 Bot 开始自言自语:分布式消息系统的方向性陷阱

当 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
← 返回博客列表