ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent工程化实践:错误处理、重试与幂等性设计

Agent工程化实践:错误处理、重试与幂等性设计 1. 从一次线上事故说起为什么错误处理是Agent工程化的分水岭去年冬天我负责的一个Agent项目在凌晨两点崩了。监控面板上红色的错误率曲线像心电图一样往上窜日志里刷屏的是同一句话agent execution terminated due to error。更让人头疼的是重启服务之后一部分任务被重复执行了——用户收到了两条一模一样的通知数据库里多出了两笔重复的订单记录。那次事故之后我花了整整一周做复盘最后得出的结论很朴素Agent系统的稳定性不取决于它跑得有多快而取决于它在出错时表现得有多体面。一个Demo级别的Agent只要主流程能跑通就算成功但一个要上生产的Agent错误处理、重试策略、幂等保障这三件事才是真正拉开工程水平差距的地方。这篇内容我想聊的就是这个话题。它适合已经写过基础Agent、准备把它推向真实业务场景的开发者也适合正在做Agent架构设计、被各种请稍后重试折磨过的同学。我会从错误分类讲起一路讲到重试机制的设计、幂等性的落地、以及那些只有踩过坑才知道的工程细节。核心关键词围绕错误处理、工程化实践、Agent、重试、幂等展开但我不想写成教科书而是想把我自己趟过的路、摔过的跤原原本本讲清楚。先说一个反直觉的观点大多数Agent系统的错误处理代码写的其实是错误掩盖。一个try-catch包住整个流程catch里打一行日志然后return null看起来程序没崩实际上问题被吞掉了等到用户投诉的时候你连现场都找不到。真正的错误处理第一步是承认错误、分类错误然后才是处理错误。2. Agent错误的四种面孔先分类再谈处理在动手写任何重试逻辑之前我强烈建议你先花半天时间把系统里可能出现的错误做一个完整分类。我见过太多团队一上来就写retry(3)结果把不该重试的错误重试了三遍把该快速失败的错误拖成了超时。2.1 按可恢复性划分瞬时错误、持久错误、逻辑错误从工程角度Agent的错误最实用的分类方式是看它重试之后会不会变好。瞬时错误Transient Error是最常见的一类典型代表是网络抖动、下游服务短暂过载、限流触发、模型API返回模型请求失败请稍后重试这类响应。这类错误的特征是同样的请求过一会儿再发大概率能成功。它们应该被重试。持久错误Permanent Error是重试也没用的错误比如参数格式错误、鉴权失败、资源不存在该项目不在请确认项目位置这种、请求体超过大小限制。对这类错误重试只会浪费时间和配额应该快速失败并把清晰的错误信息抛给上层。逻辑错误Logic Error最隐蔽指的是Agent自身的决策或状态出了问题。比如模型本轮只输出了思考过程、没有产出正文或者Agent陷入了一个循环调用自己的死循环。这类错误重试往往也没用因为根因在逻辑层需要的是修正prompt、调整状态机或者加一个循环检测。我一般会在代码里用一个枚举把这三类标出来然后在统一的错误处理入口根据类型决定行为from enum import Enum class ErrorKind(Enum): TRANSIENT transient # 可重试 PERMANENT permanent # 快速失败 LOGIC logic # 需人工介入/降级 def classify_error(exc: Exception) - ErrorKind: if isinstance(exc, (TimeoutError, ConnectionError)): return ErrorKind.TRANSIENT if isinstance(exc, (ValueError, PermissionError)): return ErrorKind.PERMANENT if isinstance(exc, AgentLoopDetected): return ErrorKind.LOGIC return ErrorKind.TRANSIENT # 默认保守处理提示默认分支我选择归为TRANSIENT而不是PERMANENT因为未知错误重试一次的成本通常低于误判为永久错误导致任务直接失败的成本。但这个默认值要根据你的业务容忍度来定。2.2 按错误来源划分模型层、工具层、编排层另一个有用的维度是看错误从哪来。Agent系统通常有三层模型调用层、工具/插件执行层、编排调度层。不同层的错误处理策略完全不同。模型层的错误大多是瞬时的——限流、超时、上下文超长。这里有个细节值得注意当上下文接近模型上限时有些平台会返回一个明确的错误码提示你启用更大上下文后重试。这种错误如果你无脑重试只会一直失败正确的做法是触发上下文压缩或者截断策略。工具层的错误最杂。调用外部API可能返回各种业务错误码调用本地工具可能抛异常。我的经验是工具层必须做错误归一化——不管底层抛什么都转换成统一的ToolExecutionError带上retryable标志和原始错误信息。这样编排层就不用关心每个工具的具体错误类型了。编排层的错误往往是逻辑错误比如状态机走到了非法状态、依赖的任务还没完成就被调度了。这类错误重试无意义应该直接告警。2.3 一张表看清错误分类与处理策略错误类型典型场景是否重试处理策略瞬时-网络连接超时、DNS失败是指数退避重试瞬时-限流429、配额超限是退避抖动降低并发持久-参数400、格式错误否快速失败返回明确信息持久-鉴权401、403否刷新凭证后重试一次逻辑-循环Agent自我调用否熔断告警逻辑-空输出只有思考无正文有限重试提升输出预算后重试这张表我建议每个做Agent的团队都根据自己的业务填一遍。填的过程本身就是一次系统性的风险梳理。3. 重试机制的设计不是所有错误都值得再试一次重试是错误处理里最容易写错的部分。我见过最离谱的代码是在一个循环里无脑重试十次中间没有任何等待结果把下游服务直接打挂。重试的本质是用时间换成功率但前提是你要控制好节奏和上限。3.1 指数退避与抖动为什么固定间隔重试是灾难假设你的Agent调用下游服务失败了你等1秒重试又失败再等1秒……如果这时候有一千个Agent实例同时在做这件事它们会在同一秒集体发起重试形成重试风暴把本来只是轻微过载的下游彻底压垮。正确的做法是指数退避Exponential Backoff每次重试的等待时间翻倍。第一次等1秒第二次2秒第三次4秒第四次8秒。这样重试的压力会随时间快速衰减。但光有指数退避还不够因为如果所有实例的退避曲线完全一致它们还是会在同一时刻重试。所以需要加抖动Jitter——在退避时间上叠加一个随机量。常见的做法是全抖动等待时间取random(0, base * 2^attempt)。import random import time def retry_with_backoff(func, max_attempts5, base_delay1.0, max_delay60.0): for attempt in range(max_attempts): try: return func() except TransientError as e: if attempt max_attempts - 1: raise # 指数退避 全抖动 delay min(base_delay * (2 ** attempt), max_delay) sleep_time random.uniform(0, delay) time.sleep(sleep_time)实测下来加了抖动之后重试风暴基本消失了。这个改动成本极低但收益巨大属于性价比最高的工程优化之一。3.2 重试预算给重试设一个总闸指数退避解决了怎么等的问题但没解决等多久的问题。如果一个任务已经重试了五次、耗时三分钟还要不要继续这时候就需要**重试预算Retry Budget**的概念。我的做法是给每个任务设一个总时间预算比如30秒。每次重试前检查剩余预算如果不够下一次退避的等待时间就直接放弃并返回失败。这样能保证任务不会无限期地卡在重试里。另一个维度是重试次数上限。这个值没有标准答案取决于你的业务。对于用户实时等待的交互式Agent可能2-3次就够了再多用户就跑了对于后台异步任务可以放宽到5-8次。注意重试次数和退避时间要一起算总账。5次重试配合指数退避最坏情况下总耗时可能是12481631秒。如果你的接口超时设置是30秒那第5次重试根本没机会执行。这两个参数必须联动调整。3.3 熔断与降级当重试也救不了的时候有些故障不是靠重试能解决的比如下游服务整体挂了。这时候如果还坚持重试只会让Agent自己也被拖垮。**熔断器Circuit Breaker**就是干这个的当某个下游的失败率超过阈值直接跳闸后续请求快速失败不再尝试。熔断器一般有三个状态关闭正常放行、打开快速失败、半开放少量请求试探。当打开状态持续一段时间后进入半开放几个请求过去如果成功就恢复关闭失败就继续打开。配合熔断的是降级Fallback。当主路径不可用时Agent应该有能力走备用路径。比如主模型不可用切换到备用模型实时查询失败返回缓存数据。降级策略需要在设计阶段就想好而不是等故障来了临时拍脑袋。我踩过的一个坑是熔断器打开了但降级逻辑没写好结果Agent返回了一个空结果上层以为任务成功了。这种静默降级比直接报错还危险。降级必须显式标记让调用方知道这是降级结果。4. 幂等性Agent重复执行的终极解药重试和幂等是一对孪生兄弟。你重试得越积极重复执行的概率就越高。如果Agent的操作不是幂等的重试就会制造脏数据。这就是为什么我在开头那个事故里用户收到了两条通知——重试机制把同一个任务执行了两遍。4.1 幂等性的本质同一个请求执行N次和执行1次效果相同幂等这个词来自数学但在工程里它的含义很直白一个操作执行一次和执行多次对系统状态的影响是一样的。查询天然幂等删除按ID删通常幂等但创建订单发送通知扣减库存这些操作默认都不幂等。让Agent的操作幂等核心思路是给每个操作一个唯一标识Idempotency Key然后在执行前检查这个标识是否已经处理过。如果处理过直接返回上次的结果不再重复执行。这个Key从哪来最可靠的是由调用方生成随请求一起传进来。如果调用方不提供Agent可以基于请求内容计算一个哈希值作为Key。但要注意基于内容哈希有个坑如果请求内容里有时间戳之类的变化字段同样的业务请求会算出不同的Key幂等就失效了。4.2 幂等性检查用DB还是Redis我的选型思路这是热词里出现的一个经典问题我也被问过很多次。答案不是非此即彼而是看你的场景。用数据库实现的好处是可靠、持久、和业务数据在同一个事务里。你可以建一张idempotency_keys表字段包括key、状态、结果、创建时间。执行操作时先尝试插入这个key利用唯一索引插入成功说明是第一次继续执行插入冲突说明已经处理过查询已有结果返回。整个过程可以和业务操作放在同一个数据库事务里保证原子性。用Redis实现的好处是快、天然支持过期。SET key value NX EX 3600这一条命令就完成了检查设置过期非常适合高并发场景。但Redis的问题是如果Redis挂了或者数据丢了幂等保障就没了而且Redis和业务数据库不在同一个事务里存在Redis标记成功但业务操作失败的不一致窗口。我的实际选型是这样的场景推荐方案理由涉及资金、订单等强一致数据库可事务可靠高并发、可容忍极小概率重复Redis快实现简单两者都要Redis前置DB兜底兼顾性能与可靠具体做法是用Redis做第一道快速拦截挡住绝大部分重复请求同时在数据库里也记录一份作为最终保障。如果Redis判断是重复直接返回如果Redis没拦住比如刚过期数据库的唯一索引还能兜底。4.3 幂等Key的生命周期管理幂等Key不能永久保存否则存储会无限膨胀。但过期时间设多长是个需要权衡的问题。设太短比如5分钟那么一个任务如果因为重试间隔较长在10分钟后才第二次执行幂等就失效了。设太长比如30天存储成本又上去了。我的经验值是幂等Key的过期时间应该大于任务的最大可能执行时长加上重试窗口。比如一个任务最长执行5分钟最多重试3次每次退避最多1分钟那么总窗口大约是8分钟幂等Key设15分钟就比较安全。对于资金类操作我建议直接永久保存或者保存至少一个对账周期比如90天因为这类操作的重复代价太高宁可多占点存储。4.4 一个容易忽略的坑幂等和重试的交互这里有个很隐蔽的问题如果Agent在执行成功但还没记录幂等Key的瞬间崩溃了重启后会怎样答案是它会认为这个操作没执行过重新执行一遍造成重复。这个窗口虽然小但在高并发下一定会出现。解决办法是把执行操作和记录幂等Key放进同一个原子操作里。用数据库的话就是同一个事务用Redis的话可以用Lua脚本保证原子性。如果做不到原子那就需要引入状态机把操作标记为处理中执行完改成已完成。重启后遇到处理中的记录需要人工介入或者走对账逻辑而不是盲目重试。5. 工程化落地把错误处理变成系统能力前面讲的都是点上的技术这一节我想聊聊怎么把这些点连成面变成整个Agent系统的能力。这也是工程化实践这个词的真正含义——不是写几段重试代码而是建立一套机制。5.1 统一错误处理中间件让每个Agent节点都受益如果你的Agent框架里有多个节点规划节点、工具调用节点、反思节点你肯定不希望每个节点都重复写一遍错误处理。我的做法是做一个统一的错误处理中间件包裹在每个节点的执行外面。这个中间件负责几件事捕获异常、分类错误、决定是否重试、记录结构化日志、上报指标。节点本身只需要专注业务逻辑不用关心这些横切关注点。def with_error_handling(node_func): def wrapper(state, config): try: return node_func(state, config) except Exception as e: kind classify_error(e) log_structured_error(nodee.__name__, kindkind, exce) metrics.increment(fagent.error.{kind.value}) if kind ErrorKind.TRANSIENT: raise RetryableError(e) raise return wrapper这样设计的好处是错误处理策略的调整只需要改中间件一处所有节点自动生效。而且结构化日志和指标是统一格式的排查问题时非常方便。5.2 结构化日志让错误现场可复现我见过太多团队的日志是这样的Error occurred。这种日志在排查问题时毫无价值。Agent系统的日志必须结构化至少要包含任务ID、节点名、错误类型、错误信息、重试次数、耗时、输入摘要。我一般用JSON格式打日志方便后续用日志平台做聚合查询。关键是任务ID要贯穿整个调用链这样你可以把一个任务的所有日志串起来看快速定位是哪一步出的问题。提示日志里不要打完整的prompt和模型输出一是量大二是可能包含敏感信息。打摘要或者哈希值就够了需要详情时再通过任务ID去专门的存储里查。5.3 可观测性三件套日志、指标、追踪光有日志还不够。Agent系统的可观测性需要三样东西日志Logging、指标Metrics、追踪Tracing。指标用来回答系统整体健康吗——错误率、重试率、平均重试次数、熔断触发次数这些数字应该出现在你的监控面板上有异常就告警。追踪用来回答这个任务为什么慢——一个任务经过了哪些节点、每个节点耗时多少、在哪一步重试了。分布式追踪能把整个链路可视化排查性能问题特别有用。日志用来回答到底发生了什么——具体的错误信息、上下文、堆栈。这三样配合起来你才能在生产环境里快速定位问题。缺了任何一个排查都会变成盲人摸象。5.4 测试错误处理最容易被漏测的地方最后说一个很多人忽略的点错误处理逻辑必须被测试覆盖。正常路径的测试大家都会写但错误路径的测试往往被跳过结果上线后才发现重试逻辑有bug。我的做法是给每个错误处理分支都写测试用mock来模拟各种错误。比如模拟下游超时验证是否触发了重试模拟重试耗尽验证是否返回了正确的错误模拟幂等Key重复验证是否返回了缓存结果。还有一类测试是混沌测试在测试环境里随机注入错误看系统能不能优雅处理。这个成本高一些但对于核心链路值得做。6. 那些只有踩过才知道的细节写到这里技术框架基本讲完了。最后我想分享几个具体的、文档里不会写的经验都是我自己踩坑换来的。第一个坑重试的幂等性检查本身也可能失败。你用来检查幂等的那个Redis或者数据库如果它自己挂了怎么办我的做法是幂等检查失败时宁可让操作失败也不要放行。因为放行意味着可能重复执行而重复执行的代价通常高于暂时不可用。第二个坑Agent的思考和行动要分开处理。模型输出思考过程但没输出正文这种错误重试时不能简单重发因为重发可能还是同样的结果。正确的做法是调整参数比如提升输出预算后再重试或者换一个更明确的prompt。热词里提到的逐级提升输出预算就是这个思路。第三个坑并发场景下幂等Key的竞争。两个请求几乎同时到达都发现Key不存在都开始执行结果重复了。这就是典型的check-then-act竞态。解决办法是用原子操作数据库唯一索引或者Redis的SET NX而不是先查后写。第四个坑错误信息要面向人不要面向机器。Error 500对用户毫无意义模型服务暂时不可用已自动重试2次请稍后再试才有价值。错误信息的设计也是工程化的一部分它直接影响用户对你系统的信任度。第五个坑重试日志要能区分第几次重试。如果日志里只写重试中你根本不知道是第一次还是最后一次。带上attempt编号排查时一目了然。这些细节看起来琐碎但正是它们决定了你的Agent系统是能跑还是能扛。错误处理这件事没有银弹只有一个个具体的、经过验证的工程决策。我到现在也不敢说自己做得完美每次线上出问题都是一次重新审视这些机制的机会。但至少现在当凌晨的告警响起时我能比较从容地打开日志知道该从哪里下手了。
返回列表