说实话一个Agent能顺畅跑完一条完整任务链的机率远比你想象的低。模型超时、工具返回乱码、中间解析失败、上游服务限流……任何一个环节出问题都会让整个会话卡死或崩掉。所以我一直认为容错优化不是一个“有则更好”的加分项而是Agent能不能真正落地的生死线。这一篇我重点聊三块异常处理怎么分类、重试机制怎么设计才不烧钱、降级策略怎么做到“用户无感”。适合正在写Agent编排层、或者把Agent接入业务系统但总被不稳定折磨的开发者也适合那些刚学完Agent基础、想进阶工程化的同学。1.1 为什么Agent容错比普通后端容错更复杂如果你写的是一个普通接口容错的核心就是“重试 兜底返回”。但Agent不一样它不是一个原子操作而是一连串动态决策和工具调用的组合。举一个我踩过的例子一个天气查询Agent工作流是先让模型判断用户意图再决定是否调用天气API。某个下午第三方天气API超时了普通做法是“重试两次还失败就返回错误”。但问题来了——模型已经在这轮对话里生成了“我正在为您查询天气”的中间话术此时如果直接抛错用户的体感就是“机器人回了一半话然后死机了”。更麻烦的是重试期间用户可能又追加了新消息导致上下文顺序错乱。所以Agent容错真正的难点在于你不光要处理“某个调用失败了”这个事实你还得处理“失败发生在对话流的哪个位置”“用户看到什么状态”“下一轮该拿什么状态继续”。这就让容错从单点技术问题变成了需要分层设计的系统工程。1.2 分层容错模型从底层到顶层的三层防线我在几个项目里反复调整后最终固定下来一套分层容错模型每一层解决不同粒度的问题第一层是单点调用层负责最基础的异常捕获和重试解决“模型API超时了”“工具接口返回500”这类最具体的故障。这一层是重试机制的主战场。第二层是流程编排层负责感知当前整个任务执行到了哪一步、已经用了多少次工具调用、离预算上限还剩多少再决定是继续、跳过、重来还是终止。这一层解决的是“整个任务链接下来该怎么办”的问题。第三层是用户交互层负责把前面所有异常转化成用户能看懂、能继续操作的文案和操作选项而不是把一堆堆栈信息直接甩到用户脸上。很多Agent产品体验差不是模型不行而是这一层基本没做。这三层是递进关系单点调用层的失败可以被流程编排层消化流程编排层的决策结果最后要交给用户交互层得体地表达出来。我后来做代码评审时有个习惯先看异常处理在哪个层再看信息能不能传上去、传下去如果链路断了容错基本等于没做。2. 异常处理框架先分类再处置2.1 先把异常分好类处理逻辑才会简单我见过很多团队的Agent代码里就一个裸的try...except异常往日志里一打然后返回一串英文报错。这种做法的最大问题是把所有异常当成一样的导致该重试的重试了不该重试的该降级的也没降级。我建议把Agent运行期的异常分成三类确定性异常比如参数校验失败、Json解析报错、工具名不存在、必填字段缺失。这类异常有个特点重试一百次结果还是一样的因为问题出在代码和调用规则本身。对它们的正确态度是快速失败并把错误格式化成人类可读的信息。软性异常比如远端服务返回500、网络闪断、超时、限流。这类异常的特点是问题不在你这边在服务的瞬时状态上。它们是重试机制和降级机制的核心目标。模型推理异常这是Agent特有的一类包括上下文超过窗口限制、模型返回的Json格式残缺、多次函数调用参数不对、输出安全校验没通过等等。这类异常最隐蔽因为它们看起来是“返回了内容”但内容根本没法用。我的经验是把它们和普通异常分开抓甚至单独建一个异常子类因为处置策略完全不同。2.2 异常处置矩阵什么样的异常配什么样的动作在具体落地时我会用一张处置矩阵帮助团队做决策。这张矩阵是给所有开发成员看的它的核心价值在于让“异常处理”不是某个人临时拍脑袋而是有一套约定俗成的规则。异常类型是否重试是否降级处理方式参数校验失败否否快速失败返回校验错误详情Json解析失败尝试1次是先让模型重新生成仍失败则降级工具调用超时是可选重试2次后降级为直答或更换工具远端500错误是可选带退避的重试上限3次远端限流429是是通过Retry-After头做长退避触发熔断上下文超长否是做摘要压缩压缩失败则降级短模型内容安全校验失败否否直接终止不给重试机会这个矩阵看着简单但每条都是实践中磨出来的。比如“Json解析失败重试1次”这条调过模型的人都知道同一个问题让模型重新生成一次成功概率不低所以值得试一遍。但要设置上限——我见过模型连续五次把Json生成错了再重试就纯属浪费钱和时间。2.3 异常的对象化与结构化输出除了分类我强烈建议把异常处理从面向字符串改成面向对象。具体来说在Agent的核心执行器里自定义一个AgentError(Exception)基类子类至少包含三层信息错误码给程序看、人类可读信息给日志和用户看、结构化上下文给上层决策看。举个例子如果工具调用超时向上抛出的不是一个字符串而是class ToolExecutionError(AgentError): def __init__(self, tool_name, error_type, retryable, duration_ms): super().__init__(f[{error_type}] 工具 {tool_name} 执行失败) self.tool_name tool_name self.error_type error_type # timeout / http_500 / rate_limit self.retryable retryable # 是否允许重试 self.duration_ms duration_ms这样的好处很直接上层要决定“是重试、跳过还是降级”只需要看error_type和retryable不需要再去字符串里匹配关键词。做日志聚合时也可以直接用error_type做维度统计能非常直观地看出系统里哪类异常占比最高。3. 重试机制参数、策略与实现3.1 并不是所有失败都值得重试我见过最典型的重试滥用方式对所有异常都无脑重试3次结果服务已经挂了还在那里反复撞击把局部故障放大成了集体超时。所以我先说说哪些情况值得重试第一必须错误本身具备瞬时性。远端超时、网络抖动、服务重启导致的短暂连接失败这些重试有价值。参数错误、权限不足、内容违规这三种打死都不能重试。第二必须确认调用是幂等的。我有个项目里踩过很深的坑——天气API本身没问题但请求参数里的trace_id由服务端生成后每次重试都要带原来的trace_id结果重试时我重新生成了一个导致服务端把第二次请求当成了一笔新交易扣了两次费用。重试之前一定要确认同一个请求执行两次和一次结果一致否则要么修复幂等逻辑要么禁止重试。3.2 重试次数与退避参数怎么定关于重试次数和间隔我的经验是用公式而不是拍脑袋。这里给出一个比较稳的默认配置MAX_RETRY_TIMES 3 # 总重试次数 BASE_DELAY_SECONDS 1.0 # 首次重试间隔 MAX_DELAY_SECONDS 8.0 # 间隔上限 TIMEOUT_PER_ATTEMPT 5 # 单次调用的超时时间执行顺序是第1次失败后等1.5秒1.0乘随机抖动第2次失败后等3秒左右第3次失败后等约6秒。如果超过3次还失败就不再重试直接进入降级。这里有两个细节容易被忽略。一是指数退避的指数边界间隔翻倍增长但必须设上限不然第5次重试可能要等几十秒任何用户都等不了。二是抖动jitter这是让所有重试请求时间点不要完全一样而采用的随机偏移。如果不加抖动同一时间启动的几百个Agent会像锁死一样同时发出重试目标服务恢复前一刻被这一波重试流量压到崩溃。实现很简单在间隔上乘一个0.5到1之间的随机数就行。3.3 单次调用超时比重试次数更重要的参数很多人只调重试次数却忽略了单次调用超时。实际上我认为单次超时比分重试重要。把单次超时从30秒改成5秒再配上重试3次最差情况耗时是20秒而如果不设超时、傻傻等30秒再过重试最差情况可能是90秒以上用户早就走了。我见过不少团队在Agent调用模型API时用默认的超时时间结果一个正常的任务链要调5次模型每一次都卡在接近超时阈值整体延迟轻松突破一分钟。我的做法是给每类调用单独设置超时普通的模型推理5秒带工具选择的推理8秒长上下文摘要生成10到15秒。宁可偶尔失败走重试也不要让用户面对一个长时间转圈的窗口。3.4 一个可复用的重试装饰器实现这里提供一个我项目里沉淀下来的重试器可以放到Agent工具函数集合里直接用import time import random from functools import wraps def retry_with_backoff(max_times3, base_delay1.0, max_delay8.0, exceptions(TimeoutError, ConnectionError)): def decorator(func): wraps(func) def wrapper(*args, **kwargs): attempt 0 while attempt max_times: try: return func(*args, **kwargs) except exceptions as e: attempt 1 if attempt max_times: raise # 最后一次失败直接上抛 # 计算带抖动的退避时间 delay min(max_delay, base_delay * (2 ** (attempt - 1))) jitter random.uniform(0.5, 1.0) time.sleep(delay * jitter) return None # 理论不可达防御用 return wrapper return decorator使用方式就是给工具函数套一个装饰器retry_with_backoff(max_times3, base_delay1.0, max_delay6.0) def call_weather_api(city): # 这里是真实 HTTP 调用 ...需要说明的是这里的exceptions传入的很关键它白名单式地定义了哪些异常值得重试。如果业务里自定义的ToolExecutionError标了retryableTrue可以在进入wrapper前做一道判断不属于白名单的一律不重试。4. 降级策略从优雅到兜底的完整链路4.1 模型降级大模型不行就换小模型降级的第一层也是我认为性价比最高的一层是模型本身的降级。具体来说当主力模型连续失败或超时的时候把当前这个子任务的模型调用切换到备用的、更快的、成本更低的模型。我在一个项目中遇到过这样的情况主力模型的单个调用平均要8秒而备用模型只要2秒。某个时段主力模型持续超时我把Agent里的“意图识别”“关键词提取”这类小任务切到备用模型后整体成功率立刻回来了用户侧几乎感知不到差异。模型降级有个需要注意的细节降级前后的返回格式必须一致。如果你主力模型返回的是{city: 北京}备用模型却返回city北京这种格式那降级等于引入了新的故障。所以设计降级方案时前面所有模型的输出都要经过统一的输出解析层让上层感知不到换过模型否则整个流程还是得崩在解析上。4.2 工具降级同样的能力换个实现方式比模型降级更常见的其实是工具降级。很多Agent对外部API的依赖是功能性的比如“查天气”“算运费”“查单价”但实现同一个能力可以有多条路径。举个例子A项目的核心工具“街道路况查询”底层依赖的是某第三方实时路况API。某天这个API开始返回数据延迟重试两次还是不行。这时候如果直接把错误返回给用户体验极差。后来我在上游加了一个静态路况数据库——虽然数据是半小时前的但用户问的如果只是“今天哪条路最堵”这类粗粒度问题旧数据完全够用我会在回复里顺带说明一句“数据更新于20分钟前”。这个降级不仅挽救了体验还把一个完全失败的会话变成了可用会话。工具降级需要注意降级后一定要修改当前任务的置信度标记。比如工具名从live_traffic切到cached_traffic之后Agent在最终回复给用户时措辞要从“当前实时路况为”改成“根据最近一次数据”避免对用户说假话这是合规层面的底线。4.3 上下文降级长文本策略与历史压缩Agent跑长了之后一个隐蔽的降级需求是上下文相关的。当累积的对话历史太长、接近模型窗口上限时再不处理就直接崩了。这里容错策略就变成了主动掐断历史或者做摘要压缩。我常用的策略是“两段式压缩”。第一步把早期的低价值消息直接删掉只保留用户的核心意图和最近几轮对话。第二步如果窗口还是紧张就用一个便宜的模型把前面对话总结成一段摘要塞回上下文里。这两个动作都做了之后仍然超限才允许报错。这个降级策略我测试过很多次最大的坑是压缩摘要时负责压缩的模型会“读一遍”对话这个读的操作也要耗token和耗时。所以摘要压缩这个动作本身必须异步做不能阻塞用户当前的请求。但异步又会带来状态同步问题我的办法是先把摘要写进一个临时存储下一轮对话再读出来用保证用户看到的连贯性。4.4 兜底方案永远有一条用户能走的路降级链条的最后一步不是报错而是给用户一条能走的路。我做任何Agent项目都会要求产品经理和高层先明确一个“什么都失败时用户应该看到什么”的默认文案。我目前的兜底格式是这样的模板“抱歉当前服务暂不可用。您可以稍后再试或者联系人工客服联系方式。”不加堆栈信息不加错误码不假装它可以自己恢复。这个兜底文案看起来简单但它意味着我们的系统在“所有技术手段都失效”时仍然是有序的、有尊严的而不是狼狈地把内网异常直接暴露给用户。如果你做的是偏ToB的Agent工具我建议再往后走一步兜底文案里附带一个“生成问题报告”的按钮后台记录当时的trace_id、失败异常类型、上下文快照方便事后定位。这个按钮能帮你收获大量真实环境下的失败样本很多重试参数和超时阈值都是这么调出来的。5. 超时、熔断与可观测性工程配套三板斧5.1 三级超时体系重试机制只解决“失败了能不能再来一次”的问题但要保护整个Agent不卡死还需要超时控制。我强烈建议Agent项目搭建三级超时体系单次调用超时每一轮模型推理、每一次工具调用的独立超时前面已经提过。单轮任务超时一个子任务从开始到最终返回的总时长限制。比如执行“查询天气”整个链路的累计上限是16秒包含模型推理时间和工具调用时间。到点还没完成编排层直接判负走降级。整段会话超时整段对话的总时长预算或者总步骤数预算。我之前有个项目给Agent设定了一轮最多执行8次工具调用超过就直接收束结果防止Agent在某个问题上反复挣扎、无限循环。这三级的设置需要做估算。比如一轮有1次模型推理5秒1次工具调用5秒1次结果总结推理5秒那单轮任务超时至少是15秒我通常会留30%的余量定到20秒。如果定死15秒偶尔慢一点就直接误伤了正常请求。5.2 熔断器防止重试引发雪崩重试机制有个副作用如果某个下游服务已经故障所有Agent还在对它反复重试这个服务会被打得更烂从而引发雪崩。所以重试必须和熔断器配合着用。我在项目里写了一个极简的内存熔断器思路是统计最近一分钟内某个调用的失败率如果连续失败超过阈值就打开熔断开关import time from collections import deque class CircuitBreaker: def __init__(self, threshold5, window_seconds60): self.failures deque(maxlenthreshold) self.window_seconds window_seconds self.open_until 0 def allow_request(self): if time.time() self.open_until: return False # 熔断打开直接拒绝调用 # 清理超过窗口期的失败记录 while self.failures and (time.time() - self.failures[0]) self.window_seconds: self.failures.popleft() return len(self.failures) 5 def record_failure(self): now time.time() self.failures.append(now) if len(self.failures) 5: self.open_until now 30 # 熔断30秒注意这个熔断器的关键点一旦打开熔断后续所有请求会直接短路不再进入重试逻辑直接走降级。这样重试风暴在熔断层就被拦住了。而30秒之后熔断器自动进入半开状态——放一个小比例的试探测请求过去如果成功就关闭熔断如果失败就继续熔断。5.3 可观测性没有日志的容错等于白做最后是容错系统的“眼睛”——可观测性。坦率说如果一个容错系统没有配套日志出了问题根本没法排查。我给项目至少加了三类日志。异常日志每捕获一个异常至少记录异常类型、所在模块、工具名、耗时、用户trace_id。这里有个容易被忽略的小点异常日志的输出格式要带结构化字段比如 JSON 格式方便聚合统计。重试日志每次重试都要打一条日志标注第几次重试、间隔多少、错误是什么。重试日志的价值在于它能直接暴露“重试是否真的有效”。我见过一个项目重试了三天翻日志才发现所有重试都发生在同一个上游服务上而且重试后还是同样的错误码重试纯粹是浪费。降级日志每次触发降级都要打日志并记录降级前的原因和降级目标。降级日志直接反映了系统里各条链路的健康状况。如果降级日志占比过高说明核心依赖不稳定应该优先去修上游而不是在Agent这边缝缝补补。以上这些日志我最后都会汇总到一个追踪平台上用trace_id串联起“一次会话里调用过什么工具、失败在哪里、有没有重试、有没有降级、最终结果是什么”。复查问题的时候按trace_id一查整条链路清清楚楚。6. 常见问题与排查技巧实录6.1 重试风暴我在真实的Agent项目里遇到过最严重的一次事故某个公共接口偶尔返回500足以让线上500多个Agent几乎同时进入重试状态。那一刻所有请求叠加在一起把接口打到了彻底无响应连健康检查都挂了。后续排查出的问题就是集群里成百上千个请求在同一秒发出重试形成风暴。排查和解决思路是给重试加上指数退避和抖动参数再加全局级别的熔断器。熔断器层面的维度不只是单个Agent实例而是把请求限制到整个服务集群的共享存储维度来统计的防止每个实例各自为战。6.2 重试导致重复扣费从前面的天气API例子延伸出来重试的另一个风险是“重复扣费”。当你调用第三方付费API时服务端如果没有做幂等重试一次就等于扣一次钱。最严重的情况是某团队在账单上看了一笔费用异常原以为是一次正常调用其实是重试了3次产生了3次扣费。解决办法是两招组合第一调用方在请求头里加上一个自己的request_id要求服务端幂等。如果服务端不支持幂等就退而求其次——自己本地记录上一次请求的响应用同样的request_id查询结果避免重发。第二重试次数一定要压低像付费API这种我一般只允许重试1次而且要等至少3秒以上再重试给服务端留出返回结果的时间窗口。6.3 模型重试后的上下文污染有一个问题很容易被忽略模型上次生成了一段半截话术重试的时候要不要把这段内容带入上下文如果不处理重试生成的回复接在前面半句话后面就会出现“我在为您查询天气北京的当前温度——现在为您查询杭州天气”这种驴唇不对马嘴的拼接。我的经验是模型调用的重试不要复用在同一个上下文中继续追加而是用一份快照上下文。也就是说建一个上下文快照保存的是“这个子任务开始执行时的状态”每次重试都从快照出发而不是从“上次生成的结果”出发。代码实现上就是序列化快照和反序列化快照重试前一致重试后恢复。6.4 降级策略“反向”触发还有一个较少提到但危害很大的场景降级策略被某个代理转发或边缘缓存误导导致正常成功请求被误判为失败从而走了降级反而把正常的服务质量拉低了。比如某些网络环境里代理超时阈值设置得太小正常耗时1秒的请求被判定为超时然后被降级成低质量响应。遇到这种情况不要急着改重试和降级参数先用trace_id拉出一次完整调用链的耗时分布确定故障到底发生在哪一段。我建议降级触发的判断里加一个“最小延迟阈值”默认值设为100到200毫秒任何小于这个阈值的“失败”基本都是误判直接忽略。7. 从可用到靠谱Agent容错能力分级清单最后分享一个我常用来评估项目容错成熟度的清单。它按照从低到高的等级递进你可以对照检查自己的Agent处于哪个阶段。L0 基础可用级所有工具调用都有try...except异常能记录日志但不暴露堆栈给用户单一模型调用有超时和单次重试。L1 稳定可用级按异常类型区分是否重试重试采用指数退避和抖动单轮任务有总超时所有失败都有用户可读的兜底文案。L2 业务健壮级有模型降级和工具降级策略核心调用有熔断器付费和不可幂等的调用有专门的重复执行保护降级结果会异步追踪和统计。L3 生产可靠级上下文压缩策略和快照重试整段会话的步数限制和成本预算所有容错行为通过结构化日志和trace_id可追踪容错参数可以通过配置中心动态调整。我见过的很多团队停在L1觉得“够用了”直到一次流量高峰把上游服务击穿才痛定思痛开始做熔断和降级。容错这个东西永远是故障来临前先铺好路。你不需要一步到位做到L3但至少要明确自己现在处在哪个级别、下一步往哪走。我个人在实际操作中的体会是容错优化最忌讳贪多求全。一次性把重试、熔断、降级、上下文压缩全部写进代码大概率会因为各组件之间的参数冲突而引入新的不稳定因素。更好的方式是先从异常分类和重试参数调优做起再逐步叠加熔断和降级每一层都通过灰度流量验证过后再往下一层走。系统是不会一夜之间变可靠的但只要你每一层都扎实整体崩溃的概率会越来越低。
阅读完成 · 觉得有帮助?