数据截至 (上游 commit 53ea1e8ba6fd)
NanoClaw — 架构与原理
30 秒导读: NanoClaw 把「个人 AI 助理」做成一个常驻的 Node 主机进程 + 一堆按需拉起的 Docker 容器。你在 Telegram/Slack/iMessage 里 @ 它,主机把消息写进一个 SQLite 文件,容器里的 Claude Code 读到、干活、把回复写进另一个 SQLite 文件,主机再发回聊天软件。它最值得学的不是「怎么调模型」,而是「怎么让主机和沙箱之间只靠两个文件通信,还不出错」。
1. 这是什么(零基础也能懂)
一句话定义
NanoClaw 是一个跑在你自己机器上的聊天式 AI 助理: 你在日常用的聊天软件里跟它说话,它在隔离的 Linux 容器里替你干活(读文件、跑命令、上网、发消息)。
解决什么问题、给谁用
设想这个场景:你想让 AI 助理帮你管日程、盯 GitHub、每周五整理一次周报,而且希望直接在微信/Telegram 里跟它说,而不是打开某个网页。问题来了——你敢把「能跑任意 shell 命令」的 AI 直接放在自己电脑上吗?
NanoClaw 的答案是:能跑,但只在容器里跑,而且只能看见你明确挂进去的目录。
它面向的是「一个人 + 自己的机器」,不是团队 SaaS:
| 你是谁 | 你会怎么用它 |
|---|---|
| 想要私人助理的个人开发者 | clone 下来跑 bash nanoclaw.sh,配一个 IM 渠道,开始 @ 它 |
| 关心安全的自建党 | 看得懂全部代码(主机侧 ~2.4 万行 TS,容器侧 ~5700 行),容器 + 凭据网关双重边界 |
| 想自己改的人 | 项目明确说「定制 = 改代码」,没有配置文件迷宫,让 Claude Code 直接改你的 fork |
它能做什么
- 多渠道接入 —— WhatsApp、Telegram、Discord、Slack、iMessage、Teams、邮件等,由
/add-<channel>技能按需装进来(主干不自带任何渠道适配器)。 - 每个 agent 一套工作区 —— 自己的
CLAUDE.md、自己的记忆目录、自己的容器、自己的挂载白名单。 - 定时任务 —— cron 式重复任务,还能挂一个「预检脚本」:脚本说没事做就不唤醒 agent(省 token)。
- 容器隔离 —— agent 跑在 Docker 里,
Bash工具的杀伤半径被限制在容器内。 - 凭据不落地到 agent —— API key 由 OneCLI Agent Vault 在请求发出时注入,容器里拿不到明文。
- agent 改自己 —— agent 可以申请「给我装个 apt 包 / 接一个 MCP server」,走人工审批后主机重建镜像、重启容器。
用起来什么样
装:
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
用(在你配好的聊天窗 口里,默认触发词 @Andy):
@Andy 每个工作日早上 9 点给我发一份销售管线概览(可以读我的 Obsidian 目录)
@Andy 暂停周一那个简报任务
运维(在主机终端里,ncl 是它的管理 CLI):
ncl groups list # 有哪些 agent 工作区
ncl wirings list # 哪个聊天群接到了哪个 agent
ncl tasks list # 定时任务
ncl sessions list # 当前会话(只读)
一句话直觉
把它想成一个「消息中转站 + 一次性工位」:
- 聊天软件是大厅,主机进程是前台。
- 每个会话是一间独立工位(一个容器),下班就拆(容器
--rm)。 - 前台和工位之间没有对讲机,只有一个收件筐和一个发件筐——两个 SQLite 文件。前台只往收件筐放,工位只往发件筐放,谁也不碰对方的筐。
这段只是直觉比喻,不是术语。 后面各章一律用「主机进程 / 容器 / inbound.db / outbound.db」这套名字。这个「两个筐」的设计是整个项目的技术核心,第 2 章专讲。
2. 顶层全景(它大概怎么转)
2.1 一张图看清结构
怎么读这张图:从上往下是四层——平台、常驻的单一 Node 主机进程、两个 SQLite 文件、按需拉起的容器;圈号 ①~⑤ 对应下面 §2.2 表里的部件;中间那一层的 两个 .db 文件是唯一的跨进程边界。
┌─ 平台(Telegram / Slack / iMessage / …)
│ ↓ 平台把消息交进来 ↑ 主机把回复发回去
│
├─ 主机进程:单个常驻 Node 进程,代码在 src/
│ ① 渠道适配器:把某个平台的收发翻译成统一的入站事件
│ ② 路由 router:查中央库 data/v2.db(用户 / 群 / agent / 接线),定 agent 与会话
│ ③ 会话管理 + 容器运行器:消息写进 inbound.db,再 docker run 拉起该会话的容器
│ ④ 投递 delivery:轮询 outbound.db,把 agent 的输出交给适配器发回平台
│ 另有 60 秒一次的巡检 host-sweep,兜底所有异常路径(见 §2.3 第 7 步)
│
├─ 跨进程边界:会话目录里的两个 SQLite 文件
│ 没有 socket、没有管道、没有文件监听,两边都是轮询
│ inbound.db 主机写、容器读 —— 主机 → 容器 的唯一通道
│ outbound.db 容器写、主机读 —— 容器 → 主机 的唯一通道
│
└─ 容器:每会话一个,退出即删(--rm)
⑤ agent-runner(Bun + Claude Agent SDK):轮询 inbound.db 干活,结果写 outbound.db
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| 入口 | 起 DB、跑迁移、拉起适配器、开三个轮询 | src/index.ts |
| 渠道适配器 | 把某个 IM 平台的收发抽象成统一接口 | src/channels/adapter.ts(接口)、src/channels/channel-registry.ts(注册表) |
| 路由 router | 决定这条消息该给哪个 agent、落到哪个会话 | src/router.ts |
| 会话管理 | 建会话目录、开关两个 session DB、写消息行 | src/session-manager.ts |
| 容器运行器 | 拼 docker run 参数、算挂载、拉起/杀掉容器 | src/container-runner.ts |
| 投递 delivery | 轮询 outbound.db,把 agent 的输出发回平台 | src/delivery.ts |
| 巡检 host-sweep | 60 秒一次:同步状态、判卡死、重试、重复任务重排 | src/host-sweep.ts |
| agent-runner | 容器内的轮询循环:读消息 → 调 provider → 写回复 | container/agent-runner/src/poll-loop.ts |
| guard | 所有特权动作(建 agent、装包、跨 agent 发消息)的唯一决策点 | src/guard/guard.ts |
| ncl CLI | 管理中央库的命令行,主机走 Unix socket、容器走 session DB | src/cli/dispatch.ts、container/agent-runner/src/cli/ncl.ts |
2.3 主线走一遍(高层,不进代码)
- 平台来消息。 适配器把它交给
routeInbound,主机顺手盖上「是哪个适配器实例收到的」这个戳。 - 路由决定归属。 查中央库:这个聊天窗口接了哪些 agent?这条消息够不够触发(被 @ 了?匹配正则?)?发消息的人有没有权限?
- 落到会话。 每个 (agent, 聊天群, 线程) 组合对应一个会话;会话在磁盘上是一个目录,里面有
inbound.db和outbound.db。主机把消息写进 inbound.db。 - 唤醒容器。 如果 这个会话没有容器在跑,
docker run拉一个,把会话目录挂进去。 - 容器干活。 容器里的 agent-runner 每 0.5~1 秒轮询一次 inbound.db,读到新消息就格式化成 prompt 交给 Claude Agent SDK;要发消息就写进 outbound.db。
- 主机投递。 主机每 1 秒轮询运行中会话的 outbound.db,把新行通过适配器发回平台,并在 inbound.db 的
delivered表里记一笔「这条发过了」。 - 巡检兜底。 每 60 秒一次的 sweep 负责所有异常路径:容器崩了、消息卡住了、到点的定时任务该唤醒了、重复任务该重排了。
注意第 3 步和第 6 步之间没有任何「通知」机制——没有信号、没有管道、没有文件监听。两边都是轮询 SQLite。这个看起来「土」的选择,恰恰是它能在 Docker Desktop 的 virtiofs 挂载上稳定工作的原因,第 2 章会讲清楚为什么。
3. 阅读地图
建议顺序(每章都能独立读,但按序读收益最大):
| 顺序 | 章节 | 讲什么 | 你会带走什么 |
|---|---|---|---|
| 1 | 01-message-lifecycle.md | 一条消息的一生,主线端到端 | 整个系统怎么串起来 |
| 2 | 02-two-db-ipc.md | 两个 SQLite 当 IPC 的全部工程细节 | 本项目最值钱的一章:跨挂载文件通信的坑与解法 |
| 3 | 03-container-isolation.md | 容器怎么起来、挂了什么、凭据怎么隔离 | 一套可抄的「给 AI 开沙箱」清单 |
| 4 | 04-agent-runner.md | 容器内的轮询循环与投递门 | 流式输出怎么做到「不漏发、不重发」 |
| 5 | 05-entities-and-guard.md | 实体模型、触发策略、特权动作闸门 | 多 agent × 多渠道的建模方式 |
| 6 | 06-extensibility.md | 模块注册表、skill 安装、定时任务 | 「主干不带功能、按需装」的实现手法 |
| 7 | 07-essence-and-limits.md | 精华 / 局限 / 对比 / 总代码地图 | 什么该抄、什么别抄 |
只想看一章? 想学跨进程通信 → 第 2 章;想学 AI 沙箱 → 第 3 章;想学 agent 输出的可靠投递 → 第 4 章。
4. 规模速览(用来校准预期)
| 指标 | 数值 | 怎么来的 |
|---|---|---|
| 主机侧源码(不含测试) | 约 23,600 行 TypeScript | src/**/*.ts 排除 *.test.ts |
| 主机侧测试 | 约 20,900 行 | src/**/*.test.ts |
| 容器侧 agent-runner(不含测试) | 约 5,700 行 | container/agent-runner/src/**/*.ts |
| 测试文件数 | 126 个 | src + container 下的 *.test.ts |
| 运行时依赖 | 8 个 | package.json dependencies |
测试量几乎和主机源码等量——这是个「注释比代码长、测试比代码多」的项目。读它的时候,注释本身就是设计文档:很多关键决策(为什么用 DELETE journal、为什么开一次关一次、为什么 seq 分奇偶)只写在源码注释里。
5. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 进程入口与启动顺序 | src/index.ts | main |
| 优雅关停 | src/index.ts | shutdown |
| 入站路由主函数 | src/router.ts | routeInbound |
| 会话解析与创建 | src/session-manager.ts | resolveSession |
| 容器唤醒 | src/container-runner.ts | wakeContainer |
| 出站投递轮询 | src/delivery.ts | deliverSessionMessages / drainSession |
| 60 秒巡检 | src/host-sweep.ts | sweep / sweepSession |
| 容器内主循环 | container/agent-runner/src/poll-loop.ts | runPollLoop |
| 中央库 schema(参考副本) | src/db/schema.ts | SCHEMA |
| 会话双库 schema | src/mailbox/sqlite/schema.ts | INBOUND_SCHEMA / OUTBOUND_SCHEMA |
| 特权动作决策 | src/guard/guard.ts | guard |