工具调度系统:从注册到执行的完整生命周期

Hermes 如何把 70+ 个工具的 schema 高效下发、如何安全并发执行、如何用搜索桥实现延迟加载

系列:通过 Hermes 探秘 Agent 工程 | 第 6 篇 上一篇:记忆系统:跨会话持久化的工程实现


问题:70+ 工具怎么高效调度

Agent 的"手"是各式各样的工具——terminal 执行命令、read_file 读取文件、browser_navigate 操控浏览器、delegate_task 生成子 Agent……加在一起有 70 多个。

这些工具面临的挑战是:

  1. 注册:如何在不硬编码的前提下让工具自己声明 schema?
  2. 过滤:如何根据当前会话快速决定"模型此刻能看到哪些工具"?
  3. 执行:如何并发执行多个工具调用,同时保证消息对的合法性?
  4. 延迟加载:如何只把"可能用到"的工具提前注册?

Hermes 的工具调度系统用一套分层架构解决了这些问题。

工具注册:自注册 + 自动发现

每个工具文件在模块导入时调用 registry.register() 声明自己的 schema。注册中心是线程安全的单例 ToolRegistry,使用 RLock 保护内部状态。

一个典型的注册调用包含:

  • name:工具名称(如 read_file
  • toolset:所属工具集(如 file
  • schema:OpenAI 格式的 JSON Schema
  • handler:执行函数
  • check_fn:可用性检查(如 Docker 是否安装)
  • requires_env:必需的环境变量列表
  • max_result_size_chars:最大返回结果长度
  • dynamic_schema_overrides:运行时动态修改 schema 的回调

check_fn 的设计很精妙——它不是每次获取工具定义时都调用(太昂贵),而是在 registry 内部缓存 30 秒。如果检查失败,还有 60 秒的"宽限期":只要上次成功过,短期内就继续认为工具可用。这避免了 Docker daemon 瞬时抖动导致的工具丢失。

注册还做了一个防呆设计:如果两个工具同名但来自不同的 toolset,注册会被拒绝(防止插件意外覆盖内置工具)。除非显式传入 override=True,比如用 headed-Chrome CDP 后端替换默认的浏览器工具。

Schema 获取:过滤 + 多级缓存

get_tool_definitions() 是"模型即将被发送工具列表"时的入口。

过滤逻辑

  1. 根据 enabled_toolsets 决定包含哪些工具集
  2. 如果都没传,默认包含所有工具集
  3. 再根据 disabled_toolsets 做减法
  4. 对剩余工具逐个调用 check_fn(带缓存)决定是否可用
  5. 把最终通过的 schema 返回

三层缓存

位置Key用途
L1registry 内部 _check_fn_cachecheck_fn 引用 + TTL避免 Docker/Playwright 等外部检查过于频繁
L2registry 内部 _check_fn_last_goodcheck_fn 引用 + 绝对时间flake 宽限:失败后 60s 内仍认为可用
L3model_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 里改了并发限制,下次请求就会自动失效缓存(因为动态 schema 依赖配置)。

7 个不同的配置指纹就能覆盖典型的使用场景:web-only、coding-only、browser + file、acr、delegate 子 Agent、kanban worker、full-hermes。

主调度器: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() // 中间件修改参数
  ├─ _AGENT_LOOP_TOOLS 检查       // 拒绝直接调度循环内工具
  ├─ 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(如 """</tool_call> 等),防止模型"读"到错误字符串里的结构标记而产生幻觉。

工具执行:并发 vs 顺序

Hermes 支持两种执行模式,由 execution_mode 配置决定。

顺序执行

_execute_tool_calls_sequential():一个接一个地执行工具调用。

适用于有依赖关系的调用(比如先 write_fileterminal 测试)。

并发执行

execute_tool_calls_concurrent():用线程池并行执行所有工具调用。

并发执行的 pre-flight 流程:

  1. 中断检查:如果用户按了 Ctrl+C,直接跳过所有工具,返回取消标记
  2. Tool Search 解包:把 tool_call 桥解包成真实工具名 + 参数
  3. 会话范围检查:验证解包后的工具在当前会话的工具集范围内(防止子 Agent 越权)
  4. 中间件应用:运行请求修改中间件
  5. 拦截评估:插件钩子 + 工具守卫(Tool Guardrails)评估是否允许执行
  6. 检查点预检:对 write_filepatch 和危险 terminal 命令提前创建 Git checkpoint

然后通过线程池(最多 8 个 worker)并发执行。每个 worker 注册自己的线程 ID,让 agent 的 interrupt() 可以精准中断特定的 worker 线程。

结果按原始 tool_call 顺序写入消息列表——即使并发执行完成顺序不同,模型看到的结果顺序必须和它发出的调用顺序一致。

执行前后还会触发两个钩子:

  • tool_start_callback / tool_progress_callback:通知 UI 显示进度
  • post_tool_call:通知插件执行已完成

工具搜索桥:延迟加载

70+ 个工具不可能全部塞进每次 API 请求(有些场景只需要其中十几个)。Hermes 用"工具搜索桥"实现了延迟加载:

  1. 只注册 tool_searchtool_describetool_call 三个桥工具
  2. 模型调用 tool_search 查询"有哪些工具可用"
  3. 模型调用 tool_describe 获取某个工具的完整 schema
  4. 模型调用 tool_call 调用一个"还未加载"的工具

桥的内部流程:

tool_call(function_name="delegate_task", args={...})
  ├─ 解析出底层工具名和参数
  ├─ 检查当前会话是否被授权使用该工具(scope gate)
  ├─ 重新调度:handle_function_call(function_name="delegate_task", args)
  └─ 返回执行结果

关键点:

  • 桥对插件钩子完全透明——所有 downstream 检查看到的是真实工具名(不是 tool_call
  • 会话范围隔离:子 Agent 不能通过桥调用父会话的其他工具,即使 schema 碰巧在进程全局注册表里
  • 结果缓存:_tool_search_scoped_names() 的 scope 信息缓存在 agent 实例上,registry generation 变化时才刷新

中间件体系

贯穿工具调用全链路的中间件有两层:

请求中间件 (apply_tool_request_middleware)

在工具执行之前,可以修改参数或阻止调用。比如:

  • 将相对路径转绝对路径
  • 添加默认参数(如 terminal 的 env)
  • 记录修改历史到 trace 字段

执行中间件 (run_tool_execution_middleware)

在工具执行前后包装逻辑。比如:

  • 延迟加载依赖(某个工具需要特定 SDK 时才 import)
  • 执行超时控制
  • 执行结果的后处理

两层的 trace 字段(_tool_middleware_trace)会一直传递到 post_tool_call 钩子,让插件能观测到工具调用经过了哪些中间件的处理。

执行后转换钩子

transform_tool_result 允许插件替换工具的最终返回结果。比如:

  • 格式化错误消息
  • 隐藏敏感信息
  • 把大输出换成摘要

post_tool_call 钩子不同,后者只是"观察",前者能实际修改返回给模型的内容。

执行预算与结果截断

tools/budget_config.py 定义了每个工具结果的默认最大长度(DEFAULT_RESULT_SIZE_CHARS),并根据上下文窗口大小动态缩放——小上下文的本地模型(如 65K)得到比大上下文模型(100K/200K)更严格的预算,避免单个大工具输出把请求撑爆。

每个工具还可以有自己的 max_result_size_chars 覆盖全局默认。比如 read_file 可能允许更大的输出,而 search_files 的结果会被截断。

tool_result_storage.py 负责:

  • 持久化大的工具结果到磁盘(避免内存占用)
  • 强制执行每轮的工具输出预算

总结

Hermes 的工具调度系统是一个四层架构

职责
注册层模块导入时自注册,线程安全单例
过滤层toolset 过滤 + check_fn 可用性检查 + 三级缓存
调度层类型强制、中间件、插件钩子、ACP 审批
执行层并发/顺序执行、中断传播、结果顺序保证

这套设计让 Hermes 能在 70+ 工具的场景下保持高效的 API 请求生成,同时通过工具搜索桥实现了灵活的延迟加载——需要多少,就加载多少。

下一篇,我们将进入 Hermes 的安全防护体系——当 Agent 拥有终端操控能力时,如何防止它"做出格"的事