当前位置: 首页 > news >正文

Agent 在凌晨3 点崩了:我是18 小时后才知道的

这篇讲的是真实事故复盘一个每天处理上百条内容的自主 Agent崩了整整 18 小时期间没有任何告警没有任何日志告诉我它挂了。事后我花了两周重建了整套监控体系把血泪踩坑总结在这里。去年 10 月我在跑一个自动化内容分析 Agent——每隔几分钟拉一批数据、调 LLM 分析、写回数据库。它跑了好几周没出过事。某天早上打开 dashboard发现入库数量在凌晨 3 点 17 分突然归零到第二天下午 9 点才有人是我意识到出了问题。中间 18 小时Agent 完全沉默。没有报警、没有告警、没有任何迹象。检查日志发现是一次数据库连接超时触发了未捕获的异常进程 crash 了。而我的监控实际上只有一行# 当时的监控代码importlogging logging.basicConfig(levellogging.INFO)logger.info(任务完成)进程不在了日志自然也不会再写了。这是个非常经典的盲区——你只能监控到活着的进程死了的进程什么都不会说。一、Agent 监控为什么和普通服务不一样先说清楚这个问题。普通 Web 服务挂了很容易感知请求超时、状态码 5xx、负载均衡健康检查失败——外部有人在问它它不应答你就知道了。自主 Agent 不一样。它是主动型的——自己按计划做事没有外部触发。你没有办法通过发请求看响应来判断它还活着因为它根本不接请求。所以传统的 uptime 监控对 Agent 几乎失效。你需要一套反过来的机制让 Agent 主动证明自己还在工作。这就是心跳检测Heartbeat的核心思路也是我后来花精力最多的一块。另一个坑是状态孤岛。Agent 在内存里维护的工作进度进程一死就全没了。如果没有持久化重启后要么从头跑可能重复处理、要么完全不知道从哪里继续。三类典型 Agent 故障我整理了自己踩过的故障模式故障类型触发原因常见误判真正需要的检测方式进程 crash未捕获异常、OOM、信号终止以为 Agent 在休眠进程存活检查 心跳超时静默挂起网络等待死锁、LLM 请求无限期 pending日志显示正常但什么都没干任务进度时间戳监控逻辑死循环重试逻辑 bug、状态机卡死CPU 高看起来在运行任务完成计数器 速率监控数据污染LLM 返回不符合预期格式、写库 silent fail表面正常数据悄悄出错输出数据质量校验这四类我全踩过前三类都发生在 Agent 层最后一类发生在数据层。二、心跳机制的工程实现心跳的思路很简单Agent 定期签到如果一段时间没签到就报警。难点在于实现细节。我走了不少弯路。2.1 第一版写文件时间戳失败最初我让 Agent 每分钟 touch 一个文件用文件 mtime 判断存活# 第一版写文件心跳不推荐importtimefrompathlibimportPath HEARTBEAT_FILEPath(/tmp/agent-heartbeat)defheartbeat():HEARTBEAT_FILE.touch()# 检查脚本cron 每 5 分钟跑defcheck_alive():ifnotHEARTBEAT_FILE.exists():alert(Agent 心跳文件不存在)returnagetime.time()-HEARTBEAT_FILE.stat().st_mtimeifage300:# 5 分钟没更新alert(fAgent 心跳超时{age:.0f}s)看起来没问题实际上坑很多文件系统不是原子的NFS / tmpfs / 容器 overlay 各种场景下 mtime 更新不可靠检查进程本身可能挂cron 任务如果因为什么原因跳过就会误报无法区分忙和挂Agent 在处理大任务时可能几分钟不回来写文件导致误报2.2 第二版HTTP 心跳端点改进但有开销让 Agent 内置一个小 HTTP server外部定期 poll# Agent 内嵌心跳 serverimportthreadingimporttimefromhttp.serverimportHTTPServer,BaseHTTPRequestHandlerclassHeartbeatHandler(BaseHTTPRequestHandler):defdo_GET(self):ifself.path/healthz:statusagent.get_status()# 拿 Agent 自己的状态payload{alive:True,last_task_at:status[last_task_ts],tasks_completed:status[total_tasks],current_task:status[current_task_id],queue_depth:status[queue_len],}self.send_response(200)self.send_header(Content-Type,application/json)self.end_headers()self.wfile.write(json.dumps(payload).encode())else:self.send_response(404)self.end_headers()deflog_message(self,format,*args):pass# 静默不要污染 Agent 日志defstart_heartbeat_server(port18888):serverHTTPServer((0.0.0.0,port),HeartbeatHandler)threadthreading.Thread(targetserver.serve_forever,daemonTrue)thread.start()returnserver这版比文件心跳好但有个根本问题HTTP server 线程活着不代表主逻辑在工作。我遇到过主循环卡死等 LLM 响应但 heartbeat server 还在愉快地响应 200 OK 的情况。2.3 第三版进度时间戳心跳目前在用关键洞察要监控的不是进程存活而是任务进度。importtimeimportjsonimportredisclassAgentHeartbeat: 基于任务进度的心跳只有真正完成了工作才算活着 def__init__(self,agent_id:str,redis_client:redis.Redis):self.agent_idagent_id self.redisredis_client self.keyfagent:heartbeat:{agent_id}self.ttl300# 5 分钟没更新 死亡defbeat(self,task_id:str,tasks_done:int,queue_depth:int):完成一个任务后调用不是按时间调用payload{ts:time.time(),task_id:task_id,tasks_done:tasks_done,queue_depth:queue_depth,}# 原子操作set expireself.redis.setex(self.key,self.ttl,json.dumps(payload))defis_alive(self)-tuple[bool,dict|None]:外部检查调用rawself.redis.get(self.key)ifrawisNone:returnFalse,Nonedatajson.loads(raw)agetime.time()-data[ts]returnageself.ttl,data# Agent 主循环使用方式heartbeatAgentHeartbeat(content-analyzer-prod,redis_client)fortaskintask_queue:resultprocess_task(task)# 真正的工作save_result(result)# 只有任务完成后才打心跳——如果卡在 process_task 里不出来心跳就超时heartbeat.beat(task_idtask.id,tasks_donecounter.increment(),queue_depthtask_queue.qsize(),)这个设计有个微妙但重要的点心跳是在任务完成之后打而不是按固定时间间隔打。这样如果 Agent 卡在某个任务里比如 LLM 请求超时 60 秒心跳就会自然超时触发告警。不过这也带来一个新问题任务处理时间如果本来就很长超过 TTL会误报。解法是把 TTL 设为正常最大处理时间的 3 倍根据实际任务耗时 p99 来定。三、状态持久化进程死了也不怕进程 crash 后怎么让 Agent 重启后继续工作而不是从头开始或者乱做一通这是状态持久化要解决的问题。3.1 最小状态机设计先搞清楚 Agent 到底有哪些状态需要保存。我用的是一个最小状态集fromdataclassesimportdataclass,asdictfromtypingimportLiteralimporttimedataclassclassAgentCheckpoint:Agent 运行检查点——进程重启后从这里恢复agent_id:str# 进度信息last_processed_id:str# 上次处理到哪里了tasks_completed:int# 总计完成任务数# 当前任务状态用于幂等重试current_task_id:str|None# 正在处理的任务 IDcurrent_task_started_at:float|None# 任务开始时间# 元信息checkpoint_at:float0.0version:int1# schema 版本方便升级defsave(self,redis_client):原子写入 Redis同时备份到文件dataasdict(self)data[checkpoint_at]time.time()keyfagent:checkpoint:{self.agent_id}redis_client.set(key,json.dumps(data))# 双写文件备份Redis 挂了还能从文件恢复backup_pathPath(f/var/agent-state/{self.agent_id}.json)backup_path.parent.mkdir(parentsTrue,exist_okTrue)backup_path.write_text(json.dumps(data,indent2))classmethoddefload(cls,agent_id:str,redis_client)-AgentCheckpoint | None:启动时加载检查点keyfagent:checkpoint:{agent_id}rawredis_client.get(key)ifraw:returncls(**json.loads(raw))# Redis 里没有尝试文件备份backup_pathPath(f/var/agent-state/{agent_id}.json)ifbackup_path.exists():returncls(**json.loads(backup_path.read_text()))returnNone# 全新启动3.2 幂等任务处理有了检查点还需要确保任务可以安全重复执行。Crash 时可能任务已经执行到一半——重启后要能重试而不产生副作用。classIdempotentTaskRunner: 保证每个任务 ID 最多被处理一次 用 Redis SET NX 做分布式互斥 def__init__(self,redis_client:redis.Redis):self.redisredis_clientdefrun_once(self,task_id:str,fn,*args,**kwargs):只运行一次——幂等保证done_keyftask:done:{task_id}lock_keyftask:lock:{task_id}# 如果已经完成跳过ifself.redis.exists(done_key):return{skipped:True,task_id:task_id}# 抢锁60 秒超时防止死锁acquiredself.redis.set(lock_key,1,nxTrue,ex60)ifnotacquired:raiseRuntimeError(fTask{task_id}正在被其他进程处理)try:resultfn(*args,**kwargs)# 成功后标记完成保留 24 小时记录self.redis.setex(done_key,86400,json.dumps({done_at:time.time()}))returnresultfinally:self.redis.delete(lock_key)3.3 重启恢复逻辑把上面两块拼在一起的主循环defrun_agent(agent_id:str):redis_clientredis.Redis(...)heartbeatAgentHeartbeat(agent_id,redis_client)runnerIdempotentTaskRunner(redis_client)# 1. 加载检查点如果有checkpointAgentCheckpoint.load(agent_id,redis_client)ifcheckpoint:start_fromcheckpoint.last_processed_id logger.info(f从检查点恢复上次处理到{start_from}已完成{checkpoint.tasks_completed}个任务)else:start_fromNonelogger.info(全新启动)# 2. 主循环countercheckpoint.tasks_completedifcheckpointelse0fortaskinfetch_tasks(after_idstart_from):# 幂等处理resultrunner.run_once(task.id,process_task,task)ifnotresult.get(skipped):save_result(result)counter1# 更新检查点cpAgentCheckpoint(agent_idagent_id,last_processed_idtask.id,tasks_completedcounter,current_task_idNone,current_task_started_atNone,)cp.save(redis_client)# 打心跳heartbeat.beat(task.id,counter,task_queue.qsize())四、告警知道挂了还不够要知道挂在哪心跳超时只是告诉你出问题了但不告诉你出了什么问题。我最后搭的告警体系分三层第一层存活告警心跳超时触发条件Redis key 过期响应 SLA5 分钟告警渠道飞书机器人高优先级第二层进度告警任务完成速率异常触发条件1 小时内任务完成数 历史均值的 20%响应 SLA30 分钟这个能发现活着但没干活的情况第三层数据质量告警输出异常触发条件入库数据字段缺失率 5%或字段值分布异常响应 SLA1 小时实现用的是一个简单的 cron 脚本每 2 分钟跑# monitor.py — 放进 cron 每 2 分钟跑defcheck_agents():agentsget_registered_agents()# 从配置读取期望运行的 agent 列表foragent_idinagents:hbAgentHeartbeat(agent_id,redis_client)alive,datahb.is_alive()ifnotalive:send_alert(levelcritical,titlef[Agent Down]{agent_id},bodyf心跳超时最后心跳{data[ts]ifdataelse无记录})continue# 检查进度速率rateget_completion_rate(agent_id,window_minutes60)baselineget_baseline_rate(agent_id)ifratebaseline*0.2:send_alert(levelwarning,titlef[Agent Slow]{agent_id},bodyf任务速率{rate:.1f}/h基线{baseline:.1f}/h仅{rate/baseline:.0%})五、实战效果上面这套体系跑了大约 4 个月收集到了一些数据从运维日志统计指标引入前3 个月平均引入后4 个月平均平均故障发现时间 MTTD约 11 小时约 6 分钟平均恢复时间 MTTR约 4 小时手动重建状态约 8 分钟自动重启 检查点恢复因故障导致的任务丢失率约 12%估算0.3%可追踪误报率—约 4%主要是任务本来就耗时长导致的说一下 4% 误报率怎么来的——有几类任务本身处理时间就可能超过 10 分钟调 LLM 做长文分析这些任务会触发心跳超时误报。解法是给不同任务类型配不同的 TTL# 不同任务类型用不同 TTLTASK_TTLS{quick_classify:60,# 快速分类1 分钟超时long_analysis:900,# 长文分析15 分钟超时batch_embed:300,# 批量 embedding5 分钟超时}自动重启是用 systemd 做的# /etc/systemd/system/content-agent.service [Service] Restarton-failure RestartSec10 StartLimitInterval60 StartLimitBurst3加上状态持久化之后重启后 Agent 会从检查点继续——用户几乎感知不到有过 crash。六、一个容易忽视的坑检查点本身可能被污染踩了这个坑才加的如果 Agent 在处理过程中遇到格式错误的数据可能把 current_task_id 更新了但任务实际上没有正确完成。重启后从这个检查点恢复会跳过这个坏任务然后一路跑下去——表面上恢复了实际数据有洞。解法给检查点加版本和完整性校验importhashlibdataclassclassAgentCheckpoint:# ...之前的字段...checksum:strdefcompute_checksum(self)-str:计算关键字段的校验和payloadf{self.agent_id}:{self.last_processed_id}:{self.tasks_completed}returnhashlib.sha256(payload.encode()).hexdigest()[:16]defsave(self,redis_client):self.checksumself.compute_checksum()# ...之前的保存逻辑...classmethoddefload(cls,agent_id,redis_client)-AgentCheckpoint | None:# ...加载逻辑...cpcls(**json.loads(raw))# 校验完整性expectedcp.compute_checksum()ifcp.checksum!expected:logger.error(f检查点校验失败expected{expected}, got{cp.checksum})logger.error(丢弃损坏的检查点从头开始)returnNonereturncp常见问题QAgent 的心跳和普通 Web 服务的健康检查有什么本质区别AWeb 服务健康检查是被动响应——你问它它回答超时就是挂了。Agent 心跳是主动上报——它自己定期汇报进度超时才说明出问题了。更关键的区别在于好的 Agent 心跳应该反映任务进度而不是进程存活——进程活着但卡在某个 LLM 调用上无法前进对用户来说和挂了没区别。QRedis 本身挂了怎么办检查点和心跳都依赖 RedisA所以要双写。检查点同时写文件系统本文第 3.1 节代码里有心跳可以加一个 fallback——Redis 不可达时退化为写本地文件监控脚本同时检查两处。Redis 单点故障是个真实风险如果 Agent 是生产级的用 Redis Sentinel 或 Redis Cluster。Q这套方案适用于多 Agent 并行场景吗A基本适用但有两处要改一是每个 Agent 实例需要唯一 ID加 hostname 或 pod 名后缀二是幂等锁第 3.2 节的run_once可以天然支持多实例抢锁只需要确保 task_id 唯一。真正复杂的多 Agent 协调比如 task 分发、结果聚合需要额外的编排层不在本文范围内。从第一次 18 小时无感知故障到现在 MTTD 压到 6 分钟以内核心不是什么高深的技术就是把三件事做扎实让 Agent 主动说话心跳、让状态能活过重启检查点、让告警真的告警分层监控。监控 Agent 这件事工程味道比 AI 味道重得多。
http://www.zskr.cn/news/1412774.html

相关文章:

  • 智能体技能化:从知识存储到能力封装的组织知识管理新范式
  • 基于LangGraph构建Python依赖诊断智能体:从原理到实战
  • 查询 sql 数据库中各个表所占G得大小
  • 眼周干燥眼纹多用什么?CA眼油一个月淡化眼周所有细纹 - 全网最美
  • windows文件一致性判断方法
  • 导电聚合物枝晶生长机制与神经形态计算应用
  • AD17画3D封装踩过的坑:从丝印不封闭到高度设置,我的避坑指南全在这了
  • ChatGPT抖音脚本创作实战手册(抖音算法适配版):覆盖口播/剧情/知识类3大垂类,含平台限流规避清单
  • 5分钟搞定专业语音转文字:Faster-Whisper-GUI实战指南
  • 【桌游人必存】ChatGPT规则解释避坑清单:6类高危术语(如“顺位”“暗置”“结算阶段”)的权威定义与Prompt校准模板
  • Python实战:用遗传算法搞定物流配送路径规划(附完整代码)
  • 告别外挂射频模块!用STM32WLE5这颗LoRa SOC,从零搭建你的第一个物联网节点(附CubeMX配置避坑点)
  • 传统内存修改vs现代内存扫描:Forza-Mods-AIO如何重构FH4/FH5游戏修改技术栈
  • LaserGRBL终极指南:免费开源激光雕刻控制软件如何让创作更简单
  • llama_index.vector_stores 模块没有怎么办?
  • Kettle Carte服务配置踩坑实录:从XML配置到防火墙,一次搞定Linux部署
  • 软件设计师(十)网络与信息安全基础知识
  • 刚刚!多所高校发布论文框架新规!被说“结构有问题”别慌,这8款AI毕业论文工具实测能救急 - 逢君学术-AI论文写作
  • QMCDecode:三步解锁QQ音乐加密格式,让音乐真正自由播放
  • TikTok评论数据采集技术方案:基于浏览器自动化的高效爬取系统
  • 昆明福昌夏等六家黄金回收机构清单,老顾客亲测推荐值得收藏 - 黄金上门回收
  • 基于系统代理的抖音弹幕抓取完整指南:实时监听浏览器与客户端数据流
  • Windows内存清理终极指南:3步让老旧电脑重获新生
  • Driver Store Explorer终极指南:5步轻松清理Windows驱动,释放C盘空间
  • 5分钟掌握League-Toolkit:英雄联盟玩家的全能助手
  • 13803黄大年茶思屋第138期(基础软件领域第三期)第3题:DBOS存储跨层超时阈值的一致性感知技术
  • Legacy iOS Kit终极指南:让旧款iOS设备重获新生
  • 眼油去细纹干纹哪个牌子好?CA眼油25天淡化静态眼纹 - 全网最美
  • esxtop CPU队列多少算高?Run Queue超标判断教程
  • 从LTE到5G再到71GHz:PRACH Preamble序列长度(L_RA)的演进与选择逻辑