代码先放这,看完你就知道我为什么被坑了 $340。
// 错误分类器:区分可重试和不可重试错误typeRetryableStatus=429|500|502|503|529;typeFatalStatus=400|401|403|404|413|422;interfaceErrorClassification{retryable:boolean;backoffMs:number;maxRetries:number;reason:string;}functionclassifyError(status:number,errorBody?:string):ErrorClassification{// 429: 限流,重试但要退避if(status===429){constretryAfter=parseRetryAfter(errorBody);return{retryable:true,backoffMs:retryAfter??2000,maxRetries:3,reason:'rate_limited'};}// 5xx: 服务端临时错误,可重试if(status>=500){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;constmatch=body.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(letattempt=0;attempt<3;attempt++){try{constresponse=awaitfetch('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(attempt===2)throwe;awaitsleep(1000*(attempt+1));}}}看着没毛病对吧?任何错误重试三次,退避时间递增。但数据告诉我,这套逻辑在 Agent 场景下有个致命问题。
我写了个脚本统计了一个月的错误分布:
// 错误分布统计脚本interfaceErrorStat{status:number;count:number;retriedCount:number;successAfterRetry:number;wastedCost:number;}asyncfunctionanalyzeErrorLogs(logPath:string):Promise<ErrorStat[]>{constlogs=awaitreadLogs(logPath);conststats=newMap<number,ErrorStat>();for(constlogoflogs){conststat=stats.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.wastedCost+=log.tokenCost*(log.retryCount+1);}else{stat.successAfterRetry++;}}stats.set(log.status,stat);}return[...stats.values()].sort((a,b)=>b.wastedCost-a.wastedCost);}跑出来的结果让我有点傻眼:
| HTTP状态码 | 出现次数 | 占比 | 重试后成功 | 浪费金额 |
|---|---|---|---|---|
| 429 | 892 | 31% | 87% | $18 |
| 500/502/503 | 312 | 11% | 64% | $9 |
| 400 | 614 | 21% | 0% | $67 |
| 401 | 89 | 3% | 0% | $11 |
| 413 | 203 | 7% | 0% | $28 |
| 422 | 153 | 5% | 0% | $14 |
| 529 | 621 | 22% | 71% | $15 |
4xx 错误占了 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[]){constmaxRetries=5;constbaseDelay=500;for(letattempt=0;attempt<=maxRetries;attempt++){try{constresponse=awaitfetch('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!==429&&response.status<500){thrownewError(`Fatal HTTP${response.status}`);}if(attempt===maxRetries){thrownewError(`HTTP${response.status}after${maxRetries}retries`);}constjitter=Math.random()*0.3;constdelay=baseDelay*Math.pow(2,attempt)*(1+jitter);awaitsleep(delay);}catch(e){if(attempt===maxRetries)throwe;constdelay=baseDelay*Math.pow(2,attempt)*(1+Math.random()*0.3);awaitsleep(delay);}}}这版月费降到了 $210。退避逻辑本身没问题,但还有 6% 的浪费,来自两个地方。
一是 429 的退避时间不够准。模型限流的时候 API 会返回retry_after字段,那才是靠谱的等待时间。我用固定指数退避经常等不够就重试,又撞限流,来回折腾。
二是 529 的处理太呆。529 意思是模型服务过载,这时候继续重试同一个模型不如直接切到备用模型。但指数退避策略不知道这些,它只会傻等然后重试,运气不好连续撞五次 529,一个任务就多花五倍的钱。
第三版:错误分类加熔断
到了第三版我才想明白——重试策略的核心不是退避算法,而是错误分类。你得分清楚哪些错误值得等,哪些等了也白等。
// 策略三:错误分类 + 熔断 + 模型降级interfaceCircuitState{failures:number;lastFailureTime:number;isOpen:boolean;}classAgentRetryExecutor{privatecircuit:Map<string,CircuitState>=newMap();privatereadonlyFAILURE_THRESHOLD=5;privatereadonlyRESET_TIMEOUT=60_000;asyncexecute<T>(operation:()=>Promise<T>,context:{model:string;taskType:string}):Promise<T>{constcircuitKey=`${context.model}:${context.taskType}`;conststate=this.circuit.get(circuitKey)??{failures:0,lastFailureTime:0,isOpen:false};// 熔断器开启时直接走降级if(state.isOpen&&Date.now()-state.lastFailureTime<this.RESET_TIMEOUT){returnthis.fallback<T>(context);}if(state.isOpen){state.isOpen=false;state.failures=0;}for(letattempt=0;attempt<=3;attempt++){try{constresult=awaitoperation();state.failures=0;this.circuit.set(circuitKey,state);returnresult;}catch(e:any){conststatus=e.status??0;constclassification=classifyError(status,e.body);// 永久错误不重试,直接走降级if(!classification.retryable){returnthis.fallback<T>(context);}state.failures++;state.lastFailureTime=Date.now();// 连续失败达到阈值,开熔断if(state.failures>=this.FAILURE_THRESHOLD){state.isOpen=true;this.circuit.set(circuitKey,state);returnthis.fallback<T>(context);}if(attempt===3)throwe;awaitsleep(classification.backoffMs*Math.pow(2,attempt));}}thrownewError('unreachable');}privateasyncfallback<T>(context:{model:string;taskType:string}):Promise<T>{// 大模型不行就换小模型,或者返回缓存结果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[]):Promise<any>{// 保留 system prompt 和最近的对话轮次constsystemMsgs=originalMessages.filter(m=>m.role==='system');constrecentMsgs=originalMessages.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 协议,转载请注明出处。