从
@tool装饰器到动态加载,看 nanobot 如何打造可插拔的"瑞士军刀"
在前两篇文章中,我们剖析了 nanobot 的核心循环(AgentLoop)和整体架构。AgentLoop 负责"思考",但真正让它"行动"的,是插件系统------一个能够动态加载工具、监听事件、响应命令的万能接口。
如果把 nanobot 比作智能手机,那么:
- 核心循环是操作系统
- 插件就是 App------打电话、发短信、查天气,全凭插件实现
- 插件系统则是 App Store 和运行时的总和
今天,我们就来深入解析这个"App Store"的源码实现。
1. 插件系统的设计哲学
nanobot 的插件系统遵循三个核心原则:
- 极简注册:一个装饰器就能把普通函数变成 AI 可调用的工具
- 动态加载:启动时扫描目录,自动发现并加载所有插件
- 事件驱动:插件不仅可以作为工具被调用,还能监听系统事件(如消息到达、定时触发)
这种设计让 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 版本撰写,实际代码可能随项目迭代有所变化,建议结合最新源码阅读。