第七篇:工具调用 —— 让 Agent 拥有“手脚”

从 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 版本撰写,实际代码可能随项目迭代有所变化,建议结合最新源码阅读。

相关推荐
王解1 天前
第五篇:与 LLM 对话 —— 模型接口封装与 Prompt 工程
prompt·nanobot
王解1 天前
第四篇:万能接口 —— 插件系统设计与实现
人工智能·nanobot
王解3 天前
第一篇:初识 nanobot —— 一个微型 AI Agent 的诞生
人工智能·nanobot
王解5 天前
深入解析nanobot的原理与架构
nanobot
君哥聊编程8 天前
生产级AI战斗机NanoBot 体验(OpenClaw极简实现)
人工智能·ai·大模型·openclaw·nanobot
程序员橘子皮10 天前
Nanobot + 智谱 GLM-4.7 使用教程
openclaw·nanobot