从 Tool 抽象基类到动态执行,看 nanobot 如何让 LLM 真正"动手做事"
在前六篇文章中,我们完整地走过了 nanobot 的架构、核心循环、插件系统和记忆机制。一个 Agent 如果只会"说"不会"做",那它永远只是一个高级聊天机器人。真正的智能体需要工具------能够操作文件、执行命令、访问网络、调用 API,真正改变外部世界。
nanobot 的工具系统设计得非常精巧:它通过统一的 Tool 抽象基类,将各种能力封装成标准化的接口;通过 ToolRegistry 动态管理工具;通过 JSON Schema 让 LLM 理解如何调用工具。这套机制让 Agent 拥有了"手脚",可以真正地"动手做事"。
如果把 Agent 比作一个人:
- LLM 是大脑,负责思考决策
- 工具系统 是手脚,负责执行动作
- 工具注册表 是工具箱,告诉大脑"你有什么工具可用"
- 工具调用 是大脑指挥手脚的过程
今天,我们就来深入解析这套工具系统的源码实现。
1. 工具系统的整体架构
nanobot 的工具系统采用分层设计,从定义到执行有一条清晰的链路:
反馈层
定义层
注册层
调用层
Tool 抽象基类
内置工具实现
ToolRegistry
工具注册表
LLM 生成工具调用
参数解析与验证
工具执行
结果格式化
作为观察返回给LLM
1.1 工具系统的核心职责
| 组件 | 职责 | 源码位置 |
|---|---|---|
Tool 抽象基类 |
定义工具的统一接口 | agent/tools/base.py |
ToolRegistry |
管理所有工具的注册和发现 | agent/tools/registry.py |
| 内置工具 | 文件操作、Shell、网络搜索等 | agent/tools/ 目录 |
| 参数验证器 | 基于 JSON Schema 验证工具参数 | agent/tools/base.py |
| 执行引擎 | 实际执行工具调用 | agent/loop.py 中的 _execute_tool_calls |
2. Tool 抽象基类:工具的"标准接口"
所有工具都必须继承 Tool 抽象基类,并实现几个关键方法。这种设计确保了工具调用的统一性。
2.1 Tool 基类源码
python
# agent/tools/base.py
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
import json
class Tool(ABC):
"""所有工具的抽象基类"""
@property
@abstractmethod
def name(self) -> str:
"""工具名称,供LLM调用时使用"""
pass
@property
@abstractmethod
def description(self) -> str:
"""工具描述,LLM根据描述决定是否调用此工具"""
pass
@property
def parameters(self) -> Dict[str, Any]:
"""
工具参数Schema(JSON Schema格式)
默认从 execute 方法的签名自动生成
"""
return self._generate_schema_from_method()
async def execute(self, **kwargs) -> str:
"""
执行工具的核心方法
子类必须实现此方法
"""
# 1. 参数验证
self._validate_params(kwargs)
# 2. 执行具体逻辑
result = await self._execute_impl(**kwargs)
# 3. 结果格式化
return self._format_result(result)
@abstractmethod
async def _execute_impl(self, **kwargs) -> Any:
"""子类实现的具体执行逻辑"""
pass
def _validate_params(self, params: Dict):
"""基于 JSON Schema 验证参数"""
import jsonschema
jsonschema.validate(instance=params, schema=self.parameters)
def _generate_schema_from_method(self) -> Dict:
"""
从 execute 方法的签名自动生成 JSON Schema
使用 inspect 模块分析参数类型和默认值
"""
import inspect
sig = inspect.signature(self._execute_impl)
schema = {
"type": "object",
"properties": {},
"required": []
}
for name, param in sig.parameters.items():
# 处理参数类型
if param.annotation != inspect.Parameter.empty:
param_type = self._pytype_to_jsontype(param.annotation)
else:
param_type = "string"
schema["properties"][name] = {
"type": param_type,
"description": f"Parameter {name}"
}
# 处理必需参数
if param.default == inspect.Parameter.empty:
schema["required"].append(name)
return schema
2.2 为什么需要参数 Schema?
当 LLM 决定调用工具时,它需要知道:
- 工具有哪些参数?
- 每个参数是什么类型?
- 哪些参数是必需的?
- 参数有什么格式要求?
JSON Schema 正是为此而生。nanobot 通过 parameters 属性为每个工具生成标准化的 Schema,LLM 在调用时就能按照 Schema 生成符合格式的参数。
3. 工具注册表:动态管理的"工具箱"
ToolRegistry 是所有工具的中央注册中心,负责工具的注册、发现和管理。
3.1 ToolRegistry 源码解析
python
# agent/tools/registry.py
from typing import Dict, List, Optional, Type
from .base import Tool
class ToolRegistry:
def __init__(self):
self._tools: Dict[str, Tool] = {}
self._tool_classes: Dict[str, Type[Tool]] = {}
def register(self, tool: Tool):
"""注册工具实例"""
self._tools[tool.name] = tool
logger.info(f"Registered tool: {tool.name}")
def register_class(self, tool_class: Type[Tool]):
"""注册工具类(延迟实例化)"""
self._tool_classes[tool_class.__name__] = tool_class
def get(self, name: str) -> Optional[Tool]:
"""根据名称获取工具"""
# 如果还没实例化,则实例化
if name not in self._tools and name in self._tool_classes:
tool_class = self._tool_classes[name]
self._tools[name] = tool_class()
return self._tools.get(name)
def get_all(self) -> List[Tool]:
"""获取所有已实例化的工具"""
# 确保所有工具都已实例化
for name in list(self._tool_classes.keys()):
if name not in self._tools:
self.get(name)
return list(self._tools.values())
def get_tool_schemas(self) -> List[Dict]:
"""
获取所有工具的描述和参数Schema
这个列表会直接传给LLM,让LLM知道有哪些工具可用
"""
schemas = []
for tool in self.get_all():
schemas.append({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters
}
})
return schemas
def register_builtin_tools(self):
"""注册所有内置工具"""
from .filesystem import ReadFileTool, WriteFileTool, ListDirTool
from .shell import ExecTool
from .web import WebSearchTool, WebFetchTool
from .message import MessageTool
from .subagent import SpawnTool
tools = [
ReadFileTool(),
WriteFileTool(),
ListDirTool(),
ExecTool(),
WebSearchTool(),
WebFetchTool(),
MessageTool(),
SpawnTool(),
]
for tool in tools:
self.register(tool)
3.3 工具注册的两种方式
| 方式 | 说明 | 适用场景 |
|---|---|---|
register(tool) |
直接注册工具实例 | 简单的无状态工具 |
register_class(tool_class) |
注册工具类,按需实例化 | 有状态或资源密集型的工具 |
按需实例化的好处是:如果某个工具从未被调用,就不会创建其实例,节省了内存和初始化时间。
4. 内置工具详解
nanobot 内置了丰富的工具集,覆盖了日常自动化任务的核心需求 。
4.1 文件系统工具
python
# agent/tools/filesystem.py
class ReadFileTool(Tool):
@property
def name(self) -> str:
return "read_file"
@property
def description(self) -> str:
return "读取指定文件的内容"
async def _execute_impl(self, path: str) -> str:
"""读取文件内容"""
try:
# 安全检查:防止读取工作目录外的文件
if not self._is_path_safe(path):
return f"错误:不允许访问 {path}(超出工作目录)"
async with aiofiles.open(path, 'r') as f:
content = await f.read()
return content
except FileNotFoundError:
return f"错误:文件 {path} 不存在"
except Exception as e:
return f"读取文件失败:{str(e)}"
def _is_path_safe(self, path: str) -> bool:
"""路径安全检查:确保只访问工作目录内的文件"""
import os
workspace = os.path.expanduser("~/.nanobot/workspace")
abs_path = os.path.abspath(path)
return abs_path.startswith(workspace)
class WriteFileTool(Tool):
@property
def name(self) -> str:
return "write_file"
@property
def description(self) -> str:
return "写入内容到指定文件"
async def _execute_impl(self, path: str, content: str) -> str:
"""写入文件内容"""
try:
# 确保目录存在
directory = os.path.dirname(path)
if directory and not os.path.exists(directory):
os.makedirs(directory)
async with aiofiles.open(path, 'w') as f:
await f.write(content)
return f"成功写入文件:{path}"
except Exception as e:
return f"写入文件失败:{str(e)}"
4.2 Shell 执行工具
python
# agent/tools/shell.py
class ExecTool(Tool):
@property
def name(self) -> str:
return "shell"
@property
def description(self) -> str:
return "执行 Shell 命令(支持超时和工作空间限制)"
@property
def parameters(self) -> Dict:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "要执行的 Shell 命令"
},
"timeout": {
"type": "integer",
"description": "超时时间(秒),默认 30",
"default": 30
}
},
"required": ["command"]
}
async def _execute_impl(self, command: str, timeout: int = 30) -> str:
"""执行 Shell 命令"""
import asyncio
# 安全检查:限制工作目录
workspace = os.path.expanduser("~/.nanobot/workspace")
try:
# 创建子进程
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace # 限制在工作目录内执行
)
# 等待执行结果(带超时)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout
)
except asyncio.TimeoutError:
process.kill()
return f"命令执行超时(超过 {timeout} 秒)"
# 组合输出
result = ""
if stdout:
result += f"标准输出:\n{stdout.decode()}\n"
if stderr:
result += f"标准错误:\n{stderr.decode()}\n"
if not result:
result = "命令执行完成,无输出"
return result
except Exception as e:
return f"命令执行失败:{str(e)}"
4.3 网络工具
python
# agent/tools/web.py
class WebSearchTool(Tool):
@property
def name(self) -> str:
return "web_search"
@property
def description(self) -> str:
return "使用 Brave Search 搜索网络信息"
async def _execute_impl(self, query: str, num_results: int = 5) -> str:
"""执行网络搜索"""
api_key = self.config.get("brave_api_key")
if not api_key:
return "错误:未配置 Brave Search API Key"
url = "https://api.search.brave.com/res/v1/web/search"
headers = {
"Accept": "application/json",
"Accept-Encoding": "gzip",
"X-Subscription-Token": api_key
}
params = {
"q": query,
"count": num_results
}
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers, params=params)
if response.status_code == 200:
data = response.json()
return self._format_results(data)
else:
return f"搜索失败:HTTP {response.status_code}"
class WebFetchTool(Tool):
@property
def name(self) -> str:
return "fetch_url"
@property
def description(self) -> str:
return "获取网页内容并提取主要文本"
async def _execute_impl(self, url: str) -> str:
"""获取网页内容"""
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, timeout=10)
response.raise_for_status()
# 简单的 HTML 文本提取
from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, 'html.parser')
# 移除 script 和 style 元素
for script in soup(["script", "style"]):
script.decompose()
# 获取文本
text = soup.get_text()
# 清理多余空白
lines = (line.strip() for line in text.splitlines())
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
text = '\n'.join(chunk for chunk in chunks if chunk)
# 限制长度
if len(text) > 10000:
text = text[:10000] + "...\n[内容过长,已截断]"
return text
except Exception as e:
return f"获取网页失败:{str(e)}"
4.4 子代理工具
子代理是 nanobot 的一个高级特性,允许主代理创建后台任务并行执行 。
python
# agent/tools/subagent.py
class SpawnTool(Tool):
@property
def name(self) -> str:
return "spawn"
@property
def description(self) -> str:
return "创建子代理处理后台任务(主代理可继续处理其他请求)"
async def _execute_impl__(self, task: str, tag: str = None) -> str:
"""
创建子代理
参数:
task: 子代理需要完成的任务描述
tag: 任务标签,用于后续识别
"""
# 创建子代理实例
subagent = SubAgent(
task_description=task,
tag=tag or f"subagent_{uuid.uuid4().hex[:8]}",
parent_session_id=self.session_id
)
# 启动子代理(后台任务)
asyncio.create_task(subagent.run())
return f"子代理已创建,任务标签:{tag},将在后台执行:{task}"
子代理的特点:
- 拥有独立的系统提示和执行环境
- 可以使用文件操作、Shell 命令、网络搜索等工具
- 不能发送消息给用户或创建其他子代理
- 最多执行 15 次迭代
- 完成后通过消息总线向主代理发送系统消息通知结果
5. 工具调用的完整流程
现在,让我们追踪一个工具调用从 LLM 决策到执行完成的完整生命周期。
5.1 时序图
具体工具 ToolRegistry LLM AgentLoop 用户 具体工具 ToolRegistry LLM AgentLoop 用户 loop [遍历每个工具调用] 发送消息 get_tool_schemas() 返回工具描述列表 调用 complete(messages, tools) 返回 tool_calls 解析工具名称和参数 get(tool_name) 返回工具实例 execute(**arguments) 参数验证(jsonschema) 执行具体逻辑 返回执行结果 将结果作为观察加入上下文 再次调用(带工具结果) 返回最终回答 发送响应
5.2 核心代码:工具调用的执行
在第三篇文章中,我们已经看到了 _execute_tool_calls 的基本实现。下面是更完整的版本:
python
# agent/loop.py (扩展版)
async def _execute_tool_calls(self, tool_calls):
"""并发执行多个工具调用"""
results = []
# 准备所有任务
tasks = []
for tc in tool_calls:
tool_name = tc.function.name
arguments = json.loads(tc.function.arguments)
# 获取工具实例
tool = self.tool_registry.get(tool_name)
if not tool:
results.append({
"tool": tool_name,
"error": f"工具 {tool_name} 不存在"
})
continue
# 创建执行任务
tasks.append(self._execute_single_tool(tool, arguments))
# 并发执行所有工具
if tasks:
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(raw_results):
tool_name = tool_calls[i].function.name
if isinstance(result, Exception):
results.append({
"tool": tool_name,
"error": str(result)
})
else:
results.append({
"tool": tool_name,
"result": result
})
return results
async def _execute_single_tool(self, tool: Tool, arguments: Dict):
"""执行单个工具(带超时和错误处理)"""
try:
# 执行工具(带超时)
result = await asyncio.wait_for(
tool.execute(**arguments),
timeout=self.config.tool_timeout
)
return result
except asyncio.TimeoutError:
return f"工具执行超时(超过 {self.config.tool_timeout} 秒)"
except Exception as e:
return f"工具执行失败:{str(e)}"
5.3 工具调用的安全机制
nanobot 在工具调用中实现了多层安全防护 :
| 安全机制 | 实现方式 | 作用 |
|---|---|---|
| 路径验证 | _is_path_safe() 检查 |
防止访问工作目录外的文件 |
| 工作目录限制 | cwd=workspace 参数 |
Shell 命令只能在工作目录执行 |
| 超时控制 | asyncio.wait_for() |
防止工具执行卡死 |
| 参数验证 | JSON Schema 校验 | 确保参数格式正确 |
| 并发限制 | 信号量控制 | 防止资源耗尽 |
| 频率限制 | 基于时间的计数器 | 防止工具被过度调用 |
6. 实战案例:让 Agent 写一个文件
让我们通过一个完整的例子,看看工具调用是如何工作的。
6.1 用户请求
用户发送消息:"帮我写一个 Python 脚本,打印斐波那契数列前 10 项,保存为 fibonacci.py"
6.2 LLM 的第一次推理
LLM 接收到用户消息和工具列表,决定调用 write_file 工具:
json
{
"tool_calls": [
{
"function": {
"name": "write_file",
"arguments": {
"path": "fibonacci.py",
"content": "# 斐波那契数列前10项\n\ndef fibonacci(n):\n a, b = 0, 1\n for _ in range(n):\n print(a, end=' ')\n a, b = b, a + b\n print()\n\nif __name__ == '__main__':\n print('斐波那契数列前10项:')\n fibonacci(10)"
}
}
}
]
}
6.3 AgentLoop 执行工具
AgentLoop 解析工具调用,通过 ToolRegistry 获取 WriteFileTool 实例,执行 execute(path="fibonacci.py", content=...)。
6.4 工具执行结果
工具执行成功,返回:
成功写入文件:fibonacci.py
6.5 LLM 的第二次推理
AgentLoop 将工具结果作为观察加入上下文,再次调用 LLM:
json
{
"messages": [
{"role": "user", "content": "帮我写一个 Python 脚本,打印斐波那契数列前 10 项,保存为 fibonacci.py"},
{"role": "assistant", "tool_calls": [...]},
{"role": "tool", "tool_call_id": "...", "content": "成功写入文件:fibonacci.py"}
]
}
LLM 看到工具执行成功,生成最终回答:
json
{
"content": "已为您创建 fibonacci.py 文件,内容如下:\n```python\n# 斐波那契数列前10项\n\ndef fibonacci(n):\n a, b = 0, 1\n for _ in range(n):\n print(a, end=' ')\n a, b = b, a + b\n print()\n\nif __name__ == '__main__':\n print('斐波那契数列前10项:')\n fibonacci(10)\n```\n您可以直接运行 `python fibonacci.py` 查看结果。"
}
6.6 用户收到响应
AgentLoop 将最终回答通过消息总线返回给用户。
整个过程体现了 ReAct 模式的精髓:推理→行动→观察→再推理,直到任务完成。
7. 工具系统的设计智慧
回顾整个工具系统的实现,我们可以总结出几个关键的设计智慧:
| 设计要点 | 解决的问题 | 实现方式 |
|---|---|---|
| Tool 抽象基类 | 统一所有工具的接口 | 定义 name/description/execute 等标准方法 |
| JSON Schema 自动生成 | 让 LLM 理解工具参数 | 从方法签名自动生成参数描述 |
| ToolRegistry 中心注册 | 动态管理所有工具 | 字典存储 + 按需实例化 |
| 并发执行 | 提升多工具调用效率 | asyncio.gather 并发执行 |
| 多层安全机制 | 防止恶意操作和资源滥用 | 路径验证、超时控制、频率限制 |
| 子代理机制 | 支持后台长时间任务 | 独立的 SubAgent 实例并行执行 |
| 工具结果反馈 | 让 LLM 看到执行效果 | 将结果作为观察加入上下文 |
正是这些设计,让 nanobot 能够在 4000 行代码内实现一个功能强大且安全可靠的工具系统。
下篇预告
在下一篇文章中,我们将探讨 nanobot 的配置系统与日志监控------这是框架可观测性和可管理性的基础。你将看到:
- 多环境配置如何管理
- Pydantic 如何实现配置校验
- 日志系统如何设计
- 性能指标如何收集
敬请期待:《内外兼修 ------ 配置系统与日志监控》
本文基于 nanobot v0.1.3 版本撰写,实际代码可能随项目迭代有所变化,建议结合最新源码阅读。