第 7 章 · 01 单进程网关架构


第 7 章 · 01 单进程网关架构

本节摘要:本节进入 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.pygateway/config.pygateway/platform_registry.pygateway/platforms/base.pygateway/session.pygateway/delivery.pygateway/channel_directory.py,以及 website/docs/user-guide/messaging/index.md

⚠️ 注意gateway/run.py 约 3.2 万行、platforms/base.py 约 7500 行,绝无逐行精读的可能。本节采取"骨架走读"策略:只读类定义、能力位与消息主路径,跳过错误恢复、优雅关停、watchdog 等运维支线(它们各有独立文件如 restart.pyshutdown_watchdog.py)。

学习目标

阅读完本节,你应当能够:

  1. 说出网关的三段职责:消息接收 → 路由到 agent 会话 → 回复推送,并对应到源码文件。
  2. 解释 Platform 枚举如何用 _missing_ 钩子动态接纳插件平台。
  3. 复述 PlatformEntry 的「被动探测 / 主动安装」分离设计(#79812)。
  4. 列举 BasePlatformAdapter 的至少六个能力位及其用途。
  5. 讲清 build_session_key 如何把一条消息映射到唯一 agent 会话。

一、单进程的胆量:一个事件循环,34 条连接

先看体量分布。gateway/ 顶层 60 余个 Python 模块按职责分家:run.py(网关主循环)、session.py(会话存储)、delivery.py(出站路由)、channel_directory.py(可达频道目录)、platform_registry.py(平台注册表)、platforms/(内置平台适配器)、relay/(通用中继协议),外加 restart.pyshutdown_flush.pymemory_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。

二、平台抽象:枚举、注册表与能力位

2.1 Platform 枚举:内置 + 动态插件成员

gateway/config.py:325Platform 枚举是全系统的平台身份源。内置成员显式列出,插件平台靠 _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_)。

2.2 platform_registry:注册表与延迟加载

gateway/platform_registry.py:63PlatformEntry 是平台的完整元数据卡:工厂函数、依赖探测、配置校验、所需环境变量、安装提示、消息长度上限、平台提示词注入等 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 注释)。

2.3 BasePlatformAdapter:能力位而非 if/else

gateway/platforms/base.py:2939BasePlatformAdapter 是所有适配器的 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 分支散落在网关里。新增平台 = 新增一个子类 + 一组能力位声明,网关主循环零改动。

三、消息流水线:从平台事件到 agent 会话再回来

网关的主路径只有三步。

第一步,入站归一化。每个适配器把平台原始更新(Telegram update、飞书事件、微信 getupdates 报文)翻译成统一的 MessageEvent(text、message_type、SessionSource、media_paths),然后调用基类的 handle_messageSessionSource(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") 看到「我能联系谁」,再按名字定向投递。

图:dashboard 频道管理

四、运行面全景:主循环之外的守护协程

主路径之外,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.pystatus.pyhermes gateway status 的数据源)、readiness.py(dashboard 健康探测),95 个文件的分工图景就此完整:一个文件一件事,主循环只做消息

💡 循环要点:网关把「与人相遇」彻底从内循环剥离——适配器只生产 MessageEvent、只消费 send(),中间的会话映射、审批、压缩、记忆全部复用同一套内循环。这意味着 34 个平台共享同一个外循环:无论消息来自微信还是 Discord,每条会话轨迹都同样进入 curator 与 learning_graph 的沉淀管线。社交器官越大,自我改进的燃料越多——这是「用得越多越强」在入口端的体现。

本节要点回顾

  1. 单进程模型GatewayRunner 在一个 asyncio 事件循环并发驱动 34 平台;瓶颈在 LLM 而非网关,收益是零 IPC 的跨平台能力。
  2. 三层抽象Platform 枚举(_missing_ 动态接纳插件平台)→ platform_registry(延迟加载 + 被动探测/主动安装分离)→ BasePlatformAdapter(能力位代替 if/else)。
  3. 消息主路径三步:适配器归一化 MessageEventbuild_session_key 映射会话(DM 隔离、群默认按用户分、线程共享)→ DeliveryRouterorigin/local/platform:chat_id 出站。
  4. 运维细节:dead-target 短路防限流、channel_directory 每 5 分钟刷新、_missing_ 拒绝枚举污染。
  5. 双循环位置:网关是流量入口,多平台会话直接放大外循环的经验沉淀速率。
  6. 运行面血统:GatewayRunner 三 mixin(授权/看板监听/斜杠命令)+ 一文件一事的守护件家族(wake/scale_to_zero/restart_loop_guard/shutdown_flush/turn_lease 等),主循环只做消息。

下一节我们把镜头拉近国际平台:telegram/discord/slack/whatsapp/signal/matrix 等如何各显神通接入,同一用户换平台如何延续会话,以及语音消息如何被转写成文字进入内循环。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U