AI Agent高可用实战:错误处理与优雅降级策略
AI应用开发做到后段你会发现真正拉开差距的不是提示词写得有多花而是系统在模型出错、工具超时、供应商限流的时候还能不能保持体面。错误处理与优雅降级就是高可用Agent系统的底气。这篇我把做Agent这几年踩过的坑和用过的方案整理一下希望能给正在做AI应用开发的团队一点参考。中小自研公司的AI应用开发岗位往往就是一个人扛一条链路模型调用、工具编排、提示词维护、异常兜底全都要管。很多人最开始关注的都是“怎么把Agent跑起来”很少有人会提前想“跑起来之后它挂了我怎么收场”。等到用户量上来模型偶发抽风、第三方接口不稳定这些问题集中爆发才发现错误处理根本不是写几个try-except那么简单。这篇文章就从故障分类、重试策略、熔断机制、降级编排、排查实录五个方面把高可用Agent系统的搭建思路完整过一遍。1. Agent系统的高可用挑战为什么传统的try-catch不够用1.1 必须先分清三类典型故障做传统后端的人对错误处理都有肌肉记忆数据库连不上就重试接口报错就捕获服务挂了就切备用节点。但Agent系统的故障源多了一个不可控维度——模型本身。我把日常运维中遇到的故障分成三类处理思路完全不同。第一类是LLM调用层故障。模型服务返回429限流、5xx服务器错误、网络超时有时候是OpenAI那边波动有时候是我们自己的网络出口不稳。这类错误特征很明显有状态码、有响应耗时适合用重试、退避、切换供应商来解决。但LLM还有一种更隐蔽的故障返回了200响应也正常但内容完全不符合预期。比如让它从用户输入里提取结构化信息它给出了一堆废话或者偷偷把JSON字段名改成了别的。这种“没有状态码的故障”最坑光靠异常捕获发现不了必须配合输出校验和验证器去兜底。第二类是工具调用层故障。Agent的核心价值在于能调用外部工具可外部工具恰恰是最不受控的。第三方接口超时、数据结构变更、鉴权Token过期、上游服务限流这些我全遇到过。更常见的情况是模型把参数传错了比如天气查询Agent把城市名填成“北京市北京市”工具那边直接404模型还一本正经地回了一句“北京市今天晴”。这种故障说白了是模型的幻觉传导到了工具侧不是简单的接口报错需要在调用工具前做参数合法性校验调用后做返回值结构校验。第三类是上下文层故障。这是Agent系统独有的问题。长会话跑了几十轮Token窗口快满了系统提示词和用户目标发生冲突历史消息里有脏数据把模型的思路越带越偏。这类问题没有状态码也不会抛异常但它会让Agent的行为慢慢劣化。上下文压缩、记忆裁剪、会话重置这些都属于高可用设计的一部分而且比处理API报错更麻烦。1.2 高可用不只是“不崩”还要“能恢复”和“能降级”我在给内部团队做方案评审的时候习惯把Agent的高可用拆成三个递进层次。第一层是“可感知”。每一个环节的成功率、耗时、输入输出都要能追踪。模型调用失败了几次、哪个工具响应最慢、哪类Prompt最容易触发解析异常这些数据是后续所有策略的基础。没有可观测性谈错误处理和降级都是盲人摸象。第二层是“可恢复”。故障发生后系统能自愈。重试、超时控制、熔断、切换备用模型都是在自动恢复的范畴内。这一层解决的是“偶发故障不打扰用户”的问题。第三层是“可降级”。当下游依赖全部不给力的时候Agent不要直接死掉也不要硬撑着输出一堆胡话而是要有节奏地退到能力边界内尽量把核心诉求完成或者诚实告诉用户哪些功能暂时不可用。降级做得好不好直接决定用户对系统的信任度。用这三层标准去衡量一个Agent基本就能判断它的生产成熟度。很多系统只做到了第一层日志里什么都有但出了故障只能靠人工介入那就还谈不上高可用。2. 错误处理核心模式重试、超时与熔断的配合2.1 重试机制指数退避和抖动才是关键重试是错误处理的地基但无脑重试比不重试更可怕。假设你有10个并发请求同时调用一个已经过载的模型接口失败后所有请求同时等待1秒再次发起那第2波还是同一个时间点打过去模型压力不减反增。要解决这个问题必须引入指数退避加抖动。我常用的公式是这样的每次重试的等待时间为基础延迟乘以倍率的N次方然后再加一个随机抖动偏移。import random import time def exponential_backoff_with_jitter(attempt, base_delay1.0, multiplier2.0, max_delay60.0): 计算第attempt次重试的等待时间 attempt从0开始计第0次表示第一次重试 exponent_delay min(max_delay, base_delay * (multiplier ** attempt)) # 全抖动在0和计算出的延迟之间随机取值 return random.uniform(0, exponent_delay) for attempt in range(1, 6): wait exponential_backoff_with_jitter(attempt - 1) print(f第{attempt}次重试等待 {wait:.2f}s) time.sleep(wait)实测下来这个公式的时间序列大致是第1次重试等待01秒第2次02秒第3次04秒第4次08秒第5次016秒。注意抖动是全抖而不是只加一个随机尾部。全抖动的好处是让所有重试请求在时间轴上均匀散开避免出现“重试风暴”。但还有三个点必须记住。第一不是所有错误都适合重试。鉴权失败、参数格式错误、Prompt模板变异这类4xx错误重试100次结果也一样反而浪费时间和额度应该直接走“修正输入或切换策略”的路径。第二重试前必须确认工具的幂等性。如果调用的工具是“创建订单”“扣减余额”重试可能导致重复扣款。这类写入型工具不能盲目重试必须带上幂等键或者查重逻辑。第三重试次数要有上限。我一般控制在2到3次再多就是对故障的不尊重了赶紧切降级方案才是正事。2.2 超时分三层设不要整个流程共用一个超时超时设计是另一个容易翻车的地方。很多团队图省事给整个Agent调用链设一个总超时比如30秒。结果LLM调用占了20秒剩下10秒要跑3个工具每个工具一慢就整体超时用户那边啥也没得到。我的做法是把超时拆成三层单次模型调用超时、单次工具调用超时、单轮Agent总超时。每一层独立设置层与层之间留足缓冲。一次完整的Agent回答大致会经历“意图识别→调用若干工具→生成最终回复”这几个阶段。如果业务上一轮最多调用3个工具每个工具网络超时设为5秒LLM调用超时设为30秒那单轮总超时至少要有60到80秒的预算不能卡得太紧。总超时卡太紧的后果是工具确实慢但Agent还没来得及降级整个会话就被顶层超时掐断了用户什么都看不到。我见过一个团队把单轮总超时设成40秒LLM调用一次就花20秒工具调用只分配了20秒结果日志里全是“Agent执行超时”故障分析半天才发现是预算分配不合理。2.3 熔断器从微观重试到宏观保护的跃迁重试解决的是单个请求的偶发失败但当下游服务已经持续挂了5分钟重试只会加剧雪崩。这时候需要熔断机制——一旦错误率达到阈值直接不再调用该下游服务让系统“快速失败”而不是反复撞墙。熔断器有三个状态关闭Close、打开Open、半开Half-Open。关闭状态下请求正常通过失败次数累计失败超过阈值就切换到打开状态所有请求直接拒绝打开状态持续一段时间后进入半开状态放少量试探请求如果成功率回升就恢复关闭如果还是失败就重新打开。from datetime import datetime, timedelta class CircuitBreaker: def __init__(self, fail_threshold5, recovery_threshold0.5, open_seconds30): self.fail_threshold fail_threshold # 多少次失败触发熔断 self.recovery_threshold recovery_threshold # 半开状态下通过率恢复阈值 self.open_seconds open_seconds # 打开状态持续时间 self.state closed self.failure_count 0 self.last_open_time None self.total_half 0 self.success_half 0 def allow_request(self): if self.state open: if datetime.now() self.last_open_time timedelta(secondsself.open_seconds): self.state half_open self.total_half 0 self.success_half 0 return True return False return True def record_success(self): if self.state half_open: self.success_half 1 self.total_half 1 if self.total_half 5 and self.success_half / self.total_half self.recovery_threshold: self.state closed self.failure_count 0 else: self.failure_count 0 def record_failure(self): if self.state half_open: self.total_half 1 if self.total_half - self.success_half 2: self.state open self.last_open_time datetime.now() else: self.failure_count 1 if self.failure_count self.fail_threshold: self.state open self.last_open_time datetime.now()Agent场景的熔断粒度和微服务不太一样。我的习惯是按工具维度熔断而不是全局限流。今天搜索服务不稳定那就只熔断搜索工具Agent还能用计算器和代码解释器如果一家模型供应商持续5xx那就熔断这个供应商同时切换到备用模型。按维度熔断的好处是故障隔离边界清晰用户受影响的功能面最小。3. 核心环节实现错误处理埋点与降级策略编排3.1 四个环节各埋各的雷一个典型Agent的内部链路大致分为四段任务规划、模型调用、工具执行、结果输出。我见过最混乱的代码结构是在同一个函数里先调模型又调工具又解析结果出了错就catch到一个大块里看起来都处理了实际上哪个环节出了问题根本看不出来。正确的做法是在四个环节分别做埋点和策略加持。先说任务规划环节。Agent要把用户的一句话拆解成几个子任务这一步本身就会出错。模型可能把任务拆多了、拆少了或者拆出来的任务互相矛盾。实战中的兜底方案是“规则引擎模型解析”双通道模型拆解失败时回退到预设的任务模板或者直接把原始问题交给下游模型自行作答避免因为规划失败导致整个Agent停摆。然后是模型调用环节。这里要管的是限流、超时、返回格式异常。我的标准策略是先重试两次第一次退避1秒第二次退避2秒再失败就切换备用模型。切换模型有个小细节备用模型的能力差异会影响后续工具调用质量所以切换后要顺手调整工具调用的参数校验阈值不能无脑照搬原模型的参数。工具执行环节是故障高发区。工具可能超时、可能返回格式不匹配、可能模型把必填参数漏了。我的做法是给每次工具调用包一层“参数预检”和“返回值校验”。参数预检先跑一个轻量JSON Schema校验字段缺失直接重试生成而不是把脏参数发给第三方返回值校验则确保工具返回的数据结构能被下游正确消费发现结构不对就标记“此工具不可用”并触发对应的降级策略。最后是结果输出环节。模型生成最终回答时可能输出超长、可能忘掉引用工具結果、可能输出格式不符合前端要求。这里建议做截断保护、必要字段校验和流式输出缓冲。模型输出超长时强制截断是一个必须做的保护策略不然单轮回复就可能把Token预算吃穿。3.2 降级策略从“正常模式”到“兜底模式”分四档所谓优雅降级核心在于“降级不是一下子降到地板而是按阶梯往下走”。我一般会把Agent的降级状态分成四档每档对应不同的模型、工具集和系统提示词。degradation_levels { 0: { desc: 正常模式, model: gpt-4o, tools: [calculator, search, code_interpreter], system_hint: }, 1: { desc: 精简模式, model: gpt-4o-mini, tools: [calculator, search], system_hint: 当前处于精简模式请使用更短的思考过程完成用户请求。 }, 2: { desc: 只读模式, model: gpt-4o-mini, tools: [search], system_hint: 当前仅可检索信息禁止执行任何写入类操作。 }, 3: { desc: 兜底模式, model: gpt-4o-mini, tools: [], system_hint: 当前外部工具全部不可用请明确告知用户本次回复无法调用工具并给出基于自身知识的最佳回答。 } }从正常模式降到精简模式的条件一般是LLM调用成功率连续1分钟低于80%或主要工具的错误率超过20%。再往下走要看故障影响面如果所有外部HTTP工具都挂了说明网络出口或上游服务出了大问题这时候应该降到只读模式甚至兜底模式。这里有一个关键原则降级必须是可逆的而且恢复不能太快。我见过团队把熔断恢复窗口设成2分钟结果服务在降级和正常之间抖动了整整一下午用户一会儿看到完整功能一会儿看到残缺功能体验比一直降级还差。实际经验是恢复窗口至少给35分钟确保故障确实平息了再自动抬升。还有一个容易忽略的点降级后要诚实告知用户。不要默默换了轻量模型然后假装啥都没发生也不要让用户以为搜索结果还在但其实是模型瞎编。兜底模式下的Prompt要明确让模型说出“当前外部工具暂不可用以下是基于自身知识的回答”这样用户才能正确理解输出内容。诚实降级是维护系统信任度最重要的操作之一。4. 优雅降级实操从“能跑”到“跑得稳”的关键细节4.1 分级降级的触发、执行与自动恢复真正把降级策略落到线上需要考虑的远远不止“配置一个级别表”。触发条件、执行时机、恢复策略这三件事每一项都有无数坑。触发条件不能只看单次错误要看滑动窗口内的统计值。我在生产环境用的是1分钟窗口每分钟统计一次LLM成功率和工具成功率连续两个窗口跌破阈值才触发降级。只看一个瞬时的错误率很容易被偶发的网络抖动误导。比如一次5秒钟的DNS抖动会让成功率瞬间跌到20%但其实服务已经自愈了这时候降级就是误伤。执行时机也要琢磨。如果Agent正在处理一个已经进行到一半的请求突然检测到需要降级不要强行打断当前这个回合。正确做法是让当前请求继续跑完从下一个请求开始切换降级模式。强行打断当前请求可能让用户这次交互直接失败而且这种失败不会因为降级而变得更好只会更糟。恢复策略我前面已经提了核心是“慢恢复”。进入降级后持续探测主要依赖的成功率当连续5分钟成功率超过85%再逐步回升。回升也是逐级的不要从兜底模式一口气跳回正常模式先升到精简模式跑一会儿稳定了再升回正常模式。4.2 上下文压缩与记忆裁剪Token窗口的“降压药”Token窗口爆掉是长会话Agent的高可用杀手。用户连续提问几十轮之后系统提示词加上历史消息可能已经逼近模型上下文上限。不处理的话模型会丢失早期的关键约束开始“失忆”然后产生非常业余的回答。我常用的压缩策略是保留系统提示词和最近N轮完整对话把中间的历史消息压缩成摘要。这个方法在LangChain里叫ConversationSummaryBufferMemory原理其实很简单。def estimate_tokens(messages): # 粗略估算Token数中文按每个字符0.6 Token、英文按每个词0.3 Token total 0 for msg in messages: content msg.get(content, ) if isinstance(content, str): total len(content) * 0.6 else: total len(str(content)) * 0.6 return int(total) def compress_context(messages, max_tokens6000, keep_recent4): if estimate_tokens(messages) max_tokens: return messages # 分离系统提示词和最近对话 sys_prompts [m for m in messages if m.get(role) system] recent_msgs messages[-keep_recent:] old_msgs messages[:-keep_recent] # 把旧消息压缩成一条摘要插入到系统提示词后面 old_content .join(m.get(content, ) for m in old_msgs if m.get(role) ! system) summary_text 【历史会话摘要】 old_content[:500] ... compressed sys_prompts [{role: system, content: summary_text}] recent_msgs return compressed压缩时要特别注意两点。第一用户最初提出的核心目标必须保留。如果用户在20轮前说过“帮我写一篇关于新能源的调研报告”压缩摘要里就必须包含这个原始需求否则后面的回答就偏离主线了。第二压缩本身也会消耗Token。如果压缩的旧消息特别多可以优先用轻量模型做摘要或者直接按时间衰减截断只保留最近几轮和最重要的指令不要试图把所有内容都塞进去。这个操作本质上是在“信息完整度”和“可用长度”之间做权衡要在线上数据里反复调参才能找到自己业务的最优点。5. 常见问题与排查技巧实录5.1 LLM返回的JSON翻来覆去解析失败怎么办这是AI应用开发里最经典的坑。你在Prompt里写了三遍“只输出JSON”模型下次照样给你来一段带markdown代码块、带着客套话的混合文本。如果你只写一个json.loads就完事那Agent到了生产环境就是三天两头的异常。我的三层兜底解析策略可以给你参考import json import re def parse_model_json(raw): # 第一层直接尝试解析 try: return json.loads(raw) except json.JSONDecodeError: pass # 第二层提取json 代码块 match re.search(r(?:json)?\s*([\s\S]*?)\s*, raw) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass # 第三层截取第一个{和最后一个}之间的内容把前后废话全部去掉 start raw.find({) end raw.rfind(}) if start ! -1 and end ! -1 and end start: try: return json.loads(raw[start:end 1]) except json.JSONDecodeError: pass raise ValueError(模型输出中无法解析JSON: raw[:200])三层兜底之后基本能解决95%以上的格式问题。但还差最后一步解析成功不等于字段完整。解析出来的对象必须再做一次Schema校验缺字段、字段类型不对都要按异常处理重新让模型生成一次或者走降级。5.2 工具超时引发的连锁反应比你想的更严重我手上有一个很典型的案例。某次做AI文档分析项目PDF解析工具偶尔会慢到10秒以上而正常只要1秒。当时工具调用只设了重试没设超时结果一个PDF解析卡住后Agent的下游步骤全部排队等待用户的HTTP连接已经断开服务端还在傻傻地跑任务。这个问题暴露了两个设计缺陷一是单次工具调用没有独立的超时上限二是工具超时后没有后备路径。排查了很久才在日志里定位到根源是工具重试次数太高重试时间叠加超过了对话总超时预算。解决问题的方法是给工具包加“总超时预算”概念。比如单轮对话中所有工具调用的总耗时不能超过15秒一旦超过后面的工具调用全部拒绝强制Agent走无工具作答路径。从那次之后我所有Agent工具层的配置里都会加上超时自动截断只保留一次快速重试的机会不再让工具调用无限拖住整个对话。5.3 警惕降级带来的“假成功”降级策略上线后你还得留意一个新问题假成功。有些团队图省事catch到异常后直接在catch块里返回一个默认的成功响应比如“请求成功数据为空”。这对用户来说是非常有害的——用户以为系统正常处理了实际上什么都没查到还因为这个“成功”的标志系统监控面板上显示一切正常。我有一条铁律降级路径里返回的数据必须显式标记is_degraded字段。前端收到这个标记后要在界面上明确展示“当前为降级模式部分能力不可用”。后端日志里也要把这个字段放进关键链路追踪方便后来排查时一眼看出某次回答来自正常模式还是降级模式。排查假成功问题时最有效的动作就是去统计is_degraded为真的次数占比。如果一个Agent 40%的回答都来自降级模式那它不是高可用是在苟延残喘。别把降级当成万能药它只是帮你争取修复时间的缓冲垫核心还是要改善依赖服务本身的稳定性。5.4 常见问题速查表现象可能原因排查重点模型调用频繁触发重试费用飙升重试次数过多、没有正确区分4xx和5xx检查重试日志状态码5xx重试、4xx直接走修正路径降级后回答质量明显下降降档过深直接跳到兜底模式看看降级层级判断条件是否过于激进恢复窗口是否太短导致反复降级熔断后迟迟不恢复半开状态的试探请求太少或阈值太严格检查熔断器半开后的通过率统计适当放宽恢复阈值某一工具卡死导致整轮对话超时工具没有独立超时控制给每个工具设置独立超时并限制单轮工具总耗时预算用户反馈界面显示数据为空但无异常降级路径返回了假成功检查降级分支是否显式标记is_degraded前端是否感知到该标记长时间会话后模型“失忆”Token窗口超限压缩策略没有生效检查上下文压缩触发条件和摘要保留内容是否完整写在最后从我自己的体会来讲稳健的Agent从来不是靠某个超强模型撑起来的而是靠“在模型不靠谱的时候系统依然没有崩”。重试退避、超时分层、熔断隔离、阶梯降级、上下文压缩这些机制每一个单拎出来都是后端的老套路但组合到Agent系统里就需要根据LLM的不确定性重新设计一遍。最后分享一个小技巧把错误处理和降级相关的配置做成动态可调的放到配置中心不要写死在代码里。重试次数、熔断阈值、降级触发条件、恢复窗口这些参数线上都是要反复调的。写死的话每次调参都要走一次发版流程会错过故障响应的最佳时间。我现在的做法是提供一个配置面板运营和研发都能看到当前Agent处于哪一级降级状态排查问题的时候效率高很多。这些经验建议在团队的AI应用开发SOP文档里固化下来不然每次出故障都靠个人记忆去翻早晚会栽跟头。