
晚上八点家长的黄金咨询时段运营突然在群里刷屏小E卡住了。我看了一眼后台症状比卡住难看得多。有的家长转圈三秒然后看到一句报错有的等了九秒换来同一个错误还有一位家长运气好等了五秒拿到了答案——而这个问题本地测试从来没有超过两秒。那天晚上我翻自己代码找到了答案调用模型的地方我包了一层try / except Exception失败就重试三次。写的时候我觉得这叫稳妥实际上它是我后面所有灾难的总开关网络抖动它重试限速 429它重试我自己的 Key 配错了它也重试三次——每一次都完整计费每一秒都让家长等。这一篇就讲这件事怎么根治给小E装上重试、超时、降级三条保险丝让程序在出错这件事上表现得像个成年人。一、先扫盲错误分三种性质处置方式完全不同第一种临时故障等一下再来可能就好。超时、429 限速、5xx、连接被重置。这类错的特点是这次失败可能是偶然的——隔壁机器同样的请求就成功了。处置方式退避一下重试。第二种确定性失败再来一万次也一样。401/403 鉴权失败、400 参数写错、内容审核拒绝。你的 Key 过期了重试三次的结果是三张一样的 401外加三次计费和用户多等的九秒。处置方式立刻停别碰重试按钮要么换 Key 要么改参数。第三种结果回来了但结构不对。半截 JSON、字段缺失、输出被截断。它经常是网络抽风或 max_tokens 卡住的副产物重试一次常常就好但如果反复出现说明该改的是提示词或参数不是重试次数。所以我把它单独列成一类可以重试但要单独统计——重试率一高就是警报。全局 try/except 重试三次为什么是最糟糕的方案因为except Exception意味着它不区分上面三种性质。后果按严重程度排用户多等。每次重试之间还有退避等待。三次重试用户实际等的是三次请求 两次等待九秒起步而结局往往是同一句 401。钱多花。每次重试都是一次完整的输入 输出计费。这不是保险是给已经失败的事情再买两份。真问题被埋掉。如果 80% 的请求都在靠重试续命try/except里根本看不出来——没有日志、没有分类、没有统计你以为系统健康其实在 ICU。重试从来不是问题无差别重试才是。超时要设在客户端还是服务端都要而且它们的分工必须清楚。服务端网关的超时你改不了客户端timeout是你在构造模型时传的那个数它管的是单次 HTTP 请求不是整轮对话真正兜底的应该是在你自己代码里划的一条线——deadline“这轮请求总共只给 8 秒”。最容易被坑的地方在这里timeout20加上 SDK 自带max_retries2最坏耗时是 20 × 3 60 秒因为每次重试都重新计时。用户看到的不是 20 秒上限是一分钟白屏。所以规则只有一句谁更短谁说了算——单次 timeout 必须服从整轮 deadline。重试会不会把副作用执行两遍会。重试的语义就是同一个请求可能被发两次。查课表这种读接口无所谓但扣一次课时发送课程提醒这种带副作用的操作重试成功的那一刻家长会收到两条一样的消息。解法不新鲜给操作带上唯一的 request_id服务端见过的直接返回上次的结果或者本地先落一张幂等表。本篇的示例都是读接口但这条原则请抄在墙上。退避为什么必须带抖动想象一个线上课开售的瞬间一千个家长同时提问DeepSeek 返回了一波 429。如果所有机器的第一次重试都精确地等 0.2 秒——那 0.2 秒之后又是一千个请求整齐地撞上去429 再来一遍。抖动jitter就是给退避时间加一点随机量把整齐的重试大军打散成涓涓细流。一行代码的事防的是雪崩。二、工程目录08-稳定性/ ├── src/ │ ├── settings.py # 复用第 2 篇 │ ├── llm.py # 复用第 2 篇唯一改动max_retries 从 2 改为 0见下文 │ ├── errors.py # 异常三分法 classify_exception() │ ├── retry.py # 指数退避 抖动 总预算 AttemptLog │ ├── timeout.py # Deadline 整轮预算、单次 timeout 切片 │ ├── fallback.py # 降级链主模型 → 备用模型 → 人话文案 │ ├── budget.py # 单次 / 单会话 token 护栏 │ ├── guard.py # safe_ask()四个闸门串成一条线 │ └── fakes.py # 按剧本演出的假模型 ├── tests/ # 40 个离线用例 ├── demo.py └── pytest.ini / requirements.txt / README.md / .env.example / .gitignoresettings.py和llm.py原样从第 2 篇复用——除了一行llm.py里写死的max_retries2改成了默认 0。原因马上讲。三、errors.py先分类再谈重试classAgentError(Exception):所有我方定义的异常的共同祖先。kindunknownretryableFalsedef__init__(self,message,*,status_codeNone,causeNone):super().__init__(message)self.status_codestatus_code self.causecause self.logs[]# 由 call_with_retry 回填失败也要留下试过几次classTransientError(AgentError):kindtransientretryableTrueclassPermanentError(AgentError):kindpermanentretryableFalseclassUpstreamShapeError(TransientError):半截 JSON、字段缺失。继承 Transient它常常重试一次就好。kindshape三个决定值得说透第一retryable是重试层唯一要看的开关。分类逻辑全部住在classify_exception()里重试层只数次数、等一会儿、再打一次。职责干净了测试才写得出来。第二UpstreamShapeError继承TransientError。半截 JSON 多半是流被截断重试一次往往就好。单独建类不是为了换处置方式是为了在日志里把网络抖和模型没按格式来分开——前者查网络后者改提示词。第三认不出来的错误一律当不可重试。未知错误重试三次只是把一个故障变成三个故障。宁可快速失败、留一条清楚的日志也不要拿用户的时间去赌。defclassify_exception(exc)-AgentError:把任意异常翻译成我们的三类之一。ifisinstance(exc,AgentError):returnexcformapperin(_from_openai,_from_requests,_from_httpx):mappedmapper(exc)ifmappedisnotNone:returnmappedifisinstance(exc,(TimeoutError,ConnectionError)):returnTransientError(f底层网络异常{exc},causeexc)mapped_from_text(exc)# 类型认不出按报错文本里的关键词认ifmappedisnotNone:returnmappedreturnPermanentError(f未识别的错误按不可重试处理{type(exc).__name__}:{exc},causeexc)_from_openai是主路径——DeepSeek 走 OpenAI 兼容协议429 进来时是RateLimitError带status_code超时是APITimeoutError。这里有个必须写注释的顺序问题APITimeoutError是APIConnectionError的子类先判父类就会把超时写成连不上日志里查不出真凶。四、retry.py只重试该重试的并且留下证据defbackoff_delay(index,*,base_delay0.2,jitter0.1,randrandom.random):第 index 次失败之后该等多久index 从 1 开始。rawmin(MAX_DELAY,base_delay*(2**(index-1)))factor1.0(rand()*2.0-1.0)*jitter# ±10% 的随机浮动returnmax(0.0,raw*factor)指数退避 抖动一共就两行。rand是个可注入的随机函数真实环境传random.random测试里传一个固定值就能把纯指数和带抖动分别断言出来。defcall_with_retry(fn,*,attempts3,base_delay0.2,jitter0.1,max_total_wait2.0,deadlineNone,sleeptime.sleep,clocktime.monotonic,randrandom.random):forindexinrange(1,attempts1):ifdeadlineisnotNone:deadline.check()# 预算见底就别再发请求了startedclock()try:valuefn()exceptExceptionasexc:errorclassify_exception(exc)logs.append(AttemptLog(index,False,clock()-started,error.kind,str(error)[:160]))error.logslist(logs)# 失败也留痕catch 之后还能打印ifnoterror.retryable:raiseerrorfromexc# 401 走这里一次都不多试last_errorerrorifindexattempts:breakdelaybackoff_delay(index,base_delaybase_delay,jitterjitter,randrand)ifdeadlineisnotNone:delaymin(delay,deadline.remaining)ifwaited_totaldelaymax_total_wait:raiselast_errorfromexc# 预算不够再等直接放弃sleep(delay)waited_totaldelayelse:logs.append(AttemptLog(index,True,clock()-started,ok,))returnRetryResult(valuevalue,logslogs)raiselast_error三个保险丝全在这个函数里只重试retryable退避带抖动总预算max_total_wait——累计等待一旦要超预算就放弃把最后一次的真实错误抛出去而不是让用户以为又点了三次。我最想让你注意到的是返回值不是裸结果是RetryResult(value, logs)每个AttemptLog记着第几次、耗时、失败原因、这次失败后睡了多久。重试从黑盒里的运气变成了可以打印、可以统计的数据。今天 transient 占 12%、shape 占 3%这种话你可以说得出来。sleep / clock / rand全部做成了参数。这不是洁癖测试里塞一个不睡的sleep40 个用例总共跑不到一秒不注入的话光重试两次一个用例就得真睡 0.6 秒。五、timeout.pydeadline 是你在自己代码里划的线classDeadlineExceeded(PermanentError):整轮预算用完。刻意不可重试再试只会让用户多等一轮。kinddeadlineclassDeadline:def__init__(self,total_seconds,*,clocktime.monotonic):self.total_secondsfloat(total_seconds)self._clockclock self._startclock()propertydefremaining(self):returnmax(0.0,self.total_seconds-self.elapsed)defcheck(self):ifself.remaining0.0:raiseDeadlineExceeded(f整轮预算{self.total_seconds:.1f}s 已用完)clock可注入测试里塞一个手摇时钟过了 8 秒在微秒内就能演完。DeadlineExceeded继承PermanentError——预算都花光了重试个啥还有一个容易被忽略的细节三次重试共享 8 秒预算不能给第一次单次 timeout 8 秒否则它一超时后面两次已经没时间了。所以slice_for()把剩余预算按尝试次数切片defslice_for(self,attempts,*,capDEFAULT_TIMEOUT,floorMIN_TIMEOUT):把剩余预算切成 attempts 份作为单次请求的 timeout。shareself.remaining/max(1,attempts)returnmin(cap,max(floor,share))现在可以交代llm.py那一行改动了第 2 篇写死的max_retries2改成默认 0。SDK 自带的重试看不见错误类型、不留日志、每次重新计时叠上我们自己的call_with_retry就是 3 × 3 9 次请求。重试权必须收回到一个地方这条和其他不要重复记账的原则一回事。六、fallback.py全挂了也要说人话DEGRADED_TEXT(小E现在有点忙这个问题我先记下了。着急的话直接找任课老师我恢复后会尽快回你。)defrun_with_fallback(chain,prompt,*,parseNone,deadlineNone,...):forllminchain:try:resultcall_with_retry(lambdallmllm:_ask_one(llm,prompt,parse),...)exceptAgentErroraserr:attempts_meta.append(ModelAttempt(name,False,err.kind,str(err)))last_reasonf{name}:{err}continue# 这个模型不行换下一个returnAnswer(textresult.value,modelname,attemptsattempts_meta,...)returnAnswer(textDEGRADED_TEXT,degradedTrue,reasonlast_reason,...)注意一个反直觉的点401 这种永久错误也会跳到下一个模型。主模型的 Key 过期不代表备用模型的 Key 也过期了——永久错误只跳过这一个模型不终止整条链。降级文案我给自己定了三条说清我现在不行不装正常给一条出路不承诺做不到的事我马上回你这种话系统并不会真的回。最差的兜底是返回空字符串用户盯着一个空白气泡比看到报错还慌。七、budget.py先查余额再花钱classBudget:defcheck(self,estimated:int)-None:发请求之前问一句还花得起吗。花不起就抛别发。ifestimatedself.per_call:raiseBudgetExceeded(f这一次就要约{estimated}token超过单次上限{self.per_call})ifself.usedestimatedself.per_session:raiseBudgetExceeded(f今天已用{self.used}token再问会超过上限{self.per_session})defcharge(self,tokens:int)-int:returnself.counter.add(tokens)# 成功之后才记账为什么必须在发请求之前检查等拿到 usage 再判断钱已经花出去了。预算是刹车不是账单。TokenCounter是个独立的小对象上线时把get/add换成 Redis 的INCRBudget一行不用改。额度用完返回的是另一句文案——“今天的提问额度用完啦”这是规则不是故障话术不一样。八、guard.py四个闸门串成一条线defsafe_ask(question,llms,*,budgetNone,deadlineNone,parseNone,...):promptbuild_prompt(question)input_tokensestimate_tokens(prompt)ifbudgetisnotNone:# 闸门一预算最便宜先挡try:budget.check(input_tokens)exceptBudgetExceededaserr:returnAnswer(textBUDGET_TEXT,blockedTrue,reasonstr(err))answerrun_with_fallback(# 闸门二三四时间 → 重试 → 降级llms,prompt,parseparse,deadlinedeadline,...)ifbudgetisnotNoneandanswer.ok:# 只有真产出回答才扣额度budget.charge(input_tokensestimate_tokens(answer.text))returnanswer顺序是刻意的预算闸最便宜挡在最前面——一句话就超额度没必要发请求时间闸在每次尝试前检查别把用户的 8 秒花在第三次注定失败的重试上降级闸垫底保证用户永远收到一句完整的话。返回值是带元信息的Answer用了哪个模型、重试几次、降级没降级、花了多少 token、总共多久。产品经理问这次为什么慢你不用翻服务器日志。九、跑起来四种故障离线演一遍python demo.py——四个剧本不联网、不要 Key由fakes.py里的ScriptedChat按剧本抛出和 SDK 真实抛出同类的异常构造时连 status_code 都是原样的。真实输出────────────────────────────────────────────────────────────── 【剧本一前两次超时第三次成功】 #1 FAIL 0.00s [transient] 上游超时请求发出去了没等到完整响应 → 等待 0.05s #2 FAIL 0.00s [transient] 上游超时请求发出去了没等到完整响应 → 等待 0.11s #3 OK 0.00s [ok] 模型调用次数3 结果modeldeepseek-flash degradedFalse 重试2 耗时0.17s 回复学生时态错误every day 要求一般现在时应改为 He goes to school by bus. ────────────────────────────────────────────────────────────── 【剧本二Key 过期401一次就停】 #1 FAIL 0.00s [permanent] 上游返回 HTTP 401Invalid API key 模型调用次数1 结果modelNone degradedTrue 重试0 耗时0.00s 回复学生小E现在有点忙这个问题我先记下了。着急的话直接找任课老师我恢复后会尽快回你。 ────────────────────────────────────────────────────────────── 【剧本三返回半截 JSON重试后成功】 #1 FAIL 0.00s [shape] 返回结构不符合约定Unterminated string starting at: … → 等待 0.05s #2 OK 0.00s [ok] 模型调用次数2 结果modeldeepseek-flash degradedFalse 重试1 耗时0.06s 回复学生主谓不一致He 是第三人称单数go 要变成 goes。 ────────────────────────────────────────────────────────────── 【剧本四两个模型全挂走降级】 两个模型各试 3 次全部 HTTP 503日志略 模型调用次数6 结果modelNone degradedTrue 重试4 耗时0.32s 回复学生小E现在有点忙这个问题我先记下了。着急的话直接找任课老师我恢复后会尽快回你。演示把退避压缩到了 0.05 秒起步打印出来的等待时长是真的。四个剧本对着看特别清楚剧本二只调了 1 次模型剧本一调了 3 次——判断该不该重试的从来看的不是运气是错误分类。十、真实 pytest 输出tests/test_errors.py ............ [ 30%] tests/test_fallback.py ...... [ 45%] tests/test_guard.py ......... [ 67%] tests/test_retry.py ........ [ 87%] tests/test_timeout.py ..... [100%] 40 passed in 0.88s40 个用例全绿全程离线整个测试不到一秒。几条我最得意的test_permanent_error_is_never_retried断言 401 时fn只被调了1次注释里写着401 重试三次 用户白等 9 秒 三次计费——这条测试就是在给那晚的事故还债。test_jitter_makes_two_waits_different同样的base_delay1.0注入两个不同的随机数断言两次等待不相等且都落在 ±10% 的带里。test_deadline_is_shared_across_retries三次重试共享一个 0.3 秒的 deadlineSleepSpy会推进假时钟断言累计等待没超预算。test_budget_blocks_before_any_request断言llm.calls 0——额度不够就不该发请求。十一、踩坑清单SDK 自带重试和自己的重试相乘。ChatDeepSeek的max_retries2叠上我写的重试三次最坏 9 次请求。这是那晚九秒报错的主犯之一。现在llm.py里默认 0重试只住在call_with_retry一个地方。超时乘法。timeout20× 3 次尝试 60 秒白屏。SDK 的 timeout 是每次尝试各自计时不是总预算总预算只能是你在自己代码里的Deadline。把结构不对一开始当成永久错误。早期版本半截 JSON 直接降级家长三天两头看到小E现在有点忙。把它挪进可重试类之后降级率掉了一个数量级——顺带发现重试率高的那几天都是 max_tokens 配小了。重试把副作用执行两遍。课程提醒上线第二天有家长收到了两条一样的通知。读接口随便重试写接口必须先挂幂等键这条要在设计阶段想清楚不是上线后补的。预算放在拿到 usage 之后查。有一版护栏等响应回来才扣账高峰期一小时就刷穿了当天的额度。预算必须挡在请求之前哪怕估得不准——量级正确就够。测试里真的 sleep。最早的test_retry要真等 1.2 秒40 个用例近 40 秒。把sleep / clock / rand做成参数之后整套测试不到一秒。延时可注入是可测性的前提不是优化。降级文案写成服务异常。早期兜底就四个字家长截图发群里问是不是倒闭了。降级文案要给人出路、给预期它和回答一样是产品文案值得配测试。十二、本篇收尾现在小E的四条命都有了着落该重试的会退避着重试并留下 AttemptLog整轮请求被 Deadline 按住额度被 Budget 挡在发请求之前所有模型都挂了用户也能收到一句完整的话。上线第三天又遇到一次平台侧抖动运营群里安安静静——保险丝烧断的样子就是没有存在感的样子。但稳定只是及格线。真正的难题还在后面每次改 system prompt、换 temperature你凭什么判断变好了凭感觉吗我凭感觉改了半年直到有一天发现一个优化让批改准确率掉了八个百分点。下一篇我们做离线评测把改了提示词到底有没有变好从玄学变成一个可以跑的数字。最后一句重试不是给错误铺路是给偶然让路——分不清这两者的程序只会让用户陪着它的犹豫一起等。