ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Codex 频繁报错 at capacity?本地代理自动重试方案

Codex 频繁报错 at capacity?本地代理自动重试方案 1. 从一次深夜报错说起为什么我要自己写一个重试工具凌晨两点我正用 Codex 跑一个批量重构任务终端里突然蹦出一行红字Selected model is at capacity。我以为是偶发手动敲了回车重试结果又变成service is overloaded。再试直接卡住不动了。那一晚我大概手动重试了四十多次代码没写几行人先被折腾得够呛。如果你也在用 Codex 这类 AI 编程助手这两个报错大概率不陌生。它们本质上不是你的配置错了也不是网络断了而是服务端的模型容量被打满了——高峰期大量用户同时请求后端排队排不过来于是把一部分请求直接拒掉。问题在于Codex 客户端遇到这种错误时默认行为往往是直接失败退出而不是帮你等一等再试。对于交互式聊天手动重试还能忍但对于跑批、跑脚本、跑长任务的场景这就是灾难。所以我做了个本地自动重试工具核心思路很简单在 Codex 和模型服务之间插一层本地代理拦截到容量类错误就自动退避重试直到成功为止。我给它起了个名字叫 Steady Relay取“稳定中转”的意思。它不改变你原有的 Codex 使用习惯你该怎么配置还怎么配置只是把请求先发给本地由本地帮你扛住那些瞬时故障。这篇文章我会把整个工具的设计思路、核心原理、完整实操步骤、参数计算、踩坑记录全部摊开讲。适合两类人一类是天天被at capacity折磨、想找个稳定方案的 Codex 重度用户另一类是想理解“本地代理 自动重试”这套模式、准备自己动手改造的开发者。哪怕你只是想搞清楚这两个报错到底怎么回事看完也能有个清晰判断。提示本文讨论的是本地请求重试与流量整形属于通用的客户端容错实践不涉及任何网络访问方式的改变。2. 先搞懂报错at capacity 和 overloaded 到底差在哪很多人把这两个报错当成一回事其实它们的触发位置和应对策略并不完全相同。搞清楚区别才能设计出合理的重试逻辑否则你可能会在不该重试的时候疯狂重试反而把情况搞得更糟。2.1 两个报错的语义差异Selected model is at capacity通常意味着你指定的那个模型实例的并发配额已经满了。注意关键词是“selected model”也就是针对具体模型而言。比如你选的是某个高负载的推理模型它当前能同时处理的请求数达到了上限新请求就被挡在门外。这种错误往往是短时、可恢复的等几秒到几十秒前面的请求处理完配额就会释放。service is overloaded的范围更大指的是整个服务层面的过载可能是网关、调度层或者后端集群整体压力过高。它不一定针对某个模型而是系统级的保护机制在起作用。这种错误恢复时间通常更长也更不可预测可能几秒也可能几分钟。我用一个生活化的类比at capacity像是“这家餐厅今天订满了”换一家或者等一会儿可能有位置service is overloaded像是“整条美食街都爆满连排队的地方都没有”你只能等整条街的人流退下去。2.2 为什么客户端默认不帮你重试这里有个设计哲学问题。Codex 这类工具默认把错误直接抛给用户是因为它无法判断重试是否安全。对于纯对话重试一次无非多花点时间但如果你的请求已经触发了某些副作用比如已经开始执行工具调用、写文件、跑命令盲目重试可能导致重复执行。所以客户端选择“保守失败”把决定权交给用户。但对绝大多数“只是想让模型生成一段文本/代码”的场景来说这种保守其实是过度保守。我们完全可以在请求尚未产生副作用的阶段做自动重试这正是本地代理能发挥价值的地方——它站在请求发出之前还没到执行阶段重试是安全的。2.3 重试的边界在哪里不是所有错误都该重试。我给自己定的规则是错误类型是否重试原因Selected model is at capacity是短时配额问题退避后大概率成功service is overloaded是系统过载需要更长退避401 / 403 鉴权失败否配置问题重试无意义400 参数错误否请求本身有问题重试还是错429 限流是谨慎需要遵守退避避免加剧限流5xx 服务端错误是通常是瞬时故障连接超时是网络抖动可重试这张表是整个工具的判断核心。只对“可能自愈”的错误重试对“确定性失败”的错误立即返回否则你会陷入无意义的循环浪费时间和配额。3. 整体设计为什么选本地代理而不是改客户端确定了要自动重试接下来是方案选型。我考虑过三条路最后选了本地代理这里把取舍讲清楚方便你判断哪种更适合自己。3.1 三种可选方案对比方案一改 Codex 客户端源码。理论上最彻底直接在请求层加重试逻辑。但问题是 Codex 客户端更新频繁你每次升级都要重新打补丁维护成本极高。而且很多客户端是打包分发的改起来并不容易。适合有精力长期跟进上游的团队不适合个人。方案二写个外层脚本包装。比如用 shell 包一层检测到错误就重新调用。这个方案简单但粒度太粗——它只能重试“整个命令”无法区分是哪种错误也无法在请求级别做退避。对于交互式使用几乎没法用。方案三本地代理拦截。在本地起一个 HTTP 服务Codex 把请求发给它它再转发给真正的服务端。代理层可以精确看到每个请求的响应状态针对性地重试。对客户端完全透明升级不受影响粒度精确到单次请求。这是我最终选的方案。3.2 代理层的核心职责Steady Relay 作为本地代理承担四件事请求转发把 Codex 发来的请求原样转发到上游保持 header、body 不变。错误识别解析响应判断是否属于可重试的容量类错误。退避重试按指数退避策略等待后重发直到成功或达到上限。透明返回把最终成功的响应原样返回给 Codex客户端完全无感。关键在于“透明”二字。Codex 不知道中间有个代理它以为自己在直接跟服务端对话。这样你原有的登录态、配置、使用方式全都不用动只需要把请求地址指向本地端口。3.3 为什么用指数退避而不是固定间隔假设服务端过载你每 1 秒重试一次100 个用户就是每秒 100 次请求只会让过载更严重。指数退避的思路是第一次等 1 秒第二次等 2 秒第三次等 4 秒以此类推并加一个随机抖动jitter避免多个客户端同时重试形成“惊群”。退避时间的计算公式我采用的是delay min(base * (2 ^ attempt), maxDelay) random(0, jitter)其中base取 1 秒maxDelay取 30 秒jitter取 0 到 1 秒。这样第 1 次重试等约 1 秒第 5 次等约 16 秒第 6 次就封顶在 30 秒附近。既给了服务端恢复时间又不会让用户等太久。注意退避上限不要设得太大。我一开始设了 120 秒结果一个请求卡了两分钟体验极差。30 秒是个比较平衡的值。4. 动手实现从零搭一个能用的重试代理理论讲完进入实操。我用 Node.js 实现因为它对 HTTP 流式响应的处理比较自然而且 Codex 生态里 JS 工具链也常见。你也可以用 Python 或 Go逻辑是一样的。4.1 环境准备与依赖先确认本地有 Node.js 18 以上版本然后建一个空目录mkdir steady-relay cd steady-relay npm init -y npm install http-proxy undici这里http-proxy用来做转发undici用来发上游请求它比原生 fetch 对超时和连接控制更细。如果你不想装依赖用原生http模块也能写只是代码会啰嗦一些。4.2 核心转发逻辑代理的主体是一个 HTTP 服务监听本地端口收到请求后转发到上游。关键代码如下import http from node:http; import { request } from undici; const UPSTREAM process.env.UPSTREAM_URL; // 上游地址 const PORT 8787; const RETRYABLE [ /at capacity/i, /service is overloaded/i, /overloaded/i, ]; function isRetryable(status, bodyText) { if (status 429) return true; if (status 500 status 600) return true; return RETRYABLE.some((re) re.test(bodyText)); } const server http.createServer(async (req, res) { const chunks []; for await (const chunk of req) chunks.push(chunk); const body Buffer.concat(chunks); let attempt 0; const maxAttempts 8; while (attempt maxAttempts) { attempt; try { const upstreamRes await request(UPSTREAM req.url, { method: req.method, headers: { ...req.headers, host: new URL(UPSTREAM).host }, body, bodyTimeout: 120000, headersTimeout: 120000, }); const respText await upstreamRes.body.text(); if (isRetryable(upstreamRes.statusCode, respText) attempt maxAttempts) { const delay backoff(attempt); console.log([retry] attempt${attempt} status${upstreamRes.statusCode} wait${delay}ms); await sleep(delay); continue; } res.writeHead(upstreamRes.statusCode, upstreamRes.headers); res.end(respText); return; } catch (err) { if (attempt maxAttempts) { res.writeHead(502); res.end(JSON.stringify({ error: err.message })); return; } await sleep(backoff(attempt)); } } }); function backoff(attempt) { const base 1000; const max 30000; const jitter Math.random() * 1000; return Math.min(base * 2 ** (attempt - 1), max) jitter; } function sleep(ms) { return new Promise((r) setTimeout(r, ms)); } server.listen(PORT, () console.log(Steady Relay listening on ${PORT}));这段代码有几个细节值得说。第一我把请求体先完整读出来缓存因为重试时需要重新发送流式 body 读一次就没了。第二isRetryable同时看状态码和响应文本因为有些容量错误返回的是 200 但 body 里带错误信息光看状态码会漏判。第三重试次数封顶 8 次避免无限循环。4.3 处理流式响应上面那段代码有个问题它把响应整体读成文本再返回这对流式输出SSE是致命的——Codex 的很多响应是流式的你等它全部生成完再返回用户会感觉卡死。所以流式场景要单独处理。思路是先探测第一个数据块如果第一个块就是错误信息走重试如果第一个块是正常内容就直接把流透传给客户端。实现上可以用upstreamRes.body的异步迭代器读到第一块后判断const iterator upstreamRes.body[Symbol.asyncIterator](); const first await iterator.next(); if (first.done) { /* 空响应按错误处理 */ } const firstText first.value.toString(); if (isRetryable(upstreamRes.statusCode, firstText) attempt maxAttempts) { await sleep(backoff(attempt)); continue; } res.writeHead(upstreamRes.statusCode, upstreamRes.headers); res.write(first.value); for await (const chunk of { [Symbol.asyncIterator]: () iterator }) { res.write(chunk); } res.end();这个“探测首块”的技巧是流式代理的关键。它让你既能判断错误又不破坏流式体验。我第一次写的时候没做这个结果所有流式请求都变成了“憋一大口气再吐出来”体验很差。4.4 参数选择与计算过程几个关键参数的取值我都是实测调出来的这里把计算逻辑交代清楚maxAttempts 8按指数退避8 次的总等待时间约为 1248163030 91 秒。也就是说最坏情况下用户等一分半。再往上加意义不大因为超过一分半的过载通常不是退避能解决的。base 1000ms第一次重试等 1 秒。太短没意义太长用户第一下就烦了。maxDelay 30000ms封顶 30 秒。前面算过这是体验和效果的平衡点。jitter 0~1000ms随机抖动防止多客户端同步重试。提示如果你的使用场景是无人值守的批处理可以把 maxAttempts 调到 12、maxDelay 调到 60 秒牺牲体验换成功率。交互式使用则建议保持默认。5. 接入 Codex配置、验证与实测记录工具写好了接下来是把它接到 Codex 上。这一步的核心是把 Codex 的请求地址指向本地代理其余配置保持不变。5.1 配置接入方式Codex 的接入通常通过环境变量或配置文件指定服务地址。你需要找到当前配置里指向服务端的那个 URL把它替换成http://127.0.0.1:8787。同时把原来的地址通过UPSTREAM_URL环境变量传给代理export UPSTREAM_URL你原本的服务地址 node steady-relay.js启动后你会看到Steady Relay listening on 8787。然后另开一个终端正常启动 Codex。如果配置正确Codex 的所有请求都会先经过本地 8787 端口。这里有个容易踩的坑有些客户端会校验服务地址的域名或证书。如果你原本用的是 https 地址改成 http 本地地址后可能被拒。解决办法是保留 https 形式在本地起一个带自签证书的 https 代理或者查一下客户端是否支持自定义 base url。我实测下来多数支持自定义端点的客户端都能接受 http 本地地址。5.2 验证代理是否生效最简单的验证方法是看代理的日志。正常请求时日志应该是安静的一旦触发重试你会看到类似[retry] attempt1 status503 wait1234ms [retry] attempt2 status503 wait2456ms如果一直没日志说明请求没走到代理检查 Codex 的地址配置。如果日志里全是重试但从不成功可能是上游地址配错了或者你的账号本身有问题。我还写了个简单的自测脚本直接向代理发一个请求观察它是否正确转发curl -X POST http://127.0.0.1:8787/v1/responses \ -H Content-Type: application/json \ -d {model:your-model,input:hello}5.3 实测记录高峰期表现我在晚高峰晚上 8 点到 10 点连续跑了三天记录如下场景无代理成功率有代理成功率平均额外等待单次对话约 70%约 99%2.1 秒批量脚本100 次约 55%约 97%3.8 秒长任务流式约 60%约 95%4.5 秒可以看到代理把成功率从六七成拉到了九成五以上代价是平均多等几秒。对于批量任务这个交换非常划算——原来 100 次里有 45 次失败要手动重来现在基本一次跑完。注意成功率不是 100%因为有些过载持续时间超过了退避上限。这种情况代理会如实返回错误不会假装成功。这是设计上的取舍宁可诚实失败不要无限等待。6. 常见问题与排查技巧实录工具跑起来之后我遇到和收集了不少问题这里整理成速查表基本都是文档里不会写、只有实际用过才知道的坑。6.1 常见问题速查表现象可能原因解决办法代理启动报端口占用8787 被其他程序占用换端口或lsof -i:8787找到并结束占用进程Codex 报连接被拒代理没启动或地址写错确认代理在跑检查地址是否为 127.0.0.1请求一直卡住不返回流式响应处理有 bug检查首块探测逻辑确认没有把流读死重试日志刷屏但从不成功上游地址错误或账号异常用 curl 直连上游验证排除代理因素流式输出变成一次性吐出没做流式透传参考 4.3 节用异步迭代器逐块转发重试后出现重复内容请求已产生副作用只对生成类请求重试避免对执行类请求重试内存占用越来越高请求体缓存没释放确保每次请求结束后释放 buffer 引用6.2 三个我踩过的坑坑一把执行类请求也重试了。有一次我让 Codex 执行一个写文件的操作结果第一次请求其实已经写成功了只是响应超时代理又重试了一次文件被写了两次。教训是重试只对幂等的生成类请求安全。如果你的请求会触发工具调用或副作用要么不重试要么在请求里带幂等键。坑二退避时间没加抖动。早期版本我用固定退避结果多个终端同时跑任务时它们会在同一时刻一起重试形成规律性的请求尖峰。加了随机抖动后请求被自然打散上游压力明显平滑。坑三忽略了 header 里的 host。转发时如果不改写 host header有些上游会拒绝请求或者路由到错误的后端。我在代码里显式设置了host: new URL(UPSTREAM).host这个问题才消失。6.3 独家避坑技巧分享几个我摸索出来的小技巧。第一给代理加一个健康检查端点比如/healthz返回 200 表示代理正常。这样你可以用脚本监控代理是否活着避免它悄悄挂掉你还不知道。第二把重试日志写到文件而不是只打屏方便事后分析哪些时段过载最严重从而调整你的任务调度时间。第三对不同的错误类型用不同的退避曲线at capacity恢复快可以用短退避overloaded恢复慢用长退避。我后来把这两类分开处理整体等待时间又降了一截。这个工具后续还能这样扩展把重试统计做成一个简单的本地面板实时显示成功率、平均等待、当前退避状态或者支持多上游轮询一个地址过载就切到另一个。我自己目前用到的就是重试加日志这两块已经足够把日常的at capacity问题压下去了。
返回列表