
先说我上个月遇到的一件事。我们线上那个客服Agent用户问“帮我查一下上个月的订单物流”Agent先去调订单系统没问题再去调物流查询第三方API直接超时重试了两次之后模型居然还在回复用户“正在为您查询请稍等”。用户等了一个世纪最后收到一句“系统繁忙请稍后再试”。这还不算最糟的——更糟的是订单查询这个动作本身已经成功了但Agent以为整个链路失败自动开启了一个补偿任务去“撤销查询”结果把一条正常订单状态给改了。这就是Agent系统不做错误处理、不做优雅降级的典型现场。这篇文章我想认真聊聊AI应用开发里最容易被低估的一块错误处理与优雅降级。它不是一个“捕获异常然后弹个错误提示”那么简单而是整个Agent系统高可用设计的核心。我会从错误分类、异常体系、重试策略、降级层次、可观测性、故障复盘这几个维度把我在生产环境踩过的坑和沉淀下来的做法一次讲清楚。不管你是刚开始做Agent开发还是已经在维护线上系统这篇应该都能给你一些可落地的参考。1. 先想清楚一个问题Agent系统到底错在哪里Agent系统不是传统后端。它由“模型调度 工具调用 流程编排”三层组成每一层都有自己的失败模式而且失败模式之间还会互相影响。比如模型API因为限流报错了你重试结果工具已经被调用了一次这就引入了重复操作的问题。我刚接触Agent开发的时候第一反应是把所有代码用try-except包起来捕获到异常就记个日志、返回个“系统繁忙”。后来发现这个思路完全不对因为Agent系统的错误绝大多数不是“能捕获的异常”而是“看似正常但结果错误”的静默失败。1.1 为什么Agent错误处理和传统后端不一样传统后端服务比如一个订单API输入是确定的处理逻辑是确定的输出是确定的。出错无非是参数不对、数据库挂了、代码有bug这些都是“可枚举”的错误catch住然后返回固定错误码就能解决。Agent系统完全不是这么回事。模型API是概率性的同一个问题今天问和明天问返回的格式、语气、内容都可能不一样工具调用的参数是模型“自己”生成的而不是代码写死的所以经常出现模型觉得参数对了、但实际接口根本调不通的情况再加上多步编排第一步失败很有可能导致第二步用错了上下文而模型又不会主动告诉你“我上下文丢了”它只会一本正经地继续往下编。拿传统Web应用的“超时重试”来对比维度传统后端Agent系统失败确定性错误类型可枚举处理逻辑固定错误来源多、随机性强模型还会“编造成功”状态管理请求上下文由代码控制上下文由模型维护跨步骤容易丢失失败可见性异常会冒泡到调用方模型常把失败“包装”成正常话术重试安全性幂等性容易设计工具调用可能已生效重试会重复执行恢复策略错误码映射即可需要重试、降级、补偿、人工兜底多级配合这说明了一个核心问题Agent的错误处理本质上不是“处理异常”而是在一个高度不确定的系统里建立一条确定性足够强的边界。边界之内每一步都有明确的结果判断边界之外才允许失败往外传递。1.2 一张错误分类表搞定“错在哪”我们团队后来把所有Agent相关失败归纳成了四大类每类单独定处理策略模型侧错误模型API超时、限流rate limit、5xx、token耗尽、内容被审核拦截。特征是可以重试但重试策略要严格尤其限流时不能猛重试。工具侧错误外部API返回异常、参数schema不匹配、权限不足、第三方服务宕机。特征是有可能通过“换工具”或“换参数”恢复不能无脑重试。编排侧错误Agent进入死循环、上下文超过窗口、工具链调用顺序错乱、模型“宣称成功但实际没执行”。特征是最隐蔽通常要加循环上限、状态校验、结果确认才能发现。业务侧错误用户输入语义不明确、业务规则不满足、数据质量差。特征是不该重试也不该降级而是该澄清用户意图或转人工。这四类错误现在写进我们每个Agent项目的README里。设计阶段就统一语言大家容易被模型API限流这类问题吸引但实际生产环境里编排侧和业务侧的“静默错误”破坏力更大——因为它们不报错你甚至不知道出了问题。2. 第一道关卡把异常当成一等公民来设计2.1 先建结构化异常体系别什么都Catch Exception我见过太多Agent项目整个代码库就是一堆try: ... except Exception: return 系统繁忙。这会导致一个致命问题不同性质的错误进入同一条失败路径重试、降级、告警全部失效。正确的做法是第一周就把异常基类定义清楚class AgentError(Exception): Agent系统统一异常基类 def __init__(self, message: str, *, retryable: bool False): super().__init__(message) self.retryable retryable self.trace_id None # 由中间件注入 class RetryableError(AgentError): 模型超时、5xx、限流等可重试错误 def __init__(self, message: str): super().__init__(message, retryableTrue) class FallbackError(AgentError): 需要触发降级路径的错误 def __init__(self, message: str, fallback_level: str model): super().__init__(message, retryableFalse) self.fallback_level fallback_level class FatalError(AgentError): 不可恢复错误直接中断并走兜底话术 def __init__(self, message: str): super().__init__(message, retryableFalse) class OutputValidationError(AgentError): 模型输出不符合schema的错误 def __init__(self, message: str, raw_output: str ): super().__init__(message, retryableTrue) self.raw_output raw_output为什么必须按“retryable”来分而不是按“模型错误 / 工具错误”来分因为同一个错误在不同的运行阶段可恢复性是完全不同的。举个例子模型API超时发生在“生成最终回复”这个环节重试很有意义但如果发生在“调用支付工具”之后你就得先查支付到底成功没成功就不能重试。按“是否可重试”分类才能驱动后续的重试、降级、熔断策略正确执行。所有Agent代码里凡是会碰到“外部不确定性”的边界——模型调用、工具执行、数据库读写——都只抛这三类异常最外层只做一次except AgentError兜底不允许裸捕获Exception。这个规矩立下来之后排障效率提升了一个量级。2.2 重试不是越勤越好指数退避和抖动才是关键重试是Agent系统里最常用的容错手段但也是最容易出问题的。模型API限流的时候如果每个请求都在同一秒重试限流只会更严重。我们第一次把Agent压到线上的时候遇到过一限流就所有并发请求同时重试、结果把模型API网关打得更死的“惊群”事故。后来我们在所有重试逻辑里统一加了指数退避和抖动import random import time from functools import wraps def retry_with_backoff( func, max_retries: int 3, base_delay: float 0.5, max_delay: float 8.0, ): wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except RetryableError as e: if attempt max_retries - 1: raise # 指数退避 抖动避免惊群效应 sleep_time min(max_delay, base_delay * (2 ** attempt)) sleep_time random.uniform(0, base_delay) print(f[retry] attempt{attempt 1}, sleep{sleep_time:.2f}s) time.sleep(sleep_time) return wrapper为什么要在指数退避上再加一个随机抖动因为指数退避只解决了“重试间隔变长”的问题没有解决“多个请求同时重试”的问题。假设50个并发请求同时失败它们会以完全相同的节奏退避、完全相同的时间点回来形成一波又一波的冲击。加了随机抖动重试请求就会在时间轴上散开这才是对上游模型API友好的做法。另一个必须强调的是幂等重试。重试前先判断操作是否真的需要重试如果错误发生在“工具已成功执行但响应没回来”这个阶段重试会导致用户被重复扣款、重复下单。我们的做法是给每个Agent请求生成一个request_id并在发起工具调用时把request_id传给外部系统如果报超时先查一次这个request_id是否已经有成功记录有就直接取结果没有才允许重试。这个“查询式重试”比无脑重试安全太多。2.3 超时、熔断与限流给Agent装上刹车Agent系统比传统Web更需要全局超时控制。传统接口超时一般是单次调用超时但Agent一条请求要调模型、调工具、再调模型、再调工具如果每次调用都给30秒超时五步下来用户要等两三分钟体验完全不可接受。我们给所有Agent任务都设了“绝对截止时间”deadline。比如总预算60秒这个预算在任务启动时记录import time class AgentDeadline: def __init__(self, total_budget: float 60.0): self.deadline time.monotonic() total_budget def time_remaining(self) - float: return max(0.0, self.deadline - time.monotonic()) def check_or_raise(self): if self.time_remaining() 0: raise FatalError(agent deadline exceeded)每个环节执行前都调用check_or_raise()超了就立刻中断不再发起新的模型调用或工具调用直接走到兜底话术。这个设计让线上不会再出现“卡可几分钟没响应”的情况——用户最多等一分钟一定会收到一个明确结果。熔断也值得单独说一下。以前我们只做单次请求的重试后来发现模型API连续故障时系统会变成“所有请求都在空转重试”整体吞吐急剧下降。借鉴微服务里的断路器思路统计最近一分钟的错误率模型调用错误率超过50%就打开熔断器后续请求不再打模型API直接走降级路径过了冷却时间后放少量探测流量成功率达到阈值就关闭熔断。这个机制帮我们在模型侧故障时保住了整个客服系统的可用性——别的模块不受影响用户至少还能收到“系统正忙”之外的替代回复。3. 优雅降级让系统在有限能力下继续干活错误处理解决的是“哪些错能救、怎么救”降级解决的是“救不了的时候系统还能为用户做什么”。我以前以为降级就是返回一个“系统繁忙”后来发现一个设计得当的Agent在模型API全挂的时候依然能完成一部分核心诉求——这才是优雅降级的意义。3.1 模型层降级主备模型切换而不是干等模型API故障是最常见的。我们一开始的策略是“主模型重试三次失败就告诉用户系统繁忙”。后来发现模型提供商A挂了但提供B还活着这为什么不能切过去于是我们给LLM客户端加了一个简单的“模型路由”class FailoverLLMClient: def __init__(self, models: list[str], timeout: float 15.0): self.models models self.timeout timeout self._circuit_state {name: closed for name in models} def chat(self, messages, **kwargs): for model in self.models: if self._circuit_state[model] open: print(f[skip] {model} circuit open) continue try: return self._call_model(model, messages, **kwargs) except (RetryableError, FatalError) as e: print(f[failover] {model} unavailable: {e}) self._circuit_state[model] open # 触发熔断冷却 continue raise FatalError(all models unavailable)这里的要点是备选模型不能随便选。我们线上备了一个响应速度快、价格低一档的轻量模型只在主模型不可用的时候切换。切换之后系统要主动把“当前降级模型”这个信息写进回复头里方便后续排查。另外记住降级后Prompt最好也换一版。比如主模型能处理复杂工具调用轻量模型调工具容易出错那就让它走“只读查询简洁回复”模式。这其实是把系统提示词工程和Skill Agent结合起来的一个典型场景——每个降级路径都要有专属的Prompt、专属的Skill定义不能一套提示词走天下。3.2 工具层降级外部能力不可用时怎么补位Agent的很多工具是外部依赖比如天气查询、物流查询、订单同步。这类工具挂掉的降级方案重点不是“换个工具”而是“评估这个动作是不是必须实时拿结果”。我们有三个降级选项按优先级排列换同类型工具物流查询API挂了换另一个物流数据服务商。换查询方式实时API挂了改用本地缓存的最近一次同步数据哪怕不是最新并在回复里注明“数据时间戳为2小时前”。标记不可用并继续这个工具提供不了就明确告诉用户“该功能暂时不可用”但对话流程不中断用户还能继续聊别的问题。最忌讳的是模型不知道工具挂了还在硬编。我们会在工具层的异常捕获里把降级结果已经变成“结构化状态”而不是异常继续上抛。比如tool_result { status: degraded, tool: logistics_query, message: 实时查询不可用已返回缓存数据更新于2025-01-10 10:00, data: cached_data, }这样模型拿到这个结果之后就知道该怎么组织话术而不是自己脑补一个“系统正常”的回复。3.3 流程层降级从复杂编排退回单轮问答这是最后一道防线也是最容易被忽视的。一个Agent如果编排链路特别复杂——它要先理解意图、再调多个工具、再汇总生成——那整条链路的失败概率是“每一步成功率相乘”。步骤越多越脆弱。我们给所有核心Agent设计了一条“降级链路”完整多步 Agent调用工具多轮推理 ↓ 失败 简化 Chain去掉工具调用只做单轮问答 ↓ 失败 固定兜底话术转人工 / 推荐客服电话简化Chain的实现其实很直接把“是否允许调用工具”这个开关关掉让模型只基于系统提示词里预先准备好的一些静态信息库比如常见FAQ来回答。这保证了Agent在模型API可用、但工具层大面积故障时还能提供“信息查询”层面的价值而不是直接摆烂。这里要再强调一个点降级不是无限嵌套。我们的原则是降级最多两级再多就会引入新的不可控因素——比如降级逻辑本身出bug、用户在高延迟链路上等待两次超时。二级降级失败就直接走固定兜底不再尝试。3.4 兜底回复怎么设计才不算“摆烂”兜底回复是最容易翻车的地方。我很早就发现让模型自己临场发挥“应对失败”是一件极其危险的事——它会道歉、会猜原因、甚至会编造一个解决方案。所以我们的兜底话术是由代码强制注入而不是由模型生成的。注入之前先判断用户诉求紧急程度分两档来写低紧急诉求“当前服务暂时无法处理您的请求请您稍后重试。”这个适合查天气、问百科这类不痛不痒的场景。高紧急诉求“您的问题我已记录系统将在恢复后优先为您处理如需求紧急请拨打客服热线400-xxx-xxxx。”这个适合查订单、改预约、处理售后等场景。为什么高紧急的兜底要把“转人工”给出来因为优雅降级的本质是诚实地暴露当前限制同时给用户一个可行动的下一步。光说“系统繁忙”是把用户在死胡同里一推让Agent直接编一个“我帮您处理好了”则是更大的灾难。兜底方案宁可保守也绝不能让用户误以为任务成功了。4. 让高可用可观测、可复盘、可SOP化错误处理和降级做到位了还有一个问题你根本不知道系统在某一秒到底在重试、降级还是正常运行。没有可观测性的高可用就是盲人开车。这一节讲我们怎么做追踪、盯指标和复盘。4.1 链路追踪每一步都要留痕Agent比传统接口复杂的地方在于一次用户请求会衍生出多次模型调用、多次工具调用、多次重试和降级决策。如果这些过程没有统一追踪线上出问题你会面对一堆散乱的日志根本拼不出完整故事。我们强制所有Agent服务接入同一个日志规范核心字段就五个trace_id一次用户请求全局唯一、event_type模型调用/工具调用/降级触发/重试/兜底、model_name、duration_ms、error_type。每发生一个关键事件打一行结构化日志def log_event(trace_id, event_type, **fields): record { trace_id: trace_id, event_type: event_type, ts: time.time(), **fields, } logger.info(json.dumps(record, ensure_asciiFalse))线上排查的时候输入一个trace_id就能看到这个请求从开始到结束的全部时间线和每个环节的决策。这一步看似简单但没做之前我们一个问题平均要开三四个服务看日志才能拼起来做了之后基本是“一个trace_id定位所有”。有条件的话接一个Langfuse或LangSmith这类在线追踪平台能直接图形化看到每一步模型调用和工具调用的情况。我们在团队内部是先用Langfuse跑通随后沉淀自己的OpenTelemetry标准化——毕竟长期看Agent追踪迟早要融入公司统一的可观测体系。4.2 监控指标与告警不盯这五个数等于没做高可用有一些指标我们每周复盘会固定看线上告警也围绕这几个数来设指标含义我们用的告警阈值整体错误率Agent最终返回失败的比例3分钟平均 5% 告警重试率所有请求中发生重试的比例3分钟平均 30% 关注降级率触发任何一层降级的比例3分钟平均 10% 告警P95耗时95%请求的端到端时长超过SLO目标即告警Token消耗用量和对应成本成本环比突增50%告警为什么重试率和降级率也要盯因为它俩是“隐性故障”的风向标。有时候整体错误率不高但重试率已经飙升说明模型API正在制造大量“表面成功、实际需要补偿”的操作降级率升高则说明依赖的第三方服务正在出问题虽然用户还能沟通但服务质量已经大幅下滑。SLO这里有一个很实用的思路把“优雅降级成功”也算作可用的一部分。我们定义的客服Agent SLO是“每月整体可用性≥99.5%其中对用户产生了有效回复即算可用触发降级且兜底话术生效的请求也算可用不计入不可用时间”。这样设计不是为了粉饰指标而是鼓励降级路径被真正执行——只要降级是“知情、诚实、给了用户下一步”的它就应该被认可为一次成功的服务交付。4.3 一次故障复盘如何沉淀成SOP我坚持团队每两周做一次“故障复盘日”。不是等出了大事故才复盘而是把线上小毛病的样本攒起来变成团队知识库。复盘模板是固定的时间线什么时间开始出现错误什么时间发现什么时间恢复。根因分类模型侧 / 工具侧 / 编排侧 / 业务侧。影响范围影响多少用户、多少请求、是否触发降级。处置动作谁做的、做了什么、为什么选这个方案。沉淀载体改进项落在三个层面——代码层增加分支、提示词层修改System Prompt、监控层新增告警。这套模板看起来不稀奇但坚持下来效果巨大。比如有一次我们复盘时发现某类银行验证码工具的失败率特别高但之前一直没人发现是因为它被包在了一个大异常的catch里。复盘后我们把“按工具维度统计成功率”加到了报表里不到一周就定位到了这个第三方的接口兼容性问题。现在这套模板已经固化成团队的SOP文档——每类错误、每个降级点、每个兜底方案都有明确入口写在项目仓里。新同事接手Agent项目的时候第一件事不是看业务代码而是把这个SOP文档读一遍直接知道“线上出错该往哪里看”。5. 常见问题排查实录与团队落地建议5.1 生产环境踩过的几个经典坑坑一模型返回的JSON根本没法解析。我们最初让模型以JSON格式返回结构化信息以为设置个response_format就万事大吉。结果生产环境里收到过Markdown包裹的JSON、带尾逗号的JSON、缺字段的JSON。现在所有模型输出都要过一道“schema校验自动修复”管道先剥掉json标记再做json.loads失败就尝试正则补全依然失败就把这段原文带回“重试提示模型上次格式错误”的流程里。这个坑不踩一次你不知道模型在真实世界里有多不守规矩。坑二重试导致重复扣款。有次线上支付工具调用超时了重试逻辑自动触发结果同一笔订单被扣了两次款。从那以后我们所有资金类工具都加了一条硬规则超时之后不允许直接重试必须先通过幂等键查询结果。对了这个经验我们最后还做成了工具层的统一中间件任何工具只要声明“非幂等”重试就会被自动拦截。坑三模型说“已取消”实际没取消。这是编排侧最可怕的一种静默失败。模型生成了“好的我已经帮您取消了预约”但工具调用其实因为参数格式错误根本没执行。模型的逻辑很简单它生成的是一个“计划中的动作描述”不代表动作已被执行。现在我们的Agent框架会强制检查工具调用的返回状态并且把“工具执行确认”作为一个独立步骤回填给模型——只有确认成功的执行结果才能成为模型生成回复的依据。坑四上下文无限膨胀。用户对话一长历史消息累积下来直接把模型API的token上限打爆返回400。我们给所有多轮Agent加了一个简单的上下文管理器接近上限时先把历史摘要成一段话再塞回上下文。看似简单没有这个长对话基本必炸。坑五Agent在失败路径里死循环。工具反复失败、模型反复尝试、重试、再失败整个链路卡了四五分钟。现在所有Agent都带最大步数限制比如最多执行6步工具调用超过就强制中断走兜底。5.2 错误排查速查表现象可能原因快速处置长期改进模型API频繁超时网络抖动 / 模型侧过载 / 并发过高触发主备模型切换降低并发增加熔断器、完善模型路由策略工具返回值经常解析失败工具方接口变更 / 模型生成参数错误抓包对比真实schema临时修复参数映射给工具层加“参数预校验”中间件Agent回复“成功”但用户没收到结果模型未确认工具执行结果检查trace_id的event_type序列定位“断点”强制工具执行确认步骤重试导致重复操作重试前未做幂等判断立即关停该工具自动重试对非幂等工具实现“查询式重试”用户等很久没响应无全局截止时间 / 死循环中断当前Agent执行统一接入AgentDeadline限制最大步数这个表我们贴在团队群里日常值班照着做即可不用每次重新分析。5.3 团队落地先做三件事如果你所在团队刚开始做Agent系统不用一口气把上面所有机制全部上齐——那是过度设计。我建议按顺序只做三件事第一统一异常注入。花一天时间把项目的异常体系收敛好所有模型、工具边界只抛统一异常并打上trace_id。没有这个基础后面所有监控和降级都是空谈。第二给兜底话术“先写好”。所有核心Agent上线之前产品经理、运营、开发一起把“失败时用户会看到什么”定下来。这比优化“成功路径”更重要——因为成功路径的体验你有100种方法调失败路径的体验一旦崩了用户很难再给你第二次机会。第三搭一个最粗糙的降级开关。不需要做到全自动熔断哪怕是一个人工切换的配置开关也行——模型API挂了你能手动按一个键让线上Agent退回单轮模式先保证“不死”再慢慢加自动决策。这三件事落地之后再去聊指标复盘、聊故障演练顺序就顺了。我的经验是错误处理这个领域切不可等架构完美再动手先建立最粗的确定性边界然后一步步往里面加弹性。我目前招人的时候也会重点问“你设计的Agent在模型API不可用时会怎样表现”这个问题——能讲清楚的人说明他真的在考虑Agent系统的生产落地问题而不只是跑通一个Demo。我个人对错误处理最大的体会是一个Agent系统的高可用其实不是靠“代码写得多健壮”堆出来的而是靠一套“无论发生了什么都还能给用户一个说得过去的交代”的体系兜住的。每次线上模型出故障我们的Agent降级到轻量模式虽然回答深度下降了但用户至少还能得到有效回复而不是整个客服系统变成一个挂在聊天窗口里的“转圈图标”。这套机制上线后我们团队有一个共识先把失败时的体验设计好再谈成功时的体验优化——这条顺序建议所有做Agent应用开发的人都在项目第一天就列到日程上。