免费AI模型的实际失败原因往往是配额/限流状态码被代理层吞噬,而非模型本身能力耗尽。浏览器收到的是通用500而非可区分的429/402,导致用户无法判断该重试还是该等待。
免费 AI 额度在代理吞掉 Retry-After 头时悄然失效
免费 AI 端点通常不会因为模型能力耗尽而失败。它们失败是因为配额、速率限制或上游网关返回了浏览器代码从未学会读取的状态码。修复方案不是一个更精致的错误提示,而是一个小小的中转层——它转发你所需的操作元数据,把一个静默的限制转变为一个可操作的状态。
本周的 AI 信息流被水印和 Agent 工具门控占据,但免费模型演示中真正常见的失败要低调得多。上游提供的 429 或 402 状态到达浏览器时变成了开发者自己服务器的通用 500,于是用户看到"Something went wrong",无法区分"等待并重试"、"你已经用完了配额"和"连接实际上已断开"。
MonkeyCode 定位为一个开源选项,提供免费模型额度(目前声明为 3000 万 Token),以及一条免费服务器路径,你可以用它来测试这个中转层。披露:本文是 MonkeyCode 产品推广的一部分。请把这个 Token 数字当作需要在你自己仪表盘中确认的数据,而非永久合约;下面的实现与提供商无关。
一旦你在浏览器和模型之间加入自己的服务器,你就承担了通常会破坏失败契约的那部分。浏览器 fetch 只能检查你返回给它的响应。如果你的中转层捕获了上游的 429、记录了它,然后返回 res.status(500).json({ error: 'Upstream failed' }),你就删掉了本应告诉客户端等待的信息。
app.post('/api/generate', async (req, res) => {
const upstream = await fetch(process.env.MODEL_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(req.body),
});
const retryAfter = upstream.headers.get('retry-after');
res.status(upstream.status);
if (retryAfter) res.set('retry-after', retryAfter);
for await (const chunk of upstream.body) {
res.write(chunk);
}
res.end();
});
重要的一行是 res.status(upstream.status),而不是流转发。当你的服务器意外地把所有上游状态都压缩成 500 时,前端只能猜测,而猜测产生的是最糟糕的重试按钮:它发起一个全新请求,而配额仍然已耗尽。
在客户端,在写任何 DOM 更新之前先把响应建模为一个状态机。速率限制不是错误;它是一个带时钟的临时状态。已耗尽的配额在本次会话中是终结性的。传输失败则是完全不同的问题。
type ChatState =
| { name: 'streaming'; controller: AbortController }
| { name: 'rateLimited'; retryAt: number }
| { name: 'quotaExhausted' }
| { name: 'failed'; message: string };
function handleResponse(res: Response): ChatState | null {
if (res.status === 429) {
const seconds = Number(res.headers.get('retry-after') ?? 0);
return { name: 'rateLimited', retryAt: Date.now() + seconds * 1000 };
}
if (res.status === 402) return { name: 'quotaExhausted' };
if (!res.ok) return { name: 'failed', message: `HTTP ${res.status}` };
return null;
}
屏幕阅读器用户不应该从红色边框或消失的提示中推断这些状态。在 DOM 中放置一个单独的 aria-live="polite" 区域,然后不要再替换它。当状态改变时,写一个句子,而不是状态名称:"Rate limit reached. You can try again in 18 seconds." 对于已耗尽的配额,说"Free allowance used. Start a new session or switch the model route." 这就是可恢复的等待和模糊失败之间的区别。
不要仅仅因为感觉有用就把焦点移到错误消息上。焦点移动是一个导航动作;实时区域是一个更新。对于配额耗尽这类终结性状态,你可以把焦点移到重试或设置按钮,但要只移一次,而且要等到播报有机会运行之后。键盘用户不应该在流还在 settle 的时候被从对话记录中拽走。
当重试开始时,对前一个流调用 controller.abort() 并替换对话记录写入器。免费端点有时会在客户端已经继续之后仍交付若干 chunk。如果你在多个提示之间共享一个 reader 变量,可能会导致旧 Token 出现在新提示之后,屏幕阅读器会读出一个不再匹配用户意图的对话记录。
使用免费服务器选项而不是直接从浏览器调用模型有两个实际好处。你把凭证从前端代码中隔离出来,而且你创建了一个统一的地方来标准化任何提供商的上游状态码。这就是让重试逻辑可测试而不是成为埋在 UI 中的特殊案例的原因。
针对同一个接口运行四种上游条件:一次成功的流、一个带有效 Retry-After 的 429、一个不带 Retry-After 的 429,以及一个终结性配额响应(如 402)。在每种情况下,先只用键盘测试,然后在 macOS 上用 VoiceOver、在 Windows 上用 NVDA 或 Narrator 测试。记录精确的播报字符串以及焦点停留的位置。如果屏幕阅读器对所有四种状态都重复相同的"Something went wrong"短语,那么中转层或客户端映射就是错的。
这个设计不会让免费额度变大,如果上游提供商把它隐藏在某层负载均衡器后面,或者免费服务器本身引入了一个你没有考虑到的独立速率限制,它也无法提供帮助。如果 Retry-After 缺失了,展示一个估计的倒计时,但要标注为估计值。永远不要让 UI 暗示用户可以跳过提供商未披露的等待。
有价值的工作不是重试按钮。它是把上游的操作响应保持足够长的时间,让键盘或屏幕阅读器用户能够据此行动。针对 MonkeyCode 免费层和免费服务器路径跑一次矩阵,你会在付费用户之前捕获配额 bug。