一、串台
昨天,我在飞书话题群里同时开了两个话题,想让 Hermes 并行处理两个不同的任务。
话题 A 里,我刚发了一条消息,Hermes 的回复却出现在了话题 B 里。更离谱的是,有时候 Hermes 干脆不回复在任何话题里,而是直接在群聊主页创建了一条全新的消息——像是凭空多出了第三个话题。
你知道,这不是"回答错误",这是消息路由串台。
让 Hermes 看本地日志,很清楚:
# 来自话题 A 的消息msg_id=om_x100b5034556d38a0b4a7cde08455ae7source.thread_id=omt_1a958951820fdc85# 回复却发到了话题 Bsend() called:metadata.thread_id=omt_1a8ae460588fdb8e
还有一种更隐蔽的串台——reply_to丢失,回复走了 CREATE 路径而不是 REPLY 路径:
# 正确路径:reply_to 有值,消息回到已有话题REPLY path: reply_to=om_x100b503457c9d0a0b22ff0de3913d08reply_in_thread=True# 错误路径:reply_to=None,创建了新话题CREATE path: chat_id=oc_05b610da7d208a8ff49993284cf5259dthread_id=None
统计了一下,CREATE path走了 103 次,其中 56 次thread_id=None——意味着 56 条消息本该回复在已有话题里,却变成了群聊主页的新消息。
二、两个话题,一个脑子
场景很简单:我在话题群 Hermes 里开了两个话题,想同步解决两个问题。话题 A 讨论技术方案,话题 B 跑数据任务。
但 Hermes 只有一个"脑子"。
当我在话题 A 里追问一个细节时,Hermes 的注意力被拉走了——话题 B 里正在执行的任务被打断,进度丢失。更糟的是,Hermes 的回复经常跑错地方:话题 A 的回答出现在话题 B 里,话题 B 的中间状态消息变成了群聊主页的独立话题。
我不得不停下来,让 Hermes 先修这个 bug。
在技术上,这涉及两个层面的问题:
第一层:消息路由。Hermes 的stream_consumer.py有 5 个adapter.send()调用点缺少reply_to参数。当reply_to为 None 时,飞书 API 走的是 CREATE 路径(创建新消息),而不是 REPLY 路径(在已有话题里回复)。结果就是回复变成了群聊主页的新话题。
第二层:会话隔离。飞书话题群的chat_type返回的是private——在 Hermes 的代码里,这被映射成dm(私聊)。但话题群有chat_mode字段标明自己是topic模式。代码只看chat_type,不看chat_mode,导致所有话题群的 session 类型被错误标记为group而非forum。session key 的生成逻辑因此没有正确包含thread_id,多个话题共享了同一个会话上下文。
用一句话概括:飞书话题群的 API 接口伪装成了私聊,Hermes 的代码信以为真,结果消息路由和会话隔离双双失效。
三、根因:两个 bug,一个表象
串台其实是由两个独立的 bug 叠加造成的:
Bug 1:reply_to 丢失(消息路由层)
stream_consumer.py负责流式发送 Hermes 的回复。它有 5 个adapter.send()调用点,其中 3 个没有传递reply_to参数。当reply_to为 None 时,飞书 adapter 走 CREATE 路径,创建一条新的顶级消息,而不是在已有话题里回复。
同样,run.py里的_status_callback_sync(负责发送进度状态消息)也没有传递reply_to。
Bug 2:chat_mode 未识别(会话隔离层)
feishu.py的_resolve_source_chat_type()函数只看chat_type字段,不看chat_mode。飞书话题群的chat_type是private(映射为dm),但实际上它有chat_mode=topic。由于没有读取chat_mode,函数无法区分真正的私聊和话题群,导致:
话题群的 session 类型被标记为
group而非forumbuild_session_key()对group类型的处理逻辑与forum不同多个话题可能共享同一个 session 上下文
四、两刀修复
第一刀:修复 reply_to 传递。
在stream_consumer.py的 5 个adapter.send()调用点,补上reply_to=self.reply_to_message_id。同时修复run.py里的_status_callback_sync,让它也能传递reply_to。
这样所有回复都走 REPLY 路径,消息回到正确的话题。
第二刀:识别 chat_mode。
在feishu.py的_get_chat_info_include_fields()中增加chat_mode字段。修改_resolve_source_chat_type(),当chat_mode为topic或thread时,返回forum而非group。
同时,对真正的 DM(chat_mode不是topic)过滤掉thread_id,避免私聊消息携带无意义的话题 ID。
改动不大,思路清晰。提交了 commitb274a7363,重启 gateway 生效。
五、验证
修复后,让 Hermes 检查了 session 数据:
新建的 topic 会话类型正确标记为
forum每个话题有独立的 session key,包含
thread_id回复消息的
metadata.thread_id与source.thread_id一致CREATE path 走了 47 次(都是有
thread_id的正常新消息),REPLY path 走了 1569 次
六、记忆隔离 vs 记忆共享
修复解决了消息路由和会话隔离两个问题。但这也引出了一个更深层的设计问题:AI Agent 的记忆应该怎么管理?
完全隔离意味着每个对话都是独立的,不会串台,但 Agent 也无法从一个对话中学到的经验中受益。完全共享则会导致串台问题。
Hermes 的方案是分层记忆:
层级 | 是否隔离 | 说明 |
|---|---|---|
会话上下文 | 隔离 | 每个话题有独立的 session,互不干扰 |
长期记忆 | 共享 | memory 工具写入的全局知识,所有会话可读 |
技能库 | 共享 | skill 工具管理的可复用流程,跨会话复用 |
这就像人类的工作记忆(短期)和语义记忆(长期)各司其职——你不会把昨天午饭吃了什么记一辈子,但你会记住怎么骑自行车。

七、给 Agent 开发者的启示
1. 不要信任 API 的表面语义。飞书话题群的chat_type是一个典型的反直觉设计。在做消息路由时,永远要深入检查所有相关字段(chat_type、chat_mode、thread_id),不要只看一个维度。
2. 消息路由和会话隔离是两个独立的问题。即使 session key 正确包含了thread_id,如果reply_to没有正确传递,消息仍然会跑到错误的地方。两个层面都需要验证。
3. 用日志验证你的假设。本地的gateway.error.log记录了每条消息的source.thread_id、metadata.thread_id、reply_to和路由路径(REPLY vs CREATE)。通过对比这些字段,可以快速定位串台的具体原因。
4. AI Agent 的记忆是一个架构问题,不是一个功能问题。不要等到 Agent "失忆"了才去想记忆管理。从第一天起,就设计好什么该持久化、什么该隔离、什么该共享。
夜雨聆风