
1. 从一次线上事故说起为什么错误处理是 Agent 工程化的分水岭去年冬天我接手了一个内部 Agent 项目功能是自动处理工单读取用户提交的问题描述调用工具查询知识库生成回复草稿最后推送到审核队列。Demo 阶段一切顺利准确率能到 85% 以上团队里所有人都觉得这东西可以上线了。结果上线第三天整个流程卡死了整整两个小时积压了四百多张工单运维群里炸了锅。排查下来原因特别朴素知识库接口在某个时刻返回了一次 502Agent 的调用链没有做任何重试直接抛异常终止了。更麻烦的是这个异常被上层捕获后任务状态被标记为处理中既没有回滚也没有重试于是这批工单就永远卡在了中间态。我们花了半天时间写脚本手动重置状态才把积压清掉。这件事让我彻底明白一个道理Agent 的 Demo 和 Agent 的生产系统之间隔着的不是模型能力而是错误处理与工程化实践。模型再聪明只要一次网络抖动、一次接口超时、一次工具返回格式异常整条链路就可能崩掉。而生产环境里这些意外不是小概率事件是每天都会发生的常态。这篇内容我想聊的就是这个话题。它适合已经跑通过 Agent Demo、准备往生产环境推进的开发者也适合正在设计 Agent 框架、需要把容错能力做进底层的架构同学。我会从错误分类、重试策略、幂等设计、状态机管理、可观测性几个角度把我在实际项目里踩过的坑和总结出来的做法完整讲一遍。核心关键词围绕错误处理、工程化实践、Agent、重试、幂等展开这些都是让 Agent 从能跑变成能扛的关键。先说一个反直觉的结论在 Agent 系统里错误处理不是异常分支而是主流程的一部分。传统后端开发里我们习惯把 try-catch 当成兜底正常路径才是主线。但 Agent 系统不一样它天然要调用大量外部工具、依赖不稳定的模型服务、处理格式千奇百怪的返回值错误路径的复杂度甚至超过正常路径。如果你还把错误处理当成补丁系统一定会在某个时刻教你做人。2. Agent 错误的五种类型先分类再谈处理很多人一上来就问重试怎么写但其实更重要的问题是什么错误该重试什么错误不该重试。重试错了对象不但解决不了问题还会放大故障。我在项目里把 Agent 遇到的错误分成五类每类的处理策略完全不同。2.1 瞬时性错误网络抖动、限流、超时这类错误的特征是同一请求重试大概率能成功。典型场景包括调用外部 API 时遇到 502、503、504触发服务端限流返回 429或者请求超时。这类错误是重试机制的主要目标。判断标准很简单错误是环境造成的而不是请求本身有问题。比如 429 限流说明服务端还活着只是暂时忙502 说明网关后面的服务可能正在重启。这些情况下等几百毫秒到几秒再试成功率很高。但要注意一个坑不是所有 5xx 都值得重试。500 内部错误有时候是请求参数导致的重试一百次也是 500。我的经验是只对 502、503、504 和 429 做自动重试500 记录日志后交给人工判断。2.2 永久性错误参数错误、权限不足、资源不存在这类错误重试再多次也没用因为请求本身就是错的。比如工具调用时传了不存在的 ID、API Key 过期、访问了没有权限的资源。这类错误必须快速失败把错误信息透传给上层让 Agent 有机会调整策略或者直接告知用户。这里有个容易忽略的点Agent 调用工具时参数往往是模型生成的模型可能幻觉出一个不存在的参数。这种情况下与其盲目重试不如把错误信息回灌给模型让它重新生成参数。这就是所谓的错误反馈循环比单纯重试有效得多。2.3 模型侧错误输出格式异常、内容截断、拒绝响应Agent 依赖模型输出结构化内容比如 JSON 格式的工具调用但模型有时候会输出半截 JSON、多加了 Markdown 代码块标记、或者干脆拒绝回答。这类错误的处理方式和网络错误完全不同。我的做法是分三层处理第一层做格式清洗比如剥掉 json 包裹、补齐缺失的括号第二层做 schema 校验用 Pydantic 之类的工具严格验证字段第三层才是重试而且重试时要修改 prompt明确告诉模型上次输出哪里不符合要求。单纯重发同样的请求模型很可能犯同样的错。2.4 状态性错误中间态卡死、并发冲突这类错误最隐蔽也最致命。前面提到的工单卡死就是典型例子任务被标记为处理中但实际执行已经失败状态没有回滚。另一个常见场景是并发冲突两个 Agent 实例同时处理同一条数据互相覆盖结果。状态性错误的根源是状态管理和实际执行不一致。解决办法是引入明确的状态机每个状态转换都要有对应的补偿逻辑。这个话题后面会专门展开。2.5 系统性错误依赖服务整体不可用、配额耗尽当依赖的模型服务、数据库、消息队列整体挂掉时任何重试都是徒劳的。这时候正确的做法是熔断快速失败把请求排队或降级等依赖恢复后再继续。熔断器Circuit Breaker是这类场景的标准解法后面会讲具体实现。把这五类错误整理成一张表处理策略一目了然错误类型典型场景是否重试核心策略瞬时性错误502/503/504、429、超时是指数退避重试永久性错误参数错误、权限不足否快速失败、错误透传模型侧错误格式异常、截断有条件重试清洗校验改 prompt 重试状态性错误中间态卡死、并发冲突否状态机补偿锁系统性错误服务整体不可用否熔断降级排队提示分类不是目的目的是让每类错误都有明确的处理路径。如果你的代码里所有错误都走同一个 catch 分支那基本可以确定错误处理是不到位的。3. 重试机制的正确打开方式指数退避、抖动与重试预算分类清楚之后重试机制才有意义。我见过太多项目里的重试就是简单写个 for 循环重试三次中间 sleep 一秒。这种写法在低并发场景下勉强能用一旦上量就会引发连锁反应。3.1 为什么必须用指数退避假设你的 Agent 每秒发起 100 个请求某个依赖服务突然变慢。如果所有请求都固定间隔 1 秒重试那么 1 秒后会有 100 个请求同时打过去2 秒后又是 100 个形成周期性的流量尖峰。服务本来只是慢被这么一冲直接挂了。指数退避的核心思想是让重试间隔随重试次数指数增长第一次失败等 1 秒第二次等 2 秒第三次等 4 秒第四次等 8 秒。这样重试请求会被分散到不同时间点给依赖服务喘息的机会。用代码表达大概是这样import time import random def retry_with_backoff(func, max_retries5, base_delay1.0, max_delay60.0): for attempt in range(max_retries): try: return func() except TransientError as e: if attempt max_retries - 1: raise # 指数退避 抖动 delay min(base_delay * (2 ** attempt), max_delay) delay delay * (0.5 random.random() * 0.5) # 抖动 time.sleep(delay)3.2 抖动Jitter不是可选项是必选项上面代码里那行delay * (0.5 random.random() * 0.5)就是抖动。它的作用是给每个请求的退避时间加一个随机因子避免大量请求在同一时刻重试。没有抖动的指数退避在并发场景下依然会形成尖峰。因为所有请求的退避曲线是一样的第 N 次重试时它们还是会撞在一起。加上抖动后每个请求的重试时间点被随机打散流量曲线就平滑了。抖动的实现方式有好几种我用的是等比例抖动在 [0.5×delay, 1.0×delay] 区间内随机取值。还有全抖动[0, delay]和装饰抖动delay [0, base]效果各有侧重等比例抖动在大多数场景下够用。3.3 重试预算防止重试把系统拖垮这是很多人忽略的一点。假设一个请求最多重试 5 次那么一次用户请求最多会产生 6 次下游调用。如果下游服务已经过载你的重试会雪上加霜。重试预算Retry Budget的思路是给整个系统设定一个重试总量上限比如正常请求量的 10%。当重试请求占比超过这个阈值时直接拒绝新的重试让请求快速失败。这样可以在依赖服务出问题时避免重试流量把系统彻底压垮。实现上可以用一个滑动窗口计数器记录最近一段时间内的总请求数和重试请求数超过比例就熔断重试。3.4 重试必须配合超时重试和超时是一对孪生兄弟。如果单次请求没有超时限制一个卡住的请求会一直占着资源重试也就无从谈起。我的经验是单次请求超时时间 × 最大重试次数 上游能接受的总超时。举个例子如果上游给 Agent 的总预算是 30 秒你打算最多重试 3 次那么单次请求超时应该设在 7 秒左右留点余量。如果单次超时设成 20 秒重试一次就超预算了重试机制形同虚设。注意超时时间不要设得太短。有些模型推理本身就要十几秒超时设成 5 秒会导致大量正常请求被误判为失败反而触发无谓的重试。建议先统计 P99 延迟再在此基础上留 50% 余量。4. 幂等性设计让重试变得安全的前提重试机制有一个隐含前提同一个请求执行多次和执行一次结果应该一样。如果做不到这一点重试就会产生副作用。比如一个扣款操作重试三次就扣了三次钱这是灾难性的。这就是幂等性Idempotency要解决的问题。在 Agent 系统里幂等性尤其重要因为 Agent 会调用大量有副作用的工具发邮件、写数据库、调用支付接口、创建工单。任何一个环节不幂等重试都可能造成数据错乱。4.1 幂等键给每个操作一个唯一身份证最通用的幂等实现方式是幂等键Idempotency Key。客户端为每个逻辑操作生成一个唯一 ID服务端记录这个 ID 的处理结果。当同一个 ID 再次到来时直接返回上次的结果不重复执行。在 Agent 场景里幂等键可以这样设计{task_id}:{step_name}:{attempt_group_id}。task_id 标识整个任务step_name 标识当前步骤attempt_group_id 标识这一组重试。这样即使重试多次只要 attempt_group_id 不变服务端就能识别出是同一个操作。import hashlib def generate_idempotency_key(task_id, step_name, payload): # 用 payload 的哈希作为 attempt_group_id保证相同输入产生相同 key payload_hash hashlib.sha256( json.dumps(payload, sort_keysTrue).encode() ).hexdigest()[:16] return f{task_id}:{step_name}:{payload_hash}4.2 幂等性检查用 DB 还是 Redis这是热词里出现频率很高的问题我结合自己的实践说说。两种方案各有适用场景不是非此即彼。用数据库实现的优势是可靠、持久、支持事务。把幂等键作为唯一索引插入成功说明是首次执行插入冲突说明是重复请求。缺点是每次都要读写数据库QPS 高的时候有压力。用 Redis 实现的优势是快、支持 TTL 自动过期。用SET key value NX EX 3600一条命令就能实现不存在则设置并返回成功存在则返回失败。缺点是 Redis 可能丢数据虽然概率低而且 TTL 到期后幂等记录就没了。我的实际做法是两者结合Redis 做第一层快速拦截挡住绝大部分重复请求数据库做第二层持久化记录保证即使 Redis 挂了也不会重复执行。具体流程是请求进来先查 Redis 有没有这个幂等键有直接返回缓存的结果没有用SET NX抢占抢到就继续执行执行前在数据库插入幂等记录唯一索引执行完把结果写回 Redis 和数据库这样即使 Redis 在步骤 3 之后挂了数据库的唯一索引也能兜底。4.3 状态机幂等性的天然载体比幂等键更优雅的方案是状态机。把每个任务建模成状态机状态转换有明确的合法路径。比如工单任务的状态可以是待处理 → 处理中 → 已完成 / 失败。关键设计是状态转换必须是条件更新。比如从处理中改为已完成这个操作SQL 要写成UPDATE tasks SET statuscompleted WHERE id? AND statusprocessing。如果任务已经是 completed这条 SQL 影响行数为 0说明是重复操作直接忽略。这种设计天然幂等无论执行多少次只要状态已经转换过后续操作都不会生效。而且它还能防止并发冲突两个实例同时尝试转换状态只有一个能成功。4.4 工具调用的幂等性分级不是所有工具都需要幂等。我把 Agent 调用的工具分成三级只读工具查询知识库、读取文件、搜索。天然幂等随便重试。可幂等写工具创建资源时带幂等键、更新操作基于版本号。需要显式设计幂等。不可幂等工具发邮件、发短信、调用支付。这类工具要么不重试要么在业务层做去重。对于第三类工具我的做法是把决定发送和实际发送分离。Agent 只负责生成发送意图并落库实际发送由独立的下游消费者执行消费者基于幂等键去重。这样即使 Agent 重试也不会重复发送。5. 状态管理与补偿处理那些卡在中间的任务前面反复提到状态性错误这是 Agent 系统里最难处理的一类问题。它的本质是执行过程是有状态的但状态更新和执行本身不是原子的。5.1 中间态卡死的根因回到开头那个工单卡死的例子。流程是这样的Agent 读取工单把状态改为处理中然后调用模型生成回复最后把状态改为已完成。问题出在中间如果调用模型时进程崩溃、或者抛了未捕获的异常状态就永远停在处理中。这个问题的根源是状态更新点设计不合理。把状态改成处理中和执行实际逻辑之间存在一个窗口期任何意外都会导致状态和执行不一致。5.2 用超时心跳解决中间态解决中间态卡死有两个思路。第一个是给中间态加超时状态为处理中的任务如果超过 N 分钟没有更新就认为执行失败自动回滚到待处理或标记为失败待重试。实现上可以加一个updated_at字段后台起一个定时任务扫描超时的中间态任务。这个方案简单有效但有个前提执行过程要定期更新心跳。否则一个正常执行但耗时较长的任务会被误判为卡死。第二个思路是用消息队列的可见性超时。把任务投递到队列消费者取走后如果没在超时时间内 ack消息会自动重新入队。这个机制天然解决了中间态问题但要求执行逻辑本身幂等。5.3 补偿事务Saga 模式在 Agent 里的应用当一个任务包含多个步骤且步骤之间有依赖时部分成功是最麻烦的情况。比如创建订单 → 扣减库存 → 发起支付三步如果支付失败了前两步需要回滚。Saga 模式的核心思想是为每个正向操作定义一个补偿操作。正向操作失败时按相反顺序执行补偿。在 Agent 场景里这意味着每个工具调用都要考虑如果后续步骤失败这个调用怎么撤销。不是所有操作都能补偿。发出去的邮件撤不回来调用过的外部 API 可能没有反向接口。对于这类操作我的做法是把它们放到流程的最后或者用预留-确认两阶段模式先预留资源可撤销所有步骤都成功后再统一确认。5.4 状态机的落地实现说了这么多落地时我推荐用一张状态转换表来管理当前状态事件目标状态补偿动作待处理开始执行处理中无处理中执行成功已完成无处理中执行失败失败待重试清理临时资源处理中超时待处理清理临时资源失败待重试重试处理中无失败待重试超过重试上限永久失败告警每次状态转换都走统一的入口函数函数内部做条件更新和补偿触发。这样状态流转就是可控、可审计的不会出现莫名其妙卡住的情况。6. 可观测性没有日志和追踪错误处理就是盲人摸象错误处理做得好不好很大程度上取决于你能不能快速定位问题。如果线上出错了你只能看到一句任务失败那再完善的重试机制也救不了你。可观测性是错误处理的眼睛。6.1 结构化日志让错误可检索Agent 系统的日志必须是结构化的每条日志都要带上关键字段task_id、step_name、attempt、error_type、error_message、duration。这样出问题时你可以用 task_id 把所有相关日志串起来一眼看出卡在哪一步。import structlog logger structlog.get_logger() logger.error( tool_call_failed, task_idtask_id, step_namequery_knowledge_base, attempt3, error_typetransient, error_messagestr(e), duration_mselapsed, )结构化日志的另一个好处是便于统计。你可以按 error_type 聚合看看哪类错误最多按 step_name 聚合看看哪个步骤最容易失败。这些数据是优化错误处理策略的依据。6.2 分布式追踪看清整条调用链Agent 的一次任务往往涉及多次模型调用、多次工具调用跨越多个服务。没有分布式追踪你根本不知道时间花在哪里、错误发生在哪一环。我用 OpenTelemetry 给每个任务建一个 trace每个步骤建一个 span。span 上记录输入输出、耗时、状态。出问题时打开追踪面板整条链路一目了然哪个 span 报错、哪个 span 耗时异常、重试了几次。追踪数据还能帮你发现隐藏问题。比如你可能会发现某个工具调用的 P99 延迟特别高虽然没报错但拖慢了整个任务。这种问题光看日志是发现不了的。6.3 关键指标重试率、失败率、卡死率除了日志和追踪还要盯几个核心指标重试率重试请求占总请求的比例。这个指标突然升高说明下游有问题。最终失败率重试后仍然失败的比例。这个指标反映系统的真实健康度。中间态卡死率卡在中间态超过阈值的任务比例。这个指标高说明状态管理有问题。平均重试次数反映重试策略是否合理。太高说明下游不稳定太低可能重试不够。这些指标要配告警。比如重试率超过 20% 持续 5 分钟就告警中间态卡死率超过 1% 就告警。告警阈值要根据实际业务调整不能照搬。6.4 错误样本留存为复盘提供素材最后一点也是我觉得最有价值的把失败的请求样本完整留存下来。包括输入、模型输出、工具返回、错误堆栈。这些样本是复盘的黄金素材。我习惯把失败样本存到一个专门的表里定期review。很多错误处理策略的优化都是从这些样本里找到灵感的。比如你可能会发现某类格式错误反复出现那就该在 prompt 里加约束某个工具频繁超时那就该考虑换实现或者加缓存。7. 熔断、降级与限流系统级保护的三道防线前面讲的都是单个请求层面的错误处理。当整个依赖服务出问题时需要系统级的保护机制。熔断、降级、限流是三道经典防线在 Agent 系统里同样适用。7.1 熔断器快速失败避免雪崩熔断器的逻辑是当某个依赖的失败率超过阈值时直接拒绝后续请求不再尝试调用。经过一段冷却期后放少量请求试探如果成功就恢复失败就继续熔断。这个机制的价值在于避免资源耗尽。如果依赖服务挂了你的 Agent 还在不断发起请求、等待超时、重试线程池很快就被占满整个服务跟着挂掉。熔断器让请求快速失败保护了自身。在 Agent 场景里我建议给每个外部依赖模型服务、知识库、数据库都配一个熔断器。用现成的库比如pybreaker或者 Resilience4j 就行不用自己造轮子。7.2 降级有损但可用熔断之后请求被拒绝了但用户还在等结果。这时候需要降级策略用次优方案替代。Agent 系统的降级可以有很多层次。模型服务挂了可以降级到规则引擎或者缓存答案知识库挂了可以降级到只做意图识别不做检索工具调用失败可以降级到返回暂时无法处理请稍后重试。降级的关键是提前设计好降级路径而不是等出问题了临时想。每个关键依赖都要问自己如果它挂了我能用什么替代替代方案的效果差多少用户能接受吗7.3 限流保护自己也保护下游限流是控制请求速率的手段。在 Agent 系统里限流有两个方向入口限流保护自己不被压垮出口限流保护下游不被自己压垮。入口限流用令牌桶或者漏桶算法控制并发任务数。出口限流针对每个下游依赖单独配置比如对模型服务限制每秒 50 个请求对知识库限制每秒 100 个。限流和重试要配合使用。如果被限流了重试时要退避更久否则会加剧限流。有些实现会在收到 429 时读取Retry-After头按服务端建议的时间重试这是最优雅的做法。7.4 三道防线的协同熔断、降级、限流不是孤立的要协同工作。我的经验是请求进来先过入口限流超了就排队或拒绝调用下游前检查熔断器状态熔断中直接走降级调用时过出口限流控制对下游的压力调用失败按错误类型决定是否重试重试仍失败走降级或标记失败这套组合拳打下来系统在依赖不稳定时依然能保持基本可用而不是整体崩溃。8. 一些踩坑之后的经验之谈写到这里核心的技术点基本讲完了。最后分享几个我在实际项目里踩过的坑都是文档里不会写、但特别容易中招的地方。第一个坑重试放大了非幂等操作。早期我们给所有工具调用都加了重试结果一个发送通知的工具被重试了三次用户收到了三条一样的通知。后来我们给工具加了幂等键并且把有副作用的工具单独标记才解决这个问题。教训是加通用重试之前先确认所有被重试的操作都是幂等的。第二个坑超时时间设得太短。有段时间我们发现模型调用失败率特别高排查后发现是超时设成了 10 秒但模型 P99 延迟是 15 秒。大量正常请求被误判为超时触发了无谓的重试反而加重了模型服务负担。后来把超时改成 30 秒失败率立刻降下来了。超时时间要基于实际延迟分布来定不能拍脑袋。第三个坑日志里没有 task_id。早期日志是散的出问题后根本串不起来。有一次排查一个偶发失败花了一整天就是因为日志里没有统一的追踪 ID。后来强制要求所有日志必须带 task_id 和 step_name排查效率提升了十倍不止。第四个坑状态更新和业务逻辑写在同一个事务里。我们曾经把改状态和调用外部 API放在一个数据库事务里结果外部 API 慢的时候事务一直不提交把数据库连接池占满了。教训是外部调用不要放在数据库事务里状态更新要独立提交。第五个坑重试没有上限。有个任务因为参数错误一直失败但我们的重试逻辑没有区分错误类型一直重试把队列堵死了。后来加了错误分类和重试上限永久性错误直接失败才解决。重试必须有上限而且上限要按错误类型区分。这些坑的共同点是它们都不是技术难题而是设计疏忽。错误处理难就难在这里它考验的不是你懂多少算法而是你有没有把各种边界情况想清楚。我的建议是在设计 Agent 流程时专门花时间做一次失败模式分析列出所有可能失败的点逐个问失败了会怎样怎么恢复会不会有副作用。这个练习做下来你的系统健壮性会有质的提升。Agent 的工程化实践是个持续迭代的过程没有一劳永逸的方案。随着业务复杂度上升、调用量增长新的问题会不断冒出来。但只要把错误分类、重试、幂等、状态管理、可观测性这几个基础打牢大部分问题都能被有序地处理掉而不是演变成线上事故。