乐于分享
好东西不私藏

当 AI Agent Hermes 开始"串台":一次飞书会话隔离的调试故事

当 AI Agent Hermes 开始"串台":一次飞书会话隔离的调试故事

一、串台

昨天,我在飞书话题群里同时开了两个话题,想让 Hermes 并行处理两个不同的任务。

话题 A 里,我刚发了一条消息,Hermes 的回复却出现在了话题 B 里。更离谱的是,有时候 Hermes 干脆不回复在任何话题里,而是直接在群聊主页创建了一条全新的消息——像是凭空多出了第三个话题。

你知道,这不是"回答错误",这是消息路由串台

让 Hermes 看本地日志,很清楚:

# 来自话题 A 的消息msg_id=om_x100b5034556d38a0b4a7cde08455ae7  source.thread_id=omt_1a958951820fdc85# 回复却发到了话题 Bsend() called: metadata.thread_id=omt_1a8ae460588fdb8e

还有一种更隐蔽的串台——reply_to丢失,回复走了 CREATE 路径而不是 REPLY 路径:

# 正确路径:reply_to 有值,消息回到已有话题REPLY path: reply_to=om_x100b503457c9d0a0b22ff0de3913d08 reply_in_thread=True# 错误路径:reply_to=None,创建了新话题CREATE path: chat_id=oc_05b610da7d208a8ff49993284cf5259d thread_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_typeprivate(映射为dm),但实际上它有chat_mode=topic。由于没有读取chat_mode,函数无法区分真正的私聊和话题群,导致:

  • 话题群的 session 类型被标记为group而非forum

  • build_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_modetopicthread时,返回forum而非group

同时,对真正的 DM(chat_mode不是topic)过滤掉thread_id,避免私聊消息携带无意义的话题 ID。

改动不大,思路清晰。提交了 commitb274a7363,重启 gateway 生效。

五、验证

修复后,让 Hermes 检查了 session 数据:

  • 新建的 topic 会话类型正确标记为forum

  • 每个话题有独立的 session key,包含thread_id

  • 回复消息的metadata.thread_idsource.thread_id一致

  • CREATE path 走了 47 次(都是有thread_id的正常新消息),REPLY path 走了 1569 次

六、记忆隔离 vs 记忆共享

修复解决了消息路由和会话隔离两个问题。但这也引出了一个更深层的设计问题:AI Agent 的记忆应该怎么管理?

完全隔离意味着每个对话都是独立的,不会串台,但 Agent 也无法从一个对话中学到的经验中受益。完全共享则会导致串台问题。

Hermes 的方案是分层记忆

层级

是否隔离

说明

会话上下文

隔离

每个话题有独立的 session,互不干扰

长期记忆

共享

memory 工具写入的全局知识,所有会话可读

技能库

共享

skill 工具管理的可复用流程,跨会话复用

这就像人类的工作记忆(短期)和语义记忆(长期)各司其职——你不会把昨天午饭吃了什么记一辈子,但你会记住怎么骑自行车。

七、给 Agent 开发者的启示

1. 不要信任 API 的表面语义。飞书话题群的chat_type是一个典型的反直觉设计。在做消息路由时,永远要深入检查所有相关字段(chat_typechat_modethread_id),不要只看一个维度。

2. 消息路由和会话隔离是两个独立的问题。即使 session key 正确包含了thread_id,如果reply_to没有正确传递,消息仍然会跑到错误的地方。两个层面都需要验证。

3. 用日志验证你的假设。本地的gateway.error.log记录了每条消息的source.thread_idmetadata.thread_idreply_to和路由路径(REPLY vs CREATE)。通过对比这些字段,可以快速定位串台的具体原因。

4. AI Agent 的记忆是一个架构问题,不是一个功能问题。不要等到 Agent "失忆"了才去想记忆管理。从第一天起,就设计好什么该持久化、什么该隔离、什么该共享。