数据截至 (上游 commit 4ac938ddecce)
到处都能找到它 —— 网关、会话存储与定时任务
30 秒导读: 前四章讲的是"一轮对话怎么跑完"。这一章讲的是这一轮对话是从哪儿来的—— 可能是你在终端敲的,可能是 Telegram 群里有人 @ 了它,也可能是凌晨三点没有人在, 是一个定时器把它叫醒的。Hermes 的做法是:入口层做得很厚,agent 层一点都不知道。
1. 这是什么(零基础也能懂)
一句话定义: 这是 Hermes 的入口层——把"终端命令行""二十多个聊天平台的机器人""定时任务"
这三种完全不同的触发方式,统一收敛成同一件事:构造一个 AIAgent,喂给它一段消息,把结果送回去。
它要解决的问题。 假设你已经写好了一个能干活的 agent(前四章的内容)。现在你想要:
- 在自己电脑的终端里用它;
- 在手机上用 Telegram/Signal/微信 跟它说话;
- 团队在 Slack 频道里 @ 它;
- 每天早上八点它自动生成一份简报发到你的 iMessage。
朴素做法是给每个场景写一个程序。Hermes 的做法是只有一份 agent 代码,四类入口各自把 "消息从哪来、回哪去、这段对话算谁的"这些脏活干完,再调同一个类。
入口一览:
| 入口 | 命令 / 触发方式 | 代码位置 |
|---|---|---|
| 终端 CLI | hermes / hermes chat | hermes_cli/main.py、cli.py |
| 消息网关(20+ 平台) | hermes gateway start | gateway/run.py |
| 定时任务 | 无人触发,60 秒轮询 | cron/scheduler.py |
| TUI / 编辑器 | hermes tui、hermes acp | tui_gateway/、acp_adapter/ |
四条路最后都汇到同一个类 AIAgent(run_agent.py:412)。可以自己 grep 验证:
gateway/run.py:22506、cron/scheduler.py:6010、acp_adapter/session.py:687、
tui_gateway/server.py:7177、hermes_cli/oneshot.py:476 —— 五个调用点,同一个类。
一句话直觉: 把 agent 当成一个只会读写字符串的函数;这一章讲的全部东西, 都是这个函数外面那一圈插座——插座形状各异,函数本身一动不动。
2. 顶层全景(它大概怎么转)
先看这张图。怎么读:从上往下是"消息进来"的方向,从下往上是"回复出去"的方向; 中间那条粗线是所有入口的汇合点。
终端 Telegram/Discord/Slack/… TUI / 编辑器 闹钟
│ │ │ │ │ │
│ ┌──┴──────┴──────┴──┐ │ ┌─────┴─────┐
│ │ 平台适配器 │ │ │ 60s tick │
│ │ BasePlatformAdapter│ │ │ 文件锁去重 │
│ └────────┬──────────┘ │ └─────┬─────┘
│ │ MessageEvent │ │
│ ┌────────┴──────────┐ │ │
│ │ GatewayRunner │ │ │
│ │ 鉴权 / 命令 / 打断 │ │ │
│ └────────┬──────────┘ │ │
│ │ │ │
│ ┌────────┴──────────┐ │ │
│ │ 会话身份:会话键 │ │ │
│ │ 谁的对话?哪一段? │ │ │
│ └────────┬──────────┘ │ │
│ │ │ │
═══╧═════════════════╧═══════════════════════╧══════════════════╧═══
同 一 个 AIAgent 实 例
══════════════════════════════╤════════════════════════════════════
│
┌──────────────┴──────────────┐
│ state.db + 影子 git 仓库 │
│ 转录/token 账 文件快照 │
└─────────────────────────────┘
部件职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
GatewayRunner | 网关主进程:启动适配器、路由消息、优雅退出 | gateway/run.py:6726 |
BasePlatformAdapter | 每个平台的收发抽象(四个抽象方法) | gateway/platforms/base.py:2890 |
PlatformRegistry | 适配器自注册表,取代 if/elif | gateway/platform_registry.py:232 |
build_session_key | 把消息来源算成一个会话身份字符串 | gateway/session.py:1090 |
SessionStore | 会话键 → 会话 ID 的路由索引 + 过期策略 | gateway/session.py:1245 |
SessionDB | SQLite 落地:会话、消息转录、全文检索(检索/建表逻辑拆到 hermes_state_search.py、hermes_state_common.py 等 mixin) | hermes_state.py:3258 |
CheckpointManager | 改文件前的自动快照(模型看不见) | tools/checkpoint_manager.py:755 |
tick() | 每分钟检查一次有没有到期的定时任务 | cron/scheduler.py:7199 |
主线走一遍(高层): 平台适配器收到一条原始消息 → 包装成 MessageEvent →
GatewayRunner._handle_message(gateway/run.py:16462)按七步走:鉴权 → 斜杠命 令 →
打断正在跑的 agent → 取/建会话 → 拼上下文 → 跑 agent → 回消息。
七步的顺序写在那个 docstring 里,读源码时对着看很省事。
3. 消息网关 —— 一个进程,二十几个平台
3.1 它要解决的小问题
每个聊天平台的 SDK 都长得不一样:Telegram 是长轮询、Discord 是 WebSocket、 企业微信是回调 HTTP、Signal 要挂一个本地 HTTP 桥。Hermes 要在一个进程里同时连上这些, 还要在其中一个挂掉时不影响其他的。
3.2 适配器:四个抽象方法,其余全给默认
平台差异被压到一个抽象基类里。打了 @abstractmethod 的只有四个:
| 抽象方法 | 干什么 | 行号 |
|---|---|---|
connect(is_reconnect=False) | 连上平台、起监听,返回是否成功 | gateway/platforms/base.py:3959 |
disconnect() | 停监听、关连接、取消任务 | gateway/platforms/base.py:3979 |
send(chat_id, text, …) | 发一条文本,返回 SendResult | gateway/platforms/base.py:3984 |
get_chat_info(chat_id) | 返回至少含 name / type 的聊天信息 | gateway/platforms/base.py:7210 |
剩下的方法(send_image / send_voice / send_clarify / send_exec_approval / 打字气泡 /
草稿流式)在基类里都有默认实现或降级路径,平台能力强就重写,不能就自动退回纯文本。
这就是为什么一个 IRC 适配器只用标准库、971 行就能跑完整功能
(plugins/platforms/irc/adapter.py)。
一个体现"降级优先"的细节:send_clarify 在支持按钮的平台渲染成可点选项,
不支持的平台就退化成一段普通文字问句——ADDING_A_PLATFORM.md 里写明"They all degrade
gracefully to plain text when not overridden"。
3.3 注册表:用自注册干掉 if/elif
早期版本用一条 if/elif 链把平台名映射到适配器类。现在改成注册表:
# 示意,非源码 —— 插件侧只做这一件事
ctx.register_platform(
name="irc",
label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg), # 怎么造
check_fn=check_requirements, # 依赖齐了吗
cron_deliver_env_var="IRC_HOME_CHANNEL", # 定时任务能投这儿
standalone_sender_fn=_standalone_send, # 网关不在时也能发
max_message_length=450, # 分片长度
platform_hint="You are chatting via IRC. …", # 塞进系统提示的一句话
)
真实的是 PlatformEntry(gateway/platform_registry.py:63)——一个字段很宽的 dataclass
(数 class PlatformEntry 体里带类型注解的字段,是 22 个)。
关键在于它不只是"怎么造适配器",而是把一个平台在整个系统里的所有接入点都收进一条记录:
鉴权环境变量名(allowed_users_env)、是否可以脱敏(pii_safe)、YAML 配置翻译钩子
(apply_yaml_config_fn)、定时投递目标(cron_deliver_env_var)、脱离网关进程的独立发送
(standalone_sender_fn)。插件作者填表,核心代码不动一行。
PlatformRegistry.create_adapter(gateway/platform_registry.py:618)里的顺序是:
check_fn() 不过 → 返回 None 并打提示;validate_config() 不过 → 返回 None;工厂抛异常 →
记 error 返回 None。任何一步失败都只是这个平台不上线,不影响别的平台。
注册表优先于内置。 GatewayRunner._create_adapter(gateway/run.py:15785)先查注册表,
查到就用;查不到才落到内置的 if/elif(gateway/run.py:15832 起)。而且注册表里若"登记了但造不出来",
它会明确 return None 而不是往下穿——因为插件平台在内置链里本来就没有对应分支。
内置 9 个,插件 20 个。 内置的是 whatsapp_cloud / signal / weixin / api_server /
webhook / msgraph_webhook / bluebubbles / qqbot / yuanbao(gateway/run.py:15832-15914 的分支链)。
Telegram、Discord、Slack 这些"旗舰"平台反而住在 plugins/platforms/ 里,
和社区插件同一套机制——grep -rl "register_platform" plugins/platforms/*/*.py 数出来正好 20 个目录。
3.4 延迟加载:为什么 hermes chat 不会变慢
二十个平台插件在模块顶层各自 import 自己的重型 SDK(lark_oapi、discord.py、slack_bolt…)。
如果启动时全都加载,连一句 hermes chat 都要多等几秒——而 chat 根本不碰网关。
解法是两级注册:发现阶段只登记一个零参数的加载器
(PlatformRegistry.register_deferred,gateway/platform_registry.py:296),
真正 import 推迟到有人第一次查这个平台名时(_resolve,:393)。
is_registered() 特意把"尚未 import 的延迟项"也算作已注册(:601),
这样"这个平台存不存在"这种廉价判断不会触发重型 import。
3.5 生命周期:启动与优雅退出
启动侧(GatewayRunner.start,gateway/run.py:12390)是一个平台一个平台串起来的循环
(:6175):造适配器 → 挂 五个回调(消息处理器、致命错误处理器、会话存储、忙时处理器、
话题恢复函数,:6196-6200)→ 带超时地连接(_connect_adapter_with_timeout,:3181)。
连接失败时有个容易被忽略的细节:它会主动调一次 disconnect()(:6234),
因为失败的 connect() 可能已经开了 aiohttp session 或起了轮询任务,不收就会泄漏。
退出侧(stop,gateway/run.py:14511)比启动复杂得多,顺序是刻意排的:
收到停止信号
│
▼
① 先给还在跑 agent 的聊天发一句"我要重启了" ← 适配器此时还连着
│
▼
② 给每个在跑的会话打上 resume_pending durable 标记 ← 在等待之前打
│
▼
③ 等待 drain_timeout,让在跑的 turn 自己跑完
│
┌────┴────┐
│ │
跑完了 超时了
│ │
│ ▼ 强行打断 + 杀工具子进程 + 拆终端环境 + 关浏览器
│ │
▼ ▼
清掉标记 保留标记(下次启动据此恢复)
│
▼
④ 断开适配器 → 落 gateway_state → 视情况写 .clean_shutdown 标记
第 ② 步的注释把理由说得很清楚(gateway/run.py:14679-14682):标记必须写在等待之前,
因为如果 systemd 在 drain 期间直接把进程杀了,写在后面就永远写不上了。
只有 drain 干净地跑完,才会写 .clean_shutdown 标记(:7448);超时的路径明确跳过(:7453)。
还有一个运维向的检查很有意思:启动时会读 systemd unit 的 TimeoutStopSec,
如果它比配置的 drain 超时还短,就打一条 WARNING——因为这种配置下 systemd 会在排空中途
发 SIGKILL,在日志里看起来像"幽灵杀进程"(gateway/run.py:12453-12466,
check_systemd_timing_alignment)。
3.6 出站:发出去之前还要过几道
回复不是算完就直接发。出站侧有几个独立的小模块:
- 静默过滤 —— 模型可以整条回复只输出
NO_REPLY/[SILENT]表示"这轮我不说话"。is_intentional_silence_response(gateway/response_filters.py:56)只在整条回复恰好是 标记词时才算静默:超过 64 字符不算、正文里顺口提到NO_REPLY不算、空回复也不算 (空回复走失败路径,不是静默)。 - 运行时页脚 ——
build_footer_line(gateway/runtime_footer.py:151)在回复末尾附一行模型 · 上下文占比 · 工作目录。任何一项数据缺失就静默跳过那一项, 宁可少一格也不显示?%。 - 投递路由 ——
DeliveryTarget.parse(gateway/delivery.py:231)把"origin"/"local"/"telegram"/"telegram:123456:789"这几种写法解析成结构化目标。 注意它对平台名.lower()、对 chat_id 保留原始大小写(:149),因为很多平台的 ID 大小写敏感。 - 流式回传 ——
GatewayStreamConsumer(gateway/stream_consumer.py:164)把 agent 在工作线程里 同步吐出的 delta,经queue.Queue转到异步任务,再按节流间隔反复编辑同一条消息。 选"编辑"而非"追加"是因为 Telegram / Discord / Slack 都支持编辑,是最大公约数。 - 事件分发 ——
GatewayEventDispatcher(gateway/stream_dispatch.py:40)是更新的一层: agent 发结构化事件,由适配器决定怎么渲染;适配器的format_tool_event返回None就等于"这个平台吃掉这个事件"。它的dispatch()(:88)整个包在 try 里—— "presentation must never break the agent loop"。
3.7 中继:让别人替你连平台
gateway/relay/ 是一个实验性方向:适配器本身不知道自己在服务哪个平台。
RelayAdapter(gateway/relay/adapter.py:65)在握手时从连接器收到一份 CapabilityDescriptor
(gateway/relay/descriptor.py:42),
从里面读出"我该报多长的消息长度上限""长度按字符数还是 UTF-16 码元算"
(_LEN_FNS,gateway/relay/adapter.py:59),然后把收发全部委托给注入的 transport。
按模块头的说法,网关侧没有任何按平台分支的代码——"这个 chat_id 是个 Discord 频道"
只有连接器一侧知道。
这带来一个信任问题,下一节接着说。
3.8 钩子
HookRegistry(gateway/hooks.py:54)扫 ~/.hermes/hooks/ 下的目录,
每个目录要有 HOOK.yaml + handler.py,在 gateway:startup、session:start、
agent:start / agent:step / agent:end、command:* 这些点上触发。
gateway/builtin_hooks/ 目前是空的——_register_builtin_hooks(:72)明说保留为扩展点。
钩子里的异常一律吞掉,永不阻断主流程。
4. 会话身份 —— 同一个 bot,凭什么记得住谁是谁
4.1 它要解决的小问题
一个 bot 同时在:你的私聊、一个 50 人的群、群里的三个话题串、另一个平台的另一个群。 哪些消息该共享一段记忆,哪些必须彻底隔开? 答错的后果不是体验差,是串味—— A 的私聊内容出现在 B 的回复里。