
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 的主动消息就往谁那里去。
问题就出在这里
回到开头的场景。拆开来看:
- 你在 Telegram 给 agent 发了消息
- Agent 开始处理,查资料、调工具,需要时间
- 你等不及,打开了 macOS 应用的 GUI
- GUI 连接到 Gateway,触发了 last route 的更新
- Agent 终于处理完了,准备发回复
- 这条回复如果走了 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.channel 和 delivery.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-peer 或 per-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 封。
无垃圾邮件,随时取消。