首页 > 教程攻略 > ai教程 >Hermes Agent 源码探秘 (4):工具系统 — Agent 的"双手"

Hermes Agent 源码探秘 (4):工具系统 — Agent 的"双手"

来源:互联网 时间:2026-07-01 08:33:19

上篇我们拆了核心循环。Agent 的核心决策逻辑其实挺简单:LLM 来决定"用什么工具",然后执行它。

Hermes Agent 源码探秘 (4):工具系统 — Agent 的"双手"

那么问题就来了——这些工具是怎么注册进去的?LLM 到底是怎么知道有哪些工具可用的?当它决定要执行某个工具的时候,背后又发生了什么?

这篇就来彻底拆解 Hermes 的工具系统。

一、先搞清楚:LLM 是怎么"知道"有工具可用的?

这个问题其实是整个 AI Agent 系统能跑起来的基石。

回想一下平时跟普通 LLM(比如 ChatGPT)聊天的场景。你发给它的消息格式是这样的:

messages = [{"role": "user", "content": "今天天气怎么样?"}]

但对于支持 Function Calling / Tool Calling 的 LLM,你可以在请求里额外塞进一堆工具定义:

response = client.chat.completions.create(
    model="...",
    messages=[...],
    tools=[
        # ← 这是关键!
        {
            "type": "function",
            "function": {
                "name": "get_weather",
                "description": "获取指定城市的天气",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "city": {
                            "type": "string",
                            "description": "城市名"
                        }
                    },
                    "required": ["city"]
                }
            }
        }
    ]
)

LLM 看到 tools 这个参数后,会做一个简单判断:如果用户的问题直接回答就能搞定,那就正常回复文字;如果问题需要调用外部能力,就返回 tool_calls,告诉你要调什么函数、传什么参数。

所以,Agent 的工具系统本质上就是把一组函数定义转换成 LLM 能理解的 Schema 格式,然后执行 LLM 选中的那个函数。

二、Hermes 工具系统的三件套

Hermes 的工具系统由三部分组成:

tools/registry.py       ← 注册中心(核心枢纽)
tools/*.py              ← 各个工具的实现
model_tools.py          ← 对外接口(给 run_agent.py 用)

2.1 注册中心:tools/registry.py

registry.py 是个全局单例的工具注册表。它的核心数据结构极其简单:

class ToolRegistry:
    def __init__(self):
        self._tools: Dict[str, ToolDef] = {}  # name -> ToolDef
        self._initialized = False

    def register(self, name, toolset, schema, handler,
                 check_fn=None, requires_env=None):
        self._tools[name] = ToolDef(
            name=name,
            toolset=toolset,
            schema=schema,               # OpenAI function calling schema
            handler=handler,             # 实际的 Python 函数
            check_fn=check_fn,           # 可用性检查(比如依赖是否安装)
            requires_env=requires_env)

# 全局单例
registry = ToolRegistry()

就这么简单——一个字典,以工具名为 key,存着工具的定义和处理函数。

自动发现:discover_builtin_tools()

Hermes 不需要手动注册每个工具。它会在启动时自动扫描 tools/ 目录下所有的 .py 文件:

def discover_builtin_tools(tools_dir=None):
    """导入 tools/ 目录下所有自注册工具模块"""
    tools_path = tools_dir or Path(__file__).resolve().parent
    for f in sorted(tools_path.iterdir()):
        if f.suffix == '.py' and f.name != '__init__.py':
            if _module_registers_tools(f):
                # 导入这个模块,模块内的 registry.register() 会被执行
                importlib.import_module(f"tools.{f.stem}")

这里用了个小技巧:先做静态分析,检查文件里是否真的有 registry.register(...) 调用,有才真正导入。避免加载那些不需要的工具模块。

def _module_registers_tools(module_path: Path) -> bool:
    """检查模块文件是否有 registry.register() 调用"""
    try:
        source = module_path.read_text(encoding="utf-8")
        tree = ast.parse(source, filename=str(module_path))
    except (OSError, SyntaxError):
        return False
    return any(_is_registry_register_call(stmt) for stmt in tree.body)

使用 AST(抽象语法树)做静态分析,比简单的字符串搜索要精确得多。

2.2 工具实现:tools/*.py

每个工具是一个独立的 .py 文件。以 tools/web_search_tool.py 为例:

import json, os
from tools.registry import registry

def check_requirements() -> bool:
    """检查依赖是否可用"""
    return bool(os.getenv("OPENROUTER_API_KEY") or os.getenv("GOOGLE_API_KEY"))

def web_search(query: str, max_results: int = 5, task_id: str = None) -> str:
    """执行网络搜索"""
    # ... 实际搜索逻辑 ...
    return json.dumps({"results": [...]})

# 在全局注册
registry.register(
    name="web_search",
    toolset="web",  # 属于哪个工具集
    schema={        # OpenAI Function Calling Schema
        "name": "web_search",
        "description": "搜索互联网获取最新信息",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "搜索关键词"
                },
                "max_results": {
                    "type": "integer",
                    "description": "返回结果数量(默认5)"
                }
            },
            "required": ["query"]
        }
    },
    handler=lambda args, **kw: web_search(
        query=args.get("query", ""),
        max_results=args.get("max_results", 5),
        task_id=kw.get("task_id")
    ),
    check_fn=check_requirements,
    requires_env=["OPENROUTER_API_KEY"]
)

每个工具只需要做三件事:一个处理函数(真正干活的代码)、一个注册调用(告诉系统"有我这么个工具")、一个可选的检查函数(判断能不能用)。

添加一个新工具,只需要创建一个文件,写一个函数,加一个注册调用。下一篇可以拿这个写个小例子。

2.3 对外接口:model_tools.py

model_tools.py 是对外暴露的接口层:

# 获取所有已启用工具的定义(用于传给LLM)
def get_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode):
    """根据启用的工具集,返回工具Schema列表"""
    tools = []
    for name, tool in registry._tools.items():
        if tool.toolset in enabled_toolsets and tool.toolset not in disabled_toolsets:
            if tool.check_fn is None or tool.check_fn():
                tools.append(tool.schema)
    return tools

# 执行工具调用
def handle_function_call(function_name, function_args, task_id):
    """根据函数名查找并执行工具"""
    if function_name not in registry._tools:
        return json.dumps({"error": f"未知工具: {function_name}"})
    tool = registry._tools[function_name]
    try:
        return tool.handler(function_args, task_id=task_id)
    except Exception as e:
        return json.dumps({"error": str(e)})

三、工具集(Toolset):给工具分组

Hermes 有 20+ 工具集,每个工具集是一组相关工具的集合:

# toolsets.py
_HERMES_CORE_TOOLS = [
    "web",              # 网络搜索、内容提取
    "browser",          # 浏览器自动化
    "terminal",         # 终端命令执行
    "file",             # 文件读写搜索
    "code_execution",   # Python 沙盒执行
    "vision",           # 图像分析
    "memory",           # 持久化记忆
    "delegation",       # 子袋里
    "cronjob",          # 定时任务
    "skills",           # 技能管理
    "messaging",        # 消息发送
    "session_search",   # 历史会话搜索
    ...
]

用户可以在配置里灵活开关某些工具集:

hermes tools            # 交互式管理
hermes tools enable web      # 开启网络工具
hermes tools disable browser # 关闭浏览器工具

每个工具集还有对应的环境依赖要求:

TOOLSET_REQUIREMENTS = {
    "browser": ["PLAYWRIGHT_BROWSERS_PATH", "BROWSERBASE_API_KEY"],
    "terminal": [],                # 始终可用
    "image_gen": ["OPENAI_API_KEY"],
}

只有满足依赖条件的工具集才会被加载,保证系统在不同环境下都能优雅运行。

四、从注册到调用的完整流程

启动时
│
▼
discover_builtin_tools()
│ 扫描 tools/*.py
▼
各模块执行 registry.register()
│ 工具信息写入全局 registry
▼
用户提问
│
▼
get_tool_definitions(enabled_toolsets)
│ 从 registry 筛选启用的工具
▼
传给 LLM (tools=...)
│
▼
LLM 决定调用某个工具
│ 返回 tool_calls
▼
handle_function_call(name, args)
│ 从 registry 查找 handler
▼
执行工具 → 返回结果 → 结果追加到对话

五、这个架构好在哪里?

整个工具系统拆下来,有几个很清晰的感受:

1. 低耦合,高内聚

每个工具是独立文件,互不依赖。加一个新工具完全不影响现有工具。这正是插件化架构的精髓所在。

2. 自动发现

不需要在配置里列"我有哪些工具",代码会自描述。这跟 Ja va SPI、Python entry_points 那套思想一脉相承,但实现得更轻量。

3. Schema驱动

工具定义直接对应 OpenAI Function Calling Schema。这套 Schema 现在已经是事实标准——Anthropic、Google、Mistral 全都兼容它。Hermes 基于标准来做,天然具有很好的兼容性。

4. 条件启用

check_fn 机制让工具可以在依赖不满足时自动隐藏。比如没有安装浏览器驱动,Browser 工具就不会暴露给 LLM。这让系统在弱环境下也能优雅运行,而不是直接报错。

六、写一个新工具的实验

说了这么多,不如直接上手试试。假如要给 Hermes 加一个"计算器"工具:

新建 tools/calculator.py

import json
from tools.registry import registry

def calculate(expression: str, task_id: str = None) -> str:
    """安全执行数学表达式"""
    # 只允许数字和运算符的白名单
    allowed = set("0123456789+-*/()., ")
    if not all(c in allowed for c in expression):
        return json.dumps({"error": "表达式包含非法字符"})
    try:
        result = eval(expression, {"__builtins__": {}}, {})
        return json.dumps({"result": result})
    except Exception as e:
        return json.dumps({"error": str(e)})

registry.register(
    name="calculate",
    toolset="web",  # 归到 web 工具集(或者新建一个)
    schema={
        "name": "calculate",
        "description": "计算数学表达式,支持 +-*/ 和括号",
        "parameters": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "数学表达式,例如 '1+2*3'"
                }
            },
            "required": ["expression"]
        }
    },
    handler=lambda args, **kw: calculate(
        expression=args.get("expression", ""),
        task_id=kw.get("task_id")
    ),
)

重启 Hermes,LLM 就会知道有 calculate 这个工具可供调用了。

七、下一篇预告

现在 Agent 会思考(核心循环)了,会干活(工具系统)了。但还有一个关键问题没解决:它怎么知道自己是谁?该以什么姿态来表现自己?

第五篇我们拆 System Prompt 的组装过程——agent/prompt_builder.py

你会看到:

  • System Prompt 里到底包含了什么内容
  • 技能(Skills)是怎么注入到 Prompt 里的
  • 安全意识:怎么防止 Prompt 注入攻击