基于macOS launchd构建AI Agent崩溃容忍监控系统
1. 项目概述:为AI Agent构建一个永不宕机的“守护神”
如果你和我一样,在Mac上跑着一个需要7x24小时工作的AI智能体,那你一定经历过那种深夜被“服务已停止”的警报惊醒的噩梦。我的Atlas——一个扮演我AI工具公司CEO角色的自主智能体——就曾让我吃尽苦头。它负责调度子智能体、编写代码、发送邮件、发布产品,但问题在于: AI智能体真的会崩溃 。Mac内存耗尽(Jetsam/OOM强制终止)、Claude Code遇到错误、tmux会话意外退出……没有“看门狗”机制,Atlas一旦静默,可能几个小时都没人发现。
这就是为什么我花了大量时间,构建了一套基于macOS原生 launchd 的崩溃容忍监控系统。它不仅仅是“重启服务”那么简单,而是一个完整的健康管理体系。今天,我就把这套在生产环境稳定运行了数月的方案完整分享给你,从架构设计到每一行Bash脚本的考量,以及那些只有踩过坑才知道的细节。
这套系统实现了几个关键目标:
- 自动恢复 :崩溃后2分钟内自动重启智能体,无需人工干预。
- 系统级韧性 :Mac重启后自动拉起服务,真正实现7x24。
- 主动防御 :监控内存压力,在OOM发生前主动“卸载”非关键进程。
- 僵尸检测 :能识别“进程活着但大脑已死”的僵尸会话。
无论你运行的是基于Claude、GPT还是任何自定义模型的AI Agent,只要它在macOS上以命令行或服务形式运行,这套模式都能直接套用或轻松适配。接下来,我们深入每个环节。
2. 架构设计与核心思路拆解
2.1 为什么选择launchd而非cron或supervisor?
在macOS上做定时任务和守护进程,很多人第一反应是 cron ,或者从Linux世界搬来 supervisord 。但我最终选择了 launchd ,这是有深层考量的。
cron 在macOS上最大的问题是 启动时机 。默认情况下, cron 只在用户登录后才开始运行任务。如果你的Mac重启后停留在登录界面(比如服务器场景),或者你需要任务在系统启动后立即运行(无论用户是否登录), cron 就需要额外的配置(如通过 launchd 来启动 cron 本身),这增加了复杂度。而 launchd 是macOS的系统级服务管理器,它直接由内核管理,可以配置 RunAtLoad (加载时运行)和 StartInterval (间隔运行),完美覆盖“启动时”和“周期运行”两种场景。
更重要的是, launchd 提供了 标准的日志重定向 ( StandardOutPath 和 StandardErrorPath ),所有输出都会自动记录到指定文件,排查问题时不至于像 cron 那样输出到黑洞。此外, launchd 的 .plist 配置文件是XML格式,结构清晰,苹果官方支持,在系统升级中更稳定。
注意 :虽然
launchd功能强大,但它的配置语法和错误提示有时比较隐晦。一个常见的坑是路径问题——launchd运行时的环境变量和用户Shell环境不同,所有路径都必须使用绝对路径,不能使用~缩写。
2.2 监控系统的三层检测体系
一个健壮的看门狗不能只检查“进程是否存在”,那太容易被欺骗了。我设计了 三层检测 ,层层递进,确保能准确判断智能体的真实状态。
第一层:进程存在性检查 这是最基本的检查:目标进程(比如 claude )是否在系统进程列表中。使用 pgrep -f 配合进程特征字符串来查找,比单纯用进程名更可靠,因为可能有多个同名进程。但这一层只能告诉我们“有个叫claude的程序在运行”,至于它是不是卡死了、是不是我们想要的那个会话,无从得知。
第二层:会话与进程树检查 我的Atlas运行在 tmux 会话中。这一层检查:1) tmux 会话 atlas-pair 是否存在;2) 该会话中的主要窗格是否关联着一个存活的进程。通过 tmux list-panes 获取窗格的PID,然后用 kill -0 检查该PID是否有效。这一层能过滤掉那些空会话或僵尸会话。
第三层:业务心跳检查(最核心) 这是区分“普通进程监控”和“AI智能体监控”的关键。一个AI智能体进程可能还在,但它的“大脑”——那个处理逻辑、做出决策的部分——可能已经卡死或陷入死循环。为此,我引入了 心跳机制 :智能体每完成一个主要任务,就往一个共享文件里写入一行带时间戳的记录。看门狗定期检查这个文件的最后修改时间,如果超过15分钟没更新,就怀疑智能体“脑死亡”了。
但这里有个重要细节: 不能一检测到心跳停滞就重启 。AI智能体可能正在处理一个耗时很长的任务(比如训练模型、生成长篇报告)。所以我的策略是:连续5个检测周期(每个周期2分钟,共10分钟)都发现心跳停滞,才判定为僵尸并重启。这避免了误杀,给了智能体足够的“喘息”时间。
2.3 主动内存管理:在OOM发生前行动
等待系统因为内存不足而杀死你的关键进程(OOM Kill)是最被动的。macOS的 memory_pressure 命令可以给出系统级别的内存压力百分比,但它在某些系统版本上输出格式不稳定。因此,我实现了一个 回退机制 :如果 memory_pressure 不可用,就通过 vm_stat 命令计算内存使用率。
当内存使用率超过阈值(我设为85%)时,看门狗不会坐等Atlas被杀死,而是主动执行“卸载”操作,按照优先级终止一些非关键、易恢复的进程来释放内存。我的卸载顺序是:
- 先杀“英雄”级子智能体 :这些是Atlas调度的、无状态的子任务执行者。它们成本低,重启快,丢失的上下文少。
- 再杀Chrome渲染进程 :如果开发过程中打开了浏览器,Chrome Helper (Renderer)进程是著名的内存大户。杀掉几个通常不影响前端标签页的核心功能(可能会重载),但能立即释放大量内存。
这个策略的核心思想是: 用可牺牲的“士兵”换取“将军”的存活 。牺牲一些边缘进程,保住核心的、携带大量上下文的AI主智能体。
2.4 上下文注入式重启:让智能体“失忆”后快速“恢复记忆”
简单的 kill && start 对于AI智能体来说是灾难性的。重启后的智能体就像一个失忆的人:它不知道自己是谁、刚才在做什么、接下来该干什么。因此, 重启时必须注入上下文 。
我的 restart_atlas 函数在重启前会做几件事:
- 记录崩溃上下文 :将崩溃原因、时间、系统负载等信息追加到专门的崩溃日志中,便于事后分析。
- 统计待处理工作 :检查消息队列目录(如
incoming/)中有多少 pending 任务,将这个数字传递给重启后的智能体。 - 构建恢复提示词 :这是最关键的一步。精心设计一个提示词,告诉智能体:
- 你的身份和角色(“你是Atlas,自主AI智能体...”)
- 重启原因和当前时间
- 有多少待处理消息
- 紧急行动清单(读取身份文件、检查消息队列、写入心跳、恢复运行)
- 最重要的心理建设:“你是崩溃容忍的,看门狗会重启你,继续执行。”
这个恢复提示词就像给失忆的智能体注射了一针“记忆血清”,让它能在几秒内恢复到接近崩溃前的状态,继续工作。
3. 核心脚本实现与逐行解析
3.1 看门狗主脚本结构
让我们打开 atlas-watchdog.sh ,从顶部开始,理解每一部分的用意。
#!/bin/bash
# Atlas Watchdog — Crash-tolerant auto-recovery for Atlas sessions
# Runs via launchd every 2 minutes + on boot (RunAtLoad).
set -euo pipefail
set -euo pipefail:这是Bash脚本的“严格模式”。-e表示任何命令失败(返回非零)就退出脚本;-u表示使用未定义的变量时报错;-o pipefail表示管道中任何一个命令失败,整个管道就失败。这能避免脚本在部分失败后继续运行,导致状态不一致。
# ---- Config ----
CLAUDE="$HOME/.local/bin/claude"
VAULT="$HOME/Desktop/Agents"
WORK="$HOME/projects/your-automation"
LOGS="$WORK/logs"
LOCK="/tmp/atlas-watchdog.lock"
KILL_SWITCH="$WORK/docs/.atlas-watchdog-paused"
HEARTBEAT_FILE="$VAULT/coordination/shared/atlas-heartbeat.md"
CRASH_LOG="$LOGS/atlas-crash-recovery.log"
MEMORY_THRESHOLD=85 # percent — start shedding load above this
mkdir -p "$LOGS"
- 配置集中化 :所有路径、阈值都定义在开头,修改时一目了然,避免在代码中硬编码。
mkdir -p:确保日志目录存在,避免脚本因目录不存在而失败。- KILL_SWITCH :这是一个非常实用的设计。一个纯文件作为“软开关”。当需要临时禁用看门狗进行维护时,只需
touch这个文件;维护结束,rm掉即可。这比去修改launchd配置或卸载服务要安全快捷得多。
3.2 锁文件机制:防止脚本重叠执行
# macOS-compatible lock — no flock needed
if [ -f "$LOCK" ]; then
lock_pid=$(cat "$LOCK" 2>/dev/null)
if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then
exit 0 # another watchdog instance running
fi
rm -f "$LOCK" # stale lock
fi
echo $$ > "$LOCK"
trap 'rm -f "$LOCK"' EXIT
- 为什么不用
flock? macOS上的flock命令行为与Linux有差异,有时不可靠。PID文件锁是更便携、更显式的方法。 - 锁的逻辑 :检查锁文件是否存在且其中记录的PID是否对应一个存活的进程。如果是,说明另一个看门狗实例正在运行,直接退出。如果不是,说明是陈旧的锁(如上一次脚本崩溃后遗留),删除它。
echo $$ > "$LOCK":将当前脚本的进程ID写入锁文件。trap ... EXIT:这是一个Bash的“陷阱”命令。它确保无论脚本是正常退出还是因错误退出,在退出前都会执行rm -f "$LOCK",清理锁文件。这避免了锁文件残留导致脚本永远无法再次运行。
3.3 内存压力检查的稳健实现
check_memory_pressure 函数是主动防御的核心。
check_memory_pressure() {
local mem_pressure
mem_pressure=$(memory_pressure 2>/dev/null \
| grep "System-wide memory free percentage" \
| awk '{print $NF}' | tr -d '%')
首先尝试使用macOS原生的 memory_pressure 命令获取“系统空闲内存百分比”,然后通过计算得到使用率。但 memory_pressure 的输出格式可能随系统版本变化, grep 的字符串必须准确。
if [ -z "$mem_pressure" ]; then
# Fallback via vm_stat
local pages_free pages_inactive pages_active pages_wired
pages_free=$(vm_stat | awk '/Pages free/ {gsub(/\./, "", $3); print $3}')
pages_inactive=$(vm_stat | awk '/Pages inactive/ {gsub(/\./, "", $3); print $3}')
pages_active=$(vm_stat | awk '/Pages active/ {gsub(/\./, "", $3); print $3}')
pages_wired=$(vm_stat | awk '/Pages occupied by compressor/ {gsub(/\./, "", $3); print $3}')
如果 memory_pressure 获取失败(返回空),则使用 回退方案 :解析 vm_stat 的输出。 vm_stat 提供的是“页”的数量。这里注意 gsub(/\./, "", $3) ,因为 vm_stat 输出的数字带逗号(如“1,234”),需要去掉逗号才能进行数学运算。
local total=$((pages_free + pages_inactive + pages_active + ${pages_wired:-0}))
if [ "$total" -gt 0 ]; then
local free_pct=$(( (pages_free + pages_inactive) * 100 / total ))
mem_pressure=$((100 - free_pct))
fi
fi
计算总页数(空闲+非活跃+活跃+有线)。 “非活跃”内存 是已被分配但近期未使用的内存,可以被系统快速回收,所以通常也被算作“可用”内存。内存使用率 = 100 - (空闲+非活跃)/总内存 * 100。
if [ "${mem_pressure:-0}" -gt "$MEMORY_THRESHOLD" ]; then
log "MEMORY PRESSURE HIGH: ${mem_pressure}% used. Shedding load."
shed_memory_load
fi
}
如果计算出的内存使用率超过阈值(85%),则调用 shed_memory_load 函数开始卸载。
3.4 卸载策略:有选择地牺牲
shed_memory_load 函数体现了优先级思想。
shed_memory_load() {
# Kill hero-tier agents first (cheapest to restart)
if tmux has-session -t atlas-heroes 2>/dev/null; then
log "Killing atlas-heroes session to free memory"
tmux kill-session -t atlas-heroes 2>/dev/null || true
fi
首先检查是否存在名为 atlas-heroes 的tmux会话。这是我的“英雄”子智能体,它们执行具体、独立、无状态的小任务。终止它们损失最小,重启也最快。 2>/dev/null 隐藏错误输出(如果会话不存在), || true 确保即使 kill-session 失败,脚本也不会因 set -e 而退出。
# Kill Chrome renderers (biggest hog)
local chrome_count
chrome_count=$(pgrep -f "Google Chrome Helper (Renderer)" 2>/dev/null | wc -l | tr -d ' ')
if [ "${chrome_count:-0}" -gt 5 ]; then
log "Killing ${chrome_count} Chrome renderer processes"
pkill -f "Google Chrome Helper (Renderer)" 2>/dev/null || true
fi
}
然后处理“内存大户”。这里以Chrome渲染进程为例。 pgrep -f 查找包含该字符串的所有进程, wc -l 计数。我设置了一个条件 -gt 5 ,只有当Chrome渲染进程数量大于5时才清理,避免过度杀伤。你也可以根据自己的情况,添加其他内存消耗大的应用进程,如Slack、Electron应用等。
实操心得 :卸载目标的顺序和条件需要根据你的具体工作负载调整。原则是:先杀重启成本低的,再杀占用内存大的;设置合理的数量阈值,避免频繁误杀影响正常使用。
3.5 三层活性检查的实现细节
is_atlas_alive 函数是判断智能体状态的逻辑核心。
is_atlas_alive() {
# Layer 1: Is there a claude process running with our flags?
if pgrep -f "claude.*--dangerously-skip-permissions" > /dev/null 2>&1; then
return 0
fi
第一层,检查是否有携带特定命令行参数(如 --dangerously-skip-permissions )的 claude 进程。这个参数是我运行Claude Code时的特征。使用 pgrep -f 进行全命令字符串匹配,比只匹配进程名更精确。
# Layer 2: Is the tmux session alive with a real process in it?
if tmux has-session -t atlas-pair 2>/dev/null; then
local pane_pid
pane_pid=$(tmux list-panes -t atlas-pair:1 -F '#{pane_pid}' 2>/dev/null | head -1)
if [ -n "$pane_pid" ] && kill -0 "$pane_pid" 2>/dev/null; then
return 0
fi
fi
第二层,检查tmux会话。 tmux has-session 检查会话是否存在。如果存在,则通过 tmux list-panes 获取第一个窗格( :1 )的进程ID。 kill -0 是一个特殊的信号,它不杀死进程,只检查该PID是否存在。如果PID存在且有效,返回true。
# Layer 3: Any claude process at all?
if pgrep -x claude > /dev/null 2>&1; then
return 0
fi
return 1
}
第三层,兜底检查:是否存在任何名为 claude 的进程。 -x 参数要求进程名完全匹配。这一层是为了防止因命令行参数变化导致前两层检查漏过。如果三层检查都失败,则返回1(false),表示智能体不存活。
3.6 僵尸检测与心跳停滞处理
is_heartbeat_stale 函数检查心跳文件是否“陈旧”。
is_heartbeat_stale() {
if [ ! -f "$HEARTBEAT_FILE" ]; then
return 0 # no heartbeat = stale
fi
local last_mod now age_minutes
last_mod=$(stat -f %m "$HEARTBEAT_FILE" 2>/dev/null || echo 0)
now=$(date +%s)
age_minutes=$(((now - last_mod) / 60))
[ "$age_minutes" -gt 15 ]
}
- 如果心跳文件不存在,直接判定为陈旧(返回0)。
stat -f %m获取文件的最后修改时间戳(Unix时间,秒级)。macOS的stat与Linux不同,-f指定格式,%m是修改时间。- 计算当前时间与最后修改时间的差值,转换为分钟。如果超过15分钟,返回true。
在主循环中调用此函数时,我使用了 累计计数 策略来避免误判:
if is_heartbeat_stale; then
local stale_count_file="/tmp/atlas-stale-count"
local stale_count
stale_count=$(cat "$stale_count_file" 2>/dev/null || echo 0)
stale_count=$((stale_count + 1))
echo "$stale_count" > "$stale_count_file"
if [ "$stale_count" -ge 5 ]; then
log "Atlas stale for 10+ minutes. Force restarting."
echo 0 > "$stale_count_file"
restart_atlas "zombie_session_stale_heartbeat"
fi
else
echo 0 > /tmp/atlas-stale-count 2>/dev/null || true
fi
- 每次检测到心跳陈旧,就将计数器加1,并持久化到
/tmp/atlas-stale-count文件。 - 只有当计数器达到5(即连续5个周期,约10分钟)时,才判定为僵尸并重启。
- 一旦检测到心跳更新(
else分支),立即将计数器重置为0。这意味着只要智能体在10分钟窗口内成功更新过一次心跳,就不会被误杀。
3.7 上下文注入式重启的完整流程
restart_atlas 函数是恢复逻辑的集大成者。
restart_atlas() {
local reason="$1"
log "Atlas is DOWN. Reason: $reason. Restarting..."
# Log crash context
cat >> "$CRASH_LOG" << EOF
---
RECOVERY: $(date '+%Y-%m-%d %H:%M:%S %Z')
REASON: $reason
UPTIME: $(uptime)
---
EOF
首先,记录崩溃上下文到专门的日志文件。 uptime 命令的输出包含了系统负载信息,对事后分析崩溃是否与系统负载相关很有帮助。
# Load environment variables
if [ -f "${AGENTS}/.env" ]; then
set -a; source "${AGENTS}/.env"; set +a
fi
加载环境变量文件。 set -a 表示自动导出之后所有定义的变量, source 加载.env文件, set +a 关闭自动导出。这确保了智能体运行所需的环境变量(如API密钥、路径)在重启后依然可用。
# Count pending work
local pending_msgs
pending_msgs=$(ls "$VAULT/coordination/incoming/" 2>/dev/null | wc -l | tr -d ' ')
统计待处理消息的数量。这是恢复上下文的重要部分,让智能体知道“有多少活等着你干”。
# Build recovery prompt with full context
local RECOVERY_PROMPT
RECOVERY_PROMPT="You are Atlas — the autonomous AI agent running your-project.
CRASH RECOVERY CONTEXT:
- Recovery reason: ${reason}
- Current time: $(date '+%Y-%m-%d %H:%M:%S %Z')
- Pending messages: ${pending_msgs}
IMMEDIATE ACTIONS:
1. Read ~/Desktop/Agents/BOOTSTRAP.md for full identity
2. Check ~/Desktop/Agents/coordination/incoming/ for pending messages
3. Write a recovery heartbeat to signal you're alive
4. Resume operations
You are crash-tolerant. A watchdog restarts you if you die. Execute."
构建恢复提示词。这里有几个设计要点:
- 明确身份 :开宗明义“你是谁”。
- 提供上下文 :重启原因、时间、待办数量。
- 给出明确指令 :用编号列表告诉智能体重启后要做的具体事情,第一步永远是重新读取身份文件(
BOOTSTRAP.md),确保自我认知正确。 - 心理建设 :最后一句“你是崩溃容忍的...”非常重要,它能减少智能体因“自己刚刚死过”而产生的逻辑混乱或犹豫。
# Kill old session and start fresh
tmux kill-session -t atlas-pair 2>/dev/null || true
tmux new-session -d -s atlas-pair -n Atlas
tmux send-keys -t atlas-pair:Atlas \
"${CLAUDE} -p \"${RECOVERY_PROMPT}\" --dangerously-skip-permissions 2>&1 | tee \"${LOGS}/atlas-recovery-$(date '+%Y-%m-%d-%H%M').log\"" \
Enter
log "Atlas restarted in tmux session atlas-pair:Atlas"
}
最后,执行重启:杀死旧的tmux会话,创建新会话,并在新会话中发送启动命令。 tmux send-keys 模拟键盘输入,最后的 Enter 表示按下回车键执行命令。启动命令将Claude的执行日志同时输出到终端和指定的日志文件,便于调试。
4. 与launchd集成:系统级守护
4.1 launchd配置文件详解
将脚本变成系统服务,需要创建 ~/Library/LaunchAgents/com.whoffagents.atlas-watchdog.plist 文件。LaunchAgents作用于当前用户,LaunchDaemons作用于整个系统(需要root)。对于用户级AI智能体,LaunchAgents更合适。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.whoffagents.atlas-watchdog</string>
Label 是服务的唯一标识符,通常使用反向域名格式。
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/YOUR_USER/projects/your-automation/scripts/atlas-watchdog.sh</string>
</array>
ProgramArguments 指定要执行的程序及其参数。 必须使用绝对路径 。第一个参数是解释器( /bin/bash ),第二个是脚本路径。
<!-- Run every 2 minutes -->
<key>StartInterval</key>
<integer>120</integer>
StartInterval 指定运行间隔,单位是秒。120秒即2分钟。这个频率是权衡的结果:太频繁可能增加系统负担,太慢则故障恢复延迟长。
<!-- Run on boot -->
<key>RunAtLoad</key>
<true/>
RunAtLoad 设置为 true ,表示当 launchd 加载这个plist文件时(通常是系统启动或用户登录后),立即运行一次。这确保了Mac重启后看门狗能自动启动。
<key>StandardOutPath</key>
<string>/Users/YOUR_USER/projects/your-automation/logs/watchdog-launchd.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_USER/projects/your-automation/logs/watchdog-launchd-error.log</string>
标准输出和错误输出重定向到文件。这是 launchd 相比 cron 的一大优势,所有输出都有记录。
<!-- Restart launchd job itself if it crashes -->
<key>KeepAlive</key>
<false/>
</dict>
</plist>
KeepAlive 如果设为 true , launchd 会尝试在任务退出后重新启动它。但对于我们的看门狗脚本,我们 不希望 它无限重启。如果脚本本身因致命错误退出,我们更希望它保持停止状态,以便我们发现问题。因此设为 false 。看门狗的周期性运行由 StartInterval 保证。
4.2 加载、管理与调试launchd服务
配置文件准备好后,需要加载并启动服务:
# 1. 给脚本添加执行权限
chmod +x ~/projects/your-automation/scripts/atlas-watchdog.sh
# 2. 加载plist文件到launchd
launchctl load ~/Library/LaunchAgents/com.whoffagents.atlas-watchdog.plist
# 3. 立即启动服务(如果不使用RunAtLoad,或想立即测试)
launchctl start com.whoffagents.atlas-watchdog
# 4. 检查服务状态
launchctl list | grep atlas-watchdog
launchctl list 会显示服务列表。如果看到服务名且没有错误标记,说明加载成功。
常用管理命令 :
launchctl stop com.whoffagents.atlas-watchdog:停止服务(但下次触发间隔或重启后还会运行)。launchctl unload ~/Library/LaunchAgents/com.whoffagents.atlas-watchdog.plist:卸载服务,彻底停止。launchctl load -w ...:-w参数会覆盖plist中的Disabled键设置,但通常不需要。
调试技巧 : 如果服务没有按预期运行,按以下顺序排查:
- 检查脚本权限 :
ls -la ~/projects/your-automation/scripts/atlas-watchdog.sh,确保有x执行权限。 - 检查日志文件 :查看
StandardOutPath和StandardErrorPath指定的日志文件,看是否有错误输出。 - 手动运行脚本 :在终端直接执行脚本
/bin/bash /path/to/script.sh,看是否有错误。 - 检查launchd日志 :
sudo log stream --predicate 'subsystem == "com.apple.xpc.launchd"'可以查看launchd的系统日志,但信息较杂。更简单的是检查系统控制台(Console.app),筛选你的服务标签。 - 验证plist语法 :
plutil -lint ~/Library/LaunchAgents/com.whoffagents.atlas-watchdog.plist,确保XML格式正确。
注意事项 :修改plist文件后,需要先
unload再load才能生效。或者使用launchctl kickstart -k -p com.whoffagents.atlas-watchdog(某些系统版本支持)来强制重启服务。
5. 智能体端的心跳实现模式
看门狗是“守方”,智能体是“攻方”。要让僵尸检测生效,智能体必须定期“报平安”。
5.1 心跳文件的设计
我选择使用一个简单的Markdown文件作为心跳文件,因为它易于阅读和解析。位置放在共享目录 $VAULT/coordination/shared/atlas-heartbeat.md 。
心跳的格式设计为管道分隔的一行文本:
2024-05-27 14:30:00 CST | task: deploy_site | status: complete | next: email_outreach
包含四个关键信息:
- 时间戳 :精确到秒,带时区。
- 任务 :刚完成的主要任务名称。
- 状态 :通常是“complete”、“in_progress”、“failed”。
- 下一步 :计划中的下一个任务。
这种格式既对人类友好(可直接查看),也便于用脚本解析(用 awk -F '|' 分割)。
5.2 在Bash脚本中写入心跳
如果智能体本身是Bash脚本或由Bash脚本驱动,写入心跳很简单:
HEARTBEAT_FILE="$HOME/Desktop/Agents/coordination/shared/atlas-heartbeat.md"
# 在每个主要任务完成后写入
echo "$(date '+%Y-%m-%d %H:%M:%S %Z') | task: analyze_logs | status: complete | next: generate_report" > "$HEARTBEAT_FILE"
使用 > 重定向而非 >> ,意味着每次写入都会覆盖上次的内容。我们只需要最新的心跳,历史心跳由看门狗判断“陈旧性”后即可丢弃。
5.3 在Claude Code等AI编码助手中集成心跳
对于像Claude Code这样的AI编码助手,你需要将心跳写入逻辑整合到它的工作流中。有两种方式:
方式一:在系统提示词中明确要求 在你的Claude Code系统提示词或项目说明中加入:
工作流程要求:
1. 每完成一个主要任务阶段(如写完一个函数、完成一个模块测试、发送一封邮件),你必须更新心跳文件。
2. 心跳文件路径:~/Desktop/Agents/coordination/shared/atlas-heartbeat.md
3. 格式:一行文本,格式为:[时间戳] | task: [任务描述] | status: [状态] | next: [下一步计划]
4. 示例:2024-05-27 14:30:00 CST | task: deploy_site | status: complete | next: email_outreach
5. 这是强制要求,用于系统健康监控。忘记写入心跳可能导致你被意外重启。
方式二:通过外部包装脚本 创建一个包装脚本,在调用Claude Code前后自动处理心跳:
#!/bin/bash
# claude-wrapper.sh
HEARTBEAT="$HOME/Desktop/Agents/coordination/shared/atlas-heartbeat.md"
TASK="$1"
# 任务开始前写入“进行中”心跳
echo "$(date '+%Y-%m-%d %H:%M:%S %Z') | task: $TASK | status: in_progress | next: " > "$HEARTBEAT"
# 执行实际任务(例如调用Claude Code)
/path/to/claude --your-args "$@"
EXIT_CODE=$?
# 任务结束后写入“完成”或“失败”心跳
if [ $EXIT_CODE -eq 0 ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S %Z') | task: $TASK | status: complete | next: " > "$HEARTBEAT"
else
echo "$(date '+%Y-%m-%d %H:%M:%S %Z') | task: $TASK | status: failed | next: investigate_error" > "$HEARTBEAT"
fi
exit $EXIT_CODE
然后,所有对Claude Code的调用都通过这个包装脚本进行。
5.4 心跳机制的优化与陷阱
心跳频率的权衡 :
- 太频繁 :增加I/O负担,如果心跳文件在网络存储上可能影响性能。
- 太稀疏 :看门狗需要更长时间才能检测到僵尸,故障恢复时间变长。
- 建议 :在每个“逻辑任务单元”完成后写入。对于长时间运行的任务(超过5分钟),即使在任务中,也应定期写入“in_progress”状态的心跳。
心跳文件的并发访问 : 如果多个进程或线程同时写入心跳文件,可能损坏内容。解决方案:
- 使用文件锁 :在Bash中可以用
flock命令(但注意macOS与Linux的差异)。 - 原子写入 :先写入临时文件,然后
mv重命名。mv在同一个文件系统上是原子的。
TEMP_FILE="${HEARTBEAT_FILE}.tmp"
echo "$CONTENT" > "$TEMP_FILE"
mv "$TEMP_FILE" "$HEARTBEAT_FILE"
- 单一写入者 :设计上确保只有一个进程负责写入心跳。
心跳丢失的处理 : 如果智能体崩溃时正在写入心跳,可能导致心跳文件为空或损坏。看门狗的 is_heartbeat_stale 函数已经处理了文件不存在的情况(直接返回陈旧)。对于损坏的文件, stat 命令可能失败, || echo 0 确保了失败时返回时间戳0,也会被判定为陈旧。
6. 生产环境部署与调优指南
6.1 根据你的Mac配置调整参数
内存阈值(MEMORY_THRESHOLD) :
- 8GB RAM的Mac :建议设为
75。内存较小,需要更早开始清理。 - 16GB RAM的Mac :
85是一个平衡点。 - 32GB或以上 :可以设为
90甚至92,给应用更多内存空间。 - 监控调整 :运行
vm_stat 1或使用htop观察你的工作负载下内存使用模式,找到合适的阈值。
检测间隔(StartInterval) :
- 开发/测试环境 :可以设为
60(1分钟),更快响应故障。 - 生产环境 :
120(2分钟)是个好选择,平衡了响应速度和系统负担。 - 非常稳定的环境 :如果智能体很少崩溃,可以设为
300(5分钟)。
心跳陈旧阈值 :
- 快速响应的智能体 :如果任务通常几分钟内完成,可以设为
10(分钟)。 - 长时间任务 :如果有需要运行半小时以上的任务,应设为
30或更高,并确保任务中定期写入“in_progress”心跳。 - 累计检测次数 :我使用5次(10分钟),你可以根据间隔调整。如果间隔是1分钟,可以设为10次(10分钟);如果间隔是5分钟,可能只需2次(10分钟)。
6.2 卸载目标的自定义
shed_memory_load 函数中的卸载目标需要根据你的实际工作负载定制:
shed_memory_load() {
# 1. 你的无状态子进程/服务
# 示例:停止Docker容器(如果用了Docker)
# docker stop some-container 2>/dev/null || true
# 2. 缓存或临时进程
# 示例:清理Redis缓存(如果Redis只是缓存)
# redis-cli FLUSHALL 2>/dev/null || true
# 3. 开发工具进程
# 示例:停止本地开发服务器
# pkill -f "npm run dev" 2>/dev/null || true
# 4. 浏览器标签页(通过AppleScript优雅关闭)
# osascript -e 'tell application "Google Chrome" to close (tabs of window 1 whose title contains "Dashboard")' 2>/dev/null || true
# 5. 内存大户应用(保留核心功能)
# 示例:重启Slack(它会自动恢复)
# killall Slack 2>/dev/null || true
# 6. 最后手段:清理系统缓存(激进)
# sudo purge 2>/dev/null || true # 需要sudo密码,可能不适合自动化
log "Memory load shed completed"
}
卸载优先级原则 :
- 无状态优先 :先杀能无损或低损失重启的。
- 用户交互影响最小化 :避免关闭用户正在 actively 使用的应用窗口。
- 渐进式 :从轻量级清理开始,如果内存压力仍未缓解,再执行更激进的清理。
- 可恢复性 :确保你杀掉的进程有自动恢复机制,或者你知道如何手动恢复。
6.3 日志与监控体系建设
看门狗本身会产生日志,但你需要一个系统来监控这些日志,并在真正需要人工干预时发出警报。
日志轮转 : 长期运行的看门狗会产生大量日志。使用 logrotate 或简单的cron任务来管理:
# 简单的日志轮转脚本 /path/to/rotate-logs.sh
#!/bin/bash
LOG_DIR="$HOME/projects/your-automation/logs"
DATE=$(date +%Y%m%d)
# 压缩7天前的日志
find "$LOG_DIR" -name "*.log" -mtime +7 -exec gzip {} \;
# 删除30天前的压缩日志
find "$LOG_DIR" -name "*.log.gz" -mtime +30 -delete
# 每天运行一次:0 2 * * * /path/to/rotate-logs.sh
关键指标监控 : 除了看日志文件,还可以监控一些关键指标:
- 重启频率 :统计
atlas-crash-recovery.log中重启记录的数量。突然增加可能意味着智能体或环境有问题。 - 内存压力事件 :监控看门狗日志中“MEMORY PRESSURE HIGH”出现的频率。
- 心跳间隔 :写一个简单脚本检查心跳文件的时间戳,如果接近阈值就发出预警而非等待超时。
报警集成 : 当看门狗执行了重启操作,除了写日志,还可以发送通知:
# 在restart_atlas函数中添加
send_notification() {
local message="$1"
# 发送到Slack
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"$message\"}" \
https://hooks.slack.com/services/your/webhook/url 2>/dev/null || true
# 或者发送邮件
echo "$message" | mail -s "Atlas Watchdog Alert" your-email@example.com 2>/dev/null || true
# 或者Mac本地通知
osascript -e "display notification \"$message\" with title \"Atlas Watchdog\"" 2>/dev/null || true
}
# 在重启时调用
send_notification "Atlas restarted due to: $reason. Check $CRASH_LOG for details."
6.4 测试与验证策略
在部署到生产环境前,必须充分测试:
1. 模拟崩溃测试 :
# 测试1:直接杀死智能体进程
pkill -f "claude.*--dangerously-skip-permissions"
# 观察看门狗日志,应该在2分钟内看到重启记录
tail -f ~/projects/your-automation/logs/atlas-watchdog.log
# 测试2:制造僵尸会话(进程在但不工作)
# 先找到智能体的PID
ATLAS_PID=$(pgrep -f "claude.*--dangerously-skip-permissions")
# 发送SIGSTOP信号暂停进程(不杀死)
kill -SIGSTOP $ATLAS_PID
# 等待15+分钟,看门狗应该通过心跳检测到并重启
# 恢复进程(如果需要)
kill -SIGCONT $ATLAS_PID
2. 内存压力测试 :
# 使用memory_pressure工具模拟内存压力
# 注意:这会影响系统性能,最好在测试机器上进行
sudo memory_pressure -S -l warn
# 或者用Python快速消耗内存
python3 -c "
import time
data = []
for i in range(1000):
data.append('x' * 1024 * 1024) # 每次分配1MB
print(f'Allocated {(i+1)}MB')
time.sleep(0.1)
"
# 观察看门狗是否触发内存卸载
3. 重启恢复测试 :
# 测试Mac重启后看门狗和智能体是否自动启动
sudo reboot
# 重启后检查
launchctl list | grep atlas-watchdog
ps aux | grep claude
4. 杀开关测试 :
# 启用杀开关
touch ~/projects/your-automation/docs/.atlas-watchdog-paused
# 杀死智能体,看门狗应该不会重启
pkill -f claude
# 等待5分钟,确认没有重启
# 禁用杀开关
rm ~/projects/your-automation/docs/.atlas-watchdog-paused
# 再次杀死智能体,现在应该重启了
pkill -f claude
7. 故障排查与常见问题实录
在实际运行中,我遇到了各种各样的问题。这里记录下最典型的几个及其解决方案。
7.1 launchd服务不运行
症状 : launchctl list 中看不到服务,或者服务显示错误代码。
可能原因与解决 :
- Plist文件语法错误 :使用
plutil -lint your.plist检查XML语法。 - 路径错误 :
launchd运行在有限的环境下,所有路径必须是绝对路径。检查ProgramArguments中的路径是否存在、是否有执行权限。 - 权限问题 :确保plist文件在
~/Library/LaunchAgents/目录下,且当前用户有读写权限。 - 依赖服务未就绪 :如果脚本依赖网络或其他服务,可能在启动时那些服务还没准备好。可以添加
StartInterval而不是仅依赖RunAtLoad,或者添加延迟启动:
<key>StartCalendarInterval</key>
<dict>
<key>Minute</key>
<integer>1</integer>
</dict>
这会在加载后1分钟运行第一次。
7.2 看门狗脚本自身崩溃
症状 :智能体死了,看门狗也没重启它。
排查步骤 :
- 检查看门狗日志 :
tail -f ~/projects/your-automation/logs/watchdog-launchd-error.log - 常见原因 :
- 锁文件残留 :脚本上次异常退出,锁文件没清理。手动删除
/tmp/atlas-watchdog.lock。 - 命令不存在 :脚本中调用的
memory_pressure、vm_stat、tmux等命令在launchd的环境PATH中找不到。在脚本开头设置完整PATH:export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:$HOME/.local/bin" - 权限不足 :脚本尝试写入没有权限的目录。检查所有涉及目录的权限。
- 增加脚本健壮性 :在脚本开头添加更详细的错误处理:
# 记录每次运行
echo "$(date): Watchdog started" >> "$LOGS/watchdog-debug.log"
# 设置错误时发送通知
trap 'echo "$(date): Script failed at line $LINENO" >> "$LOGS/watchdog-error.log"; exit 1' ERR
7.3 内存检测不准确或误卸载
症状 :内存使用率计算错误,或者在不该卸载的时候杀死了进程。
调试方法 :
- 手动计算验证 :
# 在终端运行,与脚本中的计算对比
memory_pressure | grep "System-wide memory free percentage"
vm_stat | head -20
- 调整阈值 :如果频繁误卸载,尝试提高
MEMORY_THRESHOLD到90。 - 细化卸载策略 :在
shed_memory_load中添加更多日志,记录每次卸载决策:
log "Memory pressure: ${mem_pressure}%, threshold: ${MEMORY_THRESHOLD}%"
log "Chrome processes found: ${chrome_count}"
- 排除关键进程 :修改
pgrep模式,避免误杀:
# 不要杀死带有特定参数的Chrome进程
pkill -f "Google Chrome Helper (Renderer)" 2>/dev/null | grep -v "--type=renderer --extension-process" || true
7.4 心跳误判导致不必要的重启
症状 :智能体明明在工作,却被判定为僵尸而重启。
排查 :
- 检查心跳文件权限 :确保智能体有写入权限,且目录存在。
- 验证时间同步 :如果系统时间不同步,可能导致时间计算错误。确保NTP服务运行:
sudo sntp -sS time.apple.com - 调整陈旧阈值 :如果智能体有长时间运行的任务(超过15分钟),增加
is_heartbeat_stale中的时间阈值,或者让智能体在长时间任务中定期写入“in_progress”心跳。 - 检查文件系统延迟 :如果心跳文件在网络存储或慢速磁盘上,写入可能延迟。考虑使用本地临时文件:
LOCAL_HEARTBEAT="/tmp/atlas-heartbeat.md"
echo "$CONTENT" > "$LOCAL_HEARTBEAT"
cp "$LOCAL_HEARTBEAT" "$HEARTBEAT_FILE" # 异步复制
7.5 tmux会话管理问题
症状 :智能体在tmux中运行,但看门狗无法正确检测或控制。
常见问题 :
- tmux服务器未运行 :看门狗运行时,tmux服务器可能还没启动。在脚本开头确保tmux服务器运行:
# 启动tmux服务器(如果未运行)
if ! tmux has-session 2>/dev/null; then
tmux start-server
fi
- 会话名冲突 :确保
atlas-pair会话名唯一,没有其他进程使用相同名称。 - 窗格索引变化 :如果智能体会在tmux中创建新窗格,
tmux list-panes -t atlas-pair:1可能指向错误的窗格。改为检查会话中是否有任何存活的窗格:
# 更稳健的检查
if tmux has-session -t atlas-pair 2>/dev/null; then
# 获取会话中所有窗格的PID
pids=$(tmux list-panes -t atlas-pair -F '#{pane_pid}' 2>/dev/null)
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
return 0 # 找到存活的窗格
fi
done
fi
7.6 恢复提示词效果不佳
症状 :智能体重启后表现异常,似乎“忘记”了上下文。
优化方向 :
- 丰富恢复上下文 :在恢复提示词中加入更多崩溃前的状态信息:
# 添加最近的工作历史
RECENT_WORK=$(tail -5 "$VAULT/coordination/activity.log" 2>/dev/null || echo "No recent activity")
# 添加系统状态
SYSTEM_LOAD=$(uptime | awk -F'load averages: ' '{print $2}')
DISK_FREE=$(df -h / | awk 'NR==2 {print $4}')
RECOVERY_PROMPT="... Recent work:\n$RECENT_WORK\nSystem load: $SYSTEM_LOAD\nFree disk: $DISK_FREE\n..."
- 逐步恢复 :对于特别复杂的智能体,不要期望一次重启就恢复所有状态。设计一个“安全模式”启动,先执行简单的健康检查任务,确认正常后再加载完整上下文。
- 检查环境变量 :确保
.env文件中的API密钥、配置路径等正确加载,且没有过期。 - 验证身份文件 :确保
BOOTSTRAP.md等身份文件存在且内容正确。
8. 扩展与高级应用场景
基础版看门狗已经能解决大部分问题,但根据你的具体需求,还可以进一步扩展。
8.1 多智能体监控
如果你运行多个AI智能体(如CEO、CTO、CMO各一个),可以扩展看门狗来监控所有智能体:
# 配置多个智能体
AGENTS=(
"name:atlas role:ceo session:atlas-ceo cmd:claude --role ceo"
"name:prometheus role:cto session:atlas-cto cmd:claude --role cto"
"name:hermes role:cmo session:atlas-cmo cmd:claude --role cmo"
)
# 修改主循环
for agent_spec in "${AGENTS[@]}"; do
# 解析配置
IFS=':' read -r name role session cmd <<< "$agent_spec"
# 为每个智能体检查心跳、状态等
HEARTBEAT_FILE="$VAULT/coordination/shared/${name}-heartbeat.md"
if ! is_agent_alive "$session" "$cmd"; then
restart_agent "$name" "$role" "$session" "$cmd"
fi
done
8.2 资源配额与优先级
在内存紧张时,不是所有智能体都平等。可以给智能体设置优先级,内存压力时按优先级终止:
# 智能体配置带优先级
AGENTS=(
"name:atlas priority:1 session:atlas-ceo" # 优先级1(最高)
"name:prometheus priority:2 session:atlas-cto" # 优先级2
"name:hermes priority:3 session:atlas-cmo" # 优先级3(最低)
)
shed_memory_load() {
local memory_needed=$((current_usage - MEMORY_THRESHOLD + 10))
# 按优先级从低到高终止智能体,直到释放足够内存
for priority in 3 2 1; do
for agent in "${AGENTS[@]}"; do
if [[ "$agent" =~ priority:$priority ]]; then
# 终止这个智能体
# ...
# 重新计算内存使用
# 如果释放了足够内存,break
fi
done
done
}
8.3 集成外部监控系统
将看门狗与Prometheus、Grafana等监控系统集成,实现可视化监控:
# 在每次检查后输出Prometheus格式的指标
echo "# HELP atlas_agent_alive Whether the Atlas agent is alive (1) or dead (0)"
echo "# TYPE atlas_agent_alive gauge"
echo "atlas_agent_alive $(is_atlas_alive && echo 1 || echo 0)"
echo "# HELP atlas_heartbeat_age_seconds Age of the last heartbeat in seconds"
echo "# TYPE atlas_heartbeat_age_seconds gauge"
if [ -f "$HEARTBEAT_FILE" ]; then
last_mod=$(stat -f %m "$HEARTBEAT_FILE" 2>/dev/null || echo 0)
now=$(date +%s)
age=$((now - last_mod))
echo "atlas_heartbeat_age_seconds $age"
else
echo "atlas_heartbeat_age_seconds -1"
fi
# 将这些指标写入文件,由node_exporter或Prometheus抓取
8.4 机器学习预测性维护
通过分析历史崩溃日志和系统指标,可以尝试预测何时可能发生崩溃,并提前采取预防措施:
# 收集历史数据
collect_metrics() {
local timestamp=$(date +%s)
local memory_usage=$(get_memory_usage)
local cpu_usage=$(top -l 1 | grep "CPU usage" | awk '{print $3}' | tr -d '%')
local agent_uptime=$(get_agent_uptime)
echo "$timestamp,$memory_usage,$cpu_usage,$agent_uptime" >> "$LOGS/metrics-history.csv"
}
# 简单预测:如果内存使用率连续5次检查都在增长且超过80%,预警
check_trend() {
local recent_metrics=$(tail -5 "$LOGS/metrics-history.csv" | cut -d',' -f2)
local increasing=1
local prev=0
for metric in $recent_metrics; do
if [ $(echo "$metric <= $prev" | bc) -eq 1 ]; then
increasing=0
break
fi
prev=$metric
done
if [ $increasing -eq 1 ] && [ $(echo "$prev > 80" | bc) -eq 1 ]; then
log "WARNING: Memory usage trending upward and high. Consider proactive measures."
# 可以提前卸载一些非关键进程
fi
}
8.5 跨平台适配
虽然本文聚焦macOS,但核心模式可以适配Linux和Windows:
Linux适配 :
- 将
launchd替换为systemd(现代Linux)或cron+ 自定义守护脚本。 memory_pressure命令不存在,使用free -m或/proc/meminfo。vm_stat替换为vmstat或/proc/vmstat。
Windows适配 :
- 使用Windows Task Scheduler替代
launchd。 - Bash脚本转换为PowerShell脚本。
- 进程管理使用
Get-Process和Stop-Process。 - 内存检查使用
Get-Counter '\Memory\Available MBytes'。
核心思想不变:定期检查、多层检测、主动防御、上下文恢复。
这套基于macOS launchd 的AI智能体看门狗系统,在我这里已经稳定运行了数月,自动处理了多次内存崩溃、进程异常和系统重启。它最大的价值不是技术复杂度,而是将“AI智能体运维”这个模糊的概念,变成了具体、可监控、可恢复的工程实践。当你不再需要半夜起来重启服务,当你发现系统已经默默处理了3次OOM崩溃而你浑然不知时,你会体会到这种自动化带来的安心感。
真正的生产级AI应用,不仅要考虑模型效果和提示词工程,更要考虑这些“枯燥”但至关重要的运维基础。一个会自我恢复的智能体,才是一个值得信赖的合作伙伴。希望这套方案能为你节省那些本该用于创造性工作,却浪费在重启服务上的时间。
更多推荐


所有评论(0)