本文介绍如何用 claude -p(非交互调用模式)+ Node.js 搭建一套多 Agent 自动化系统,支持会话持久化、定时任务、实时进度推送。
整体架构
核心思路:claude -p 负责 LLM 调用,Node.js 负责消息路由、会话管理和任务调度,每个 Agent 一个独立进程,通过环境变量区分身份。
目录结构:
~/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:
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:
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:
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 有生命周期,长时间不活跃后会失效。检测到过期时自动开新会话:
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 事件流输出,可以实时解析并展示进度:
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 队列串行处理:
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:
---
name: 每日报告
schedule: "0 9 * * 1-5"
timezone: Asia/Shanghai
timeout: 300
---
生成今日工作摘要,整理待办事项。
调度器每分钟检查,按任务自己的时区计算触发时间:
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);
启动脚本
#!/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 模式:
# ./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 侧加全局异常捕获,防止未处理异常导致进程退出:
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 访问权限。每次重启系统后需要重新运行一次启动脚本。