第18章:项目设计 —— 我们要做什么?
一句话:设计一个完整的 AI 助手项目,把前面学的知识全用上。
前面十七章,我们从 Agent 是什么聊起,一路学了 query() 函数、Tool 定义、流式输出、结构化输出、多轮对话、MCP 协议、Agent 编排……知识点不少,但一直在"单点训练"。
现在,是时候来一次"全真模拟考"了。
从这一章开始,我们要动手做一个真正的项目 —— MiniClaw(迷你爪),一个属于你自己的 AI 助手。它不是一个 demo,不是一个 toy project,而是一个你可以真正用起来的东西:通过 Telegram 给它发消息,它会用 Claude 帮你干活。
但在写代码之前,我们得先想清楚:要做什么?怎么做?为什么这么做?
这一章,就是我们的"图纸"。
本章目标
读完这一章,你将能够:
- 理解 MiniClaw 项目的定位和目标
- 掌握完整的功能清单和技术选型
- 理解系统的分层架构设计
- 看懂数据库设计和目录结构
- 对一条消息从"发送"到"回复"的完整生命周期了然于胸
- 理解我们的设计理念,以及为什么这么设计
前置知识
- 第1-17章的所有内容(不需要全记住,但至少过了一遍)
- 基本的项目开发经验(知道什么是数据库、API、Docker)
- 一个 Telegram 账号(后面要用)
18.1 项目目标:MiniClaw —— 一个小而美的 AI 助手
灵感来源
在开始之前,我们先聊聊灵感。
你可能听说过 NanoClaw 和 OpenClaw 这两个项目:
- OpenClaw 是一个大型的开源 AI 助手平台,功能非常强大:支持 15+ 个消息渠道(Telegram、WhatsApp、Slack、Discord……)、有插件市场、有完整的管理后台。但它的代码量也很大 —— 几十万行代码,52+ 个模块,45+ 个依赖。对于个人用户来说,这个项目太"重"了,你很难搞清楚它到底在干什么。
- NanoClaw 是一个人写的"反叛之作"。它的作者说了一句很有意思的话:"我没法安心运行一个我看不懂的软件,而它还能访问我的生活。" NanoClaw 用几千行代码实现了 OpenClaw 的核心功能,整个项目你花 8 分钟就能读完。它只支持 WhatsApp,但做得非常好。
MiniClaw 的定位介于两者之间 —— 更准确地说,它是 NanoClaw 的"教学版"。
MiniClaw 是什么
MiniClaw(迷你爪),是我们这个教程的毕业项目。它是一个:
- 个人 AI 助手:通过消息平台和你对话
- 可审计的小项目:目标代码量在 800 行左右,你可以逐行读完
- 真正能用的东西:不是 demo,是你可以部署到服务器上、每天用的工具
- 前17章知识的综合应用:Agent SDK、Tool、流式输出、多轮对话……全用上
核心理念
NanoClaw 的作者提出了一个很棒的理念,我们完全认同:
"AI 越来越强大,承载它的软件应该越来越简单。"
想想看:如果 Claude 足够聪明,能自己决定用什么工具、怎么执行任务,那我们为什么还需要写那么复杂的代码来"管理"它呢?我们需要的只是:
- 一个入口 —— 接收用户的消息
- 一个引擎 —— 让 Claude 去处理
- 一个出口 —— 把结果发回去
就这么简单。剩下的复杂度,交给 Claude 自己处理。
这就是 MiniClaw 的哲学:我们只写"管道",让 AI 做"大脑"。
目标用户
MiniClaw 的目标用户就是 你自己。
它不是一个框架,不是一个 SaaS 产品,不是要服务百万用户的平台。它就是你的私人助手,按照你的需求来定制。想改什么,直接改代码 —— 反正总共也就几百行。
18.2 功能清单:它能干什么?
好,理念聊完了,来看看 MiniClaw 具体能干什么。
核心功能
| 功能 | 说明 | 优先级 |
|---|---|---|
| 多渠道接入 | Telegram(主力)+ CLI(开发调试) | P0 |
| 智能对话 | 通过 Claude 进行自然语言交互 | P0 |
| 工具调用 | 执行命令、读写文件、搜索网页 | P0 |
| 容器隔离 | 每次对话在隔离的 Docker 容器中执行 | P0 |
| 持久记忆 | 每个聊天群有独立的 CLAUDE.md 记忆文件 | P1 |
| 定时任务 | Cron 定时触发 Agent 执行 | P1 |
| 多 Agent 协作 | Agent Swarm 模式 | P2 |
| 安全管控 | 权限管理 + 操作审计 | P1 |
| 费用追踪 | 监控 Token 用量和费用 | P2 |
功能详细说明
多渠道接入
MiniClaw 支持两种消息渠道:
- Telegram Bot:这是主力渠道。为什么选 Telegram?因为它的 Bot API 完全免费、文档清晰、支持群组、支持 Markdown 格式回复,最重要的是 —— 不用翻墙就能注册 Bot(虽然可能需要代理才能访问)。
- CLI:命令行界面,主要用于开发调试。你在终端里输入消息,Agent 在终端里回复。这样你不用每次都打开 Telegram 去测试。
为什么不支持微信?因为微信没有官方的 Bot API,所有的微信机器人都是基于逆向工程,随时可能被封号。我们不做不靠谱的事。
为什么不支持 WhatsApp?NanoClaw 已经做得很好了。我们选 Telegram 是为了差异化,也是因为在国内开发者群体中 Telegram 更常用。
智能对话
这是最核心的功能。用户发一条消息,MiniClaw 用 Claude Agent SDK 来处理。
不是简单的"你问我答"式聊天,而是真正的 Agent 模式 —— Claude 会思考、会调用工具、会多步推理。比如你说:
"帮我看看最近的 GitHub trending 上有什么好项目,挑 3 个最有意思的总结一下"
Claude 不会跟你说"你可以去 GitHub 看看",它会:
- 用搜索工具去查 GitHub trending
- 逐个分析项目
- 挑出 3 个最有意思的
- 写一份总结发给你
工具调用
MiniClaw 会给 Claude 配备以下工具:
- bash:执行 shell 命令(在容器内)
- read_file / write_file:读写文件
- web_search:搜索网页
- web_fetch:抓取网页内容
这些工具都在 Docker 容器里运行,所以即使 Claude "发疯"执行了 rm -rf /,也不会影响你的主机。
容器隔离
这是安全的基础。每次 Agent 执行任务时,都会在一个独立的 Docker 容器里运行。容器里有什么:
- Claude Agent SDK 运行环境
- 挂载的群组文件夹(CLAUDE.md 等)
- 必要的工具
容器里没有什么:
- 你的主机文件系统
- 你的 SSH 密钥
- 你的环境变量(除了明确传入的)
NanoClaw 在 macOS 上还支持 Apple Container(比 Docker 更轻量),但为了简化,MiniClaw 只用 Docker。
持久记忆
每个 Telegram 群(或私聊)都有一个独立的文件夹,里面有一个 CLAUDE.md 文件。这个文件就是 Agent 的"记忆"。
比如你在一个群里说:
"记住:我不喜欢在代码里用 var,一律用 const 或 let"
Agent 会把这条偏好写入 CLAUDE.md。下次你在这个群里让它写代码,它就会遵守这个约定。
不同的群有不同的 CLAUDE.md,互不影响。你的"工作群"和"生活群"可以有完全不同的记忆。
定时任务
你可以设置定时任务,让 Agent 按时执行:
"@MiniClaw 每天早上 9 点,帮我总结一下 Hacker News 上的热门文章"
MiniClaw 会用 cron 表达式来管理这些任务。到了时间,自动触发 Agent 执行,结果发到对应的群里。
多 Agent 协作(进阶功能)
这是 Claude Agent SDK 最新支持的特性 —— Agent Swarm。你可以启动多个 Agent 协作完成复杂任务。比如:
"@MiniClaw 分析一下这个开源项目的架构,一个 Agent 看前端,一个看后端,一个看 DevOps,最后汇总"
这个功能在我们的 MVP 版本中不是必须的,但架构设计上要留好扩展的空间。
18.3 技术选型:为什么选这些?
技术选型不是"什么最新用什么",也不是"什么最热用什么",而是"什么最适合用什么"。
总览
| 组件 | 选择 | 为什么 |
|---|---|---|
| 编程语言 | TypeScript | Claude Agent SDK 原生支持,类型安全 |
| 运行时 | Node.js 20+ | LTS 版本,稳定可靠 |
| 数据库 | SQLite (better-sqlite3) | 轻量级,无需额外服务,单文件就是一个数据库 |
| 消息渠道 | Telegram Bot API (grammy) | 免费、API 优秀、支持群组 |
| 容器 | Docker | 跨平台、生态成熟 |
| Agent 引擎 | Claude Agent SDK | 本教程的主角 |
| 包管理器 | pnpm | 比 npm 快,比 yarn 简单 |
| Schema 校验 | Zod | TypeScript 原生支持,类型推导强 |
| 日志 | pino | 结构化日志,性能好 |
逐个解释
为什么用 TypeScript 而不是 Python?
前面的章节里我们同时用了 TypeScript 和 Python,两种语言都可以。但 MiniClaw 选 TypeScript,原因有三:
- Agent SDK 是 TypeScript 写的:用 TypeScript 调用 TypeScript 库,天然融合,不用额外的类型转换
- NanoClaw 也是 TypeScript:我们可以参考它的实现
- 类型安全:当你的项目超过 200 行,TypeScript 的类型系统会帮你避免很多低级错误
为什么用 SQLite 而不是 PostgreSQL / MySQL?
MiniClaw 是个人助手,不是企业级应用。你的数据量不会太大(一天几百条消息撑死了),用 SQLite 完全够了。而且:
- 零依赖:不用安装数据库服务器,一个文件就是一个数据库
- 零配置:不用设置用户名、密码、端口、连接池
- 零运维:不会挂掉,不用备份(好吧,还是要备份,但不用担心数据库进程崩溃)
- 可移植:想迁移?把那个 .db 文件拷走就行
NanoClaw 也是用的 SQLite + better-sqlite3,这个方案经过了验证。
为什么用 grammy 而不是直接调 Telegram API?
grammy 是 Telegram Bot 的 TypeScript SDK,它封装了所有底层细节:
- 自动处理消息轮询(polling)
- 自动处理消息格式转换
- 自动处理错误重试
- 支持中间件模式,代码结构清晰
直接调 Telegram HTTP API 也可以,但你会花大量时间在"轮子"上,而不是在业务逻辑上。
为什么用 Docker?
容器隔离是 MiniClaw 安全模型的核心。Docker 的好处:
- 跨平台:macOS、Linux 都能用
- 成熟:十年历史,坑基本都踩完了
- 方便:一个
docker run就起来了
在生产环境中,你可能会考虑 Apple Container(macOS 原生,更轻量)或者 gVisor(更安全的沙箱),但 Docker 是最通用的选择。
为什么用 pino 做日志?
pino 的特点:
- 结构化输出:日志是 JSON 格式,方便后续搜索和分析
- 快:号称 Node.js 最快的日志库
- 简单:API 很少,学习成本低
用 console.log 行不行?行。但当你需要区分日志级别(info/warn/error)、需要按模块过滤日志、需要格式化输出的时候,就会后悔没用日志库了。
18.4 架构设计:系统长什么样?
分层架构图
先来看全景图。MiniClaw 采用经典的分层架构,从上到下五层:
用户(Telegram / CLI)
│
│ 发消息
▼
┌─────────────────────────────────────────────────────────────────┐
│ Channel 层 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ telegram.ts │ │ cli.ts │ │
│ │ (Telegram Bot) │ │ (命令行界面) │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ └───────────┬───────────┘ │
│ │ 统一消息格式 │
└────────────────────────┼────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Router 层 │
│ │
│ - 消息过滤:这条消息需要处理吗? │
│ - 触发检测:包含 @MiniClaw 关键词吗? │
│ - 消息格式化:把原始消息转成 Agent 能理解的格式 │
│ │
└────────────────────────┼────────────────────────────────────────┘
│
│ 过滤后的消息
▼
┌─────────────────────────────────────────────────────────────────┐
│ Queue 层 │
│ │
│ - 按群组排队:同一群组的消息串行处理 │
│ - 并发控制:最多同时运行 N 个容器 │
│ - 优先级管理:定时任务 vs 即时消息 │
│ │
└────────────────────────┼────────────────────────────────────────┘
│
│ 任务
▼
┌─────────────────────────────────────────────────────────────────┐
│ Agent 层 │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Docker 容器 │ │
│ │ │ │
│ │ ┌───────────────┐ ┌───────────────┐ │ │
│ │ │ runner.ts │ │ tools.ts │ │ │
│ │ │ (Agent 运行器) │ │ (自定义工具) │ │ │
│ │ └───────┬───────┘ └───────────────┘ │ │
│ │ │ │ │
│ │ │ Claude Agent SDK │ │
│ │ │ query() / stream() │ │
│ │ ▼ │ │
│ │ ┌───────────────┐ │ │
│ │ │ Claude API │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└────────────────────────┼────────────────────────────────────────┘
│
│ 结果
▼
┌─────────────────────────────────────────────────────────────────┐
│ Storage 层 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ SQLite (db.ts) │ │ 文件系统 │ │
│ │ │ │ (groups/) │ │
│ │ - messages │ │ - CLAUDE.md │ │
│ │ - groups │ │ - 工作文件 │ │
│ │ - sessions │ │ │ │
│ │ - tasks │ │ │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
各层详解
第一层:Channel 层 —— 消息的入口和出口
Channel 层负责两件事:
- 收消息:从 Telegram / CLI 接收用户消息
- 发消息:把 Agent 的回复发回去
这一层的关键设计是 统一抽象。不管消息来自 Telegram 还是 CLI,到了 Router 层看到的都是同一种格式:
// 统一的消息格式
interface IncomingMessage {
id: string; // 消息唯一 ID
groupId: string; // 群组 ID
sender: string; // 发送者
senderName: string; // 发送者名称
content: string; // 消息内容
timestamp: string; // 时间戳
channel: 'telegram' | 'cli'; // 来源渠道
}
// Channel 接口
interface Channel {
name: string;
connect(): Promise<void>;
sendMessage(groupId: string, text: string): Promise<void>;
isConnected(): boolean;
disconnect(): Promise<void>;
}
NanoClaw 也是这么设计的。它的 Channel 接口定义了 connect()、sendMessage()、isConnected()、disconnect() 四个方法。任何消息渠道只要实现这四个方法,就能接入系统。
为什么要这么抽象?
因为未来你可能想加 Slack、Discord 甚至微信(如果有了官方 API),到时候只需要写一个新的 Channel 实现,不用改其他任何代码。这就是 面向接口编程 的好处。
第二层:Router 层 —— 消息的交通警察
Router 层像一个交通警察,决定哪些消息需要处理、哪些可以忽略。
为什么需要这一层?因为在一个 Telegram 群里,不是每条消息都需要 AI 处理。你和朋友聊天、发表情包、转发链接 —— 这些都不需要 Agent 介入。只有当你明确"叫"它的时候,它才应该响应。
Router 的判断逻辑:
收到消息
│
├─ 私聊?→ 直接处理(私聊默认就是在和 Bot 说话)
│
└─ 群聊?→ 检查是否包含触发词
│
├─ 包含 "@MiniClaw" → 处理
├─ 是对 Bot 消息的回复 → 处理
└─ 都不是 → 忽略(但存储消息,Agent 需要上下文)
NanoClaw 用的触发模式是 @Andy(它的默认名字叫 Andy),我们用 @MiniClaw。触发词是可配置的。
一个重要的细节:即使消息不需要处理,我们也会存储它。 因为当 Agent 处理下一条消息时,它需要看到之前的对话上下文,才能理解"这个人在说什么"。
第三层:Queue 层 —— 排队系统
Queue 层解决的是 并发问题。
想象一下这个场景:
- 你在 A 群发了一条消息:"帮我搜索 Python 教程"
- 紧接着在 B 群发了一条:"帮我写个 Hello World"
- A 群里有人又发了一条:"@MiniClaw 对了,要中文的"
如果没有 Queue 层,三条消息同时开始处理,就会出问题:
- 资源争抢:同时启动 3 个 Docker 容器,可能超出系统资源
- 上下文错乱:A 群的第二条消息应该在第一条处理完之后再处理,否则 Agent 不知道"中文的"是指什么
- 竞态条件:两个 Agent 同时操作同一个群的 CLAUDE.md,可能导致数据丢失
Queue 层的规则很简单:
- 同一群组的消息串行处理:A 群的消息排队一个一个来
- 不同群组的消息并行处理:A 群和 B 群互不影响
- 全局并发上限:最多同时运行 N 个容器(默认 3 个)
- 任务优先级:定时任务 < 即时消息
NanoClaw 的 GroupQueue 类就是这个设计。它维护了一个 per-group 的队列,加上一个全局的并发计数器。当一个群的任务完成后,它会先检查本群有没有排队的任务,再检查其他群有没有在等待的任务。这个设计既保证了公平性,又保证了效率。
// 简化版的 Queue 逻辑
class GroupQueue {
private groups = new Map<string, GroupState>();
private activeCount = 0;
private maxConcurrent = 3;
enqueue(groupId: string, task: Task): void {
const state = this.getGroup(groupId);
// 如果这个群已经在处理中,排队等候
if (state.active) {
state.pendingTasks.push(task);
return;
}
// 如果全局并发已满,也排队
if (this.activeCount >= this.maxConcurrent) {
state.pendingTasks.push(task);
return;
}
// 否则立即执行
this.run(groupId, task);
}
}
第四层:Agent 层 —— 核心引擎
这是整个系统最重要的一层。Agent 层负责:
- 启动 Docker 容器
- 在容器里运行 Claude Agent SDK
- 管理会话(session)
- 收集执行结果
让我们拆开看看:
容器管理(container.ts)
每次需要处理消息时,Agent 层会:
1. docker run --rm \
-v /groups/my-group:/workspace/group \ # 挂载群组文件夹
-e ANTHROPIC_API_KEY=xxx \ # 传入 API Key
miniclaw-agent \ # 使用我们的镜像
node runner.js # 运行 Agent 脚本
--rm 参数表示容器退出后自动删除,不留垃圾。
Agent 运行器(runner.ts)
这是在容器内运行的脚本。它做的事情很简单:
// runner.ts(简化版)
import { query } from '@anthropic-ai/agent-sdk';
// 1. 读取 CLAUDE.md 作为系统提示
const memory = readFile('/workspace/group/CLAUDE.md');
// 2. 构建提示
const systemPrompt = `你是 MiniClaw,一个 AI 助手。\n\n记忆:\n${memory}`;
// 3. 调用 Agent SDK
const result = await query({
model: 'claude-sonnet-4-20250514',
systemPrompt,
messages: formattedMessages,
tools: [bashTool, readFileTool, writeFileTool, webSearchTool],
maxTurns: 10,
});
// 4. 输出结果
console.log(JSON.stringify({ status: 'success', result }));
看到了吗?这就是前面十七章学的东西全部汇聚的地方:
query()—— 第4章tools—— 第5-6章systemPrompt—— 第8章- 多轮执行 —— 第10章
- 会话管理 —— 第10章
自定义工具(tools.ts)
除了 Agent SDK 内置的工具,我们还会定义一些自定义工具:
const tools = [
{
name: 'web_search',
description: '搜索网页,获取最新信息',
input_schema: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词' }
},
required: ['query']
}
},
{
name: 'remember',
description: '把重要信息记住,写入长期记忆',
input_schema: {
type: 'object',
properties: {
content: { type: 'string', description: '要记住的内容' }
},
required: ['content']
}
}
];
第五层:Storage 层 —— 数据持久化
Storage 层分两部分:
- SQLite 数据库:存储结构化数据(消息、群组、会话、定时任务)
- 文件系统:存储非结构化数据(CLAUDE.md 记忆文件、Agent 工作文件)
为什么要用两种存储?因为各有所长:
- 消息、任务这类需要查询和过滤的数据,放在 SQLite 里方便用 SQL 操作
- CLAUDE.md 这种 Agent 需要直接读写的文件,放在文件系统里更方便
数据流的完整路径
一条消息从"发送"到"回复",经过的完整路径是:
用户 → Telegram → Channel 层 → Router 层 → Queue 层 → Agent 层 → Claude API
│
用户 ← Telegram ← Channel 层 ← Router 层 ← Agent 层 ← Claude 回复 ←──┘
│
Storage 层(保存消息和结果)
注意这里有一个"回路"设计:Agent 的回复也是通过 Router 层格式化、通过 Channel 层发送的。这样保证了所有的消息(无论进出)都经过统一的处理路径。
18.5 数据库设计:存什么?怎么存?
SQLite 数据库里有四张核心表。我们一张一张来看。
messages 表 —— 消息记录
CREATE TABLE IF NOT EXISTS messages (
id TEXT, -- 消息 ID(Telegram 消息 ID)
group_id TEXT, -- 群组 ID
sender TEXT, -- 发送者 ID
sender_name TEXT, -- 发送者名称(显示名)
content TEXT, -- 消息内容
timestamp TEXT, -- ISO 时间戳
is_from_me INTEGER DEFAULT 0, -- 是不是 Bot 自己发的
is_bot_response INTEGER DEFAULT 0, -- 是不是 Bot 的回复
channel TEXT DEFAULT 'telegram', -- 来源渠道
PRIMARY KEY (id, group_id) -- 联合主键
);
CREATE INDEX idx_messages_timestamp ON messages(timestamp);
CREATE INDEX idx_messages_group ON messages(group_id, timestamp);
为什么需要 is_from_me 和 is_bot_response 两个字段?
is_from_me:标记这条消息是不是 Bot 账号发送的。有些场景下,Bot 的消息也需要被 Agent 处理(比如在群里收到自己的消息时)。is_bot_response:标记这条消息是不是 Agent 生成的回复。这样在构建上下文时,我们可以区分"用户说的话"和"Agent 说的话"。
为什么 PRIMARY KEY 是 (id, group_id) 联合主键?
因为 Telegram 的消息 ID 只在群内唯一,不同群可能有相同的消息 ID。加上 group_id 才能全局唯一。
groups 表 —— 群组信息
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY, -- 群组 ID(Telegram chat ID)
name TEXT NOT NULL, -- 群组名称
folder TEXT NOT NULL UNIQUE, -- 对应的文件夹名
trigger_pattern TEXT NOT NULL -- 触发词模式
DEFAULT '@MiniClaw',
requires_trigger INTEGER DEFAULT 1, -- 是否需要触发词(私聊可以不需要)
created_at TEXT NOT NULL, -- 创建时间
settings TEXT DEFAULT '{}' -- JSON 格式的额外设置
);
folder 字段是干什么的?
每个群组在文件系统上有一个对应的文件夹,里面存放 CLAUDE.md 等文件。folder 字段就是这个文件夹的名称。
为什么不直接用群组 ID 做文件夹名?因为 Telegram 的 chat ID 是一串数字(比如 -1001234567890),不够直观。我们用群组名称的 slug 版本作为文件夹名(比如 my-work-group),更容易管理。
settings 字段存什么?
一些群组级别的配置,用 JSON 格式存储。比如:
{
"maxTurns": 15, // Agent 最多执行几轮
"timeout": 300000, // 超时时间(毫秒)
"containerMounts": [] // 额外挂载的目录
}
用 JSON 而不是加列,是因为这些设置可能会频繁变化。每次加新设置都改表结构不太现实。
sessions 表 —— 会话管理
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- 会话 ID(UUID)
group_id TEXT NOT NULL, -- 所属群组
agent_session_id TEXT, -- Agent SDK 的会话 ID
status TEXT DEFAULT 'active', -- active / completed / error
created_at TEXT NOT NULL, -- 创建时间
updated_at TEXT NOT NULL, -- 最后更新时间
token_usage INTEGER DEFAULT 0, -- Token 使用量
FOREIGN KEY (group_id) REFERENCES groups(id)
);
CREATE INDEX idx_sessions_group ON sessions(group_id, status);
为什么要记录 agent_session_id?
Claude Agent SDK 的 query() 函数可以接受一个 sessionId 参数来恢复之前的会话。我们把这个 ID 存起来,下次同一个群的消息进来时,可以接着之前的会话继续,而不是每次都开一个新的。
这就是我们在第10章学的"多轮对话"的实际应用。
token_usage 是干什么的?
费用追踪。每次 Agent 执行完,我们会记录消耗了多少 Token。你可以按群组、按时间段来统计费用。
scheduled_tasks 表 —— 定时任务
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id TEXT PRIMARY KEY, -- 任务 ID(UUID)
group_id TEXT NOT NULL, -- 在哪个群执行
cron_expr TEXT NOT NULL, -- Cron 表达式
prompt TEXT NOT NULL, -- 执行的提示词
enabled INTEGER DEFAULT 1, -- 是否启用
last_run TEXT, -- 上次执行时间
last_result TEXT, -- 上次执行结果
created_at TEXT NOT NULL, -- 创建时间
FOREIGN KEY (group_id) REFERENCES groups(id)
);
CREATE INDEX idx_tasks_enabled ON scheduled_tasks(enabled);
Cron 表达式是什么?
一种表示"什么时候执行"的标准格式。比如:
0 9 * * *= 每天早上 9 点0 9 * * 1-5= 每个工作日早上 9 点*/30 * * * *= 每 30 分钟
NanoClaw 支持三种调度类型:cron(定时)、interval(间隔)、once(一次性)。为了简化,MiniClaw 只支持 cron。
表关系图
┌───────────┐ ┌───────────┐
│ groups │──1:N──│ messages │
│ │ │ │
│ id ──────│──┐ │ group_id │
└───────────┘ │ └───────────┘
│
├────┌───────────┐
│ │ sessions │
│ │ │
└────│ group_id │
│ └───────────┘
│
├────┌───────────────┐
│ │scheduled_tasks │
│ │ │
└────│ group_id │
└───────────────┘
很简单的星型结构:groups 是中心,其他表都通过 group_id 关联。
18.6 目录结构设计:代码放在哪?
完整目录结构
miniclaw/
├── src/ # 源代码
│ ├── index.ts # 入口:启动所有组件
│ ├── config.ts # 配置管理
│ ├── db.ts # 数据库操作
│ ├── channels/ # 渠道实现
│ │ ├── telegram.ts # Telegram Bot 集成
│ │ └── cli.ts # CLI 界面(开发用)
│ ├── agent/ # Agent 核心
│ │ ├── runner.ts # Agent 运行器(最核心的文件!)
│ │ ├── container.ts # 容器管理
│ │ └── tools.ts # 自定义工具定义
│ ├── router.ts # 消息路由
│ ├── queue.ts # 任务队列
│ └── scheduler.ts # 定时任务调度器
├── groups/ # 群组数据文件夹
│ └── {group-name}/ # 每个群一个文件夹
│ └── CLAUDE.md # 该群的记忆文件
├── data/ # 运行时数据
│ └── miniclaw.db # SQLite 数据库文件
├── container/ # 容器相关
│ ├── Dockerfile # Agent 容器镜像
│ └── agent-entry.ts # 容器内的入口脚本
├── package.json # 项目依赖
├── tsconfig.json # TypeScript 配置
├── docker-compose.yml # Docker Compose 配置
├── .env.example # 环境变量示例
└── CLAUDE.md # 项目级记忆文件
每个文件的职责
我来一个一个解释,这样你写代码的时候不会迷路。
src/index.ts —— 总指挥
这是整个程序的入口。它的工作是:
- 读取配置
- 初始化数据库
- 启动 Channel(连接 Telegram)
- 启动 Scheduler(定时任务调度)
- 开始消息循环
大约 50 行代码。
src/config.ts —— 配置管理
从环境变量或 .env 文件读取配置:
// config.ts 大概长这样
export const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY!;
export const TRIGGER_WORD = process.env.TRIGGER_WORD || '@MiniClaw';
export const MAX_CONCURRENT = parseInt(process.env.MAX_CONCURRENT || '3');
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '300000');
export const DATA_DIR = process.env.DATA_DIR || './data';
export const GROUPS_DIR = process.env.GROUPS_DIR || './groups';
大约 20 行代码。简单粗暴,不搞什么"配置管理框架"。
src/db.ts —— 数据库操作
封装所有的 SQLite 操作。包括:
initDatabase()—— 创建表结构storeMessage()—— 保存消息getRecentMessages()—— 获取最近的消息(给 Agent 做上下文)getGroup()/setGroup()—— 群组管理getSession()/setSession()—— 会话管理getDueTasks()—— 获取到期的定时任务
大约 120 行代码。
src/channels/telegram.ts —— Telegram 集成
用 grammy 库实现的 Telegram Bot。核心逻辑:
- 创建 Bot 实例
- 监听所有消息
- 把 Telegram 消息转成我们的统一格式
- 转发给 Router 处理
- 把 Agent 的回复发回 Telegram
大约 100 行代码。
src/channels/cli.ts —— CLI 界面
开发调试用的命令行界面。用 Node.js 的 readline 模块实现交互式输入。
大约 50 行代码。
src/agent/runner.ts —— Agent 运行器
这是整个项目最核心的文件。
它负责:
- 构建 system prompt(包括 CLAUDE.md 记忆)
- 格式化历史消息
- 调用 Claude Agent SDK 的
query()或stream() - 处理工具调用
- 收集执行结果
大约 150 行代码。前面十七章学的所有技术点,都在这个文件里汇聚。
src/agent/container.ts —— 容器管理
管理 Docker 容器的生命周期:
startContainer()—— 启动容器stopContainer()—— 停止容器buildImage()—— 构建 Agent 镜像
大约 80 行代码。
src/agent/tools.ts —— 自定义工具
定义 Agent 可以使用的工具列表和工具执行逻辑。
大约 60 行代码。
src/router.ts —— 消息路由
消息过滤和格式化逻辑。
大约 60 行代码。
src/queue.ts —— 任务队列
per-group 的队列实现,带全局并发控制。
大约 80 行代码。
src/scheduler.ts —— 定时任务
轮询数据库中的 scheduled_tasks 表,把到期的任务提交给 Queue。
大约 50 行代码。
代码量统计
| 文件 | 预估行数 | 职责 |
|---|---|---|
| index.ts | ~50 | 启动 |
| config.ts | ~20 | 配置 |
| db.ts | ~120 | 数据库 |
| telegram.ts | ~100 | Telegram |
| cli.ts | ~50 | CLI |
| runner.ts | ~150 | Agent 核心 |
| container.ts | ~80 | 容器 |
| tools.ts | ~60 | 工具 |
| router.ts | ~60 | 路由 |
| queue.ts | ~80 | 队列 |
| scheduler.ts | ~50 | 定时任务 |
| 总计 | ~820 |
820 行。在我们的 800 行目标附近。完全可以在一个下午读完全部代码。
18.7 消息处理流程详解:一条消息的旅程
让我们用一个具体的例子,走一遍完整的消息处理流程。
场景
用户 "小明" 在 Telegram 群 "AI学习小组" 中发了一条消息:
@MiniClaw 帮我查一下北京今天的天气
完整流程
第 1 步:Telegram 推送消息
Telegram 服务器通过长轮询(polling)把这条消息推送给我们的 Bot。grammy 库帮我们处理了所有的底层通信。
Telegram Server → grammy → bot.on('message') 回调触发
第 2 步:Channel 层接收并格式化
telegram.ts 收到原始的 Telegram 消息对象,把它转换成我们的统一格式:
const message: IncomingMessage = {
id: '12345',
groupId: '-1001234567890',
sender: '987654321',
senderName: '小明',
content: '@MiniClaw 帮我查一下北京今天的天气',
timestamp: '2025-03-15T10:30:00Z',
channel: 'telegram'
};
同时,消息被保存到 SQLite 的 messages 表中。
第 3 步:Router 层判断是否需要处理
Router 检查消息内容:
// router.ts 里的逻辑
function shouldProcess(message: IncomingMessage, group: Group): boolean {
// 私聊?直接处理
if (!group.isGroup) return true;
// 群聊?检查触发词
if (message.content.includes(group.triggerPattern)) return true;
// 是对 Bot 消息的回复?也处理
if (message.replyToBot) return true;
// 其他情况:不处理
return false;
}
这条消息包含 "@MiniClaw" 触发词,通过检查。Router 把触发词去掉,提取出纯粹的用户意图:
原始: "@MiniClaw 帮我查一下北京今天的天气"
处理后: "帮我查一下北京今天的天气"
第 4 步:Queue 层排队
Router 把处理后的消息提交给 Queue:
queue.enqueue('-1001234567890', processTask);
Queue 检查:
- "AI学习小组" 当前没有正在处理的任务 → 不用排队
- 全局活跃容器数 < 3 → 没有超过并发上限
- 结论:立即执行
第 5 步:Agent 层启动容器
容器管理器启动一个 Docker 容器:
docker run --rm \
--name miniclaw-ai-study-group-abc123 \
-v $(pwd)/groups/ai-study-group:/workspace/group \
-e ANTHROPIC_API_KEY=sk-ant-xxx \
miniclaw-agent \
node agent-entry.js
容器启动后,agent-entry.ts 开始执行。
第 6 步:构建 Agent 上下文
在容器内,runner.ts 做了以下准备工作:
- 读取 CLAUDE.md:
/workspace/group/CLAUDE.md 内容:
---
这个群是 AI 学习小组,成员主要讨论 AI 相关技术。
- 用户偏好中文回复
- 回复风格:简洁、有条理
---
- 获取最近消息(从 SQLite,通过 IPC 传入容器):
[10:28] 小明: 今天北京好冷啊
[10:29] 小红: 是啊,听说要降温
[10:30] 小明: @MiniClaw 帮我查一下北京今天的天气
- 构建 system prompt:
你是 MiniClaw,一个 AI 助手。你在"AI学习小组"群中。
记忆:
这个群是 AI 学习小组,成员主要讨论 AI 相关技术。
- 用户偏好中文回复
- 回复风格:简洁、有条理
当前时间:2025-03-15 10:30:00
第 7 步:调用 Claude Agent SDK
const result = await query({
model: 'claude-sonnet-4-20250514',
systemPrompt: systemPrompt,
messages: [
{ role: 'user', content: '帮我查一下北京今天的天气' }
],
tools: [webSearchTool, bashTool],
maxTurns: 10,
});
第 8 步:Agent 思考并使用工具
Claude 的思考过程(简化版):
思考:用户想知道北京今天的天气,我需要用搜索工具查一下。
工具调用:web_search({ query: "北京今天天气 2025年3月15日" })
工具返回:
北京今天天气:晴转多云,气温 5°C ~ 15°C,
西北风 3-4 级,空气质量良好...
思考:我得到了天气信息,整理后回复用户。
第 9 步:生成回复
Agent 生成最终回复:
北京今天天气:
- 天气:晴转多云
- 气温:5°C ~ 15°C
- 风力:西北风 3-4 级
- 空气质量:良好
建议多穿点衣服,昼夜温差较大。
第 10 步:结果返回
容器把结果写入标准输出(stdout),外部的 container.ts 通过监听子进程的输出来收集结果:
const output: ContainerOutput = {
status: 'success',
result: '北京今天天气:\n\n- 天气:晴转多云\n...',
newSessionId: 'sess_abc123',
};
容器随即退出并被自动清理(--rm 参数的效果)。
第 11 步:发送回复
结果经过 Router 层格式化,通过 Channel 层发回 Telegram:
await bot.api.sendMessage(
'-1001234567890', // 群 ID
'北京今天天气:\n\n- 天气:晴转多云\n...',
{ parse_mode: 'Markdown' }
);
第 12 步:保存记录
最后,Agent 的回复也被保存到 messages 表:
storeMessage({
id: 'bot-reply-12346',
groupId: '-1001234567890',
sender: 'miniclaw-bot',
senderName: 'MiniClaw',
content: '北京今天天气:...',
timestamp: '2025-03-15T10:30:15Z',
isBotResponse: true
});
会话 ID 被保存到 sessions 表,下次这个群有消息时可以恢复会话。
流程总结
整个过程用时约 5-15 秒(取决于 Claude API 的响应速度)。用户的体验就是:
- 在 Telegram 里发消息
- 等几秒
- Bot 回复了天气信息
用户完全感知不到背后的容器、队列、数据库这些东西。好的基础设施就应该是这样 —— 看不见,但一直在。
18.8 核心设计理念:为什么这么设计?
MiniClaw 的设计不是拍脑袋想出来的,是从 NanoClaw 和 OpenClaw 的实践中总结出来的。
从 NanoClaw 学到的
1. 代码量控制在 1000 行以内
NanoClaw 的作者能说出这句话:
"整个项目你花 8 分钟就能读完。"
这不是吹牛,是他刻意追求的结果。当你的 AI 助手有权限执行命令、读写文件、访问网络的时候,你必须能够理解它的每一行代码。不是"大概知道它在干什么",而是"确切知道它在干什么"。
MiniClaw 同样追求这个目标。800 行代码,你一个下午就能全部读完、全部理解。
2. 单进程,简单可靠
NanoClaw 是单进程架构。不搞微服务,不搞消息队列,不搞分布式。一个 Node.js 进程跑所有逻辑。
为什么?因为对于个人助手来说,单进程已经足够了。你不需要处理每秒百万条消息,你的消息量可能一天不到一百条。单进程意味着:
- 不用处理进程间通信
- 不用管分布式一致性
- 调试简单(一个进程,一份日志)
- 部署简单(一个命令启动)
MiniClaw 也采用单进程架构。
3. 容器隔离是安全的基础
NanoClaw 的安全模型不是靠"代码里检查权限"(application-level security),而是靠"操作系统级别的隔离"(OS-level isolation)。Agent 运行在容器里,它物理上就碰不到你的主机文件系统。
这比任何权限检查代码都靠谱。因为权限检查代码可能有 bug,容器隔离是操作系统保证的。
MiniClaw 同样采用容器隔离。
4. 约定优于配置
NanoClaw 没有配置文件。不是"有一个但很简单",是压根就没有。
想改触发词?改代码。想改超时时间?改代码。想改日志级别?改代码。
听起来很"原始"对吧?但想想看 —— 你的代码只有几百行,改一行配置和改一行代码有什么区别?反而省去了"配置文件解析"这层复杂度。
MiniClaw 折中了一下:用环境变量做配置(.env 文件),因为有些东西(API Key、Bot Token)确实不应该硬编码在代码里。但除了这些敏感信息,其他的配置都直接写在代码里。
5. Fork 定制,而非插件架构
NanoClaw 不搞插件系统。它的扩展方式很简单 —— Fork 一份,然后直接改代码。
为什么不搞插件?因为插件系统本身就是一大坨代码。你需要定义插件 API、写插件加载器、处理插件之间的依赖关系、做插件沙箱……这些代码可能比你的核心业务逻辑还多。
对于个人项目来说,直接改代码是最高效的扩展方式。
MiniClaw 也是这个理念。你想加个新功能?直接在代码里加。你的定制版就是你的版本。
从 OpenClaw 学到的
OpenClaw 虽然"重",但它在工程上有很多值得学习的地方。
1. Lane Queue 系统
OpenClaw 的 Lane Queue 是一个精心设计的并发控制系统。它确保同一个对话通道(lane)内的消息串行处理,不同通道并行处理。这个设计解决了很多实际问题:
- 防止同一个用户的两条消息被同时处理
- 防止两个 Agent 同时写一个文件
- 在资源有限时合理分配容器
NanoClaw 的 GroupQueue 就是受此启发。MiniClaw 的 Queue 层也是。
2. 语义快照
OpenClaw 在处理网页内容时,不是保存原始 HTML,而是保存"语义快照" —— 提取出关键信息后的结构化数据。这样既节省存储空间,又让 Agent 更容易理解。
MiniClaw 在搜索工具的实现中会借鉴这个思路。
3. 多渠道抽象
OpenClaw 支持 15+ 个消息渠道,它的渠道抽象层设计得非常好。虽然我们不需要支持那么多渠道,但"统一消息格式"这个设计模式是值得借鉴的。
18.9 与 NanoClaw / OpenClaw / ZeroClaw 的对比
让我们把 MiniClaw 和业内的几个主要项目做个对比,看看各自的定位有什么不同。
项目对比表
| 特性 | MiniClaw(我们的) | NanoClaw | OpenClaw | ZeroClaw |
|---|---|---|---|---|
| 定位 | 教学项目 + 可用 | 个人助手 | 全功能平台 | 超轻量运行时 |
| 语言 | TypeScript | TypeScript | TypeScript | Rust |
| 代码量 | ~800 行 | ~数千行 | ~430K 行 | ~数万行 |
| 消息渠道 | Telegram + CLI | 15+ 渠道 | 多渠道(trait 抽象) | |
| 容器隔离 | Docker | Docker / Apple Container | 无(应用级安全) | 沙箱 + allowlist |
| Agent 引擎 | Claude Agent SDK | Claude Agent SDK | 自研引擎 | 多 Provider 可换 |
| 记忆 | SQLite + CLAUDE.md | SQLite + CLAUDE.md | .jsonl 转录 | 可插拔记忆后端 |
| 扩展方式 | 直接改代码 | Skills 技能 | ClawHub 插件 | Trait 接口实现 |
| 定时任务 | Cron | Cron / Interval / Once | Gateway Jobs | Cron |
| 内存占用 | ~100MB | ~100MB | >1GB | <5MB |
| 目标用户 | 学习者 | 个人 | 企业 / 团队 | 极客 / 嵌入式 |
几点说明
MiniClaw vs NanoClaw
MiniClaw 可以看作 NanoClaw 的"教学简化版"。主要区别:
- MiniClaw 用 Telegram,NanoClaw 用 WhatsApp
- MiniClaw 代码更少,因为砍掉了一些生产环境才需要的功能(比如 Apple Container 支持、mount allowlist 等)
- MiniClaw 有详细的注释和文档,是为了教学;NanoClaw 的代码更精炼
MiniClaw vs OpenClaw
完全不同的量级。OpenClaw 是一个"平台",MiniClaw 是一个"工具"。打个比方:OpenClaw 是一辆大巴车,能坐 50 个人;MiniClaw 是一辆自行车,就你一个人骑。你不需要大巴车来上班。
MiniClaw vs ZeroClaw
ZeroClaw 用 Rust 写的,追求极致性能。它能在 $10 的硬件上运行,内存占用不到 5MB。但代价是:Rust 的学习曲线陡峭,代码不如 TypeScript 易读。MiniClaw 选择了"好理解",ZeroClaw 选择了"高性能",各有各的权衡取舍。
我们的独特价值
MiniClaw 的独特价值不在于功能多强大、性能多高,而在于:
- 可学习性:每一行代码都有对应的教程章节在解释"为什么这么写"
- 可理解性:800 行代码,一个下午读完
- 可修改性:想改什么就改什么,不用担心"改坏了"
- 知识的完整闭环:从第1章到第18章,从理论到实践,从零到一
18.10 安全设计:不能大意的事
安全不是"加分项",是"基本线"。一个能执行命令、读写文件的 AI 助手,如果安全没做好,后果可能很严重。
三层安全模型
┌───────────────────────────────────────────┐
│ 第一层:容器隔离(OS 级别) │
│ Agent 物理上碰不到主机文件系统 │
├───────────────────────────────────────────┤
│ 第二层:权限控制(应用级别) │
│ 只有授权的群才能触发 Agent │
├───────────────────────────────────────────┤
│ 第三层:操作审计(事后追溯) │
│ 所有操作都有日志记录 │
└───────────────────────────────────────────┘
第一层:容器隔离
- Agent 在 Docker 容器中运行
- 只挂载该群组的文件夹,不挂载其他任何目录
- 不传入不必要的环境变量
- 容器用完即删(
--rm) - 设置资源限制(CPU、内存、运行时间)
// 容器配置示例
const containerConfig = {
image: 'miniclaw-agent',
rm: true, // 用完即删
timeout: 300000, // 5分钟超时
memory: '512m', // 内存限制
cpus: '1.0', // CPU 限制
network: 'bridge', // 网络隔离
volumes: [
`${groupDir}:/workspace/group` // 只挂载本群文件夹
],
env: {
ANTHROPIC_API_KEY: apiKey, // 只传必要的环境变量
}
};
第二层:权限控制
- 只有注册过的群才能触发 Agent
- 主频道(你的私聊)有管理权限
- 其他群的权限可以单独配置
第三层:操作审计
- 所有消息记录到数据库
- 所有 Agent 的工具调用记录到日志
- 定时任务的每次执行结果都保存
不做什么
安全设计也包括"决定不做什么":
- 不支持微信:没有官方 API,逆向工程不可控
- 不做远程管理后台:减少攻击面
- 不做多用户:每个人自己部署自己的实例
- 不做热更新:改代码后重启,简单粗暴但安全
18.11 开发计划:怎么一步步来?
好了,设计完毕。但我们不会一上来就写 820 行代码。我们分四步走:
Phase 1:最小可用版本(第19-20章)
目标:能在 CLI 里和 Agent 对话。
实现:
config.ts+db.ts—— 基础设施runner.ts—— Agent 核心cli.ts—— CLI 界面- 不用 Docker,直接在本地运行
代码量:~200 行
完成后你就有一个能用的 CLI AI 助手了。
Phase 2:接入 Telegram(第21章)
目标:通过 Telegram 和 Agent 对话。
新增:
telegram.ts—— Telegram Botrouter.ts—— 消息路由
代码量:累计 ~400 行
完成后你就有一个真正的 Telegram AI 助手了。
Phase 3:容器隔离 + 队列(第22章)
目标:安全可靠。
新增:
container.ts—— Docker 容器管理queue.ts—— 任务队列Dockerfile—— Agent 容器镜像
代码量:累计 ~600 行
完成后你的 AI 助手就有了安全保障。
Phase 4:高级功能(第23章)
目标:锦上添花。
新增:
scheduler.ts—— 定时任务- 持久记忆(CLAUDE.md 读写)
- 费用追踪
代码量:累计 ~800 行
完成后就是完整的 MiniClaw 了。
为什么这么分?
每个 Phase 结束后,你都有一个可以运行的东西。不是"代码写了一半跑不起来",而是"每一步都能看到成果"。
这也是软件开发的一个重要原则:增量交付。不要试图一次做完所有事情,而是一步一步来,每一步都确保能用。
本章小结
这一章我们完成了 MiniClaw 的完整设计。来回顾一下关键点:
- 定位:MiniClaw 是一个小而美的个人 AI 助手,目标 800 行代码,完全可审计
- 功能:Telegram 接入 + 智能对话 + 工具调用 + 容器隔离 + 持久记忆 + 定时任务
- 架构:五层分层架构 —— Channel → Router → Queue → Agent → Storage
- 数据库:SQLite 四张表 —— messages、groups、sessions、scheduled_tasks
- 安全:容器隔离 + 权限控制 + 操作审计
- 理念:代码量少、单进程、容器隔离、约定优于配置、Fork 定制
设计只是蓝图,代码才是建筑。下一章,我们就正式开始写代码了。
下一章预告
第19章:搭建项目骨架 —— 我们将创建 MiniClaw 项目,初始化 TypeScript 环境,实现配置管理和数据库模块,完成 Phase 1 的基础设施。你将写下 MiniClaw 的第一行代码。
准备好了吗?让我们开始吧。