开源自托管项目 Hermes Agent 的加平台清单:反推适配层标准
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
判断一个 Agent 项目的适配层设计得好不好,最快的办法是问它:接一个新平台,需要改多少处核心代码。 NousResearch 的 hermes-agent(MIT 许可,一个常驻在自己机器上、连着聊天软件账号干活的开源 Agent;注意它和 Nous Research 的 Hermes 开源模型系列不是一回事,也和若干同名商标无关)把这个问题的答案写进了仓库里:gateway/platforms/ADDING_A_PLATFORM.md。这份文件同时给出了两个答案——插件路径是「零改动核心」,内建路径是一张编号到 16 项的清单。两个答案摆在一起,本身就是一份可以拿来量自己项目的尺子。
站内已有几篇相邻的文章分工不同:跨平台扩展的三种设计取向对比 讲的是通用方法论层面的取舍,pi 的扩展加载器怎么设计 拆的是另一个项目的加载机制,Agent 工具接口该怎么设计 谈的是工具层而不是传输层。本篇只做一件事:把这个具体项目的加平台清单逐项落到实处,看它每一项在防什么,然后反推出适配层的判断标准。
一、清单为什么值得当标准读,而不是当教程读
大多数项目的「如何新增一个 X」文档写的是理想路径:继承基类、实现几个方法、注册一下。这份文件不一样,它在内建路径的开头就写明了立场——这是给核心贡献者的清单,每一项都是真实的集成点,漏掉任何一项都会造成功能损坏、特性缺失或行为不一致。
于是这 16 项就变成了一份负面清单:它把「一个消息平台在这套架构里到底需要被多少个子系统认识」全部摊开了。你会看到平台名要出现在枚举里、出现在适配器工厂里、出现在两张授权字典里、出现在系统提示词的平台提示里、出现在工具集合里、出现在定时任务投递映射里、出现在独立发送工具的映射里、出现在频道目录的会话发现列表里、出现在 CLI 状态显示里、出现在配置向导的平台列表里。清单末尾甚至给了一条自查手法:拿已有平台名去核心目录里做一次全局搜索,凡是提到别的平台却没提到你的文件,就是漏掉的集成点。
这条自查手法暴露了架构的真实形状:这些集成点之所以需要人工逐个补,是因为它们都是硬编码的映射表,而不是从注册表反查出来的。 判断标准第一条由此得出——看一个项目加平台要改几处映射表,就知道它的扩展点是「注册」还是「散落」。而这份文件推荐社区走插件路径的理由也正是这个:插件系统会自动接手适配器创建、配置解析、用户授权、定时任务投递、发送消息路由、系统提示词提示、状态显示、网关初始化这一整串事情。
二、两条路径分别长什么样
插件路径的形状很朴素:在配置目录下的插件目录(或仓库自带的 plugins/platforms/ 下)放一个 plugin.yaml 和一个 adapter.py,适配器继承 BasePlatformAdapter,在 register(ctx) 入口里调 ctx.register_platform() 完成注册。以 plugins/platforms/irc/adapter.py 为例,注册调用里传的是平台名、显示标签、适配器工厂、依赖检查函数、配置校验、必需环境变量列表、安装提示、交互式安装函数,以及一组可选钩子。撰稿时仓库的 plugins/platforms/ 下已经有二十来个平台目录,Telegram、Discord、Slack、Matrix、飞书、钉钉、企业微信、IRC、LINE、Teams、Google Chat、Mattermost、邮件与短信通道都在其中。
那组可选钩子是插件路径最值得抄的部分,因为每一个都对应一类「不补就会静默失效」的边角:
- 环境变量启用钩子在适配器构造之前就把配置项填好。文件里说得很直白:没有它,只用环境变量配置的部署在网关状态命令里根本不显示,直到 SDK 真的把适配器实例化出来为止。
- YAML 配置转换钩子让插件自己拥有配置文件的键名结构,而不是逼核心配置模块为每个平台长一段样板代码。它还顺带说明了优先级实现方式:允许改写进程环境变量,但要用「未设置才写」的守卫来保住环境变量高于配置文件的次序。
- 定时投递环境变量钩子指定这个平台的「默认投递频道」变量名,让定时任务的投递目标不必去改调度器里那几个硬编码集合。
- 独立发送钩子处理「定时任务不在网关进程里跑」的情况。没有它会怎样?文件给了准确的失败信息:任务会正常触发,但真正发送时返回一句「这个平台没有活着的适配器」。这一条特别值得记,因为它是典型的半成功故障——链路看着通了,最后一跳掉了。
- 插件声明文件里的必需/可选环境变量条目会自动进到 CLI 配置模块的可选变量表里,让安装向导拿到描述、提示语、是否密码字段和链接。
文件还写了两种进阶结构。一种是平台有硬性时间窗约束时(它举的例子是 LINE 的一次性回复令牌有效期很短、WhatsApp 的会话窗口按小时算),适配器可以覆写「持续输入中」的那个方法,在到达某个阈值时插一条中途气泡,同时强调必须继续调用父类实现让心跳不中断、并在 finally 里拆掉自己的副任务;plugins/platforms/line/adapter.py 里能看到这个覆写连着一个请求缓存状态机和一个中断处理的覆写。另一种是同一个平台有两套传输方式(非官方接口对官方接口、轮询对长连接、库 A 对库 B),此时的正确结构是两个适配器共用一个行为混入类:仓库里 gateway/platforms/whatsapp_common.py 的混入类持有门控、允许名单、提及解析、广播过滤和该平台风味的格式转换,两个适配器各自只管传输,并且注册成不同的平台枚举值,好让网关同时跑两个号。混入类必须写在基类列表的第一位,否则它的消息格式化方法压不住基类的通用默认实现——这是那种不写下来就一定会有人踩的顺序坑。
三、基类替你兜了什么,你必须自己补什么
gateway/platforms/base.py 是这套设计的重心,撰稿时它是一个六千八百多行的单文件。清单要求你实现的必需方法只有七个:构造函数、连接、断开、发送文本、发送输入状态、发送图片、获取会话信息。发送文件、语音、视频、动图、本地图片这几个在基类里有默认桩。真正体现设计意图的是第三组,交互式方法:多选提问、危险命令批准、斜杠命令确认、模型选择、有限选项选择——这些没实现时会优雅退化成纯文本,实现了就变成可点按钮。清单还额外要求:按钮回调标识要沿用跨适配器共用的那套前缀约定,这样网关侧的解析函数不用改就能工作。
这里可以提炼第二条判断标准:适配层的方法应该分成三档——不实现就跑不起来的、不实现就少个能力的、不实现就退化成文字的。 三档混在一个必需列表里,新平台的接入成本会被虚高地放大。
基类另一半的价值在一批能力开关上,它们都是布尔类型的类属性,网关侧统一用取属性带默认值的方式读,调用点不做平台分支。撰稿时能读到的包括:是否渲染围栏代码块、输入状态是否以文字形式呈现、是否支持在一轮结束后异步推送通知、发送时是否自己切分长消息、用户能输入的命令前缀是什么、是否支持某种把可续接定时任务平铺进频道的形态、以及这个平台上是否有真人在等着回答。最后这个开关的注释写得很实在:网关重启后的自动恢复轮次要据此决定说什么,有人在的平台报告一下并询问下一步,没人在的事件型平台必须直接把没做完的活干完,否则一句「已恢复,请问接下来做什么」就等于把任务丢了。
还有两个属性专门管访问控制的语义分层,一个表示「这个适配器自己在入口就按配置策略拦过一遍」,一个表示「这条入站消息的授权是上游可信通道已经做完的」。基类注释把二者的区别掰得很细,并且明确前者不等于「已授权」:因为那些适配器的默认策略是开放转发所有发送者,所以网关只在它的有效策略确实是名单限制时才信任它,绝不为「开放」这种取值放行——那会变成网络暴露面上的失败即放开。想把权限这一层单独看,可以对着 Agent 的最小权限设计 一起读。
四、组成部分对照表
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 加平台清单 | 给出插件路径与 16 项内建集成点 | gateway/platforms/ADDING_A_PLATFORM.md | 决定走插件还是改核心 |
| 适配器基类 | 生命周期、会话来源构造、消息分派、媒体缓存、长文切分、能力开关 | gateway/platforms/base.py | 写适配器的第一行 |
| 插件注册入口 | 在 register(ctx) 里调 ctx.register_platform() 挂上工厂与钩子 | plugins/platforms/irc/adapter.py | 走插件路径落地 |
| 插件声明 | 平台名、标签、必需与可选环境变量描述 | plugins/platforms/irc/plugin.yaml | 让配置向导认识你的平台 |
| 行为混入类 | 两种传输模式共用的门控、名单、提及解析、格式转换 | gateway/platforms/whatsapp_common.py | 同一平台有两套接口 |
| 时间窗定制 | 覆写输入状态循环,在阈值处插中途交互 | plugins/platforms/line/adapter.py | 平台有硬性回复时限 |
| 事件型入口 | 签名校验、限流、幂等、模板渲染、跨平台投递 | gateway/platforms/webhook.py | 接的是服务而不是人 |
| 平台注册表 | 判断一个投递目标名是否已被插件注册 | gateway/platform_registry.py | 投递目标是插件平台 |
| 平台测试 | 配置加载、初始化与名单解析、发送与切分、插件注册与独立发送的回归 | tests/gateway/test_irc_adapter.py | 交付前 |
五、把 webhook 适配器当反面参照读
gateway/platforms/webhook.py 是同一个基类下最不像聊天平台的实现:它跑一个 HTTP 服务接收外部服务的推送,把负载套进提示词模板变成一次 Agent 运行,再把结果投递回原处或另一个平台。它值得单独看,因为它把「对面没有人」这件事的全部代价都写出来了。
安全部分是逐层的。每条路由必须有签名密钥,启动时校验;有一个明确写成测试专用的跳过校验取值,一旦它和非回环绑定地址同时出现,进程直接拒绝启动,注释里管这叫部署级的自伤脚枪,宁可早崩。请求处理是先认证后读体:先看内容长度头,再用服务器级上限约束分块传输的请求体,读完还要对实际字节数再判一次。签名校验分支覆盖了几家常见的头部约定,其中通用签名有新旧两版,新版把时间戳绑进被签名内容以防重放,旧版只签请求体、仍然接受但按路由告警一次。最见功力的是新旧版之间的关系:只要新版签名头出现就锁定新版校验,即使时间戳缺失或过期也直接拒绝,绝不回落到旧版——注释解释了原因,迁移期的发送方通常两版头一起发,如果不完整的新版能回落,攻击者只要在重放时把时间戳头摘掉,就能用仍然有效的旧版签名把请求降级回新版本要堵的那个洞。
可靠性部分同样成对出现:从几个常见的投递标识头里取一个当去重键,落进带过期时间的缓存,重复投递直接按重复返回;每条路由按固定窗口限流;投递配置按会话标识存起来,注释特意写明取用时不能弹出,否则中途的状态提示消息会把条目消耗掉,导致最终回复静默降级成只写日志。绑定地址的默认值也有故事:默认不指定主机、让事件循环按解析出的地址族各建一个监听套接字,注释里记了两次失败——只绑 IPv4 会让纯 IPv6 内网里的地址不可达,而只绑 IPv6 在内核把套接字设成仅 v6 的主机上又会打断本地回环健康检查。
还有一处结构性的收尾:这个适配器把「非交互恢复」开关设为假,并且覆写了运行完成钩子,在真正跑完时关闭这条一次性会话。注释解释了为什么必须挂在这个钩子上——消息分派本身是即发即忘的,包在它外面等于在运行开始前就关了会话;而不关会话的后果是数据库里的行永远没有结束时间,清理逻辑只回收有结束时间的行,于是这些会话无限堆积。这类「幽灵行」是常驻服务特有的账:它不影响任何一次运行的正确性,只在几个月后以磁盘占用的形式结算。
六、边界与代价
这套设计放弃的东西也很清楚,值得如实写出来。
第一,插件路径的「零改动核心」是靠一堆可选钩子换来的,而钩子是可以不填的。填不填不影响启动,只影响某条链路在特定条件下是否成立——环境变量配置不显示在状态里、定时投递最后一跳失败、配置向导没有提示语。这类缺失不会报错,只会在几周后以「怎么没收到」的形式出现。换句话说,它把改核心的编译期问题换成了运行期的静默缺失。
第二,内建路径那 16 项是人工纪律,不是机制保障。清单自己给的兜底手段是全局搜索加人工比对,这说明架构没有一个能强制枚举全部集成点的地方。对照着看,你自己项目里如果也有类似的映射表,至少可以给它们加一个「注册表里的每个平台都必须在这些表里出现」的测试。
第三,这个项目的形态本身就意味着风险,不粉饰:它常驻在你的机器上、连着你的聊天账号、能开终端执行命令、往磁盘写文件、访问外部服务。加一个平台等于给这台机器多开一个入站面。仓库在这方面确实有对应设计——按平台的允许名单与「允许所有人」的显式开关、危险命令的批准按钮、日志里对敏感标识的脱敏、webhook 路由的签名与限流——但这些都需要你正确配置才生效。清单里那条「在所有日志输出中脱敏电话号码等标识」不是可选项,它专门指出脱敏要加在集中的脱敏模块里,而不是只在你自己适配器的日志里做,否则消息在其他子系统的日志里还是明文。
第四,它明确不管几件事。适配层不负责平台侧的账号合规与接口条款,你用非官方通道接一个平台,被封号的风险由你承担。它也不负责你的模型服务开销:各家服务商的计费与限制规则不同且会调整,以官方最新说明为准,这套架构只是把「事件进来就跑一轮」这件事变得容易,跑多少轮是你自己的配置结果——那条跳过 Agent 直接投递的路由选项就是为「这类事件不值得花一次推理」准备的逃生口。
第五,跨平台投递依赖网关进程里活着的目标适配器。走独立发送钩子的那条路是为了绕开这个依赖,但它是另一套代码路径,能力上和完整适配器不对等。
七、上手与避坑清单
- 先决定路径,再写第一行代码。 会踩是因为照着内建清单动手最直觉——枚举、工厂、映射表一路加下去,等到要提交才发现这是给核心贡献者的路。怎么避:默认走插件路径,只有当你确实需要改基类行为时才考虑动核心,并且把改动理由写在提交说明里。
- 可选钩子按「会不会静默失效」逐个过一遍,而不是按「要不要」。 会踩是因为钩子的名字听起来都像增强项。怎么避:把它们当成四道验收——只用环境变量配置时状态命令能否看到这个平台、定时任务投递能否落地、投递发生在网关进程之外时能否落地、配置向导里的提示语是否成句。
- 混入类必须放在基类列表第一位。 会踩是因为顺序写反了程序照样启动,只是消息格式退回通用默认,表现为「格式偶尔不对」这种最难定位的症状。怎么避:写完立刻发一条带格式的消息实测,不要靠读代码确认。
- 按钮回调标识沿用既有前缀约定。 会踩是因为自己定一套更顺手,然后发现按钮点了没反应。怎么避:先看已实现按钮的那几个适配器里回调标识怎么拼,照抄结构;顺带注意有些平台对回调数据长度有硬限制,标识要短。
- 长消息切分交给基类,但要设对平台的长度上限和度量单位。 会踩是因为默认按码点数算,而有的平台按 UTF-16 码元计数,表情和部分中日韩字符各占两个单位,于是你以为没超限的消息被平台拒了。怎么避:确认平台的计数单位,需要时覆写长度函数,别自己写切分——基类的切分会在跨代码块处补上闭合围栏并带上分片指示。
- 自己的消息要过滤掉,同步与回显消息也要。 会踩是因为不过滤就会自问自答成回复循环,而且这种循环在计费上很贵。怎么避:连接后先只读不回,确认事件流里哪些是自己发的。
- 流式连接必须做带抖动的指数退避重连。 会踩是因为固定间隔重连在平台侧故障恢复时会形成同时重连的尖峰。怎么避:退避加随机抖动,并把重连日志留出来。
- 不要相信清单里的文件路径一定还在原地。 这条是我核对目录时发现的:清单在参考实现处提到的几个文件里,撰稿时只有官方云接口那个适配器和共享混入还在
gateway/platforms/下,Telegram、Discord 以及另一个 WhatsApp 传输的实现都已经搬进plugins/platforms/各自的目录。这不是文档写错,是插件化在推进而清单没同步——恰好也印证了插件路径是活跃路径。怎么避:动手前先列一遍目录,以代码为准。 - 测试按清单给的那几类先写。 会踩是因为适配器测试很容易只测「发出去了吗」。怎么避:按它列的方向补——平台枚举取值、从环境变量加载配置、适配器初始化时的名单解析与默认值、辅助函数、会话来源的序列化往返、授权映射里有没有你、发送工具的路由映射里有没有你。撰稿时
tests/gateway/目录下以 test_ 开头的文件有五百多个,附近就能找到同类样本照着写。
收尾:一份自检清单
把这篇的结论压成能对着自己项目问的几句话:新增一个平台需要改几处核心映射表;接入方法有没有分成必需、可选、可退化三档;有没有一个开关能让调用点不做平台分支;平台的硬约束(时间窗、长度单位、按钮数据长度)在哪一层被吸收;非交互入口有没有独立的认证、限流、幂等三件套;一次性会话有没有在真正结束的那个钩子上收尾。
想继续往下读,顺序建议是:先把 gateway/platforms/ADDING_A_PLATFORM.md 当目录读一遍,再挑 plugins/platforms/irc/adapter.py 这个依赖最少的插件从头看到注册入口,然后带着问题去 gateway/platforms/base.py 里查具体能力开关的注释——那些注释里往往写着某个默认值背后的一次真实故障。安全边界相关的部分,可以配合 MCP 的安全边界怎么划 一起对照,两者面对的都是「让外部输入触发本地执行」这同一类问题。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的 gateway 和 开源 Agent 项目 Hermes Agent 的消息分档路由怎么做。