找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2255

积分

0

好友

297

主题
发表于 1 小时前 | 查看: 2| 回复: 0

最近OpenClaw的火爆催生了许多衍生项目,其中超轻量级的nanobot 尤为引人注目,项目地址是 https://github.com/HKUDS/nanobot

nanobot项目主页,展示其徽标、下载量与核心特性

nanobot是一个超轻量级的个人AI助手框架,它实现了LLM对话、工具调用、多渠道通信(Discord/飞书)、会话记忆、定时任务和后台子Agent等核心能力。

根据官方文档,其核心Agent代码仅有约3510行,功能上却与OpenClaw的430k+行代码相似,体积小了99%!对于希望理解OpenClaw这类人工智能 Agent设计原理,但又对海量代码望而却步的开发者来说,nanobot无疑是一个极佳的学习和研究样本。项目作者也将其“研究就绪”作为核心卖点之一:

nanobot关键特性:超轻量、研究就绪、快速、易用

这足以体现作者对代码质量的信心。本文旨在深入解析nanobot的源码,借此探究OpenClaw的设计精髓,并学习其精巧的工程实现。

项目骨架

整体架构

首先,我们通过官方架构图来建立整体认知:

Agent Loop架构流程图,展示消息从聊天软件到LLM、工具交互再返回的完整循环

流程非常清晰:用户通过聊天软件发送消息,LLM接收消息后,为完成任务会调用各种工具(如联网搜索、文件操作)。工具执行结果会被拼接到AI的上下文中,LLM据此决定是回复用户还是继续调用工具。这个循环会持续直到问题解决或任务中断。Memory(记忆)和Skills(技能)作为Context(上下文)贯穿整个过程,为LLM提供状态和能力支持。

对应到项目代码的目录结构中,各个包的功能划分如下:

nanobot项目目录结构及各模块功能说明

通过包名基本可以猜到其职责,后文会逐一详解。

核心配置文件

我们先看项目的核心配置文件 pyproject.toml。其依赖项非常精简,都是常用库,这大大降低了学习门槛。

nanobot项目依赖清单,包含typer、litellm、pydantic等常用库

此外,配置中有一行核心内容:

[project.scripts]
nanobot = "nanobot.cli.commands:app"

这表示通过pip或uv安装后,系统会生成一个名为 nanobot 的命令,其入口点位于 nanobot.cli.commands 包下的 app 对象。这个 app 就是整个项目的命令行入口,后文会详细分析。

基建服务

接下来分析项目的几个基础服务模块,主要是 bus(消息总线)和 providers.base,它们定义了消息格式、传输和LLM接口等核心概念。

bus 消息总线

bus/events.py 定义了两个核心消息类:用户发送的 InboundMessage 和AI回复的 OutboundMessage

bus/queue.py 则定义了两个异步队列及对应的发布/消费方法,代码简洁明了:

MessageBus核心方法:发布/消费入站和出站消息

bus/                         消息总线
├── events.py                定义两种消息格式
│                              ├── InboundMessage  → 进来的消息(用户发的)
│                              └── OutboundMessage → 出去的消息(回复给用户的)
└── queue.py                 MessageBus:两个异步队列 + 订阅分发机制
                                ├── inbound 队列    → 渠道放消息,Agent 取消息
                                └── outbound 队列   → Agent 放回复,渠道取回复

这种设计的最大优势是实现了解耦。Agent和各聊天渠道不直接通信,所有消息都通过统一的总线。不同渠道(如QQ、飞书)的原始消息格式会被转换为统一的 InboundMessage,Agent无需关心来源。同样,Agent只负责生成 OutboundMessage 并放到总线,由各渠道自行处理发送细节,避免了核心逻辑与渠道API的耦合。

试想没有 OutboundMessage 的混乱场景:

# ❌ 没有 OutboundMessage 的情况
if msg.channel == "telegram":
    html = markdown_to_html(final_content)
    await telegram_bot.send_message(chat_id=int(msg.chat_id), text=html, parse_mode="HTML")
elif msg.channel == "discord":
    await http.post(f"https://discord.com/api/channels/{msg.chat_id}/messages", ...)
elif msg.channel == "feishu":
    receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
    await feishu_client.send(...)
elif msg.channel == "whatsapp":
    await ws.send(json.dumps({"type": "send", "to": msg.chat_id, ...}))

所有发送逻辑都耦合在Agent核心模块中,每增加一个新渠道就得修改核心代码。有了消息总线,这些问题迎刃而解。

providers.base.py

这个文件定义了 LLMProvider 抽象类,其核心方法是 chat,返回类型为 LLMResponse。这意味着所有要接入的大模型服务都需要继承 LLMProvider 并实现 chat 方法,且响应必须转换为 LLMResponse 类型。

LLMResponse数据类定义,包含content、tool_calls等关键字段

这种抽象设计的好处显而易见:无论接入多少种大模型,都只需要编写新的实现类,上层调用逻辑完全统一。

工具系统

工具是AI与外界交互的手脚。nanobot内置了10个工具,涵盖文件操作、Shell命令、网络搜索、主动发消息、后台子任务、定时任务等能力,全部位于 agent/tools/ 目录下:

agent/tools/
├── base.py         # 工具抽象基类(所有工具的模板)
├── registry.py     # 工具注册表(管理所有工具)
├── filesystem.py   # 文件工具:读、写、编辑、列目录
├── shell.py        # Shell 工具:执行命令
├── web.py          # 网络工具:搜索、抓取网页
├── message.py      # 消息工具:主动给用户发消息
├── spawn.py        # 子任务工具:启动后台 Agent
└── cron.py         # 定时工具:创建/管理定时任务

工具基类 Tool

base.py 中定义了所有工具的抽象基类 Tool。任何工具都必须提供以下四个部分:

属性/方法 类型 作用
name 属性 工具名称,如 "read_file""exec"
description 属性 工具描述,告诉LLM这个工具能做什么
parameters 属性 参数定义,用JSON Schema描述,告诉LLM需要哪些参数
execute() 方法 实际执行逻辑,接收参数,返回字符串结果

除了这些必需接口,Tool 基类还提供了两个通用方法:

  • to_schema(): 将工具信息转换为OpenAI function calling格式。这份“说明书”让LLM知道有哪些工具可用以及如何使用。
  • validate_params(): 在执行前校验LLM传来的参数是否合法。

Tool抽象基类定义,包含name、description、parameters等抽象属性及execute方法

这里有一个关键设计:execute() 方法的返回值统一是字符串。无论工具功能是读文件、执行命令还是搜索网页,最终都返回 str。因为工具执行结果需要拼接回LLM的上下文,统一返回字符串最为简单直接。

工具注册表 ToolRegistry

有了工具模板,还需要一个中心化的管理器。registry.py 中的 ToolRegistry 就是为此而生,其内部维护了一个 dict[str, Tool] 字典,以工具名称为键。

ToolRegistry的register方法,用于注册工具

它有三个核心方法:

  • register(tool): 注册一个工具到字典中。
  • get_definitions(): 遍历所有工具,调用各自的 to_schema() 方法,生成供LLM使用的工具列表。
  • execute(name, params): 根据工具名找到对应对象,先调用 validate_params() 校验参数,再执行 execute()

execute() 方法的错误处理也很有借鉴意义:如果工具执行出错,它不会抛出异常,而是返回一个以 "Error:" 开头的字符串。这样LLM收到错误信息后可以自行决定下一步(如重试或告知用户),增强了Agent的鲁棒性。

那么,这些工具是在何时注册到 ToolRegistry 中的呢?答案在 agent/loop.py_register_default_tools() 方法里:

_register_default_tools方法,注册文件、Shell、网络等默认工具集

系统初始化时,10个内置工具会被全部注册。至此,工具系统的脉络已清晰:Tool 定义模板 → 具体工具类实现 → ToolRegistry 统一管理 → Agent启动时注册。

LLM Provider

如果说工具是AI的手脚,那么LLM就是AI的大脑。我们已经见过 LLMProvider 抽象类,其唯一实现是 LiteLLMProvider

为什么只需要一个实现类?因为nanobot借助了 LiteLLM 这个开源库来统一不同大模型(OpenAI、Anthropic、Gemini、DeepSeek、智谱、通义千问等)的API调用。LiteLLMProvider 的核心 chat() 方法流程如下:

第一步:模型前缀处理
LiteLLM约定不同来源的模型需要不同前缀(如 openrouter/, dashscope/)。chat() 方法会根据配置的模型名自动补全前缀。

chat方法中的模型前缀处理逻辑,适配不同厂商模型

例如,用户在配置中写 "qwen-max",代码会自动转为 "dashscope/qwen-max"

第二步:调用LiteLLM
前缀处理完后,构建参数并调用LiteLLM的 acompletion 异步接口。如果传入了 tools 参数,会设置 tool_choice = "auto",让LLM自行决定是否调用工具。

第三步:解析响应
_parse_response() 方法将LiteLLM返回的复杂响应格式,解析为统一的 LLMResponse 对象。

上下文构建

每次调用LLM,都需要为其组装完整的上下文,包括身份、记忆、技能和历史对话。这项工作由 ContextBuilder 完成,它依赖 MemoryStoreSkillsLoader 两个数据源。

agent/memory.py 中的 MemoryStore 管理Agent的记忆,采用朴素的Markdown文件存储:

  • 长期记忆: workspace/memory/MEMORY.md,记录跨越时间的重要信息。
  • 每日笔记: workspace/memory/YYYY-MM-DD.md,按天记录。
    其核心方法 get_memory_context() 会将长期记忆和今日笔记拼接为文本,注入到system prompt中。

agent/skills.py 中的 SkillsLoader 负责加载Agent技能(也是Markdown文件)。技能有两个来源,优先级从高到低:

  1. workspace技能:用户放在 workspace/skills/ 下的自定义技能。
  2. 内置技能:项目自带的 nanobot/skills/ 目录下的技能。

加载策略采用了巧妙的渐进式加载以节省Token:

  • always技能:标记了 always=true 的技能,其完整内容会直接注入prompt。
  • 按需技能:仅注入一个包含名称、描述和路径的摘要列表。当Agent需要时,可以使用 read_file 工具去读取完整内容。
    
    # 1. Always-loaded skills: 完整内容注入
    always_skills = self.skills.get_always_skills()

2. Available skills: 只注入摘要

skills_summary = self.skills.build_skills_summary()


`agent/context.py` 中的 `ContextBuilder` 负责最终组装。`build_system_prompt()` 方法按以下顺序构建系统提示词:
1.  **核心身份** (`_get_identity()`): 如“你是nanobot”,当前时间、系统信息等。
2.  **引导文件** (`_load_bootstrap_files()`): 加载 `AGENTS.md`(行为准则)、`SOUL.md`(人格)、`USER.md`(用户画像)等。
3.  **记忆** (`memory.get_memory_context()`): 长期记忆 + 今日笔记。
4.  **技能** (`skills`): always技能完整内容 + 其他技能摘要列表。

`build_messages()` 方法则在上述system prompt基础上,加入来自Session的历史对话和当前用户消息,构成最终发送给LLM的messages列表。

## 会话管理
`session/manager.py` 定义了 `Session` 和 `SessionManager`。
- `Session` 代表与某个用户的一次对话,核心是一个消息列表 `messages`。
- `SessionManager` 通过 `session_key`(格式为`渠道:聊天ID`,如`feishu:12345`)来区分和管理不同会话。

其核心逻辑包括内存缓存(避免频繁读盘)和磁盘持久化。每个会话存储为一个 `.jsonl` 文件,每行一条消息。`get_or_create(key)` 方法遵循:先查缓存 → 再查磁盘 → 都没有则新建。

![SessionManager的get_or_create和save方法,实现会话的缓存与持久化](https://static1.yunpan.plus/attachment/e3d9ab10313208cb.webp)

JSONL格式简单且易于追加,虽然当前 `save()` 实现是全量写入而非追加。

## Agent Loop(核心模块)
`agent/loop.py` 中的 `AgentLoop` 是整个项目的“大脑”,它将前述所有组件汇聚一堂。

### 组装零件
看其 `__init__` 方法接收的参数,就能对应到之前的各个模块:`bus`(消息总线)、`provider`(LLM服务)、`workspace`(工作空间)等。初始化过程中,它创建了三个核心成员:
```python
self.context = ContextBuilder(workspace)    # 上下文组装
self.sessions = SessionManager(workspace)   # 会话管理
self.tools = ToolRegistry()                 # 工具注册表

随后调用 _register_default_tools() 注册所有默认工具。

运行循环

run() 方法是Agent的主循环。它持续从消息总线的inbound队列中消费 InboundMessage,处理后再将结果作为 OutboundMessage 发布回总线。

AgentLoop的run方法,展示了取消息、处理消息、发布响应的核心循环

这里使用了 asyncio.wait_for 并设置1秒超时,以实现非阻塞轮询,使得 stop() 方法可以随时中断循环。

核心方法 _process_message()

这是最核心的方法,处理一条用户消息分为四个阶段:

1) 准备会话

session = self.sessions.get_or_create(msg.session_key)  # 获取或创建会话

2) 组装上下文

messages = self.context.build_messages(
    history=session.get_history(),      # 历史对话
    current_message=msg.content,        # 当前用户消息
    channel=msg.channel,                # 渠道信息
    chat_id=msg.chat_id,                # 聊天 ID
)

3) LLM ↔ Tools 循环(ReAct模式)

while iteration < self.max_iterations:
    iteration += 1

    # 调 LLM
    response = await self.provider.chat(
        messages=messages,
        tools=self.tools.get_definitions(),
    )

    # LLM 要调用工具?
    if response.has_tool_calls:
        # 把 LLM 的回复(包含工具调用)追加到 messages
        messages = self.context.add_assistant_message(messages, response.content, tool_call_dicts)

        # 执行工具,把结果追加到 messages
        for tool_call in response.tool_calls:
            result = await self.tools.execute(tool_call.name, tool_call.arguments)
            messages = self.context.add_tool_result(messages, tool_call.id, tool_call.name, result)
    else:
        # LLM 不需要工具了,直接输出文本回复
        final_content = response.content
        break

这个 while 循环就是官方架构图中 Agent Loop 的具体实现。它完美体现了ReAct(Reasoning + Acting)模式:LLM推理决策 → 执行工具动作 → 根据结果再次推理,循环直到任务完成或达到最大迭代次数。

Agent Loop流程图,展示LLM与工具的交互循环

4) 收尾

session.add_message("user", msg.content)
session.add_message("assistant", final_content)
self.sessions.save(session)                      # 保存会话

return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=final_content)

CLI快捷入口

除了通过消息总线处理消息的 run() 方法,AgentLoop 还提供了 process_direct() 方法。它直接构造 InboundMessage 并调用 _process_message(),绕过了消息总线,主要用于命令行模式 nanobot agent -m "xxx"

渠道系统

渠道系统负责连接外部聊天平台,处理消息的出入站。

渠道基类 BaseChannel

channels/base.py 定义的 BaseChannel 是所有渠道的抽象基类,要求子类实现 start()send(msg)stop() 三个方法。基类还提供了白名单检查 (is_allowed) 和统一的消息预处理方法 (_handle_message)。

渠道管理器 ChannelManager

channels/manager.py 中的 ChannelManager 负责管理所有渠道。它运行一个后台任务,持续从消息总线的outbound队列消费消息,并根据 msg.channel 字段路由到对应渠道的 send() 方法。

msg = await self.bus.consume_outbound()     # 从总线取消息
channel = self.channels.get(msg.channel)    # 找到对应渠道
await channel.send(msg)                     # 发送

至此,出站流程完全串联:AgentLoopbus.publish_outbound()ChannelManager._dispatch_outbound()Channel.send() → 用户。

定时任务、心跳与子任务

除了主对话流程,nanobot还提供了一些重要的辅助能力。

定时任务 CronService

定义于 cron/ 目录下。其核心是一个定时器循环,计算并执行到期的 CronJob。巧妙之处在于,任务的执行是通过回调函数 on_job 交给Agent处理的(在 cli/commands.py 中设置),本质上是定时给Agent发送一条消息,完全复用现有的Agent Loop能力。

心跳服务 HeartbeatService

heartbeat/service.py 实现了一个简单实用的功能:每隔30分钟(默认)读取 workspace/HEARTBEAT.md 文件,如果有内容,则将其作为prompt发给Agent处理。这相当于给AI设置了一个定期检查待办事项的“闹钟”。

子任务 SubagentManager

agent/subagent.py 中的 SubagentManager 解决了耗时任务阻塞主对话的问题。用户可以通过 SpawnTool 触发一个独立的后台子Agent来执行长任务。子Agent完成后,通过消息总线通知主Agent,再由主Agent告知用户。

_announce_result方法,子任务完成时通过消息总线通知主Agent

这一设计巧妙地复用了现有的消息总线机制,保持了架构的简洁。

CLI入口与整体组装

cli/commands.py 是项目的组装车间,使用Typer框架定义命令,将所有模块整合为可运行的程序。几个关键命令:

  • nanobot onboard: 初始化配置和工作空间。
  • nanobot agent -m "xxx": 单次对话模式,展示了最核心的组装逻辑。
  • nanobot gateway: 启动完整网关,这是最复杂的命令,它按顺序创建并启动了所有核心服务:
# 1. 基础组件
config = load_config()                          # 加载配置
bus = MessageBus()                              # 消息总线
provider = LiteLLMProvider(...)                 # LLM 提供商

# 2. 定时任务
cron = CronService(store_path)                  # 创建 Cron 服务

# 3. Agent Loop(核心)
agent = AgentLoop(bus, provider, workspace, ..., cron_service=cron)

# 4. 设置 Cron 回调(定时任务通过 agent.process_direct() 执行)
async def on_cron_job(job: CronJob) -> str | None:
    response = await agent.process_direct(job.payload.message, ...)
    return response
cron.on_job = on_cron_job

# 5. 心跳服务
async def on_heartbeat(prompt: str) -> str:
    return await agent.process_direct(prompt, session_key="heartbeat")
heartbeat = HeartbeatService(workspace, on_heartbeat=on_heartbeat, ...)

# 6. 渠道管理
channels = ChannelManager(config, bus)

# 7. 全部启动
await cron.start()
await heartbeat.start()
await asyncio.gather(agent.run(), channels.start_all())

Cron与Heartbeat服务的回调设置,均指向agent.process_direct方法

最后的 asyncio.gather() 并发启动了Agent主循环和所有渠道监听。注意,定时任务和心跳服务的回调最终都指向 agent.process_direct(),这再次体现了“一切皆消息”的设计哲学和惊人的代码复用。

全链路回顾

让我们以一个具体场景(用户在飞书上发送“帮我搜一下今天的新闻”)来串联整个流程:

用户在飞书发消息
  │
  ▼
FeishuChannel._on_message()                    [channels/feishu.py]
  │  解析消息,构造 sender_id、chat_id
  ▼
BaseChannel._handle_message()                  [channels/base.py]
  │  权限检查 → 打包成 InboundMessage
  ▼
bus.publish_inbound(msg)                       [bus/queue.py]
  │  消息放入 inbound 队列
  ▼
AgentLoop.run() → bus.consume_inbound()        [agent/loop.py]
  │  从队列取出消息
  ▼
AgentLoop._process_message(msg)                [agent/loop.py]
  │
  ├── sessions.get_or_create(msg.session_key)  [session/manager.py]
  │     获取会话历史
  │
  ├── context.build_messages(history, msg, ...)[agent/context.py]
  │     组装系统提示词(身份、记忆、技能)+ 历史 + 用户消息
  │
  ├── while 循环开始 ─────────────────────────────
  │   │
  │   ├── provider.chat(messages, tools)       [litellm_provider.py]
  │   │     调用 LLM,返回 LLMResponse
  │   │
  │   ├── response.has_tool_calls? → Yes
  │   │     执行 web_search 工具,将结果追加回messages
  │   │
  │   ├── provider.chat(messages, tools)        ← 带上搜索结果再问一次
  │   │
  │   └── response.has_tool_calls? → No
  │          final_content = response.content   → "以下是今天的新闻..."
  │
  ├── 保存 user 和 assistant 消息到 session
  │
  └── return OutboundMessage(channel="feishu", chat_id="12345", content=...)
      │
      ▼
bus.publish_outbound(response)                 [bus/queue.py]
  │  消息放入 outbound 队列
  ▼
ChannelManager._dispatch_outbound()            [channels/manager.py]
  │  根据 channel="feishu" 找到 FeishuChannel
  ▼
FeishuChannel.send(msg)                        [channels/feishu.py]
  │
  ▼
用户在飞书收到回复 ✅

一条消息从用户发出,历经渠道层 → 消息总线 → Agent Loop(含LLM与工具循环)→ 消息总线 → 渠道层,最终回到用户。每个组件职责单一,通过消息总线松耦合连接。

结语

通过对 nanobot 这一开源实战项目的源码剖析,我们得以窥见一个现代AI Agent框架的核心设计。它用约3500行Python代码,清晰地实现了LLM调度、工具系统、多渠道通信、记忆、会话、定时任务等完整能力,堪称“麻雀虽小,五脏俱全”。其模块化设计、消息总线解耦、以及高度的代码复用思想,为理解和构建更复杂的Agent系统提供了宝贵的蓝本。希望这篇分析能帮助你更深入地理解AI Agent的工作原理与工程实现。




上一篇:深入对比Java项目构建工具:Maven与Gradle核心差异与选型指南
下一篇:网易紧急辟谣:否认使用 AI 全面清退外包
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-19 07:48 , Processed in 0.600983 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表