背景
Hermes 把 CLI、TUI、桌面 app、几十种 messaging 平台(飞书/微信/Telegram/Discord/Slack…)接到同一份"agent core"上。理解它的 session 模型,是理解为什么"我换个入口跟它说话"和"我在同个地方继续说"行为完全不同的关键。
本文整理自一次源码阅读(~/.hermes/hermes-agent/ 仓库,commit 详见文末版本),结论都标注了文件路径 + 行号,便于回查。
核心结论
Hermes 的 session 体系是三层叠加,每一层解决一类问题:
- 路由层:决定"这条消息属于哪个 session"
- 存储层:决定"这个 session 持久化到哪、怎么 append"
- 并发层:决定"同一 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:6146的build_session_key入口 - 群/单聊判定靠官方
message_type+chat_type(p2p/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。
入口合同
| |
- 默认
force_new=False→ 能复用就复用,绝不主动新建 force_new=True的唯一调用点:slash_commands.py:292(/new//reset)
一次访问的"single-flight"路径
session.py:2598 起:
- 拿 inflight lock,按
session_key检查是否有同 key 的进行中查询 - 有就
event.wait()共享 owner 的结果(同一 routing key 的并发消息只产生一次 session 落库) - 没有就调用
_get_or_create_session_impl()真正读 / 写 store - 不管成败都
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 附近:
| |
三个安全性质
| 性质 | 实现 |
|---|---|
| Generation-scoped, identity-checked release | token 带 owner_key + run_generation,释放时只认当前持有者(#28686 教训:老 turn 的 unwind 不能释放新 turn 的 lease) |
| Fail-closed on timeout | 超时抛 TurnLeaseTimeoutError → 必须拒掉消息并提示"请重发",绝不并发跑 |
| Bounded registry | 字典上限 DEFAULT_MAX_LEASES=512;只能驱逐 idle entry,正在持有的永远不被驱逐 |
已知边界(注释自己承认的)
- CLI 进程是另一个 process,不在 in-process lock 里——CLI continuity 跨进程那一对要靠 DB-level lease 解决(#64934 还在跟)
- Mid-turn compression rotation 留了一个小 alias 窗口:tip-walk 切到新 child id 时 parent 的 lease 还在跑——后续要在 binding-sync 站点把 lease 别名过去
拿到 lease 后到写 transcript 之间的安全区
run.py:19419–19422:
| |
注释:"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.0 | 5 分钟内不再尝试,防止反复重试拖死 turn |
| 压缩模型 | anthropic/claude-sonnet-4.6 写死 | hygiene 必须用稳定可复现的便宜模型压,不允许跟 user 当前模型走,否则 cache 又破 |
5. 什么时候会"开新 session"
主动:/new / /reset
只支持这两条命令,config.py:941 的 reset_triggers = ["/new", "/reset"] 可配。
slash_commands.py:144 _handle_reset_command() 实际做了 7 件事:
_invalidate_session_run_generation(session_key, reason="session_reset")——让正在跑的 turn 后续解锁时不再有效_release_running_agent_state(session_key)——清掉_running_agents槽位- 关掉老 agent 的资源(terminal sandbox、browser daemon、后台子进程)——走 worker 线程 +
asyncio.wait_for(timeout=_RESET_CLEANUP_TIMEOUT_S),不能阻塞 event loop(#35994) - 清所有 conversation-scoped 状态:model/reasoning 临时覆盖、one-turn restore、model note、cache、queue overflow
- 中断这个 session 在飞的 async 子任务(#55578)——reset 后老 session 的子 agent 回填无主
- 下次消息来时
get_or_create_session(force_new=True)拿到新 session - 发 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 | 当前默认 |
完整配置:
| |
bg_process_max_age_hours 防 #29177:开了个长期 devbox,session 一直显示"有进程在跑"而不让 reset。
6. iLink long-polling 入口的特殊性(微信专属)
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
7. 一图:iLink webhook → transcript 完整链路
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 |
| 飞书/微信同机器上同时用 = 各是各的 session | Platform.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 复用 session | in-process 安全,lease 同步 rebind | ✅ 但只限 in-process |
跨进程会出现的具体问题
- Prompt cache 反复失效——下次问"刚才我们说到哪了"答得很糟
- identity-marker 错位——
repair_message_sequence触发,悄悄删/合并行,飞书里可能看到自己"说过"但其实没在飞书说的话 - async delegation 跨进程悬挂(#55578 姊妹问题)——subagent 跑完找不到 owner,token 烧了结果不归你
- 持久 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_create | gateway/session.py:2598 |
| 单飞行复用 | gateway/session.py:2619–2651 |
| Platform enum | gateway/config.py:325 |
| 微信 iLink long-poll | gateway/platforms/weixin.py:1371 _poll_loop |
| 微信文本 batch | gateway/platforms/weixin.py:1577 _text_batch_key |
| 微信群/单聊判定 | gateway/platforms/weixin.py:391 _guess_chat_type |
| 通用 handle_message | gateway/platforms/base.py:6146 |
| turn_lease 实现 | gateway/turn_lease.py |
| lease acquire | gateway/run.py:19396 |
| durable_active_turn 标记 | gateway/run.py:19419 |
| session hygiene | gateway/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_key跟session_key区别——为什么 routing 用_quick_key、锁 session 用session_idrepair_message_sequence什么时候触发、修了什么hermes --session=<id> --continueCLI 串行模式具体怎么用- prompt cache 怎么跨 turn 保持稳定(
prompt_builder.py跟 system prompt 的字节稳定性) - 微信多账号 iLink 在 key 算法下是否会跨账号串 session(当前算法不会,因为
account_id不参与 key——这是隐患)
12. 我自己用这套的实践建议
- 默认
mode: "none"——别开自动 reset,长 session 是价值 - 飞书/微信之外,CLI 同一时刻只在一个进程跑——避免跨进程 session 漂移
- 想"换入口继续",先
/new再切——把责任显式化,不要靠"它会自动理解" - 长 session(>1000 条)观察 hygiene 是否会触发——超过 0.85 阈值时它会自动压,但阈值是
len(history) >= 4才考虑,小 session 别担心 - 修改 routing key 维度配置(
group_sessions_per_user、thread_sessions_per_user)前先export_session老数据——改完之后 key 变了,老 session 就够不着了