AI Agent 跑 24 小时后,我补上的 6 个运维护栏

让 Agent 长期在线不难,难的是出错后谁负责、怎么止损。这篇是我们把自主 Agent 跑进生产后,踩坑补洞的真实复盘------6 个护栏,每一个都是事故倒逼出来的。

Agent 上线第一周,我以为最大的挑战是"它能不能把任务做对"。

三个月之后,我意识到我错了。Agent 做对任务其实没那么难。真正难的是:它做错了一件事,我怎么最快知道?谁来接管?损失能不能回滚?

这篇不是产品介绍,也不是架构展望。这是我们从第一次事故到第六个护栏落地的工程复盘。场景是我们内部的 openclaw-lab 系列 Agent:一组 24×7 在线的自主 Agent,负责内容生产、数据采集、平台操作。


事故 0:Agent 跑飞的第一晚

上线第 4 天,凌晨 2 点,我收到一封邮件------不是告警,是来自某平台的账号违规通知。

Agent 在循环里出了 bug,把同一篇文章连续发布了 11 次。平台标记账号为"异常行为",暂时限流。

后来复盘,问题出在三个地方:

  1. 没有任务去重------Agent 每次重启都从头跑,不知道自己上一轮做了什么
  2. 没有操作限速------publish 动作无上限,循环直到抛异常才停
  3. 没有人工接管入口------我能看到日志,但不能"暂停"它

这三个问题,引出了第一批护栏。


护栏 1:权限边界------Agent 不应该能做的事,系统不允许它做

这是最容易被忽视的一层。大家默认"Agent 出错最多是 API 调用失败",但实际上 Agent 可以:

  • 发邮件
  • 发平台内容
  • 修改配置
  • 调用下游 Webhook
  • 删除数据

如果 Agent 有权限做这些事,而没有任何限制,那你就是在赌它永远不出 bug。

我们的做法是引入"操作许可表"(Action Allowlist):

typescript 复制代码
// agent-permission-config.ts
export interface ActionPermission {
  action: string;          // 操作名
  dailyLimit: number;      // 每天最多执行次数,-1 = 无限制
  requiresApproval: boolean; // true = 需要人工确认才能执行
  cooldownMs: number;      // 两次执行之间的最小间隔(毫秒)
  idempotencyKey?: string; // 幂等 key 模板,防止重复执行
}

export const AGENT_PERMISSIONS: ActionPermission[] = [
  {
    action: "publish_article",
    dailyLimit: 5,
    requiresApproval: false,
    cooldownMs: 30 * 60 * 1000,  // 至少 30 分钟一篇
    idempotencyKey: "{{date}}-{{article_hash}}"
  },
  {
    action: "send_notification",
    dailyLimit: 20,
    requiresApproval: false,
    cooldownMs: 5 * 60 * 1000
  },
  {
    action: "delete_content",
    dailyLimit: 0,
    requiresApproval: true,     // 删除操作必须人工确认
    cooldownMs: 0
  },
  {
    action: "modify_config",
    dailyLimit: 3,
    requiresApproval: true,
    cooldownMs: 60 * 60 * 1000
  }
];
typescript 复制代码
// permission-gate.ts
class PermissionGate {
  private redis: Redis;

  async check(action: string, agentId: string): Promise<{
    allowed: boolean;
    reason?: string;
  }> {
    const perm = AGENT_PERMISSIONS.find(p => p.action === action);
    if (!perm) return { allowed: false, reason: "action not in allowlist" };

    // 1. 检查 daily limit
    const dailyKey = `agent:${agentId}:${action}:${today()}`;
    const count = parseInt(await this.redis.get(dailyKey) || "0");
    if (perm.dailyLimit !== -1 && count >= perm.dailyLimit) {
      return { allowed: false, reason: `daily limit ${perm.dailyLimit} reached` };
    }

    // 2. 检查 cooldown
    const cooldownKey = `agent:${agentId}:${action}:lastrun`;
    const lastRun = parseInt(await this.redis.get(cooldownKey) || "0");
    if (Date.now() - lastRun < perm.cooldownMs) {
      return { allowed: false, reason: "cooldown not expired" };
    }

    // 3. 检查是否需要人工审批
    if (perm.requiresApproval) {
      return { allowed: false, reason: "requires_human_approval" };
    }

    return { allowed: true };
  }

  async record(action: string, agentId: string) {
    const dailyKey = `agent:${agentId}:${action}:${today()}`;
    await this.redis.incr(dailyKey);
    await this.redis.expire(dailyKey, 86400);
    await this.redis.set(`agent:${agentId}:${action}:lastrun`, Date.now());
  }
}

关键细节requiresApproval: true 的操作不是"不执行",而是"进审批队列"。Agent 继续跑其他任务,等人批了再回来执行这一步。


护栏 2:任务队列------让 Agent 知道自己在做什么

Agent 重启后"失忆"是一个高频问题。没有状态持久化,Agent 就像没有短期记忆------每次唤醒都是全新的,不知道上一轮停在哪里。

我们设计了一个简单的任务队列协议:

python 复制代码
# task_queue.py
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
import json, redis, uuid

class TaskStatus(Enum):
    PENDING = "pending"
    IN_PROGRESS = "in_progress"
    COMPLETED = "completed"
    FAILED = "failed"
    REQUIRES_APPROVAL = "requires_approval"
    CANCELLED = "cancelled"

@dataclass
class AgentTask:
    id: str = field(default_factory=lambda: str(uuid.uuid4()))
    type: str = ""
    payload: dict = field(default_factory=dict)
    status: TaskStatus = TaskStatus.PENDING
    attempts: int = 0
    max_attempts: int = 3
    created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
    started_at: str | None = None
    completed_at: str | None = None
    error: str | None = None
    idempotency_key: str | None = None  # 防重复

class TaskQueue:
    def __init__(self, redis_client, agent_id: str):
        self.r = redis_client
        self.agent_id = agent_id
        self.queue_key = f"agent:{agent_id}:tasks"

    def enqueue(self, task: AgentTask) -> bool:
        # 幂等检查:同一个 idempotency_key 不入队两次
        if task.idempotency_key:
            idem_key = f"agent:{self.agent_id}:idem:{task.idempotency_key}"
            if self.r.exists(idem_key):
                return False  # 已存在,跳过
            self.r.set(idem_key, task.id, ex=86400 * 7)

        self.r.lpush(self.queue_key, json.dumps(task.__dict__))
        return True

    def claim_next(self) -> AgentTask | None:
        # BRPOPLPUSH 原子地取出并放入 in-progress 队列
        raw = self.r.brpoplpush(
            self.queue_key,
            f"{self.queue_key}:in_progress",
            timeout=5
        )
        if not raw:
            return None
        data = json.loads(raw)
        task = AgentTask(**{k: v for k, v in data.items()
                           if k in AgentTask.__dataclass_fields__})
        task.status = TaskStatus.IN_PROGRESS
        task.started_at = datetime.utcnow().isoformat()
        task.attempts += 1
        self._persist(task)
        return task

    def complete(self, task: AgentTask):
        task.status = TaskStatus.COMPLETED
        task.completed_at = datetime.utcnow().isoformat()
        self._remove_from_in_progress(task)
        self._persist(task)

    def fail(self, task: AgentTask, error: str):
        task.error = error
        if task.attempts < task.max_attempts:
            # 重试:放回主队列,指数退避
            task.status = TaskStatus.PENDING
            delay = 2 ** task.attempts * 60  # 2min, 4min, 8min
            self.r.zadd(f"{self.queue_key}:delayed",
                       {json.dumps(task.__dict__): time.time() + delay})
        else:
            task.status = TaskStatus.FAILED
            self._persist(task)
            self._notify_failure(task)
        self._remove_from_in_progress(task)

任务状态流转图:

scss 复制代码
PENDING → IN_PROGRESS → COMPLETED
                ↓
              FAILED (attempts < max)
                ↓
           [指数退避] → PENDING (retry)
                ↓
         FAILED (max attempts reached) → 告警
                ↓
    REQUIRES_APPROVAL → [等人工确认] → PENDING

护栏 3:失败恢复------不是"重试"这么简单

很多人理解的"失败恢复"就是"重试 3 次"。这只处理了最简单的情况------网络抖动、临时限流。

真实生产里的失败场景要复杂得多:

失败类型 是否可重试 正确处理方式
网络超时 ✅ 是 指数退避重试,最多 3 次
API 限流 (429) ✅ 是 读 Retry-After header,等待后重试
认证失败 (401/403) ❌ 否 立即停止,告警,等人处理
内容违规被拒 ❌ 否 标记任务为 FAILED,不重试
下游服务宕机 ⚠️ 部分 记录断点,等服务恢复后从断点续跑
Agent 进程崩溃 ✅ 是 从 in_progress 队列恢复,重建 context
数据格式错误 ❌ 否 人工审查后修复再重入队
操作已成功但未记录 ⚠️ 特殊 幂等检查,避免重复操作

最容易被忽视的是最后一条------"操作已成功但未记录"

Agent 发布了文章,但在记录到数据库之前进程崩溃了。下次启动,Agent 不知道这篇文章已经发布,会再发一次。

解决方案:Outbox Pattern

python 复制代码
# outbox.py --- 操作记录先写到 outbox,执行后确认
class Outbox:
    def __init__(self, db):
        self.db = db

    async def record_intent(self, task_id: str, action: str,
                            payload: dict) -> str:
        """先记录意图,再执行操作"""
        outbox_id = str(uuid.uuid4())
        await self.db.execute("""
            INSERT INTO agent_outbox
            (id, task_id, action, payload, status, created_at)
            VALUES (?, ?, ?, ?, 'pending', ?)
        """, outbox_id, task_id, action, json.dumps(payload),
            datetime.utcnow())
        return outbox_id

    async def confirm(self, outbox_id: str, result: dict):
        """操作成功后确认"""
        await self.db.execute("""
            UPDATE agent_outbox
            SET status='completed', result=?, completed_at=?
            WHERE id=?
        """, json.dumps(result), datetime.utcnow(), outbox_id)

    async def check_duplicate(self, action: str,
                               payload_hash: str) -> bool:
        """发起操作前检查是否已有完成记录"""
        row = await self.db.fetchone("""
            SELECT id FROM agent_outbox
            WHERE action=? AND payload_hash=? AND status='completed'
            AND created_at > datetime('now', '-7 days')
        """, action, payload_hash)
        return row is not None

# 使用
async def publish_with_outbox(task: AgentTask):
    payload_hash = hashlib.md5(
        json.dumps(task.payload, sort_keys=True).encode()
    ).hexdigest()

    # 幂等检查
    if await outbox.check_duplicate("publish_article", payload_hash):
        print(f"[skip] article already published: {payload_hash}")
        return

    # 记录意图
    outbox_id = await outbox.record_intent(
        task.id, "publish_article", task.payload
    )

    # 执行操作
    result = await platform_api.publish(task.payload)

    # 确认完成
    await outbox.confirm(outbox_id, result)

这个模式多写了约 30 行代码,但它让 Agent 重启后能安全地"接上"------而不是从头重跑并产生副作用。


护栏 4:人工接管------不是"关掉",而是"暂停然后接手"

第一次事故之后我做的最粗暴的应对是:写了一个 kill 脚本。遇到问题就 pkill -f agent-runner

这有两个问题:

  1. 杀进程会丢失 in-progress 任务的状态,下次重启可能重复执行
  2. 杀掉之后,我要手动去"接"它留下的烂摊子,不知道它做到哪一步了

我们后来设计了一套分级接管协议

bash 复制代码
# 接管指令等级

# Level 1 - 软暂停(不打断当前任务,完成后停止接收新任务)
curl -X POST http://localhost:8765/agent/pause \
  -d '{"reason": "manual inspection", "operator": "ethan"}'

# Level 2 - 检查点停止(当前任务执行到下一个检查点后暂停)
curl -X POST http://localhost:8765/agent/checkpoint-stop \
  -d '{"after_task": true, "reason": "config review"}'

# Level 3 - 立即暂停(保存当前状态后立即停止)
curl -X POST http://localhost:8765/agent/freeze \
  -d '{"save_context": true, "reason": "emergency"}'

# Level 4 - 紧急终止(不保存状态,用于安全事件)
curl -X POST http://localhost:8765/agent/terminate \
  -d '{"force": true, "reason": "security_incident"}'
python 复制代码
# agent-control-api.py
from fastapi import FastAPI
from enum import Enum

class AgentControlState(Enum):
    RUNNING = "running"
    PAUSING = "pausing"    # 等当前任务完成后暂停
    PAUSED = "paused"
    FROZEN = "frozen"      # 立即暂停,状态保存
    TERMINATED = "terminated"

class AgentController:
    def __init__(self, agent, task_queue):
        self.agent = agent
        self.queue = task_queue
        self.state = AgentControlState.RUNNING

    async def pause(self, reason: str, operator: str):
        """软暂停:完成当前任务后停止"""
        self.state = AgentControlState.PAUSING
        await self._log_takeover_event("pause", reason, operator)
        # agent 在每个任务完成后检查 state

    async def freeze(self, reason: str, operator: str):
        """立即保存 context 并停止"""
        context_snapshot = await self.agent.snapshot_context()
        await self._save_snapshot(context_snapshot)
        self.state = AgentControlState.FROZEN
        await self._log_takeover_event("freeze", reason, operator)

    async def resume(self, operator: str):
        """从 PAUSED/FROZEN 恢复"""
        if self.state == AgentControlState.FROZEN:
            await self.agent.restore_context()
        self.state = AgentControlState.RUNNING
        await self._log_takeover_event("resume", "manual resume", operator)

    async def _log_takeover_event(self, action, reason, operator):
        # 写入审计日志,不可删除
        await self.audit_log.write({
            "timestamp": datetime.utcnow().isoformat(),
            "action": action,
            "reason": reason,
            "operator": operator,
            "agent_state_before": self.state.value,
            "pending_tasks": await self.queue.count_pending()
        })

这套协议的核心思路是:接管不是对立于 Agent 运行的,而是 Agent 运行机制的一部分。Agent 代码里明确支持"被暂停"这件事,而不是靠外力强杀。


护栏 5:操作审计------出了事要能还原"Agent 做了什么"

Agent 出问题之后,最头疼的问题不是修复------而是搞清楚它做了什么

日志不够用,因为:

  • 日志记录的是"意图"("尝试发布文章"),不是"效果"("文章发到了哪个 URL")
  • 日志没有操作者追溯(谁触发了这个 Agent 任务?)
  • 日志太多,难以从中筛出"影响了外部状态"的操作

我们设计了一个独立的操作审计表,只记录"产生了外部副作用"的操作:

sql 复制代码
-- 审计表:只记录影响外部状态的操作
CREATE TABLE agent_audit_log (
  id          TEXT PRIMARY KEY,
  agent_id    TEXT NOT NULL,
  task_id     TEXT NOT NULL,
  action      TEXT NOT NULL,           -- 'publish_article', 'send_email', etc.
  target      TEXT,                    -- 操作对象(URL/ID/邮箱)
  payload_hash TEXT,                   -- 操作参数的哈希(用于幂等检查)
  result      TEXT,                    -- 操作结果(JSON)
  reversible  BOOLEAN DEFAULT FALSE,   -- 这个操作能否回滚?
  rollback_info TEXT,                  -- 如果能回滚,怎么回滚
  triggered_by TEXT,                   -- 谁触发了这个任务(user/cron/webhook)
  operator    TEXT,                    -- 如果有人工参与,是谁
  created_at  DATETIME NOT NULL,
  -- 审计日志不可删除:没有 DELETE 权限的用户只能写入
  CHECK (action IN ('publish_article', 'send_notification',
                    'modify_config', 'delete_content',
                    'external_webhook', 'platform_login'))
);

-- 查询:过去 24 小时内所有影响了外部状态的操作
SELECT action, target, result, triggered_by, created_at
FROM agent_audit_log
WHERE agent_id = 'blog-ops-agent'
  AND created_at > datetime('now', '-24 hours')
ORDER BY created_at DESC;

reversible 字段是个关键设计------它让我们在事故发生后,能快速判断哪些操作可以自动回滚:

python 复制代码
# 注册可回滚操作
REVERSIBLE_ACTIONS = {
    "publish_article": {
        "rollback": lambda result: platform_api.unpublish(result["article_url"]),
        "window_hours": 24   # 发布 24 小时内可回滚
    },
    "send_notification": {
        "rollback": None,    # 通知无法撤回
        "window_hours": 0
    }
}

async def rollback_recent(agent_id: str, hours: int = 1):
    """回滚最近 N 小时内可逆的操作"""
    recent = await db.fetchall("""
        SELECT * FROM agent_audit_log
        WHERE agent_id = ?
          AND reversible = TRUE
          AND created_at > datetime('now', ? || ' hours')
        ORDER BY created_at DESC
    """, agent_id, f"-{hours}")

    for op in recent:
        action_config = REVERSIBLE_ACTIONS.get(op["action"])
        if action_config and action_config["rollback"]:
            result = json.loads(op["result"])
            await action_config["rollback"](result)
            print(f"[rollback] {op['action']} → {op['target']}")

护栏 6:心跳与自愈------Agent 不应该默默失联

Agent 进程挂了,如果没有外部监控,你可能几个小时后才发现------因为没有任务在跑,也没有报错。

我们的心跳设计分两层:

层 1:进程心跳

python 复制代码
# heartbeat.py
import asyncio, redis, os, signal

HEARTBEAT_KEY = "agent:{agent_id}:heartbeat"
HEARTBEAT_INTERVAL = 30   # 每 30 秒写一次
HEARTBEAT_TTL = 90        # 90 秒没更新 = 认为挂了

async def heartbeat_loop(agent_id: str, r: redis.Redis):
    while True:
        await r.setex(
            HEARTBEAT_KEY.format(agent_id=agent_id),
            HEARTBEAT_TTL,
            json.dumps({
                "ts": time.time(),
                "pid": os.getpid(),
                "pending_tasks": await queue.count_pending(),
                "in_progress": await queue.count_in_progress(),
                "last_completed": await queue.last_completed_id()
            })
        )
        await asyncio.sleep(HEARTBEAT_INTERVAL)

# 监控端:检查心跳并自动重启
async def watchdog(agent_id: str):
    while True:
        hb = await r.get(HEARTBEAT_KEY.format(agent_id=agent_id))
        if hb is None:
            await alert("Agent heartbeat lost", agent_id)
            await restart_agent(agent_id)  # 自动重启
        await asyncio.sleep(60)

层 2:任务心跳(防止任务卡住而进程健在)

这个更容易被忽视。进程活着,但任务卡在某个步骤一动不动------这在进程心跳里看不出来。

python 复制代码
# 任务内部定期更新"我还活着"
async def execute_with_heartbeat(task: AgentTask, fn, timeout_minutes=30):
    task_hb_key = f"agent:task:{task.id}:heartbeat"
    deadline = time.time() + timeout_minutes * 60

    async def hb_loop():
        while True:
            await r.setex(task_hb_key, 120, time.time())
            await asyncio.sleep(30)

    hb_task = asyncio.create_task(hb_loop())
    try:
        result = await asyncio.wait_for(fn(task), timeout=timeout_minutes * 60)
        return result
    except asyncio.TimeoutError:
        await queue.fail(task, f"task timeout after {timeout_minutes}m")
        raise
    finally:
        hb_task.cancel()
        await r.delete(task_hb_key)

6 个护栏的事故覆盖率

跑了约 8 周之后,我们统计了每个护栏拦截的问题类型:

护栏 触发次数(8周) 最严重的被拦截场景
权限边界(操作许可表) 23 次 Agent bug 导致 publish 连续触发,cooldown 拦截了 19 次重复
任务队列(幂等去重) 8 次 进程重启后 3 个 in-progress 任务被安全恢复,无重复操作
失败恢复(Outbox) 5 次 网络闪断期间 publish 已成功但确认未写入,Outbox 防止了二次发布
人工接管(分级协议) 4 次 2 次 Level-1 软暂停,1 次 Level-3 freeze,1 次紧急终止
操作审计 持续 事故后能在 2 分钟内重建"Agent 做了什么"的完整时间线
心跳与自愈 6 次 4 次进程崩溃后自动重启(恢复时间 < 90 秒),2 次任务超时告警

有个数字比较反直觉:进程崩溃比任务卡住更容易被发现。进程崩溃 90 秒内心跳失效,监控立刻告警。但任务卡住而进程活着,如果没有任务级心跳,可能 30 分钟后才发现。所以层 2 心跳实际上比层 1 更重要。


什么时候该加护栏,什么时候不必

这些护栏不是零成本的------每加一个护栏,就多了一层需要维护的基础设施。

我们的判断框架:

必须要有(任何线上 Agent):

  • 操作许可表(最低成本,防最大的坑)
  • 任务幂等 key(防重复是基础要求)
  • 进程级心跳(不要让 Agent 默默挂着)

建议有(长跑 / 高频操作的 Agent):

  • Outbox Pattern(只在操作有外部副作用时需要)
  • 分级接管接口(操作越不可逆,越要有 Level 3+)
  • 操作审计日志(只记录副作用,不记录内部状态)

可以不要(简单脚本式 Agent):

  • 任务级心跳(任务完成时间 < 5 分钟的可以不加)
  • 断点续跑(如果重跑无副作用,直接 re-run 更简单)

一个没人告诉你的发现

Agent 运维最大的隐患不是技术问题,是责任真空

我们最初没有明确"Agent 这个小时内做了什么事,谁负责"。结果就是:出了问题,没人第一时间去看 Agent 的操作日志,因为大家都以为别人在看。

最后我们做的最有效的一件事是:为每个 Agent 指定一个"值班人",每天上班后花 5 分钟看 Agent 审计日志的摘要。

这不是监控 Agent,而是把 Agent 的行为纳入正常工程流程------就像看 CI/CD 日志一样自然。


常见问题

Q: 任务队列用 Redis 还是数据库? A: 都行,但有明显分工。Redis BRPOPLPUSH 适合高频、低延迟的任务调度(毫秒级取任务);SQLite/Postgres 适合需要持久化审计和复杂查询的任务状态存储。我们的做法是:Redis 做调度,DB 做审计,两者结合。

Q: Agent 的操作回滚窗口该设多长? A: 取决于操作的可见性。"发布文章"这类公开操作,回滚窗口越短越好(我们设 24 小时)------因为公开的时间越长,被人看到、分享、收藏的可能性越大,强行撤回反而造成更多困扰。内部操作(修改配置、发通知)回滚窗口可以更长,但超过 72 小时的基本没有实操意义。

Q: 人工接管之后,怎么把任务"还给" Agent? A: 接管后所有任务进入 PAUSED 状态。人工处理完之后,调用 /agent/resume,Agent 从 PAUSED 队列里继续取任务,不会重跑已完成的。关键是接管前要看清楚 in-progress 任务是否已完成------如果操作已在平台生效,记得手动 complete() 这个任务,否则 resume 之后 Agent 会重试。

相关推荐
武子康4 小时前
调查研究-169 开源 TTS 模型横向对比:从“能发声“到“可部署的语音智能基础设施“(2026 版)
人工智能·openai
大数据在线13 小时前
布局Agentic AI,亚马逊云科技组合拳再升级
人工智能·openai·亚马逊云科技·智能体·agentic ai
Aqoo16 小时前
为什么我把专利交底书做成 Skill
openai·agent
武子康1 天前
调查研究-168 MiroFish 本地化部署分析:主仓库、Zep Cloud、离线 Fork 与真正可控的多智能体沙盘
人工智能·aigc·openai
七牛开发者1 天前
Skills 是什么?Claude 官方教你做一个好用的 Skill
aigc·openai·claude
七牛开发者1 天前
AI Agent 的 4 个工程关键词:Prompt、Context、Loop、Harness 到底是什么?
aigc·openai·agent
猫头_1 天前
跨 AI IDE 的协作痛点:用了五六个 AI 编辑器,提示词(Skills)该怎么统一管理?
openai·ai编程·cursor
Nturmoils1 天前
把 GitNexus 接进 Codex:安装、索引、Web UI 和项目分析实操
openai·claude
米小虾2 天前
"Chat is dead":OpenAI 正在杀死的不是聊天,是整个 AI 交互范式
人工智能·openai