← 返回博客
OpenClaw 消息路由:谁动了我的回复?

OpenClaw 消息路由:谁动了我的回复?

你跟 OpenClaw 说话,用 Telegram 说的,回复却跑到了 GUI 上。你左等右等,Telegram 一片沉默。打开 macOS 应用一看,回复好端端地躺在那里。

你以为是 bug。不是。这是系统按照它的规矩在办事。只不过它的规矩,跟你脑子里想的那套,不是一回事。

这篇文章把这件事从头到尾拆干净。

一个大前提:所有私信,一个桶

OpenClaw 的会话系统有一条根本性的设计决定:你跟 agent 之间的私信,不管从 Telegram 发的、WhatsApp 发的、WebChat 发的,统统灌进同一个会话。

会话的 key 长这样:

agent:main:main

一个 agent,一把钥匙,一个桶。你从 Telegram 扔进去的消息,和你从 WhatsApp 扔进去的消息,在 agent 看来没有区别。它只知道"这个人跟我说了什么",不关心你从哪扇门进来的。

这个行为由 session.dmScope 控制,默认值 "main"

为什么这么做?道理很简单:连续性。你在 Telegram 跟 agent 聊到一半,切到 WhatsApp 接着聊,agent 记得前面的话。不用重复背景,不用重新建立上下文。多个渠道,不过是进入同一间屋子的不同门。

群组聊天是另一回事。每个群有自己的 key(agent:main:telegram:group:-1001234567890),彼此不干扰。群是群,私信是私信,井水不犯河水。

核心机制:回复发往何处

agent 生成了回复,这条回复究竟发到哪里?

你问我答的场景,不用想,从哪来回哪去。你在 Telegram 问的,回复回到 Telegram。这是确定性的路由,不需要模型做判断,不需要"最后路由"参与。代码说了算。

文档原话:

OpenClaw 将回复路由回消息来源的渠道。模型不会选择渠道;路由是确定性的,由主机配置控制。

做到这一步,一切太平。问题出在另一类场景上。

有些回复没有明确的来源渠道。心跳:agent 自己醒过来,没人问它话,它主动找你说事。定时任务:cron 跑完了,结果要发给你。多步操作的后续回复:你的一条消息触发了一串动作,最后那条回复已经不是对你原始消息的直接回应。

这些场景里,"从哪来回哪去"的逻辑不成立了,因为根本没有"来"。系统需要一个兜底规则。

这个兜底规则叫 last route。

每次你从某个外部渠道给 agent 发消息,系统调用 updateLastRoute,把这个渠道记下来。下次 agent 需要主动发消息、又不知道该发到哪里时,就用这个记录。

简单说:谁最后跟 agent 说过话,agent 的主动消息就往谁那里去。

问题就出在这里

回到开头的场景。拆开来看:

  1. 你在 Telegram 给 agent 发了消息
  2. Agent 开始处理,查资料、调工具,需要时间
  3. 你等不及,打开了 macOS 应用的 GUI
  4. GUI 连接到 Gateway,触发了 last route 的更新
  5. Agent 终于处理完了,准备发回复
  6. 这条回复如果走了 last route 逻辑,它发到了 GUI

还有一种更常见的翻车现场:心跳。

心跳的 target 默认是 "last",意思就是"发到上次活跃的渠道"。你最后一次操作如果是在 GUI 里点了一下,心跳回复就出现在 GUI。你在 Telegram 等着 agent 给你汇报,等到天荒地老也不会来,因为消息去了别的地方。

文档里有句话讲得很直白:

多个设备/渠道可以映射到同一会话,但历史记录不会完全同步回每个客户端。建议:对长对话使用一个主设备。

这句话背后的意思是:系统知道这个问题,但它选择不帮你自动解决。你得自己管好渠道。

治法

认清了病因,下药就不难。核心就一条思路:别靠 "last",把目标写死。

想看更多实战拆解?

AI、工程与实验,每月 1–2 封。

无垃圾邮件,随时取消。

治法一:心跳钉死渠道

最直截了当的修法。把心跳的 target"last" 改成你主用的渠道:

{
  agents: {
    defaults: {
      heartbeat: {
        every: "30m",
        target: "telegram",
        to: "你的 Telegram chat ID"
      }
    }
  }
}

不管你中间在哪个设备上晃悠了一下,心跳回复永远出现在 Telegram。一刀切,干净利落。

治法二:定时任务写死投递渠道

一样的毛病,一样的治法。如果你不指定 delivery.channeldelivery.to,隔离式定时任务的结果会回退到 last route。

写死它:

openclaw cron add \
  --name "Morning brief" \
  --cron "0 7 * * *" \
  --tz "America/Los_Angeles" \
  --session isolated \
  --message "Summarize overnight updates." \
  --announce \
  --channel telegram \
  --to "你的 Telegram chat ID"

JSON 写法:

{
  delivery: {
    mode: "announce",
    channel: "telegram",
    to: "你的 Telegram chat ID"
  }
}

治法三:拆会话,各管各的

如果你压根不需要跨渠道的连续性,最干脆的做法是让每个渠道有自己独立的会话:

{
  session: {
    dmScope: "per-channel-peer"
  }
}

Telegram 的 key 变成 agent:main:telegram:dm:你的ID,WebChat 的变成 agent:main:webchat:dm:你的ID。两条独立的线,不会串。

代价也摆在台面上:你在 Telegram 聊的内容,切到 WebChat 后 agent 一概不记得。两边各过各的日子。

治法四:不拆会话,管好自己

如果你要跨渠道连续性,又受不了回复跑偏,最简单的办法是纪律:

  • 只在一个渠道上发消息(比如 Telegram)
  • GUI 只看不说话
  • 心跳和 cron 的 target 都钉死到 Telegram

这样 last route 基本不会被意外更新。不靠机制,靠自觉。朴素,管用。

各路场景,一表说清

场景投递目标怎么定你该怎么做
你发消息,agent 直接回复回到来源渠道,确定性不用管,天然正确
心跳回复target 字段,默认 "last"改成固定渠道
隔离式 cron 任务delivery.channel + delivery.to,缺省走 last route显式指定
主会话 cron(系统事件)跟随心跳的 target确保心跳 target 正确
Agent 主动调用消息工具工具参数决定看 agent 的 prompt 怎么写

补充:渠道可见性

心跳还能按渠道控制"是否显示":

channels:
  defaults:
    heartbeat:
      showOk: false      # 没事的时候不刷屏
      showAlerts: true    # 有事的时候报警
      useIndicator: true  # 给 UI 发状态指示
  telegram:
    heartbeat:
      showOk: true        # Telegram 上什么都显示

优先级:单账户设置 > 单渠道设置 > 渠道默认值 > 内置默认值。

合理的用法:Telegram 显示全部心跳反馈,让你随时确认 agent 在线;Slack 只显示警报,免得刷屏。各渠道各有分工,各安天命。

补充:Telegram 论坛投递

如果你用 Telegram Forum(带主题帖的群),定时任务可以精确投递到某个主题。格式:

-1001234567890:topic:123

群组 ID 加主题 ID。回复跑到了群的"通用"区而不是你指定的主题帖?检查一下有没有用 :topic: 格式。十之八九是格式没写对。

补充:发送策略

想彻底堵住某类消息的投递,用 sendPolicy

{
  session: {
    sendPolicy: {
      rules: [
        { action: "deny", match: { channel: "discord", chatType: "group" } },
        { action: "deny", match: { keyPrefix: "cron:" } }
      ],
      default: "allow"
    }
  }
}

这两条规则:第一条堵死 Discord 群组的投递,第二条堵死所有 cron 任务的投递。

运行时可以临时覆盖:

  • /send on 放行当前会话
  • /send off 阻止当前会话
  • /send inherit 清除覆盖,回到配置

补充:跨渠道身份合并

per-peerper-channel-peer 模式下,同一个人在 Telegram 和 Discord 默认是两个身份。用 identityLinks 可以合并:

{
  session: {
    identityLinks: {
      alice: ["telegram:123456789", "discord:987654321012345678"]
    }
  }
}

alice 的两个渠道映射到同一个规范 ID,共享会话。一个人就是一个人,别让渠道把人拆成两半。

归结

回到开头:你在 Telegram 说的话,回复为什么跑到了 GUI?

原因只有一个:所有私信共享一个会话,而会话的 last route 被 GUI 更新了。agent 的回复走了 last route 逻辑,它就去了 GUI。

修法也只有一个:别用 "last",把目标写死。

心跳设 target: "telegram",cron 设 --channel telegram --to <chat_id>。就这么简单。

路由问题的根源,从来不是系统出了错,而是你没搞清楚系统按什么规矩办事。搞清楚了,一行配置就解决。搞不清楚,换十个设备也还是一团浆糊。

新文章,直接发到你的邮箱。

AI、工程与实验,每月 1–2 封。

无垃圾邮件,随时取消。