Hermes Session 管理:飞书/微信/CLI 统一会话模型

背景

Hermes 把 CLI、TUI、桌面 app、几十种 messaging 平台(飞书/微信/Telegram/Discord/Slack…)接到同一份"agent core"上。理解它的 session 模型,是理解为什么"我换个入口跟它说话"和"我在同个地方继续说"行为完全不同的关键。

本文整理自一次源码阅读(~/.hermes/hermes-agent/ 仓库,commit 详见文末版本),结论都标注了文件路径 + 行号,便于回查。

核心结论

Hermes 的 session 体系是三层叠加,每一层解决一类问题:

  1. 路由层:决定"这条消息属于哪个 session"
  2. 存储层:决定"这个 session 持久化到哪、怎么 append"
  3. 并发层:决定"同一 session 不能并发写"

三层各管一摊,只允许 prompt cache 失效的唯一场景是显式 reset。这条原则写在 AGENTS.md 顶部,Hermes 一切设计都向它对齐。

1. 路由层:session key 怎么算

gateway/session.py::build_session_key()(1090–1230 行),注释自称为 “single source of truth for session key construction”

关键规则

场景key 组成
DM(私聊)agent:main:<platform>:dm[:<scope_id>]:<chat_id>[:<thread_id>]
DM 没 chat_id退化到 <platform>:dm:<user_id>[:<thread_id>](防止跨用户串线,session.py:1151–1154)
群 / 频道agent:main:<platform>:<chat_type>:<slack_scope_id>:<chat_id>[:<thread_id>]
群默认 thread 共享同一个 thread 内所有用户共享 session(论坛/话题的预期 UX)
群按用户隔离group_sessions_per_user=True 时 key 末尾追加 user_id

Slack 例外

Slack 额外带 scope_id(workspace 标识)防止不同 workspace 同名 channel 撞 key。其他平台不引入这种 namespace。

飞书 / 微信具体怎么填字段

微信gateway/platforms/weixin.py):

  • 走 iLink long-polling(EP_GET_UPDATES),不是 webhook(1371 行 _poll_loop
  • _guess_chat_type()(391 行)用 room_id 判群/单聊
  • 群默认 group_sessions_per_user=True,每个发件人独立 session
  • 1577 行 _text_batch_key:用 build_session_key 把同一 session 的连发文本合并成一条,避免 cache 反复 invalidate

飞书(适配器被打包/合并到 helpers.py,没有独立 platforms/feishu.py):

  • 共用基类 platforms/base.py:6146build_session_key 入口
  • 群/单聊判定靠官方 message_type + chat_typep2p / group
  • 平台 enum:gateway/config.py:349 Platform.FEISHU = "feishu"

/resume 跨 routing key 复用 session

switch_session() 把一个现有 session_id 绑到新的 routing key,turn_lease 同步 rebind(turn_lease.py:266 _rebind())。但只支持 in-process,跨进程要走 hermes --session=<id> --continue 串行模式(见下文"已知边界")。

2. 存储层:SessionStore 怎么持久化

gateway/session.py 是一个 4233 行的 SessionStore,底层 SQLite + FTS5。

入口合同

1
2
3
4
5
def get_or_create_session(
    source: SessionSource,
    force_new: bool = False,
    touch_activity: bool = True,
) -> SessionEntry
  • 默认 force_new=False → 能复用就复用,绝不主动新建
  • force_new=True 的唯一调用点:slash_commands.py:292/new / /reset

一次访问的"single-flight"路径

session.py:2598 起:

  1. 拿 inflight lock,按 session_key 检查是否有同 key 的进行中查询
  2. 有就 event.wait() 共享 owner 的结果(同一 routing key 的并发消息只产生一次 session 落库)
  3. 没有就调用 _get_or_create_session_impl() 真正读 / 写 store
  4. 不管成败都 event.set() 唤醒所有 follower

Slack 跨 workspace 兼容迁移

session.py:2682–2712 有一段 Slack 老数据迁移:

  • 老 key 不带 scope_id,新 key 带
  • 迁移策略(组合自 #20583/#66398/#68925):
    • 老 entry 记录的 workspace 明确 → 迁到匹配的新 key
    • 老 entry scope-less 且 DM → 第一个 workspace 一次性认领(1:1 DM 一个人)
    • 老 entry scope-less 且 channel/group → 拒绝迁移(跨 workspace 撞 key 会导致 transcript 串到错租户,正是这次修的 bug)

持久化跨重启

SQLite 落盘 + FTS5,gateway 重启历史不丢。

3. 并发层:turn_lease 怎么避免交错写

存在理由写在 gateway/turn_lease.py 顶部 1–49 行:

switch_session()/resume、CLI continuity rebind、async delegation 完成后 pin、telegram topic tip-walk——这些操作都让一个 session_id 被多个 routing key 指向。旧的 busy guard 按 routing key 加锁(adapter 的 _active_sessions、runner 的 _running_agents),但持久化 transcript 按 session_id 持有。当一个 session_id 被两条 routing key 同时指到,两个 turn 各自拿自己 routing key 的锁,谁都看不到对方——结果在 transcript 上交错写

怎么关

run.py:19396 附近:

1
2
3
4
5
6
_lease_token = await _lease_registry.acquire(
    session_entry.session_id,   # ← 锁的是解析后的 session_id
    owner_key=...
    generation=run_generation,
    timeout=_float_env("HERMES_TURN_LEASE_TIMEOUT", DEFAULT_LEASE_WAIT),
)

三个安全性质

性质实现
Generation-scoped, identity-checked releasetoken 带 owner_key + run_generation,释放时只认当前持有者(#28686 教训:老 turn 的 unwind 不能释放新 turn 的 lease)
Fail-closed on timeout超时抛 TurnLeaseTimeoutError → 必须拒掉消息并提示"请重发",绝不并发跑
Bounded registry字典上限 DEFAULT_MAX_LEASES=512;只能驱逐 idle entry,正在持有的永远不被驱逐

已知边界(注释自己承认的)

  1. CLI 进程是另一个 process,不在 in-process lock 里——CLI continuity 跨进程那一对要靠 DB-level lease 解决(#64934 还在跟)
  2. Mid-turn compression rotation 留了一个小 alias 窗口:tip-walk 切到新 child id 时 parent 的 lease 还在跑——后续要在 binding-sync 站点把 lease 别名过去

拿到 lease 后到写 transcript 之间的安全区

run.py:19419–19422

1
2
await self._mark_durable_active_turn(event, session_entry.session_key)
history = await self.async_session_store.load_transcript(session_entry.session_id)

注释:"A turn only becomes durable recovery work after it owns the lease"——排队等 lease 的消息不算 “durable active”。如果 gateway 在等 lease 的瞬间被 kill,恢复时不会把这部分"还没真开始的 turn"当作要恢复的工作。

4. 自动压缩:session hygiene

run.py:19439–19460 起的 long session 自动压缩

阈值为什么
触发条数history and len(history) >= 4至少 4 条才考虑
触发比例0.85(不是 0.5)比 agent 自带 compressor 阈值(0.5)得多;hygiene 是兜底,agent 自带 compressor 在 tool loop 里用真实 token 计数更准
硬上限_hyg_hard_msg_limit=5000触发即压
总耗时上限_hyg_total_ceiling_seconds=600.0整个压缩总耗时不能超 10 分钟
失败冷却_hyg_failure_cooldown_seconds=300.05 分钟内不再尝试,防止反复重试拖死 turn
压缩模型anthropic/claude-sonnet-4.6 写死hygiene 必须用稳定可复现的便宜模型压,不允许跟 user 当前模型走,否则 cache 又破

5. 什么时候会"开新 session"

主动:/new / /reset

只支持这两条命令,config.py:941reset_triggers = ["/new", "/reset"] 可配。

slash_commands.py:144 _handle_reset_command() 实际做了 7 件事:

  1. _invalidate_session_run_generation(session_key, reason="session_reset")——让正在跑的 turn 后续解锁时不再有效
  2. _release_running_agent_state(session_key)——清掉 _running_agents 槽位
  3. 关掉老 agent 的资源(terminal sandbox、browser daemon、后台子进程)——走 worker 线程 + asyncio.wait_for(timeout=_RESET_CLEANUP_TIMEOUT_S)不能阻塞 event loop(#35994)
  4. 清所有 conversation-scoped 状态:model/reasoning 临时覆盖、one-turn restore、model note、cache、queue overflow
  5. 中断这个 session 在飞的 async 子任务(#55578)——reset 后老 session 的子 agent 回填无主
  6. 下次消息来时 get_or_create_session(force_new=True) 拿到新 session
  7. 发 reset 通知给用户(notify=True 且平台不是 api_server / webhook

关键/new 不是"立即生成一个新 session",而是"拆掉当前的;下条消息来时再拿个新的"。老 transcript 仍然在 DB 里,只是 routing 不再指过去;这是 route 漂移,不是数据销毁。导出用 hermes_state_portability.py::export_session()

自动:session_reset 策略

config.py:554 起的 SessionResetPolicy默认 mode="none"(2026-07 后改的;之前是 “both”——24h idle + 每天 4am 强清,抱怨"对话怎么没了"后改的):

mode触发条件默认推荐
"daily"每天本地时区 at_hour(默认 4 点)不推荐给个人
"idle"距上次活动 idle_minutes(默认 1440 = 24h)企业 / SRE 场景
"both"上面两个哪个先到极端保守
"none"永不自动 reset当前默认

完整配置:

1
2
3
4
5
6
7
session_reset:
  mode: "idle"                       # 或 daily / both / none
  idle_minutes: 1440
  at_hour: 4
  notify: true                       # 触发时给你发一条通知
  notify_exclude_platforms: ["api_server", "webhook"]
  bg_process_max_age_hours: 24       # 后台子进程超过这个年龄不再 pin 住 session

bg_process_max_age_hours 防 #29177:开了个长期 devbox,session 一直显示"有进程在跑"而不让 reset。

gateway/platforms/weixin.py

  • 端点 EP_GET_UPDATES = "ilink/bot/getupdates"(97 行)
  • _poll_loop()(1371 行):hold 连接最多 LONG_POLL_TIMEOUT_MS 毫秒,server 端有消息立刻 push 回来;response 的 suggested_timeout_ms 还能动态调整
  • 拿到 msgs[]每条独立 asyncio.create_task(self._process_message_safe(message))(1421 行)——同批次 N 条消息并行进 dispatcher,这正是 lease 存在的第一个原因
  • _process_message() 里三层去重:
    • message_id 去重(iLink 偶发重发,1476 行)
    • 内容 MD5 二次去重(整条重投兜底,1482 行)
    • 群/单聊 + 黑白名单(1488–1497 行)
  • 文本消息先入 _enqueue_text_event() 走 batch 合并(1577–1610 行)——避免 cache 反复 invalidate

8. 用户行为 → 内部机制对照表

现象原因
同一 DM 反复聊,AI 记得之前的话同一 key → 同一 session_id → 同一 transcript row
群里两个人 @ AI,AI 不会把 A 的话当成 B 说的群模式默认 group_sessions_per_user=True,key 里带 user_id
微信连发 3 条短消息,AI 看到 1 条合并消息微信 adapter 的 _enqueue_text_event + _text_batch_key
飞书/微信同机器上同时用 = 各是各的 sessionPlatform.WEIXIN="weixin" vs Platform.FEISHU="feishu"
/approve /deny /stop 不会被合并到对话里should_bypass_active_session() 在 base.py:6189 直派
偶尔 agent 让你"请重发"_lease_registry.acquire(timeout=...)TurnLeaseTimeoutError,dispatch 返回 “bounded rejection/resend notice”
长聊了几个月后某天突然自己"瘦身"session hygiene 在 turn 入口按 0.85 阈值自动压

9. 跨入口混用:飞书 ↔ 终端 CLI 的实际行为

Hermes 自己承认(turn_lease.py 顶部注释)CLI 进程不在 in-process lock 里,跨进程 session 复用是 known limit

三种情况

场景行为安全吗
同一个 hermes-agent 进程(gateway 模式 + 终端 attach)platform 维度不同,key 不同 → 不会合流✅ 安全
独立 CLI 进程(hermes / pi / codex),跨进程共享 session_id没有跨进程锁;两进程各自写,顺序按 flush 时间而非打字时间⚠️ 串扰
/resume 跨 routing key 复用 sessionin-process 安全,lease 同步 rebind✅ 但只限 in-process

跨进程会出现的具体问题

  1. Prompt cache 反复失效——下次问"刚才我们说到哪了"答得很糟
  2. identity-marker 错位——repair_message_sequence 触发,悄悄删/合并行,飞书里可能看到自己"说过"但其实没在飞书说的话
  3. async delegation 跨进程悬挂(#55578 姊妹问题)——subagent 跑完找不到 owner,token 烧了结果不归你
  4. 持久 ledger 的 session_key 漂移——shutdown_flush.py:359 那个 session_key 字段记录的是 routing key 不是 session_id

推荐用法

  • 把终端和飞书当成"两种入口"而不是"同时入口"
  • 想要"在 A 上面接到 B 继续":主动 /new/resume 显式断/resume 同进程安全;跨进程要走 hermes --session=<id> --continue 这种单进程串行模式
  • 真要两边都用:当前 Hermes 没给你一个干净的并发方案

10. 关键源码定位

主题文件:行
session key 构造gateway/session.py:1090
DM 跨用户隔离规则gateway/session.py:1151–1154
Slack scope 迁移gateway/session.py:2682–2712
SessionStore get_or_creategateway/session.py:2598
单飞行复用gateway/session.py:2619–2651
Platform enumgateway/config.py:325
微信 iLink long-pollgateway/platforms/weixin.py:1371 _poll_loop
微信文本 batchgateway/platforms/weixin.py:1577 _text_batch_key
微信群/单聊判定gateway/platforms/weixin.py:391 _guess_chat_type
通用 handle_messagegateway/platforms/base.py:6146
turn_lease 实现gateway/turn_lease.py
lease acquiregateway/run.py:19396
durable_active_turn 标记gateway/run.py:19419
session hygienegateway/run.py:19439–19460
/new / /reset 处理gateway/slash_commands.py:144 _handle_reset_command
reset_triggers 配置gateway/config.py:941
自动 reset 策略gateway/config.py:554 SessionResetPolicy
平台 prompt 块(feishu/weixin)agent/prompt_builder.py:1093, 1101

11. 还没挖但值得看的几个点

  • _quick_keysession_key 区别——为什么 routing 用 _quick_key、锁 session 用 session_id
  • repair_message_sequence 什么时候触发、修了什么
  • hermes --session=<id> --continue CLI 串行模式具体怎么用
  • prompt cache 怎么跨 turn 保持稳定(prompt_builder.py 跟 system prompt 的字节稳定性)
  • 微信多账号 iLink 在 key 算法下是否会跨账号串 session(当前算法不会,因为 account_id 不参与 key——这是隐患)

12. 我自己用这套的实践建议

  1. 默认 mode: "none"——别开自动 reset,长 session 是价值
  2. 飞书/微信之外,CLI 同一时刻只在一个进程跑——避免跨进程 session 漂移
  3. 想"换入口继续",先 /new 再切——把责任显式化,不要靠"它会自动理解"
  4. 长 session(>1000 条)观察 hygiene 是否会触发——超过 0.85 阈值时它会自动压,但阈值是 len(history) >= 4 才考虑,小 session 别担心
  5. 修改 routing key 维度配置group_sessions_per_userthread_sessions_per_user)前先 export_session 老数据——改完之后 key 变了,老 session 就够不着了
使用 Hugo 构建
主题 StackJimmy 设计