三个月前,有支AI Agent在凌晨2点挂了。

它的任务听起来挺简单:每天抓数据、生成报告、再推给下游系统。但挂了就是真挂了——没有报错、没有告警,下游系统只是安安静静地停止接收新数据。直到第二天早上有人问“昨天的报告呢”,才发现出事了。
当时所谓的“监控”是什么?就是每过一小时手动跑一遍 ps aux | grep agent。
回过头来看,这其实是犯了一个根本性的错误:错把“进程活着”当成了“Agent正常运行”。
为什么Agent的可观察性比普通服务更难
普通服务要是挂了,看HTTP返回的5xx状态码就一目了然。但Agent的行为模式要复杂得多:
- 它可以「活着但卡住」——进程还在,但LLM调用被限速重试卡了三个小时,半点进度都没推进
- 它可以「活着但走错路」——任务确实在执行,但每一步都在做错误决策,直到资源耗尽了才崩溃
- 它可以「静默失败」——工具调用返回了空数组,Agent判断“没有数据”,然后正常退出,可实际上是查询条件写错了
传统的“进程是否存活”检测,对这三种情况几乎全部失效。要真正掌握Agent的健康状态,需要的是语义级别的健康检测。
第一层:心跳 ≠ 进程探活
拿OpenClaw跑Agent的实践来说,它自带heartbeat机制,但最初方向就配错了:
// 错误配置:只检测进程{"heartbeat": {"interval": "30m","check": "process"}}
进程活着真不等于Agent在干活。正确做法是让Agent主动写入心跳时间戳:
// agent/main.js — 每完成一个工作单元就更新async function processTask(task) {
await updateHeartbeat({
task_id: task.id,
step: 'started',
timestamp: Date.now()
});
const result = await llm.call(task.prompt);
await updateHeartbeat({
task_id: task.id,
step: 'llm_done',
tokens_used: result.usage.total_tokens,
timestamp: Date.now()
});
// ... 后续步骤
}
然后需要一个独立的watchdog进程来检查心跳是否超时:
// watchdog.js
async function checkHeartbeat() {
const lastBeat = await db.get('agent:heartbeat:last');
const age = Date.now() - lastBeat.timestamp;
if (age > 10 * 60 * 1000) { // 10 分钟没心跳
await alert.send(`Agent 疑似卡死,上次心跳 ${Math.round(age/60000)} 分钟前,步骤:${lastBeat.step}`);
}
}
这里的关键点是:watchdog必须是独立的进程,不能和Agent挤在同一个进程里——不然Agent一崩,watchdog也跟着没了。
第二层:状态快照与检查点
Agent执行到一半挂了,这种情况最难处理:重启后不知道跑到哪一步了,从头跑可能会重复操作,不跑又会导致数据丢失。
现在的做法是在每个“不可逆操作”前都写入检查点:
class AgentCheckpoint {
constructor(runId, storage) {
this.runId = runId;
this.storage = storage; // Redis / 本地 SQLite 均可
}
async sa ve(step, state) {
await this.storage.set(`checkpoint:${this.runId}:${step}`, {step, state, sa ved_at: Date.now()});
console.log(`[checkpoint] sa ved step=${step}`);
}
async load(step) {
return this.storage.get(`checkpoint:${this.runId}:${step}`);
}
async hasCompleted(step) {
const cp = await this.load(step);
return cp !== null;
}
}
// 使用
async function runPipeline(runId) {
const cp = new AgentCheckpoint(runId, redis);
// 步骤 1:拉数据(幂等,可重跑)
let rawData;
if (await cp.hasCompleted('fetch')) {
rawData = (await cp.load('fetch')).state.data;
console.log('[resume] skipping fetch, loaded from checkpoint');
} else {
rawData = await fetchData();
await cp.sa ve('fetch', { data: rawData });
}
// 步骤 2:LLM 处理(有成本,不可随意重跑)
let analysis;
if (await cp.hasCompleted('analyze')) {
analysis = (await cp.load('analyze')).state.result;
} else {
analysis = await llm.analyze(rawData);
await cp.sa ve('analyze', { result: analysis });
}
// 步骤 3:写入下游(只跑一次)
if (!await cp.hasCompleted('push')) {
await pushToDownstream(analysis);
await cp.sa ve('push', { pushed_at: Date.now() });
}
}
这段代码的核心价值在于:重启后能从上次成功的步骤继续执行,既不会重复调用LLM,也不会重复向下游写入数据。
第三层:语义健康检查
心跳功能只告诉你Agent还在跑,但回答不了“跑得对不对”这个问题。为此,每5分钟跑一次“语义探针”会很有帮助:
async function semanticHealthCheck(agent) {
// 发一个有已知答案的探针问题
const PROBE = {
input: "2+2等于多少?",
expected_pattern: /4/
};
const start = Date.now();
const result = await agent.run(PROBE.input, { timeout: 30_000 });
const latency = Date.now() - start;
const metrics = {
latency_ms: latency,
responded: result !== null,
correct: PROBE.expected_pattern.test(result?.output || ''),
timestamp: Date.now()
};
await metrics.record('agent.health', metrics);
if (!metrics.correct) {
await alert.critical(`语义健康检查失败:探针问题回答异常,latency=${latency}ms`);
}
if (latency > 20_000) {
await alert.warn(`Agent 响应过慢:${latency}ms`);
}
return metrics;
}
在生产环境中,探针问题可以设计得更复杂——比如“处理一条测试数据,然后验证输出格式是否正确”。核心思路清晰:有输入、有期望输出、机器能自动判断对错。
第四层:故障恢复自动化
前三层解决的是“发现问题”,那发现之后呢?
过去的流程是:收到告警 → 手动SSH → 查日志 → 重启。凌晨3点碰到这种情况,基本不现实。
现在把恢复动作直接写成代码:
class AgentSupervisor {
constructor(agentFactory, options = {}) {
this.agentFactory = agentFactory;
this.maxRestarts = options.maxRestarts ?? 3;
this.restartWindow = options.restartWindow ?? 3600_000; // 1h 内最多 N 次
this.restartHistory = [];
this.agent = null;
}
async start(task) {
this.agent = await this.agentFactory();
try {
return await this.agent.run(task);
} catch (err) {
return this.handleFailure(err, task);
}
}
async handleFailure(err, task) {
const now = Date.now();
this.restartHistory = this.restartHistory.filter(t => now - t < this.restartWindow);
if (this.restartHistory.length >= this.maxRestarts) {
// 超过重启次数上限,人工介入
await alert.critical(`Agent 在 ${this.restartWindow/60000} 分钟内重启了 ${this.maxRestarts} 次,停止自动恢复,等待人工处理`, { error: err.message, last_checkpoint: await this.getLastCheckpoint() });
throw err;
}
this.restartHistory.push(now);
const delay = Math.min(1000 * 2 ** this.restartHistory.length, 60_000);
await alert.warn(`Agent 崩溃,${delay/1000}s 后自动重启(第 ${this.restartHistory.length} 次)`, { error: err.message });
await sleep(delay);
// 重启并从检查点恢复
this.agent = await this.agentFactory();
return this.agent.resumeFrom(task, await this.getLastCheckpoint());
}
}
一定要设硬上限。自动恢复功能很实用,但无限重启只会掩盖真正的bug,还会白白烧钱——毕竟每次LLM调用都有成本。
现在的监控架构
踩了这三个月的坑,最终打磨出来的Agent监控框架是这样的:
┌─────────────────────────────────────────┐
│ Agent 主进程 │
│ ┌─────────┐ ┌──────────┐ ┌────────┐ │
│ │ 心跳写入 │ │ 检查点存储│ │ 指标上报│ │
│ └────┬────┘ └────┬─────┘ └───┬────┘ │
└───────┼─────────────┼────────────┼───────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Redis │ │ SQLite │ │ InfluxDB│
└────┬────┘ └─────────┘ └────┬────┘
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│Watchdog │ │ Grafana │
│(独立进程)│ │(告警规则)│
└────┬────┘ └────┬────┘
│ │
└──────────┬────────────┘
▼
┌──────────┐
│ 告警通知 │
│(TG/邮件) │
└──────────┘
四层加在一起,从“凌晨挂了到第二天早上才发现”变成了“5分钟内自动告警、30分钟内自动恢复或人工接管”。
踩坑总结
- 别用进程活着当健康指标——要用语义心跳
- watchdog必须独立于Agent进程——否则Agent一崩,什么都等于零
- 每个不可逆操作前都要存检查点——幂等重跑的成本比重来一遍低太多
- 自动恢复必须设上限——无限重启等于无限烧钱,还会把真实问题彻底掩盖
- 语义探针比日志更早发现问题——日志记录的是“发生了什么”,而探针检测的是“能不能正常工作”
如果你的Agent目前也只有“进程监控”这一层,上面的代码可以直接参考使用。
