三个月前,我的 AI Agent 在凌晨 2 点挂了。
它负责每天抓取数据、生成报告、推送给下游系统。挂了之后什么都没发生——没有报错,没有告警,下游系统只是静静地不再收到数据。直到第二天早上用户问"昨天的报告怎么没出来",我才发现。
当时的状态监控就是:每小时ps aux | grep agent。
这是我犯的第一个根本性错误:把"进程存活"当成"Agent 正常运行"。
为什么 Agent 的可观察性比普通服务更难
普通服务挂了,你看 HTTP 5xx 就知道了。Agent 不一样:
- 它可以是「活着但卡住」:进程在跑,但 LLM 调用卡在 rate limit retry 里,三小时没有实质进展
- 它可以是「活着但走错路」:任务执行了,但每一步都在做错误决策,直到资源耗尽才崩
- 它可以是「静默失败」:工具调用返回空数组,Agent 认为"没有数据",正常退出,但实际上是查询条件写错了
传统的"进程是否存活"检测对这三种情况全部失效。你需要的是语义级别的健康检测。
第一层:心跳 ≠ 进程探活
我用 OpenClaw 跑 Agent,它有内置的 heartbeat 机制。但我最初配错了方向:
// 错误配置:只检测进程{"heartbeat":{"interval":"30m","check":"process"}}进程活着 ≠ Agent 在干活。正确的做法是让 Agent主动写入心跳时间戳:
// agent/main.js — 每完成一个工作单元就更新asyncfunctionprocessTask(task){awaitupdateHeartbeat({task_id:task.id,step:'started',timestamp:Date.now()});constresult=awaitllm.call(task.prompt);awaitupdateHeartbeat({task_id:task.id,step:'llm_done',tokens_used:result.usage.total_tokens,timestamp:Date.now()});// ... 后续步骤}然后有个独立的 watchdog 进程检查心跳是否超时:
// watchdog.jsasyncfunctioncheckHeartbeat(){constlastBeat=awaitdb.get('agent:heartbeat:last');constage=Date.now()-lastBeat.timestamp;if(age>10*60*1000){// 10 分钟没心跳awaitalert.send(`Agent 疑似卡死,上次心跳${Math.round(age/60000)}分钟前,步骤:${lastBeat.step}`);}}关键点:watchdog 必须是独立进程,不能跟 Agent 在同一个进程里——否则 Agent 崩了 watchdog 也跟着没了。
第二层:状态快照与检查点
Agent 执行到一半挂了最难处理:重启后不知道跑到哪里了,从头跑可能重复操作,不跑又丢数据。
我现在的做法是每个"不可逆操作"前都写检查点:
classAgentCheckpoint{constructor(runId,storage){this.runId=runId;this.storage=storage;// Redis / 本地 SQLite 均可}asyncsave(step,state){awaitthis.storage.set(`checkpoint:${this.runId}:${step}`,{step,state,saved_at:Date.now()});console.log(`[checkpoint] saved step=${step}`);}asyncload(step){returnthis.storage.get(`checkpoint:${this.runId}:${step}`);}asynchasCompleted(step){constcp=awaitthis.load(step);returncp!==null;}}// 使用asyncfunctionrunPipeline(runId){constcp=newAgentCheckpoint(runId,redis);// 步骤 1:拉数据(幂等,可重跑)letrawData;if(awaitcp.hasCompleted('fetch')){rawData=(awaitcp.load('fetch')).state.data;console.log('[resume] skipping fetch, loaded from checkpoint');}else{rawData=awaitfetchData();awaitcp.save('fetch',{data:rawData});}// 步骤 2:LLM 处理(有成本,不可随意重跑)letanalysis;if(awaitcp.hasCompleted('analyze')){analysis=(awaitcp.load('analyze')).state.result;}else{analysis=awaitllm.analyze(rawData);awaitcp.save('analyze',{result:analysis});}// 步骤 3:写入下游(只跑一次)if(!awaitcp.hasCompleted('push')){awaitpushToDownstream(analysis);awaitcp.save('push',{pushed_at:Date.now()});}}这段代码做到了:重启后从上次成功的步骤继续,不重复 LLM 调用,不重复写入下游。
第三层:语义健康检查
心跳告诉你 Agent 在跑,但不告诉你跑得对不对。我加了一个每 5 分钟跑一次的"语义探针":
asyncfunctionsemanticHealthCheck(agent){// 发一个有已知答案的探针问题constPROBE={input:"2+2等于多少?",expected_pattern:/4/};conststart=Date.now();constresult=awaitagent.run(PROBE.input,{timeout:30_000});constlatency=Date.now()-start;constmetrics={latency_ms:latency,responded:result!==null,correct:PROBE.expected_pattern.test(result?.output||''),timestamp:Date.now()};awaitmetrics.record('agent.health',metrics);if(!metrics.correct){awaitalert.critical(`语义健康检查失败:探针问题回答异常,latency=${latency}ms`);}if(latency>20_000){awaitalert.warn(`Agent 响应过慢:${latency}ms`);}returnmetrics;}真实生产中,探针问题可以更复杂——比如"处理一条测试数据,验证输出格式正确"。核心是:有输入、有期望输出、机器可判断对错。
第四层:故障恢复自动化
上面三层都是"发现问题"。发现之后呢?
我之前的流程是:收到告警 → 手动 SSH → 查日志 → 重启。这在凌晨 3 点不现实。
现在的做法是把恢复动作编成代码:
classAgentSupervisor{constructor(agentFactory,options={}){this.agentFactory=agentFactory;this.maxRestarts=options.maxRestarts??3;this.restartWindow=options.restartWindow??3600_000;// 1h 内最多 N 次this.restartHistory=[];this.agent=null;}asyncstart(task){this.agent=awaitthis.agentFactory();try{returnawaitthis.agent.run(task);}catch(err){returnthis.handleFailure(err,task);}}asynchandleFailure(err,task){constnow=Date.now();this.restartHistory=this.restartHistory.filter(t=>now-t<this.restartWindow);if(this.restartHistory.length>=this.maxRestarts){// 超过重启次数上限,人工介入awaitalert.critical(`Agent 在${this.restartWindow/60000}分钟内重启了${this.maxRestarts}次,停止自动恢复,等待人工处理`,{error:err.message,last_checkpoint:awaitthis.getLastCheckpoint()});throwerr;}this.restartHistory.push(now);constdelay=Math.min(1000*2**this.restartHistory.length,60_000);awaitalert.warn(`Agent 崩溃,${delay/1000}s 后自动重启(第${this.restartHistory.length}次)`,{error:err.message});awaitsleep(delay);// 重启并从检查点恢复this.agent=awaitthis.agentFactory();returnthis.agent.resumeFrom(task,awaitthis.getLastCheckpoint());}}重点:设硬上限。自动恢复很好,但无限重启会掩盖真正的 bug,还会烧钱(LLM 调用是有成本的)。
现在的监控架构
三个月踩坑下来,我的 Agent 监控长这样:
┌─────────────────────────────────────────┐ │ Agent 主进程 │ │ ┌─────────┐ ┌──────────┐ ┌────────┐ │ │ │ 心跳写入 │ │ 检查点存储│ │ 指标上报│ │ │ └────┬────┘ └────┬─────┘ └───┬────┘ │ └───────┼─────────────┼────────────┼───────┘ │ │ │ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ Redis │ │ SQLite │ │ InfluxDB│ └────┬────┘ └─────────┘ └────┬────┘ │ │ ▼ ▼ ┌─────────┐ ┌─────────┐ │Watchdog │ │ Grafana │ │(独立进程)│ │(告警规则)│ └────┬────┘ └────┬────┘ │ │ └──────────┬──────────────┘ ▼ ┌──────────┐ │ 告警通知 │ │(TG/邮件) │ └──────────┘四层加一起,从"发现凌晨挂机要到早上"变成了"5 分钟内自动告警、30 分钟内自动恢复或人工接管"。
踩坑总结
- 不要用进程活着当健康指标——用语义心跳
- watchdog 必须独立于 Agent 进程——否则 Agent 崩了什么都不知道
- 每个不可逆操作前存检查点——幂等重跑比重来成本低很多
- 自动恢复要设上限——无限重启=无限烧钱,而且掩盖真实问题
- 语义探针比日志更早发现问题——日志记录的是发生了什么,探针检测的是能不能正常工作
如果你的 Agent 现在也只有"进程监控",这篇文章里的代码可以直接拿去用。有问题欢迎评论区交流。