2026.10.30
429 的文案里没有 429:一次模型降级链失效的复盘
模型池按错误文案分类,Agnes 的 429 写着「实名认证以获取更高 rpm」,于是被误判成永久额度耗尽;无效模型又把降级链拦腰截断,最后 sidecar 还被未捕获异常带崩。这篇记录四个叠加的故障,以及分类器为什么不能只读人话。
上一篇《模型池的两种失败》里,我把失败分成两类:额度耗尽是永久的,限流是暂时的。这个判断现在看仍然成立,但那次漏掉了一个更前置的问题:
你怎么确定自己拿到的是哪一种失败?
这次线上事故就是分类器自己错了。一个 429 被当成永久额度耗尽,一个不存在的模型把降级链拦腰截断,最后聊天室的 sidecar 还被未捕获异常带崩。四个问题叠在一起,表现成"聊天室能用,智能体和文档工作台却 500"。
现象#
- 聊天室 @AI 正常回复
- 智能体和文档工作台返回
{"error":"Failed to get AI response"} - WebSocket 连不上,但 Next.js 首页还是 200
日志里最刺眼的一行是:
message: '请登录platform进行实名认证以获取更高rpm'
[model-pool] agnes-2.5-flash 免费额度耗尽,降级明明写着"更高 rpm",却被判成了"免费额度耗尽"。
故障一:用文案做分类#
模型池里有两个分类函数:
function isQuotaError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /quota|balance|rate.?limit|429/i.test(msg);
}
function isRateLimitError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return msg.includes("429") || /rate.?limit/i.test(msg);
}Agnes 的 429 文案是"请登录 platform 进行实名认证以获取更高 rpm"。里面既没有 429,也没有 rate limit。于是:
- 我后来给
isQuotaError补了statusCode === 429,它先返回了 true - 但
isRateLimitError仍然只看 message,返回 false - catch 走进
else分支,调用markModelExhausted
const exhaustedModels = new Set<string>();
export function markModelExhausted(modelName: string): void {
exhaustedModels.add(modelName);
}这个 Set 是进程内的。一旦被加进去,本进程永久跳过,不是 60 秒冷却。
教训#
错误分类的输入应该是结构化字段:statusCode、responseBody、嵌套的 lastError。人话文案会变、会本地化、会为了产品提示改措辞,拿它做状态机判断迟早出错。
修复后:
const matches = (e: unknown) =>
statusOf(e) === 429 ||
/429|rate.?limit|too many requests|\brpm\b|\btpm\b/i.test(hay(e));429 走 60 秒冷却,403 才永久摘除。
故障二:无效模型截断降级链#
Agnes 429 之后,链继续往下走:
qwen3.8-max 403 免费额度耗尽
qwen3.7-max-preview 403 免费额度耗尽
qwen3.7-max-2026-05-20 403 免费额度耗尽
qwen3.6-max-preview 400 Model not exist.前三个是额度问题,代码正确地继续了。第四个是"模型不存在"——这既不是额度,也不是限流,旧的 catch 直接 throw,后面的 deepseek-v3.1、glm-5.2、kimi-k2.6 根本没被尝试。
智能体和文档工作台的上层 catch 只把额度错误映射成"没精力了",其他错误统一 500。于是最后那个 Model not exist 变成了用户看到的 Failed to get AI response。
教训#
"模型不存在"是模型级不可用,不是整条链的终态。正确动作是标记这个模型、换下一个,而不是把异常抛给业务层。
if (isModelUnavailableError(err)) {
markModelExhausted(entry.modelName);
console.warn(`[model-pool] ${entry.modelName} 不可用,跳过`);
lastErr = err;
continue;
}降级链的价值在于能走到最后。中间任何一个配置错误都不该让它提前结束。
故障三:链尾没有可用模型#
修完无效模型后我直接探测了 high 链的每个模型:
qwen3.8-max、qwen3.7-max-preview、glm-5.2、kimi-k2.6、qwen3.6-plus……全部 403qwen3.6-max-preview、deepseek-v3.1是Model not exist- low 链的
qwen-flash-2025-07-28是 200
所以 high 链即使跳过无效模型,也没有一个能用的。最后在 high 链末尾加了一个已知可用的 flash 模型兜底:
// 质量低于 max 系列,但比直接报「没精力了」或退化成规则兜底好
{ modelName: "qwen-flash-2025-07-28", ..., tier: "high" }教训#
- 降级链的上限不是"接了多少个模型",而是最后一个模型是否有额度
- 不要凭配置猜,直接发最小请求探测每个模型的实际状态
- 兜底模型可以牺牲质量换可用性,但要明确这是取舍
故障四:sidecar 被未捕获异常带崩#
聊天室里触发 AI 的代码原本是:
void handleAI(prompt, userId, aiRole, replyTo, userName);handleAI 内部抛出的 AI_APICallError 没有人接。Node 22 的默认行为是:未捕获的 Promise rejection 直接终止进程。
于是 sidecar 挂了,Next.js 还在跑。健康检查只看 3000,看起来一切正常;但 3001 已经没了,WebSocket 自然连不上。
教训#
- fire-and-forget 必须
.catch(),哪怕只是打日志 - 流式读取也要 try/catch;错误可能发生在第一个 chunk 之后
- 一个容器里跑多个进程时,健康检查要覆盖每个端口/进程,不能只看主应用
故障五:所有失败都说「没精力了」#
聊天室失败时的兜底文案是从额度场景复用的:
await finishWithFallback(requestId, aiRole, cfg.name);
// 内部固定使用 ENERGY_DOWN_MESSAGE = "没精力了,宕机了"模型不存在、网络错误、数据库异常,全都显示"没精力了"。用户以为是自己 token 用完了,实际上当天只用了 3792 / 1000000。
修复后按原因分流:
- 额度 / 限流 → 没精力了
- 其他错误 → 这边出了点问题,稍后再试
教训#
用户可见文案必须和真实原因对应。把所有异常塞进一句兜底话,短期省事,长期会让排查方向完全跑偏。
验证方式#
改完没有只跑单测,而是做了两件事:
- 在容器里直接调用 high 链,确认命中
agnes-2.5-flash并返回内容 - 手动
markModelExhausted("agnes-2.5-flash")强制主模型不可用,观察完整降级:
qwen3.8-max 免费额度耗尽 → 降级
qwen3.7-max-preview 免费额度耗尽 → 降级
qwen3.7-max-2026-05-20 免费额度耗尽 → 降级
qwen3.6-max-preview 模型不存在 → 跳过
deepseek-v3.1 模型不存在 → 跳过
glm-5.2 免费额度耗尽 → 降级
kimi-k2.6 免费额度耗尽 → 降级
qwen3.6-plus 免费额度耗尽 → 降级
qwen3.5-plus 免费额度耗尽 → 降级
qwen-flash-2025-07-28 成功 → "OK"只有把主模型真正拿掉,才算验证了降级链。
最后#
上一篇说"失败必须先分类,再处置"。这次补上后半句:
分类本身也是要被测试的。 供应商改一句提示文案、返回一个 404、或者在 429 里写"请实名认证",都可能让你的状态机走到完全错误的枝干。分类器读的应该是协议字段,而不是人话;永久状态要有恢复路径;降级链要能走到最后一个模型;异步调用要有人接住异常。
这四点里任何一点缺失,都不算"有冗余"。