用 Claude Code CLI 构建类OpenClaw 多 Agent 自动化系统

本文介绍如何用 claude -p(非交互调用模式)+ Node.js 搭建一套多 Agent 自动化系统,支持会话持久化、定时任务、实时进度推送。

整体架构

核心思路:claude -p 负责 LLM 调用,Node.js 负责消息路由、会话管理和任务调度,每个 Agent 一个独立进程,通过环境变量区分身份。

目录结构:

text 复制代码
~/ai/agents/
├── bot.js          # 通用 Bot 服务(所有 Agent 共用)
├── start.sh        # 一键启动脚本
├── logs/
├── main/           # Agent A
│   ├── SOUL.md     # 角色定义
│   ├── TOOLS.md    # 工具配置
│   ├── crons/      # 定时任务
│   └── sessions/   # 会话持久化
├── blog/           # Agent B
└── ...

加载 System Prompt

启动时扫描 Agent 目录,按规则合并文件为 system prompt:

javascript 复制代码
const PROMPT_FILES = ["SOUL.md", "USER.md", "TOOLS.md", "AGENTS.md"];
const skip = new Set([...PROMPT_FILES, "MEMORY.md", "HEARTBEAT.md"]);

var parts = [];
// 优先加载白名单文件
for (var pf of PROMPT_FILES) {
  if (fs.existsSync(path.join(agentDir, pf))) {
    parts.push(fs.readFileSync(path.join(agentDir, pf), "utf-8"));
  }
}
// 再加载其他全大写命名文件(配置),跳过小写开头文件(数据)
var extras = fs.readdirSync(agentDir).filter(f =>
  f.endsWith(".md") && !skip.has(f) && /^[A-Z][A-Z0-9_-]+\.md$/.test(f)
);
for (var ef of extras) {
  parts.push(fs.readFileSync(path.join(agentDir, ef), "utf-8"));
}
var SYSTEM_PROMPT = parts.join("\n\n---\n\n");

注意:不要加载目录下所有 .md 文件,记忆文件、日志文件会撑爆 system prompt(超过 100KB 后 Claude 会拒绝)。全大写命名作为配置文件,小写开头作为数据文件,用命名规则做区分。

会话持久化

claude -p 支持 --resume <session-id> 续接会话,关键是从流式输出中拿到服务端分配的 session_id

javascript 复制代码
async function handleChat(chatId, text) {
  var session = getOrCreateSession(chatId);
  var isFirstTurn = session.turns === 0 || !session.id;

  // 始终用 stream-json,无论第几轮
  var args = ["-p", text, "--model", CLAUDE_MODEL,
    "--dangerously-skip-permissions",
    "--output-format", "stream-json", "--verbose"];

  if (!isFirstTurn) {
    args.push("--resume", session.id);  // 续接已有 session
  } else {
    if (SYSTEM_PROMPT) args.push("--system-prompt", SYSTEM_PROMPT);
  }

  var streamResult = await runClaudeStream(args);

  if (streamResult.sessionId) session.id = streamResult.sessionId;
  session.turns++;
  saveSession(chatId);
}

从事件流中提取 session_id

javascript 复制代码
for (var line of lines) {
  var evt = JSON.parse(line);
  if (evt.session_id && !result.sessionId) {
    result.sessionId = evt.session_id;  // 服务端分配,不能自己生成
  }
  if (evt.type === "result") {
    result.text = evt.result;
  }
}

两个注意点:

  • session_id 必须是服务端返回的,本地生成的 UUID 传给 --resume 会报 "No conversation found"
  • --continue(续接最近一次)不适合多 Agent 并发,容易串到其他 session

Session 过期自动重置

服务端 session 有生命周期,长时间不活跃后会失效。检测到过期时自动开新会话:

javascript 复制代码
var streamResult = await runClaudeStream(args);

if (streamResult.error && streamResult.error.includes("No conversation found") && !isFirstTurn) {
  resetSession(chatId);
  // 重建参数,开新 session
  args = ["-p", text, "--model", CLAUDE_MODEL,
    "--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose"];
  if (SYSTEM_PROMPT) args.push("--system-prompt", SYSTEM_PROMPT);
  streamResult = await runClaudeStream(args);
}

实时进度推送

--output-format stream-json --verbose 会把每一步的工具调用以 JSON 事件流输出,可以实时解析并展示进度:

javascript 复制代码
proc.stdout.on("data", function(chunk) {
  for (var line of lines) {
    var evt = JSON.parse(line);
    if (evt.type === "assistant" && evt.message?.content) {
      var toolUse = evt.message.content.find(c => c.type === "tool_use");
      if (toolUse) {
        var action = describeToolUse(toolUse);  // "读取文件 bot.js"
        updateStatus(action);  // 实时更新展示
      }
    }
  }
});

展示效果:

复制代码
✓ 读取文件 bot.js
✓ 搜索内容 handleChat
▸ 编辑文件 bot.js
⏱ 23s

消息并发控制

同一个 session 不支持并发调用,用 per-chatId 的 Promise 队列串行处理:

javascript 复制代码
const chatQueues = new Map();

function enqueueChat(chatId, fn) {
  if (!chatQueues.has(chatId)) chatQueues.set(chatId, Promise.resolve());
  var p = chatQueues.get(chatId).then(fn);
  chatQueues.set(chatId, p);
  return p;
}

// 收到消息时
enqueueChat(chatId, () => handleChat(chatId, text));

定时任务调度

每个 Cron 任务是一个 Markdown 文件,YAML frontmatter 定义调度信息,正文是 prompt:

markdown 复制代码
---
name: 每日报告
schedule: "0 9 * * 1-5"
timezone: Asia/Shanghai
timeout: 300
---

生成今日工作摘要,整理待办事项。

调度器每分钟检查,按任务自己的时区计算触发时间:

javascript 复制代码
function getNowInTimezone(tz) {
  var str = new Date().toLocaleString("en-US", { timeZone: tz });
  return new Date(str);
}

function fieldMatch(field, val) {
  if (field === "*") return true;
  if (field.startsWith("*/")) return val % parseInt(field.slice(2)) === 0;
  if (field.includes("-")) {
    const [a, b] = field.split("-").map(Number);
    return val >= a && val <= b;
  }
  return parseInt(field) === val;
}

function cronMatch(expr, now) {
  const [min, hour, day, mon, dow] = expr.split(/\s+/);
  return fieldMatch(min,  now.getMinutes())
      && fieldMatch(hour, now.getHours())
      && fieldMatch(day,  now.getDate())
      && fieldMatch(mon,  now.getMonth() + 1)
      && fieldMatch(dow,  now.getDay());
}

setInterval(() => {
  for (const task of cronJobs) {
    const now = getNowInTimezone(task.timezone);
    if (cronMatch(task.schedule, now)) runCronJob(task);
  }
}, 60_000);

启动脚本

bash 复制代码
#!/bin/bash
DIR="$(cd "$(dirname "$0")" && pwd)"
source "$HOME/ai/.bot-tokens"  # 读取各 Agent 的 Token(不提交到 git)

start_bot() {
  local name="$1" token="$2" botname="$3" port="$4"
  BOT_TOKEN="$token" BOT_NAME="$botname" HEALTH_PORT="$port" AGENT_NAME="$name" \
    nohup node "$DIR/bot.js" \
    >> "$DIR/logs/bot-${name}.stdout.log" \
    2>> "$DIR/logs/bot-${name}.stderr.log" &
  echo "Started $name (pid $!)"
}

start_bot main "$TOKEN_main" "助手A" 3880
start_bot blog "$TOKEN_blog" "助手B" 3881

敏感信息(Token、密码、服务器地址)单独放在 .bot-tokens.server-config 文件中,加入 .gitignore,不随代码提交。

进程守护

nohup 启动后进程异常退出不会自动重启,加一个 watchdog 模式:

bash 复制代码
# ./start.sh watch 模式
watch_bots() {
  while true; do
    for name in main blog tools; do
      port=$(get_port $name)
      if ! curl -sf "http://localhost:$port/health" > /dev/null 2>&1; then
        echo "[watchdog] $name 无响应,重启..."
        start_bot $name ...
      fi
    done
    sleep 30
  done
}

Node.js 侧加全局异常捕获,防止未处理异常导致进程退出:

javascript 复制代码
process.on("uncaughtException", (err) => {
  console.error("[FATAL] uncaughtException:", err.message);
  // 记录日志但不退出
});
process.on("unhandledRejection", (reason) => {
  console.error("[FATAL] unhandledRejection:", reason);
});

macOS Keychain 问题

Claude Code 的认证 token 存在 macOS Keychain 中。通过 launchd 启动的后台进程无法访问 Keychain,claude 命令会认证失败。

解决方式:从当前登录的交互 Shell 直接运行 bash start.sh,进程继承 Shell 的 Keychain 访问权限。每次重启系统后需要重新运行一次启动脚本。


原文链接chenguangliang.com/posts/blog1...

相关推荐
用户5191495848453 小时前
InstaWP Connect 漏洞利用工具 (CVE-2024-2667)
人工智能·aigc
2601_954434553 小时前
2026年电钢琴品牌专业深度测评:排名前五权威榜单发布
大数据·人工智能·python
V搜xhliang02463 小时前
AI大模型辅助临床医学科研应用、论文写作、数据分析与AI绘图学习
人工智能·学习·数据分析
Legend NO243 小时前
从模糊需求到高保真原型,用 TRAE 实现全流程自动化
人工智能
Andrew-Feng3 小时前
AI时代的规范驱动开发——OpenSpec
人工智能·驱动开发
森诺Alyson3 小时前
前沿技术借鉴研讨-2026.4.9(视觉语言模型)
论文阅读·人工智能·经验分享·语言模型·自然语言处理·论文笔记·论文讨论
TG_yunshuguoji3 小时前
腾讯云代理商:利用腾讯云智能体开发平台实现接入 OpenClaw
人工智能·云计算·腾讯云·智能体·openclaw
财经资讯数据_灵砚智能3 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(夜间-次晨)2026年4月8日
大数据·人工智能·信息可视化·自然语言处理·ai编程
天青色等烟雨..3 小时前
基于 Claude Code 与 Codex 双 AI 协同的论文写作与质量校准研究 —— 从数据分析、初稿撰写到交叉审稿全流程
人工智能