原文:Hermes Agent 13 | Gateway 架构:二十余渠道如何复用同一套 Agent Runtime
入口可以有一百个,但引擎只需要一个
先从一个实际场景出发
假设你同时在 Telegram 和 Slack 上部署了 Hermes。一个用户在 Telegram 私聊里说"帮我搜一下 LLM inference 的最新论文",另一个用户在 Slack 频道的线程里说"翻译一下这段 Go 代码"。这两条消息同时到达。
从 Agent runtime 的角度看,它不关心消息来自 Telegram 还是 Slack。它需要的只有三样东西:
-
一段文本(或者附带图片、语音、文件)
-
一个 session key,用来定位"这条消息属于哪段对话"
-
一个回调通道,用来把结果发回去
Gateway 就是做这件事的中间层:把 20 种平台 API 的差异,收敛成 Agent runtime 能消费的统一输入;再把 Agent 的输出,展开成各平台能渲染的原生格式。
整体架构:一图看完
Telegram Discord Slack Signal Email WeChat ... Webhook/API
│ │ │ │ │ │ │
└────┬────┘ │ │ │ │ │
│ │ │ │ │ │
┌────▼────────────▼───────▼───────▼───────▼────────────▼──┐
│ BasePlatformAdapter │
│ connect() / disconnect() / send() / get_chat_info() │
│ + 可选覆写: send_image / send_voice / edit_message ... │
└────────────────────────┬────────────────────────────────┘
│ MessageEvent (统一消息格式)
│
┌────────────────────────▼────────────────────────────────┐
│ GatewayRunner (gateway/run.py) │
│ - 平台适配器注册与生命周期管理 │
│ - 消息路由: 授权 → 命令拦截 → session 解析 → Agent 执行 │
│ - 响应投递: 文本/图片/语音/文件的跨平台归一 │
│ - Cron ticker: 后台定时任务触发与投递 │
│ - Hook 系统: 生命周期事件 → 可扩展处理器 │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ AIAgent (run_agent.py) --- Agent Runtime │
│ 同一套模型调用、工具系统、记忆/技能、上下文压缩 │
└─────────────────────────────────────────────────────────┘
gateway/run.py 一个文件 10,485 行,gateway/platforms/base.py 2,201 行。数字看着吓人,但核心设计思路很克制。接下来逐层拆。
统一消息协议:MessageEvent
所有平台适配器的工作,最终都要归结到一件事:把平台原生事件转换成一个 **MessageEvent** 实例。
这个 dataclass 定义在 gateway/platforms/base.py(第 656 行):
@dataclass
class MessageEvent:
# 消息内容
text: str
message_type: MessageType = MessageType.TEXT
# 来源信息
source: SessionSource = None
# 原始平台数据(调试用)
raw_message: Any = None
message_id: Optional[str] = None
# 媒体附件
media_urls: List[str] = field(default_factory=list)
media_types: List[str] = field(default_factory=list)
# 回复上下文
reply_to_message_id: Optional[str] = None
reply_to_text: Optional[str] = None
# 频道/话题绑定的自动技能
auto_skill: Optional[str | list[str]] = None
# 频道级别的临时系统提示(不持久化到对话历史)
channel_prompt: Optional[str] = None
# 内部合成事件标记(跳过授权检查)
internal: bool = False
timestamp: datetime = field(default_factory=datetime.now)
MessageType 枚举覆盖了 9 种消息类型:TEXT、PHOTO、VIDEO、AUDIO、VOICE、DOCUMENT、LOCATION、STICKER、COMMAND。
注意两个不那么显然的字段:
-
auto_skill:Telegram 的论坛话题(forum topic)或 Discord 频道可以绑定技能。消息到达时,适配器把绑定的技能名写入这个字段,Agent runtime 会自动加载对应的 SKILL.md。 -
channel_prompt:Discord 的频道描述(channel topic)或 Slack 的频道说明可以充当临时系统提示。它只在本次 API 调用时注入,不写入对话历史,也不影响 prompt cache。
这是一个很聪明的设计:平台特有的上下文信息,通过 MessageEvent 的扩展字段传递,而不是让 Agent runtime 去感知平台差异。
平台适配器:4 个抽象方法 + N 个可选覆写
BasePlatformAdapter(base.py 第 853 行)定义了适配器的完整协议。必须实现的抽象方法只有 4 个:
| 方法 | 职责 |
|---|---|
connect() |
连接平台,开始接收消息 |
disconnect() |
断开连接 |
send(chat_id, content, reply_to, metadata) |
发送文本消息 |
get_chat_info(chat_id) |
获取聊天/频道信息 |
实现了这 4 个方法,一个平台适配器就能工作了。但只靠 send() 发文本,体验会很粗糙------图片变成 URL 链接、语音变成文件路径、长消息被截断。所以 base class 提供了一组带默认实现的可选方法:
# 媒体相关
async def send_image(chat_id, image_url, caption, ...) -> SendResult
async def send_animation(chat_id, animation_url, ...) -> SendResult
async def send_voice(chat_id, audio_path, ...) -> SendResult
async def send_video(chat_id, video_path, ...) -> SendResult
async def send_document(chat_id, file_path, ...) -> SendResult
async def send_image_file(chat_id, image_path, ...) -> SendResult
# 消息编辑(流式输出场景)
async def edit_message(chat_id, message_id, content, *, finalize=False) -> SendResult
# 输入状态指示
async def send_typing(chat_id, metadata=None) -> None
async def stop_typing(chat_id) -> None
# 生命周期钩子
async def on_processing_start(event: MessageEvent) -> None
async def on_processing_complete(event: MessageEvent, outcome: ProcessingOutcome) -> None
默认实现都是"降级方案"。比如 send_image() 的默认行为是把 URL 作为文本发出去;edit_message() 默认返回 success=False,调用方会 fallback 到发一条新消息。各平台按自身能力覆写。
这种设计意味着:写一个最简适配器可能只需要 200 行代码,但做到体验完善可以展开到上千行。 实际上,Discord 适配器 3,648 行(因为有 Rich Embed、Reaction、Thread 等丰富交互),而 Signal 适配器只有 897 行。
20 个平台适配器
Platform 枚举定义在 gateway/config.py(第 48 行),当前包含 20 个值:
class Platform(Enum):
LOCAL = "local"
TELEGRAM = "telegram"
DISCORD = "discord"
WHATSAPP = "whatsapp"
SLACK = "slack"
SIGNAL = "signal"
MATTERMOST = "mattermost"
MATRIX = "matrix"
HOMEASSISTANT = "homeassistant"
EMAIL = "email"
SMS = "sms"
DINGTALK = "dingtalk"
API_SERVER = "api_server"
WEBHOOK = "webhook"
FEISHU = "feishu"
WECOM = "wecom"
WECOM_CALLBACK = "wecom_callback"
WEIXIN = "weixin"
BLUEBUBBLES = "bluebubbles"
QQBOT = "qqbot"
从即时通讯(Telegram、Discord、Signal)到企业协作(Slack、飞书、钉钉、企业微信),从邮件/短信到智能家居(Home Assistant),再到通用接入(Webhook、API Server)------覆盖面已经相当完整。加上微信(weixin)和 QQ(qqbot),中国生态也没落下。
适配器注册流程
GatewayRunner 在启动时遍历配置,按需创建适配器:
# gateway/run.py --- 适配器注册(简化)
for platform, platform_config in self.config.platforms.items():
if not platform_config.enabled:
continue
adapter = self._create_adapter(platform, platform_config)
adapter.set_message_handler(self._handle_message) # 消息回调
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
adapter.set_session_store(self.session_store)
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
success = await adapter.connect()
if success:
self.adapters[platform] = adapter
关键点:每个适配器拿到的 _handle_message 是同一个函数引用 ------GatewayRunner._handle_message。这就是"多入口复用同一套处理逻辑"的接入点。
Session Key:一条公式决定"这是哪段对话"
session 管理是 Gateway 最精妙的设计之一。核心问题是:同一个 Agent runtime,面对来自不同平台、不同聊天、不同用户、不同线程的消息,如何正确地隔离会话上下文?
答案是一个确定性的 key 构造函数,定义在 gateway/session.py(第 440 行):
def build_session_key(
source: SessionSource,
group_sessions_per_user: bool = True,
thread_sessions_per_user: bool = False,
) -> str:
规则分两大类。
DM(私聊)规则
私聊场景最简单------一个用户、一个对话:
if source.chat_type == "dm":
if source.chat_id:
if source.thread_id:
return f"agent:main:{platform}:dm:{source.chat_id}:{source.thread_id}"
return f"agent:main:{platform}:dm:{source.chat_id}"
if source.thread_id:
return f"agent:main:{platform}:dm:{source.thread_id}"
return f"agent:main:{platform}:dm"
Telegram 的"论坛话题"(forum topic)在技术上是 DM 里的 thread,所以会走 chat_id:thread_id 的路径------同一个 DM 聊天窗口下的不同话题各有独立 session。
群组/频道规则
群组场景复杂得多,因为要处理"同一个群里不同用户是否共享上下文"的问题:
participant_id = source.user_id_alt or source.user_id
key_parts = ["agent:main", platform, source.chat_type]
if source.chat_id:
key_parts.append(source.chat_id)
if source.thread_id:
key_parts.append(source.thread_id)
# 线程内默认共享;非线程群组默认按用户隔离
isolate_user = group_sessions_per_user
if source.thread_id and not thread_sessions_per_user:
isolate_user = False
if isolate_user and participant_id:
key_parts.append(str(participant_id))
return ":".join(key_parts)
这里有一个非常关键的设计决策:
线程内默认共享(所有参与者看到同一段对话),非线程群组默认按用户隔离(每个人各有独立上下文)。
为什么?因为 Telegram 论坛话题、Discord 线程、Slack 线程的用户心智模型就是"大家在同一个线程里讨论同一件事"。如果按用户隔离,A 在线程里说的话 B 看得见,但 Agent 却当两段独立对话处理------这会让用户困惑。
而在不带线程的群组里,用户 A 说"帮我翻译这段代码"、用户 B 说"搜一下最新的 Rust 版本",如果共享 session,Agent 的上下文就乱了。所以默认按用户隔离。
几个实际 session key 的例子:
| 场景 | Session Key |
|---|---|
| Telegram 私聊 | agent:main:telegram:dm:123456789 |
| Telegram 论坛话题 | agent:main:telegram:dm:987654321:123 |
| Discord 频道(用户隔离) | agent:main:discord:channel:123456789:user_456 |
| Discord 线程(共享) | agent:main:discord:channel:123456789:987654321 |
| Slack 线程(共享) | agent:main:slack:channel:C123ABC:ts1234567890 |
Session key 到 session_id 的映射默认持久化在 ~/.hermes/sessions/sessions.json 里;如果启用了 profile,则会随对应的 HERMES_HOME/sessions/ 目录变化。SessionEntry 记录了创建时间、更新时间、token 用量、预估成本等元数据。
消息路由:从平台事件到 Agent 执行
当一个 MessageEvent 到达 GatewayRunner._handle_message 时,它会经过一条完整的处理管线。
第一步:授权检查
event 到达
│
├── internal=True?→ 跳过授权(合成事件)
├── user_id 为空?→ 拒绝
└── _is_user_authorized()
├── 允许名单匹配?→ 通过
└── DM 配对 fallback → 等待首次绑定
授权在 gateway/run.py 的 _is_user_authorized() 方法中完成。这是第 10 讲安全防线中"第一层:用户授权"在 Gateway 中的落地。
第二步:命令拦截
授权通过后,系统检查是否为特殊命令。这些命令在 Agent 之前被拦截处理:
-
/stop:中断正在运行的 Agent -
/new:重置当前 session -
/approve//deny:危险命令审批响应 -
/steer:向运行中的 Agent 注入引导提示 -
/queue:查看排队消息
拦截发生在 _handle_message 的第 2865-3172 行之间。注意这些命令不经过 Agent runtime ,而是由 Gateway 直接处理。这保证了即使 Agent 卡死,用户仍然可以用 /stop 中断它。
第三步:Session 解析与上下文构建
session = session_store.get_or_create_session(session_key)
context = build_session_context(session, source, connected_platforms, home_channels)
SessionContext 包含 SessionSource(来源信息)、已连接平台列表、home channel 字典。这些信息会被 build_session_context_prompt() 函数序列化为系统提示的一部分注入到 Agent:
## Current Session Context
**Source:** Telegram (DM with alice)
**User:** alice
**Connected Platforms:** local (files on this machine), telegram: Connected ✓, slack: Connected ✓
**Home Channels (default destinations):**
- telegram: My Notes (ID: 123456789)
**Delivery options for scheduled tasks:**
- `"origin"` → Back to this chat (alice)
- `"local"` → Save to local files only (<HERMES_HOME>/cron/output/)
- `"telegram"` → Home channel (My Notes)
这段系统提示让 Agent 知道自己"身处何方"------知道消息来自 Telegram 私聊、知道有哪些平台可用、知道 cron 任务可以投递到哪里。
一个细节值得注意:对于共享线程(多用户参与的线程),系统提示不会写死某个用户名,而是标注"Multi-user thread --- messages are prefixed with sender name"。原因在代码注释里写得很清楚:
# In shared thread sessions (non-DM with thread_id), multiple users
# contribute to the same conversation. Don't pin a single user name
# in the system prompt --- it changes per-turn and would bust the prompt
# cache. Instead, note that this is a multi-user thread; individual
# sender names are prefixed on each user message by the gateway.
如果每次换个人说话就改系统提示里的用户名,LLM 的 prompt cache 就会被打碎。所以改为在每条用户消息前加 [sender name] 前缀,系统提示保持稳定。这是一个为了 prompt cache 效率而做的有意识的设计妥协。
第四步:后台异步执行
消息处理不在主 event loop 中阻塞。BasePlatformAdapter.handle_message() 会立即 spawn 一个后台 task:
async def handle_message(self, event: MessageEvent) -> None:
# 返回很快------spawn 后台任务
task = asyncio.create_task(
self._process_message_background(event, session_key)
)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
_process_message_background() 是真正的执行主体,完整生命周期如下:
1. 启动 typing 指示器循环 (_keep_typing)
2. 触发 on_processing_start 钩子
3. 调用 _message_handler → Agent runtime 执行
4. 响应投递(文本/图片/语音/文件)
5. 触发 on_processing_complete 钩子
6. 检查 pending message 队列
7. 停止 typing 指示器
这个后台模型的好处是:新消息可以在 Agent 运行期间继续到达 。如果用户在 Agent 运行时追加了新消息,它会被放入 pending queue;如果用户发了 /stop,它会通过中断机制取消当前运行。
跨平台消息格式归一
Agent runtime 的输出是 Markdown 文本,但各平台对 Markdown 的支持千差万别。Gateway 在响应投递时做了一系列归一化处理。
图片提取与原生投递
Agent 经常在输出中嵌入  格式的图片。extract_images() 方法(base.py 第 1154 行)会把这些标记提取出来,通过 send_image() 以平台原生方式投递------在 Telegram 里显示为内联图片,在 Discord 里显示为 Embed。
如果 URL 以 .gif 结尾,还会自动切换到 send_animation(),让 GIF 在 Telegram 上自动播放而不是作为静态图显示。
长消息分片
truncate_message() 方法(base.py 第 2072 行)处理超长消息的分片:
@staticmethod
def truncate_message(
content: str,
max_length: int = 4096,
len_fn: Optional[Callable[[str], int]] = None,
) -> List[str]:
两个设计细节:
-
代码块感知:如果分片点落在三反引号(`````)代码块内部,会在当前分片末尾关闭代码块,在下一个分片开头重新打开(带上原始的语言标签)。这避免了跨分片的渲染错误。
-
自定义长度函数 :Telegram 按 UTF-16 code unit 计算消息长度(一个 emoji 占 2 个 unit),所以 Telegram 适配器传入
utf16_len而不是 Python 的len。
分片后会加上 (1/3)、(2/3) 这样的指示器。
Pending Message 合并
用户经常连发多条消息,尤其是在 Telegram 上。比如先发一张截图,紧接着发一句"帮我看看这个报错"。如果不做合并,第一条消息触发 Agent 开始运行,第二条消息就变成了 pending message,下一轮才处理------用户体验很割裂。
merge_pending_message_event() 函数(base.py 第 742 行)解决这个问题:
def merge_pending_message_event(
pending_messages, session_key, event, *, merge_text=False,
) -> None:
-
照片连发 (Photo burst):多张图片合并到同一个
MessageEvent的media_urls列表里 -
图文混合:图片和文字合并,caption 拼接
-
文字追加 (
merge_text=True,Telegram 启用):连续的文字消息用\n拼接,避免只保留最后一条
流式编辑:让消息"动起来"
对于支持消息编辑的平台(Telegram、Slack、Discord、Mattermost),Gateway 可以实现"流式输出"效果:先发一条消息,然后持续 edit_message() 更新内容,用户看到文字逐步生成。
edit_message() 的签名有一个 finalize 参数:
async def edit_message(
self, chat_id, message_id, content, *, finalize=False,
) -> SendResult:
大多数平台(Telegram、Slack、Discord)不区分"中间编辑"和"最终编辑"------edit 就是 edit。但钉钉的 AI Card 有"进行中"和"已完成"两种 UI 状态,需要在最后一次编辑时显式关闭。所以 base class 定义了 REQUIRES_EDIT_FINALIZE 类属性(默认 False),钉钉适配器覆写为 True,流式消费端据此决定是否发出 finalize 编辑。
这种"默认安全降级 + 按需覆写"的模式贯穿了整个适配器设计。
生命周期钩子与 Hook 系统
Gateway 有两层事件机制。
适配器级钩子
on_processing_start 和 on_processing_complete 是适配器级别的钩子,在 _process_message_background() 的头尾调用:
await self._run_processing_hook("on_processing_start", event)
# ... Agent 执行 ...
await self._run_processing_hook(
"on_processing_complete", event,
ProcessingOutcome.SUCCESS if processing_ok else ProcessingOutcome.FAILURE,
)
ProcessingOutcome 有三个值:SUCCESS、FAILURE、CANCELLED。
典型用法:Discord 适配器在 on_processing_start 时给消息加一个 👀 reaction(表示"正在处理"),在 on_processing_complete 时根据结果换成 ✅ 或 ❌。
Gateway 级 Hook 系统
更通用的 Hook 系统定义在 gateway/hooks.py,支持 8 种事件:
| 事件 | 触发时机 |
|---|---|
gateway:startup |
Gateway 进程启动 |
session:start |
新 session 创建 |
session:end |
session 结束(/new 或 /reset) |
session:reset |
自动重置完成(idle 超时或每日重置) |
agent:start |
Agent 开始处理消息 |
agent:step |
工具调用循环的每一步 |
agent:end |
Agent 处理完成 |
command:* |
任意斜杠命令(通配符匹配) |
Hook 从 hooks 目录发现和加载。默认路径是 ~/.hermes/hooks/;启用 profile 后,对应的是当前 profile 的 HERMES_HOME/hooks/。每个 hook 是一个目录,包含 HOOK.yaml(声明名称和监听的事件列表)和 handler.py(提供 handle(event_type, context) 函数):
# HookRegistry.emit() --- 简化
handlers = list(self._handlers.get(event_type, [ ]))
# 通配符匹配:command:* 匹配任意 command:xxx
if ":" in event_type:
base = event_type.split(":")[0]
wildcard_key = f"{base}:*"
handlers.extend(self._handlers.get(wildcard_key, [ ]))
for fn in handlers:
result = fn(event_type, context)
if asyncio.iscoroutine(result):
await result
一个内置 hook 的例子是 boot-md:它监听 gateway:startup 事件,在 Gateway 启动时自动执行 BOOT.md 里的引导任务。默认文件位于 ~/.hermes/BOOT.md;启用 profile 后则位于当前 HERMES_HOME/BOOT.md。
Hook 的错误处理原则是:catch + log,绝不阻塞主管线。一个坏掉的 hook 不会导致消息处理失败。
Cron 投递与 Home Channel
Hermes 的 Gateway 进程内置了一个 Cron ticker------一个后台 daemon 线程,每隔 60 秒检查一次定时任务:
# gateway/run.py --- Cron ticker 启动
cron_thread = threading.Thread(
target=_start_cron_ticker,
args=(cron_stop,),
kwargs={"adapters": runner.adapters, "loop": asyncio.get_running_loop()},
daemon=True,
name="cron-ticker",
)
Cron 任务执行完毕后,输出需要投递到某个地方。这就涉及到投递路由机制。
DeliveryTarget
gateway/delivery.py 定义了 DeliveryTarget 类,支持 4 种目标格式:
| 格式 | 含义 |
|---|---|
"origin" |
回到创建任务的原始聊天 |
"local" |
保存到本地文件(默认 ~/.hermes/cron/output/;profile 下随 HERMES_HOME 变化) |
"telegram" |
发到 Telegram 的 home channel |
"telegram:123456" |
发到指定的 Telegram 聊天 |
Home Channel
每个平台可以配置一个 home channel------默认投递目的地:
@dataclass
class HomeChannel:
platform: Platform
chat_id: str
name: str # 显示用的可读名称
配置示例(config.yaml):
gateway:
platforms:
telegram:
enabled: true
token: "bot_token_here"
home_channel:
chat_id: "123456789"
name: "My Notes"
当用户创建一个 cron 任务,指定 deliver="telegram" 但不给 chat_id 时,输出就发到这个 home channel。如果 origin 不可用(比如 cron 任务是在 CLI 创建的,但 CLI 已经退出),home channel 是 fallback;如果 home channel 也没配,local 作为最终兜底------输出永远不会丢。
这套投递机制还被注入到系统提示中(前面展示的 Session Context),让 Agent 知道"我可以把定时任务的结果投递到哪些地方"。这样当用户说"每天早上帮我搜一下新闻,发到我的 Telegram",Agent 可以正确构造 cron 任务的 deliver 参数。
故障恢复:断了还能接上
Gateway 长驻运行,网络断连是常态。适配器的故障处理分三层。
发送重试
_send_with_retry()(base.py 第 1487 行)是所有消息发送的底层包装:
-
遇到连接类错误(
connectionreset、connectionrefused等):指数退避重试,最多 2 次 -
遇到格式化错误:降级为纯文本重试
-
明确区分 timeout 和连接错误 :
_is_timeout_error()单独判断。read/write timeout 说明请求可能已经到达服务端,盲目重试会导致重复发送------所以 timeout 不重试_RETRYABLE_ERROR_PATTERNS = (
"connecterror", "connectionerror", "connectionreset",
"connectionrefused", "connecttimeout", "network",
"broken pipe", "remotedisconnected", "eoferror",
)
注意列表里有 connecttimeout(连接建立超时,安全重试)但没有 timed out(读写超时,不安全重试)。
适配器级断连恢复
如果一个平台适配器遇到不可恢复错误(比如 bot token 被撤销),它调用 _set_fatal_error() 标记自己。GatewayRunner._handle_adapter_fatal_error() 接管:
-
如果
retryable=True:放入_failed_platforms队列,等待后台重连 -
如果
retryable=False:记录错误,不再尝试
后台重连看门狗
_platform_reconnect_watcher() 是一个后台 asyncio task,专门负责重连失败的平台:
-
初始延迟 10 秒
-
指数退避:
30 * (2 ** (attempt - 1)),最大封顶 300 秒 -
最多重试 20 次
-
重连成功后更新运行时状态、重建 channel directory,并恢复适配器服务
这保证了即使某个平台临时不可用(比如 Telegram API 维护),Gateway 整体继续运行,等 API 恢复后自动重连------用户无需手动干预。
PII 脱敏:别把电话号码送给 LLM
一个容易被忽略的细节:Gateway 在构建系统提示时支持 PII 脱敏。
_PII_SAFE_PLATFORMS = frozenset({
Platform.WHATSAPP, Platform.SIGNAL,
Platform.TELEGRAM, Platform.BLUEBUBBLES,
})
对于这些平台,build_session_context_prompt() 可以安全地把用户 ID / chat ID 替换成确定性的 SHA-256 哈希。关键判断标准不是"ID 是否就是手机号",而是这些平台不依赖原始 ID 参与 mention 语法,因此脱敏后不会破坏 Agent 的交互能力:
def _hash_sender_id(value: str) -> str:
return f"user_{hashlib.sha256(value.encode('utf-8')).hexdigest()[:12]}"
而 Discord 被排除在外,因为 Discord 的消息提及(mention)语法是 <@user_id>,LLM 需要真实 ID 才能正确 at 用户。
哈希是确定性的:同一个用户 ID 永远映射到同一个哈希值。这意味着 Agent 虽然看不到真实标识符,仍然可以跨轮次识别"这是同一个用户"。对于 WhatsApp、Signal 这类平台,手机号会在脱敏过程中被一并剥离;而像 Telegram 这样的场景,即使 user ID 本身不是手机号,也仍然可以安全哈希。路由层继续使用原始 ID------脱敏只发生在送往 LLM 的系统提示中。
总结:Gateway 的设计原则
回顾整个 Gateway 架构,有几个一致的设计原则:
-
归一化在边缘做,runtime 保持平台无关。 适配器把平台差异消化在自己内部,
MessageEvent是 Agent runtime 看到的唯一接口。 -
默认安全降级,按需覆写增强。 4 个抽象方法保证最小可用,十余个可选方法允许丰富体验。新平台的接入成本被拉到最低。
-
Session key 决定上下文边界。 一条确定性公式,根据 platform / chat / thread / user 的组合计算 key。线程内共享、群组内隔离的默认策略,映射了真实的用户心智模型。
-
后台执行 + 中断支持。 消息处理在后台 task 中异步运行,新消息随时可到达。
/stop可以中断运行中的 Agent,pending queue 保证消息不丢。 -
投递永远有兜底。 origin → home channel → local,三级 fallback 确保 cron 输出不会凭空消失。
如果你正在做类似的事情------让同一个 Agent 能力跑在多个消息平台上------Hermes 的这套方案提供了一个经过实战验证的参考。核心不是"写 20 个适配器",而是定义好那个居中的协议层:统一的消息格式、确定性的 session 隔离、渐进式的能力覆写。有了这一层,每多接一个平台,就只是再填一个适配器。