本节摘要:本节进入 Hermes 的生态层——
gateway/目录约 11.4 万行、95 个文件,是全书最大的器官。核心命题是:一个 Python 单进程如何同时服务 34 个消息平台。答案分三层:其一,进程模型——GatewayRunner(gateway/run.py:6883,该文件约 3.2 万行)在单个 asyncio 事件循环里驱动所有平台适配器,长轮询、WebSocket、webhook 全部收敛为协程;其二,平台抽象——Platform枚举 +platform_registry注册表 +BasePlatformAdapter基类把 34 个平台抹平成统一的MessageEvent进、send()出;其三,消息流水线——入站消息经build_session_key映射到 agent 会话,回复经DeliveryRouter路由推送。网关是双循环的流量入口:更多会话意味着更多经验沉淀。
内容来源:原项目源码
gateway/run.py、gateway/config.py、gateway/platform_registry.py、gateway/platforms/base.py、gateway/session.py、gateway/delivery.py、gateway/channel_directory.py,以及website/docs/user-guide/messaging/index.md。
⚠️ 注意:
gateway/run.py约 3.2 万行、platforms/base.py约 7500 行,绝无逐行精读的可能。本节采取"骨架走读"策略:只读类定义、能力位与消息主路径,跳过错误恢复、优雅关停、watchdog 等运维支线(它们各有独立文件如restart.py、shutdown_watchdog.py)。
阅读完本节,你应当能够:
Platform 枚举如何用 _missing_ 钩子动态接纳插件平台。PlatformEntry 的「被动探测 / 主动安装」分离设计(#79812)。BasePlatformAdapter 的至少六个能力位及其用途。build_session_key 如何把一条消息映射到唯一 agent 会话。先看体量分布。gateway/ 顶层 60 余个 Python 模块按职责分家:run.py(网关主循环)、session.py(会话存储)、delivery.py(出站路由)、channel_directory.py(可达频道目录)、platform_registry.py(平台注册表)、platforms/(内置平台适配器)、relay/(通用中继协议),外加 restart.py、shutdown_flush.py、memory_monitor.py 等运维件。
「单进程」的含义是:没有每平台一个微服务。hermes gateway run 启动后,start_gateway(run.py:30816)构建 GatewayRunner 并进入 asyncio 主循环。Telegram 的长轮询、Discord 的 WebSocket 心跳、飞书的 WebSocket 事件、钉钉的 Stream 连接、webhook 的 HTTP 服务——每个平台适配器都是一个 connect() 返回协程的普通对象,挂在同一个事件循环上并发推进。I/O 等待期间事件循环去跑别的平台,这正是 asyncio 的教科书用法;agent 的内循环(第 2 章)也在同一进程内被调用。
为什么敢这么做?因为瓶颈不在网关而在 LLM API——消息频率天然被模型响应速度限制,单机事件循环足以扛住数百并发会话。工程收益巨大:所有平台共享同一份会话存储(~/.hermes/sessions/)、同一套审批回调、同一个 DeliveryRouter,跨平台转发消息只是函数调用,不需要任何 IPC。
gateway/config.py:325 的 Platform 枚举是全系统的平台身份源。内置成员显式列出,插件平台靠 _missing_ 钩子动态创建:
325 class Platform(Enum): 330 LOCAL = "local" 331 TELEGRAM = "telegram" ... 350 RELAY = "relay" # generic relay adapter fronted by the connector 351 @classmethod 352 def _missing_(cls, value): 353 """Accept unknown platform names only for known plugin adapters.""" ... 366 if value in _Platform__bundled_plugin_names: 367 pseudo = object.__new__(cls) 368 pseudo._value_ = value
_missing_ 只对「文件系统扫描到的捆绑插件平台」或「运行时注册的平台」放行,任意字符串一律拒绝——防止枚举污染。于是 Platform("irc") 与 Platform("telegram") 一样合法,且 Platform("irc") is Platform("irc") 恒真(缓存进 _value2member_map_)。
gateway/platform_registry.py:63 的 PlatformEntry 是平台的完整元数据卡:工厂函数、依赖探测、配置校验、所需环境变量、安装提示、消息长度上限、平台提示词注入等 20 余字段。其中最值得咀嚼的是「被动探测 / 主动安装」分离(check_fn 与 ensure_deps_fn):
platform_registry.py:81 # PASSIVE dependency probe: returns True when the platform's 82 # dependencies are available RIGHT NOW. Must be side-effect free — it is 83 # called from status displays (``hermes setup``, ``hermes status``, ...) ... 96 # ACTIVE dependency installer: make the platform's dependencies available, ... 103 # Why two fields (#79812): when the ACTIVE installer was registered as 104 # ``check_fn``, every status display pip-installed SDKs as a side effect ...
这段注释记录了一次真实翻车:安装器注册成 check_fn 后,每次 hermes status 都会顺手 pip 装包(桌面端启动卡在 94%);反过来只注册被动探测,则 create_adapter() 在依赖未装时直接返回 None,Teams 永远连不上。两个字段各司其职后「两个调用点都构造性正确」。PlatformRegistry(platform_registry.py:232)还实现了延迟加载:平台适配器模块在顶层 import 重型 SDK(lark_oapi、discord.py、slack_bolt……),全部急切加载会让每次 hermes chat 白等数秒,于是注册表只记一个零参 loader,真正 lookup 时才 import(platform_registry.py:246-258 注释)。
gateway/platforms/base.py:2939 的 BasePlatformAdapter 是所有适配器的 ABC,只要求 connect/disconnect/send/get_chat_info 四个抽象方法。平台差异被建模为一组能力位(类属性):
| 能力位 | 默认 | 用途 |
|---|---|---|
supports_code_blocks |
False | 平台能否渲染代码块(Slack mrkdwn 会把语言标签打成字面量) |
splits_long_messages |
False | 适配器是否自行分片长消息(Telegram/Discord 为 True) |
supports_async_delivery |
True | 回合结束后能否异步回推(API server 这类无状态面为 False) |
typed_command_prefix |
"/" | 用户可敲的命令前缀(Slack/Matrix 改成 "!") |
interactive_resume |
True | 有没有人在线回应"会话已恢复"(webhook 为 False) |
读法是:调用方一律 getattr(adapter, "xxx", 默认),没有任何 per-platform 分支散落在网关里。新增平台 = 新增一个子类 + 一组能力位声明,网关主循环零改动。
网关的主路径只有三步。
第一步,入站归一化。每个适配器把平台原始更新(Telegram update、飞书事件、微信 getupdates 报文)翻译成统一的 MessageEvent(text、message_type、SessionSource、media_paths),然后调用基类的 handle_message。SessionSource(session.py:149)携带 platform/chat_id/chat_type/user_id/thread_id/scope_id 七元组,是会话身份的全部原料。
第二步,会话键映射。build_session_key(session.py:1090)是「单一事实来源」:
session.py:1128 ns = _session_key_namespace(profile) 1129 platform = source.platform.value ... 1140 dm_parts = [ns, platform, "dm"] ... 1192 key_parts = [ns, platform, chat_type_slot] ... 1196 if source.chat_id: 1197 key_parts.append(source.chat_id) 1198 if effective_thread_id: 1199 key_parts.append(effective_thread_id)
DM 按 agent:main:<platform>:dm:<chat_id> 隔离私聊;群聊默认 group_sessions_per_user=True 在同一群里按用户分会话;线程(thread)默认全员共享一个会话——Telegram 论坛主题、Discord/Slack 线程的预期体验。会话键再交给 SessionStore(session.py:1245,SQLite + 线程安全)换取缓存的 AIAgent 实例与历史 transcript,进入第 2 章解剖过的内循环。
第三步,出站路由。agent 的回复不直接回平台,而是交给 DeliveryRouter(delivery.py:294)。DeliveryTarget.parse(delivery.py:231)解析目标串——origin(回来源)、local(落盘)、telegram:123456(指定平台会话):
delivery.py:244 if target_lower == "origin": 245 if origin: 246 return cls(platform=origin.platform, chat_id=origin.chat_id, ...) ... 261 if ":" in target_stripped: 262 parts = target_stripped.split(":", 2) 263 platform_str = parts[0].lower()
路由器还维护 DeadTargetRegistry(delivery.py:341-362):被踢出群、被拉黑的目标标记为 dead 后跳过重发,避免每次 cron 触发都撞限流刷日志;一旦后续发送成功则自动摘除标记。配合 channel_directory.py 每 5 分钟刷新的可达频道目录(含人类友好别名 channel_aliases.json),agent 可以用 send_message(action="list") 看到「我能联系谁」,再按名字定向投递。

主路径之外,GatewayRunner 的类定义行就泄露了它的 mixin 血统:
run.py:6883 class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, GatewaySlashCommandsMixin):
三个 mixin 分别管授权、看板监听与斜杠命令。授权是第一道闸:_is_user_authorized 在入站冷路径(run.py:16846 的 _handle_message,10354 行的快路径同样)核对 allowlist,PlatformEntry 上的 allowed_users_env/allow_all_env 字段让插件平台无需碰核心代码即可声明自己的授权环境变量。斜杠命令(slash_commands.py)让用户在任何平台敲 /new、/model、/stop、/voice——typed_command_prefix 能力位保证 Slack/Matrix 上自动改写成 ! 前缀。看板监听(kanban_watchers.py)把第 8 章的任务卡事件桥接进聊天(任务完成通知推送)。
外围守护件同样各占一文件:wake.py(外部唤醒——cron 或 dashboard 主动触发会话)、scale_to_zero.py(无负载收缩)、restart.py + restart_loop_guard.py(计划内重启与防重启风暴)、shutdown_flush.py/shutdown_watchdog.py(优雅关停刷盘与超时强杀)、memory_monitor.py/disk_status.py(资源水位)、systemd_notify.py(服务管理器心跳)、turn_lease.py(回合租约防并发写)。加上 authz_mixin.py、status.py(hermes gateway status 的数据源)、readiness.py(dashboard 健康探测),95 个文件的分工图景就此完整:一个文件一件事,主循环只做消息。
💡 循环要点:网关把「与人相遇」彻底从内循环剥离——适配器只生产
MessageEvent、只消费send(),中间的会话映射、审批、压缩、记忆全部复用同一套内循环。这意味着 34 个平台共享同一个外循环:无论消息来自微信还是 Discord,每条会话轨迹都同样进入 curator 与 learning_graph 的沉淀管线。社交器官越大,自我改进的燃料越多——这是「用得越多越强」在入口端的体现。
GatewayRunner 在一个 asyncio 事件循环并发驱动 34 平台;瓶颈在 LLM 而非网关,收益是零 IPC 的跨平台能力。Platform 枚举(_missing_ 动态接纳插件平台)→ platform_registry(延迟加载 + 被动探测/主动安装分离)→ BasePlatformAdapter(能力位代替 if/else)。MessageEvent → build_session_key 映射会话(DM 隔离、群默认按用户分、线程共享)→ DeliveryRouter 按 origin/local/platform:chat_id 出站。_missing_ 拒绝枚举污染。下一节我们把镜头拉近国际平台:telegram/discord/slack/whatsapp/signal/matrix 等如何各显神通接入,同一用户换平台如何延续会话,以及语音消息如何被转写成文字进入内循环。