OpenClaw 对话机制源码分析(一):一条消息如何找到它的会话
本系列有三篇:
• 本篇:消息怎么找到它的会话 • 下篇:找到会话之后的启动准备 • 第三篇:LLM 调用时的容错管理
和 Agent 发了一条消息,它做了回复,这个过程中间发生了什么?
听着这个过程好像只是 prompt → LLM → response,但在一个真实的 Agent 运行时里,第一个问题不是“怎么回复”,而是“这条消息属于哪个会话”。一个 Agent 可能同时服务多个渠道、多个群组、多个用户,每条消息必须先能正确找到它应该写入的那个对话历史。
本篇文章来拆解下OpenClaw,怎么把一条消息路由到正确的会话,中间做了哪些设计。

消息的会话类型
OpenClaw 可以同时跑着多个 Agent,接入多个渠道(Telegram、Discord、微信、CLI、Web)。一条消息到达后,系统第一件事不是分析内容,而是回答一个路由问题:这条消息应该写进哪个会话的历史记录?
要理解路由,先要理解 OpenClaw 怎么分类消息来源。源码里有一个 ChatType 类型定义(src/channels/chat-type.ts):
exporttypeChatType = "direct" | "group" | "channel";
三个值分别对应三种聊天场景。direct 是一对一私聊——你和 Agent 之间的私聊,比如你在 WebChat 或微信里跟 Agent 单独对话,就是 direct。group 是多人群组——一个独立的聊天室,你要被拉进去才能参与,比如微信群、QQ群、Telegram 群。channel 是频道——它不是独立存在的聊天室,而是存在于一个“父空间”里的子房间,比如 QQ频道里的各个子频道、Discord 服务器里的 #general / #dev 房间。
这三种聊天类型在路由层面的作用,就是决定消息被分到哪个独立的会话。direct 的消息进私聊会话,group 的消息进对应群组的会话,channel 的消息进对应频道的会话。它们之间的上下文完全隔离——你在私聊里说的话不会出现在群聊的上下文里,不同群组之间也互不干扰。
会话不能永远用下去
隔离解决了”消息往哪放”的问题。但还有一个 Agent 特有的问题:会话内的对话历史会越来越长。
Agent 不像人类聊天——人类可以自然地到下一段,但 Agent 每次回复都会把完整的对话历史作为 input 发给 LLM。聊了 100 轮的会话,第 101 轮要把前面 100 轮全部发出去。这意味着:token 成本会线性增长,旧信息可能变成噪音(三天前问的天气信息和今天要写的代码没关系),而且 LLM 有上下文窗口上限(比如 200K token)。
所以需要一个机制来回答:Agent 的记忆应该有多长?什么时候该“翻篇”?
可能你也遇到过这种场景,上周三和 Agent 聊了一个复杂的技术问题,讨论了很多背景、做了几个决策。今天周末再打开同一个会话窗口,继续说“上次我们讨论的那个方案,我想改一下”——Agent 好像完全忘记了你说的是什么。
你的感觉是“明明是同一个对话,为什么它不记得了?”但实际上,它已经被重置了。界面上看着还是同一个会话名称、同一个入口,但底层已经换了一个新的 sessionId,指向一个新的空白 transcript 文件。上周三的对话历史对 Agent 来说不存在。
重置策略:什么时候”翻篇”
这就是重置策略(Reset Policy)的作用。它定义了”什么条件下,让一个会话的历史上下文清空,Agent 从头开始”的规则。
官方文档 docs/concepts/session.md, 有对 Session lifecycle 的定义:
Sessions are reused until they expire.
会话会被复用,直到它过期。过期的触发条件有三种:
Daily reset(默认行为)——每天凌晨 4 点(gateway 主机本地时间)自动开始新会话。判断基准是当前 sessionId 的创建时间(sessionStartedAt),不是最后一次消息时间。
Idle reset(可选)——一段时间没有交互后开始新会话。通过 session.reset.idleMinutes 配置。判断基准是最后一次真实的用户/渠道交互(lastInteractionAt)——heartbeat、cron、exec 等系统事件不会续命。
Manual reset——用户在聊天里输入 /new 或 /reset 手动触发。
这三种条件可以单独配置,也可以同时生效。如果同时配置了 daily 和 idle,两个条件同时在跑,哪个先触发就先重置。
这里有必要明确下重置的定义。重置不是“废弃”这个会话了,它实际做的事情是:生成新的 sessionId(后续消息写入新的 transcript 文件),重新走一遍 Bootstrap(注入最新版本的 workspace 文件、技能、记忆),清除运行时的 auto model override(如果之前因故障自动切换了模型,重置后回到主模型),丢弃旧会话中排队的系统事件通知。旧的 transcript 文件不会被删除。
所以重置更准确的理解是“新开始”——旧的数据还在,但 此Agent会话 从最新的初始知识重新开始。这意味着重置还有一个“刷新”作用:如果你在两次对话之间更新了 MEMORY.md、改了 AGENTS.md 的行为规则、装了新 skill,只有重置后 Agent 才能看到这些变化。
不同类型的会话,重置方式不同
在重置策略的类型定义中(src/config/sessions/reset-policy.ts),出现了一个跟 ChatType 不完全一样的分类:
exporttypeSessionResetType = "direct" | "group" | "thread";
thread 是指某条消息的回复链或子话题——Discord 的 Thread、Telegram 的 Forum Topic、Slack 的 Thread。
ChatType 有 channel,这里没有 channel 但多了 thread。因为这两套分类有不同的目的。
ChatType 回答的是”消息从哪来”——路由层面需要区分 direct/group/channel 三种不同的场景。
SessionResetType 回答的是”上下文该活多久”——从重置策略的角度看,channel 和 group 特征一样(都是多人公共空间,话题切换快),没必要单独配置,所以合并为 group。而 thread 的生命周期跟父群/频道完全不同——群聊可能持续几个月活跃,而一个 thread 通常几小时就结束了,所以在重置策略上被独立出来。
为什么每种类型需要不同的重置方式?因为需要的”保鲜期”不同。
私聊是你和 Agent 之间的持续对话,上下文跨越几天很正常。群聊里话题切换快,几小时前的讨论大概率跟当前无关。Thread 更极端——它通常是针对某个具体问题的短讨论,结束就结束了。OpenClaw 允许你通过 session.resetByType 对每种类型单独配置重置策略。默认情况下所有类型统一走 daily reset(每天凌晨 4 点重置),但你可以根据场景覆盖,比如让群聊用 idle 模式、thread 用更短的 idle 周期。
消息到达后的第一个问题:这条消息属于谁?
回到路由本身。一条消息从 Telegram 群里到达,携带的信息是:渠道 = Telegram,群 ID = 12345,发送者 = user_789。系统需要把这条消息写进正确的会话历史。
最直觉的做法是给每个会话分配一个随机 UUID,再维护一张映射表:Telegram 群 12345 → 内部会话 uuid-abc。消息进来时先查映射表,找到对应的会话 ID,再写入。
映射表意味着多维护一层状态——每次新群、新 thread 出现都要先创建映射记录,每次消息进来先查映射再查会话,两次查询。
OpenClaw 的做法更直接:消息自带的来源信息(渠道 + 群 ID + 发送者)本身就是稳定且唯一的。与其分配一个随机 ID 再维护映射关系,不如直接用来源信息本身作为 key——输入天然能定位输出,不需要中间层。这就是确定性计算的 Session Key——同一个群、同一个发送者的消息,不管什么时候到达,算出来的 key 一定相同,直接路由到同一个会话。
源码里这个计算逻辑很清晰(src/config/sessions/session-key.ts):
exportfunctionderiveSessionKey(scope: SessionScope, ctx: MsgContext) {
// 全局作用域:所有消息共享一个 key
if (scope === "global") {
return"global";
}
// 群组/频道:用群组标识生成 key
const resolvedGroup = resolveGroupSessionKey(ctx);
if (resolvedGroup) {
return resolvedGroup.key; // 格式: "telegram:group:12345"
}
// DM 直接:用发送者身份
constfrom = ctx.From ? normalizeE164(ctx.From) : "";
returnfrom || "unknown";
}
上面的 resolveGroupSessionKey(ctx) 就是处理群组/频道场景的函数。它从消息上下文中提取渠道、类型和群组 ID, 组合成key:
// src/config/sessions/group.ts
return {
key: `${provider}:${kind}:${finalId}`, // 如 "telegram:group:12345"
channel: provider,
id: finalId,
chatType: kind === "channel" ? "channel" : "group",
};
三个字段组合:渠道(telegram/discord/…)+ 类型(group/channel)+ 具体 ID。最后再经过 resolveSessionKey 加上 Agent 命名空间前缀,变成最终的持久化 key:
// DM → "agent:my-assistant:main"
// 群组 → "agent:my-assistant:telegram:group:12345"
return`agent:${canonicalAgentId}:${raw}`;
具体用哪些字段来组合,取决于聊天类型:direct 只需要发送者 ID(或者归入 main),group/channel 需要渠道 + 类型 + 群组 ID。
还有一类消息不走这个计算
上面的计算逻辑适用于从聊天平台来的消息——它们有自然的来源信息(哪个渠道、哪个群、谁发的)可以用来算 key。但还有一类消息不是从聊天平台来的:子代理任务、cron 定时任务、外部 API 调用、webhook。
这些场景的共同特征是:程序化触发、一次性执行、没有“群 ID”“发送者 ID”这些自然标识。子代理跑完任务就结束,cron job 每次执行是独立的,API 调用完就走。它们不需要“后续消息找到回家的路”,因为根本没有“后续消息”。
对于这类场景,调用方在发起时直接传入 ctx.SessionKey,跳过自动计算:
const explicit = ctx.SessionKey?.trim();
if (explicit) {
returnnormalizeExplicitSessionKey(explicit, ctx);
}
这个 key 通常包含 UUID 或任务标识(比如 agent:main:explicit:cron-abc123),每次任务生成一个新 key,会话是一次性的——用完就结束,不会有后续消息再路由进来。
Session Key 解决了“消息往哪放”的问题,重置策略解决了“会话该活多久”的问题。但找到会话之后,它可能是空的(刚创建或刚重置),需要注入上下文让 Agent 知道自己是谁,还得防止两条消息同时写入同一个会话。这些“找到之后怎么让会话准备好”的问题,下一篇展开。
夜雨聆风