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

系列:Hermes Agent 源码探秘 作者:元思未来 字数:约3200字


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

那问题来了------这些工具是怎么注册的?LLM 怎么知道有哪些工具可用?执行工具的时候发生了什么?

这篇就来拆 Hermes 的工具系统


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

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

当你跟一个普通的 LLM(比如 ChatGPT)聊天时,你发给它的消息格式是:

ini 复制代码
messages = [
    {"role": "user", "content": "今天天气怎么样?"}
]

但对于支持 Function Calling / Tool Calling 的 LLM,你可以额外传入工具定义

python 复制代码
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 参数后,会做判断:

  1. 如果用户的问题可以通过回复文字解决 → 正常回复
  2. 如果需要调用工具 → 返回 tool_calls,告诉你要调什么函数、传什么参数

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


二、Hermes 工具系统的三件套

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

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

2.1 注册中心:tools/registry.py

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

python 复制代码
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 文件:

python 复制代码
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(...) 调用,有才导入。这样避免了导入那些不需要的工具模块。

python 复制代码
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 为例:

python 复制代码
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"]
)

每个工具只需要:

  1. 一个处理函数(真正干活的代码)
  2. 一个注册调用(告诉系统"有我这么个工具")
  3. 一个可选的检查函数(判断能不能用)

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

2.3 对外接口:model_tools.py

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

python 复制代码
# 获取所有已启用工具的定义(用于传给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+ 工具集,每个工具集是一组相关工具的集合:

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

用户可以在配置里开启或关闭某些工具集:

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

每个工具集可能有对应的环境依赖:

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

只有满足依赖条件的工具集才会被加载。


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

erlang 复制代码
启动时
    │
    ▼
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. 自动发现

不需要在配置里列"我有哪些工具",代码自描述。这是 Java SPI、Python entry_points 那套思想,但更轻量。

3. Schema驱动

工具定义跟 OpenAI Function Calling Schema 直接对应。这套 Schema 已经成为事实标准------Anthropic、Google、Mistral 都兼容它。Hermes 基于标准做,天然兼容性好。

4. 条件启用

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


六、写一个新工具的实验

说了这么多,不如实战一下。假如我要给 Hermes 加一个"计算器"工具:

新建 tools/calculator.py

python 复制代码
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 注入攻击

代码位置: ~/.hermes/hermes-agent/tools/registry.py, model_tools.py
关键数字: 60+ 工具文件,20+ 工具集,1 个注册中心


元思未来 · 行稳致远,进而有为

相关推荐
studentliubo1 小时前
重生之点亮Agent技术栈--agent
agent·ai编程
鼎道开发者联盟2 小时前
跳出传统 RAG!用 LLM Wiki 构建闭环式产品 Agent 协作体系
agent·rag·hermes·llmwiki
Code_流苏2 小时前
DeepSeek V4 Flash测评:更快、更省,日常体验依旧很稳!
ai·agent·深度求索·日常体验·deepseek v4·高效模型
想ai抽3 小时前
hermes-kanban-技术架构学习与调研
ai·agent·hermes
HIT_Weston3 小时前
89、【Agent】【OpenCode】glob 工具提示词(参数内容)
人工智能·agent·opencode
后端小肥肠3 小时前
一人公司如何用 WorkBuddy + Obsidian 搭一套长期记忆系统?
人工智能·aigc·agent
zavoryn3 小时前
Harness Agent 的工程化底座
agent
pixle03 小时前
LangChain v1.2 Text-to-SQL 实战:从入门到生产级部署
sql·langchain·agent·智能助手·text-to-sql