9 · 插件系统

plugins/,351 个文件。这一章讲第三方怎么在不改核心代码的前提下扩展系统

9.1 三个发现来源

~/.hermes/plugins/     用户级 —— 对这台机器上的所有项目生效
./.hermes/plugins/     项目级 —— 跟着代码仓库走,团队共享
pip entry points       包级   —— 用 pip install 安装某个包就自动生效
来源适用场景特点
用户级「我个人习惯用的工具」不进版本控制,不影响别人
项目级「这个项目需要的能力」提交进仓库,团队共享。新同事拉下代码就有
包级「发布给社区用的插件」走标准的 Python 包分发渠道,可以有版本、依赖、更新

「pip entry points」(入口点)是 Python 的标准机制:一个包可以在自己的元数据里声明「我提供了某类插件」,安装后框架自动发现,不需要用户手动注册。

9.2 插件能提供什么

plugins/
├── platforms/          22 个聊天平台适配器          → 第 1 章
├── memory/             8 种记忆后端                 → 第 8 章
├── context_engine/     上下文引擎                   → 第 7 章
├── model-providers/    模型供应商                   → 第 11 章
├── cron_providers/     定时任务提供者               → 第 12 章
├── kanban/             看板协作                     → 第 10 章
├── browser/            浏览器自动化
├── image_gen/          图像生成
├── video_gen/          视频生成
├── observability/      可观测性
├── dashboard_auth/     仪表盘认证
├── security-guidance/  安全指引
├── google_meet/        会议集成
├── spotify/            音乐
├── teams_pipeline/     Teams 流水线
├── disk-cleanup/       磁盘清理
├── hermes-achievements/ 成就系统
├── web/                网页相关
├── plugin_storage.py   ★ 插件的持久化存储
└── plugin_utils.py     ★ 插件工具函数

插件通过一套「上下文 API」向系统注册三类东西:工具、钩子、命令行子命令

9.3 最重要的设计:区分「可叠加能力」与「互斥策略」

这是插件系统设计里最容易漏掉的一个区分
可叠加能力互斥策略
例子 工具插件、平台适配器、图像生成后端 记忆提供者、上下文引擎
装 3 个会怎样 有 3 份能力,互不冲突。装得越多能力越强 系统不知道该听谁的
系统的处理 全部加载 「单选」—— 只允许激活一个

两处源码明确了这个约束:

// 上下文引擎(第 7 章)
「Selection is config-driven: `context.engine` in config.yaml.
  Default is "compressor" (the built-in). Only one engine is active.」

// 记忆提供者(第 8 章)
「The MemoryManager enforces a one-external-provider limit to prevent
  tool schema bloat and conflicting memory backends.
  Only one external provider runs at a time.」

如果不做这个区分会怎样

用户装了两个上下文引擎,都实现了 should_compress() 引擎 A:「该压缩了」 引擎 B:「不用压缩」 系统怎么办? · 听 A 的? → B 的作者会说「我的引擎被无视了」 · 都跑一遍?→ 压缩两次,第二次拿到的是第一次的结果,行为完全不可预测 · 随机选? → 每次行为不一样,无法排查 ★ 没有正确答案。所以必须在"装第二个"的那一刻就报错, 而不是留到运行时产生诡异行为。

在你自己的插件系统里,这个区分要在设计阶段就做出来。

判据很简单:「装两个的语义是『两份能力』还是『两个互相矛盾的答案』?」

如果是后者,就必须标记为单选,并且在加载第二个时明确报错。留到运行时会产生极难排查的问题 —— 因为症状是「行为和预期不一样」,而不是「报错了」。

9.4 插件的存储

plugins/plugin_storage.py

插件需要持久化自己的数据(配置、缓存、状态)。系统提供统一的存储抽象,而不是让每个插件自己决定往哪写。

这解决三个问题:

9.5 插件与工具集的联动

回顾第 4 章的工具集解析函数:

def _get_plugin_toolset_names() -> Set[str]        # 插件提供的工具集
def _get_registry_toolset_aliases() -> Dict[str, str]
def resolve_toolset(name, visited=None, *, include_registry: bool = True)

插件不只是「注册几个工具」,它可以注册一整个工具集。这样用户在配置里写 toolsets: [my_plugin_set] 就能启用插件的全部能力,而不用逐个列工具名。

那个 include_registry 参数说明:系统区分「内置工具集」和「注册表里的工具集(含插件的)」,某些场景下可以只解析内置的 —— 大概是为了在插件还没加载完时也能工作,或者为了安全场景下排除第三方工具。

9.6 插件钩子

插件可以挂钩到系统的几个关键点:

钩子时机与用途
pre_llm_call调模型前。注意它的约束:只能「追加到用户消息」,从不重写消息列表 —— 这是为了保护提示词缓存的前缀(第 7 章的 select_context() 才可以替换列表)
post_tool_call工具执行后。可以观察、记录、告警
agent:step智能体每走一步(第 3.6 节的步骤回调)
审批钩子第 5 章的 _fire_approval_hook,让插件参与安全决策
网关钩子gateway/hooks.py + gateway/builtin_hooks/,消息进出的节点

pre_llm_callselect_context 的权限差别

第 7 章的原文:「Unlike the pre_llm_call plugin hook (which appends to the user message and intentionally never rewrites the list, to preserve the cache prefix), select_context() may replace the message list.」

译:不同于 pre_llm_call 插件钩子(它只追加到用户消息,并且刻意从不重写列表,以保护缓存前缀),select_context() 可以替换整个消息列表。

这是一个分级授权的设计:

· 普通插件pre_llm_call)→ 只能追加,权限小,不会破坏缓存
· 上下文引擎select_context)→ 可以整个替换,权限大 —— 但它是「单选」的,用户明确选择了它,而且它的输出仍要过所有校验器

权限的大小和「用户是否明确授权」成正比。一个可以随便装十个的普通插件,不该有替换整个上下文的权力。

9.7 MCP:另一条扩展路径

除了插件,Hermes 还支持 MCP(Model Context Protocol,模型上下文协议)—— 一个让智能体接入外部工具服务的开放标准。

tools/mcp_tool.py       378 KB    MCP 客户端
mcp_serve.py            38 KB     ★ 把 Hermes 自己作为 MCP 服务暴露
optional-mcps/          65 个文件  内置的可选 MCP 服务
插件MCP
语言必须是 Python任何语言(跨进程通信)
进程同进程独立进程或远程服务
能力深 —— 可以挂钩子、注册工具集、替换核心策略浅 —— 主要是提供工具和资源
崩溃影响可能影响主进程隔离,不影响
生态Hermes 专属跨智能体产品通用

mcp_serve.py 那一项值得注意:Hermes 可以把自己作为 MCP 服务暴露出去。也就是说另一个智能体可以把 Hermes 当成一个工具来调用 —— 这让「智能体调用智能体」成为可能。

9.8 这套扩展体系的整体形状

按「权限大小」和「侵入深度」排列: ┌─ 最深、权限最大 ──────────────────────────────┐ │ 上下文引擎 / 记忆提供者 │ │ → 可替换核心策略,但【单选】,配置驱动 │ ├───────────────────────────────────────────────┤ │ 平台适配器 / 模型供应商 / 定时任务提供者 │ │ → 实现一个明确的抽象基类,可叠加 │ ├───────────────────────────────────────────────┤ │ 普通插件 │ │ → 注册工具、钩子、命令;钩子只能追加不能替换 │ ├───────────────────────────────────────────────┤ │ MCP 外部服务 │ │ → 跨进程、跨语言,只能提供工具和资源 │ ├───────────────────────────────────────────────┤ │ 技能(第 13 章) │ │ → 纯 Markdown 文本,零代码 │ └─ 最浅、权限最小 ──────────────────────────────┘

这个梯度是有意义的:扩展的门槛和它能造成的破坏成正比。

写一个技能只需要写 Markdown,任何人都能做,最多让智能体多知道一些操作步骤。
写一个上下文引擎需要理解整套契约(缓存不变式、生命周期、版本兼容),而它一旦出错会让整个系统的上下文管理失效。

系统通过「不同层级用不同机制」把这个梯度显式化了 —— 而不是提供一个万能的插件接口让所有人都能做所有事。