开源自托管 Agent 项目 Hermes Agent 的插件系统:目录分域与扩展边界
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
这个项目的插件系统不是一套扩展点,而是十几套并列的扩展点,每套有自己的抽象基类、自己的发现路径、自己的选择规则;plugin.yaml 那个清单文件只是其中很薄的一层,它管不了注册,甚至有些字段根本没有代码去读。 先说清消歧:这里讲的是 NousResearch/hermes-agent 这个 MIT 许可的开源自托管 Agent 项目(LICENSE 署名 Nous Research),不是 Nous Research 的 Hermes 系列开源模型,也不是任何同名的商标或第三方库。下面所有目录名、字段名、方法名都能在这个仓库里直接搜到。
如果你想看的是通用方法论——扩展点该怎么切、加载器该怎么读目录、微小扩展点值不值得单独开一层——站内已经写过 pi 的扩展加载机制 和 pi 的扩展微点设计,另一个项目的插件与集成分层见 ECC 的插件与集成。那三篇讲的是”该怎么想”,本篇只做一件事:把 hermes-agent 这个具体仓库的目录、清单、两组后端插件的真实契约摊开,让你能对着文件逐条核。
一、顶层目录先按”能力域”分家,而不是按”插件”平铺
打开 plugins/,你看到的不是一堆插件,而是 18 个顶层目录,绝大多数是能力域的名字:browser、memory、model-providers、web、image_gen、video_gen、platforms、context_engine、cron_providers、dashboard_auth、observability、teams_pipeline,以及少数几个自成一体的功能插件,比如 disk-cleanup、security-guidance、spotify、kanban、google_meet、hermes-achievements。域目录里再一层一层是具体实现:plugins/memory/honcho/、plugins/browser/firecrawl/、plugins/web/tavily/。
这个分法的直接后果是:插件不是一种东西。plugins/browser/ 下的三个目录(browser_use、browserbase、firecrawl)实现的是同一个抽象基类,彼此可替换、同时只有一个生效;而 disk-cleanup 这种平铺插件注册的是自己的工具和钩子,跟谁都不冲突。加载器把这两类分开对待,靠的是清单里的 kind 字段。
hermes_cli/plugins.py 里有一个 _VALID_PLUGIN_KINDS 集合,允许的值是 standalone、backend、exclusive、platform、model-provider。加载逻辑按 kind 分流,规则相当直白,也相当值得记住:
kind: backend且是仓库自带的,自动加载——它随项目发货,必须开箱可用;同域内谁真正服务调用,由config.yaml里对应的选择键决定。kind: platform且是自带的,注册为延迟加载器。原因写在注释里:这些聊天平台适配器在模块级导入各家重量级 SDK,一次性全加载会给每次命令行调用都加上好几秒,包括根本不碰网关的普通对话。于是只在真正要用某个平台时才导入。kind: model-provider只登记清单、不导入模块,交给providers/__init__.py自己的发现流程去做。注释解释了原因:重复导入会造出两个 profile 实例,破坏”后写者赢”的覆盖语义。kind: exclusive直接跳过,由所属域自己的发现系统负责。- 剩下的(
standalone、用户自己装的 backend、pip 入口点插件)全部是opt-in,要出现在plugins.enabled允许列表里才加载。
再往上一层,通用扫描器在扫自带目录时带了一个跳过名单:memory、context_engine、platforms、model-providers。前三个里的 memory 和 context_engine 有独立发现路径,platforms 是往下多扫一层的域。也就是说,通用插件加载器压根不负责记忆插件,这一点直接决定了下一节的所有细节。
二、清单文件管什么,以及它不管什么
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 通用插件加载器 | 扫四类来源、解析清单、按 kind 分流、执行 plugins.enabled 允许列表 | hermes_cli/plugins.py | 自己写平铺插件、或插件没被加载要查原因时 |
| 浏览器后端抽象基类 | 规定云端浏览器会话的生命周期与元数据契约 | agent/browser_provider.py | 接一个新的云浏览器厂商时 |
| 浏览器后端实现 | 各家的建会话/关会话/兜底清理 | plugins/browser/{browser_use,browserbase,firecrawl}/provider.py | 照抄最接近的一个当模板时 |
| 记忆插件发现 | 扫自带与用户目录、按路径加载模块、只允许一个生效 | plugins/memory/__init__.py | 换记忆后端、或自己写一个时 |
| 记忆配置声明层 | 用纯数据描述配置面板的字段、类型、密钥归属 | plugins/memory/config_schema.py | 想让自己的记忆后端在界面里出现配置项时 |
| 某个后端的配置声明 | 具体字段清单,可当范例 | plugins/memory/honcho/config_schema.py | 写自己的声明文件时 |
| 插件共享并发原语 | 线程安全的懒加载单例,防重复初始化 | plugins/plugin_utils.py | 插件里要缓存一个昂贵客户端时 |
| 模型提供商域说明 | 讲清这一域的发现顺序与覆盖语义 | plugins/model-providers/README.md | 加一家模型服务商时 |
先看清单本身。plugin.yaml 是纯 YAML,字段很少,但读它的代码只认一部分。加载器构造清单对象时取的是:name、version、description、author、requires_env、provides_tools、provides_hooks,外加从路径推出来的注册键和从内容推出来的 kind。注册键这一点有个细节值得留意:平铺插件的键就是目录名,域目录下的插件键是”域/名”这种带斜杠的形式,plugins.enabled 的查找同时接受这个键和裸名字,好让老配置继续能用。
再看不被读的部分。plugins/browser/browser_use/plugin.yaml 整个文件只有七行,除了 name、version、description、author、kind: backend,还声明了一个 provides_browser_providers 列表。浏览器插件文档把这个字段写进了目录结构说明,也写进了收尾检查清单,但通篇没交代谁会去读它。把整个 Python 树搜一遍,provides_browser_providers 只出现在三个浏览器插件自己的清单和文档里,没有任何代码读它;同域的 provides_web_providers 也一样。
这不是在挑错,而是在提醒一个判断:在这套设计里,清单是元数据与文档,真正让扩展生效的是 register(ctx) 那一行。browser_use 的 __init__.py 全文十四行,核心就是导入 provider 类,然后 ctx.register_browser_provider(BrowserUseBrowserProvider())。你写清单写错一个可选字段,多半什么都不会发生;register 里少调一次注册,插件就是彻底不存在。这也顺带说明为什么”照抄最近的那个目录”在这个仓库里是被官方推荐的做法——契约的重心在代码里,不在声明里。
记忆插件这边的清单更能说明问题。plugins/memory/*/plugin.yaml 普遍带一个 hooks: 列表,写着自己实现了哪些生命周期钩子(比如会话结束时的那个、上下文压缩之前的那个)。但通用加载器认的字段名是 provides_hooks,而记忆域的发现代码打开 plugin.yaml 时只取一个 description。所以那份 hooks: 清单实际上是给人看的文档。同样,记忆插件的清单里普遍没有 kind,是加载器在检测到用户目录里有记忆 provider 时把它强制归为 exclusive,靠的是一个文本启发式:读 __init__.py 的前 8192 字节,看里面有没有出现注册函数名或者基类名。
三、记忆后端:单选、独立发现、声明式配置面板
记忆域的规则一句话讲完:同一时间只能有一个外部记忆后端生效,由 config.yaml 里的 memory.provider 指定。文档把理由写得很直接——避免工具清单膨胀和后端互相打架。自带的实现目录有 honcho、mem0、supermemory、hindsight、holographic、openviking、retaindb、byterover,从描述看覆盖了云端语义检索、本地 SQLite 事实库、知识图谱等不同路子。记忆本身该怎么分层是另一个话题,站内单独写过 Agent 记忆分层,这里只谈扩展点。
发现流程有几个工程细节,值得任何写过插件加载器的人看一眼:
模块按路径加载,不走包导入。 加载器用文件路径直接构造模块规格,还会顺手把插件目录里其它 .py 文件预先登记到 sys.modules,好让插件内部的相对导入(比如从自己的 store 模块里取类)能解析。用户自己装的 provider 更麻烦:它们被挂到一个合成命名空间 _hermes_user_memory 下面,避免和自带 provider 在 sys.modules 里撞名,而这个不存在于磁盘上的父包必须先注册一个空壳,否则插件里任何一句相对导入都会直接报”没有这个模块”。
两种注册姿势都兜住。 优先找模块里的 register(ctx),用一个假的上下文对象去接注册调用;接不到再退一步,遍历模块属性找基类的子类并实例化。这种”两条路”的写法在插件系统里很常见,代价是失败时的报错会变模糊。
配置面板是声明出来的,不是画出来的。 每个 provider 可以在自己目录里放一个 config_schema.py,导出一个 CONFIG_SCHEMA 数据对象,声明字段的键、标签、类型、默认值、说明、是否密钥、下拉选项、分组。界面侧只有一个通用渲染器,服务端只有一对通用的读写接口,所以”给一个新后端加配置界面”是纯声明,不用写任何专用组件。字段类型是一组常量:文本、下拉、密钥、布尔、数字、JSON。存储后端也是常量,目前有扁平 JSON 和某个后端专有的宿主块两种,由 web 服务端按声明分派。
这个声明层里有三处设计取向写在注释里,我认为比字段表本身更有参考价值:
第一,密钥和普通配置分开存。标为密钥类型的字段写进环境变量存储,用声明里的 env_key 定位,而且接口只回一个”是否已设置”的标志,值不会被读回来。
第二,声明文件也按路径加载,而且明确禁止它导入别的东西。注释给的理由是:插件的 __init__.py 会把 Agent 运行时拉进来,而这些东西不能进 web 服务器进程。所以声明文件只允许从声明模块本身导入常量和数据类。这是一条很硬的分层约束,写自己的 provider 时容易违反。
第三,缓存键是解析出来的文件路径,不是 provider 名字。注释说得很清楚:用户装的插件是按配置档隔离的,一个档的查询结果绝不能拿去回答另一个档。另外加载失败时不缓存——否则一个空面板会一直钉在那里,直到重启。这两条都是被真实问题教出来的写法。
拿 honcho 那份声明当例子看,它把字段分成”连接""身份""会话”等分组,只把一小部分标为 inline 放进紧凑面板,其余的收进完整配置弹窗;旧版命令行或环境变量写过的值靠 aliases 和 env_fallbacks 兼容读取,不用再为某个 provider 写专门的迁移代码。会话映射策略给了四个选项:每次会话独立、按工作目录共享、按 git 仓库共享、全局共享一个。这类选项的含金量在于它暴露了记忆的作用域,而作用域选错了,“记住的东西”就会跨项目串味。
另外,记忆域还允许 provider 自带命令行子命令:目录里放一个 cli.py,定义一个接 argparse 子解析器的注册函数,就会挂到主命令下面。有一条门禁值得知道:这些子命令只在你的 provider 是当前生效的那个时才出现,没配它的用户在帮助里看不到。
四、浏览器后端:只做会话生命周期,不做浏览
浏览器域的抽象基类在 agent/browser_provider.py,它的开头就把边界划死了:provider 不实现浏览。它实现的是会话生命周期——建一个远程浏览器会话,交回一个 CDP 的 websocket 地址,用完拆掉。项目自己的浏览器栈连上那个地址去驱动页面,于是每个新接的厂商都免费获得完整的一套浏览器工具调用。
必须实现的是一个身份属性、一个可用性检查,以及三个生命周期方法:建会话、关会话、紧急清理;显示名是可选的,不写就回落到身份名,另外还有两个保留旧接口名字的兼容壳,转调新方法。契约里有几条读起来就知道是踩过坑的:
- 可用性检查不许发网络请求。文档写明它在工具注册时跑,并且每次重绘工具列表都会跑。你在这里塞一个健康检查探测,代价会摊到所有人每一次命令上。
- 建会话返回的元数据字典有固定形状:会话名、会话 ID、CDP 地址、启用了哪些特性标志,另有一个可选的托管网关计费键。其中会话 ID 那个键名是遗留命名,基类文档里两次强调不要改名——它保存的是当前 provider 的会话 ID,跟具体是哪家无关,改了名调度侧就得做形状翻译。这是一个很典型的取舍:保留一个名字不对的键,换来调度器是纯注册表查找、零厂商分支。
- 建会话可以抛异常:凭据缺失抛一种,网络或接口失败抛另一种,由调度侧呈现给用户。但关会话和紧急清理绝不能抛——出错就记日志返回失败,让清理循环继续往下走。这条区分很重要,很多人会顺手在清理路径上抛异常,结果一个会话清不掉就把整批都卡住。
还有一条工程标准写在浏览器插件文档结尾,我觉得值得单独拎出来:如果一个后端不能通过项目自己的工具配置界面被选中并配好,就算没做完——“让用户自己去设一个环境变量”不算集成。为此基类给了一个可选的设置声明方法,让你申报要哪些密钥、在界面里显示什么标签、以及安装后要触发的那个补齐依赖的动作。这跟上一节记忆域的声明式配置是同一种思路:把”怎么被配置”也当成扩展点契约的一部分,而不是丢给文档。
三个自带实现的复杂度是递增的,文档直接给了抄写建议:最简单的那个当骨架,最复杂的那个演示带特性标志(隐身、代理、保活)以及在付费特性不可用时优雅回落。
五、两组插件放一起看,不对称的地方才是真边界
把记忆和浏览器摆在一起,会看到几处刻意的不一致,这些不一致就是这套扩展点的边界形状。
用户插件的落盘位置不一样。 浏览器后端的用户插件放在用户目录下的 plugins/browser/<名字>/,跟自带布局镜像对称;记忆 provider 的用户插件却是扁平放在用户目录的 plugins/<名字>/,靠前面说的文本启发式认出来。同一个仓库里两种约定,写文档时容易讲错,写脚本时更容易放错位置。
同名冲突的胜负规则相反。 记忆域是自带优先,用户目录里同名的会被跳过;模型提供商域在自己的 README 里写的是用户插件覆盖自带,走注册函数里”后写者赢”的语义,明确说了”丢个文件进去就能替掉内置的”。同一个词”覆盖”,在两个域里方向相反。你要改一个自带后端的行为,先确认自己在哪个域。
声明的深度不一样。 模型提供商这一域的扩展面已经收得非常薄:新增一家的动作就是建目录、在初始化文件里构造一个 profile 对象并注册、再写一份四五行的清单,README 明确列出鉴权、配置、模型列表、体检、元数据、运行时等一串模块全部自动接线,“其它什么都不用改”。遇到某家的怪癖,就在 profile 子类里覆盖对应钩子(README 点了两个真实例子:一家要改请求体额外字段,另一家要翻译思考配置)。而记忆和浏览器两域仍然要求你实现一个有多个方法的抽象基类。同一个项目里,不同能力域的扩展成本差一个数量级,这也是判断”我这个需求该落在哪”的第一手依据。
共享层是被具体 bug 逼出来的。 plugins/plugin_utils.py 只做一件事:给插件作者两个线程安全的懒加载原语,一个装饰零参工厂,一个是手动持有的槽位,供那种”实例取决于传入配置”的取值函数用。文件开头的注释把它要防的坑写得很具体:最常见的插件写法是进程级懒单例,两个线程同时进来都过了空判断,都跑了一遍昂贵初始化,后写的把先写的覆盖掉,先那个客户端占的连接、文件句柄、后台线程就泄漏了。注释还点明为什么这在这个项目里够得着——多线程会话共享一个进程,来源包括被委派的工具调用、后台 worker,以及自我改进那条分叉。槽位的语义是”第一份配置赢”,工厂抛异常则不缓存、下次重试。这个模块目前的真实使用者是 plugins/memory/honcho/client.py,它用槽位持有客户端,并配了一个重置函数。
一个只有一处调用方的共享模块,通常说明它是从事故里长出来的,而不是先设计好的。抄这个仓库的插件写法时,这个文件是少数值得整段读完的。
六、边界与代价:它明确不管什么
它不管让不同域的扩展点长成一个样。 前面那些不对称不是过渡状态,而是每个域按自己的需要各自演化的结果。好处是每个域的契约都贴着自己的问题;代价是没有”一个插件模型”可学,你每加一类东西都要重新读一份文档和一个基类。想要统一插件模型的人会在这里觉得别扭。
它不管替你判断把数据交出去的后果。 记忆插件文档里有一段写得很老实:传给后端的会话上下文可能包含用户与助手消息、助手的工具调用、以及工具返回结果,而工具调用和结果里可能有文件路径、命令输出或者其它工作区数据;云端后端应当说明哪些内容离开了设备。换句话说,装一个云记忆后端等于把一部分工作区内容持续外送。这件事系统不会拦你,只提醒插件作者去写清楚。相关的权限思路见 最小权限的 Agent 设计。
它不管自带后端要不要经你同意才跑。 自带的 backend 类插件是自动加载的,理由是随项目发货就该开箱可用。它们不服务调用只是因为可用性检查没通过(没配密钥、没装可选依赖),不是因为你没启用。真正的 opt-in 只作用于你自己装的插件和平铺插件。如果你的安全模型是”默认什么都不加载”,这里需要你自己额外收紧。
它不管多个记忆后端并存。 单选是硬规则,第二个注册进来会被拒绝并告警。想同时用两套记忆,这个扩展点不给你。
它不管第三方产品插件进主仓库。 项目在插件指南里明确写了政策:集成别人产品或项目的插件(观测后端、厂商 SaaS 连接器、分析面板、付费服务对接)走独立插件仓库分发,用户装到自己的目录或者用 pip 入口点,不并入核心树。文档说明这是耦合与维护的取舍,不是质量门槛。你要做商业集成,一开始就该按独立仓库规划,别指望合并进去。
它不管声明层被绕过之后的后果。 配置声明文件禁止导入别的模块这一条是纯约定,没有机制拦你。你在声明文件里图省事导一下自己的客户端模块,本地大概能跑,但会把 Agent 运行时拉进 web 服务器进程,坏在什么时候不好预测。
还有一层是这个项目的整体形态决定的:它常驻在你的机器上、开终端执行命令、可以接你的聊天软件账号、往磁盘写文件、访问外部服务。插件系统是它扩张这些能力的入口,你往 plugins/ 里放的每一个目录,拿到的都是同一个进程的权限。装第三方插件跟装一个能在你机器上执行命令的程序没有区别,值不值得看代码,自己判断。
七、上手与避坑清单
先定域,再动手。 会踩是因为大多数人默认”插件就是插件”,于是照着通用插件指南写了个记忆后端,结果通用加载器根本不扫记忆域,插件安安静静地不存在。避法:先确认你要加的东西属于哪个能力域,去读那个域的专属文档和基类,通用插件指南只适用于自带工具与钩子的平铺插件。
清单别指望它生效。 会踩是因为清单看着像配置,让人以为写了就会被读。实际被解析的字段就那几个,域专属的声明字段基本是文档。避法:把清单当元数据写,把契约写在注册函数和基类实现里;插件没生效时先去看 register() 有没有被调用,而不是反复改 YAML。
注册键别只写裸名字。 会踩是因为域目录下的插件注册键带斜杠,你在允许列表里只写目录名,看起来对但可能匹配不上(兼容裸名是额外照顾,不是主路径)。避法:写允许列表时用路径推出来的那个形式,“域/名”。
可用性检查里别发网络请求。 会踩是因为”检查可用”这个语义天然让人想去 ping 一下接口。避法:只做便宜检查——环境变量在不在、可选依赖能不能导入;把真正的连通性验证留给建会话或专门的体检命令。
清理路径别抛异常。 会踩是因为写关闭逻辑时习惯把异常往上抛。避法:关会话和紧急清理里全部捕获,记日志、返回失败,让批量清理继续;只在建会话里抛,让调用侧有机会告诉用户是缺凭据还是网络挂了。
遗留键名别顺手改。 会踩是因为那个会话 ID 的键名带着某一家厂商的前缀,看起来像没清理干净的历史包袱,新写实现时很想改成中性名字。避法:照抄。基类文档两处强调它是跨厂商通用的,改了会话就管不住。
同步写入别阻塞。 会踩是因为记忆后端往往要发网络请求或者跑一次模型抽取,直接写在每轮同步里,整条对话就被拖住。避法:文档给的做法是把工作放进守护线程,并在启动新线程前给上一个留一小段汇入时间。
存储路径别硬编码家目录。 会踩是因为看到默认目录就写死了,结果多配置档之间共享了同一份数据。避法:用初始化时传进来的那个宿主目录参数,或者用项目提供的取宿主目录函数。
别在配置声明文件里导入运行时。 会踩是因为没人拦你,本地还能跑。避法:声明文件只从声明模块导入常量与数据类,其余一律不碰。
改自带后端前先确认覆盖方向。 会踩是因为”用户覆盖自带”听起来很自然,但记忆域是自带优先,你放在用户目录的同名 provider 会被直接跳过,而你会以为自己的改动生效了。避法:动手前把那一域的发现顺序读一遍。
收个尾
这套插件系统给出的判断其实很朴素:扩展点的粒度应该跟着能力域走,而不是跟着”插件”这个抽象走。模型提供商这一域已经薄到只剩一个数据对象加一份清单,浏览器域收成三个生命周期方法,记忆域因为要参与提示装配、检索、写入、压缩前抢救等多个时机,就必须留一个方法更多的基类。你在自己的系统里切扩展点时,可以照着这个顺序自问:这个域里的实现之间是可替换的,还是可叠加的?同时能生效几个?配置界面能不能声明出来?失败时谁兜着?
要继续往下读,建议按这个顺序:先 hermes_cli/plugins.py 的模块开头注释和 kind 分流那一段,把加载规则装进脑子;再 agent/browser_provider.py,它是这个仓库里契约写得最清楚的一份基类文档;然后 plugins/memory/config_schema.py,看声明式配置怎么把界面成本降到零;最后 plugins/plugin_utils.py,读一个从事故里长出来的共享模块。四个文件读完,你再看任何一个 plugins/ 子目录,都能几分钟内判断它属于哪一类、生效条件是什么、以及它把什么风险带进了你的机器。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 怎么把运行轨迹变成训练与评测数据 和 开源自托管 Agent 项目 Hermes Agent 的两套前端分工。