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被杀死,而是主动执行“卸载”操作,按照优先级终止一些非关键、易恢复的进程来释放内存。我的卸载顺序是:

  1. 先杀“英雄”级子智能体 :这些是Atlas调度的、无状态的子任务执行者。它们成本低,重启快,丢失的上下文少。
  2. 再杀Chrome渲染进程 :如果开发过程中打开了浏览器,Chrome Helper (Renderer)进程是著名的内存大户。杀掉几个通常不影响前端标签页的核心功能(可能会重载),但能立即释放大量内存。

这个策略的核心思想是: 用可牺牲的“士兵”换取“将军”的存活 。牺牲一些边缘进程,保住核心的、携带大量上下文的AI主智能体。

2.4 上下文注入式重启:让智能体“失忆”后快速“恢复记忆”

简单的 kill && start 对于AI智能体来说是灾难性的。重启后的智能体就像一个失忆的人:它不知道自己是谁、刚才在做什么、接下来该干什么。因此, 重启时必须注入上下文

我的 restart_atlas 函数在重启前会做几件事:

  1. 记录崩溃上下文 :将崩溃原因、时间、系统负载等信息追加到专门的崩溃日志中,便于事后分析。
  2. 统计待处理工作 :检查消息队列目录(如 incoming/ )中有多少 pending 任务,将这个数字传递给重启后的智能体。
  3. 构建恢复提示词 :这是最关键的一步。精心设计一个提示词,告诉智能体:
    • 你的身份和角色(“你是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."

构建恢复提示词。这里有几个设计要点:

  1. 明确身份 :开宗明义“你是谁”。
  2. 提供上下文 :重启原因、时间、待办数量。
  3. 给出明确指令 :用编号列表告诉智能体重启后要做的具体事情,第一步永远是重新读取身份文件( BOOTSTRAP.md ),确保自我认知正确。
  4. 心理建设 :最后一句“你是崩溃容忍的...”非常重要,它能减少智能体因“自己刚刚死过”而产生的逻辑混乱或犹豫。
  # 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 键设置,但通常不需要。

调试技巧 : 如果服务没有按预期运行,按以下顺序排查:

  1. 检查脚本权限 ls -la ~/projects/your-automation/scripts/atlas-watchdog.sh ,确保有 x 执行权限。
  2. 检查日志文件 :查看 StandardOutPath StandardErrorPath 指定的日志文件,看是否有错误输出。
  3. 手动运行脚本 :在终端直接执行脚本 /bin/bash /path/to/script.sh ,看是否有错误。
  4. 检查launchd日志 sudo log stream --predicate 'subsystem == "com.apple.xpc.launchd"' 可以查看 launchd 的系统日志,但信息较杂。更简单的是检查系统控制台(Console.app),筛选你的服务标签。
  5. 验证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

包含四个关键信息:

  1. 时间戳 :精确到秒,带时区。
  2. 任务 :刚完成的主要任务名称。
  3. 状态 :通常是“complete”、“in_progress”、“failed”。
  4. 下一步 :计划中的下一个任务。

这种格式既对人类友好(可直接查看),也便于用脚本解析(用 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”状态的心跳。

心跳文件的并发访问 : 如果多个进程或线程同时写入心跳文件,可能损坏内容。解决方案:

  1. 使用文件锁 :在Bash中可以用 flock 命令(但注意macOS与Linux的差异)。
  2. 原子写入 :先写入临时文件,然后 mv 重命名。 mv 在同一个文件系统上是原子的。
TEMP_FILE="${HEARTBEAT_FILE}.tmp"
echo "$CONTENT" > "$TEMP_FILE"
mv "$TEMP_FILE" "$HEARTBEAT_FILE"
  1. 单一写入者 :设计上确保只有一个进程负责写入心跳。

心跳丢失的处理 : 如果智能体崩溃时正在写入心跳,可能导致心跳文件为空或损坏。看门狗的 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"
}

卸载优先级原则

  1. 无状态优先 :先杀能无损或低损失重启的。
  2. 用户交互影响最小化 :避免关闭用户正在 actively 使用的应用窗口。
  3. 渐进式 :从轻量级清理开始,如果内存压力仍未缓解,再执行更激进的清理。
  4. 可恢复性 :确保你杀掉的进程有自动恢复机制,或者你知道如何手动恢复。

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

关键指标监控 : 除了看日志文件,还可以监控一些关键指标:

  1. 重启频率 :统计 atlas-crash-recovery.log 中重启记录的数量。突然增加可能意味着智能体或环境有问题。
  2. 内存压力事件 :监控看门狗日志中“MEMORY PRESSURE HIGH”出现的频率。
  3. 心跳间隔 :写一个简单脚本检查心跳文件的时间戳,如果接近阈值就发出预警而非等待超时。

报警集成 : 当看门狗执行了重启操作,除了写日志,还可以发送通知:

# 在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 中看不到服务,或者服务显示错误代码。

可能原因与解决

  1. Plist文件语法错误 :使用 plutil -lint your.plist 检查XML语法。
  2. 路径错误 launchd 运行在有限的环境下,所有路径必须是绝对路径。检查 ProgramArguments 中的路径是否存在、是否有执行权限。
  3. 权限问题 :确保plist文件在 ~/Library/LaunchAgents/ 目录下,且当前用户有读写权限。
  4. 依赖服务未就绪 :如果脚本依赖网络或其他服务,可能在启动时那些服务还没准备好。可以添加 StartInterval 而不是仅依赖 RunAtLoad ,或者添加延迟启动:
<key>StartCalendarInterval</key>
<dict>
    <key>Minute</key>
    <integer>1</integer>
</dict>

这会在加载后1分钟运行第一次。

7.2 看门狗脚本自身崩溃

症状 :智能体死了,看门狗也没重启它。

排查步骤

  1. 检查看门狗日志 tail -f ~/projects/your-automation/logs/watchdog-launchd-error.log
  2. 常见原因
  • 锁文件残留 :脚本上次异常退出,锁文件没清理。手动删除 /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"
  • 权限不足 :脚本尝试写入没有权限的目录。检查所有涉及目录的权限。
  1. 增加脚本健壮性 :在脚本开头添加更详细的错误处理:
# 记录每次运行
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 内存检测不准确或误卸载

症状 :内存使用率计算错误,或者在不该卸载的时候杀死了进程。

调试方法

  1. 手动计算验证
# 在终端运行,与脚本中的计算对比
memory_pressure | grep "System-wide memory free percentage"
vm_stat | head -20
  1. 调整阈值 :如果频繁误卸载,尝试提高 MEMORY_THRESHOLD 到90。
  2. 细化卸载策略 :在 shed_memory_load 中添加更多日志,记录每次卸载决策:
log "Memory pressure: ${mem_pressure}%, threshold: ${MEMORY_THRESHOLD}%"
log "Chrome processes found: ${chrome_count}"
  1. 排除关键进程 :修改 pgrep 模式,避免误杀:
# 不要杀死带有特定参数的Chrome进程
pkill -f "Google Chrome Helper (Renderer)" 2>/dev/null | grep -v "--type=renderer --extension-process" || true

7.4 心跳误判导致不必要的重启

症状 :智能体明明在工作,却被判定为僵尸而重启。

排查

  1. 检查心跳文件权限 :确保智能体有写入权限,且目录存在。
  2. 验证时间同步 :如果系统时间不同步,可能导致时间计算错误。确保NTP服务运行: sudo sntp -sS time.apple.com
  3. 调整陈旧阈值 :如果智能体有长时间运行的任务(超过15分钟),增加 is_heartbeat_stale 中的时间阈值,或者让智能体在长时间任务中定期写入“in_progress”心跳。
  4. 检查文件系统延迟 :如果心跳文件在网络存储或慢速磁盘上,写入可能延迟。考虑使用本地临时文件:
LOCAL_HEARTBEAT="/tmp/atlas-heartbeat.md"
echo "$CONTENT" > "$LOCAL_HEARTBEAT"
cp "$LOCAL_HEARTBEAT" "$HEARTBEAT_FILE"  # 异步复制

7.5 tmux会话管理问题

症状 :智能体在tmux中运行,但看门狗无法正确检测或控制。

常见问题

  1. tmux服务器未运行 :看门狗运行时,tmux服务器可能还没启动。在脚本开头确保tmux服务器运行:
# 启动tmux服务器(如果未运行)
if ! tmux has-session 2>/dev/null; then
    tmux start-server
fi
  1. 会话名冲突 :确保 atlas-pair 会话名唯一,没有其他进程使用相同名称。
  2. 窗格索引变化 :如果智能体会在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 恢复提示词效果不佳

症状 :智能体重启后表现异常,似乎“忘记”了上下文。

优化方向

  1. 丰富恢复上下文 :在恢复提示词中加入更多崩溃前的状态信息:
# 添加最近的工作历史
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..."
  1. 逐步恢复 :对于特别复杂的智能体,不要期望一次重启就恢复所有状态。设计一个“安全模式”启动,先执行简单的健康检查任务,确认正常后再加载完整上下文。
  2. 检查环境变量 :确保 .env 文件中的API密钥、配置路径等正确加载,且没有过期。
  3. 验证身份文件 :确保 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应用,不仅要考虑模型效果和提示词工程,更要考虑这些“枯燥”但至关重要的运维基础。一个会自我恢复的智能体,才是一个值得信赖的合作伙伴。希望这套方案能为你节省那些本该用于创造性工作,却浪费在重启服务上的时间。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐