开源自托管 Agent 项目 Hermes Agent 的三层工具收纳

2026-07-30

本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。

工具多了以后真正贵的不是调用,而是「摆在桌面上」——每一轮请求都要把所有工具的 JSON schema 原样带上,哪怕这一轮一个都用不到。 这篇讲的是 NousResearch 开源的自托管常驻 Agent 项目 hermes-agent(MIT 许可证,LICENSE 署名 Nous Research),不是 Nous Research 那套同名的开源模型系列,也不是任何同名商标或同名库。它的仓库里对这个问题的答案不是「压缩描述」,而是把工具的存在感拆成三层:注册表管「谁存在」,工具集管「这次会话给谁」,工具搜索桥管「摆出来的是完整 schema 还是一行名字」。

站内已经有几篇相邻的文章,分工先说清楚:pi 的工具层设计 讲的是另一个项目的工具抽象,工具描述怎么写 是跨项目通用的写法方法论,MCP 工具数量怎么控 是协议侧的取舍。本篇不重复这些,只做一件事:把 hermes-agent 这个具体仓库里的三层实现读出来,落到文件、函数、配置键上,让你能自己打开对照。

一、这三层各解决什么问题

先把地图铺开。三层不是同一个问题的三种做法,而是三个不同阶段的闸门,出错时的症状也不一样。

组成部分它负责什么对应仓库位置你什么时候会碰到它
工具注册表收集每个工具的 schema、handler、所属工具集、可用性探测函数,是全过程唯一的工具真相源tools/registry.py写插件、排查「工具不存在」
内置工具发现扫描 tools/ 目录下的顶层 .py,只导入那些在模块顶层真的调用过 registry.register(...) 的模块tools/registry.py 里的 discover_builtin_tools新加的工具文件没出现在列表里
工具集定义把工具名分组,组之间可以用 includes 互相组合,决定一次会话的候选集合toolsets.pyTOOLSETS_HERMES_CORE_TOOLS想收紧某个接入渠道能干什么
装配与过滤按启用/禁用的工具集解析出工具名,跑可用性检查,必要时重写动态 schemamodel_tools.py_compute_tool_definitions调试「这个工具为什么没出现」
工具搜索桥把可延迟的工具换成 tool_search / tool_describe / tool_call 三件套,并按预算渲染目录清单tools/tool_search.pyMCP 服务器一多、上下文吃紧
工具集分布表批量数据生成时按概率抽样出工具集组合toolset_distributions.pyDISTRIBUTIONS跑批量任务造数据,而不是线上服务

顺序值得记一下:注册表决定上限,工具集决定这次给多少,工具搜索决定给出来的这些以什么形态摆。三层任何一层没配对,模型看到的工具列表都会和你以为的不一样。

二、注册表:把「谁存在」和「谁能用」分开

tools/registry.py 的模块文档把导入链画得很直白:注册表本身不 import 任何工具模块,工具模块反过来 import 它,model_tools.py 再 import 注册表和全部工具模块。这是为了躲循环导入,也顺带把注册表做成了一个没有下游依赖的叶子。

每个工具通过 registry.register() 声明自己,ToolEntry 上挂的字段决定了它后续被怎么对待:toolset 决定它属于哪一组,check_fn 是可用性探测,requires_env 记录它需要哪些环境变量,max_result_size_chars 单独限制它的返回体量,is_async 让注册表在派发时自动桥接协程,dynamic_schema_overrides 是个零参回调,在每次取定义时把运行时相关的字段覆盖进去——注释里给的例子是委派工具的描述必须反映用户当前配置的并发子任务数与派生深度,否则模型会被告知错误的限制。

「谁存在」和「谁能用」在这里是分开的两件事。get_definitions() 只返回 check_fn() 通过(或者压根没有 check_fn)的工具。而 check_fn 探的往往是外部世界:Docker 守护进程在不在、某个驱动装没装、浏览器二进制有没有。这类探测会抖,所以注册表给它加了两层缓存策略:结果按约 30 秒的 TTL 缓存;同时记住每个探测函数最近一次返回 True 的时间,如果一次失败发生在上次成功后的一小段宽限期内,就当成抖动,返回上次的成功结果,并且不把这次失败写进缓存。代码注释把这个设计的动机写得很具体——一次超时的探测会让整组终端和文件工具从正在装配的 agent 上消失,最典型的表现是委派出去的子任务回报「工具 read_file 不存在」。宽限期过后仍然失败的,才会被如实记下来。

注册表还管两件很容易被忽略的事。一是同名覆盖:跨工具集的同名注册默认被拒绝,只记 error 日志不抛异常;要覆盖内置实现必须显式传 override=True,而且如果发起者是插件,还要在配置里给这个插件开 allow_tool_override 才放行,否则抛 PermissionError。授权是绑在 handler 定义所在的模块命名空间上的,注释里明确说这样绑是为了让 lambda 和嵌套函数没法把覆盖行为洗白。二是 deregister() 走同一套门禁——否则插件可以先把别人的工具摘掉,再往空位上普通注册一次,绕过整个覆盖检查;MCP 前缀的工具集被豁免,因为动态发现本来就要反复拆装自己的工具。

最后是一个 _generation 计数器,每次注册、注销、别名登记都自增。上层的定义缓存把它当版本号用,任何注册表变动都会让缓存失效。

三、工具集:一次会话到底给多少

toolsets.py 里的 TOOLSETS 是一张静态表,每项有 descriptiontoolsincludesincludes 让工具集能组合工具集,resolve_toolset() 递归展开,共享一个已访问集合来做环检测和菱形依赖去重,遇到已访问的名字就静默返回空表——注释说这要么是菱形(工具已经从另一条路收集过),要么是真环(跳过是安全的)。还有 all* 两个特殊别名,会把所有工具集展开合并,这样以后新增工具集不用改这里。

真正的核心是 _HERMES_CORE_TOOLS 这个共享列表:网页检索、终端与进程、文件读写与补丁、视觉与图像生成、技能管理、一整套浏览器操作、待办与记忆、会话历史检索、澄清提问、代码执行与任务委派、定时任务、智能家居、看板协作、桌面控制。所有接入渠道的工具集都建立在它之上——CLI、定时任务、各家聊天平台的工具集,tools 字段基本都是 _HERMES_CORE_TOOLS 或者它加上少量平台专属工具。改一处,所有渠道同步生效。

这份共享列表也意味着一件事你得心里有数:把这套 Agent 接到某个聊天账号上,默认拿到的就是能开终端、能写文件、能派子任务的完整能力面。仓库里唯一明显收紧过的是 webhook:

_HERMES_WEBHOOK_SAFE_TOOLS = [
    "web_search",
    "web_extract",
    "vision_analyze",
    "clarify",
]

上面那段注释解释得很清楚:webhook 事件可能来自不可信的第三方内容,比如公开 PR 的标题和评论,所以默认工具集刻意收窄,避免被提示注入撬动本地执行。这是个可以照抄的判断——按入口的可信度分配工具,而不是按功能齐全度。

另外两个细节容易踩。一个是「姿态型」工具集:codingposture: True 标记,是按会话选中的编码姿态,它把配对写代码时会用到的工具留下,把消息、语音、图像生成、音乐、智能家居、定时任务、桌面控制这些去掉。另一个是禁用逻辑:model_tools.py 里对以 hermes- 开头的平台包和姿态型工具集做特殊处理,禁用它们时只减去「非核心增量」,也就是 bundle_non_core_tools() 算出来的那部分。原因写在注释里——平台包本身包含核心工具,整包减掉会把其他启用工具集共享的核心工具一起抹掉,模型的工具列表直接空掉。

至于 toolset_distributions.py,名字容易让人误会。它是给批量数据生成跑的:DISTRIBUTIONS 里每个分布给若干工具集各配一个百分比,sample_toolsets_from_distribution() 对每个工具集独立掷一次骰子决定是否纳入,所以一次采样可能同时激活多个工具集;如果全都没中,就兜底选概率最高的那个。表里预置了若干种口味,从「只有网页检索」的极简一路到偏浏览器、偏终端、偏科研的组合。它解决的是「造训练/评测数据时让工具组合有分布」,不是线上请求的确定性路由——这点后面还会再提。

四、工具搜索桥:把 schema 换成三个入口

前两层是「给不给」,tools/tool_search.py 这层是「怎么摆」。它的模块文档开头就把不变量钉住了:toolsets._HERMES_CORE_TOOLS 里的核心工具永远不延迟,一次例外都没有。可延迟的只有 MCP 工具和非核心的插件工具。

激活后,模型看到的是三个桥工具替代原来那一堆:tool_search 按关键词搜可延迟目录,tool_describe 加载某个工具的完整参数 schema,tool_call 带参数调用它。这三个名字是保留名,注册表已有的同名保护会拒掉任何想占用它们的工具。

分层规则是 2026 年 7 月那版计划定下的,逻辑和直觉不太一样:只要存在任何一个可延迟工具,桥就激活——schema 一律延迟;随目录规模变化的是清单,不是激活决定。三档是这样:目录里一个可延迟工具都没有时是纯直通;清单能塞进预算时,桥的描述里嵌一份分组的「名字 + 短描述」目录,塞不进就退化成只列名字;连只列名字都超预算时,只留一行一个服务端的汇总(名字加工具数),个别工具只能靠搜索发现。

预算怎么算:min(listing_max_tokens, threshold_pct% × 上下文长度),拿不到上下文长度时百分比那条腿退回一个固定值。token 估算用的是最朴素的字符数除以 4,代码里把这个常量单独拎出来并解释了为什么故意偏向低估。关于上下文预算这件事本身怎么想,可以对照读 Agent 的上下文预算怎么定

退化是按服务端做的,这是我觉得这份实现里最值得学的一处。注释举的例子很具体:一个扁平 API 面的服务端可能挂着几千个工具,光名字就是几万 token 级别;如果全局退化,那么和它同时挂着的一个只有二十几个工具的小服务端,也会跟着丢掉自己的清单。所以实现是先全量、再全名字、再按渲染后体积从大到小逐个把服务端折叠成汇总行,中间任一步塞得下就停。排序键是「体积、名字」两级,对同一目录是确定的,渲染出来的块字节稳定——这一句是为提示缓存服务的:不稳定的排序会让每轮的请求前缀都不一样。

清单为什么必须存在,仓库文档给了理由:没有清单时被延迟的能力对模型是不可见的,实测里模型会转而用可见的核心工具硬顶(比如在终端里跑命令,而不去搜那个已经挂好的专用工具),或者干脆宣称这个能力不存在。清单把技能列表那套做法搬到了工具上——名字始终可见,参数 schema 才延迟。桥描述里对应的措辞也是照这个思路写的:如果名字出现在清单里,就不要声称它不可用;已经看到确切名字时可以跳过搜索,直接去取 schema。

检索用的是 BM25,索引的是拆过下划线的工具名、描述、顶层参数名,schema 主体故意不进索引。BM25 一无所获时退回工具名的子串匹配,注释说清了这是为了兜住零 IDF 的退化情形——查询词在每个文档里都出现时,BM25 会给不出正分。

三个细节值得单拎出来:

其一,目录跨轮无状态,每次装配都从当前工具定义列表重建。注释把这个决定归因于一次真实事故:会话级缓存的目录会和活的注册表漂移,症状是工具悄悄消失。

其二,桥的作用域被限制在会话自己的工具集范围内。scoped_deferrable_names() 算出这个会话合法可达的工具名集合,派发和展开两侧都拿它当门禁,所以一个被限制了工具集的子任务或渠道会话,没法借桥去发现或调用范围外的工具。

其三,模型经常「盲调」——只知道名字就直接 tool_call,把必填参数漏了。实现里有一道前置校验:只检查 schema 里 required 字段的键在不在,缺了就不派发,直接把参数 schema 回给模型,让它一个来回修好。注释写明只看键的缺失,不做类型检查、不拒绝 null,因为这些下游本来就有修复逻辑;校验器自己出异常时也一律放行,绝不阻断合法调用。

配置这一侧,仓库把这一层挂在 tools.tool_search 底下,一共六个键,值得逐个认一遍它们各自管什么:enabled 是总闸,三档 auto / on / off,前两档都是「只要存在可延迟工具就激活」,off 才是彻底关掉、全部工具直通;threshold_pct 是清单预算占模型上下文长度的百分比;search_default_limit 是模型调搜索时不带 limit 参数默认返回几条;max_search_limit 是模型自己能要到的条数上限;listing 决定要不要把那份分组的名字加短描述目录嵌进桥的描述里,同样是三档;listing_max_tokens 是清单的绝对上限,和百分比那条取小。除了这套字典写法,代码还兼容一种早期的布尔写法,直接给 tool_search 写 true 等价于 enabled: auto。具体默认值以仓库里的配置默认文件为准,别照抄二手文章。

解析这些值的代码对每一项都做了钳位:布尔写法会被翻译成对应档位,认不出的值退回安全默认,数值全部夹到各自的合法区间内。文档里的原话是,配置里的一个拼写错误不应该让 agent 崩掉。这个取向本身就值得抄:工具治理相关的配置一旦解析失败就抛异常,代价是整个 agent 起不来,而它想控制的那点上下文体积远没有可用性重要。

五、边界与代价:它放弃了什么

这套设计不是免费的,而且仓库自己把代价列在文档里,没有藏。

核心工具永不延迟,所以它压不了核心那部分。 如果你的上下文是被终端、文件、浏览器这一大堆核心工具的 schema 吃掉的,工具搜索一点忙都帮不上。要缩只能走工具集这一层:换成更窄的工具集,或者用禁用配置减掉。

冷工具至少多一次来回。 第一次要用某个被延迟的工具时,要多花一到两次模型调用去找和加载 schema。静态那侧省下的 token 是真的,但一部分会在运行时还回去。清单在位时这个来回通常消失,因为模型能直接去取 schema。

被延迟的 schema 拿不到系统提示前缀的缓存收益。 取回来的 schema 会进对话历史,后续轮次能被缓存,但它永远不在那段最稳定的前缀里。

依赖模型能写出像样的检索词。 这是个前提假设,小模型做得没那么好。它不是万能的召回,检索失败就是能力失联。

改动工具集会让提示缓存失效。 桥工具的描述里带着延迟工具的数量,加减一个工具就变了字面,缓存作废。这和任何工具集改动的代价是一样的。

关键词检索没有语义。 BM25 加子串兜底,意味着措辞对不上就搜不到。文档也提到实现里刻意没做 JS 沙箱那种「代码模式」,只用「结构化工具」这条路——搜索、描述、调用就是三个普通函数,理由写的是沙箱那条路要多铺一大片面出来,不值得。

可用性判断有最多约 30 秒的滞后。 探测结果带 TTL,抖动还会被当成上次成功来处理。好处是别的地方讲过了,代价是你刚拔掉一个后端,它可能还在广告自己的工具。

分布表不是线上路由。 toolset_distributions.py 是概率抽样,同一个分布名两次采样结果可以不同,拿它当线上会话的工具分配器就是在给自己找不确定性。

还有一条不在文档里但你必须自己承担的:这个项目会常驻在你的机器上、开终端执行命令、连你的聊天账号、往磁盘写文件、访问外部服务。上面三层是控制「模型能看见什么」的机制,不是安全边界的全部。谁能给它发消息、发的内容可信到什么程度、终端里跑出来的东西谁复核,这些它管不了,得你自己在部署时定。

六、上手与避坑清单

以为开了工具搜索就能压掉全部工具体积。 会踩是因为「延迟」听起来像全局开关,实际核心工具被硬编码排除。避法:先打开 toolsets.py 数一遍 _HERMES_CORE_TOOLS 有多少个,那部分永远在。真要瘦身,从换工具集开始,不要指望桥。

把平台包名写进禁用列表。 会踩是因为名字看着就该整包关掉,但平台包本身包含核心工具,代码为了不把工具列表清空,只减非核心增量。避法:平台包名属于「选哪个工具集」那一栏,不属于禁用栏;想减功能就禁具体的小工具集。

插件注册同名工具却悄无声息地没生效。 会踩是因为跨工具集的同名注册被直接拒绝,只写 error 日志、不抛异常,插件那边看起来一切正常。避法:确实要替换内置实现时显式传 override=True,同时在配置里给这个插件开 allow_tool_override;没开会抛 PermissionError,比静默好排查。

改完配置或环境变量,行为没变。 会踩是因为两层缓存:可用性探测结果按 TTL 缓存,工具定义的记忆化则把配置文件的修改时间和大小当指纹。避法:等一两轮,或者走它自带的工具管理命令(注册表专门提供了清空探测缓存的入口给这类配置变更用),别在那儿反复重启找玄学。

新加的工具文件没被发现。 会踩是因为发现逻辑有三个硬条件:文件在 tools/ 顶层、是 .py、并且模块顶层真的有一句 registry.register(...);写在函数里的注册不算,另外有几个文件名被显式跳过。避法:照着已有工具文件的形状写,注册调用放模块级;发现结果还带一层按修改时间和大小键控的磁盘缓存,排查时先想到它。

把清单关掉省 token,结果任务成功率掉了。 会踩是因为清单看起来只是冗余的说明文字。但实测的结论是反的:能力不可见时模型会用可见的核心工具硬顶或者宣称做不到。避法:想省预算优先调预算上限和百分比,让它自己退化成只列名字,而不是直接关成裸桥。

拿分布表做线上工具分配。 会踩是因为文件名里的「分布」被读成了「分发」。避法:线上会话用工具集和启用/禁用配置,确定性的;分布表留给批量造数据。

收束

读到这里,可以拿四个问题自查你手上的 Agent:模型这一轮实际看到多少个工具、其中有多少这一轮压根用不上;哪些工具是「永远在」的,这个名单是谁定的、改起来要动几个地方;一个工具因为外部依赖不可用时,是消失得无声无息还是有日志可查;被隐藏起来的能力,模型有没有办法知道它存在。

想继续往下读代码,建议的顺序是:tools/tool_search.pyassemble_tool_defs 往上看,它是这层的入口;然后回到 model_tools.py_compute_tool_definitions,看工具集解析、可用性过滤、动态 schema 重写、桥装配这四步的先后;最后翻 tests/tools/test_tool_search.py,测试比文档更能告诉你哪些行为是被当作契约锁住的。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent自托管开源 Agent 项目 Hermes Agent 的终端环境抽象

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。