你在电脑前让 Agent 帮忙查一个问题。它刚找到线索,你得出门了,于是拿起手机,在聊天软件里补了一句:“沿着刚才那个方向继续。”
我们很容易期待它自然接上话。毕竟名字一样,背后调用的模型也一样,为什么还需要重新解释?
但对一个真正接入多个入口的 Agent 系统来说,“刚才”是一个需要计算的地址。那句话来自哪个平台、哪个账号、哪个群、哪条讨论线程?它应该打开哪段历史?如果群里另一个人也说了“继续”,两个人是在合作,还是各有各的任务?
Hermes Agent 首先值得读的,正是这条从聊天窗口走向执行核心的路。它让不同入口复用 Agent 能力,同时用会话路由决定上下文的归属。读懂这部分,再去看记忆、工具和长期任务,会少掉许多误会。
门可以有很多,接待流程要说同一种话
先把一个消息平台想成一扇门。Telegram、Discord 或 Slack 各有自己的事件格式、连接方式、回复限制和线程规则。如果把这些差异全部塞进 Agent 的推理循环,循环很快就会变成平台条件判断的集合。
Hermes 把消息平台的适配工作放在外侧。平台适配器接收事件,整理出公共的 MessageEvent;事件里的 SessionSource 带上平台、聊天、发送者和线程等来源信息。Gateway 随后处理接入检查、会话路由和任务调度,执行核心才接到需要处理的对话。
平台插件的实现可以在 plugins/platforms 下找到,共享行为集中在 gateway/platforms/base.py。这层分工的价值很具体:平台如何连接、文本怎样分段发送,不必成为模型如何调用工具的前置知识。反过来,工具循环变复杂,也不应该迫使每个平台重新实现它。
CLI 等入口同样可以使用 AIAgent 核心。不过,“使用同一套核心代码”“复用同一个内存对象”“进入同一段对话”,是三件不同的事。换一扇门,最多证明你进入了同一套系统,还不能证明接待员已经找到了你的那本记录。
这也是阅读 Hermes 时很有用的起点:入口负责把事情说清楚,Gateway 负责把事情交对地方,Agent 负责往下做。这个职责关系来自官方的 Gateway 说明和实现,具体路由行为还要继续看代码。
“刚才那段对话”,到底存在哪个格子里
假设 Alice 在一个群里问:“帮我分析一下这个错误。”几秒后,Bob 在同一个群里问了另一个问题。如果系统只用群 ID 查找历史,两个人就会进入同一本记录。这个选择未必错误,但它必须是有意作出的产品决定。
Hermes 的 build_session_key 把这个决定写成一个确定性的路由算法。它根据来源字段和会话配置拼出 session_key。默认命名空间是 agent:main,命名 profile 可以换成自己的前缀;平台也进入键中,因此两个平台碰巧出现相同的聊天 ID,不会仅凭这个 ID 合流。Slack 还会带上工作空间的范围标识。
私聊通常按聊天 ID 路由。当聊天 ID 缺失时,代码才退到发送者 ID;连发送者信息也没有,仍存在更粗的兜底。因此来源信息是否完整,是适配层必须认真对待的事。路由函数无法凭空补出一个可靠的身份。
群聊的默认行为更有意思:没有线程时,通常把参与者也放进键里,让 Alice 和 Bob 各有上下文;进入线程后,默认允许同一条线程里的参与者共享对话。线程在这里承载了一种产品语义——大家正在围绕同一个话题讨论。
这些行为可以配置。需要线程也按人隔离时,在群聊按人隔离开启的前提下,再开启线程按人隔离。不要只看配置名就猜规则,实际条件由 group_sessions_per_user 和 thread_sessions_per_user 共同决定。

还有一个很容易漏掉的细节。Discord 的一条频道消息可能触发创建新线程,第一条消息到达时,真正的线程来源尚未形成。Hermes 可以用 prospective_thread_id 提前把它路由到即将出现的线程,并把类型槽规范成 thread。这样后续在线程里发来的消息,才能生成相同的键。
如果只在第二条消息开始使用线程 ID,用户看到的是连续讨论,系统看到的却是两段历史。这个小分支提醒我们:会话路由不只是字符串拼接,它是在不同事件阶段之间维持产品承诺。上述行为可以直接对照 会话键实现。
地址相同,执行的人却可能需要换一个
接下来再拆开两层。session_key 是用于查找的地址;SessionEntry.session_id 标识持久化的那段会话;缓存中的 AIAgent 则是当前用来执行的对象。三者有关联,却不应该绑成一个生命周期。
想象一本客户记录和一位接待员。客户记录可以保留,接待员可以换班。进程重启、缓存对象被回收,不能自动推导出记录已经消失;同样,看到一个熟悉的 Agent 对象,也不能据此证明当前消息应该读它曾经处理过的所有历史。
Hermes 还给缓存复用加了一道配置签名。模型、工具集、临时提示、部分运行配置、用户标识和是否载入上下文文件等因素,会影响这个签名。可以稳定复用时保留对象;影响构造状态的条件变化时,则需要重建。工具名称先排序,所以只是列表顺序变化,不必制造一次无意义的重建。
最值得停下来看的,是用户标识为什么也参与签名。
前面说过,共享线程可以让 Alice 和 Bob 得到同一个路由键。但某些记忆集成会在初始化时绑定用户身份。如果直接沿用 Alice 创建的 Agent 对象,Bob 的新消息就可能被归到错误的记忆主体。代码因此把 user_id 和 user_id_alt 纳入配置签名,让同一共享会话在切换参与者时,也能够替换执行对象。
这是一个很好的设计取舍:保留共享讨论的连续性,同时牺牲一部分缓存热度,换取正确的身份归属。它没有要求“共享上下文”和“共享用户身份”必须一起发生。官方在 Agent 缓存签名 的注释中明确解释了这层原因。
配置签名只是判断对象是否适合复用的依据。它不承担用户认证,也不能代替工具权限校验。地址、身份和权限分别回答不同的问题,把它们区分开,后续接内部系统时才不会把路由隔离误当成安全边界。
第二条消息,偏偏在第一条还没准备好时到了
真实聊天不会等初始化结束再发送下一条消息。用户可能连发三句,也可能在 Agent 正查资料时按下停止。系统需要处理的,既有“谁创建会话”,也有“谁拥有这一轮执行”。
会话存储的 get_or_create_session 使用按键协调的 single-flight:同一个路由键出现重叠请求时,一个请求负责创建或获取,其余请求等待这一轮的结果或错误。它还把可能阻塞的恢复和存储工作,与短时间持有的内存锁分开。这样做的目的,是避免两个并发调用各自认领一段新会话,同时不把慢存储操作塞进最宽的临界区。
但拿到同一个会话,并不等于可以同时启动两个 Agent 轮次。Gateway 在正常消息通过接入处理后,会检查正在运行的会话,让忙碌时到来的消息进入对应的排队或中断策略;准备新轮次时,还会先认领活动槽位。
一个关键动作是先放入 _AGENT_PENDING_SENTINEL。此时真正的 Agent 可能还没初始化好,历史可能还没载入完成,但“这个会话已经有人接手”的事实先成立。否则,只要初始化中间发生一次等待,第二条消息就可能误以为这里仍然空闲。

single-flight 解决的是重叠的会话获取;活动槽位与占位符解决的是一轮任务的接管。它们看起来都在“防重复”,但保护的对象并不一样。把两者合并成一句“系统加了锁”,就看不出为什么仅锁住数据库仍然不够。入口流程见 消息接入实现。
消息终于交到 Agent 手里
Gateway 需要持续接收平台事件,而模型调用和工具执行可能耗时很长。Hermes 把同步执行工作安排到 executor 中,外侧继续使用异步调度。这首先是运行安排:让一次耗时任务不直接堵住整个消息接待循环。线程本身并不提供工具执行的安全沙箱。
进入执行核心后,run_conversation 围绕准备请求、调用模型、整理响应、执行工具轮次或处理文本结果展开。循环检查迭代预算,也留有特定的预算宽限路径;不能把它理解为毫无条件地一直调用模型,直到模型愿意停下。
执行期间的文本增量、工具进度和澄清需求,通过回调与 Gateway 衔接。最后如何发送、是否已经流式送达、平台消息如何呈现,继续由交付侧处理。相关轮次还带有执行代次,用来判断晚到的工作是否仍属于当前轮次。旧任务的结果晚到,不代表它就应该接管新任务的界面。
这种组织方式让 Agent 循环保持自己的问题意识:下一步请求什么、工具结果怎样进入对话、什么时候结束。而消息系统保持另一套问题意识:这个结果属于谁、应该发到哪、是否已经发过。可以分别阅读 轮次执行 与 对话循环。
真正值得带走的是这几条边界
为了避免只凭流程图理解,我从固定源码中提取了路由函数及其必要辅助函数、配置签名函数,执行了 22 项行为检查。检查覆盖默认群聊与共享线程、按人隔离、跨平台及 Slack 工作空间、预建线程连续性,以及同一路由下用户变化导致签名变化等情况。
这些检查使用最小来源对象和平台枚举,没有启动真实 Gateway,也没有连接聊天平台、模型或记忆服务;会话存储的并发机制和 WhatsApp 标识规范化没有在这组测试中运行。它们验证的是本文几个关键算法分支,不能当成完整系统的端到端验证。
回到开头那句“沿着刚才那个方向继续”。Hermes 能复用一套 Agent 核心,但跨入口续聊需要明确的会话关联,不能仅凭名字相同就自动成立。真正需要设计的是:哪类消息应该共享历史,哪类参与者需要独立归属,哪种变化可以只换执行对象而保留记录。
等这条消息被送进正确的会话,下一个问题才值得问:对话越来越长,Agent 到底应该记住什么,又该怎样把记忆带进下一轮?

