工具调度系统:从注册到执行的完整生命周期
Hermes 如何把 70+ 个工具的 schema 高效下发、如何安全并发执行、如何用搜索桥实现延迟加载
通过 Hermes 探秘 Agent 工程 | 第 4 篇 · 查看全部
上一篇:工具系统:从注册到调度
[系列文章导航]
| # | 文章 | 定位 |
|---|---|---|
| 1 | Agent Loop:Agent 的核心执行循环 | 入口 |
| 2 | System Prompt:身份、上下文与策略的三层架构 | 认知层 |
| 3 | 工具系统:从注册到调度 | 工具层 |
| 4 | 工具调度系统:从注册到执行的完整生命周期 | 调度层 |
| 5 | 安全防护体系:当 Agent 拥有终端时如何防止「做出格」的事 | 安全层 |
| 6 | 沙箱与代码执行:让 Agent 安全跑代码的 RPC 架构 | 执行层 |
| 7 | 上下文压缩:让 Agent 在有限窗口里「记得住」 | 记忆层 |
| 8 | 记忆系统:跨会话持久化的工程实现 | 记忆层 |
| 9 | 技能系统:Agent 如何把经验变成可复用的程序化记忆 | 记忆层 |
| 10 | Provider 抽象层:让 Hermes 同时驾驭 30+ 个 LLM 提供商 | 模型层 |
| 11 | Gateway 网关:连接 20+ 平台的统一消息路由 | 接入层 |
| 12 | 多 Agent 协作:委托、调度与看板 | 协作层 |
问题:70+ 工具怎么高效调度
Agent 的"手"是各式各样的工具——terminal 执行命令、read_file 读取文件、browser_navigate 操控浏览器、delegate_task 生成子 Agent……加在一起有 70 多个。
这些工具面临的挑战是:
- 注册:如何在不硬编码的前提下让工具自己声明 schema?
- 过滤:如何根据当前会话快速决定"模型此刻能看到哪些工具"?
- 执行:如何并发执行多个工具调用,同时保证消息对的合法性?
- 延迟加载:如何只把"可能用到"的工具提前注册?
Hermes 的工具调度系统用一套分层架构解决了这些问题。
工具注册:自注册 + 自动发现
每个工具文件在模块导入时调用 registry.register() 声明自己的 schema。注册中心是线程安全的单例 ToolRegistry,使用 RLock 保护内部状态。
check_fn 的设计很精妙——它不是每次获取工具定义时都调用(太昂贵),而是在 registry 内部缓存 30 秒。如果检查失败,还有 60 秒的"宽限期":只要上次成功过,短期内就继续认为工具可用。这避免了 Docker daemon 瞬时抖动导致的工具丢失。
注册还做了一个防呆设计:如果两个工具同名但来自不同的 toolset,注册会被拒绝(防止插件意外覆盖内置工具)。
Schema 获取:过滤 + 多级缓存
get_tool_definitions() 是"模型即将被发送工具列表"时的入口。
过滤逻辑
- 根据
enabled_toolsets决定包含哪些工具集 - 如果都没传,默认包含所有工具集
- 再根据
disabled_toolsets做减法 - 对剩余工具逐个调用
check_fn(带缓存)决定是否可用 - 把最终通过的 schema 返回
三层缓存
| 层 | 位置 | Key | 用途 |
|---|---|---|---|
| L1 | registry 内部 _check_fn_cache | check_fn 引用 + TTL | 避免外部检查过于频繁 |
| L2 | registry 内部 _check_fn_last_good | check_fn 引用 + 绝对时间 | flake 宽限:失败后 60s 内仍认为可用 |
| L3 | model_tools 层 _tool_defs_cache | (enabled, disabled, generation, cfg_fp, kanban_flag, skip_tool_search_assembly) | 避免每次请求走完整的过滤流程 |
L3 缓存最多存 8 个条目,LRU 淘汰。缓存 key 里包含了 config.yaml 的 mtime + size——这意味着你在 hermes config 里改了并发限制,下次请求就会自动失效缓存。
主调度器:handle_function_call()
handle_function_call() 是工具调用的核心入口,处理从"模型输出 tool_calls"到"返回执行结果"的完整链路。
调用链
model_tools.handle_function_call()
├─ coerce_tool_args() // 类型强制:"42" → 42
├─ Tool Search bridge // tool_call 解包成真实工具
├─ apply_tool_request_middleware() // 中间件修改参数
├─ pre_tool_call hook // 插件拦截
├─ edit_approval (ACP/Zed) // 文件修改审批
├─ dispatch 到 registry // 实际执行
├─ run_tool_execution_middleware() // 执行后中间件
├─ transform_tool_result hook // 插件修改结果
└─ post_tool_call hook // 通知监听器
类型强制
模型有时会返回 "42"(字符串)而不是 42(数字),Hermes 的 coerce_tool_args() 会根据 schema 声明的类型自动转换。
错误统一格式
所有工具执行异常都被捕获并返回统一的 {"error": "..."} 格式 JSON。错误字符串还会经过 _sanitize_tool_error() 清洗——去除可能干扰模型的 framing token。
工具执行:并发 vs 顺序
顺序执行
_execute_tool_calls_sequential():一个接一个地执行工具调用。适用于有依赖关系的调用。
并发执行
execute_tool_calls_concurrent():用线程池并行执行所有工具调用。
并发执行的 pre-flight 流程:
- 中断检查:如果用户按了 Ctrl+C,直接跳过所有工具
- Tool Search 解包:把
tool_call桥解包成真实工具名 + 参数 - 会话范围检查:验证解包后的工具在当前会话的工具集范围内
- 中间件应用:运行请求修改中间件
- 拦截评估:插件钩子 + 工具守卫评估是否允许执行
- 检查点预检:对
write_file、patch和危险 terminal 命令提前创建 Git checkpoint
然后通过线程池(最多 8 个 worker)并发执行。结果按原始 tool_call 顺序写入消息列表——即使并发执行完成顺序不同,模型看到的结果顺序必须和它发出的调用顺序一致。
工具搜索桥:延迟加载
70+ 个工具不可能全部塞进每次 API 请求。Hermes 用"工具搜索桥"实现了延迟加载:
- 只注册
tool_search、tool_describe、tool_call三个桥工具 - 模型调用
tool_search查询"有哪些工具可用" - 模型调用
tool_describe获取某个工具的完整 schema - 模型调用
tool_call调用一个"还未加载"的工具
关键点:
- 桥对插件钩子完全透明——所有 downstream 检查看到的是真实工具名
- 会话范围隔离:子 Agent 不能通过桥调用父会话的其他工具
中间件体系
请求中间件 (apply_tool_request_middleware)
在工具执行之前,可以修改参数或阻止调用。
执行中间件 (run_tool_execution_middleware)
在工具执行前后包装逻辑。
两层的 trace 字段(_tool_middleware_trace)会一直传递到 post_tool_call 钩子,让插件能观测到工具调用经过了哪些中间件的处理。
执行预算与结果截断
tools/budget_config.py 定义了每个工具结果的默认最大长度(DEFAULT_RESULT_SIZE_CHARS = 100,000 字符),并根据上下文窗口大小动态缩放——小上下文的本地模型(如 65K)得到比大上下文模型(100K/200K)更严格的预算。
关键设计:
- PINNED_THRESHOLDS:
read_file设为float("inf"),防止无限 persist->read->persist 循环 - 预算缩放公式:
per_result = max(8000, min(window_chars * 0.15, 100000)) - 预览大小:1500 字符的 inline snippet
tool_result_storage.py 负责:
- 持久化大的工具结果到磁盘(避免内存占用)
- 强制执行每轮的工具输出预算(默认 200,000 字符/轮)
总结
Hermes 的工具调度系统是一个四层架构:
| 层 | 职责 |
|---|---|
| 注册层 | 模块导入时自注册,线程安全单例 |
| 过滤层 | toolset 过滤 + check_fn 可用性检查 + 三级缓存 |
| 调度层 | 类型强制、中间件、插件钩子、ACP 审批 |
| 执行层 | 并发/顺序执行、中断传播、结果顺序保证 |
下一篇:安全防护体系:当 Agent 拥有终端时如何防止「做出格」的事 — 当 Agent 拥有终端操控能力时,如何防止它"做出格"的事