ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

Agent工程化实践:错误处理、重试与幂等设计指南 1. 为什么错误处理才是 Agent 工程化的分水岭做 Agent 开发的人大概都有过这种体验Demo 阶段一切丝滑工具调用、多轮推理、记忆读写全都跑得通可一旦放到真实环境里跑上几天日志里就开始出现各种似曾相识的报错——模型请求失败请稍后重试、agent execution terminated due to error、模型本轮只输出了思考过程、没有产出正文。这些问题单看每一条都不难难的是它们会以组合拳的形式出现把一个原本能自愈的流程彻底打崩。我做了几年 Agent 相关的项目越来越确信一件事Agent 的能力上限由模型决定但 Agent 的可用性下限由错误处理决定。一个只会 happy path 的 Agent 是玩具一个能在各种异常下优雅降级、自动恢复、不产生副作用的 Agent 才是产品。这也是为什么错误处理与工程化实践值得单独拿出来讲——它不是一个功能点而是贯穿整个 Agent 生命周期的骨架。这篇文章面向的是已经写过至少一个能跑通的 Agent、现在准备把它推向更真实场景的开发者。我会围绕重试策略、幂等设计、错误分类、状态一致性、可观测性这几条主线把我在实际项目里踩过的坑和总结出的方案摊开讲。核心关键词就四个Agent、错误处理、工程化实践、重试、幂等。读完之后你应该能给自己手上的 Agent 搭出一套扛得住真实流量的容错体系而不是每次出问题都靠重启服务祈祷。先说一个反直觉的结论大部分 Agent 的错误其实不是错误而是没被正确分类的中间状态。模型输出空正文、工具返回超时、上下文超限这些在业务语义上完全是不同性质的事件但很多框架把它们一股脑塞进except Exception里统一重试结果就是该重试的没重试、不该重试的疯狂重试最后把配额烧光、把下游打挂。所以整篇文章的起点就是先把错误这件事本身拆清楚。2. Agent 错误分类先搞清楚你在处理什么2.1 四类错误与它们的本质差异在动手写任何重试逻辑之前我习惯先把 Agent 运行过程中可能出现的异常分成四类。这个分类不是学术上的严谨划分而是从该怎么处理这个实用角度出发的。错误类型典型表现是否可重试处理策略瞬时故障网络抖动、模型请求超时、限流 429是指数退避重试语义失败模型只输出思考过程无正文、工具参数格式错误是有限次调整提示词后重试资源约束上下文超限、token 预算耗尽、并发超限视情况压缩上下文或降级永久错误工具不存在、权限不足、参数非法否快速失败并上报瞬时故障是最容易处理的也是重试机制的主战场。它的特征是同样的请求再发一次大概率能成功比如模型服务端的偶发 5xx、网络层的连接重置。这类错误的关键是退避策略后面会详细讲。语义失败是最容易被忽视的一类。热词里那句模型本轮只输出了思考过程、没有产出正文系统已自动重试 2 次就是典型的语义失败。模型没报错HTTP 200但返回的内容对业务来说是不可用的。这类错误如果直接当成功处理下游会拿到空数据如果当永久错误处理又浪费了模型其实差一点就对了的机会。我的做法是把它归为可重试但需要干预——重试时不是原样再发而是追加一句约束比如请直接输出最终答案不要包含思考过程。资源约束类错误在长会话 Agent 里特别常见。上下文超限、token 预算耗尽这些不是 bug是设计边界。处理方式不是重试而是降级压缩历史、丢弃低优先级记忆、切换到更小的模型。热词里1m 上下文已经全量可用这种提示本质上就是在告诉你资源边界在哪你需要据此设计降级路径。永久错误最忌讳的就是重试。工具名拼错了、API key 失效了、参数类型不对这些重试一万次也不会成功只会拖慢失败反馈、浪费资源。这类错误应该快速失败并且把清晰的错误信息透传给上层或用户。2.2 错误分类的落地一个可复用的判定函数光有分类表不够得能落到代码里。我通常会在 Agent 的异常处理层写一个判定函数把原始异常映射到上面四类。下面是一个简化版的 Python 示例思路比实现更重要from enum import Enum class ErrorKind(Enum): TRANSIENT transient # 瞬时故障可退避重试 SEMANTIC semantic # 语义失败需干预重试 RESOURCE resource # 资源约束需降级 PERMANENT permanent # 永久错误快速失败 def classify_error(exc: Exception, context: dict) - ErrorKind: # 网络层与限流 if isinstance(exc, (ConnectionError, TimeoutError)): return ErrorKind.TRANSIENT if getattr(exc, status_code, None) in (429, 500, 502, 503, 504): return ErrorKind.TRANSIENT # 上下文与预算 if context length in str(exc).lower() or token budget in str(exc).lower(): return ErrorKind.RESOURCE # 语义层模型返回了但内容不可用 if context.get(empty_completion) or context.get(only_reasoning): return ErrorKind.SEMANTIC # 工具与参数 if isinstance(exc, (KeyError, TypeError, ValueError)): return ErrorKind.PERMANENT return ErrorKind.PERMANENT # 未知错误默认快速失败避免盲目重试注意未知错误默认归为永久错误这是一个刻意的保守选择。宁可快速失败让人看到也不要盲目重试把问题掩盖掉。等你确认某类未知错误其实是瞬时的再把它挪到 TRANSIENT 里。这个函数的价值在于它把要不要重试这个决策从散落各处的try/except里抽出来变成一个统一的、可测试的、可观测的判定点。后面所有的重试、降级、上报逻辑都基于这个分类结果来分派。2.3 为什么分类比重试本身更重要我见过太多项目重试逻辑写得花里胡哨——指数退避、抖动、熔断全都有但错误分类一塌糊涂结果就是用正确的工具做了错误的事。一个永久错误被重试了 5 次不仅浪费了 5 次调用还让真正的失败信号延迟了十几秒才暴露出来一个资源约束错误被当成瞬时故障重试每次都因为上下文还是那么长而再次失败纯属空转。分类做对了重试策略其实很简单分类做错了再精妙的退避算法也救不了。这也是我把它放在最前面的原因——错误处理的第一性原理是先识别再行动。3. 重试机制从粗暴循环到指数退避加抖动3.1 朴素重试的三个致命问题新手写重试最常见的就是这样for i in range(3): try: return call_agent() except Exception: continue这段代码有三个问题。第一没有退避三次调用几乎在同一瞬间发出如果下游是因为过载而失败这样只会雪上加霜。第二没有区分错误类型永久错误也重试三次。第三没有上限保护如果call_agent内部又嵌套了重试就会出现重试的乘法效应3 层各重试 3 次就是 27 次调用。3.2 指数退避与抖动的参数计算正确的重试应该是指数退避加随机抖动。公式很简单delay min(base * (2 ** attempt), max_delay) * (1 random_jitter)我常用的参数是base0.5s、max_delay30s、jitter0.3。算一下实际延迟第 1 次重试约 0.5s第 2 次约 1s第 3 次约 2s第 4 次约 4s第 5 次约 8s之后被 max_delay 截断在 30s 附近。抖动的作用是打散多个客户端同时重试造成的尖峰——如果 100 个请求同时失败、同时按固定间隔重试就会形成周期性的流量脉冲抖动让它们错开。为什么 base 选 0.5s 而不是 1s因为 Agent 场景下很多瞬时故障比如模型服务的偶发限流恢复得很快0.5s 起步能更快拿到结果用户体验更好。为什么 max_delay 是 30s因为超过 30s 的重试对交互式 Agent 来说已经失去意义了用户早就等不及了这时候应该走降级或失败路径而不是继续等。3.3 重试预算给重试本身设一个天花板单次调用的重试次数好控制难控制的是整个 Agent 任务的重试总量。一个复杂任务可能包含十几次模型调用和工具调用如果每次都能重试 5 次最坏情况下这个任务会发起几十次调用成本和时间都失控。我的做法是引入重试预算retry budget给每个任务分配一个总预算比如 20 次重试机会每次重试消耗 1 个预算耗尽后所有后续调用直接快速失败。这样即使某个环节陷入重试循环也不会拖垮整个任务。预算的数值需要根据任务的复杂度和成本敏感度来定我一般从平均调用次数的 2 倍起步再根据线上数据调整。class RetryBudget: def __init__(self, total: int): self.remaining total def consume(self) - bool: if self.remaining 0: return False self.remaining - 1 return True实操心得重试预算要和任务超时配合使用。光有预算没有超时一个慢速失败的任务可能耗光预算还占着连接光有超时没有预算快速失败的任务可能瞬间打满重试次数。两个一起用才稳。3.4 语义失败的重试不是重发是修正前面提到语义失败需要干预重试。具体怎么做以模型只输出思考过程没有正文为例我的处理是第一次失败后在消息历史里追加一条系统提示明确要求输出格式第二次失败后降低温度参数或切换到更稳定的模型第三次还失败就判定为永久错误返回兜底内容。这种逐级升级的重试策略比单纯重发有效得多因为它每次都在改变输入条件而不是期待同样的输入产生不同的输出。热词里系统已自动重试 2 次逐级提升输出预算说的就是这个思路——重试不是重复是升级。4. 幂等设计让重试变得安全的前提4.1 为什么重试必须配幂等重试有一个隐藏前提重复执行不会产生额外副作用。如果一次工具调用是给用户发一条消息重试三次就发了三条用户会疯。如果一次操作是扣款 10 元重试三次就扣了 30 元这是事故。所以幂等不是可选项是重试机制能成立的地基。幂等的意思是同一个操作执行一次和执行多次对系统状态的影响相同。注意是对状态的影响相同不是返回结果相同——第二次调用可以直接返回第一次的结果这也算幂等。4.2 幂等键的设计与生成实现幂等的核心是幂等键idempotency key。每次操作生成一个全局唯一的键服务端记录这个键对应的执行结果重复请求直接返回缓存结果。键怎么生成关键是同一个逻辑操作在重试时必须用同一个键。所以键不能每次调用时随机生成而应该由操作的业务语义决定。常见做法是idempotency_key hash(task_id step_name normalized_params)task_id标识整个任务step_name标识任务内的哪一步normalized_params是归一化后的参数比如把字典按 key 排序再序列化。这样同一个任务的同一步骤无论重试多少次键都一样。注意参数归一化很重要。如果参数里包含时间戳、随机数、请求 ID 这类每次都变的东西键就会每次都不同幂等直接失效。生成键之前一定要把这类易变字段剔除或替换成稳定值。4.3 幂等性检查用 DB 还是 Redis这是热词里被反复问到的问题我的答案取决于场景维度DB 实现Redis 实现持久性强重启不丢弱可能丢性能中等高一致性强一致最终一致适用场景资金、订单等关键操作高频、可容忍偶发重复的操作实现复杂度低唯一索引即可中需处理过期与竞争我的经验是关键操作走 DB高频非关键操作走 Redis两者可以叠加。具体做法是 Redis 做第一层快速去重挡住绝大部分重复请求DB 做第二层持久化保证防止 Redis 丢数据后重复执行。DB 层用唯一索引实现插入幂等键时如果冲突就说明是重复请求直接查已有结果返回。CREATE TABLE idempotency_records ( idem_key VARCHAR(128) PRIMARY KEY, result JSONB, created_at TIMESTAMP DEFAULT NOW() );插入时用INSERT ... ON CONFLICT DO NOTHING然后查一次结果。这个模式简单、可靠几乎适用于所有关系型数据库。4.4 幂等与 Agent 工具调用的结合Agent 的工具调用是幂等设计的高发区。我的做法是给每个工具定义一个idempotent标记读操作查询、检索天然幂等直接标记为 true写操作发送、创建、修改默认 false需要显式实现幂等键机制才能标记为 true。对于标记为 false 的工具重试策略要特别小心——要么不重试要么在重试前先做一次状态确认比如先查一下消息是否已发送。这个确认步骤本身必须是幂等的读操作否则又陷入循环。5. 状态一致性与断点恢复5.1 Agent 的状态到底存在哪Agent 和普通无状态服务最大的区别是它是有状态的而且状态会跨多次调用演进。一次任务可能经历规划→调用工具→观察结果→再规划多个循环中间任何一步失败都需要知道我进行到哪了。状态通常分三层会话状态对话历史、记忆、任务状态当前步骤、已完成步骤、中间产物、执行状态正在进行的调用、锁。前两层需要持久化第三层通常是临时的。我的做法是把任务状态显式建模成一个状态机每一步的完成都写一次持久化。这样即使进程崩溃重启后也能从最后一个已完成的步骤继续而不是从头再来。这就是断点恢复。5.2 断点恢复的实现要点断点恢复的关键是步骤的原子性。一个步骤要么完全完成并记录要么完全没发生。如果步骤执行到一半崩溃恢复时必须能判断出这一步没完成需要重做而重做又要求这一步是幂等的——你看幂等又一次成为前提。具体实现上我会给每个步骤记录三态pending、running、done。执行前写running执行成功后写done。恢复时running状态的步骤视为可能执行了一半需要根据幂等性决定是重做还是查询确认。def resume_task(task_id): steps load_steps(task_id) for step in steps: if step.status done: continue if step.status running: # 可能执行了一半先确认状态 if confirm_step_effect(step): mark_done(step) continue execute_step(step) # 幂等执行实操心得running状态是最危险的因为它意味着不确定。我通常会给running状态加一个超时超过一定时间还停留在running就强制进入确认流程。这个超时值要大于步骤的最长执行时间否则会误判正在正常执行的步骤。5.3 并发场景下的状态竞争Agent 扛并发是热词里高频出现的问题。多个请求同时操作同一个任务状态时会出现经典的竞态两个请求都读到pending都去执行结果执行了两次。解决办法是乐观锁或悲观锁。乐观锁用版本号读取时带上版本写入时检查版本是否变化变了就重试。悲观锁用数据库行锁或分布式锁直接串行化。Agent 场景下我更倾向乐观锁因为并发冲突通常不频繁乐观锁开销更小。UPDATE task_steps SET status done, version version 1 WHERE id ? AND version ?;如果影响行数为 0说明版本被改过需要重新读取再决策。这个模式简单有效配合幂等执行能扛住大部分并发场景。6. 可观测性让错误处理可调试6.1 结构化日志是底线错误处理最怕的不是出错是出错后不知道发生了什么。我见过太多项目日志里只有一句agent execution terminated due to error没有任何上下文排查全靠猜。结构化日志是底线。每条日志至少包含task_id、step_name、error_kind、attempt、duration、error_message。用 JSON 格式输出方便后续检索和聚合。logger.error(agent_step_failed, extra{ task_id: task_id, step_name: step.name, error_kind: kind.value, attempt: attempt, duration_ms: elapsed, error: str(exc), })6.2 关键指标与告警阈值光有日志不够还需要指标来发现趋势。我关注的几个核心指标重试率重试次数 / 总调用次数。持续高于 10% 说明下游有问题。各错误类型占比瞬时故障突然升高通常是下游抖动永久错误升高通常是代码或配置问题。任务成功率端到端成功的任务占比这是最终的用户体验指标。P99 延迟重试会拉长延迟P99 是重试策略是否过激的晴雨表。告警阈值我一般设成重试率连续 5 分钟超过 15% 告警任务成功率低于 95% 告警P99 延迟超过基线 2 倍告警。阈值不是拍脑袋定的要基于历史数据先观察一周再定。6.3 追踪把一次任务的所有调用串起来Agent 的一次任务会发起很多次调用分散在不同服务、不同日志里。没有追踪你根本拼不出完整的执行链路。我的做法是给每个任务分配一个trace_id所有相关调用都带上这个 ID日志和指标都按它聚合。这样排查问题时只要拿到trace_id就能看到这个任务从开始到失败的全部调用序列、每步的耗时、每步的错误。效率比翻日志高一个数量级。7. 常见问题与排查速查表7.1 高频问题速查现象可能原因排查方向解决思路重试后仍然失败错误分类错误永久错误被重试看 error_kind 分布修正分类逻辑重试导致重复副作用缺少幂等保护检查写操作是否有幂等键补幂等机制重试风暴打挂下游无退避或退避过短看重试时间分布加指数退避和抖动任务卡在 running步骤崩溃未清理查 running 超时加超时和确认流程上下文超限反复失败资源错误被当瞬时错误看错误信息关键词走降级而非重试并发下状态错乱缺少锁或版本控制查并发写入加乐观锁7.2 几个我踩过的坑坑一重试放大了下游的限流。早期我没加抖动所有客户端按固定间隔重试结果下游的限流窗口被周期性打满本来能恢复的服务一直恢复不了。加了抖动之后立刻好转。坑二幂等键包含了时间戳。有次排查为什么幂等没生效发现键的生成里带了now()每次重试键都不同等于没做幂等。这个坑很隐蔽因为代码看起来有幂等逻辑但实际失效。坑三把语义失败当成功。模型返回了空正文代码判断response is not None就认为成功结果下游拿到空数据报错错误定位绕了一大圈。后来加了内容有效性校验才解决。坑四重试预算没设一个坏任务烧光了配额。某次一个任务陷入重试循环因为没有总预算它一直重试到把当天的模型配额耗尽影响了所有其他任务。加了预算之后这类问题再没出现过。7.3 排查的通用思路遇到 Agent 错误我的排查顺序是先看 trace_id 拉全链路再看 error_kind 判断性质再看 attempt 判断重试是否合理最后看幂等和状态是否一致。这个顺序能覆盖 90% 的问题剩下的 10% 通常是业务逻辑本身的 bug那就得回到代码里看了。8. 工程化落地把上面这些串成一个框架8.1 分层架构把前面讲的东西组织起来我通常分成四层执行层实际调用模型和工具抛出原始异常。分类层把原始异常映射成四类错误。策略层根据错误类型决定重试、降级还是失败管理重试预算。状态层持久化任务状态支持断点恢复保证幂等。这四层各司其职互不耦合。执行层不需要知道重试策略策略层不需要知道状态怎么存。这样任何一层要改都不会牵动其他层。8.2 配置化而非硬编码重试次数、退避参数、预算大小、超时阈值这些都不应该硬编码在代码里。我把它们抽成配置按任务类型区分。比如查询类任务重试可以激进一点写操作类任务重试要保守。配置化之后调参不用改代码、不用重新部署线上就能调整。8.3 测试错误处理错误处理最难测因为异常路径平时不触发。我的做法是注入故障在测试环境里人为让模型超时、让工具返回错误、让上下文超限验证重试、降级、恢复是否按预期工作。这些测试用例平时跑得少但每次改错误处理逻辑都必须跑一遍否则很容易改出新问题。实操心得故障注入测试最好做成自动化的集成到 CI 里。我见过太多项目错误处理逻辑改完之后没人测上线才发现重试不生效或者降级路径有 bug。自动化测试是唯一可靠的保障。8.4 渐进式上线错误处理的改动影响面很大不要一次性全量上线。我的做法是先在一个小流量的任务类型上灰度观察重试率、成功率、延迟的变化确认没问题再逐步扩大。灰度期间要盯紧指标一旦发现异常立刻回滚。9. 一些关于 Agent 错误处理的个人体会做了这么多 Agent 项目我最大的体会是错误处理不是给系统打补丁而是系统设计的一部分。你在设计 Agent 架构的时候就应该把哪里会失败、失败了怎么办、怎么恢复想清楚而不是等出了问题再补。补出来的错误处理往往是零散的、互相冲突的而设计进去的错误处理是统一的、可演进的。另一个体会是简单可靠胜过花哨。我见过有人给 Agent 设计了复杂的熔断、降级、自愈机制结果因为逻辑太绕出了新问题反而更难排查。相比之下一个清晰的错误分类加一个朴素的指数退避往往能解决 80% 的问题。剩下的 20% 再针对性处理不要一上来就上重型武器。最后幂等这件事怎么强调都不为过。它是重试的前提是断点恢复的前提是并发安全的前提。如果你的 Agent 里有任何写操作还没有幂等保护我建议你先把这件事补上再谈其他优化。因为一个不幂等的系统重试越多错得越离谱。这套东西我在几个项目里反复打磨过从最初的手忙脚乱到现在基本能从容应对各种异常中间踩的坑都写在上面的速查表里了。如果你正在给自己的 Agent 补错误处理希望这些经验能帮你少走点弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进