Hermes工具系统设计(核心流水线篇)

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  ← 项目启动入口

注册表层(叶子节点)

tools/registry.py
无外部依赖,仅定义数据结构和注册方法

工具实现层

tools/file_ops.py

tools/terminal_tool.py

tools/web_search.py

tools/*.py

触发层

model_tools.py
导入注册表 + 触发所有工具模块导入

启动入口层

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扫描阶段直接跳过,恶意代码永远不会被执行。

tools/ 目录下所有 .py 文件

AST 静态预扫描
解析文件语法结构

顶层是否存在
registry.register() 调用?

执行 import 加载
触发 registry.register()

直接跳过
不执行任何代码

工具注册成功
加入工具注册表

backdoor.py / 测试脚本
全局副作用不会触发

当然,这不是终极安全防护。如果恶意脚本伪造了 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平台"的情况。通用能力统一维护,专属能力差异化叠加。

各平台叠加层

共享基线 _HERMES_CORE_TOOLS

file: read_file, write_file, patch, search_files

terminal: run_command

web: web_search, web_extract

browser: browser_navigate

planning: task_plan

hermes-discord
基线 + discord + discord_admin

hermes-feishu
基线 + feishu_doc_read + feishu_drive

hermes-cli
基线 + cli_specific_tools

hermes-vscode
基线 + editor_integration

3.2 原子工具集 × 场景工具集:组合优于继承

Hermes将工具集拆分为两种类型,通过组合模式灵活拼装:

原子工具集:是最小的工具能力单元,包含固定的工具列表,不依赖其他工具集。比如 file 工具集包含 read_filewrite_filepatchsearch_filesweb 工具集包含 web_searchweb_extract

场景工具集:通过 includes 字段嵌套多个原子工具集,面向具体业务场景封装能力套装。比如:

# 调试场景:自动包含文件、网络、终端能力
"debug": {"includes": ["file", "web", "terminal"]},

# 安全场景:剔除高危终端操作
"safe_mode": {"includes": ["file", "web"]},  # 不含 terminal

场景工具集

原子工具集

file
read_file, write_file, patch, search_files

web
web_search, web_extract

terminal
run_command

debug
includes: file + web + terminal

safe_mode
includes: file + web

这种设计遵循了组合优于继承的原则:不做 DebugToolSet extends FileToolSet extends BaseToolSet 的深层继承链,而是通过声明式组合拼出需要的工具集。场景一变,换一套组合即可,无需改代码。

3.3 启用/禁用双向独立开关

每个Agent循环开始时,get_tool_definitions() 执行三道过滤:

  1. 展开:解析所有启用工具集的完整工具列表
  2. 剔除:减去所有禁用工具集的工具
  3. 过滤:剔除 check_fn 返回 False 的工具(环境不满足)

两套规则互不干扰、叠加生效。比如你可以全局启用 hermes-cli(全量工具)的同时,单独禁用 homeassistant(不需要智能家居功能):

# 用户配置
enabled_toolsets = ["hermes-cli"]      # 全量启用
disabled_toolsets = ["homeassistant"]  # 排除智能家居
# 结果:全量工具 - 智能家居相关工具

工具集类比自助餐菜单:启用表示"我要这些",禁用表示"这些除外"。你可以同时选择"全席"再排除"海鲜",系统从全席中移除被排除的,返回剩余列表。无需修改任何源码。

第三步:环境校验过滤

逐个执行 check_fn
剔除环境不满足的工具

第二步:剔除禁用工具集

disabled_toolsets
e.g. homeassistant

第一步:展开启用工具集

enabled_toolsets
e.g. hermes-cli + debug

用户配置

全量工具列表:50个工具

剩余工具:45个

最终可用工具清单
发送给 LLM


四、执行层:七阶段标准化流水线

供给层解决了"LLM能看到什么工具",执行层解决的是"工具怎么被精准、安全、稳定地调用"。

4.1 完整执行链路

LLM返回工具调用后,工具不是直接执行的。它要穿过一条七阶段流水线:

LLM 返回工具调用
    → coerce_tool_args()        # ① 参数类型转换与校验
    → pre_tool_call 钩子        # ② 插件拦截点
    → ACP 操作审批机制           # ③ 高危操作人工确认
    → registry.dispatch()       # ④ 路由到具体 handler
    → handler 执行              # ⑤ 业务逻辑
    → post_tool_call 钩子       # ⑥ 耗时统计与日志
    → _sanitize_tool_error()    # ⑦ 错误信息脱敏与标准化
    → 返回结果给 LLM

LLM 输出工具调用

① coerce_tool_args
参数类型转换与校验

② pre_tool_call 钩子
插件可拦截

③ ACP 操作审批
write_file/patch 需人工确认

④ registry.dispatch
路由到 handler

⑤ handler 执行
业务逻辑

⑥ post_tool_call 钩子
duration_ms 耗时统计

⑦ _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如何随运行时状态动态变化?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值