让 Agent 长期在线不难,难的是出错后谁负责、怎么止损。这篇是我们把自主 Agent 跑进生产后,踩坑补洞的真实复盘------6 个护栏,每一个都是事故倒逼出来的。
Agent 上线第一周,我以为最大的挑战是"它能不能把任务做对"。
三个月之后,我意识到我错了。Agent 做对任务其实没那么难。真正难的是:它做错了一件事,我怎么最快知道?谁来接管?损失能不能回滚?
这篇不是产品介绍,也不是架构展望。这是我们从第一次事故到第六个护栏落地的工程复盘。场景是我们内部的 openclaw-lab 系列 Agent:一组 24×7 在线的自主 Agent,负责内容生产、数据采集、平台操作。
事故 0:Agent 跑飞的第一晚
上线第 4 天,凌晨 2 点,我收到一封邮件------不是告警,是来自某平台的账号违规通知。
Agent 在循环里出了 bug,把同一篇文章连续发布了 11 次。平台标记账号为"异常行为",暂时限流。
后来复盘,问题出在三个地方:
- 没有任务去重------Agent 每次重启都从头跑,不知道自己上一轮做了什么
- 没有操作限速------publish 动作无上限,循环直到抛异常才停
- 没有人工接管入口------我能看到日志,但不能"暂停"它
这三个问题,引出了第一批护栏。
护栏 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。
这有两个问题:
- 杀进程会丢失 in-progress 任务的状态,下次重启可能重复执行
- 杀掉之后,我要手动去"接"它留下的烂摊子,不知道它做到哪一步了
我们后来设计了一套分级接管协议:
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 会重试。