系列: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 参数后,会做判断:
- 如果用户的问题可以通过回复文字解决 → 正常回复
- 如果需要调用工具 → 返回
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"]
)
每个工具只需要:
- 一个处理函数(真正干活的代码)
- 一个注册调用(告诉系统"有我这么个工具")
- 一个可选的检查函数(判断能不能用)
添加一个新工具,只需要创建一个文件,写一个函数,一个注册调用。三件事。 下一篇我可以用这个写个小例子。
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 个注册中心
元思未来 · 行稳致远,进而有为