Nano Bot 源码分析笔记
nanobot 技术要点
1. 适配器模式实现多 LLM 统一响应
核心思想:不同 LLM 提供商的 API 返回格式各异,通过适配器模式统一转换为 LLMResponse 格式。
实现方式:
- 每个 Provider 实现
_parse_response()方法 - 将原始 API 响应转换为统一的
LLMResponse数据结构 - 上层代码(AgentRunner)无需关心底层使用哪个 LLM
支持的提供商:
- AnthropicProvider 处理 Anthropic 格式
- OpenAICompatProvider 处理 OpenAI 格式
- DeepSeekProvider 处理 DeepSeek 格式
统一数据结构:
1 |
|
2. 消息处理全流程(LLM 调用前后)
核心思想:用户消息到达 LLM 前经过 5 个阶段处理,LLM 回复后保存时还有一轮清洗。
1 | 用户消息到达 |
记忆压缩
估算当前 prompt token 量,超出预算则循环切割旧消息段交给 LLM 压缩,写入 MEMORY.md / HISTORY.md,指针前移。压缩后这些旧消息不再出现在历史中。详见第 8 节:记忆系统。
1 | # loop.py |
裁剪历史
从 session 中取指针之后的消息,跳过开头的非 user 消息和孤儿 tool result,保证消息结构合法。纯数据操作,不涉及 LLM。详见第 11 节:会话管理。
1 | # loop.py |
拼 system prompt
把 identity、bootstrap 文件(AGENTS/SOUL/USER/TOOLS.md)、MEMORY.md、always skills、skills 目录拼成一段完整的 system prompt。详见第 3 节:上下文构建。
1 | # context.py — build_messages() 内部调用 |
合并当前消息
生成 runtime context(当前时间 + channel + chat_id,标记为元数据非指令),处理用户内容(纯文本或图片 base64 多模态),两者合并为一条 user message,避免连续同角色消息被 provider 拒绝。
1 | # context.py — build_messages() 内部调用 |
保存清洗
LLM 回复后,新消息存入 session 前做清洗:user 消息剥掉 runtime context 前缀(下次会重新生成),tool result 超长内容截断,空 assistant 消息跳过不存,所有消息补 timestamp。
1 | # loop.py |
各阶段增减总览
| 阶段 | 做了什么 | 增加了什么 | 去掉了什么 |
|---|---|---|---|
| 记忆压缩 | 旧消息交给 LLM 提炼 | MEMORY.md 内容变大 | 指针前的旧消息从视野消失 |
| 裁剪历史 | 取合法的近期消息 | 无 | 孤儿 tool result、开头非 user 消息 |
| 拼 system prompt | 组装 Agent 人格 | identity + bootstrap(AGENTS/SOUL/USER/TOOLS.md) + memory + skills | 无 |
| 合并当前消息 | 拼用户输入 | runtime context、图片 base64 | 无 |
| 保存清洗 | 存入 session | timestamp | runtime context 前缀、空 assistant、超长 tool result |
3. 上下文构建(ContextBuilder)
核心思想:ContextBuilder 把 5 层信息拼成 LLM 的完整输入,层次从稳定到动态。
拼接信息与来源:
| 层次 | 来源 | 稳定性 | 内容 |
|---|---|---|---|
| identity | 硬编码在 _get_identity() |
最稳定,改代码才能变 | Agent 名字、运行时环境、workspace 路径、平台策略、行为指南 |
| bootstrap 文件 | workspace 下的 AGENTS/SOUL/USER/TOOLS.md | 用户手动编辑 | Agent 人设、性格、用户画像、工具说明 |
| memory | workspace/memory/MEMORY.md | 运行时自动积累 | 长期事实(偏好、项目背景) |
| skills | workspace/skills/ + nanobot/skills/ | 用户可随时增删 | always 技能全文 + 其余技能的 XML 目录 |
| runtime context | 每次请求动态生成 | 每次都不同 | 当前时间、channel、chat_id |
拼接流程:
1 | build_system_prompt() |
最终结构:
1 | # 送给 LLM API 的两个关键参数 |
4. Tool 参数处理
核心思想:LLM 传来的参数可能格式不规范,通过两步处理确保参数正确。
处理流程:
cast_params() - 类型转换(容错)
- 将字符串转为正确类型:
"10"→10、"true"→True - 支持 string → integer/number/boolean 的自动转换
- 将字符串转为正确类型:
validate_params() - 参数验证(严格)
- 基于 JSON Schema 递归验证参数
- 检查类型、必填字段、范围、长度、枚举值
- 返回错误列表(空列表表示验证通过)
验证内容:
- 类型检查:integer, number, string, boolean, array, object
- 必填字段检查:required 字段是否存在
- 数值范围检查:minimum, maximum
- 字符串长度检查:minLength, maxLength
- 枚举检查:enum 值是否在允许列表中
- 递归验证:支持嵌套对象和数组
设计特点:
- 返回错误列表而非抛异常,可一次性收集所有错误
- 路径追踪机制,错误信息精确定位(如
user.age should be integer) - 递归验证支持任意深度的嵌套结构
类型映射:
1 | _TYPE_MAP = { |
5. Tool 系统(含MCP)
核心思想:将 Agent 的外部能力抽象为统一的 Tool 接口,LLM 通过 Function Calling 调用,nanobot 负责参数转换、验证、执行和错误处理。
完整调用流程:
1 | 用户消息 |
错误处理:失败自动追加 [Analyze the error above and try a different approach.],引导 LLM 换策略。
MCP vs 原生 Tool:原生 Tool 进程内直接调用零开销;MCP Tool 连接外部服务器进程,适合接入第三方生态。
工具列表:
| Tool | 用途 | 边界 |
|---|---|---|
exec |
执行 Shell 命令 | deny_patterns 拦截危险命令;超时最大 600s;输出截断头尾各 5000 字符 |
read_file |
读文件(支持图片) | 仅限 allowed_dir;128K 字符上限;支持分页 |
write_file |
写文件 | 仅限 allowed_dir;自动创建父目录 |
edit_file |
替换文件中的文本 | 模糊匹配容忍空格差异;多处匹配需指定 replace_all |
list_dir |
列目录 | 自动忽略 .git/node_modules;默认最多 200 条 |
message |
主动发消息/附件给用户 | 唯一能发文件附件的工具;依赖 send_callback 注入 |
web_search |
网页搜索 | 支持 Brave/Tavily/DuckDuckGo;无 Key 自动降级;最多 10 条 |
web_fetch |
抓取网页正文 | SSRF 防护;外部内容加不可信标记;图片返回多模态块;50K 字符上限 |
spawn |
派发子 Agent 后台执行 | 子 Agent 无 message/spawn 工具;完成通过 MessageBus 通知 |
cron |
定时任务增删查 | 不能在 cron 中再创建任务;持久化到 jobs.json 重启不丢失 |
mcp_* |
接入外部 MCP 服务器 | 需外部进程;工具名自动生成;超时默认 30s |
6. SubAgent 机制
核心思想:主 Agent 专注交互,将复杂耗时任务派发给子 Agent 在后台独立执行,完成后通知主 Agent。
实现方式:
- SpawnTool 是普通 Tool,注册在 ToolRegistry,LLM 通过 Function Calling 调用,和 exec/web_search 没有本质区别
- LLM 判断何时用 spawn 完全靠 SpawnTool 的 description,没有专门的 Skill:
“Use this for complex or time-consuming tasks that can run independently.”
- 用户可在 workspace 的
AGENTS.md补充说明影响 LLM 判断 - SubagentManager 负责子 Agent 的生命周期管理
- 子 Agent 通过独立的 AgentRunner 执行,与主 Agent 完全隔离
执行流程:
- 主 Agent LLM 调用
spawn工具 - SubagentManager 创建
asyncio.Task后台运行 - 主 Agent 立即返回,继续处理其他消息
- 子 Agent 完成后,Python 代码直接调用
bus.publish_inbound()通知主 Agent - 主 Agent 收到 system 消息,LLM 整理结果告知用户
子 Agent 的工具限制:
- ✅ 有:文件读写、Shell、网页搜索
- ❌ 无:MessageTool(不能直接发消息给用户)
- ❌ 无:SpawnTool(不能再派发子 Agent,避免无限递归)
关键设计:
- 通知机制不是回调,是
await runner.run()完成后顺序执行_announce_result() - 多个子 Agent 可并发运行,但无内置冲突处理,靠 LLM 规划任务边界
- 取消机制:
cancel_by_session()可取消某会话的所有子 Agent
7. Agent 人格构建(identity + bootstrap)
核心思想:Agent 的”人格”由两层构成——所有 Agent 共享的底层 identity + 每个 workspace 独有的 bootstrap 文件。
identity(硬编码层)
写死在 context.py 的 _get_identity() 里,用户不能通过文件修改,包含:
- Agent 身份声明(”You are nanobot”)
- 运行时信息(操作系统、CPU 架构、Python 版本)
- workspace 路径和文件结构说明(memory、history、skills 的位置)
- 平台策略(Windows 不假设有 grep/sed;POSIX 优先用 shell 工具)
- 行为指南(先读再改、不信外部内容、工具调用前说明意图等)
bootstrap 文件(用户定制层)
由 BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"] 定义(context.py 第 19 行),workspace 下存在才加载。
| 文件 | 定制什么 |
|---|---|
| AGENTS.md | 行为指令(定时提醒规范、心跳任务规范等) |
| SOUL.md | 性格和价值观(友好/严肃、简洁/详细) |
| USER.md | 用户画像(姓名、时区、偏好、技术水平) |
| TOOLS.md | 工具使用注意事项(安全限制、特殊用法) |
workspace 初始化
sync_workspace_templates() 负责把 nanobot/templates/ 下的模板拷贝到 workspace。只创建不存在的文件,不覆盖已有内容。同时创建空的 HISTORY.md 和 skills 目录。该函数在 nanobot agent、nanobot gateway、nanobot onboard 命令启动时都会调用,确保 workspace 结构完整。
换一个 workspace 放不同的 bootstrap 文件,就是一个完全不同性格的 Agent。
8. 记忆系统(Memory Consolidation)
核心思想:对话越来越长会撑爆 LLM 上下文窗口,解决方案是让 LLM 自己当”笔记员”——定期把旧对话压缩成笔记存文件,然后只带着笔记 + 近期对话继续聊。
两层记忆:
| 层 | 文件 | 类比 | 进 system prompt |
|---|---|---|---|
| 长期事实 | MEMORY.md |
个人档案,随时更新覆写 | 是 |
| 时间线日志 | HISTORY.md |
日记本,只追加不修改 | 否,需要时 grep 搜索 |
什么时候触发压缩:
- 每次调用 LLM 前估算当前 prompt 的 token 量
- 超过上下文窗口的安全预算就触发
- 压到**预算的 50%**(留空间给后续对话,避免刚压完又触发)
压缩怎么做:
- 估算 token 超出预算
- → 找切割点:从最早未压缩的消息往后扫,在 user 消息处标记(避免切断工具调用配对)
- → 切出旧消息段,连同当前 MEMORY.md 一起交给 LLM
- → LLM 输出两样东西:
- 更新后的个人档案 → 覆写 MEMORY.md
- 带时间戳的摘要 → 追加到 HISTORY.md
- → 指针前移,这段旧消息以后不再送给 LLM
- → 重新估算 token → 还超?再切一段(最多 5 轮)→ 不超?结束
为什么不需要重叠 chunk:
- 每次压缩时 LLM 能同时看到已有 MEMORY.md 和新消息段,MEMORY.md 本身就是跨批次的共享上下文
降级兜底:LLM 压缩连续失败 3 次,就把原始消息直接存进 HISTORY.md,宁可粗糙也不丢数据。
已知缺口:MEMORY.md 没有大小上限。正常情况下 LLM 会合并覆写旧事实,增长很慢;但长期运行 + 话题极度分散时,理论上可能膨胀到挤压对话空间。
9. 技能系统(Skills)
核心思想:Skill 是给 LLM 看的”操作手册”(markdown),教 Agent 遇到什么场景、调什么工具、传什么参数。写 markdown 就能扩展 Agent 能力。
Skill vs Tool:Skill 教 LLM 怎么用 Tool,Tool 是 LLM 真正能调用的函数。Skill 本身不被执行,LLM 读完后按手册调用 Tool 完成任务。
设计要点:
| 设计点 | 说明 |
|---|---|
| 两类来源 | workspace 自定义(高优先级,同名覆盖内置)+ nanobot 内置 |
| 两种加载模式 | always=true 全文进 system prompt;其余只放 XML 目录,LLM 按需用 read_file 读取 |
| 渐进式加载 | 元数据始终在 → SKILL.md 触发后读 → 附带资源按需加载 |
| 依赖检查 | frontmatter 声明 bins/env,不满足标记 available=false |
| 无缓存 | 每次 LLM 请求前重新扫描,运行时新增 skill 立即生效 |
内置了 memory、cron、weather、github 等 8 个 skill,其中 memory 是唯一 always=true 的。
10. LLM 提供商(Provider)
核心思想:通过注册表 + 自动匹配 + 统一抽象,让上层代码不关心底层用的是哪家 LLM。用户只需填模型名,系统自动找到对应服务商并抹平 API 差异。
设计模式:注册表模式(PROVIDERS 元组定义所有服务商元数据)+ 适配器模式(4 种 backend 实现类把不同 API 转为统一 LLMResponse)+ 策略模式(_match_provider() 按优先级自动路由)。
Provider 分类:
| 类型 | 特征 | 代表 |
|---|---|---|
| Gateway | 聚合平台,一个 key 访问所有模型,请求格式兼容 OpenAI | OpenRouter、AiHubMix、SiliconFlow、火山引擎 |
| 标准 | 直连厂商 API,靠模型名关键词匹配 | Anthropic、OpenAI、DeepSeek、Gemini、智谱、通义、Moonshot |
| OAuth | 不用 API key,走浏览器授权登录 | OpenAI Codex、GitHub Copilot |
| 本地 | 本地部署,无需或简单 key | vLLM、Ollama、OpenVINO |
| 自定义 | 用户提供完整 api_base + api_key | Custom、Azure OpenAI |
Backend 实现类:
| backend 值 | 实现类 | 覆盖范围 |
|---|---|---|
openai_compat |
OpenAICompatProvider | 绝大多数(标准、Gateway、本地都走这个) |
anthropic |
AnthropicProvider | Anthropic(消息格式完全不同,需要专门转换) |
azure_openai |
AzureOpenAIProvider | Azure OpenAI(需要 api_version) |
openai_codex |
OpenAICodexProvider | OpenAI Codex(OAuth 认证) |
模型名 → Provider 匹配优先级:
| 优先级 | 匹配方式 | 例子 |
|---|---|---|
| 1 | 用户强制指定 provider | config 里写 provider: deepseek |
| 2 | 模型名前缀精确匹配 | github_copilot/gpt-4o → GitHub Copilot |
| 3 | 模型名包含关键词 | deepseek-chat 含 “deepseek” → DeepSeek |
| 4 | 本地部署兜底 | 无关键词匹配 → 有 api_base 的本地 provider |
| 5 | 有 API key 的兜底 | 什么都没匹配 → 第一个有 key 的 provider |
重试机制:瞬时错误(429/500/502/503/504/timeout)按 1s → 2s → 4s 重试最多 3 次;非瞬时错误且含图片时,去掉图片重试一次(兼容不支持多模态的模型)。
11. 配置系统(Config)
核心思想:单一 config.json 管理所有运行时配置,通过 Pydantic 做结构校验,环境变量可覆盖,所有路径从配置文件位置派生。
三个核心文件:
| 文件 | 职责 |
|---|---|
schema.py |
用 Pydantic 定义配置结构(Config 根对象),声明字段类型和默认值,自动校验不合法配置 |
loader.py |
负责加载 config.json → 版本迁移 → Pydantic 验证 → 返回 Config 对象 |
paths.py |
所有路径从 config.json 所在目录派生(data、cron、media、logs、workspace 等) |
Config 根对象结构:
1 | Config |
关键机制:
| 机制 | 说明 |
|---|---|
| Pydantic 校验 | 声明字段类型,写入时自动检测类型错误(如字符串填到整数字段) |
| 环境变量覆盖 | NANOBOT_ 前缀 + __ 分隔嵌套层级,如 NANOBOT_AGENTS__MODEL=gpt-4o |
| 版本迁移 | _migrate_config() 处理旧版配置字段位置变更 |
| 多实例支持 | 通过 --config 参数指定不同配置文件,全局变量 _config_path 记录当前实例路径 |
| Provider 自动匹配 | Config 初始化时调用 _match_provider(),按 5 级优先级为模型名找到对应 provider |
| 路径派生 | 所有数据目录从 get_config_path().parent 派生,无硬编码路径 |
config.json 维护方式:用户手动编辑,Agent 自身没有修改配置的工具。
12. 会话管理(Session)⭐
核心思想:按 channel:chat_id 隔离对话历史,持久化到 JSONL,append-only 保证 LLM prompt cache 命中。
两个类:
| 类 | 职责 |
|---|---|
Session |
持有 messages 列表,提供裁剪、清空、合法性修正 |
SessionManager |
JSONL 读写、内存 cache、生命周期管理 |
get_or_create 三种情况:
| 情况 | 处理 |
|---|---|
| 内存 cache 命中 | 直接返回 |
| 磁盘有文件 | _load() 恢复,含游标 |
| 都没有 | 新建空 Session |
隔离规则:key 格式 channel:chat_id,不同用户或平台各自独立。唯一共享的是 Agent 级别的 MEMORY.md / HISTORY.md。
last_consolidated 游标
messages[0:last_consolidated] 已压缩进 MEMORY.md / HISTORY.md,游标持久化在 JSONL 第一行,重启后恢复。
1 | messages: [msg_0 ... msg_49 | msg_50 ... msg_99] |
LLM 每次收到的完整上下文
1 | ┌─ system prompt ──────────────────────────────┐ |
孤儿 tool result:截断后 window 开头是 tool result,但对应 assistant tool_calls 已被截掉,provider 会拒绝。_find_legal_start() 扫描找合法起始点,丢弃孤儿。
append-only 与 LLM Cache
是一个LLM层的机制,但实际底层就是KV Cache,来避免前面重复计算:provider 对消息前缀做 hash,前缀不变则复用缓存。
因此传过去的消息,只追加不修改 → 前缀稳定 → cache 命中。
1 | 第1次 [A,B,C] → 缓存前缀 |
13. 事件总线(Bus)
核心思想:两个 asyncio.Queue(inbound / outbound),所有需要触达 AgentLoop 的入口统一走 inbound queue,AgentLoop 回复走 outbound queue。各方只和 Bus 交互,互不认识。
1 | Channel / SubAgent / Cron |
三类 inbound 来源:
| 发送方 | 场景 |
|---|---|
| Channel | 用户发来消息 |
| SubAgent | 子 Agent 完成后通知主 Agent |
| Cron | 定时任务触发 |
session_key_override:InboundMessage 默认 session key 是 channel:chat_id,此字段可覆盖,用于 Slack thread 等需要在同一 chat_id 下隔离子会话的场景。
14. 集成层 Channels
核心思想:Channel 是平台适配层,统一把各平台消息转为 InboundMessage 塞进 Bus,互相完全隔离,只和 Bus 交互。
BaseChannel 最少实现:
| 方法 | 职责 |
|---|---|
start() |
连接平台,长期监听,收到消息调 _handle_message() |
stop() |
断开连接,清理资源 |
send(msg) |
发送 OutboundMessage 给用户 |
可选:send_delta()(流式)、login()(交互登录)
_handle_message 流程:平台消息 → 白名单检查 → 封装 InboundMessage → bus.publish_inbound()
ChannelManager:读 config 决定启用哪些 Channel,统一启动,持续消费 outbound queue 分发回对应 Channel,失败按 1s/2s/4s 重试,流式 delta 合并后再发。
Registry 发现机制:内置(pkgutil 扫描目录)+ 插件(entry_points),内置优先。用户可通过第三方包注册插件,config.json 开启即用。
内置 Channel:Telegram、Slack、Discord、飞书、企业微信、微信、钉钉、QQ、Mochat、Matrix、Email、WhatsApp
不走 Channel 体系的入口:
| 入口 | 说明 |
|---|---|
| CLI | 手动构建 Bus + AgentLoop,直接 publish_inbound |
| SubAgent | 完成后直接 publish_inbound 通知主 Agent |
15. 定时计划(Cron / Heartbeat)
Cron — 定时任务
LLM 通过 CronTool 增删查任务,持久化到 jobs.json,重启不丢失。三种调度方式:
| kind | 说明 |
|---|---|
at |
指定时间点执行一次 |
every |
固定间隔循环 |
cron |
标准 cron 表达式,支持时区 |
执行时调 on_job 回调(AgentLoop 注入),到期后重新计算下次时间。
Heartbeat — 周期性唤醒
用户用自然语言在 HEARTBEAT.md 写任务条件(如”每天9点提醒喝水”),LLM 定期读取判断当前是否该执行。用 tool call 强制返回结构化 {action: skip/run},避免解析自由文本。有任务则跑完整 agent loop,结果值得通知时才发送。
16. CLI 入口与运行模式
两种启动模式:
终端模式(nanobot agent) |
服务模式(nanobot gateway) |
|
|---|---|---|
| 交互入口 | 键盘终端输入 | Telegram/Slack 等 Channel |
| 面向用户 | 你自己 | 部署给多人使用 |
| 典型部署 | 本地 PC | 服务器常驻 |
| Channel | 无 | 启动 ChannelManager |
| Heartbeat | 无 | 启动 HeartbeatService |
注:代码里命令名叫
gateway,与 Provider 层的”聚合平台 Gateway”(OpenRouter 等)是两个不同概念,此处称”服务模式”以作区分。
终端模式启动流程:
1 | _load_runtime_config() → 加载 config.json |
服务模式启动流程:
1 | _load_runtime_config() → 加载 config.json |
多用户限制:单进程 asyncio,消息进 inbound queue 排队串行处理。小规模(几十人)够用,规模扩大时:
| 问题 | 原因 |
|---|---|
| 串行处理 | 用户A等LLM时,用户B的消息在排队 |
| LLM 调用慢 | 每次几秒到几十秒,排队体验差 |
| 单点故障 | 进程崩了所有用户断连 |
| 无水平扩展 | 不能加机器分摊压力 |
项目本身不内置高并发方案,大规模需在外层自行添加负载均衡和多进程部署。
17. 沙盒机制
结论:这不是真正的沙盒。 没有独立沙盒模块,没有 Docker / seccomp / chroot 等操作系统级隔离,只是代码层面的路径检查和命令过滤。
| 机制 | 位置 | 作用 |
|---|---|---|
restrict_to_workspace |
config.json + ExecTool / FilesystemTool | 文件读写和命令执行只能在 workspace 目录内 |
deny_patterns |
ExecTool | 正则黑名单,阻止危险 shell 命令(如 rm -rf /) |
生产部署时官方推荐开启 restrictToWorkspace: true,但本质仍是应用层防护,绕过方式理论上存在。

