☰
Agent工程化实践:错误处理、重试与幂等性设计
2026/10/1 12:21:31 网站建设 项目流程

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_attempts=5, base_delay=1.0, max_delay=60.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次重试配合指数退避,最坏情况下总耗时可能是1+2+4+8+16=31秒。如果你的接口超时设置是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(node=e.__name__, kind=kind, exc=e) metrics.increment(f"agent.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系统是"能跑"还是"能扛"。错误处理这件事,没有银弹,只有一个个具体的、经过验证的工程决策。我到现在也不敢说自己做得完美,每次线上出问题,都是一次重新审视这些机制的机会。但至少现在,当凌晨的告警响起时,我能比较从容地打开日志,知道该从哪里下手了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询