
【Bug已解决】Codex Desktop: capacity errors should preserve intent and auto-retry instead of handing model routing back to the user 解决方案原始报错Codex Desktop: capacity errors should preserve intent and auto-retry instead of handing model routing back to the user 场景向模型服务发请求时遇到容量错误服务端繁忙 / 限流 / 容量不足客户端不做任何重试直接把请求失败请选择另一个模型或重试的锅甩给用户让用户手动去点。用户本来只是想完成一次任务却被中途打断去当调度员。 关键词容量错误、自动重试、意图保留、故障转移、退避策略、不暴露给用户。一、现象长什么样用户发出一条消息后台返回容量类错误典型是 HTTP 429 或 503或业务层capacity_error。此时客户端的行为是弹出一个对话框模型当前不可用请选择其他模型或稍后重试把请求中断把接下来怎么办的决策丢回给用户用户要么手动换模型要么手动点重试要么放弃。问题在于容量错误通常是瞬时且可恢复的。服务端只是那一秒满了过几秒就有空位。如果客户端在后台悄悄重试几次绝大多数请求本可以成功用户根本不会察觉。把决策甩给用户等于把一个应该用代码自动处理的瞬时故障硬变成了一个需要人类介入的产品断点。二、背景容量错误到底是什么容量错误和请求本身有错是两回事请求错误400 / 认证失败 / 参数非法重试也没用必须让用户改请求。容量错误429 限流 / 503 不可用 / 业务层 capacity服务端暂时处理不了但请求本身没问题稍后重试大概率成功。正确的设计是对容量错误客户端应在不惊动用户的前提下自动重试可能换一个等价端点或同一端点退避重试保留用户最初的完整意图用哪个模型、发什么内容、哪些参数只有当重试彻底耗尽仍失败才把问题交给用户。三、根因错误分类缺失 意图未保留把问题拆开常见根因没有错误分类客户端把所有非 2xx 都当成用户错误弹窗不区分容量错误和可恢复错误。意图未保留每次重试都重新构造请求体或者把当前选中的模型当作可变状态在弹窗里被改掉重试时用的已经不是用户最初要的模型。重试暴露给用户重试逻辑要么没有要么把重试按钮交给用户点没有后台自动重试。无限/无退避重试少数实现有重试但没退避、没上限把服务端打得更满反而加重容量问题。下面用最小模型复现没有重试、直接抛给用户的错误写法再给正确实现。四、最小可运行复现下面模拟一个请求函数有 60% 概率返回容量错误。错误写法是不重试、直接把错误抛给调用方用户import random class CapacityError(Exception): pass def call_model(body: dict) - dict: # 模拟70% 概率容量不足 if random.random() 0.7: raise CapacityError(capacity_error: model busy) return {ok: True, echo: body[input]} def handle_user_send(body: dict): try: return call_model(body) except CapacityError: # 错误写法直接把锅甩给用户让他去选模型/重试 return {need_user_action: True, message: 请选择其他模型或重试} if __name__ __main__: body {model: gpt-x, input: 总结这段日志} result handle_user_send(body) print(result) # 大概率输出 {need_user_action: True, ...} —— 用户被迫介入运行几次会发现明明请求没问题却频繁要求用户手动处理。这正是因为容量错误被当成终态了。五、方案带指数退避的自动重试第一层修复遇到容量错误后台自动重试用指数退避避免踩踏服务端import random import time class CapacityError(Exception): pass def call_model(body: dict) - dict: if random.random() 0.7: raise CapacityError(capacity_error: model busy) return {ok: True, echo: body[input]} def send_with_retry(body: dict, max_retries: int 5, base_delay: float 0.5) - dict: last_err None for attempt in range(max_retries): try: return call_model(body) except CapacityError as e: last_err e if attempt max_retries - 1: break # 指数退避 一点抖动避免所有客户端同时重试 delay base_delay * (2 ** attempt) jitter random.uniform(0, delay * 0.3) time.sleep(delay jitter) # 重试耗尽才抛出 raise last_err if __name__ __main__: body {model: gpt-x, input: 总结这段日志} try: print(send_with_retry(body)) except CapacityError: print({need_user_action: True, message: 服务持续繁忙请稍后再试})现在同样的 70% 失败率经过几次退避重试后绝大多数请求能自动成功用户无感知。只有在连续多次都失败时才降级为请稍后再试。六、方案保留意图——重试用同一份请求体第二层修复重试时绝不重新构造请求体原样复用用户最初提交的body保证模型、输入、参数一模一样。如果请求体里含随机成分如某些采样种子要把种子固化为请求的一部分避免重试两次结果不同造成困惑import copy def send_preserving_intent(body: dict, max_retries: int 5, base_delay: float 0.5) - dict: intent copy.deepcopy(body) # 冻结用户最初意图 last_err None for attempt in range(max_retries): try: # 每次都用同一份 intent绝不在重试里改模型/输入 return call_model(intent) except CapacityError as e: last_err e if attempt max_retries - 1: break time.sleep(base_delay * (2 ** attempt)) raise last_err关键点是intent在循环外冻结。即便用户在等待期间切换了下拉框里的当前模型重试用的依旧是他点击发送那一刻的模型——这正是保留意图的含义。七、方案故障转移也不暴露给用户第三层修复如果同一模型多端点或同一能力有多个等价模型可以在后台做故障转移failover同样对用户透明。故障转移要满足转移到的目标能力等价、请求体不变、次数有限def send_with_failover(body: dict, endpoints: list, max_retries: int 5): intent copy.deepcopy(body) last_err None for attempt in range(max_retries): ep endpoints[attempt % len(endpoints)] # 轮询等价端点 try: return call_model_on(intent, ep) except CapacityError as e: last_err e if attempt max_retries - 1: break time.sleep(0.5 * (2 ** attempt)) raise last_err def call_model_on(body: dict, endpoint: str) - dict: if random.random() 0.7: raise CapacityError(fcapacity_error {endpoint}) return {ok: True, endpoint: endpoint, echo: body[input]} if __name__ __main__: body {model: gpt-x, input: 总结这段日志} eps [ep-a, ep-b, ep-c] try: print(send_with_failover(body, eps)) except CapacityError: print({need_user_action: True, message: 所有等价端点暂不可用})用户全程只发了一次请求至于中途重试了几遍、换过哪个端点他都看不到——这正是不把路由决策交回用户的目标。八、验证把重试与意图保留锁进测试import itertools def test_retry_recovers_from_transient_capacity(): # 前两次失败第三次成功 states {n: 0} def flaky(body): states[n] 1 if states[n] 2: raise CapacityError(busy) return {ok: True} out send_with_retry_wrapped(flaky, {input: x}) assert out {ok: True} assert states[n] 3 def test_intent_preserved_across_retries(): seen [] def recorder(body): seen.append(body[model]) if len(seen) 3: raise CapacityError(busy) return {ok: True} send_with_retry_wrapped(recorder, {model: gpt-x, input: y}) assert seen [gpt-x, gpt-x, gpt-x] # 模型始终不变 def send_with_retry_wrapped(fn, body, max_retries5, base_delay0): last None for i in range(max_retries): try: return fn(body) except CapacityError as e: last e if i max_retries - 1: break time.sleep(base_delay) raise last if __name__ __main__: test_retry_recovers_from_transient_capacity() test_intent_preserved_across_retries() print(容量错误重试与意图保留测试通过。)九、排查清单容量错误把决策甩给用户按顺序查错误分类客户端是否区分容量错误429/503/capacity与请求错误400/认证前者应自动处理。自动重试遇到容量错误是否有后台重试还是直接弹窗让用户点退避策略重试是否有指数退避 抖动避免无退避把服务端打得更满。重试上限是否有最大重试次数避免无限重试。意图保留重试用的是否是用户发送那一刻的请求体模型/输入/参数是否原样复用故障转移是否后台轮询等价端点转移是否对用户透明、次数有限降级时机只有重试彻底耗尽才把问题交用户且提示应明确持续繁忙而非请选模型。十、小结容量错误甩给用户本质是把瞬时可恢复的故障当成了需要人类决策的终态。修复思路分三层分类区分容量错误与请求错误前者走自动恢复路径重试指数退避 抖动 上限后台静默重试保意图 透明故障转移重试/换端点都用用户最初的请求体全程不暴露路由决策只有重试耗尽才降级提示且提示应诚实说明服务持续繁忙。这样用户的体验是发一条就成一条容量波动被代码消化在后台而不是变成一次次打断。