AI Agent 教程

第18章:项目设计 —— 我们要做什么?

一句话:设计一个完整的 AI 助手项目,把前面学的知识全用上。

前面十七章,我们从 Agent 是什么聊起,一路学了 query() 函数、Tool 定义、流式输出、结构化输出、多轮对话、MCP 协议、Agent 编排……知识点不少,但一直在"单点训练"。

现在,是时候来一次"全真模拟考"了。

从这一章开始,我们要动手做一个真正的项目 —— MiniClaw(迷你爪),一个属于你自己的 AI 助手。它不是一个 demo,不是一个 toy project,而是一个你可以真正用起来的东西:通过 Telegram 给它发消息,它会用 Claude 帮你干活。

但在写代码之前,我们得先想清楚:要做什么?怎么做?为什么这么做?

这一章,就是我们的"图纸"。


本章目标

读完这一章,你将能够:

前置知识


18.1 项目目标:MiniClaw —— 一个小而美的 AI 助手

灵感来源

在开始之前,我们先聊聊灵感。

你可能听说过 NanoClawOpenClaw 这两个项目:

MiniClaw 的定位介于两者之间 —— 更准确地说,它是 NanoClaw 的"教学版"。

MiniClaw 是什么

MiniClaw(迷你爪),是我们这个教程的毕业项目。它是一个:

核心理念

NanoClaw 的作者提出了一个很棒的理念,我们完全认同:

"AI 越来越强大,承载它的软件应该越来越简单。"

想想看:如果 Claude 足够聪明,能自己决定用什么工具、怎么执行任务,那我们为什么还需要写那么复杂的代码来"管理"它呢?我们需要的只是:

  1. 一个入口 —— 接收用户的消息
  2. 一个引擎 —— 让 Claude 去处理
  3. 一个出口 —— 把结果发回去

就这么简单。剩下的复杂度,交给 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 支持两种消息渠道:

为什么不支持微信?因为微信没有官方的 Bot API,所有的微信机器人都是基于逆向工程,随时可能被封号。我们不做不靠谱的事。

为什么不支持 WhatsApp?NanoClaw 已经做得很好了。我们选 Telegram 是为了差异化,也是因为在国内开发者群体中 Telegram 更常用。

智能对话

这是最核心的功能。用户发一条消息,MiniClaw 用 Claude Agent SDK 来处理。

不是简单的"你问我答"式聊天,而是真正的 Agent 模式 —— Claude 会思考、会调用工具、会多步推理。比如你说:

"帮我看看最近的 GitHub trending 上有什么好项目,挑 3 个最有意思的总结一下"

Claude 不会跟你说"你可以去 GitHub 看看",它会:

  1. 用搜索工具去查 GitHub trending
  2. 逐个分析项目
  3. 挑出 3 个最有意思的
  4. 写一份总结发给你

工具调用

MiniClaw 会给 Claude 配备以下工具:

这些工具都在 Docker 容器里运行,所以即使 Claude "发疯"执行了 rm -rf /,也不会影响你的主机。

容器隔离

这是安全的基础。每次 Agent 执行任务时,都会在一个独立的 Docker 容器里运行。容器里有什么:

容器里没有什么:

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,原因有三:

  1. Agent SDK 是 TypeScript 写的:用 TypeScript 调用 TypeScript 库,天然融合,不用额外的类型转换
  2. NanoClaw 也是 TypeScript:我们可以参考它的实现
  3. 类型安全:当你的项目超过 200 行,TypeScript 的类型系统会帮你避免很多低级错误

为什么用 SQLite 而不是 PostgreSQL / MySQL?

MiniClaw 是个人助手,不是企业级应用。你的数据量不会太大(一天几百条消息撑死了),用 SQLite 完全够了。而且:

NanoClaw 也是用的 SQLite + better-sqlite3,这个方案经过了验证。

为什么用 grammy 而不是直接调 Telegram API?

grammy 是 Telegram Bot 的 TypeScript SDK,它封装了所有底层细节:

直接调 Telegram HTTP API 也可以,但你会花大量时间在"轮子"上,而不是在业务逻辑上。

为什么用 Docker?

容器隔离是 MiniClaw 安全模型的核心。Docker 的好处:

在生产环境中,你可能会考虑 Apple Container(macOS 原生,更轻量)或者 gVisor(更安全的沙箱),但 Docker 是最通用的选择。

为什么用 pino 做日志?

pino 的特点:

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 层负责两件事:

  1. 收消息:从 Telegram / CLI 接收用户消息
  2. 发消息:把 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 层解决的是 并发问题

想象一下这个场景:

如果没有 Queue 层,三条消息同时开始处理,就会出问题:

  1. 资源争抢:同时启动 3 个 Docker 容器,可能超出系统资源
  2. 上下文错乱:A 群的第二条消息应该在第一条处理完之后再处理,否则 Agent 不知道"中文的"是指什么
  3. 竞态条件:两个 Agent 同时操作同一个群的 CLAUDE.md,可能导致数据丢失

Queue 层的规则很简单:

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 层负责:

  1. 启动 Docker 容器
  2. 在容器里运行 Claude Agent SDK
  3. 管理会话(session)
  4. 收集执行结果

让我们拆开看看:

容器管理(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 }));

看到了吗?这就是前面十七章学的东西全部汇聚的地方:

自定义工具(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 层分两部分:

  1. SQLite 数据库:存储结构化数据(消息、群组、会话、定时任务)
  2. 文件系统:存储非结构化数据(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_meis_bot_response 两个字段?

为什么 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 表达式是什么?

一种表示"什么时候执行"的标准格式。比如:

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 —— 总指挥

这是整个程序的入口。它的工作是:

  1. 读取配置
  2. 初始化数据库
  3. 启动 Channel(连接 Telegram)
  4. 启动 Scheduler(定时任务调度)
  5. 开始消息循环

大约 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 操作。包括:

大约 120 行代码。

src/channels/telegram.ts —— Telegram 集成

用 grammy 库实现的 Telegram Bot。核心逻辑:

  1. 创建 Bot 实例
  2. 监听所有消息
  3. 把 Telegram 消息转成我们的统一格式
  4. 转发给 Router 处理
  5. 把 Agent 的回复发回 Telegram

大约 100 行代码。

src/channels/cli.ts —— CLI 界面

开发调试用的命令行界面。用 Node.js 的 readline 模块实现交互式输入。

大约 50 行代码。

src/agent/runner.ts —— Agent 运行器

这是整个项目最核心的文件。

它负责:

  1. 构建 system prompt(包括 CLAUDE.md 记忆)
  2. 格式化历史消息
  3. 调用 Claude Agent SDK 的 query()stream()
  4. 处理工具调用
  5. 收集执行结果

大约 150 行代码。前面十七章学的所有技术点,都在这个文件里汇聚。

src/agent/container.ts —— 容器管理

管理 Docker 容器的生命周期:

大约 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 检查:

第 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 做了以下准备工作:

  1. 读取 CLAUDE.md
/workspace/group/CLAUDE.md 内容:
---
这个群是 AI 学习小组,成员主要讨论 AI 相关技术。
- 用户偏好中文回复
- 回复风格:简洁、有条理
---
  1. 获取最近消息(从 SQLite,通过 IPC 传入容器):
[10:28] 小明: 今天北京好冷啊
[10:29] 小红: 是啊,听说要降温
[10:30] 小明: @MiniClaw 帮我查一下北京今天的天气
  1. 构建 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 的响应速度)。用户的体验就是:

  1. 在 Telegram 里发消息
  2. 等几秒
  3. 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)内的消息串行处理,不同通道并行处理。这个设计解决了很多实际问题:

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 WhatsApp 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 vs OpenClaw

完全不同的量级。OpenClaw 是一个"平台",MiniClaw 是一个"工具"。打个比方:OpenClaw 是一辆大巴车,能坐 50 个人;MiniClaw 是一辆自行车,就你一个人骑。你不需要大巴车来上班。

MiniClaw vs ZeroClaw

ZeroClaw 用 Rust 写的,追求极致性能。它能在 $10 的硬件上运行,内存占用不到 5MB。但代价是:Rust 的学习曲线陡峭,代码不如 TypeScript 易读。MiniClaw 选择了"好理解",ZeroClaw 选择了"高性能",各有各的权衡取舍。

我们的独特价值

MiniClaw 的独特价值不在于功能多强大、性能多高,而在于:

  1. 可学习性:每一行代码都有对应的教程章节在解释"为什么这么写"
  2. 可理解性:800 行代码,一个下午读完
  3. 可修改性:想改什么就改什么,不用担心"改坏了"
  4. 知识的完整闭环:从第1章到第18章,从理论到实践,从零到一

18.10 安全设计:不能大意的事

安全不是"加分项",是"基本线"。一个能执行命令、读写文件的 AI 助手,如果安全没做好,后果可能很严重。

三层安全模型

┌───────────────────────────────────────────┐
│  第一层:容器隔离(OS 级别)                  │
│  Agent 物理上碰不到主机文件系统                │
├───────────────────────────────────────────┤
│  第二层:权限控制(应用级别)                  │
│  只有授权的群才能触发 Agent                   │
├───────────────────────────────────────────┤
│  第三层:操作审计(事后追溯)                  │
│  所有操作都有日志记录                         │
└───────────────────────────────────────────┘

第一层:容器隔离

// 容器配置示例
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,  // 只传必要的环境变量
  }
};

第二层:权限控制

第三层:操作审计

不做什么

安全设计也包括"决定不做什么":


18.11 开发计划:怎么一步步来?

好了,设计完毕。但我们不会一上来就写 820 行代码。我们分四步走:

Phase 1:最小可用版本(第19-20章)

目标:能在 CLI 里和 Agent 对话。

实现:

代码量:~200 行

完成后你就有一个能用的 CLI AI 助手了。

Phase 2:接入 Telegram(第21章)

目标:通过 Telegram 和 Agent 对话。

新增:

代码量:累计 ~400 行

完成后你就有一个真正的 Telegram AI 助手了。

Phase 3:容器隔离 + 队列(第22章)

目标:安全可靠。

新增:

代码量:累计 ~600 行

完成后你的 AI 助手就有了安全保障。

Phase 4:高级功能(第23章)

目标:锦上添花。

新增:

代码量:累计 ~800 行

完成后就是完整的 MiniClaw 了。

为什么这么分?

每个 Phase 结束后,你都有一个可以运行的东西。不是"代码写了一半跑不起来",而是"每一步都能看到成果"。

这也是软件开发的一个重要原则:增量交付。不要试图一次做完所有事情,而是一步一步来,每一步都确保能用。


本章小结

这一章我们完成了 MiniClaw 的完整设计。来回顾一下关键点:

  1. 定位:MiniClaw 是一个小而美的个人 AI 助手,目标 800 行代码,完全可审计
  2. 功能:Telegram 接入 + 智能对话 + 工具调用 + 容器隔离 + 持久记忆 + 定时任务
  3. 架构:五层分层架构 —— Channel → Router → Queue → Agent → Storage
  4. 数据库:SQLite 四张表 —— messages、groups、sessions、scheduled_tasks
  5. 安全:容器隔离 + 权限控制 + 操作审计
  6. 理念:代码量少、单进程、容器隔离、约定优于配置、Fork 定制

设计只是蓝图,代码才是建筑。下一章,我们就正式开始写代码了。


下一章预告

第19章:搭建项目骨架 —— 我们将创建 MiniClaw 项目,初始化 TypeScript 环境,实现配置管理和数据库模块,完成 Phase 1 的基础设施。你将写下 MiniClaw 的第一行代码。

准备好了吗?让我们开始吧。

← 上一章17. 高级特性