Hermes工具系统(上):从自注册到按需供给,一条工具全生命周期流水线是如何炼成的
50个工具全塞给LLM会发生什么?
你刚给Agent接了新的文件系统工具,想着"多一个工具多一条路"。紧接着又加上了终端执行、网页搜索、浏览器操控、任务委派。几周下来,工具数量从个位数膨胀到了50个。
然后你发现Agent开始不对劲了——让它读个配置文件,它调用了网页搜索。让它修复一个代码bug,它打开了浏览器。Token消耗暴涨了40%,但任务完成率反而下降了。
这不是Agent"变蠢了"。这是工具系统在发出警报。
我们来算一笔账:50个内置工具,每个工具的Schema大约200到500个token。全量推送时,光工具定义就消耗10K-20K Token。这还没算系统提示词、技能定义、记忆注入。更致命的是——工具越多,LLM选择正确工具的决策干扰度越高。当列表里有50个选项时,工具选择准确率显著下降。
但这只是工具系统面临的第一个问题。
随着Agent能力的持续扩展,传统单体架构下工具管理的系统性缺陷逐一暴露:工具注册混乱、发现低效、供给粗暴、执行黑盒。这不再是加几个if-else能解决的——它需要一套完整的生命周期管理体系。
Hermes工具系统正是在这一背景下诞生的工程解决方案。它不止是50个工具的集合,而是一条覆盖注册→发现→供给→执行四大阶段的完整流水线。每一层只管自己的事,改一层不影响其他层。
下面按流水线顺序,逐层拆解。
一、注册层:代码即配置的自注册哲学
1.1 为什么不写YAML配置文件?
大多数Agent框架采用中心化YAML/JSON配置注册模式。工具数量不到20个时跑得通,但一旦超过20个,四个工程缺陷会集中爆发:
| 缺陷 | 具体表现 | 后果 |
|---|---|---|
| 配置-代码分离 | 代码更新了参数,配置文件忘了同步 | LLM拿到过时的工具描述,调用失败 |
| 手动维护 | 每新增/删除一个工具都要手动改配置、重启服务 | 迭代效率极低,改一行代码配五分钟配置 |
| 版本冲突 | 多人协作时配置频繁冲突、残留条目无人清理 | 线上出现"幽灵工具"——代码已删但配置还在 |
| 扩展困难 | 第三方插件接入必须修改核心配置文件 | 插拔成本高,本质上是"源码级耦合" |
Hermes的答案是:代码即配置。 工具的实现与声明放在同一个Python文件中,只需在模块顶层调用一次 registry.register():
# tools/file_ops.py
from tools.registry import registry
def read_file_handler(args):
path = args.get("path")
with open(path, "r") as f:
return {"content": f.read()}
registry.register(
name="read_file",
toolset="file",
schema={
"name": "read_file",
"description": "Read the contents of a file at the given path",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file"}
},
"required": ["path"],
},
},
handler=read_file_handler,
check_fn=lambda: True,
)
删除文件即删除工具。不存在"文件删了但配置还在"的残留。不会遗忘,不会不一致。
1.2 循环依赖解耦:单向分层依赖链
自注册看起来很美好,但实现时有一个经典技术陷阱:Python的循环依赖。
注册表需要被所有工具文件导入,工具文件又需要被某个"触发模块"加载。模块A导入模块B,模块B又导入模块A——解释器直接栈溢出。
Hermes的解法是单向分层依赖链 + 稳定模块下沉原则:
tools/registry.py ← 无任何外部依赖,最底层叶子节点
↑
tools/*.py ← 每个工具文件 import registry 并调用 register()
↑
model_tools.py ← 导入 registry + 触发所有工具模块的导入
↑
run_agent.py, cli.py, batch_runner.py ← 项目启动入口
核心思想:让最稳定的模块做叶子节点。 注册表只定义数据结构和注册方法,不依赖任何业务逻辑,所以它永远不会被循环引用。依赖只能从上层流向下层,无法形成闭环。
这遵循了架构设计中的一个重要原则——稳定依赖原则(SDP):不稳定模块(业务工具)依赖稳定模块(注册表),而非反过来。
1.3 注册时的冲突防护:不同工具集差异化策略
系统同时存在内置工具、MCP外部工具、自定义插件工具三类来源,同名冲突不可避免。
Hermes没有采用"后注册静默覆盖先注册"的粗暴做法,而是针对不同工具集制定了差异化策略:
- 不同工具集之间:默认禁止覆盖,注册失败时输出明确的ERROR日志并拒绝注册,防止插件工具篡改内置核心工具能力
- MCP工具之间:允许同名覆盖,适配多服务端动态插拔、能力替换的场景(类比USB设备的即插即用逻辑)
- generation版本号:每次注册、注销、刷新都会自增,为后续缓存失效、状态同步提供唯一依据
二、发现层:AST静态分析的安全门禁
2.1 为什么不能无脑导入整个目录?
最直观的发现方案是:import tools/*,遍历目录下的所有Python文件,导入即注册。
这个方案有两个致命问题:
第一,安全风险。如果有人在 tools/ 目录下放了 backdoor.py,里面的恶意代码会在模块导入时无声无息地执行。工具目录通常具有较高的文件系统访问权限,这是理想的攻击面。
第二,启动污染。测试脚本、迁移脚本、废弃工具如果混放在 tools/ 目录,它们的全局副作用(打印日志、初始化连接、创建临时文件)会在每次启动时触发。
Hermes的答案是:AST静态预扫描——先检查,后导入。 在执行任何代码之前,先解析文件的语法结构,只有顶层包含 registry.register() 调用的文件才会被实际导入:
# tools/registry.py
def _module_registers_tools(module_path: Path) -> bool:
"""检查模块顶层是否有 registry.register(...) 调用"""
source = module_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(module_path))
return any(_is_registry_register_call(stmt) for stmt in tree.body)
这个机制实现了一个基础但有效的安全门禁:没有注册声明的脚本,连代码都不会执行。有人放了个 backdoor.py,里面藏着 os.system("rm -rf /")——但因为文件顶层没有 registry.register() 调用,AST扫描阶段直接跳过,恶意代码永远不会被执行。
当然,这不是终极安全防护。如果恶意脚本伪造了 registry.register() 调用,AST扫描会放行。但要达到四层纵深防御的体系化安全,还有检查函数门控、黑名单、前置钩子、审批链在后面兜底。
2.2 MCP工具的分层异步加载
Hermes区分了两类工具的加载时机:
- 内置工具和本地插件:在模块导入阶段完成扫描注册,纯本地操作,快且无阻塞
- MCP(Model Context Protocol)外部工具:依赖网络请求和服务探测,单次扫描最长可能阻塞120秒
MCP工具如果放在模块导入阶段加载,会直接阻塞Gateway心跳,导致服务启动超时。Hermes采用异步延迟加载,将MCP工具的发现推迟到事件循环就绪之后,确保服务快速启动、工具按需就绪。
这个设计严格遵循 “模块导入无长时间阻塞副作用” 的工程原则——import 语句不该让你等两分钟。
三、供给层:组合式按需推送策略
注册层和发现层解决了"有什么工具",供给层解决的是"给LLM看哪些工具"。
3.1 共享基线 + 平台叠加
Hermes定义了一个统一的核心工具基线 _HERMES_CORE_TOOLS,包含文件读写、终端执行、网页检索、浏览器操控、任务规划等约40个通用基础工具。所有业务平台——Discord、飞书、CLI、VSCode——共享这个基线。
各平台在基线上叠加自己的专属工具:
"hermes-discord": {
"tools": _HERMES_CORE_TOOLS + ["discord", "discord_admin"],
},
"hermes-feishu": {
"tools": _HERMES_CORE_TOOLS + [
"feishu_doc_read", "feishu_drive_list_comments",
],
},
这个模式的工程价值在于:修改一次基线,所有平台同步更新。不会出现"为飞书平台新增了WebP图片处理工具,却遗漏了Discord平台"的情况。通用能力统一维护,专属能力差异化叠加。
3.2 原子工具集 × 场景工具集:组合优于继承
Hermes将工具集拆分为两种类型,通过组合模式灵活拼装:
原子工具集:是最小的工具能力单元,包含固定的工具列表,不依赖其他工具集。比如 file 工具集包含 read_file、write_file、patch、search_files;web 工具集包含 web_search 和 web_extract。
场景工具集:通过 includes 字段嵌套多个原子工具集,面向具体业务场景封装能力套装。比如:
# 调试场景:自动包含文件、网络、终端能力
"debug": {"includes": ["file", "web", "terminal"]},
# 安全场景:剔除高危终端操作
"safe_mode": {"includes": ["file", "web"]}, # 不含 terminal
这种设计遵循了组合优于继承的原则:不做 DebugToolSet extends FileToolSet extends BaseToolSet 的深层继承链,而是通过声明式组合拼出需要的工具集。场景一变,换一套组合即可,无需改代码。
3.3 启用/禁用双向独立开关
每个Agent循环开始时,get_tool_definitions() 执行三道过滤:
- 展开:解析所有启用工具集的完整工具列表
- 剔除:减去所有禁用工具集的工具
- 过滤:剔除
check_fn返回 False 的工具(环境不满足)
两套规则互不干扰、叠加生效。比如你可以全局启用 hermes-cli(全量工具)的同时,单独禁用 homeassistant(不需要智能家居功能):
# 用户配置
enabled_toolsets = ["hermes-cli"] # 全量启用
disabled_toolsets = ["homeassistant"] # 排除智能家居
# 结果:全量工具 - 智能家居相关工具
工具集类比自助餐菜单:启用表示"我要这些",禁用表示"这些除外"。你可以同时选择"全席"再排除"海鲜",系统从全席中移除被排除的,返回剩余列表。无需修改任何源码。
四、执行层:七阶段标准化流水线
供给层解决了"LLM能看到什么工具",执行层解决的是"工具怎么被精准、安全、稳定地调用"。
4.1 完整执行链路
LLM返回工具调用后,工具不是直接执行的。它要穿过一条七阶段流水线:
LLM 返回工具调用
→ coerce_tool_args() # ① 参数类型转换与校验
→ pre_tool_call 钩子 # ② 插件拦截点
→ ACP 操作审批机制 # ③ 高危操作人工确认
→ registry.dispatch() # ④ 路由到具体 handler
→ handler 执行 # ⑤ 业务逻辑
→ post_tool_call 钩子 # ⑥ 耗时统计与日志
→ _sanitize_tool_error() # ⑦ 错误信息脱敏与标准化
→ 返回结果给 LLM
每个阶段职责单一、边界清晰。排查问题时,无需通读整个执行引擎——先定位问题发生在链路中的哪个阶段,再针对性深入。全链路环节清晰、故障排查可精准定位至具体节点。
4.2 参数矫正:LLM输出不是可信数据源
LLM的输出天然不可靠——它可能把 "true" 当布尔值传、把字符串类型塞进需要整数的地方。coerce_tool_args() 的作用就是作为参数层面的适配层,依据Schema定义的类型规则,把LLM的非标准化输出矫正为handler期望的标准格式。
80%的参数错误能被类型矫正自动修复,剩下20%需要在Schema描述上做文章——让LLM看到更明确的类型提示,从源头减少输出偏差。
4.3 路由分发:统一的执行入口
所有工具的执行统一经过 registry.dispatch() 路由。这意味着你可以在一个地方做所有工具的横切关注点——日志、计时、异常捕获——而不需要在每个handler里重复写。
这也是插件工具能天然继承全部管控能力的关键:它们走的是和内置工具完全相同的分发管道。
小结:四层各管各的,改一层不影响其他层
把整条流水线串起来看:
| 层 | 职责 | 核心机制 |
|---|---|---|
| 注册层 | “有什么” | 自注册 + 单向依赖链 + 冲突防护 |
| 发现层 | “怎么找” | AST静态预扫描 + MCP异步延迟加载 |
| 供给层 | “推什么” | 共享基线 + 组合模式 + 双向独立开关 |
| 执行层 | “怎么跑” | 七阶段流水线 + 参数矫正 + 统一路由 |
四层之间各管各的——注册表不知道工具集的存在,工具集不知道安全检查的存在,安全检查不知道异步桥接的存在。改一层不影响其他层。这不是"把功能堆在一起",而是"把职责拆开来"。
50个工具不需要逐个记忆。只需掌握这条执行流水线。遇到问题,定位到流水线的哪一段,顺藤摸瓜。
下一篇我们将讨论这条流水线之外的四大工程体系——四层纵深防御如何拦截危险操作?第三方插件如何零配置接入?异步内核怎么套同步外壳?Schema如何随运行时状态动态变化?
427

被折叠的 条评论
为什么被折叠?



