Gateway 网关:连接 20+ 平台的统一消息路由

如何用 session key 隔离多用户、用双层守卫防止并发、用 delivery 系统实现任意渠道回复

系列:通过 Hermes 探秘 Agent 工程 | 第 8 篇 上一篇:安全防护体系:当 Agent 拥有终端时如何防止「做出格」的事


问题:一个 Agent,20+ 个入口

当你安装 Hermes,你可能通过以下方式跟它聊天:

  • Telegram:Ping bot
  • Discord:服务器里召唤 Agent
  • Slack:工作群里 /hermes
  • 微信:公众号或企业微信机器人
  • 命令行:终端里直接 hermes
  • REST API:自己写的前端调用

Hermes 需要在所有这些渠道里统一行为——安全配置一样、工具权限一样、输出体验一样。

Hermes 的解决方案是 Gateway:一个长连接进程,作为所有消息渠道的统一入口。


Gateway 是什么

Gateway 是一个独立的后台服务(systemd / launchd),管理所有平台适配器和会话状态。它不执行 LLM 推理本身——它只做消息接收、路由、响应下发

┌─────────────────────────────────────────────────┐
│                  GatewayRunner                   │
│                                                  │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐       │
│  │ Telegram │  │ Discord  │  │  Slack   │  ...  │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘       │
│       │             │             │              │
│       └─────────────┼─────────────┘              │
│                     ▼                            │
│            _handle_message()                     │
│                     │                            │
│         ┌──────────┼──────────┐                  │
│         ▼          ▼          ▼                  │
│   Slash cmd   AIAgent    Queue/BG                │
│   dispatch    creation   sessions                │
│                     │                            │
│                     ▼                            │
│               SessionStore                       │
│            (SQLite persistence)                  │
└─────────────────────────────────────────────────┘

Session 模型

Session Key 结构

Gateway 的每个对话都用一个 session key 唯一标识:

agent:main:{platform}:{chat_type}:{chat_id}

例如:

  • agent:main:telegram:private:123456789(Telegram 私聊)
  • agent:main:discord:channel:987654321(Discord 频道)
  • agent:main:slack:group:ABC123(Slack 群组)

加了话题的平台的 chat_id 里还包含线程 ID(如 Telegram 论坛主题、Discord Thread)。

不要手动构造 session key——始终使用 gateway/session.py 里的 build_session_key() 函数。session key 是数据库查询、缓存映射、路由匹配的唯一 key,拼写错误意味着创建一个孤儿会话或消息投递失败。

SessionStore 持久化

所有会话数据存储在 SQLite 数据库里:

  • 消息历史
  • 会话元数据(创建时间、来源渠道、启用配置等)
  • 工具守卫状态
  • 上下文压缩摘要

SessionStore 提供了一套原子 API,让 Gateway 在重启后可以从数据库恢复会话。


消息流:从平台到 AIAgent

一条消息到达 Gateway 后的完整路径:

1. 平台适配器 → MessageEvent

每个平台适配器(Telegram Adapter / Discord Adapter 等)接收各自平台的原始事件,规范化成 Hermes 内部的 MessageEvent

规范化包含:

  • 提取发送者 ID、频道 ID、消息内容
  • 分离文本正文与附件
  • 识别是否为命令(以 / 开头)

2. 基础适配器 → 两层守卫

BasePlatformAdapter 定义了两层消息守卫机制:

L1 — 适配器层守卫:检查 _active_sessions。如果当前会话里已经有 AIAgent 在运行,消息被放入 _pending_messages 队列,并设置 interrupt 事件。这一步确保在消息进入 Gateway 主逻辑之前就被拦截——多个平台的适配器并行运行,不需要每个平台自己实现检查逻辑。

L2 — GatewayRunner 层守卫:检查 _running_agents。对在 AIAgent 运行期间到达的消息,做分流:

  • /stop/new/status/approve/deny管理员命令内联调度(直接调用 _message_handler() 异步执行)
  • 普通用户消息 → 触发 running_agent.interrupt(),让 AIAgent 自己决定是打断当前工具执行还是排队处理

这种内联调度避免了背景竞争条件:如果 /approve 排队等待当前任务执行完,而当前任务其实在等用户去按 approve……就死锁了。

3. GatewayRunner → AIAgent

消息通过守卫后,GatewayResolver 决定如何处理:

  • Slash 命令:发到 gateway/run.py 的命令分发器
  • 模型切换中:拦截 /model 命令,返回"请等当前任务完成或 /stop"
  • 普通消息:创建 AIAgent 实例,传入当前 session 的配置(模型、工具集、安全设置)

AIAgent 开始执行 run_conversation() 工具调度循环。中间产生的所有输出通过 delivery.py 实时轨递到源平台。

4. 响应下发

gateway/delivery.py 负责消息的出站投递:

  • 直接回复:发回到来源平台
  • Home Channel 投递:cron job 的输出路由到指定 channel
  • 跨平投递:可以指定把消息发到不同的平台(如 Discord 用户通过 Telegram 收回复)
  • 显式目标投递:send 命令可以通过 telegram:-100123456:17585 这样的 URL 指定任意目标

Cron job 的投递被刻意设计为不在 gateway session 历史里——避免消息交替违规(某些平台限制机器人必须在用户消息后才能回复)。


多层授权

Gateway 按顺序执行以下检查,任一通过即授权:

  1. 平台级 allow-all(如 TELEGRAM_ALLOW_ALL_USERS):该平台的全部用户被允许
  2. 平台级 allowlist(如 TELEGRAM_ALLOWED_USERS):逗号分隔的用户 ID
  3. DM pairing:已授权的管理员可以生成配对码,新用户输入配对码完成授权
  4. 全局 allow-allGATEWAY_ALLOW_ALL_USERS):所有平台的所有用户被允许
  5. 默认:拒绝

这是纵深授权——按粒度从粗到细排列。配对这个设计特别实用——管理员不需要编辑配置文件,直接通过聊天就能给用户解锁。配对状态持久化在 gateway/pairing.py,重启后仍然有效。


平台适配器:插件体系

绝大多数平台适配器写在 plugins/platforms/<name>/adapter.py,少部分旧版直接写在 gateway/platforms/

统一的适配器接口:

方法作用
connect()连接到该平台 API
disconnect()从容断开连接
send_message()发送消息到该平台
on_message()接收平台事件 → 生成 MessageEvent

几个有趣的细节:

Token Locks:用 acquire_scoped_lock() 防止两个 profile 同时用同一个 bot token。这在部署多 profile(如"公司号"和"测试号")时特别关键——同时重连会踢掉对方的 session。

适配器隔离:每个适配器独立运行在自己的 asyncio task 里。一个适配器崩溃(比如 Discord.py 异常)不会拖垮整个 Gateway——其他平台继续正常工作。

中继适配器(relay adapter):当配置了 GATEWAY_RELAY_URL,Gateway 会通过出站 WebSocket WebSocket 连接到一个"连接器"服务,然后接收 descriptorinbound 帧。不知道——这样可以对接到没有适配器实现的平台(如某个小众即时通讯协议)。


配置来源

Gateway 的配置来自三层:

来源示例
~/.hermes/.envAPI keys、bot tokens、平台凭证
~/.hermes/config.yaml模型设置、工具配置、显示选项
环境变量覆盖上面任何值

CLI 和 Gateway 的配置行为不同:CLI 用 load_cli_config() 内置了默认值字典,而 Gateway 直接读 YAML 配置。这意味着同一个配置字段,如果用户没写进 config.yaml,CLI 可能会给默认行为,但 Gateway 可能表现为不同行为。


Gateway 的运营运维

服务安装

# 安装为系统服务
hermes gateway install

# 管理服务
hermes gateway start
hermes gateway stop
hermes gateway restart
hermes gateway status

# systemd 日志
journalctl --user -u hermes-gateway -f

# macOS 日志
tail -f ~/.hermes/logs/gateway.log

安装过程会自动生成 systemd .service 文件(Linux)或 launchd .plist(macOS),设置重启策略(Restart=on-failure / KeepAlive),确保 Gateway 崩溃后自动重启。

优雅重启

Hermes Gateway 支持两种重启方式:

  1. systemd / launchd 重启:SIGTERM → 超时 SIGKILL → 重启。这种重启会中断正在执行的 Agent 任务。

  2. SIGUSR1 重启(Hermes 独有):发送 SIGUSR1 信号 → GatewayRunner 捕获信号 → request_restart(via_service=True) → 等待当前正在运行的 AIAgent 完成任务(最多 agent.restart_drain_timeout 秒)→ 正常退出 → 服务管理器自动重启。

SIGUSR1 重启是无损升级的——新代码加载后,当前任务被完成,后续请求自动发到新进程版本。

Profile 支持

Hermes Gateway 支持多 profile——相同机器上运行多个独立实例:

hermes gateway run --profile prod    # 生产实例(绑定 127.0.0.1:8080)
hermes gateway run --profile test    # 测试实例(绑定 127.0.0.1:8081)

Profile 通过环境变量 HERMES_PROFILE 隔离:不同的 ~/.hermes/profiles/<profile>/ 目录存储独立配置、独立的数据库文件、独立的 profile Gateway 进程独立的 profile Gateway 实例。


总结

Gateway 是 Hermes 的消息路由器——不思考,只管连接、转发、持久化。它的核心设计:

  • Session Keyagent:main:{platform}:{chat_type}:{chat_id} 格式,唯一标识一个对话工程模型
  • 双层守卫:适配器层 + GatewayRunner 层,既防止并发又支持 admin 命令打断
  • 纵深授权:从粗到细的授权检查 + DM pairing 这个很实用的自助授权模式
  • 插件适配器:每个平台一个插件目录,共享 BasePlatformAdapter 接口
  • 无损升级:SIGUSR1 + drain timeout + 进程服务管理器自动重启

下一篇,我们深入 Hermes 的 Provider 抽象层——如何让 Hermes 同时与 OpenAI、Anthropic、Gemini、本地 Ollama 等多种 LLM 提供商协作。