版本说明:本文依据 2026 年 8 月 6 日读取的 Nous Research 官方
hermes-agent仓库、Hermes Messaging Gateway 文档,以及 Telegram、Discord、Slack 的官方平台协议文档撰写。Hermes 的网关实现仍在快速演进,平台数量、默认超时、重试次数和连接方式均属于版本化细节,生产使用时应再次核对当前版本。本文范围:讨论 Hermes 如何把一个可以调用模型和工具的 Agent,变成长期在线、可从 Telegram、微信、企业微信、Discord、Slack 等移动或协作平台访问的消息服务。本文不重复模型 API 的 SSE/WebSocket 原理,也不重新讲 Agent Loop 内部推理,而是聚焦 平台接入、事件归一化、会话路由、并发控制、消息呈现和可靠投递。
Claude Code、Codex 一类 Coding Agent 的核心问题,是如何在本地工作区中组织模型请求、工具调用和文件修改。Hermes 面对的额外问题则是:
- 用户不一定坐在运行 Agent 的机器前;
- 用户可能从多个聊天平台、群聊和线程进入;
- 平台事件模型、身份标识、消息长度和富文本格式并不统一;
- 一个 Agent Turn 可能运行几分钟,而平台回复令牌、Typing Indicator 或消息编辑接口有自己的时间约束;
- Gateway 可能在模型已经完成任务后、平台确认消息送达前崩溃;
- 平台可能重复推送入站事件,也可能在出站时返回 429、断开长连接或丢失 ACK;
- 同一个 Gateway 要同时服务多个 Chat,同时又不能让同一个会话中的两次任务相互污染。
因此,Hermes 的重点不是再设计一种模型传输,而是在模型 Agent 外增加一层长期运行的 Messaging Gateway。这层 Gateway 把不可靠、异构的外部消息系统,转换成 Agent 可以稳定消费的会话和事件;再把 Agent 的流式输出,转换为各平台能够接受的消息、编辑、附件和确认流程。Hermes 官方文档将其描述为一个连接所有已配置平台、管理会话、运行 Cron 任务并负责消息交付的单一后台进程。1
1. Hermes 与 Claude Code、Codex 的网络拓扑差异
Hermes 并不是用另一种方式实现 Claude Code 或 Codex。它更接近一个“把底层 Agent 服务化”的接入层。理解三种拓扑的区别,是理解后续架构的前提。
1.1 CLI Agent
CLI Agent 的典型路径是:
用户终端 │ stdin / keyboard ▼本地 Agent 进程 ├── 模型 API ├── 本地文件系统 ├── Shell / 工具 └── stdout / TUI它的几个基本假设是:
-
用户与 Agent 进程处在同一个交互生命周期中。 终端关闭、进程退出,交互通常就结束。
-
用户输入与输出通道天然一一对应。 当前 stdin 的输入,通常就回到当前 stdout。
-
身份问题相对简单。 操作系统用户、当前工作目录和本地配置往往已经构成主要信任边界。
-
审批可以同步进行。 Agent 请求执行危险命令时,可以直接在终端弹出确认。
-
结果交付通常不需要独立持久化。 模型完成后打印到终端即可;若进程在打印前崩溃,常被视为任务失败,而不是“任务完成但最后一公里未送达”。
CLI Agent 的网络层重点是模型链路和工具链路,而不是消息平台的路由与投递。
1.2 IDE Agent
IDE Agent 通常拆成多个进程或组件:
Webview / Chat Panel │Extension Host │Agent Runtime ├── Model Provider ├── MCP / Tools └── Workspace与 CLI 相比,它多出几类状态:
- Webview 与 Extension Host 的 IPC;
- 编辑器窗口、Workspace、选中文件和 Diff 状态;
- IDE 重载后的会话恢复;
- UI 面板中的渐进式输出和审批交互;
- 插件进程与本地工具进程的生命周期。
但 IDE Agent 仍然大多依附于用户当前打开的 IDE。它面对的主要是“本地交互界面”和“编辑器状态”问题,而不是长期在线的跨平台消息交付。
1.3 长期运行的消息 Gateway
Hermes 的拓扑不同:
Gateway 拓扑改变了系统的基本约束:
- 进程长期存在:即使没有用户输入,也要保持平台连接、执行 Cron、清理会话和处理恢复。
- 多来源输入:同一进程接收多个平台、多个 Chat、多个线程和多个用户的事件。
- 异步交互:用户可以在 Agent 执行过程中离线,任务仍然继续。
- 身份与路由是第一等状态:每条消息必须知道来自哪个平台、哪个 Chat、哪个 Thread、哪个用户,最终也必须回到正确位置。
- 执行完成与投递完成分离:Agent 生成了最终结果,不代表用户已经收到。
- 平台能力不对称:有的平台支持编辑、Typing、Thread 和语音;有的平台只支持一次性发送文本。
- 崩溃恢复必须跨进程:会话、待投递消息和恢复边界不能只放在内存里。
可以把三种架构的关注点概括为:
| 架构 | 主要生命周期 | 核心状态 | 最难的网络问题 |
|---|---|---|---|
| CLI Agent | 终端进程 | 当前 Turn、工作区、工具状态 | 模型流、工具出网、终端中断 |
| IDE Agent | IDE/Extension Host | Workspace、面板、会话、Diff | IPC、编辑器重载、工具桥接 |
| Hermes Gateway | 长期后台服务 | 平台身份、Session、队列、投递义务 | 多端接入、路由、限流、断线、可靠投递 |
Hermes 官方开发文档给出的统一链路是:
User ↔ Messaging Platform ↔ Platform Adapter ↔ Gateway Runner ↔ AIAgentAdapter 负责适配外部平台,Gateway Runner 负责授权、会话和调度,AIAgent 负责实际推理与工具执行。2
2. Hermes Gateway 架构

Hermes Gateway 的核心并不复杂:把平台差异压缩在 Adapter 中,把会话连续性放进 Session Store,把推理交给 AIAgent,把定时任务交给 Cron,把最终交付义务交给 Delivery Ledger。真正的工程难点,是让这五部分在断线、并发、重启和平台限制下仍保持一致。
2.1 Platform Adapter
所有平台 Adapter 都继承统一的 BasePlatformAdapter,至少实现:
connect():建立长连接、启动 Long Poll、启动 HTTP Server,或初始化 SDK;disconnect():清理连接和后台任务;send():向目标 Chat 发送文本;send_typing():可选,发送 Typing Indicator;get_chat_info():可选,补充 Chat 元数据;handle_message(event):把归一化事件交给 Gateway Runner。2
Adapter 的职责不是简单封装一个 SDK。它至少承担六类转换:
-
连接转换 把 Telegram Long Poll、Discord Gateway、Slack Socket Mode、WeCom WebSocket、Webhook 等不同接入方式统一为“收到 MessageEvent”。
-
身份转换 从平台原始事件中抽取 User ID、Chat ID、Thread ID、Message ID、Workspace/Guild Scope。
-
内容转换 把文本、图片、语音、文件、Reply、Mention 转成统一字段。
-
授权前置检查 某些平台会在 Adapter 层就检查用户或群组策略,减少未授权事件进入核心 Gateway。
-
输出格式转换 将统一的文本、Markdown、附件和 Thread 元数据翻译成平台 API 参数。
-
平台故障分类 区分网络瞬时失败、认证失败、限流、不可恢复配置错误和平台拒绝。
平台 Adapter 的设计目标是:上层 Gateway 不应知道 Telegram 的 message_thread_id、Slack 的 thread_ts 或 Weixin 的 context_token 如何工作,只需要处理统一的消息与交付结果。
2.2 Per-chat Session Store
Hermes 使用 SessionSource 描述一条消息从哪里来,主要字段包括:
platformchat_idchat_typeuser_idthread_idmessage_idscope_idparent_chat_idprofile这些字段共同参与路由、隔离和上下文注入。3
Session Store 不直接把“用户 ID”当作唯一会话键,而是生成一个确定性的 Session Key:
agent:main:{platform}:{chat_type}[:{chat_id}][:{thread_id}][:{participant_id}]这样做有几个直接效果:
- Telegram 私聊 A 与私聊 B 不共享上下文;
- 同一群组中的不同 Topic 可以独立;
- Discord Guild、Slack Workspace 可以通过
scope_id形成更高层隔离; - 同一个用户在 Telegram 与微信上的对话默认不合并;
- 同一 Chat 内是否按用户隔离,可以通过策略控制;
- Gateway 重启后,可以根据 Session Key 找回原有 Session ID 和 Transcript。
Hermes 的 Session Store 同时维护:
- Session Key 到 Session ID 的映射;
- 会话起止时间和最近活跃时间;
- 消息 Transcript;
- Token 和成本统计;
suspended、resume_pending等恢复标记;- 会话来源和结果投递路由。
官方 Session Lifecycle 文档指出,SQLite 是消息 Transcript 的规范存储,sessions.json 保留 Session Key 到 Session ID 的映射和元数据;当会话因重启中断时,resume_pending 可以保留原 Session ID,而 /stop 或强制挂起产生的 suspended 则会在下次访问时强制创建新会话。4
2.3 AIAgent Runtime
Gateway 本身不负责推理。它在确认授权、找到 Session、准备平台上下文后,把消息交给 AIAgent。
AIAgent Runtime 仍然负责传统 Agent 的工作:
- 构造模型上下文;
- 调用模型;
- 解析 Tool Call;
- 执行 Shell、GitHub、MCP 等工具;
- 回填 Tool Result;
- 多轮迭代;
- 生成最终答案。
Gateway 为 Runtime 增加的是外部上下文:
- 当前平台;
- Chat 类型;
- 用户与群组信息;
- Thread 信息;
- 可用的发送渠道;
- 当前 Session ID;
- Approval Channel;
- 平台能力,例如能否编辑消息、发送语音或显示按钮。
这使模型不仅知道“任务是什么”,也知道“结果最终要交付到什么地方”。
2.4 Cron Scheduler
Gateway 不只响应用户输入,还要运行定时任务。官方架构文档显示,Cron Scheduler 与 Session Store 和 Adapter 共享 Gateway 进程,按固定周期检查到期任务,并将结果投递到配置的 Home Channel。Messaging Gateway 文档当前描述的默认 Tick 周期是 60 秒。1
Cron 带来一个重要语义差异:
- 普通消息是“用户输入驱动”;
- Cron 是“时间驱动”;
- 后台任务是“任务完成驱动”。
三者最终都通过 Delivery 层发消息,但不应强行写入同一个用户聊天 Transcript。Hermes 明确规定,Cron 交付保留在自己的 Cron Session 中,不镜像回普通 Gateway 会话,以避免对话角色交替被破坏。5
2.5 Delivery Ledger
Delivery Ledger 是 Hermes 区别于普通 Bot Demo 的关键部件。
当 Agent 已经生成最终回复时,这段回复可能只存在于 Python 局部变量中。若 Gateway 在以下时刻崩溃:
Agent 完成 ↓开始调用平台 send() ↓平台已收到,但 ACK 尚未返回 ↓Gateway 崩溃系统无法知道用户是否已收到消息。若不持久化:
- 不重发:可能永久丢失结果;
- 直接重发:可能产生静默重复;
- 重跑 Agent:可能重复修改仓库、重复创建 PR、再次消耗模型 Token。
Hermes 因此把最终回复先记录为一个持久化“交付义务”,再执行平台发送。具体状态为:
pending → attempting → delivered ↘ failed ↘ abandoned这部分将在第 9 节深入分析。
3. 不同平台的入站连接方式
不同消息平台的“消息到达”并不是一个统一协议。Gateway 的第一层复杂性,就是维护多个不同生命周期的入站连接。
3.1 Telegram Bot API
Telegram Bot API 基于 HTTPS。Bot 接收更新有两种互斥方式:
getUpdatesLong Poll;setWebhook注册的 HTTPS Webhook。
同一个 Bot 不能同时使用两者。Telegram 使用单调递增的 update_id 帮助客户端确认和去重;未获取的 Update 最多在服务端保留 24 小时。6
Hermes 的 Telegram Adapter 基于 python-telegram-bot,负责:
- 接收私聊、群聊、Topic 和命令;
- 发送文本、图片、文件和语音;
- Typing Indicator;
- 消息编辑和渐进式输出;
- Reply 与 Thread 路由;
- 网络超时、初始化失败和代理处理。7
Telegram 接入的工程重点不是 HTTP 请求本身,而是:
- Long Poll 连接需要持续续期;
update_id必须去重;- Bot Token 缺失属于不可恢复配置错误,网络抖动属于可恢复错误;
- Topic 和私聊 Topic 的 Reply Anchor 语义不同;
- 文本长度按 UTF-16 Code Unit 计算,而不是 Python 字符数;
- 流式编辑过快会触发 Flood Control;
- Telegram 返回的文件 URL 会过期,需要及时下载到本地缓存。
3.2 Weixin 与 WeCom
“微信”在 Hermes 中对应两条不同链路。
Weixin:个人微信 iLink Bot API
Hermes 的 Weixin Adapter 通过腾讯 iLink Bot API 接入个人微信,设计特点是:
- 使用
getupdatesLong Poll 接收入站消息; - 每次出站回复必须带上该 Peer 最新的
context_token; - 图片、文件和语音通过 AES-128-ECB 加密的 CDN 协议传输;
- 支持 QR 登录;
- 有独立的 Typing API;
- 对频率限制、Session 过期和重复消息分别处理。8
这里的 context_token 类似平台侧对话续接凭证。它不是 Hermes Session ID,而是微信平台要求的回复上下文。Gateway 必须把它按 Account 和 Peer 持久化,否则重启后无法正确回复。
WeCom:企业微信 AI Bot WebSocket
WeCom Adapter 使用持续 WebSocket:
aibot_subscribe完成认证;aibot_msg_callback接收入站消息;aibot_send_msg发送 Markdown;- 媒体通过分块上传命令发送;
- 30 秒心跳;
[2, 5, 10, 30, 60]秒级退避重连;- 通过
req_id关联请求与响应; - 支持 DM、Group 的 Pairing/Allowlist 策略。9
Weixin 与 WeCom 虽然都叫“微信”,但网络形态完全不同:
| 维度 | Weixin | WeCom |
|---|---|---|
| 入站方式 | Long Poll | 持久 WebSocket |
| 回复关联 | context_token | WebSocket req_id/Callback |
| 媒体 | 加密 CDN | 分块上传 API |
| 连接恢复 | Long Poll 重建 | Heartbeat + Backoff Reconnect |
| 消息编辑 | 不支持渐进编辑 | 当前 Adapter 不支持编辑 |
| 典型身份 | 个人账号 Peer | 企业用户/群组 |
3.3 Discord Gateway
Discord Gateway 是持久 WebSocket 协议。客户端建立连接后:
- 服务端发送
Hello,包含 Heartbeat Interval; - 客户端按周期发送 Heartbeat;
- 服务端回
Heartbeat ACK; - 事件带序列号
s; - 断线后可通过
session_id、resume_gateway_url和最后序列号发送Resume; - 服务端可能重放断线期间遗漏的事件。10
Hermes 的 Discord Adapter 使用 discord.py,额外处理:
- Guild、Channel、Thread 和 DM;
- Slash Command;
- 自动 Thread;
- Mention 与 Reply;
- Message Component 字段的 UTF-16 长度;
- 附件下载和 SSRF Redirect Guard;
- 启动 Ready 超时;
- WebSocket 关闭超时后的底层 Transport Abort。11
Discord 的恢复重点是:重连不等于新会话。Gateway 可以恢复平台 WebSocket,而 Hermes Session 仍然保持不变。
3.4 Slack Socket Mode
Slack Socket Mode 允许 App 主动建立 WebSocket,而无需公开 Webhook Endpoint。连接 URL 通过 apps.connections.open 获取,Slack 会定期要求刷新连接。每个事件包含 envelope_id,客户端必须 ACK;Slack 支持同时维护多条 Socket 连接以提高可用性。12
Hermes 的 Slack Adapter 使用 slack-bolt 和 AsyncSocketModeHandler,支持:
- Channel 和 DM;
- Slash Command;
- Thread;
- Workspace 隔离;
- Web API 出站发送;
- Thread Context 获取;
- Markdown 到 Slack
mrkdwn的转换; - 多用户并发 Slash Command 的 ContextVar 隔离。13
Slack 的关键路由字段不只有 Channel ID,还包括:
- Team/Workspace ID;
- Thread TS;
- User ID;
- Envelope ID;
- Slash Command 的
response_url。
其中 Workspace ID 属于持久路由状态,必须跨流式、异步和恢复边界保留,不能在发送时退回“默认 Workspace”。
3.5 Webhook
Webhook 模式中,平台通过 HTTP POST 把事件推给 Gateway。正确实现至少要处理:
- Endpoint 身份验证;
- 签名校验;
- 时间戳和重放窗口;
- Event ID 去重;
- 快速 ACK;
- 异步执行长任务;
- 出站 API 与入站 Webhook 分离;
- 重试造成的重复事件。
Webhook 的核心语义是:
平台收到 2xx,只代表 Gateway 接收了事件,不代表 Agent 已完成任务。
因此 Webhook Handler 不应同步等待几分钟的 Agent Turn。更稳妥的做法是:
- 验证并持久化入站事件;
- 立即返回 ACK;
- 在后台交给 Session Queue;
- 最终通过独立出站 API 交付结果。
3.6 轮询型平台
Long Poll 的抽象流程是:
cursor = last_confirmed_cursor
loop: events, next_cursor = poll(cursor, timeout) for event in events: if not deduplicated(event.id): normalize_and_dispatch(event) persist(next_cursor)可靠的轮询 Adapter 需要:
- 持久化 Cursor/Offset;
- 允许重复获取同一批事件;
- 使用 Event ID 去重;
- 对空结果正常续轮;
- 对超时快速重建请求;
- 对 429 和服务端错误使用退避;
- 避免并发启动两条 Poll Loop;
- 在断开时关闭旧连接池。
Weixin 和 Telegram Long Poll 都属于这种模式,但它们的 Cursor、Session 和回复约束不同,不能共享平台实现,只能共享抽象原则。
4. 平台事件归一化

Gateway 不可能让 AIAgent 理解每个平台的原始 Payload。它需要一个稳定的 Normalized Message Contract。
一个简化后的统一事件可以表示为:
{ "platform": "telegram", "scope_id": null, "user_id": "123456", "user_name": "alice", "chat_id": "-100987654", "chat_type": "group", "parent_chat_id": null, "thread_id": "42", "message_id": "991", "reply_to_message_id": "950", "platform_update_id": "834921", "text": "请修复仓库中的支付超时问题", "media": [ { "kind": "image", "local_path": "/.../cache/images/img_xxx.png", "mime": "image/png" } ], "raw_message": {}}真正的字段会因平台能力而增加,但核心思想不变:平台专有事件先被压缩为稳定的路由字段、内容字段和可审计元数据。
4.1 User ID
User ID 的用途不只是显示用户名,还包括:
- Allowlist;
- DM Pairing;
- Per-user Session Isolation;
- 审批权限;
- 审计;
- 消息归属;
- 防止 Bot/Webhook 冒充用户。
需要注意:
- 不同平台的 User ID 不可直接比较;
- 某些平台同时有短期 ID 和稳定 Alt ID;
- 显示名称不是身份标识;
- 日志中应按平台策略做哈希或脱敏;
- 跨平台“同一个人”不能凭昵称自动合并。
Hermes 的 SessionSource 同时保留 user_id 与 user_id_alt,就是为了处理 Signal UUID、Feishu Union ID 等稳定身份差异。3
4.2 Chat ID
Chat ID 表示交付目的地,是最重要的路由字段。
它可能代表:
- Telegram 私聊;
- Telegram Group;
- Discord Channel;
- Slack Channel;
- Weixin Peer;
- WeCom Group;
- Webhook 的逻辑 Channel。
Chat ID 必须带 Platform Namespace 使用。裸字符串 12345 不足以构成全局唯一键:
telegram:dm:12345discord:channel:12345weixin:dm:123454.3 Thread ID
Thread ID 把一个 Chat 进一步拆成多条会话 Lane。
典型例子:
- Telegram Forum Topic;
- Telegram DM Topic;
- Discord Thread;
- Slack
thread_ts; - Feishu Thread。
Thread ID 的关键不是“显示在哪个 UI 里”,而是决定:
- Session 是否独立;
- 回复发送到哪里;
- 是否继承父 Channel 的 Model/System Prompt Override;
/stop应取消当前 Thread 还是整个 Chat;- 结果恢复时回到哪个 Lane。
Hermes 默认让 Group Session 按用户隔离,而 Thread 默认共享;这些规则可配置。4
4.4 Message ID
Message ID 用于:
- Reply Anchor;
- 去重;
- 编辑;
- Reaction;
- Pin;
- 生成 Delivery Obligation ID;
- 区分同一 Session 中的不同 Turn。
入站事件可能被平台重复推送,因此不能把“成功收到一次 HTTP 请求”当作唯一性保证。Adapter 应对 Message ID、Update ID 或 Envelope ID 建立去重窗口。
4.5 Reply Anchor
“回复这条消息”在不同平台里并不是同一件事:
- Telegram 普通群聊可使用 Reply ID;
- Telegram Forum Topic 更依赖
message_thread_id; - Telegram 私聊 Topic 可能同时需要 Topic ID 和触发消息 ID;
- Slack Thread 使用 Thread TS;
- Discord Thread 通常通过 Channel/Thread 本身路由;
- 某些跨频道 Handoff 必须刻意发送为顶层消息,不能误用 Reply Anchor。
Hermes 的 Base Adapter 专门生成平台感知的 Thread Metadata,并对 Telegram 私聊 Topic、群组 Topic、Slack Thread Handoff 等路径做不同处理。14
这说明 Reply Anchor 不是 UI 装饰,而是交付地址的一部分。
4.6 文件、图片和语音
媒体归一化通常包含两步:
- Adapter 从平台下载内容;
- Gateway 缓存为本地文件,让 Agent Tool 使用本地路径。
这样做的原因包括:
- 平台文件 URL 会过期;
- 后续 Vision/STT Tool 需要本地文件;
- 重试时不应再次依赖平台临时 URL;
- 需要统一做大小限制和 MIME 校验;
- 需要防止 SSRF 和恶意 Redirect;
- 需要在不同平台格式之间转换。
Hermes Base Adapter 对入站媒体设置统一大小上限,默认 128 MiB,可配置;还会校验图片 Magic Bytes、限制下载流量,并对 Redirect 重新执行 SSRF 检查。15
语音处理还要处理容器格式差异。例如 Telegram 的普通 Audio 与 Voice Bubble 支持的格式不同;Hermes 会根据平台和文件扩展名决定使用原生 Voice、Audio 还是普通 Document。
5. 会话和身份路由
事件归一化完成后,Gateway 需要回答两个问题:
- 这条消息是否有权进入 Agent?
- 它属于哪一个持续会话?
5.1 一个 Chat 对应一个 Session
最简单的规则是:
Session Key = Platform + Chat Type + Chat ID这保证一个 Chat 的多条消息共享上下文,同时不同 Chat 隔离。
但真实平台还需要 Thread、Participant、Scope 和 Profile:
agent:main:{profile}:{platform}:{scope}:{chat_type}:{chat_id}:{thread_id}:{participant_id}其中有些字段按需省略。Hermes 官方实现使用确定性 Key,而不是每次创建随机会话;这样 Gateway 重启后仍能重新找到原 Session。4
5.2 群聊和私聊隔离
私聊通常天然一人一会话:
telegram:dm:alicetelegram:dm:bob群聊则有两种策略:
共享群会话
所有人共享上下文:
telegram:group:team-chat优点是多人协作自然;风险是:
- 一个用户看到另一个用户的上下文;
- 权限边界复杂;
- Prompt Injection 和身份混淆风险高;
- 费用和任务归属不清晰。
群内按用户隔离
在 Key 中增加 Participant:
telegram:group:team-chat:user-alicetelegram:group:team-chat:user-bobHermes 默认对 Group/Channel 使用 Per-user Isolation,以降低上下文串扰;Thread 则默认共享,以便一个 Topic 中多人协作。4
5.3 Thread 继承
Thread 既要独立,又需要继承父级策略。
可继承的内容包括:
- 平台与 Workspace Scope;
- 父 Channel Allowlist;
- Channel Model Override;
- System Prompt Override;
- Home Channel;
- 代理与凭证;
- 格式和消息长度能力。
不应继承的内容包括:
- 父 Channel 的完整对话 Transcript;
- 其他 Thread 的 Pending Tool State;
- 其他 Thread 的
/stop信号; - 其他 Thread 的 Delivery ID。
合理的路由方式是:
父级提供策略Thread 提供会话边界Message 提供当前 Turn Anchor5.4 用户权限和 Allowlist
Hermes Gateway 默认拒绝未在 Allowlist 中、也未完成 DM Pairing 的用户。官方授权顺序为:
- 平台级 Allow-all;
- 平台 Allowlist;
- DM Pairing;
- 全局 Allow-all;
- 默认拒绝。5
这是必要的,因为 Gateway 背后的 Agent 可能拥有:
- Shell;
- GitHub;
- 文件系统;
- 浏览器;
- 企业 API;
- 通知能力。
一个普通聊天 Bot 的未授权消息最多造成垃圾回复;一个带 Shell 的 Agent 则可能执行真实副作用。
权限判断至少应绑定:
PlatformScope / Workspace / GuildChatUserRoleCommandTool RiskHermes 还提供 /approve、/deny,让危险工具的审批可以在移动端完成。审批命令需要绕过“当前 Agent 正在运行”的普通消息 Guard,直接到达等待中的权限请求,否则会形成死锁。5
5.5 跨平台会话边界
默认情况下,Telegram 上的 Alice 与微信上的 Alice 是两个不同身份、两个不同 Session。
跨平台自动合并会产生严重问题:
- 昵称可能相同但不是同一个人;
- 一个平台的认证强度可能低于另一个;
- 群聊上下文可能被带入私聊;
- Delivery 路由可能错发;
- 审批权限可能被跨平台提升。
因此,跨平台会话合并必须是显式能力,至少需要:
- 账户绑定;
- 双向验证;
- 明确的 Canonical User ID;
- 可撤销映射;
- 审计日志;
- 来源标记;
- 权限取交集而非并集。
Hermes 支持跨平台发送,但这不等于跨平台会话自动合并。Cross-platform Delivery 应被视为一个显式工具动作,而不是隐式 Session 共享。5
6. 将模型流转换成移动端消息

模型输出通常是毫秒到秒级的增量 Token 流,而消息平台需要的是离散的 API 调用。Gateway 需要在二者之间增加一个 Stream Consumer。
Hermes 的流式桥接大致如下:
Hermes 的 GatewayStreamConsumer 将同步回调转为异步 Queue,再进行缓冲、限频和渐进式编辑。16
6.1 Typing Indicator
Typing Indicator 的目的不是装饰,而是填补 Time to First Visible Feedback。
一个完整回复可能经历:
用户发送消息→ Gateway 路由→ Agent 构造上下文→ 模型首 Token→ 多轮工具执行→ 最终回复若没有 Typing Indicator,用户会把几秒到几分钟的等待误认为 Bot 离线。
但 Typing Indicator 也有生命周期:
- 周期刷新;
- Turn 完成后停止;
/stop后立即停止;- Gateway 重启后不恢复旧 Indicator;
- 平台不支持时静默降级;
- 不应因 Typing 失败影响主任务。
Weixin 有专门的 sendtyping API;Telegram、Discord、Slack 也有各自的 Typing/Presence 机制,但支持范围和持续时间不同。
6.2 Progressive Message Edit
支持消息编辑的平台,可以先发送一条 Preview,再逐步编辑:
send("正在分析… ▌") → message_idedit(message_id, "已读取仓库… ▌")edit(message_id, "发现问题位于… ▌")edit(message_id, "最终答案")相比连续发送几十条消息,这种方式:
- 减少消息噪声;
- 保持上下文集中;
- 更接近 CLI 流式体验;
- 降低通知数量。
但必须控制编辑频率。Hermes Stream Consumer 使用:
- Edit Interval;
- Buffer Threshold;
- Cursor;
- Flood-control Strikes;
- Adaptive Backoff;
- Final Fresh Message;
- Draft/Edit/Off Transport 切换。16
当连续编辑多次触发 Flood Control 时,系统会停止渐进编辑,保留最终发送,避免为了“看起来实时”而丢失真正结果。
6.3 文本分段
消息平台有长度限制。Gateway 不能简单用 text[:limit] 截断,因为会破坏:
- Unicode;
- Markdown;
- Code Fence;
- 表格;
- 链接;
- Tool Result;
- 语义段落。
更合理的分段顺序是:
- 按完整消息块;
- 按段落;
- 按换行;
- 按句子;
- 按空格;
- 最后按平台字符计量单位硬切。
分段还需要记录 Continuation Message ID。若流式输出超过单条消息上限,后续 Edit 不能继续编辑第一条,而要创建新的消息段,并确保 Finalization 不重复发送已经展示的前缀。
6.4 平台长度限制
不同平台的“字符数”定义也不同。
例如 Telegram 的单条文本上限是 4096 个 UTF-16 Code Unit。一个 Emoji 或某些扩展汉字可能占两个 Unit,因此 Python len(text) 不能直接作为上限。Hermes 专门实现 UTF-16 长度和安全前缀切割。14
WeCom Adapter 当前设置 4000 字符上限,并会对接近上限的连续输入进行批处理,以识别客户端拆分。9
因此,Gateway 的统一输出接口应提供:
adapter.measure_text(text)adapter.max_message_lengthadapter.split_message(text)而不是在核心层写死一个通用数字。
6.5 Markdown 转换
模型输出通常接近 CommonMark/GFM,但平台格式并不兼容:
- Telegram MarkdownV2 有严格转义;
- Discord 支持一部分 Markdown;
- Slack 使用
mrkdwn,不原生渲染 GFM Table; - WeCom 使用自己的 Markdown 子集;
- Weixin 可能需要转换为纯文本块;
- Code Fence 未闭合会污染整个后续消息。
Hermes 的 Slack Adapter 会识别 GFM Pipe Table,把表格转成等宽 Code Block,并按 CJK 显示宽度补齐列;Stream Consumer 会在最终发送前补齐未闭合的三反引号和 Inline Backtick,防止整条消息渲染错误。1316
Markdown 转换的正确位置是 Platform Adapter 或 Render Layer,而不是修改 Agent 原始答案。这样可以同时保留:
- 原始语义内容;
- 平台渲染结果;
- Delivery Ledger 中可重放的规范内容;
- 审计和故障定位能力。
6.6 图片、文件和语音回复
多模态交付至少包含三类路径:
图片
- 本地生成图片;
- Tool 下载图片;
- 模型返回图片 URL;
- 平台原生图片消息;
- 多图批量发送。
文件
- 代码补丁;
- 报告;
- 日志;
- ZIP;
- PDF;
- Tool Result 落盘文件。
语音
- 用户语音转写;
- Agent 文本 TTS;
- 平台原生 Voice Bubble;
- Discord Voice Channel;
- 流式 TTS。
Adapter 必须知道平台格式能力。例如 Telegram 普通 Audio 仅支持特定附件格式,而 Voice Bubble 常要求 OGG/Opus;Hermes 会根据平台构造不同输出扩展名和发送路径。14
媒体交付不能只记录“文本已发送”。Delivery Result 至少应包含:
{ "success": true, "message_id": "...", "media_ids": ["..."], "platform": "telegram", "chat_id": "...", "thread_id": "...", "delivered_at": "..."}7. 每个 Chat 的并发控制

消息 Gateway 同时面对两种相反需求:
- 不同 Chat 要并行,否则一个长任务会阻塞所有用户;
- 同一 Session 要有序,否则两条消息会竞争同一个 Transcript 和工具状态。
7.1 同一会话串行
Hermes 使用两级 Running-agent Guard:
- Base Adapter 在事件进入 Gateway Runner 前检查 Session 是否活跃;
- Gateway Runner 再检查当前 Session 是否已有 Agent 在运行。5
当同一 Session 正在运行时,普通消息可以:
- 入队;
- 触发 Interrupt;
- 被
/queue显式排队; - 根据 Busy Mode 合并;
- 等待当前 Turn 结束。
不能直接启动第二个 AIAgent 共用同一 Transcript,否则会出现:
Turn A 读取历史 HTurn B 也读取历史 HTurn A 写入 Tool Result ATurn B 写入 Tool Result B最终历史顺序不确定这会破坏模型上下文、工具副作用和消息角色交替。
7.2 不同会话并行
不同 Session 可以并行运行,但要受全局上限约束:
max_concurrent_sessions当活跃 Session 达到上限时,新 Session 会收到明确的容量提示,而 /status 等轻量控制命令仍可绕过限制。Hermes 的测试明确验证了这一行为。17
合理的并发策略应至少区分:
- 活跃前台 Session;
- 后台任务;
- Cron;
- 消息投递;
- Typing Refresh;
- Platform Reconnect;
- Media Download。
不能用一个 Semaphore 把所有任务粗暴锁死,否则长 Agent Turn 可能阻止平台心跳,最终造成全局断线。
7.3 /stop 与任务取消
/stop 不是普通消息。它必须:
- 绕过当前 Session 的排队;
- 找到正在运行的 AIAgent;
- 触发 Cancellation/Interrupt;
- 终止或标记当前工具;
- 停止流式编辑和 Typing;
- 清理 Pending Approval;
- 更新 Session 状态;
- 防止旧的异步 Delta 在新 Session 中继续发送。
Gateway Runner 对 /stop、/approve、/deny 等命令执行 Inline Dispatch,就是为了避免它们被正在等待的 Agent 自己阻塞。5
取消的正确语义是:
停止当前执行,不等于删除历史;停止旧 Turn,也不允许旧回调继续污染新 Turn。
7.4 后台任务
/background <prompt> 把任务放入独立后台 Session,而不是继续占用当前 Chat 的前台 Turn。
后台任务需要独立维护:
- Session ID;
- Cancel Token;
- Tool Process;
- Completion Notification;
- Result Delivery Target;
- 资源预算;
- 重启后的处理策略。
若后台任务与当前 Chat 共享同一 Session,后续用户输入会与后台 Tool Result 交错,造成上下文污染。
7.5 Gateway 重启后的 Session 恢复
Gateway 重启后有两类状态:
Session Continuity
Transcript 和 Session Metadata 仍存在。resume_pending 表示上次执行因进程重启或 Drain Timeout 中断,下次访问应保留原 Session ID。4
In-flight Execution
旧进程中的 Python Task、模型连接和 Shell Process 不一定仍存在。不能把“Session 可恢复”误解成“任意 Tool Call 可原地恢复”。
Hermes 的恢复策略区分:
resume_pending:软恢复,同一个 Session 继续;suspended:硬中止,下次访问创建新 Session;- Delivery Ledger:恢复已经生成、尚未确认交付的最终回复;
- Gateway Health Watchdog:发现进程或 Event Loop 停滞后由系统管理器重启。
Session 恢复解决“上下文连续性”,Delivery Ledger 解决“最终结果不丢失”,两者不能互相替代。
8. 平台断线和限流
8.1 长连接断开
不同平台的长连接恢复机制不同:
Discord
- Heartbeat;
- Heartbeat ACK;
- Sequence;
- Session ID;
- Resume Gateway URL;
- Resume 重放遗漏事件。10
Slack Socket Mode
- WebSocket URL 临时获取;
- Envelope ACK;
- Refresh Requested;
- 多连接冗余;
- 连接轮换。12
WeCom
- 30 秒 Heartbeat;
- 连接与请求超时;
- 指数式 Backoff;
- 重新 Subscribe 和认证;
- Pending Response 失败清理。9
Weixin
- Long Poll 超时后重建;
- 连续失败计数;
- 短重试与长 Backoff;
- Session Expired 单独处理;
- Context Token 恢复。8
Gateway 应把平台连接状态与 Agent Session 分离:
Platform Connection: disconnected / connecting / readyAgent Session: active / idle / resume_pending / suspendedDelivery: pending / attempting / delivered / failed平台断线时,不应删除所有 Session;只需要暂停或排队出站发送,并在连接恢复后继续。
8.2 Webhook 重复事件
Webhook 重复是正常现象,不是异常。
平台可能因为:
- ACK 超时;
- 网络抖动;
- 服务端重试;
- Gateway 重启;
- 负载均衡切换;
再次发送同一事件。
正确的入站去重键通常是:
Platform + Scope + Event ID如果平台没有 Event ID,可退化到:
Platform + Chat ID + Message ID + Event Type去重记录必须有 TTL,避免无限增长;但 TTL 不能短到平台正常重试窗口内失效。Weixin 当前使用 300 秒去重 TTL;WeCom 使用有界 Message Deduplicator。89
8.3 平台 API 429
平台 429 与模型 429 类似,但恢复动作不同:
- Telegram Flood Control 可能要求等待指定秒数;
- Discord/Slack API 有 Method/Route/Workspace 级限流;
- Weixin 有平台特定 Rate-limit Error Code;
- 媒体上传与普通文本可能使用不同 Bucket;
- 编辑消息与发送新消息可能分别限流。
Adapter 应把平台错误规范化为:
retryableretry_afterscopeoperationplatform_error_codemessageOutbound Queue 再按这些字段安排重试。不能在每个发送调用里各自 sleep(),否则同一 Chat 的多个消息会乱序。
8.4 消息编辑频率限制
模型可能每几十毫秒产生一个 Delta,但平台不允许同样频率的 Edit。
Gateway 需要把 Token 流重整为较低频率的 UI 更新:
高频 Token Delta ↓字符阈值 + 时间阈值 ↓每 0.x ~数秒一次 Edit ↓遇到 429 增大间隔 ↓连续失败后关闭 Progressive Edit ↓最终消息必须保留Hermes Stream Consumer 记录 Flood Strike,并动态提高 Edit Interval;达到阈值后禁用本轮渐进编辑,转而保证最终发送。16
这里的优先级应始终是:
最终结果送达 > 渐进展示完整 > 实时性8.5 代理和网络切换
Gateway 是长期进程,网络环境可能变化:
- 企业代理切换;
- VPN 启停;
- DNS 污染;
- 代理凭证过期;
- NO_PROXY 改变;
- SOCKS 连接重建;
- TLS CA 更新;
- 平台域名解析变化。
Hermes Base Adapter 的代理解析顺序为:
- 平台专用代理变量;
HTTPS_PROXY/HTTP_PROXY/ALL_PROXY;- macOS 系统代理;
NO_PROXY匹配则绕过代理。18
SOCKS 路径使用 Remote DNS,以避免本地 DNS 污染。网络切换后的正确处理不是重启所有 Agent Session,而是:
- 重建 Adapter 连接;
- 清理旧连接池;
- 保留 Session Store;
- 保留 Delivery Ledger;
- 对未确认出站消息进入恢复流程。
9. Durable Delivery Ledger

Delivery Ledger 解决的问题非常具体:
Agent 已经生成了最终答案,但 Gateway 还不能证明平台已经收到。
9.1 回复发送前持久化
在任何平台 Send 之前,Gateway 先写入:
obligation_idsession_keyplatformchat_idthread_idcontentstate = pendingattempts = 0owner_pidowner_started_atcreated_atobligation_id 由:
session_key + inbound message_ref + content哈希生成。同一个 Turn、同一内容重复记录,会得到同一个 ID,从而具备记录层幂等性。19
关键顺序是:
先 record_obligation()再调用平台 send()如果反过来,进程可能在 Send 成功后、写 Ledger 前崩溃,恢复系统将完全不知道这条回复存在。
9.2 平台确认后完成
完整状态迁移是:
delivered 只能在 Adapter 返回明确成功后设置。HTTP 请求发出、Socket Write 成功、平台返回 2xx,分别代表不同层次,Adapter 必须把“平台已接受”转成统一 SendResult.success。
9.3 Gateway 崩溃后的重新投递
Gateway 启动时执行 sweep_recoverable():
- 扫描
pending、attempting、failed; - 检查原 Owner PID 和进程启动时间;
- 若原进程仍存活,不抢占;
- 若原进程已死亡,原子 Claim;
- Attempts 加一;
- 仅 Claim 当前启动中有可用 Adapter 的平台;
- 返回待重投记录;
- Adapter 重新发送。19
这种 Owner Stamp 不只检查 PID,还检查 Process Start Time,避免操作系统复用 PID 后误判旧进程仍存活。
9.4 At-least-once 语义
Hermes 选择的是“诚实的 At-least-once”:
pending
Send 尚未开始,可以直接重投,没有重复风险。
attempting
崩溃发生在等待平台响应期间。消息可能已经送达,也可能没有送达。恢复时必须重投,但可能重复。
failed
平台明确拒绝过一次。重启是新的重试边界,可以再次尝试,但仍带恢复标记。
delivered
已确认,不再处理。
Exactly-once 需要平台提供端到端幂等键和可查询交付状态。多数消息平台不提供这种统一能力,因此 Gateway 不应假装具备 Exactly-once。
9.5 可能重复时显式提示
对于 attempting 或 failed,Hermes 在重投内容前加可见前缀:
♻️ Recovered reply — the gateway restarted during delivery,so this may be a duplicate:这比静默重复更诚实。
分布式系统中的不确定性无法总被消除,但可以被:
- 记录;
- 限定;
- 暴露;
- 审计。
Hermes 当前限制:
- 最多 3 次重投;
- 最长 24 小时有效;
- Delivered/Abandoned 记录保留 7 天;
- 总行数有上限。19
9.6 为什么不重新运行整个 Agent Turn
重新运行整个 Agent Turn 是最危险的恢复方式,因为原 Turn 可能已经:
- 修改文件;
- Push Branch;
- 创建 Pull Request;
- 发送外部通知;
- 调用付费 API;
- 写数据库;
- 消耗大量 Token。
Delivery Ledger 已经保存最终文本。恢复的目标只是“把这段结果交付给用户”,不是“重新计算一次结果”。
正确边界是:
Agent Execution State Delivery State已完成 未确认因此恢复动作应是:
重投最终消息而不是:
重新执行用户任务Delivery Ledger 本质上把“计算结果”和“结果交付”拆成两个独立事务。
10. Telegram 或微信端完整案例
下面用一个统一任务串起所有组件:
用户从 Telegram 或微信发送:“修复仓库中的支付超时问题,运行测试并创建 Pull Request。”
具体到 Telegram:
- Adapter 通过 Bot API 收到 Update;
- 使用
update_id和 Message ID 去重; - 若是 Topic,生成 Thread Metadata;
- 发送 Typing;
- Agent 运行时,Stream Consumer 可以编辑 Preview;
- 最终回复进入 Delivery Ledger;
- Send 成功返回 Message ID;
- 标记 Delivered。
具体到 Weixin:
- Long Poll
getupdates收到消息; - Adapter 记录最新
context_token; - Typing 使用 iLink API;
- Weixin 不支持同样的渐进式编辑路径,Gateway 更依赖 Typing 和最终消息;
- 出站消息必须带该 Peer 的
context_token; - 最终回复同样先进入 Delivery Ledger;
- 若崩溃后重投,使用持久化的 Session/Peer 路由重新发送。
在这个案例中,Gateway 重启后没有再次请求模型、没有再次运行测试、没有再次创建 PR。它只恢复最后一公里的 Delivery Obligation。
11. 可复用的多端 Agent 网关设计
Hermes 的实现可以抽象为八个可复用模块。
11.1 Platform Adapter
统一接口:
class PlatformAdapter: async def connect(self) -> bool: ... async def disconnect(self) -> None: ... async def send( self, chat_id: str, content: str, reply_to: str | None, metadata: dict | None ) -> SendResult: ... async def send_typing(self, chat_id: str) -> None: ... async def edit_message( self, chat_id: str, message_id: str, content: str ) -> SendResult: ...Adapter 还应声明能力:
{ "supports_threads": true, "supports_editing": true, "supports_typing": true, "supports_images": true, "supports_files": true, "supports_voice": false, "max_message_length": 4096, "length_unit": "utf16"}11.2 Normalized Message
统一消息必须稳定表达:
- 来源 Platform;
- Scope;
- User;
- Chat;
- Thread;
- Message;
- Reply;
- 文本;
- 媒体;
- Update/Event ID;
- 授权上下文;
- 原始 Payload;
- 时间戳。
核心不变量:
Raw Platform Event ↓ AdapterNormalized Message ↓ Gateway Core任何平台走同一授权、会话和调度流程11.3 Per-chat Session
Session Key 至少包含:
ProfilePlatformScopeChat TypeChat IDThread IDParticipant ID(按策略)Session Store 需要:
- Transcript;
- Session ID;
- Origin;
- Reset Policy;
- Resume State;
- Token/Cost;
- Last Activity;
- Pending Background State。
11.4 Outbound Delivery Queue
出站 Queue 负责:
- 同一 Chat 保序;
- 平台独立速率限制;
- Progressive Edit 节流;
- 文本分段;
- 媒体上传;
- Retry-After;
- 优先级;
- Final Message 优先;
- 连接不可用时等待。
合理的优先级可设为:
P0:Approval / Deny / StopP1:最终结果P2:错误与状态P3:Progressive EditP4:Typing Refresh在资源紧张时,应先丢弃低优先级的 Typing 和 Edit,而不是最终结果。
11.5 Delivery Ledger
Ledger 的最小字段:
delivery_idsession_keyplatformchat_idthread_idcontent_hashcontentstateattemptsownercreated_atupdated_atlast_error状态机:
pending → attempting → delivered ↘ failed ↘ abandoned核心不变量:
最终结果必须先持久化,再开始平台发送。
11.6 Rate Limit Adapter
不同平台应提供统一限流结果:
@dataclassclass RateLimitDecision: retryable: bool retry_after: float | None bucket: str | None global_limit: bool operation: str这样 Outbound Queue 可以统一调度,而不把 Telegram FloodWait、Discord Route Bucket、Slack Retry-After 和 Weixin Error Code 散落在业务逻辑中。
11.7 Approval Channel
移动端 Approval 需要:
- Approval ID;
- Session Key;
- Tool Call ID;
- 操作摘要;
- 风险级别;
- 过期时间;
- Approve/Deny;
- 防重放;
- 授权用户校验。
Approval 必须绕过普通 Session Queue,直接唤醒等待中的 Agent。否则 Agent 等审批,审批消息又排在 Agent 后面,形成永久死锁。
11.8 Gateway Health Watchdog
Health Watchdog 至少监控三层:
Process Liveness
- PID;
- Service Manager;
- Restart Count;
- Crash Loop。
Event-loop Liveness
- Heartbeat;
- Scheduling Delay;
- Thread Block;
- File Descriptor;
- Queue Depth。
Platform Liveness
- Connected;
- Last Event Time;
- Last ACK Time;
- Heartbeat RTT;
- Reconnect Count;
- Auth Expiry;
- Rate-limit State。
Hermes 支持 systemd Watchdog:只有 Event Loop 能及时调度时才发送 Heartbeat;如果 Event Loop 停止推进,systemd 重启进程。普通平台网络断开不应被误判成整个 Event Loop 死亡。1
结语:多端 Agent 的正确边界是“会话、执行与交付分离”
Hermes 的架构价值,不在于支持多少聊天平台,而在于它把三个经常被混在一起的状态明确拆开:
1. 会话状态
用户是谁来自哪个 Chat / Thread历史上下文是什么重启后是否继续同一个 Session2. Agent 执行状态
当前 Turn 是否运行正在等待哪个 Tool是否被 /stop后台任务是否仍存活3. 消息交付状态
最终文本是否已生成平台发送是否开始平台是否确认是否需要重投是否可能重复这三个状态的生命周期不同,也必须由不同组件负责:
| 状态 | 负责组件 |
|---|---|
| 会话 | Session Store |
| 执行 | AIAgent Runtime / Gateway Runner |
| 交付 | Outbound Queue / Delivery Ledger |
一套生产级多端 Agent Gateway 应遵循五条原则:
- 平台差异终止在 Adapter 边界。
- Session Key 必须显式编码平台、Chat、Thread 和身份隔离。
- 同一 Session 保序,不同 Session 有界并行。
- 实时展示可以降级,最终结果不能丢失。
- 执行完成不等于交付完成;未知交付结果必须诚实地采用 At-least-once。
这也是 Hermes 与普通 CLI Agent 最根本的区别:它不仅要让 Agent“完成任务”,还要在用户离线、平台限流、连接断开和 Gateway 重启的情况下,确保正确的用户最终能在正确的会话里收到结果。