本章目录In this chapter
1 · 网关层
gateway/,99 个文件,其中 run.py 单文件 1.55 MB —— 是整个项目最大的模块。这一章讲一个常驻进程如何让 22 个聊天软件都能触达同一个智能体。
1.1 它解决什么问题
先说清楚没有网关会怎样。假设你想让智能体支持 Slack 和 Telegram 两个平台:
网关就是把这些共性抽出来的那一层。它是一个长期不关闭的后台进程,同时连着所有平台,负责:
- 把各平台千奇百怪的消息格式归一化成统一的事件对象
- 做会话路由 —— 判断这条消息属于哪一场对话
- 做用户授权
- 分发斜杠命令
- 驱动定时任务的时钟
- 管理智能体实例的缓存(同一场会话复用同一个实例)
- 把回复按各平台的规则发出去
1.2 平台适配器抽象基类
gateway/platforms/base.py,333 KB。这是那份「插座标准」—— 规定任何一个平台想接进来必须提供什么。
核心思路:把差异抽象成「能力查询方法」
class BasePlatformAdapter(ABC):
# —— 消息长度限制 ——
def max_message_length_for_chat(self, chat_id: str) -> int
def message_len_fn(self) -> Callable[[str], int]
def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]
# —— 流式输出能力 ——
def supports_draft_streaming(self, ...) -> bool
def prefers_fresh_final_streaming(self, ...) -> bool
def streaming_overflow_limit(self) -> Optional[int]
async def send_draft(self, ...)
# —— 权限模型 ——
def enforces_own_access_policy(self) -> bool
def authorization_is_upstream(self) -> bool
# —— 渲染 ——
def render_message_event(self, event, sink) -> None
def format_tool_event(self, event, *, mode: str = "all", ...) -> str
def format_tool_preview(self, preview: "ToolPreview") -> str
def set_status_text(self, chat_id: str, text: Optional[str]) -> None
hermes-agent/gateway/platforms/base.py
对比两种写法:
# 写法 A:在主流程里判断平台
if platform == 'slack':
max_len = 40000
elif platform == 'discord':
max_len = 2000
elif platform == 'telegram':
max_len = 4096
elif platform == 'sms':
max_len = 160
# ... 22 个分支,而且这样的判断散落在几十处
# 写法 B:问适配器
max_len = adapter.max_message_length_for_chat(chat_id)
写法 A 的问题不是难看,是「新增平台要改几十个地方」。而且你不知道要改哪几个 —— 只能靠搜索和运气。
写法 B 下,新增一个平台 = 实现一组能力声明。主流程一行都不用动。而且抽象基类会强制你实现所有必需的方法,漏一个直接报错。
这就是这一层能撑住 22 个平台的根本原因。
被抽象出来的差异有多大
| 能力 | 各平台的实际差异 |
|---|---|
能不能编辑已发消息supports_draft_streaming |
Slack / Telegram / Discord:能。所以可以流式更新同一条消息,用户看到文字逐渐生长。 短信:不能。只能发新消息 —— 流式输出根本没法做,只能等全部生成完再发一条。 |
偏好重发最终版吗prefers_fresh_final_streaming |
有些平台编辑消息会触发通知或者把消息顶到最新,体验很差。这类平台宁可「流式过程用草稿,最终结果发一条新的」。 |
单条消息长度上限max_message_length_for_chat |
Discord 2,000 / Telegram 4,096 / 短信 160 / Slack 约 40,000。 注意这个方法接收 chat_id 参数 —— 因为同一平台的不同频道可能有不同限制(比如企业版和免费版)。 |
长度怎么算message_len_fn |
更微妙:「长度」的定义各平台不同。有的按字符数,有的按 UTF-16 码元,有的把 emoji 算多个。所以返回的是一个计算函数而不是一个数字。 |
平台自己管权限吗enforces_own_access_policy |
企业微信 / 飞书:有完整的企业权限体系,能进到这个群的人就是被授权的。 IRC:完全没有权限概念。任何人都能发消息,必须由智能体自己做授权。 |
授权在上游吗authorization_is_upstream |
有些接入方式(比如通过企业网关代理)已经在上游做过身份认证,智能体这里不该再问一遍。 |
1.3 消息事件的归一化
各平台的消息格式完全不同。网关把它们统一成一个事件对象:
class MessageType(Enum): ...
class ProcessingOutcome(Enum): ...
class MessageEvent:
...
def is_command(self) -> bool # 是不是斜杠命令
def get_command(self) -> Optional[str] # 命令名
def get_command_args(self) -> str # 命令参数
class CachedMedia:
def context_note(self) -> str # 附件的上下文说明
class TextDebounceState: ... # ★ 文本去抖动
class SendResult: ...
class EphemeralReply(str): # ★ 带过期时间的临时回复
def __new__(cls, text: str, ttl_seconds: Optional[int] = None): ...
def text(self) -> str: ...
两个值得单独说的类
TextDebounceState(文本去抖动状态)解决的是这个场景:
EphemeralReply(临时回复)是一个继承自 str 的类,带一个过期时间。用于那些「说完就该消失」的消息 —— 比如「正在思考…」这类状态提示。在支持的平台上,这类消息会在一段时间后自动删除,不污染聊天记录。
让它继承 str 是一个很实用的设计:所有原本处理字符串的代码不用改,照常工作;只有关心过期时间的代码才去检查它是不是 EphemeralReply。
1.4 网关主循环里的那些防御机制
run.py 里的函数名本身就是一份「长驻进程会遇到什么问题」的清单:
① 卫生冷却:防止压缩失败反复重试
def _hygiene_cooldown_for_failure(...)
def _reset_hygiene_failure_streak(gateway, session_key: str) -> None
def hygiene_compaction_recovered(...)
def _record_hygiene_cooldown(...)
「卫生」(hygiene)在这里指的是后台自动做的上下文整理。如果某个会话的压缩连续失败,就给它一个冷却期,别再反复尝试 —— 否则会持续消耗资源而且持续失败。
_reset_hygiene_failure_streak(重置失败连击)这个命名说明它跟踪的是连续失败次数,成功一次就清零。这和第 3 章会讲的幂等锁是同一个思路。
② 瞬时网络错误识别
def _is_transient_network_error(exc: BaseException) -> bool
长驻进程必须区分「网络抖了一下」和「真的出问题了」。前者应该静默重试,后者应该告警。如果不区分,用户会被无意义的网络波动告警淹没,最终屏蔽所有告警。
③ 用户可见文本的密钥脱敏
def _redact_gateway_user_facing_secrets(text: str) -> str
def _redact_approval_command(cmd: "str | None") -> str
网关会把一些内部信息发到聊天窗口(错误提示、审批请求)。这些文本里可能夹带 API 密钥、令牌、密码。而聊天窗口是多人可见的、会被搜索的、会被归档的。所以发出去之前必须脱敏。
注意有两个脱敏函数:一个给通用文本,一个专门给「待审批的命令」。因为命令里的密钥形态不一样(可能在环境变量赋值里、在参数里、在管道里)。
④ 供应商错误的用户友好化
def _gateway_provider_error_reply(text: str) -> str
def _looks_like_gateway_provider_error(text: str) -> bool
def _sanitize_gateway_final_response(platform: Any, text: str) -> str
模型服务返回的错误信息通常是给开发者看的(含堆栈、内部错误码、请求 ID)。直接发到聊天窗口对用户毫无意义。所以要识别出来并翻译成人话。
⑤ 重启守卫
gateway/restart.py
gateway/restart_loop_guard.py ← ★ 重启风暴防护
重启风暴是长驻进程的经典故障:进程崩溃 → 自动重启 → 启动时又崩 → 又重启 → 无限循环。每秒重启几十次,日志被刷爆,CPU 打满。
守卫的做法通常是:记录最近的重启次数和间隔,如果在短时间内重启太多次,就停止自动重启并保持崩溃状态 —— 让人来看一眼。
⑥ 内存监控与优雅排空
gateway/memory_monitor.py 内存占用监控
gateway/agent_cache_pressure.py 智能体缓存压力
gateway/drain_control.py ★ 优雅排空
gateway/disk_status.py 磁盘状态
智能体缓存压力:网关会缓存智能体实例(同一场会话复用同一个)。但每个实例都持有完整的消息历史 —— 几十场活跃会话就能吃掉几 GB 内存。所以需要监控压力并在必要时淘汰不活跃的实例。
优雅排空:要关闭网关时,不能直接杀掉 —— 手头正在处理的消息会丢。正确做法是「停止接受新消息,把手头的处理完,然后退出」。
⑦ 投递账本
gateway/delivery.py
gateway/delivery_ledger.py ← ★ 投递账本
gateway/rich_sent_store.py
gateway/message_timestamps.py
gateway/dead_targets.py ← 死目标(比如被删掉的频道)
投递账本记录「哪条回复已经发到哪里了」。它防的是重复投递:网络超时时你不知道消息发出去没有,重试可能导致用户收到两遍。有账本就能判断。
死目标处理的是:智能体要回复的那个频道被删了、机器人被踢出群了、用户拉黑了。这些投递会永久失败,必须识别出来并停止重试,否则重试队列会无限增长。
1.5 状态消息与进度反馈
def _status_template_to_regex(template: str) -> str
def _gateway_compression_progress_notices_enabled() -> bool
def _prepare_gateway_status_message(platform, event_type: str, message: str) -> Optional[str]
async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata)
def render_notice_line(notice) -> str
智能体在长任务中需要给用户反馈「我还在干活」。但在聊天软件里做这件事很微妙:
- 发太多状态消息 → 刷屏
- 不发 → 用户以为死了
- 用编辑同一条消息的方式更新 → 只在支持编辑的平台可行
_status_template_to_regex 这个函数值得注意:它把状态消息的模板转成正则表达式。用途应该是「识别聊天记录里哪些消息是自己之前发的状态消息」,从而可以更新或删除它们 —— 因为平台 API 返回的消息 ID 可能已经丢失,只能靠内容匹配来找。
1.6 中断与恢复
def _is_fresh_gateway_interruption(...)
def build_resume_recovery_note(...)
def _prepare_resume_pending_message(...)
def _build_replay_entry(...)
def _startup_restore_drain_timeout_secs() -> float
def _auto_continue_freshness_window() -> float
这一组函数处理的是:网关重启后,那些「进行到一半」的会话怎么办。
这一组函数是「长驻」和「命令行工具」的本质差别所在。
命令行工具崩了就崩了,用户重新跑一次。长驻进程崩了之后必须自己判断「刚才干到哪了、该不该继续、要不要告诉用户」 —— 而且判断依据只有磁盘上的状态。
这部分逻辑在架构图上完全看不见,但它占了网关模块相当大的比重。
1.7 网关内置钩子
gateway/hooks.py
gateway/builtin_hooks/
网关层有自己的钩子系统,让插件可以在消息进出的关键节点插入逻辑。和第 9 章讲的插件系统配合,构成了「不改核心代码就能扩展网关行为」的能力。
1.8 这一层的代价
1.55 MB 的单文件是这一层最直观的代价。它不是设计缺陷,是「22 个平台 × 每个平台的边角情况」组合爆炸的必然结果。
从函数名可以看出,这个文件里塞了:冷却策略、错误分类、脱敏、状态渲染、时间戳处理、审批转发、进度线程解析、平台显示配置、Telegram 特有的提及格式转换(_telegramize_command_mentions)……
每一个都很小,但加起来就是 1.55 MB。而且它们大多无法被抽象掉 —— 因为它们本质上就是在处理外部世界的不规则性。
1 · The Gateway Layer
gateway/: 99 files, including run.py, a single 1.55 MB file — the largest module in the whole project. This chapter is about how one always-on process lets 22 chat apps reach the same agent.
1.1 The Problem It Solves
Start with what happens without a gateway. Say you want the agent to support two platforms, Slack and Telegram:
The gateway is the layer that pulls all of that shared logic out. It is a background process that never shuts down, connected to every platform at once, and it is responsible for:
- Normalizing the wildly different message formats of each platform into a uniform event object
- Session routing — deciding which conversation a message belongs to
- User authorization
- Dispatching slash commands
- Driving the clock for scheduled tasks
- Managing the cache of agent instances (the same session reuses the same instance)
- Sending replies out according to each platform's rules
1.2 The Platform Adapter Abstract Base Class
gateway/platforms/base.py, 333 KB. This is the “wall-socket standard” — it specifies what any platform must provide in order to plug in.
The core idea: turn the differences into “capability query methods”
class BasePlatformAdapter(ABC):
# —— message length limits ——
def max_message_length_for_chat(self, chat_id: str) -> int
def message_len_fn(self) -> Callable[[str], int]
def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]
# —— streaming capabilities ——
def supports_draft_streaming(self, ...) -> bool
def prefers_fresh_final_streaming(self, ...) -> bool
def streaming_overflow_limit(self) -> Optional[int]
async def send_draft(self, ...)
# —— permission model ——
def enforces_own_access_policy(self) -> bool
def authorization_is_upstream(self) -> bool
# —— rendering ——
def render_message_event(self, event, sink) -> None
def format_tool_event(self, event, *, mode: str = "all", ...) -> str
def format_tool_preview(self, preview: "ToolPreview") -> str
def set_status_text(self, chat_id: str, text: Optional[str]) -> None
hermes-agent/gateway/platforms/base.py
Compare the two styles:
# Style A: branch on the platform in the main flow
if platform == 'slack':
max_len = 40000
elif platform == 'discord':
max_len = 2000
elif platform == 'telegram':
max_len = 4096
elif platform == 'sms':
max_len = 160
# ... 22 branches, and checks like this scattered across dozens of places
# Style B: ask the adapter
max_len = adapter.max_message_length_for_chat(chat_id)
The problem with Style A isn't that it's ugly. It's that “adding a platform means editing dozens of places.” And you don't know which places — you find them by grep and by luck.
Under Style B, adding a platform = implementing one set of capability declarations. Not a single line of the main flow changes. And the abstract base class forces you to implement every required method; miss one and it errors out immediately.
This is the fundamental reason this layer can carry 22 platforms.
How big are the differences being abstracted away?
| Capability | How the platforms actually differ |
|---|---|
Can it edit a sent message?supports_draft_streaming |
Slack / Telegram / Discord: yes. So the same message can be updated in a stream, and the user watches the text grow. SMS: no. It can only send new messages — streaming is simply not possible; you wait for the full response and send it in one go. |
Does it prefer a fresh final message?prefers_fresh_final_streaming |
On some platforms, editing a message fires a notification or bumps the message to the top, which is a bad experience. Those platforms would rather “use a draft while streaming, then send the final result as a new message.” |
Per-message length capmax_message_length_for_chat |
Discord 2,000 / Telegram 4,096 / SMS 160 / Slack roughly 40,000. Note that this method takes a chat_id argument — because different channels on the same platform can have different limits (enterprise vs. free tier, for instance). |
How length is countedmessage_len_fn |
Subtler: the very definition of “length” differs by platform. Some count characters, some count UTF-16 code units, some count an emoji as several. So it returns a counting function, not a number. |
Does the platform manage permissions itself?enforces_own_access_policy |
WeCom / Feishu: they have a full enterprise permission system; anyone who can get into the group is, by definition, authorized. IRC: no concept of permissions at all. Anyone can send a message, so the agent must do authorization itself. |
Is authorization upstream?authorization_is_upstream |
Some integration paths (through an enterprise gateway proxy, say) have already authenticated the identity upstream, and the agent shouldn't ask again. |
1.3 Normalizing Message Events
Every platform has a completely different message format. The gateway unifies them into one event object:
class MessageType(Enum): ...
class ProcessingOutcome(Enum): ...
class MessageEvent:
...
def is_command(self) -> bool # is this a slash command?
def get_command(self) -> Optional[str] # command name
def get_command_args(self) -> str # command arguments
class CachedMedia:
def context_note(self) -> str # context note for an attachment
class TextDebounceState: ... # ★ text debouncing
class SendResult: ...
class EphemeralReply(str): # ★ a temporary reply with an expiry
def __new__(cls, text: str, ttl_seconds: Optional[int] = None): ...
def text(self) -> str: ...
Two classes worth calling out
TextDebounceState handles this scenario:
EphemeralReply is a class that inherits from str and carries an expiry time. It is for messages that “should disappear once said” — status hints like “Thinking…”. On platforms that support it, these messages delete themselves after a while and don't clutter the chat history.
Inheriting from str is a very practical design: every piece of code that already handles strings keeps working unchanged; only the code that cares about expiry checks whether it is an EphemeralReply.
1.4 The Defensive Mechanisms in the Gateway Main Loop
The function names in run.py are, on their own, a checklist of “what goes wrong for a long-running process”:
① Hygiene cooldown: stop retrying failed compaction
def _hygiene_cooldown_for_failure(...)
def _reset_hygiene_failure_streak(gateway, session_key: str) -> None
def hygiene_compaction_recovered(...)
def _record_hygiene_cooldown(...)
“Hygiene” here means the automatic context tidying done in the background. If compaction keeps failing for a session, give it a cooldown period rather than trying again and again — otherwise it burns resources continuously and fails continuously.
The name _reset_hygiene_failure_streak tells you it tracks a count of consecutive failures, reset to zero on the first success. This is the same idea as the idempotency lock in Chapter 3.
② Recognizing transient network errors
def _is_transient_network_error(exc: BaseException) -> bool
A long-running process has to tell “the network hiccuped” apart from “something is actually broken.” The former should retry silently; the latter should alert. If you don't distinguish them, users drown in meaningless alerts about network blips and eventually mute all alerts.
③ Redacting secrets from user-visible text
def _redact_gateway_user_facing_secrets(text: str) -> str
def _redact_approval_command(cmd: "str | None") -> str
The gateway sends some internal information into the chat window (error messages, approval requests). That text may carry API keys, tokens, and passwords. And a chat window is visible to many people, searchable, and archived. So it must be redacted before it goes out.
Note that there are two redaction functions: one for general text, one specifically for “the command awaiting approval.” Secrets in a command take different shapes (in an environment variable assignment, in an argument, in a pipeline).
④ Making provider errors user-friendly
def _gateway_provider_error_reply(text: str) -> str
def _looks_like_gateway_provider_error(text: str) -> bool
def _sanitize_gateway_final_response(platform: Any, text: str) -> str
Error messages from model services are usually written for developers (stack traces, internal error codes, request IDs). Sent straight into a chat window, they mean nothing to the user. So they have to be recognized and translated into plain language.
⑤ Restart guard
gateway/restart.py
gateway/restart_loop_guard.py ← ★ restart storm protection
A restart storm is the classic failure of a long-running process: the process crashes → auto-restarts → crashes again on startup → restarts again → forever. Dozens of restarts per second, logs flooded, CPU pinned.
The usual guard: record recent restart counts and intervals, and if there are too many restarts in a short window, stop auto-restarting and stay down — so a human takes a look.
⑥ Memory monitoring and graceful drain
gateway/memory_monitor.py memory usage monitoring
gateway/agent_cache_pressure.py agent cache pressure
gateway/drain_control.py ★ graceful drain
gateway/disk_status.py disk status
Agent cache pressure: the gateway caches agent instances (the same session reuses the same one). But every instance holds the full message history — a few dozen active sessions can eat several GB of memory. So the pressure has to be monitored, and inactive instances evicted when necessary.
Graceful drain: when the gateway needs to shut down, you can't just kill it — messages currently being processed would be lost. The right way is “stop accepting new messages, finish what's in hand, then exit.”
⑦ Delivery ledger
gateway/delivery.py
gateway/delivery_ledger.py ← ★ delivery ledger
gateway/rich_sent_store.py
gateway/message_timestamps.py
gateway/dead_targets.py ← dead targets (a deleted channel, say)
The delivery ledger records “which reply has already been sent where.” It guards against duplicate delivery: on a network timeout you don't know whether the message went out, and a retry could make the user see it twice. With a ledger, you can tell.
Dead targets covers the cases where the channel the agent wants to reply to has been deleted, the bot has been kicked from the group, or the user has blocked it. These deliveries will fail forever, and they must be recognized and retries stopped, or the retry queue grows without bound.
1.5 Status Messages and Progress Feedback
def _status_template_to_regex(template: str) -> str
def _gateway_compression_progress_notices_enabled() -> bool
def _prepare_gateway_status_message(platform, event_type: str, message: str) -> Optional[str]
async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata)
def render_notice_line(notice) -> str
During a long task the agent needs to tell the user “I'm still working.” But doing that inside a chat app is delicate:
- Too many status messages → spam
- None → the user thinks it died
- Updating by editing the same message → only works on platforms that support editing
The function _status_template_to_regex deserves a note: it converts a status-message template into a regular expression. The likely purpose is “recognize which messages in the chat history are status messages I sent earlier,” so they can be updated or deleted — because the message IDs returned by the platform API may already be lost, and matching on content is the only way to find them.
1.6 Interruption and Recovery
def _is_fresh_gateway_interruption(...)
def build_resume_recovery_note(...)
def _prepare_resume_pending_message(...)
def _build_replay_entry(...)
def _startup_restore_drain_timeout_secs() -> float
def _auto_continue_freshness_window() -> float
This group of functions handles: after the gateway restarts, what happens to the sessions that were “halfway through”?
This group of functions is where the essential difference between “always-on” and “command-line tool” lives.
When a command-line tool crashes, it crashes; the user runs it again. When a long-running process crashes, it has to work out on its own “where was I, should I continue, should I tell the user” — and the only evidence it has is the state on disk.
None of this logic shows up on an architecture diagram, but it accounts for a substantial share of the gateway module.
1.7 Built-in Gateway Hooks
gateway/hooks.py
gateway/builtin_hooks/
The gateway layer has its own hook system, letting plugins insert logic at the key points where messages enter and leave. Together with the plugin system in Chapter 9, this gives you the ability to “extend gateway behavior without changing core code.”
1.8 What This Layer Costs
A single 1.55 MB file is the most visible cost of this layer. It isn't a design flaw; it is the inevitable result of the combinatorial explosion of “22 platforms × every platform's edge cases.”
You can tell from the function names what's packed into this file: cooldown policy, error classification, redaction, status rendering, timestamp handling, approval forwarding, progress-thread parsing, per-platform display config, Telegram-specific mention format conversion (_telegramize_command_mentions)…
Each one is small, but together they add up to 1.55 MB. And most of them can't be abstracted away — because what they are doing, fundamentally, is handling the irregularity of the outside world.