Hermes Agent 源码探秘 (4):工具系统 — Agent 的"双手"
上篇我们拆了核心循环。Agent 的核心决策逻辑其实挺简单:LLM 来决定"用什么工具",然后执行它。

那么问题就来了——这些工具是怎么注册进去的?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 注入攻击