前言:入口把消息交给 Python 以后,真正决定 Agent 行为的是运行时:AIAgent 保存什么,Provider Transport 负责什么,Tool Registry 为什么不仅是函数表,以及大量工具 Schema 怎样避免撑爆上下文。本文只沿着一轮模型—工具循环向下追,不再讨论界面细节。
系列导航:这组文章按“总览 → 入口 → 运行时 → 扩展 → 状态 → 协作 → 安全”的顺序展开;第一次阅读可以从总览开始,带着具体问题也可以直接跳到专题。
- 01|Hermes Agent 架构总览:从 Agent Loop 到智能体平台
- 02|Hermes Desktop 架构:Electron 如何连接本地 Python Agent
- 03|Hermes Agent 运行时:AIAgent、模型协议与工具执行
- 04|Hermes Agent 扩展体系:Skill、Plugin 与 MCP
- 05|实战:同一个 Markdown 检查器的 Skill、Plugin 与 MCP 三种实现
- 06|Hermes Agent 的持续学习系统:Memory、Review、Skill 与 Curator
- 07|Hermes Agent 的长会话架构:Prompt Cache、压缩与 SessionDB
- 08|Hermes Agent 的多 Agent 与自动化:从 Goal 到 Kanban
- 09|Hermes Agent 安全架构:审批、沙箱、回滚与凭据隔离
Hermes 自带哪些工具
Hermes 当前源码的核心工具并不少,但不能把源码里的名称直接理解为“每次对话都会把全部 Schema 发给模型”。Toolset、Profile、Provider 配置、依赖探测、check_fn 和会话来源会逐层过滤;Plugin 与 MCP 还会在此基础上动态增减工具。下面列的是当前基线里的能力族,而不是一个永远不变的数量承诺。
| 能力族 | 代表工具 | 用途与启用说明 |
|---|---|---|
| Web | web_search、web_extract | 搜索与提取网页正文;依赖相应服务配置 |
| 终端与进程 | terminal、process | 执行命令、管理长进程;落点由 Environment Backend 决定 |
| 文件与代码修改 | read_file、write_file、patch、search_files | 读取、搜索和修改工作区文件 |
| 浏览器 | browser_navigate、browser_snapshot、browser_click、browser_type、browser_cdp 等 | 页面导航、DOM/无障碍快照、交互、视觉与 CDP 控制 |
| 视觉与媒体 | vision_analyze、image_generate、视频生成与查询、text_to_speech | 需要对应模型或服务 Provider |
| Skill | skills_list、skill_view、skill_manage | 发现、按需读取和管理操作知识 |
| Agent 协作 | execute_code、delegate_task、clarify、todo | 程序化编排、子任务委派、澄清与计划维护 |
| 状态与自动化 | memory、session_search、cronjob | 跨会话记忆、历史搜索和定时任务 |
| 桌面专属 | read_terminal、open_preview、focus_pane、react_to_message、setup_mcp | 只向 Desktop 来源会话开放,通过反向事件桥执行 |
| 项目与可选集成 | project_list、project_create、project_switch,以及 Home Assistant、看板、Computer Use | 按会话表面、依赖和配置条件注册 |
工具最终在哪里执行
工具名相同,不代表执行机器相同。Terminal Environment 抽象目前可以落到 local、Docker、Singularity、Modal、Daytona、Vercel Sandbox 或 SSH。Desktop 选择远程模式后,Terminal、文件和代码工具应在远端项目环境工作;只有窗口、剪贴板、通知、Pane 状态这类 UI 能力留在本机。
| 能力 | 本地模式 | SSH / Cloud 模式 | 边界依据 |
|---|---|---|---|
| 模型与 Agent Loop | 本地 Python Backend | 远端 Backend | 当前 Gateway 连接 |
| Terminal / 文件 / 普通 Python | 本机工作区 | 远端工作区或沙箱 | Environment Backend |
| Plugin / MCP Server | Backend 所在机器或其配置目标 | 远端 Backend 所在侧 | Profile 与进程生命周期 |
| 窗口 / Pane / 剪贴板 / 通知 | 桌面 Renderer 与 Electron | 仍在用户桌面 | Desktop 反向事件桥 |
因此工具系统应该按三层理解:Registry 决定“有哪些能力”,会话过滤决定“模型本轮看见什么”,Environment 与 Surface 决定“调用最终在哪里发生”。Skill 提供怎么做的知识,Plugin 可以注册新的本地代码与生命周期扩展,MCP 把外部服务映射成动态工具;它们最后仍汇入同一套工具选择、审批、执行和结果回填流程。
AIAgent:会话运行时的组合根
run_agent.py 里的 AIAgent 仍然是最显眼的对象。它保存模型客户端、工具快照、上下文引擎、记忆管理器、SessionDB、迭代预算、回调、中断状态和一堆会话级计数。刚开始读时,很容易把所有行为都归到这个类上。
但当前代码已经在持续拆分:构造过程转发给 agent/agent_init.py,主循环转发给 agent/conversation_loop.py,工具批处理在 agent/tool_executor.py,上下文压缩和 Turn Finalizer 也有独立模块。所以我更愿意称它为组合根和会话级运行时容器。
一轮请求到底怎么跑
sequenceDiagram
participant U as 用户入口
participant A as 智能体运行时
AIAgent
participant L as 对话循环
Conversation Loop
participant P as 模型提供方传输层
Provider Transport
participant T as 工具执行管线
Tool Pipeline
participant S as 会话数据库
SessionDB
U->>A: 运行对话
run_conversation(message)
A->>S: 获取会话租约并读取历史
A->>L: 进入本轮循环
L->>L: 恢复或构建冻结 Prompt
L->>P: 转换并发送统一消息
P-->>L: 归一化响应
alt 模型返回工具调用
L->>T: 校验、审批、执行
T-->>L: 工具结果
L->>L: 追加消息并继续循环
else 模型返回最终文本
L->>S: 持久化消息和用量
L-->>A: 最终响应
final_response
A-->>U: 展示结果
end内部消息统一成 OpenAI 风格的 role、content 和 tool_calls 字典。不同模型协议在边界上做转换,Conversation Loop 尽量不直接处理每家供应商的原始结构。
循环还维护一组很“死板”但非常必要的不变量:用户、助手和工具消息必须合法交替;工具调用和工具结果不能变成孤儿;临时展示字段、数据库行号和内部标记不能发给严格的模型 API;中断产生的半截消息也不能污染后续历史。模型服务对历史结构比人严格得多,少一个字段或多一个连续的 user message,都可能换来一次很难看懂的 400。
Provider Transport:把模型协议差异挡在边界上
多模型系统最容易长成一片 if provider == ...。Hermes Agent 用 ProviderTransport 把协议数据路径抽了出来,核心接口包括:
convert_messages():把内部消息转换成供应商格式;convert_tools():转换工具 Schema;build_kwargs():拼出最终 API 参数;normalize_response():把响应归一化回内部对象。
当前代码里能看到 Chat Completions、Responses、Anthropic Messages 和 Bedrock Converse 等 Transport。它们处理工具格式、停止原因、缓存统计和响应字段差异;客户端生命周期、凭据刷新、流式中断和模型回退仍由 Agent 运行时统一管理。
这个边界我很喜欢,因为它没有走两个极端:Transport 如果太薄,协议判断会重新泄漏到主循环;如果太厚,每个 Transport 又会复制一套凭据、重试和中断逻辑。现在的划分大致是“协议格式归 Transport,执行生命周期归 Agent”。
模型供应商本身还有另一套 ProviderProfile 注册机制,用来声明名称、别名、地址、凭据字段、默认模型和供应商特例。这样,“支持一个新的供应商目录”和“支持一种新的线协议”成了两个不同维度,不需要绑死在一起。
Tool Registry:一个小型应用服务器
工具系统主要由 tools/registry.py、model_tools.py 和 toolsets.py 组成。一个工具注册时不只交出 handler,还要声明名称、toolset、JSON Schema、可用性检查、环境依赖、异步标记、结果大小限制,以及可选的动态 Schema 覆盖。
工具为什么还要分 Toolset
模型每轮请求都要看到工具 Schema,所以“注册了”不等于“每个会话都应该暴露”。Toolset 把 file、terminal、browser、memory、web 等能力组合起来,再根据入口、平台、Profile 和用户配置求出本会话的工具快照。
- 成本:Schema 越多,每轮固定输入越大;
- 安全:公开 Webhook 不该拿到本地终端的完整能力;
- 可达性:只有带图形界面的会话,才应该看到需要界面响应的工具。
换句话说,能力属于 Session,不属于 Process。一个后端进程可能同时服务桌面客户端、终端和远程消息会话,不能靠一个全局环境变量推断所有会话都有哪些能力。
Tool Search:工具已经获准,不等于完整 Schema 必须常驻
当一个会话同时连接多个 MCP Server 和 Plugin 时,即使一次都不用,它们的 JSON Schema 也会在每轮占用上下文。当前源码的 tools/tool_search.py 因此在 Toolset 快照之后又加了一层渐进披露:Hermes 核心工具始终直接可见;只有 MCP 工具与非核心 Plugin 工具可以延迟。默认 tools.tool_search.enabled: auto,只要存在可延迟工具就启用桥接。
flowchart LR
Snapshot[本会话 Toolset 快照] --> Split{工具来源}
Split -->|Hermes 核心
Hermes Core| Eager[完整 Schema 直接进入请求]
Split -->|MCP / 非核心 Plugin| Catalog[构建本轮临时 Catalog]
Catalog --> Budget{目录是否装得下预算}
Budget -->|可以| Tier1[Tier 1:Bridge + 分组名称清单]
Budget -->|过大| Tier2[Tier 2:Bridge + Server 摘要]
Tier1 --> Discover[工具搜索与描述
tool_search / tool_describe]
Tier2 --> Discover
Discover --> Call[工具调用
tool_call]
Call --> Unwrap[解包为真实工具名]
Unwrap --> Pipeline[统一 Hook / Approval / Dispatch 管线]模型实际只常驻看到 tool_search、tool_describe、tool_call 三个桥接 Schema。Tier 1 会在预算内附上按 Server 分组的名称与短描述,模型看到精确名称时可以直接 describe;目录再大就退化到 Tier 2,只保留每个 Server 的工具数量与领域线索。目录预算取“模型上下文百分比”和绝对上限的较小值,每次组装请求都从当前工具定义重建,所以会话中增删 MCP 不会留下失真的旧 Catalog。
搜索使用工具名、描述和参数名上的 BM25,并带名称子串兜底。更重要的是,tool_call 不是一条绕过策略的暗门:它先验证该工具仍在本会话获准的可延迟集合里,缺少必填参数时直接返回参数 Schema 让模型修复;真正执行前再解包成原工具名,因此 Hook、Guardrail、审批、活动日志和结果限制看到的仍是真实工具。它节省的是“常驻 Schema”,不是削弱能力边界。
一次工具调用经过的管线
flowchart LR
Call[模型工具调用] --> Coerce[参数修正与作用域检查]
Coerce --> Middleware[请求中间件]
Middleware --> Hook[工具调用前钩子
pre_tool_call Hook]
Hook --> Approval{需要人工审批?}
Approval -->|拒绝| Block[统一错误结果]
Approval -->|通过| Dispatch[注册表分派
Registry Dispatch]
Dispatch --> Result[结果规范化]
Result --> Post[post Hook 与结果变换]
Post --> History[写回 Tool Message]所以 Tool Registry 远不只是“函数名到函数”的字典。参数契约、作用域、审批、插件决策、错误封装和审计都集中在这里。handler 正常只返回字符串,唯一的结构化例外是受支持的多模态 envelope,这让上层日志、预算和持久化不必猜工具会返回什么类型。
工具还可以声明 check_fn。如果依赖、凭据或外部服务不可用,它就不出现在模型 Schema 里。当前实现会短时缓存检查结果,并保留最近一次成功判断来吸收瞬时抖动。我以前容易把这理解成简单的功能开关,源码读下来才发现它还在处理探测成本和服务抖动。
参考资料与源码入口
题图来源:《瀑布、池塘与自然》,创作者 dimitrisvetsikas1969,来自 Pixabay;依据 Pixabay Content License(Pixabay 内容许可)使用。