2026.10.30

429 的文案里没有 429:一次模型降级链失效的复盘

模型池按错误文案分类,Agnes 的 429 写着「实名认证以获取更高 rpm」,于是被误判成永久额度耗尽;无效模型又把降级链拦腰截断,最后 sidecar 还被未捕获异常带崩。这篇记录四个叠加的故障,以及分类器为什么不能只读人话。

约 6 分钟

上一篇《模型池的两种失败》里,我把失败分成两类:额度耗尽是永久的,限流是暂时的。这个判断现在看仍然成立,但那次漏掉了一个更前置的问题:

你怎么确定自己拿到的是哪一种失败?

这次线上事故就是分类器自己错了。一个 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。于是:

  1. 我后来给 isQuotaError 补了 statusCode === 429,它先返回了 true
  2. 但 isRateLimitError 仍然只看 message,返回 false
  3. 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……全部 403
  • qwen3.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。

修复后按原因分流:

  • 额度 / 限流 → 没精力了
  • 其他错误 → 这边出了点问题,稍后再试

教训#

用户可见文案必须和真实原因对应。把所有异常塞进一句兜底话,短期省事,长期会让排查方向完全跑偏。

验证方式#

改完没有只跑单测,而是做了两件事:

  1. 在容器里直接调用 high 链,确认命中 agnes-2.5-flash 并返回内容
  2. 手动 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 里写"请实名认证",都可能让你的状态机走到完全错误的枝干。分类器读的应该是协议字段,而不是人话;永久状态要有恢复路径;降级链要能走到最后一个模型;异步调用要有人接住异常。

这四点里任何一点缺失,都不算"有冗余"。