news 2026/10/3 3:35:36

Agent稳定性收口:重试、幂等与并发控制的工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Agent稳定性收口:重试、幂等与并发控制的工程实践

周五晚上九点,飞书群里静悄悄的。按照设定,当日19:00应该准时出现汇总好的团队日报,但什么都没有。我打开Agent的后台日志,看到一行安静的报错:agent execution terminated due to error。再往前翻,周二早上的打卡提醒发生过一次10分钟级别的延迟,周四有一份日报被重复推送,还有一次,一位同事的日报内容出现在了另一位同事的名字下面。功能都在,机器人也确实每天都在跑,但它始终让人不能放心。这种状态下,我决定给这个打卡/日报Agent项目做一次彻底的收口——不加新功能,不换架构,只把已知的Bug修改干净,把稳定性指标立起来,然后封一个v1.0。

这篇笔记是这个系列的第九篇,也正好可以作为"项目如何从能用走向可靠"的一份阶段复盘。如果你也在做自己的Agent项目,或者正好卡在"功能能做出来但不敢上线"的尴尬期,这篇很值得看。我会把三个让我花掉整个周末的Bug排查链路完整还原,再把稳定性收口里真正管用的四件事讲清楚。

1. 收口的起点:v1.0之前这个Agent处于什么状态

1.1 从三件日常事务到一个自治流程

这个Agent要解决的痛点,说穿了就是三个高频又琐碎的操作:早上到了工位要记得打卡,下班前要把当天的进展整理成日报,日报还要发到群里供大家同步。对个人来说每件事都花不了十分钟,但一旦进入"同时跟进三条项目线,每天还要开着四五个会"的节奏,这些事情就特别容易被遗忘或拖延。

所以v0.x版本的设计目标非常朴素:做一个能自动完成这三件事的小机器人。它在固定时间读取日历与考勤提醒,在用户进入工作状态前推送打卡提醒;到了当天结束,它会把散落在聊天记录、文档和任务列表里的工作条目捞出来,交给LLM整理成结构化的日报草稿,再由用户一键确认后推送进团队群。整个流程的关键词是"半自动",LLM负责起草,人负责确认,减少了工作量但保留了掌控感。

技术选型上,主流程用的是Python 3.11,配FastAPI做控制台和回调接口,状态存SQLite,定时调度走APScheduler,LLM调用通过统一的供应商网关完成,消息推送接的是飞书机器人。这套组合不算花哨,但足够便宜、足够透明,出现问题可以直接翻表和日志定位。实际上,到v1.0收口结束,我没有换掉任何一个基础设施组件——改动都在模块内部,这也说明选型本身没有拖后腿。

1.2 功能齐全却不稳定的那段时间

功能层面,这个Agent很早就"做完了":打卡提醒能准时触发,日报内容生成质量也基本能用,用户确认和后续的推送链路全部跑通。但从运行的视角看,它一直属于"薛定谔的稳定"——大部分时间是好的,但你也不能因此就信任它。我在收口前的日志里整理过一份事故清单,时间跨度大概三周:

  • 打卡提醒延迟10分钟一次,原因是调度线程在等待一个外部接口的响应,没有设置超时;
  • 日报漏推一次,原因是LLM调用抛了异常,整个任务直接退出,没有触发重试;
  • 日报重复推送一次,用户点了两次"确认"产生了两个任务;
  • 还有若干次无规律失败,排查后发现是磁盘空间告急。

这些问题的共同点在于:它们都不是功能缺了,而是异常路径没有设计完整。这个观察在后面直接决定了收口的优先级——先补的是对失败的容忍能力,而不是新的功能点。

1.3 收口目标:不是修完Bug,而是让失败成为可预测事件

给v1.0定目标的时候,我没有用"修完以下若干Bug"这种清单式的写法,而是把目标转成了几条围绕失败处理的量化指标:

  • 连续30天无漏报(打卡提醒和日报推送都算);
  • 日报推送成功率不低于99.5%,以任务维度统计,重试成功的也算;
  • 人工干预次数降为0,意思是任何一次任务失败都能被系统自己处理,或至少被明确标记出来,而不是静默丢失;
  • 已知Bug全部关闭,且对每条修复补充回归用例。

"让失败成为可预测事件"是这次收口里我认为最重要的一句话。稳定性从来不是杜绝失败——外部LLM会不稳定,网络会抖动,用户会点出错的按钮,这些事一定会发生。真正要做到的是:失败发生时,系统能识别它、隔离它、重试它,实在不行就把问题变成一个待办事项,而不是半夜让用户来告诉你说"今天群里没日报"。

2. 复盘三个真正让我花掉整个周末的Bug

2.1 时区八小时偏差:一个前后端字段不一致引发的"早起失败"

这个Bug的现象很反直觉:同事A在后台把打卡提醒时间设置成09:00,界面回显也没问题,但第二天实际收到提醒的时间是17:00左右。不是不触发,而是触发时间完全不对劲。

排查的第一步是看调度器。日志显示APScheduler本身是准时的,定的每个时间点都有对应的触发记录。问题出在触发之后——调度器读取用户配置的提醒时间,发现读出来的值和用户设置的差了8小时。到这里,嫌疑范围就收敛到了"时间字段进入系统之后的路径"。

第二步,对比前端提交和后端解析的原始数据。我在控制台里打印了前端请求的payload,里面是这样的:

{ "user_id": "u_001", "remind_time": "2026-01-12 09:00:00+08:00" }

而后端用标准解析函数转成datetime对象之后,变成2026-01-12 01:00:00+00:00,再写入SQLite时统一存成UTC。看起来没什么问题,问题出在读取展示和调度比较时,系统没有统一转回东八区,而调度器又直接把UTC时间当作本地时间去算cron表达式,于是9点的提醒被推到了下午5点。

根因一句话就能说清:同一个时间字段,前端把它当"东八区的早晨",后端在某一环把它当"UTC的凌晨",中间转换逻辑只做了一半。修复动作有三步:传输层统一使用ISO 8601带时区格式;存储层一律UTC;展示和调度计算时显式转目标时区。这次之后我刻意不在任何地方依赖本机时区。

这类Bug之所以隐蔽,在于它前后端各自都没错:前端显示的是用户设置的值,后端存储也符合UTC规范,只有两者之间的那条解读链不一致。排查它的思路值得记下来——遇上看似随机的时间偏移,第一件事就是同时打印前端payload和后端解析结果,把两边的原始值放在一起看,而不是猜代码。

2.2 磁盘被日志吃满:LLM原始响应把Agent拖到"执行终止"

第二个Bug是我自己也容易犯的错。某天下午,Agent开始所有任务大面积失败,日志和错误通知里反复出现一行agent execution terminated due to error,看起来没有任何规律。服务重启之后能好一阵子,但几个小时后又会复发。这种"重启管用"的故障,往往和资源耗尽相关。

我登录服务器先看系统指标,果然root分区使用率已经95%以上。接下来用du逐个目录定位,发现/var/log/agent/下有一个按天轮转的任务日志文件,体积已经超过50GB。打开之后我沉默了——里面每一行都是LLM原始响应的完整JSON,包含全部推理内容、token统计和上千行的思维链文本。

当时为了调试日报生成质量,顺手加了一个"记录LLM完整响应"的开关,调完参数之后忘了关。日志组件本身有轮转策略,但我设置的保留策略是"保留一年的天级文件",加上SQLite的WAL文件也在同步膨胀,Agent在写上下文快照和删旧记录之间来回折腾,磁盘就这么被吃满了。

修复分了三层。第一层是立即止血:关闭LLM完整响应日志,清理历史日志,对SQLite执行WAL checkpoint合并文件。第二层是制度性修复:LLM的原始响应如果需要保留,统一落到专门的对象存储接口,日志里只写摘要字段;日志轮转改成保留最近7天,并且新增磁盘水位告警,超过80%就开始通知。第三层是验证:让Agent在满载LLM调用的条件下连续跑两天,跟踪磁盘曲线稳定不再上涨。

这次Bug给我的启发是:LLM类项目比普通Web应用更容易被日志撑爆。一次调用的token原始响应就是几千字符,一天几百次任务就是几十GB,这还只是日志,没有算上下文快照和中间产物。处理Agent项目,磁盘和内存的监控优先级应该排在功能本身前面一点。

2.3 并发上报互相覆盖:日报内容串场与状态更新竞态

第三个Bug来自一个很常见的场景:某个同事在19:02手动点了催更日报的按钮,几乎同时,定时调度也触发了他的日报生成任务。结果就是,最后群里出现的日报,内容是把两次上下文混在一起生成的,更严重的一次,A的内容挂在了B的名字下面。

第一次看到串场,我下意识认为是LLM输入拼错了,查了输入输出的对应关系,发现单个任务的提示词本身没问题,问题出在数据写入层。两个任务并发读写同一个用户的"待生成状态记录":任务A读取了用户最新工作条目,开始生成文本;任务B也读了同一份数据,开始自己的生成;A写回结果,B也在稍后基于旧的读取结果写回,于是B的写入把A覆盖了,甚至把状态标记也改成自己的task_id。

复现之后,"并发安全"四个字就很具体了。参数不齐、性能不够都只是表象,真正的根因是:一个核心记录的写入没有原子性保障,留下了"两个任务各自以为自己拥有这行数据"的窗口。修复做法在后面第三章幂等部分会展开,这里先讲核心动作:给每一个生成任务分配全局唯一的task_id;写状态时用条件更新,只有当前状态仍然是pending的任务才能把自己改为running;如果发现task_id不存在或者状态早已不是pending,直接拒绝本次操作。这样,同一时刻无论来了几个重复请求,只有第一个能抢到处理权。

现在回头看,这个Bug用一句话概括就是:Agent在并发场景下扛不住不是因为代码执行并发度不够,而是因为业务状态缺少一个"该谁处理"的仲裁机制。这是做Agent项目特别容易忽略的地方,因为单用户demo永远测不出来,只有真实多人使用时才会暴露。

3. 稳定性收口的核心:把失败变成可预期、可容忍、可追踪

3.1 重试策略:哪些错误该重试,哪些错误重试只会添乱

收口的重头戏是重试。第一批次我做了一件事:把所有可能抛出的错误分成了两类。

第一类是可重试错误,特征是"换一个时间点重做,可能会成功"。典型包括上游限流429、服务端5xx、网络超时、临时性的连接断开。对Agent项目而言,LLM服务商的限流和5xx非常常见,这类错误几乎天然就是为重试设计的。

第二类是不可重试错误,特征是"无论重试多少次,结果都一样"。典型包括鉴权失败401/403、请求参数校验失败400、业务逻辑拒绝。这类错误重试纯粹是浪费token和时间,只会让磁盘里的失败记录越来越多。

重试的具体策略上,我用了指数退避加随机抖动:第一次失败了,等1秒重试;第二次等2秒;第三次等4秒;最多重试3次,超过之后整个任务进入失败队列,并由通知模块标记为"待人工处理"。随机抖动的作用是避免多个任务在重试时同时醒来,把上游接口打出一波集中流量。

这里有一个很容易踩的细节:重试不能放在调用Agent内部放得太浅。比如不能把重试放在"生成日报文本"的for循环里,否则一旦用户同时触发了多个任务,重试风暴会直接把限流打穿。我的做法是把重试收口到统一的任务执行器里,每个任务一次重试序列,互不影响。

3.2 超时三层:连接超时、读超时、整体超时的取舍

超时是稳定性收口里性价比最高的一项改造。原本Agent有很多"卡住"的地方,不是报错,而是请求发出去之后没有回应,线程就一直傻等。外部LLM服务偶尔出现"连接挂起"的故障,不设超时的话,一个任务可以卡到天荒地老,把整个调度队列都堵住。

我把超时拆分成了三层:

  • 连接超时:建立TCP连接的最长等待时间,设10秒。超过这个时间,多半是网络路径出问题,重试也没意义,直接放弃本次并标记网络异常。
  • 读超时:连接建立后,等待服务端返回数据的间隔。设60秒。外部服务偶尔会有"连接正常但一直不吐字节"的情况,读超时就是为了兜住这个场景。
  • 整体超时:从任务开始到完成LLM调用总时长上限,设90秒。包含排队、请求、读取和结果后处理的时间。

为什么整体90秒?我统计过生产环境正常调用的耗时分布,95%的任务在60秒内返回,剩下5%分布在60到85秒之间。把整体超时定在90秒,既能覆盖绝大多数正常调用,又能避免个别慢调用长期占用执行线程。如果超过90秒还没有完成,这个任务会先记一次失败并触发重试,不会无限等下去。

3.3 幂等机制:日报生成绝不重复,推送最多一次

在2.3那个Bug之后,幂等就成了我优先处理的硬需求。理解幂等最简单的类比是电商下单:你快速点了十次"提交订单",但真正被创建的订单只有一份,因为系统用一个订单号把所有重复请求合并了。日报生成也一样,需要有一个"任务号"来保证同一个事件不会被处理两次。

实现上,我引入了一个task_id的生成规则:每次触发日报生成(不管是定时调度的首次生成,还是用户的再次确认),都先尝试在任务表里插入一条状态为pending的记录。SQLite的唯一索引保证了相同业务条件下第二条记录插不进去,插不进去就说明任务已经存在,直接返回已有状态即可。

有了这个前置插记录,后续的状态流转就可以设计成稳定的条件更新:

UPDATE daily_report_tasks SET status = 'running', worker = ?, started_at = ? WHERE task_id = ? AND status = 'pending'

这一条语句把"确认我有处理权"这个动作做成了原子操作。只有当前状态还是pending的任务能抢占为running,其他并发请求在更新时发现影响行数为0,就知道自己来晚了,直接退出。

推送环节同样不能裸发。我的规则是:日报内容生成成功后,先写一条"待推送"记录,推送动作执行并收到消息网关成功响应后,再把它标记为已推送。如果中途出现疑问(比如网关超时但消息实际上已经发出),宁可保留重复推送一次的容忍,也不能让日报凭空消失。因为重复日报用户一眼能看出来,漏报则连反馈都没有。

3.4 并发度限制:Agent扛并发不是放开线程,而是约束流量

"AI agent怎么扛并发"这个话题在社区里讨论得很多,我的结论比较直接:对一个以外部LLM调用为主要资源的Agent来说,扛并发首先要做减法。放开并发倒是容易,但外部服务有配额,你并发一高首先撞上的是429限流,然后是自家SQLite的写锁等待,最后得到一堆需要重试的烂摊子。

我用了信号量把LLM并发调用数限制在4。任务执行器在真正发起模型调用前会先获取许可,没有许可就排队等待。整个系统的调度、推送和其他轻操作不受这个信号量约束,只有最贵的外部调用被门控住了。

这个数字是根据外部服务的配额和任务的耗时分布算出来的。4路并发、每路平均耗时约45秒,一分钟大约能完成5个日报任务,对一个几十人的团队绰绰有余。更重要的是,这个约束让重试、日志和状态更新都处于可预测的负载下,磁盘、内存和外部API都不会被瞬时的并发突发打爆。

4. 质量验收与v1.0的体面收尾

4.1 可观测性改造:从"看错误日志"到"看状态流转"

Bug阶段找问题要靠日志定位,但v1.0收口之后我意识到,日志再规范也只是散点记录,稳定性应该建立在"能随时看到系统状态"的基础上。所以我给任务增加了一条完整的生命周期:pending、running、succeeded、failed,外加一个代表"重试中"的retrying状态。

每个任务启动时生成结构化日志,固定字段包括task_id、用户ID、触发来源、当前状态、耗时、LLM调用次数和token数、错误码。所有日志不写散文,统一JSON一行一条。这样在日志系统里筛一个task_id,就能把它的完整生命周期串起来:几点几分被谁触发的、在哪一步开始调用模型、调用了几次、每次多久、最终结果如何。

这套改造的价值在第一次实际排障中就体现了。后来有一次日报内容质量异常,我不需要猜,直接按task_id拉出日志,发现模型的temperature参数在某次配置更新后被意外改成了0.9,生成路径里多了一次重试。整个过程五分钟就定位完。

4.2 告警与值班守则:机器能发现的不要等用户投诉

稳定性收口的另一个重要部分是告警。我不指望有人时刻盯着后台,也不希望用户成为故障的第一发现者。我把告警规则收敛成四条,既覆盖了最危险的情况,又不至于告警疲劳:

  • 连续5次LLM调用失败,立即告警。这是最常见的故障前置信号;
  • 队列积压超过10个任务,立即告警。说明调度或执行环节出现了下游拥堵;
  • 磁盘使用率超过80%,立即告警。这是2.2那个Bug留下的教训;
  • 日报推送成功率(按小时统计)低于90%,告警并自动冻结定时调度,防止故障扩大。

告警渠道和日报推送一样,走飞书机器人。规则入库,值班人员只需要按标准处置流程响应:先看任务状态分布,再筛对应task_id日志,处理完把结论更新到故障记录里。

4.3 v1.0验收清单与持续一个月运行的体会

验收不是某个时刻的开香槟,而是一段时间的持续确认。我给v1.0列了一份四行的检查清单,每天自动跑一遍:

检查项阈值说明
打卡提醒及时性偏差小于1分钟每天多时段抽查
日报生成成功率不低于99.5%重试成功也算
重复/串场事故0次按task_id去重核对
人工干预次数0次无手动补报

一个月跑下来,最直观的变化是:我每天早上不再需要打开后台日志检查日报是否发出去了,只在群里看到它正常出现就够了。这种"无事发生"其实才是稳定性最好的证明。

最后再分享一个小插曲:v1.0封版那天,有同事问我,后面是不是可以加自动周报、自动月度总结这些新功能。我说功能不急,先把这套稳定性框架延续到下一个版本再说。Agent项目最怕的不是功能少,而是用户已经在依赖它了,你却还没给它一个可以依赖的理由。v1.0的意义不是版本号涨了,而是这个项目第一次有了明确的可靠性底线。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 3:35:35

Flutter for OpenHarmony电子合同App活动历史模块实现与踩坑总结

做 Flutter for OpenHarmony 电子合同签署App 的这段经历里,我一度以为最硬核的会是签名面板、证书解析、骑缝章渲染这些"看得见"的模块。结果真到了测试和交付阶段,卡住我时间最久的,反而是看起来平平无奇的"活动历史"功…

作者头像 李华
网站建设 2026/10/3 3:35:35

渭河流域12.5米DEM与标准矢量数据交付规范

简介:本资源面向地理信息系统(GIS)学习者、水文与流域研究者及遥感制图实践者,提供渭河流域高精度空间数据一体化解决方案,有效支撑流域分析、地形可视化、论文成图与教学演示等核心需求。压缩包共18个文件&#xff0c…

作者头像 李华
网站建设 2026/10/3 3:35:18

AUV辅助水下物联网信息收集:基于AoI优化的Matlab仿真方案

水下物联网的数据收集一直是个让人头疼的问题。传统固定节点组网用声学链路通信,速率低、延迟高、能耗也大,而且水下环境信号衰减严重,靠静态中继很难保证数据的新鲜度。这几年学界慢慢转向用AUV(自主水下航行器)当移动…

作者头像 李华
网站建设 2026/10/3 3:35:16

高通8155音频链路七层穿透:从APP到DSP寄存器的全栈解析

1. 项目概述:为什么8155的音频链路值得花一整天去抠透高通8155平台在智能座舱领域几乎是事实上的行业标杆,但真正能说清楚“一段MP3播放出来,数据到底经历了哪些模块、被谁改了格式、在哪被拆包又在哪被重装”的工程师,我见过不到…

作者头像 李华
网站建设 2026/10/3 3:34:40

RK3588部署FaceNet完整指南:PyTorch转RKNN的踩坑与优化

去年年中接了一个边缘设备上做人脸识别的项目,老板指定要用 RK3588,模型用 FaceNet。说实话,当时脑子里第一个念头是“这不就是装个环境,导个模型,跑个推理吗”,真正动手之后才发现,从 PyTorch …

作者头像 李华
网站建设 2026/10/3 3:34:38

PostgreSQL流复制协议:从WAL到排障,彻底搞懂主从同步机制

1. 流复制协议不是"配置项",是你排障的最后一层眼睛如果你只把PostgreSQL流复制当成primary_conninfo加max_wal_senders这样的配置项,那你会错过一整个层次的排障能力。我见过太多DBA,主从能跑起来就觉得万事大吉,一遇到…

作者头像 李华