用Nanobot反推OpenClaw:Agent框架源码核心骨架解析
2026/9/16 12:24:05 网站建设 项目流程

最近在啃 OpenClaw 的源码。坦白说,OpenClaw 这类项目什么都好,就是对第一次看源码的人不太友好。模块多、入口多、各种连接器、部署脚本、Skill 仓库混在一起,光是把整个仓库结构过一遍就容易劝退。后来我换了个路子:先精读 Nanobot——一个体量小得多的 Agent 框架——把 Agent 最核心的运行骨架吃透,再回头对照 OpenClaw 的模块划分。这套路径走下来,效果比直接硬啃好得多。这篇文章就是这套学习路径的记录,标题里“总体刎”三个字,盲猜是想打“总体剖析”。

要是你也被某个大型 Agent 项目的源码体积吓到过,或者想搞清楚 Agent 框架到底由哪几个核心模块组成,这篇应该能帮你省下不少弯路。我会从“为什么用 Nanobot 反推 OpenClaw”讲起,再拆 Nanobot 的源码骨架,最后落到 OpenClaw 的架构扩展和实战搭建上。

1. 为什么用 Nanobot 来反推 OpenClaw 的架构

1.1 OpenClaw 和 Nanobot 的定位差异

OpenClaw 是典型的“全家桶”Agent 项目,你能想到的形态它基本都有:安装脚本、Windows 离线整合包、多种 IM 接入插件、Termux 环境部署、甚至还有往 ESP32 上移植的玩法。功能多意味着代码多,而且这些功能往往围绕同一个核心循环展开,外围代码会把你淹没。

Nanobot 的定位则非常克制。它基本上只做一件事:给你一个最小可用的 Agent 内核。没有复杂的插件体系,没有一堆连接器,核心逻辑就是“读取输入、调模型、执行工具、返回结果”。

维度OpenClawNanobot
代码量大,模块多小,结构清晰
运行形态多端接入、可部署到不同环境偏向单进程、单会话
核心能力Agent + Skill + 连接器Agent 内核
适合场景生产级、多端、可扩展学习原理、快速验证
上手门槛

如果你一开始就冲 OpenClaw 源码去,大概率会陷入“每个目录都打开过,但每个目录都没看完”的状态。先看 Nanobot,等于先把骨架抽出来,再回来看 OpenClaw 时,你会很清楚哪些代码是核心、哪些是外围适配。

1.2 小项目学架构的优势

小项目学架构,最大的好处是“没有地方可躲”。代码只要少,每一行都有它的位置,你不会因为层层封装而找不到真正的逻辑。

我在读 Nanobot 源码时最大的感受是:它把 Agent 最本质的三件事摆在了明面上——调模型、管消息、执行工具。这三件事对应到任何 Agent 项目里都是逃不开的主干。大项目会把这三件事包装成各种 Service、Manager、Executor,但底层的运行逻辑不会变。

用个不恰当但很贴切的类比:学写字先临摹大字帖,而不是直接去抄一篇密密麻麻的公文。Nanobot 就是那张大字帖。OpenClaw 里的 Skill 机制、连接器抽象、部署脚本,都是在 Nanobot 这一层骨架上长出来的肌肉和皮肤。骨架没搞清楚之前,看肌肉只会觉得“这里鼓一块那里鼓一块”,不知道为什么要这么长。

1.3 我看源码时先锁定的 4 个关键模块

读源码最忌讳从头翻到尾,应该按运行主线来。我在 Nanobot 里锁定了 4 个关键模块,顺序如下:

  1. 入口文件。看启动时初始化了什么,外部依赖有哪些。
  2. 消息循环。看 Agent 在拿到一条输入后,到底经历了哪些步骤才给出回复。
  3. 工具注册机制。看用户能力是怎么挂载到 Agent 身上的。
  4. 上下文管理。看多轮对话时历史消息如何保留、如何截断。

按这个顺序读下来,你会发现整个项目其实是一条直线:入口初始化好一切,循环反复执行,工具负责真正干活,上下文保证 Agent 不会失忆。后续再看 OpenClaw 时,我也是用同样的四步去找对应模块,只不过 OpenClaw 里每一步都变得更厚实。

2. Nanobot 源码里的 Agent 核心骨架

2.1 入口文件与全局初始化

Nanobot 的入口逻辑非常直白,简化后大概是这样的过程:先读取配置,再创建 LLM 客户端,然后把内置工具注册进一个注册表,最后启动 Agent 的消息循环。

# 简化的入口逻辑,展示 Nanobot 启动时做了什么 def main(): config = load_config("nanobot.yaml") llm = create_llm(config.llm) registry = ToolRegistry() registry.register(TimeTool()) registry.register(WeatherTool()) agent = Agent(llm=llm, tools=registry) agent.run()

这段代码几乎不需要注释就能看懂。但值得琢磨的是“为什么入口要做这三件事”。配置文件先行,意味着项目的所有可调参数都被集中管理,而不是散落在代码各处;LLM 客户端单独创建,说明模型服务被当成外部依赖隔离,任何需要调用模型的地方都通过这个 client 走;工具注册则定义了这个 Agent 能干什么。

我自己看源码的习惯是:先把入口函数里所有初始化调用列个清单,再逐个去看对应类。这样你心里会有一张“外部依赖清单”,哪些是要连网络的、哪些是纯本地的、哪些是可替换的,一目了然。OpenClaw 的入口虽然复杂很多,但本质上也是先做配置、再做连接、最后启动主循环,只是多了环境探测和连接器初始化。

2.2 对话循环与事件分发

Agent 的核心不是某一类复杂算法,而是那个反复执行的循环。Nanobot 的 agent loop 拆开看,本质上是一个带工具调用的 while 循环:

  1. 收集新的用户输入,追加到 messages 列表。
  2. 把完整 messages 发给模型。
  3. 检查模型返回内容里有没有 tool_calls。
  4. 如果有,逐个执行工具,把工具结果追加到 messages,回到第 2 步。
  5. 如果没有 tool_calls,说明模型已经给出最终回答,把这段文本返回给用户。

这个循环最关键的终止条件有两个:一是模型不再返回 tool_calls,说明任务结束了;二是触发最大迭代次数,防止死循环。

实际项目中第二个条件极其重要。模型在复杂任务里经常会出现“调用工具 -> 拿到结果 -> 继续调用工具”的循环,如果工具结果一直不满足模型预设条件,它可以一直调下去。所以几乎所有 Agent 框架都会有一个 max_iterations 参数。我在 OpenClaw 的源码里也看到类似的防御逻辑,只是它的叫法更花哨,但底层的思路完全一致。

# 极简 agent loop 伪代码 for _ in range(max_iterations): response = llm.chat(messages) if not response.tool_calls: return response.content for call in response.tool_calls: messages.append(tool_result(call)) return "迭代次数超限"

这段伪代码基本就是 Nanobot 消息循环的骨架。理解了它,你就理解了所有 Agent 框架的引擎部分。

2.3 工具注册机制与 function calling

工具注册是 Agent 框架里最容易出彩也最容易踩坑的模块。Nanobot 的做法是把每个工具描述成一个 JSON Schema,让模型在对话时决定是否调用它。

# 一个工具注册后,模型能看到的 schema 长这样 tool_schema = { "type": "function", "function": { "name": "get_weather", "description": "查询城市天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名"} }, "required": ["city"] } } }

这段 schema 的作用不是给程序读的,而是给模型读的。模型根据 description 和 parameters 判断“我现在需不需要调用这个工具、传入什么参数”。它不像传统代码那样“调用一个函数”,而是“生成一个 JSON,描述我想调用哪个函数、传什么参数”,框架再去解析并分发。

分发逻辑也非常直接,本质就是一个字典查表:

async def dispatch(name: str, arguments: str): tool = TOOLS.get(name) if not tool: return {"error": f"tool not found: {name}"} args = json.loads(arguments) return await tool["fn"](**args)

不要小看这个字典查表。OpenClaw 的 Skill 系统、插件系统、甚至多 Agent 调度,底层都是这个模式的变种。你理解了“工具注册 = 维护一张名字到函数的映射表”,再看任何 Agent 框架的能力扩展机制都不会慌。

有一点需要特别注意:工具函数的入参必须严格匹配 schema 里的 properties,否则模型生成的参数在解包时很可能报错。实践里我给工具写参数时都会故意写成“尽量简单、平铺、不要嵌套对象”,因为嵌套对象会让模型生成参数的出错率明显上升。

2.4 记忆与上下文管理

多轮对话的关键是上下文管理。Nanobot 的做法很实在:用一个列表维护所有消息,在发送给模型前做一次裁剪,只保留最近 N 轮,同时确保 system prompt 不被裁掉。

def cut_messages(messages, max_tokens=8000): # 估算每条消息的 token 数,从最旧的消息开始丢弃 # 直到总长度低于 max_tokens while estimate_tokens(messages) > max_tokens: if len(messages) <= 1: break messages.pop(1) # 保留 messages[0](system),丢弃后面的旧消息 return messages

有人会问,为什么不能把所有历史全塞给模型?原因有两个:成本和窗口上限。模型上下文有长度限制,token 超了就报错;另一方面,即使不超,历史越长,每次请求的耗时和费用都线性上涨。滑动窗口是性价比最高的方案。

Nanobot 这种“无脑截断”的方式在简单场景够用,但它有个明显缺点:如果被截断的消息里包含关键信息,Agent 就会“失忆”。OpenClaw 在记忆模块上明显走得更远,它会做摘要、向量检索、长期存储,但本质上解决的问题和 Nanobot 一样:在有限上下文里,保留最重要的信息。从源码学习的角度看,我建议你先吃透滑动窗口,再看摘要压缩,最后看向量检索,这条路最顺。

3. 从 Nanobot 到 OpenClaw:架构扩展的推演

3.1 单 Agent 到多 Agent / Skill 体系

Nanobot 是一个循环、一个模型、一组工具。这个模型在简单任务上表现不错,但现实世界中一个能干的 Agent 往往需要挂载大量能力。OpenClaw 的解法是引入 Skill 体系:每个 Skill 包含一组工具定义、触发规则、可能还有独立的提示词,按需加载,用完可卸载。

对比一下就很清晰:Nanobot 像一把瑞士军刀,工具是固定在刀身上的;OpenClaw 是可换模块的工具箱,你出门前决定带哪几个模块。这个设计带来的直接好处是 prompt 不会被所有工具的定义撑爆。我见过有人把 50 个工具全塞给模型,结果模型选择工具的错误率高得离谱,因为 description 互相干扰。OpenClaw 用 Skill 把能力分组后,每次只暴露当前任务需要的那一组工具,模型的选择准确率会高很多。

你在看 OpenClaw 的 skill 推荐列表时,会发现很多 Skill 本质上只是“一组工具 + 一段使用说明”,比如联网搜索、网页解析、代码执行。它们都遵循同样的注册和分发机制,只不过被组织成了可插拔的单元。

3.2 单进程到多端接入

Nanobot 的输入来源很简单,命令行敲一句话就完事。但 OpenClaw 需要同时服务多个不同的端:终端、IM 插件、物联网设备、甚至跑在 ESP32 这类小硬件上。如果消息循环直接依赖特定输入端,项目就没法扩展了。

架构上的解法是抽象出 Connector 层,也就是把“收到一条消息”和“来自哪个渠道”解耦开:

class Connector: def receive(self) -> Message: ... def send(self, msg: str): ...

每个端实现自己的 Connector,Agent 核心循环只面对统一的 Message 对象。新增一个端就是新增一个 Connector 实现类,核心循环完全不用改。这个设计在 OpenClaw 里被大量使用,你看到的各种 IM 接入插件,本质上都是 Connector 的具现化。

这给源码学习提供了一个重要启发:看一个 Agent 项目扩展能力有多强,先看它的消息入口是写死的还是抽象过的。写死的入口,加一个渠道就要大改;抽象过的入口,加渠道永远是新写一个类,不动主干。

3.3 部署形态的差异

部署方式往往能反映项目架构的成熟度。Nanobot 的部署非常简单,装依赖跑起来就行。OpenClaw 则面对更复杂的场景:用户可能用安装脚本指定 git 分支安装、用 Windows 离线整合包、在安卓 Termux 里跑、甚至在更小的设备上跑。

这里最值得学的不是安装脚本本身,而是它背后的“环境校验”设计。OpenClaw 启动时会做各种环境探测,比如确认当前是不是 WSL2 环境、有没有对应架构的二进制、依赖版本对不对。如果校验失败,会明确告诉你“could not safely verify the WSL2 environment”而不是直接崩溃。这个设计在跨平台项目里非常重要。

我在自己项目里也养成一个习惯:所有部署脚本都分两层,第一层是纯环境探测脚本,用最简单的命令判断系统类型、架构、依赖是否齐全;第二层才根据探测结果决定安装策略。不要一边安装一边校验,否则用户看到的错误五花八门,很难定位。

3.4 我眼中的 OpenClaw 核心分层

把 Nanobot 的骨架外推,我在 OpenClaw 里看到的主要是这样几层:

分层职责对应 Nanobot 里的原型
配置层读取 yaml、环境变量、CLI 参数入口处的 load_config
接入层处理来自不同渠道的消息agent.run() 前的输入收集
编排层Agent 循环、多 Agent 调度、任务分解while 循环本身
能力层Skill、工具注册、工具分发ToolRegistry + dispatch
存储层会话状态、长期记忆、文件缓存messages 列表的持久化版本

这个分层把 OpenClaw 的复杂表象简化成五件事。你读它的源码时,心里带着这张表,每看到一个文件就先问一句“它属于哪一层”。如果哪一层都归不进去,大概率是辅助代码,可以暂时跳过。这套分类法我后来也用在了其他项目上,效率提升很明显。

4. 实操:基于 Nanobot 风格搭建一个最小 Agent 骨架

4.1 准备环境与依赖

理论讲再多,不如实际跑一个最小骨架。我在本地测过的环境配置如下:Python 3.10 以上,安装 openai 库就行。模型服务可以用 OpenAI 兼容接口,只要对方提供了 base_url 和 api_key,代码可以通用。

python -m venv venv source venv/bin/activate pip install openai

这里有个小提醒:如果你用的是云端模型接口,注意网络连通性;如果是在本机跑本地模型,需要确保模型服务监听在可访问的端口上。两种方式用同一个 OpenAI 兼容客户端都能接,只是 base_url 不同。

4.2 核心代码:一套可直接运行的 Agent 骨架

下面这段代码是 Nanobot 思路的极简复刻版,我把工具注册、消息循环、工具分发都压缩在一个文件里,方便你理解整体运行逻辑。

import json import os from openai import OpenAI client = OpenAI( base_url=os.getenv("LLM_BASE_URL", "https://api.openai.com/v1"), api_key=os.getenv("LLM_API_KEY", "YOUR_API_KEY"), ) TOOLS = {} TOOL_SCHEMAS = [] def register(name, description, parameters): def decorator(fn): TOOLS[name] = fn TOOL_SCHEMAS.append({ "type": "function", "function": { "name": name, "description": description, "parameters": parameters, }, }) return fn return decorator @register("get_weather", "查询城市天气,入参city为城市名", { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], }) def get_weather(city: str): result = {"city": city, "temperature": 26, "condition": "晴"} return json.dumps(result, ensure_ascii=False) def dispatch(name: str, args: dict): fn = TOOLS.get(name) if not fn: return json.dumps({"error": f"未注册的工具: {name}"}, ensure_ascii=False) return fn(**args) def run_agent(messages): for _ in range(3): resp = client.chat.completions.create( model=os.getenv("LLM_MODEL", "qwen-plus"), messages=messages, tools=TOOL_SCHEMAS, ) msg = resp.choices[0].message if not msg.tool_calls: return msg.content messages.append(msg) for tc in msg.tool_calls: result = dispatch(tc.function.name, json.loads(tc.function.arguments)) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": result, }) return "迭代次数超限,请简化任务或检查工具结果。" if __name__ == "__main__": messages = [{"role": "system", "content": "你是助手,请用中文回答。"}] while True: user_input = input(">>> ") if user_input.strip().lower() in ("exit", "quit"): break messages.append({"role": "user", "content": user_input}) answer = run_agent(messages) print("Agent:", answer)

这段代码的运行逻辑就是前面讲的那条直线:注册工具、进入循环、模型决定是否调用工具、执行工具、继续循环。你可以直接把 api_key 和 base_url 换成自己的,然后在终端里跑一句“北京今天天气怎么样”,它会经历一次完整的工具调用流程。

4.3 接一个真实工具时的关键细节

以 get_weather 为例,这个工具函数非常简单,返回一个 JSON 字符串。但有一点必须强调:工具函数的返回值必须是字符串,不能是 dict,也不能是对象。因为 OpenAI 兼容接口的 tool 消息要求 content 是字符串类型,如果你返回 dict,很多服务端会直接报错。

另外,schema 里的 description 写得越清楚,模型越不容易乱传参数。像 get_weather 这种只有一个参数的函数还好,如果是多参数的函数,我的习惯是把每个参数的单位、可选值、默认值都写进 description,这样能明显降低模型生成错误 JSON 的概率。

4.4 参数选择与实测心得

我自己连续测了几个 Agent 骨架项目后,有几个参数经验值得记录。

第一个是迭代次数上限。一般工具调用类的任务,两步之内就能结束,设置成 3 是一个比较稳的折中。设太大,模型在工具结果不理想时会反复尝试,成本飙升;设太小,遇到稍微复杂一点的连锁工具调用就被截断。

第二个是上下文长度的预分配。发请求时要给工具结果预留 token 空间,否则工具返回一大段内容时,总长度会直接撑爆上下文窗口。我在骨架里没有显式处理,但真实使用时会在发送前检查 messages 总长度,预留出至少 2000 token 给工具结果。

第三个是 temperature。普通闲聊用 0.7 没问题,但一旦涉及工具调用,我强烈建议把温度调到 0.3 以下。温度越高,模型生成的参数 JSON 就越容易出现格式错误,比如多一个引号、少一个逗号。工具调用的场景里,稳定比创意重要得多。

5. 源码阅读与部署中常见的坑

5.1 源码拿下来先跑不起来的常见原因

我在复现 Nanobot 和 OpenClaw 时遇到过几类导致跑不起来的问题,按出现频率排个序:

  • Python 版本不对。项目要求 3.10,但你用的是 3.8,某些语法直接报错。
  • 依赖安装不完整。项目读的是 requirements.txt,但你只装了核心依赖,缺了几个隐式依赖。
  • 环境变量没配齐。api_key、base_url 之类的配置缺失,启动时不报错,调用模型时才挂。
  • 模型接口不兼容。项目默认模型要求支持某些参数,你的模型服务不支持,返回 400 错误。

遇到这些问题不要慌,排查思路是:先看启动日志,日志定位到第一处报错;然后确认 Python 版本和依赖列表;再看环境变量。最忌讳的是上来就改代码,先确认环境再动手。环境问题导致的报错,改代码是没用的。

5.2 工具调用失败时的日志定位方法

工具调用失败时的现象通常是:模型返回了一句话“我暂时无法查询天气”,而不是真正去调用工具。这时候要分几步排查。

第一步,检查工具 schema 是否成功传入模型。你可以在发请求前打印 TOOL_SCHEMAS,确认里面有内容。如果列表为空,模型当然不知道有这个工具可用。

第二步,检查模型返回的原始响应。很多项目只取 msg.content,把 tool_calls 丢弃了,导致你根本看不到模型其实尝试调用了工具。我排查时都会先打印原始响应结构,确认模型给的是 content 还是 tool_calls。这一步能直接区分“模型没想调用工具”和“代码把工具调用结果吞了”。

第三步,检查工具函数的返回值。日志里如果报了“expected string, got dict”之类的错,那就是返回值类型不匹配。改成 json.dumps 包装再返回就行。

5.3 环境校验与跨平台部署的取舍

OpenClaw 部署时经常出现环境校验失败的问题,尤其是 WSL2 相关提示。我第一次看到“could not safely verify the WSL2 environment”时还以为是程序出 bug 了,后来才意识到这是环境探测模块的正常反馈:它没检测到预期的 WSL2 特征,就给出警告,而不是硬着头皮跑。

这种设计值得借鉴。跨平台项目最怕的就是“用户环境千奇百怪,程序假设只有一个环境”。好的做法是先把环境打上标签,再根据标签决定路径。比如系统是 Linux 还是 Windows、架构是 x86 还是 ARM、有没有 GPU、网络通不通,这些探测结果应该集中在一个地方管理。

我自己在类似场景里踩过的坑是:在 Termux 环境里部署时,以为只要装了依赖就能跑,结果项目依赖某个系统工具,而 Termux 默认没有。所以现在我会先把“系统类型、架构、缺什么命令”一次性输出,再决定下一步。OpenClaw 里的环境校验模块本质上就是干这件事的,区别只在它把细节封装得更完整。

5.4 会话残留与重复回复的排查思路

在长连接类 IM 插件场景里,有一个很经典的坑:用户发了消息,Agent 回复了,但下次发消息时,Agent 像失忆一样又重复处理了上一次的内容。这类问题通常不是模型问题,而是会话状态没清理干净。

排查时我一般分三步。第一步,检查 messages 列表是不是在每次会话开始时被重置。如果重置逻辑只创建了新列表而没有清空旧引用,就会出现残留。第二步,检查 system prompt 是不是被重复注入。有些代码会在每轮循环里重新 append system 消息,时间一长列表里堆了好几条 system,模型行为会变得混乱。第三步,确认长连接断开重连后,会话 ID 是否仍然保持同一个,如果是,那历史消息可能会继续累积。这个场景下最直接的方案就是每次都生成独立的会话 ID,超时自动清理。

我在实际项目中还遇到过一类“类似会话残留”的情况:模型服务端的上下文没清理,导致代码层面已经重置了,但服务端依然带着之前的记忆。这种问题比较隐蔽,排查办法是换一个新会话 ID 再发一条消息,看回复风格是否明显变化。如果变化很大,说明问题出在会话 ID 复用上,而不是消息列表本身。

最后再分享一点个人体会。我读 OpenClaw 源码时,原本觉得先看 Nanobot 是绕路,真正读完之后才发现这是最省时间的路径。OpenClaw 里大量模块都能从 Nanobot 骨架上找到对应物:工具注册变成 Skill 清单,命令行输入变成多端 Connector,上下文截断变成完整的记忆存储。如果你也在啃一个复杂项目,不妨先在它的生态里找最小的那个参考实现,读透了再回头看大的,你会发现大型项目的复杂主要是“多”而不是“深”。另一个很实用的学习技巧是:不要从头到尾读源码,先给工具注册函数加个断点,跑一个会触发工具的任务,看整个调用过程的栈帧。这一趟走完,你对架构的理解可能比读十篇文档都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询