Agent 的重试逻辑坑了我 $340——三种策略实测,最简单的反而省了 73%
代码先放这看完你就知道我为什么被坑了 $340。// 错误分类器区分可重试和不可重试错误typeRetryableStatus429|500|502|503|529;typeFatalStatus400|401|403|404|413|422;interfaceErrorClassification{retryable:boolean;backoffMs:number;maxRetries:number;reason:string;}functionclassifyError(status:number,errorBody?:string):ErrorClassification{// 429: 限流重试但要退避if(status429){constretryAfterparseRetryAfter(errorBody);return{retryable:true,backoffMs:retryAfter??2000,maxRetries:3,reason:rate_limited};}// 5xx: 服务端临时错误可重试if(status500){return{retryable:true,backoffMs:1000,maxRetries:2,reason:server_error};}// 4xx (除 429): 客户端错误重试就是浪费钱return{retryable:false,backoffMs:0,maxRetries:0,reason:client_error};}functionparseRetryAfter(body?:string):number|null{if(!body)returnnull;constmatchbody.match(/retry_after\s*:\s*(\d)/);returnmatch?parseInt(match[1])*1000:null;}你可能会想这不就是普通的错误分类吗有什么好讲的对但我在 Agent 场景下跑了三个月数据发现一个被大多数人忽略的事实——LLM API 的 4xx 错误占比远高于传统 Web API而这类错误重试多少次都不会成功。三种策略三个月数据我有个 Agent 系统每天处理约 2000 个任务每个任务平均调用 LLM API 三到五次。去年底换了个新模型月费突然从 $120 飙到 $340。排查了一圈才发现问题出在重试逻辑上。原来的代码长这样估计你也能在不少教程里看到类似的写法// 策略一无脑重试原来的写法asyncfunctioncallLLM(messages:any[],tools?:any[]){for(letattempt0;attempt3;attempt){try{constresponseawaitfetch(https://api.openai.com/v1/chat/completions,{method:POST,headers:{Authorization:Bearer${apiKey},Content-Type:application/json},body:JSON.stringify({model:gpt-4o,messages,tools})});if(!response.ok)thrownewError(HTTP${response.status});returnawaitresponse.json();}catch(e){if(attempt2)throwe;awaitsleep(1000*(attempt1));}}}看着没毛病对吧任何错误重试三次退避时间递增。但数据告诉我这套逻辑在 Agent 场景下有个致命问题。我写了个脚本统计了一个月的错误分布// 错误分布统计脚本interfaceErrorStat{status:number;count:number;retriedCount:number;successAfterRetry:number;wastedCost:number;}asyncfunctionanalyzeErrorLogs(logPath:string):PromiseErrorStat[]{constlogsawaitreadLogs(logPath);conststatsnewMapnumber,ErrorStat();for(constlogoflogs){conststatstats.get(log.status)??{status:log.status,count:0,retriedCount:0,successAfterRetry:0,wastedCost:0};stat.count;if(log.retried){stat.retriedCount;if(!log.eventuallySucceeded){stat.wastedCostlog.tokenCost*(log.retryCount1);}else{stat.successAfterRetry;}}stats.set(log.status,stat);}return[...stats.values()].sort((a,b)b.wastedCost-a.wastedCost);}跑出来的结果让我有点傻眼HTTP状态码出现次数占比重试后成功浪费金额42989231%87%$18500/502/50331211%64%$940061421%0%$67401893%0%$114132037%0%$284221535%0%$1452962122%71%$154xx 错误占了 34%重试后成功率全是 0%。光 400 这一类就白烧了 $67。413 也是Agent 上下文太长触发了请求体限制重试多少次结果都一样——prompt 没变token 数不会变。这就很蠢了。相当于你每个月花 $120 买了一张永远不会中的彩票。为什么 LLM API 的 4xx 占比这么高因为 Agent 场景下触发 400 的原因太多了tool 参数 schema 写错了、prompt 里带了被安全策略拦截的内容、模型不支持的参数组合、上下文超了模型限制……这些全是永久错误改了才能过重试是在原地踏步。传统 Web API 的错误分布完全不是这样的。你调一个支付接口90% 的错误都是 500 或 503限流和服务器抖动占大头重试确实能解决问题。但 LLM API 不一样Agent 每次请求的 prompt 都在变工具组合也在变出 400 的概率天然就高。拿 Web API 那套重试逻辑套上来等于在一个错误分布完全不同的场景里做错误决策。更隐蔽的是重试的隐性成本。一次 400 错误的重试不只是浪费了一次 API 调用的钱——你的 Agent 可能因为这次失败触发补偿逻辑又多调了一两次工具这些工具本身也有 API 开销。雪球滚起来一个月 $67 的直接浪费实际造成的间接浪费可能翻倍。第二版指数退避加抖动好吧第一版确实太粗糙。第二版我上了指数退避加抖动这也是大多数教程推荐的标准答案。// 策略二指数退避 抖动asyncfunctioncallLLMWithBackoff(messages:any[],tools?:any[]){constmaxRetries5;constbaseDelay500;for(letattempt0;attemptmaxRetries;attempt){try{constresponseawaitfetch(https://api.openai.com/v1/chat/completions,{method:POST,headers:{Authorization:Bearer${apiKey},Content-Type:application/json},body:JSON.stringify({model:gpt-4o,messages,tools})});if(response.ok)returnawaitresponse.json();// 429 和 5xx 重试其他直接抛if(response.status!429response.status500){thrownewError(Fatal HTTP${response.status});}if(attemptmaxRetries){thrownewError(HTTP${response.status}after${maxRetries}retries);}constjitterMath.random()*0.3;constdelaybaseDelay*Math.pow(2,attempt)*(1jitter);awaitsleep(delay);}catch(e){if(attemptmaxRetries)throwe;constdelaybaseDelay*Math.pow(2,attempt)*(1Math.random()*0.3);awaitsleep(delay);}}}这版月费降到了 $210。退避逻辑本身没问题但还有 6% 的浪费来自两个地方。一是 429 的退避时间不够准。模型限流的时候 API 会返回retry_after字段那才是靠谱的等待时间。我用固定指数退避经常等不够就重试又撞限流来回折腾。二是 529 的处理太呆。529 意思是模型服务过载这时候继续重试同一个模型不如直接切到备用模型。但指数退避策略不知道这些它只会傻等然后重试运气不好连续撞五次 529一个任务就多花五倍的钱。第三版错误分类加熔断到了第三版我才想明白——重试策略的核心不是退避算法而是错误分类。你得分清楚哪些错误值得等哪些等了也白等。// 策略三错误分类 熔断 模型降级interfaceCircuitState{failures:number;lastFailureTime:number;isOpen:boolean;}classAgentRetryExecutor{privatecircuit:Mapstring,CircuitStatenewMap();privatereadonlyFAILURE_THRESHOLD5;privatereadonlyRESET_TIMEOUT60_000;asyncexecuteT(operation:()PromiseT,context:{model:string;taskType:string}):PromiseT{constcircuitKey${context.model}:${context.taskType};conststatethis.circuit.get(circuitKey)??{failures:0,lastFailureTime:0,isOpen:false};// 熔断器开启时直接走降级if(state.isOpenDate.now()-state.lastFailureTimethis.RESET_TIMEOUT){returnthis.fallbackT(context);}if(state.isOpen){state.isOpenfalse;state.failures0;}for(letattempt0;attempt3;attempt){try{constresultawaitoperation();state.failures0;this.circuit.set(circuitKey,state);returnresult;}catch(e:any){conststatuse.status??0;constclassificationclassifyError(status,e.body);// 永久错误不重试直接走降级if(!classification.retryable){returnthis.fallbackT(context);}state.failures;state.lastFailureTimeDate.now();// 连续失败达到阈值开熔断if(state.failuresthis.FAILURE_THRESHOLD){state.isOpentrue;this.circuit.set(circuitKey,state);returnthis.fallbackT(context);}if(attempt3)throwe;awaitsleep(classification.backoffMs*Math.pow(2,attempt));}}thrownewError(unreachable);}privateasyncfallbackT(context:{model:string;taskType:string}):PromiseT{// 大模型不行就换小模型或者返回缓存结果thrownewError(Circuit open for${context.model}, fallback not configured);}}这版的关键变化有三个。一是错误分类器把 4xx 直接标记为不可重试省掉了所有 400/401/413/422 的重试开销。这是省钱最多的一步——光这一项就砍掉了 $120 的月费。二是熔断器。同一个模型加任务类型连续失败五次直接开熔断 60 秒后续请求走降级路径。这个在模型服务大面积故障的时候特别有用不然你的 Agent 会像一只撞玻璃的鸟一样反复撞墙。三是退避时间用 API 返回的retry_after而不是自己猜。429 的时候 API 告诉你多久后来你就听它的别自作聪明。这版月费降到 $92浪费率 1.2%。剩下那 1.2% 主要是 529 过载场景下的重试后面考虑加模型自动降级来消除。这里有个容易忽略的指标单位成功成本。你光看每次调用的平均成本没意义得看总成本除以成功任务数。策略一把每次调用成本压低了但成功任务数也少了因为永久错误重试耗光了预算单位成功成本反而更高。策略三虽然单次调用可能更贵熔断后走降级模型但成功率高算下来反而最划算。顺便说一句雷达鸭的客服 Agent 上跑的就是这套方案日均调用 300 次左右一个月下来 API 费用稳定在 $30 以内。之前用无脑重试的时候有个周末限流特别严重一天就烧了 $8心疼了好几天。一个例外错误分类也不是万能的。有一种情况会让你误判模型返回 413 但其实是 prompt 太长导致的你把 prompt 截短一点重试就能成功。这种情况我把 413 单独拎出来做了处理——检测到 413 就触发上下文压缩压缩后再试一次。注意不是无脑重试是修了 prompt 再试。// 413 特殊处理上下文压缩后重试一次asyncfunctionhandleContextOverflow(originalMessages:any[],tools:any[]):Promiseany{// 保留 system prompt 和最近的对话轮次constsystemMsgsoriginalMessages.filter(mm.rolesystem);constrecentMsgsoriginalMessages.slice(-10);constcompressed[...systemMsgs,{role:system,content:[earlier context truncated]},...recentMsgs];returncallLLMWithBackoff(compressed,tools);}你要是也遇到 413 频繁出现别急着加 retry先查一下上下文是不是太长了。Agent 跑久了对话历史不清理token 数只会越来越大到某个临界点就开始稳定触发 413。反正我以后不会再写for (let i 0; i 3; i) retry()这种代码了。你呢关于老三10 年软件开发老兵软件设计师加人工智能应用工程师目前在搞鸿蒙 ArkTS 北向开发和 Web 前端业余折腾 AI 自动化。不定期在 CSDN 分享鸿蒙和 AI 方向的踩坑笔记。本文遵循 MIT 协议转载请注明出处。

相关新闻

最新新闻

日新闻

周新闻

月新闻