工具调度系统:从注册到执行的完整生命周期
Hermes 如何把 70+ 个工具的 schema 高效下发、如何安全并发执行、如何用搜索桥实现延迟加载
系列:通过 Hermes 探秘 Agent 工程 | 第 6 篇 上一篇:记忆系统:跨会话持久化的工程实现
问题:70+ 工具怎么高效调度
Agent 的"手"是各式各样的工具——terminal 执行命令、read_file 读取文件、browser_navigate 操控浏览器、delegate_task 生成子 Agent……加在一起有 70 多个。
这些工具面临的挑战是:
- 注册:如何在不硬编码的前提下让工具自己声明 schema?
- 过滤:如何根据当前会话快速决定"模型此刻能看到哪些工具"?
- 执行:如何并发执行多个工具调用,同时保证消息对的合法性?
- 延迟加载:如何只把"可能用到"的工具提前注册?
Hermes 的工具调度系统用一套分层架构解决了这些问题。
工具注册:自注册 + 自动发现
每个工具文件在模块导入时调用 registry.register() 声明自己的 schema。注册中心是线程安全的单例 ToolRegistry,使用 RLock 保护内部状态。
一个典型的注册调用包含:
name:工具名称(如read_file)toolset:所属工具集(如file)schema:OpenAI 格式的 JSON Schemahandler:执行函数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() 是"模型即将被发送工具列表"时的入口。
过滤逻辑
- 根据
enabled_toolsets决定包含哪些工具集 - 如果都没传,默认包含所有工具集
- 再根据
disabled_toolsets做减法 - 对剩余工具逐个调用
check_fn(带缓存)决定是否可用 - 把最终通过的 schema 返回
三层缓存
| 层 | 位置 | Key | 用途 |
|---|---|---|---|
| L1 | registry 内部 _check_fn_cache | check_fn 引用 + TTL | 避免 Docker/Playwright 等外部检查过于频繁 |
| 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 里改了并发限制,下次请求就会自动失效缓存(因为动态 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_file 再 terminal 测试)。
并发执行
execute_tool_calls_concurrent():用线程池并行执行所有工具调用。
并发执行的 pre-flight 流程:
- 中断检查:如果用户按了 Ctrl+C,直接跳过所有工具,返回取消标记
- Tool Search 解包:把
tool_call桥解包成真实工具名 + 参数 - 会话范围检查:验证解包后的工具在当前会话的工具集范围内(防止子 Agent 越权)
- 中间件应用:运行请求修改中间件
- 拦截评估:插件钩子 + 工具守卫(Tool Guardrails)评估是否允许执行
- 检查点预检:对
write_file、patch和危险 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 用"工具搜索桥"实现了延迟加载:
- 只注册
tool_search、tool_describe、tool_call三个桥工具 - 模型调用
tool_search查询"有哪些工具可用" - 模型调用
tool_describe获取某个工具的完整 schema - 模型调用
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 拥有终端操控能力时,如何防止它"做出格"的事