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)我常用的参数是base=0.5s、max_delay=30s、jitter=0.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 补错误处理,希望这些经验能帮你少走点弯路。