第四篇:万能接口 —— 插件系统设计与实现

@tool 装饰器到动态加载,看 nanobot 如何打造可插拔的"瑞士军刀"

在前两篇文章中,我们剖析了 nanobot 的核心循环(AgentLoop)和整体架构。AgentLoop 负责"思考",但真正让它"行动"的,是插件系统------一个能够动态加载工具、监听事件、响应命令的万能接口。

如果把 nanobot 比作智能手机,那么:

  • 核心循环是操作系统
  • 插件就是 App------打电话、发短信、查天气,全凭插件实现
  • 插件系统则是 App Store 和运行时的总和

今天,我们就来深入解析这个"App Store"的源码实现。


1. 插件系统的设计哲学

nanobot 的插件系统遵循三个核心原则:

  1. 极简注册:一个装饰器就能把普通函数变成 AI 可调用的工具
  2. 动态加载:启动时扫描目录,自动发现并加载所有插件
  3. 事件驱动:插件不仅可以作为工具被调用,还能监听系统事件(如消息到达、定时触发)

这种设计让 nanobot 具备了极强的扩展性------你不需要修改核心代码,只需在 plugins/ 目录下放一个 Python 文件,就能为 Agent 增加新能力。


2. 插件定义规范:两种编写方式

nanobot 支持两种插件定义方式:函数式(推荐)类式(高级)

2.1 函数式插件:@tool 装饰器

对于大多数场景,你只需要写一个普通函数,加上 @tool 装饰器:

python 复制代码
# plugins/weather.py
from nanobot import tool

@tool(description="获取指定城市的天气")
def get_weather(city: str) -> str:
    """调用天气 API 并返回结果"""
    # 实际实现...
    return f"{city} 的天气是晴天,25℃"

这就是一个完整的插件!@tool 装饰器做了三件事:

  • 将函数注册到工具注册表
  • 解析函数签名,自动生成 JSON Schema(供 LLM 理解参数)
  • 将函数包装成可异步执行的 Tool 对象

2.2 类式插件:继承 Plugin 基类

对于需要监听多种事件、维护状态的复杂插件,可以继承 Plugin 基类:

python 复制代码
# plugins/reminder.py
from nanobot import Plugin, on_command, on_event

class ReminderPlugin(Plugin):
    async def on_load(self):
        """插件加载时调用,可用于初始化"""
        self.reminders = []
    
    @on_command("remind", description="设置提醒")
    async def set_reminder(self, time: str, message: str):
        # 处理 /remind 命令
        pass
    
    @on_event("message")
    async def on_message(self, message):
        # 监听所有消息
        if "提醒我" in message.content:
            await self.set_reminder(...)

基类 Plugin 定义了以下生命周期方法:

方法 触发时机 用途
on_load() 插件加载后 初始化资源
on_unload() 插件卸载前 清理资源
on_message(message) 每条消息到达 全局消息监听
on_command(name, args) 命令匹配时 响应特定命令
on_event(event_type, data) 系统事件发布时 响应内部事件

3. 插件加载器:动态发现与导入

当 nanobot 启动时,PluginLoader 会扫描指定目录(默认为 ~/.nanobot/plugins/ 和内置的 agent/tools/),动态加载所有插件。

3.1 加载流程图



启动PluginLoader
扫描插件目录
遍历所有.py文件
尝试导入模块
模块中是否有tool或Plugin子类?
注册到PluginRegistry
忽略
调用插件的on_load方法
完成加载

3.2 核心代码解析:动态导入

loader.py 中的核心逻辑使用了 importlib 动态导入模块:

python 复制代码
# agent/loader.py
import importlib.util
from pathlib import Path

class PluginLoader:
    def __init__(self, registry):
        self.registry = registry
        self.plugin_dirs = [
            Path.home() / ".nanobot" / "plugins",
            Path(__file__).parent / "tools"  # 内置工具
        ]
    
    async def load_all(self):
        for directory in self.plugin_dirs:
            if not directory.exists():
                continue
            for py_file in directory.glob("*.py"):
                await self._load_plugin_from_file(py_file)
    
    async def _load_plugin_from_file(self, file_path):
        # 动态导入模块
        module_name = file_path.stem
        spec = importlib.util.spec_from_file_location(module_name, file_path)
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
        
        # 寻找插件定义
        for attr_name in dir(module):
            attr = getattr(module, attr_name)
            # 检查是否是 Plugin 子类
            if isinstance(attr, type) and issubclass(attr, Plugin) and attr != Plugin:
                plugin_instance = attr()
                await plugin_instance.on_load()
                self.registry.register_plugin(plugin_instance)
            # 检查是否有 @tool 装饰的函数
            elif hasattr(attr, "__nanobot_tool__"):  # 装饰器添加的标记
                self.registry.register_tool(attr)

这里有一个巧妙的设计:@tool 装饰器会在函数上添加一个 __nanobot_tool__ = True 属性,加载器通过这个标记识别工具函数,而不需要依赖全局变量。


4. 插件注册与发现:PluginRegistry

PluginRegistry 是插件系统的数据中心,维护着所有已加载的插件和工具,并提供查询接口。

4.1 注册表的数据结构

python 复制代码
class PluginRegistry:
    def __init__(self):
        self.plugins = []          # 所有插件实例
        self.tools = {}            # name -> Tool 对象
        self.commands = {}         # command_name -> (plugin, method)
        self.event_handlers = defaultdict(list)  # event_type -> [handlers]
    
    def register_tool(self, func):
        tool = Tool.from_function(func)
        self.tools[tool.name] = tool
    
    def register_plugin(self, plugin):
        self.plugins.append(plugin)
        # 扫描插件中的命令和事件处理器
        for method_name in dir(plugin):
            method = getattr(plugin, method_name)
            if hasattr(method, "__nanobot_command__"):
                cmd_info = method.__nanobot_command__
                self.commands[cmd_info.name] = (plugin, method)
            if hasattr(method, "__nanobot_event__"):
                event_type = method.__nanobot_event__
                self.event_handlers[event_type].append((plugin, method))

4.2 处理冲突:同名命令怎么办?

如果两个插件注册了相同的命令名,默认行为是后者覆盖前者(按加载顺序)。但用户可以通过配置指定优先级:

json 复制代码
{
  "plugins": {
    "priority": ["weather", "reminder"]  // weather 插件的命令优先级更高
  }
}

在注册时,如果检测到命令名冲突,会检查优先级列表,保留优先级高的插件命令。


5. 钩子与装饰器:背后的魔法

装饰器是 nanobot 插件系统的"语法糖",让注册变得极其简单。下面我们看看 @tool@on_command 是如何实现的。

5.1 @tool 装饰器源码

python 复制代码
# agent/decorators.py
def tool(*, name=None, description=None):
    def decorator(func):
        # 设置元数据
        func.__nanobot_tool__ = True
        func.__nanobot_tool_name__ = name or func.__name__
        func.__nanobot_tool_description__ = description or func.__doc__
        
        # 解析函数签名,生成参数 schema
        schema = generate_schema_from_function(func)
        func.__nanobot_tool_schema__ = schema
        
        return func
    return decorator

当装饰器被调用时,它会在原函数上附加元数据。之后 Tool.from_function 会读取这些元数据创建 Tool 对象:

python 复制代码
class Tool:
    @classmethod
    def from_function(cls, func):
        return cls(
            name=func.__nanobot_tool_name__,
            description=func.__nanobot_tool_description__,
            schema=func.__nanobot_tool_schema__,
            callable=func
        )

5.2 @on_command 装饰器源码

python 复制代码
def on_command(name, *, description=""):
    def decorator(method):
        method.__nanobot_command__ = CommandInfo(name, description)
        return method
    return decorator

这个装饰器同样只是在方法上附加信息,注册过程由 PluginRegistry 在扫描插件时完成。


6. 实战示例:编写一个计算器插件

让我们动手写一个完整的计算器插件,体验从编写到使用的全过程。

6.1 插件代码

~/.nanobot/plugins/calculator.py 中写入:

python 复制代码
from nanobot import tool

@tool(description="执行基本数学运算")
def calculate(expression: str) -> float:
    """
    计算数学表达式,例如 '2 + 2' 或 'sqrt(16)'
    
    参数:
        expression: 要计算的数学表达式字符串
    
    返回:
        计算结果
    """
    # 安全起见,使用受限的 eval(实际生产环境建议用更安全的方法)
    allowed_names = {
        k: v for k, v in math.__dict__.items() if not k.startswith("__")
    }
    allowed_names.update({"abs": abs, "round": round})
    
    try:
        # 编译表达式
        code = compile(expression, "<string>", "eval")
        for name in code.co_names:
            if name not in allowed_names:
                raise NameError(f"使用未允许的名称: {name}")
        result = eval(code, {"__builtins__": {}}, allowed_names)
        return float(result)
    except Exception as e:
        return f"计算错误: {str(e)}"

6.2 加载与注册

保存文件后,重启 nanobot(或向进程发送 SIGHUP 触发重载)。启动日志中会出现:

复制代码
[INFO] 发现新插件: calculator
[INFO] 注册工具: calculate

6.3 在对话中使用

用户发送消息:"计算 2+2 等于多少?"

AgentLoop 将用户消息和工具描述发给 LLM,LLM 返回工具调用请求:

json 复制代码
{
  "tool_calls": [{
    "name": "calculate",
    "arguments": {"expression": "2+2"}
  }]
}

AgentLoop 执行 calculate("2+2"),得到结果 4,将结果作为观察继续推理,最终回答用户:"2+2 等于 4。"

6.4 调试与验证

如果想验证插件是否被正确加载,可以使用 nanobot agent --debug 模式运行,日志会详细显示工具调用过程:

复制代码
[DEBUG] 可用工具: ['calculate', 'shell', 'read_file', ...]
[DEBUG] 调用工具 calculate(expression='2+2')
[DEBUG] 工具返回: 4.0

7. 高级特性:MCP 协议集成

除了直接编写 Python 插件,nanobot 还支持通过 MCP (Model Context Protocol) 集成外部服务。MCP 是 Anthropic 提出的标准,允许 AI 客户端发现和使用远程工具。

config.json 中配置 MCP Server:

json 复制代码
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"]
    }
  }
}

nanobot 会启动这些 MCP Server 进程,并通过 stdio 通信,将它们提供的工具也注册到工具注册表中。这样,插件系统的边界就从本地 Python 扩展到了任何支持 MCP 的服务。


8. 插件系统的设计智慧

回顾整个插件系统的实现,我们可以总结出几个关键设计点:

设计要点 解决的问题 实现方式
装饰器标记 简洁的声明式注册 在函数/方法上附加元数据
动态导入 无需重启即可加载新插件 importlib 扫描目录
统一的工具 Schema 让 LLM 理解工具调用 从函数签名生成 JSON Schema
插件优先级 处理命令名冲突 配置化优先级列表
MCP 集成 突破语言边界,复用生态 通过 stdio 与外部进程通信
事件钩子 插件间解耦协作 基于 PluginRegistry 的事件分发

正是这些设计,让 nanobot 能够在 4000 行代码内实现一个功能强大且易于扩展的插件系统。


下篇预告

在下一篇文章中,我们将探讨 nanobot 如何与各种 LLM 提供商交互------从 OpenAI 到本地 vLLM,从 API 调用到流式响应。你将看到:

  • LiteLLMProvider 如何统一封装 20+ 种模型
  • 对话历史如何管理,Token 如何计数
  • 流式输出如何在异步环境中实现

敬请期待:《与 LLM 对话 ------ 模型接口封装与 Prompt 工程》


本文基于 nanobot v0.1.3 版本撰写,实际代码可能随项目迭代有所变化,建议结合最新源码阅读。

相关推荐
一只理智恩1 小时前
向量数据库在AI领域的核心作用、优势与实践指南
数据库·人工智能
deephub1 小时前
深入RAG架构:分块策略、混合检索与重排序的工程实现
人工智能·python·大语言模型·rag
DeepModel2 小时前
【回归算法】多项式核回归详解
人工智能·数据挖掘·回归
人工智能研究所2 小时前
从 0 开始学习人工智能——什么是推理模型?
人工智能·深度学习·学习·机器学习·语言模型·自然语言处理
mtouch3332 小时前
三维沙盘系统配置管理数字沙盘模块
人工智能·ai·ar·vr·虚拟现实·电子沙盘·数字沙盘
Peter·Pan爱编程2 小时前
打造私有AI助理:OpenClaw + Ollama本地大模型 + 飞书机器人全接入指南
人工智能·机器人·飞书
Hy行者勇哥2 小时前
Claude Code 类似软件全景对比:差异、成本与选型(技术分享)
大数据·人工智能·学习方法
Hy行者勇哥2 小时前
国产 AI 编程助手全景:哪些像 Claude Code?哪些可平替?差异与成本(技术分享)
人工智能·学习方法
minhuan2 小时前
大模型应用:情感分析:用Stacking堆叠集成+大模型实现1+1>2的AI决策.92
人工智能·集成学习·情感分析·stacking堆叠集成