NanoClaw 架构分析

消息队列、Agent Loop 与并发处理机制的完整源码级分析

基于 /workspace/project/src/ 源码 · 2026-03-18

1. 全局流程概览

从用户发送一条消息到收到回复,经过以下链路:

Channel (飞书/WhatsApp/Telegram) onMessage 回调 SQLite Database (messages 表) startMessageLoop 每 2s 轮询 GroupQueue.enqueueMessageCheck() 检查并发槽位 ├─ 有空闲槽 → runForGroup() → 启动 Docker 容器 └─ 无空闲槽 → waitingGroups[] 排队等待 Docker Container (Agent 运行) stdout 标记输出 Channel.sendMessage() → 用户看到回复

2. 消息接入与存储

源码位置:src/index.ts 509-538 行

流程

  1. Channel 调用 onMessage 回调,传入 NewMessage
  2. Sender 白名单过滤:检查发送者是否在允许列表中。Drop 模式下直接丢弃未授权消息(不入库)
  3. storeMessage() 写入 SQLite messages
  4. storeChatMetadata() 更新聊天元数据(群名、渠道、时间戳,不存消息内容

数据库 Schema

messages (
  id, chat_jid, sender, sender_name, content,
  timestamp, is_from_me, is_bot_message, attachments
)

chats (
  jid, name, last_message_time, channel, is_group
)
💡 关键点

消息入库和处理是解耦的。入库是同步的(收到就存),处理是异步轮询的(每 2s 查一批)。这意味着消息不会丢失,即使处理端临时不可用。

3. 消息轮询主循环

源码位置:src/index.ts 362-461 行

轮询间隔:POLL_INTERVAL = 2000ms

双游标设计

这是整个系统最精妙的设计之一——用两个独立的时间戳游标来保证消息不丢不重:

游标 含义 推进时机
lastTimestamp 消息循环"已看到"的最新消息时间 拉取到新消息后立即推进(处理前)
lastAgentTimestamp[chatJid] Agent "已处理"的最新消息时间(按群隔离) Agent 成功处理后推进

主循环伪代码

while (true) {
  // 1. 拉取新消息(timestamp > lastTimestamp)
  const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp);

  if (messages.length > 0) {
    // 2. 立即推进"已看到"游标(不等处理)
    lastTimestamp = newTimestamp;
    saveState();

    // 3. 按群分组
    const messagesByGroup = groupBy(messages, 'chatJid');

    for (const [chatJid, groupMessages] of messagesByGroup) {
      // 4. 检查触发词(非主群需要 @机器人名)
      if (needsTrigger && !hasTrigger) continue;

      // 5. 取出该群所有待处理消息(从 lastAgentTimestamp 开始)
      const allPending = getMessagesSince(chatJid, lastAgentTimestamp[chatJid]);

      // 6. 尝试管道到活跃容器
      if (queue.sendMessage(chatJid, formatted)) {
        // ✅ 成功:消息通过 stdin 管道送入正在运行的容器
        lastAgentTimestamp[chatJid] = lastTimestamp;
      } else {
        // ❌ 无活跃容器:排队等待启动
        queue.enqueueMessageCheck(chatJid);
      }
    }
  }

  await sleep(2000);
}
⚠️ 触发词逻辑

非主群(如私聊、其他群)需要 @机器人名 才触发。但只要一批消息中任意一条包含触发词,该批次所有积压消息都会被送入处理。

4. GroupQueue — 并发与排队

源码位置:src/group-queue.ts

GroupQueue 是整个系统的调度核心,负责:同一群内消息串行化、跨群容器并发控制。

每个群的状态机

interface GroupState {
  active: boolean;           // 容器是否在运行
  idleWaiting: boolean;      // 是否在等待后续输入(空闲)
  isTaskContainer: boolean;  // 正在跑定时任务还是消息
  runningTaskId: string;     // 当前任务 ID
  pendingMessages: boolean;  // 有消息在等
  pendingTasks: QueuedTask[];// 排队中的定时任务
  process: ChildProcess;     // 进程句柄
  containerName: string;     // Docker 容器名
  retryCount: number;        // 失败重试计数
}

核心调度逻辑

enqueueMessageCheck(groupJid)

if (state.active) {
  state.pendingMessages = true;   // 已有容器跑着,标记等待
  return;
}
if (activeCount >= MAX_CONCURRENT_CONTAINERS) {
  state.pendingMessages = true;
  this.waitingGroups.push(groupJid); // 无空闲槽,排队
  return;
}
this.runForGroup(groupJid, 'messages'); // 有槽位,立即启动

sendMessage(groupJid, text) — 管道消息到活跃容器

// 写入 IPC 文件(原子写入:临时文件 + rename)
// 路径:/data/ipc/{groupFolder}/input/{timestamp}-{random}.json
// 仅在容器 active 且非 task 模式时有效

drainGroup() — 容器退出后的排水逻辑

容器结束后:
  1. 优先处理排队的定时任务(tasks > messages)
  2. 再处理排队的消息
  3. 如果该群没有待处理项:
     → 调用 drainWaiting() 从全局等待队列拉下一个群
✅ 关键:同群消息严格串行

同一个群/私聊,同一时间只有一个容器在跑。新消息要么通过 stdin 管道送入当前容器,要么等当前容器结束后再启动新容器处理。不会出现同一个群两个容器同时跑的情况。

5. 多消息并发场景详解

场景:消息 A 正在处理中,用户又发了消息 B 和 C
  1. 消息 B、C 通过 Channel 回调写入 SQLite(毫秒级,不受容器影响)
  2. 消息循环(每 2s)检测到 B、C 是新消息,推进 lastTimestamp
  3. 尝试 queue.sendMessage(chatJid, formatted)
  4. 容器 active 且在处理消息 → 写入 IPC 文件,管道送入容器的 stdin → Agent 在同一个 session 内看到新消息
  5. Agent 在当前对话上下文中处理 B、C,无需重启容器
场景:容器正在跑定时任务,用户发了新消息
  1. queue.sendMessage() 检测到 isTaskContainer = true → 返回 false
  2. queue.enqueueMessageCheck()pendingMessages = true
  3. 定时任务完成 → drainGroup() 先检查 pendingTasks
  4. 无更多任务 → 检查 pendingMessages = true → 启动新容器处理消息
场景:5 个群同时有消息,第 6 个群来了新消息
  1. 前 5 个群各占一个容器槽位
  2. 第 6 个群 → activeCount >= MAX_CONCURRENT_CONTAINERS
  3. 加入 waitingGroups 队列
  4. 任一容器结束 → drainWaiting() → 弹出第 6 个群 → 启动容器
💡 管道(Pipe)机制的意义

如果容器正在处理消息 A,新消息 B 不需要等容器重启——直接通过 IPC 文件管道送入。这意味着 Agent 能在同一个 session 上下文中看到所有消息,避免了重启容器的开销和上下文丢失。

6. Session 生命周期

源码位置:src/index.ts 281-360 行, src/container-runner.ts 117-192 行

Session 复用

// 从 DB 加载 session ID
const sessionId = sessions[group.folder];

// 首次运行:sessionId 为空 → 容器创建新 session
// 后续运行:复用同一个 sessionId → 继承对话历史

// 容器返回新 sessionId → 持久化到 DB
if (output.newSessionId) {
  sessions[group.folder] = output.newSessionId;
  setSession(group.folder, output.newSessionId);
}

每群独立隔离

容器空闲超时

容器处理完消息后不会立即退出,而是进入空闲等待状态,等待后续消息管道进来:

7. Context Window 与溢出

容器输出限制

CONTAINER_MAX_OUTPUT_SIZE = 10MB

// 超出时(container-runner.ts 381-393):
if (chunk.length > remaining) {
  stdout += chunk.slice(0, remaining);
  stdoutTruncated = true;
  logger.warn('Container stdout truncated due to size limit');
}
// 容器继续运行,但输出被截断

消息历史限制

Claude 自身的 Context Window

NanoClaw 不直接管理 Claude 的 context window。当对话超长时:

⚠️ 这就是"被打断"的根因

当一个 session 累积了大量操作(安装 skill、调 API、生成图片等),Claude 的 context window 接近上限时,SDK 会触发 compaction。在压缩过程中,尚未发送的回复可能被吞掉。NanoClaw 的消息队列本身是正常的——消息确实被管道进了容器——但 Agent 内部的 context 溢出导致了丢失。

8. 定时任务与消息队列的交互

源码位置:src/task-scheduler.ts

轮询间隔:SCHEDULER_POLL_INTERVAL = 60000ms(每分钟检查一次)

调度流程

const loop = async () => {
  const dueTasks = getDueTasks(); // WHERE next_run <= NOW AND status = 'active'

  for (const task of dueTasks) {
    // 通过 GroupQueue 排队(和消息共用同一套队列)
    deps.queue.enqueueTask(task.chat_jid, task.id, () => runTask(task, deps));
  }

  setTimeout(loop, 60000);
};

任务 vs 消息的优先级

维度消息定时任务
排水优先级高(先排水任务再排水消息)
容器关闭延迟30 分钟空闲超时10 秒(快速释放槽位)
管道送入可以管道到活跃容器不可以(等容器结束后独立启动)
Chat 隔离按 chatJid按 chatJid(任务绑定到创建时的群)
💡 任务会"抢占"消息

如果容器在空闲等待中,有定时任务到期,会通过 closeStdin() 强制关闭空闲容器,然后启动任务容器。任务结束后,如果还有待处理消息,再启动消息容器。

9. IPC 进程间通信

源码位置:src/ipc.ts

轮询间隔:IPC_POLL_INTERVAL = 1000ms

容器内的 Agent 通过 IPC 文件与宿主通信。Agent 写文件到 /workspace/ipc/(容器内路径),宿主每秒扫描处理。

支持的 IPC 消息类型

类型说明权限
send_message发送消息到指定聊天主群可发任意群,非主群仅限自己
send_file发送文件/workspace/group/ 下的文件
schedule_task创建定时任务验证 cron/interval 表达式
pause_task暂停任务
resume_task恢复任务
cancel_task删除任务
update_task更新任务配置
register_group注册新群仅主群
refresh_groups刷新群列表仅主群

原子写入:所有 IPC 文件使用 临时文件 + rename 模式,防止读取到写了一半的文件。

10. 崩溃恢复机制

源码位置:src/index.ts 467-479 行

function recoverPendingMessages(): void {
  for (const [chatJid, group] of Object.entries(registeredGroups)) {
    const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
    const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
    if (pending.length > 0) {
      logger.info({ group: group.name, pendingCount: pending.length },
        'Recovery: found unprocessed messages');
      queue.enqueueMessageCheck(chatJid);
    }
  }
}

启动时调用。利用双游标的间隙(lastTimestamp 已推进但 lastAgentTimestamp 未推进的消息)来恢复未处理的消息。

Agent 失败的回滚策略

情况处理方式原因
Agent 成功推进 lastAgentTimestamp消息已处理
Agent 失败,未产出任何输出回滚 lastAgentTimestamp用户没收到任何回复,重试安全
Agent 失败,但已产出部分输出不回滚用户已收到部分回复,重试会导致重复

11. 容器隔离与安全

源码位置:src/container-runner.ts 60-241 行

主群 vs 非主群的挂载差异

挂载内容主群非主群/私聊
群文件夹✅ /workspace/group✅ 仅自己的群文件夹
Session✅ /home/node/.claude✅ 独立目录
IPC✅ /workspace/ipc✅ 仅自己的 IPC 目录
项目源码✅ 只读
.env 文件❌ 用 /dev/null 遮蔽
其他群可见性✅ available_groups.json

12. 核心参数汇总

参数说明
消息轮询间隔2 秒startMessageLoop()
调度器轮询间隔60 秒startSchedulerLoop()
IPC 轮询间隔1 秒startIpcWatcher()
最大并发容器5(可配置)MAX_CONCURRENT_CONTAINERS
最大重试次数5 次指数退避: 5s, 10s, 20s, 40s, 80s
容器空闲超时30 分钟IDLE_TIMEOUT = 1800000ms
容器输出上限10 MBCONTAINER_MAX_OUTPUT_SIZE
消息历史上限200 条/查询DB 查询 limit
任务关闭延迟10 秒TASK_CLOSE_DELAY_MS
触发词要求仅非主群需 @机器人名

13. 关键设计洞察

🔒 双游标保证消息不丢

lastTimestamp(已看到)和 lastAgentTimestamp(已处理)的分离设计,确保即使 NanoClaw 在两个游标推进之间崩溃,重启后也能通过 recoverPendingMessages() 恢复未处理的消息。

🔄 管道复用避免重启

新消息通过 IPC 文件管道送入活跃容器,Agent 在同一个 session 中处理,避免容器重启的开销(约 3-10 秒)和上下文断裂。

⚡ 任务优先于消息

定时任务在排水时优先级高于消息,避免时间敏感的自动化任务被积压的聊天消息饿死。

📦 原子 IPC

所有 IPC 文件使用「写临时文件 → rename」模式,彻底杜绝读取到半写文件的问题。

🏗️ 群级隔离

Session、IPC、文件系统挂载全部按群隔离。主群有额外的只读权限访问项目源码和跨群通信能力。

14. 回答:为什么会出现"被打断"

回到最初的问题——在处理"私聊用户定时任务"的问题时,头像请求"打断"了回复。

🔍 分析

NanoClaw 的消息队列不会造成打断。机制如下:

  1. 你发了"私聊用户"的问题 → 消息入库 → 容器启动 → Agent 开始处理
  2. 你发了"做头像"的请求 → 消息入库 → 通过 IPC 管道送入同一个容器
  3. Agent 在同一个 session 内先后收到了这两条消息

问题不在 NanoClaw,而在 Claude Agent 层面

  • 那个 session 已经累积了大量操作(装 skill、调 API、生成多张图片),context window 接近上限
  • Claude SDK 的 context compaction 可能在处理过程中触发,压缩了中间状态
  • 或者更简单地:Agent 在一个 session 内收到多个请求时,优先级判断出了问题,先去做了新请求而没有完成当前回复

结论:NanoClaw 消息队列工作正常,问题出在 Agent 内部的多请求处理逻辑。