《互联网产品的 AI 架构·模型接入与能力边界》4.2 优先级、加权与健康回退

在五维路由之上实现可测试的候选筛选与排序:按能力、区域、上下文、输出上限和延迟先排除,再按档位优先级与加权分数排序,并用熔断状态处理健康回退与冷却后的半开再进入,最终给出确定性的 TypeScript 路由器、可审计的拒绝原因与六组完整测试。

优先级不是“越贵越好”

上一节把路由拆成五个维度,这一节要把维度变成排序。最常见的误解是:优先级就是价格从高到低,越贵的模型越先试。这个理解只在“所有模型都能完成同一个任务”时成立,而现实里模型的能力、窗口、输出上限和区域支持都不同。

「回声」的三类调用各有自己的优先级:智能回复要 frontier,因为一句话的语气错误会让用户直接不信任;内容审核要 economy,因为它只需要稳定分类,贵模型不会显著降低误杀;搜索重排先按区域筛,再在同档位里按权重分散。优先级是“档位顺序”,不是“价格顺序”。

优先级必须显式配置,不能藏在模型清单的排列顺序里。清单顺序会随着注册、下线和合并而变化,一旦排序依赖数组下标,某次无关的配置调整就可能改变线上流量。

先排除,再排序

路由的第一步不是算分,而是排除。原因有两个:不符合硬约束的模型即使分数再高也不能用;把淘汰原因记录下来,才能在事故复盘时回答“为什么没选它”。

过滤顺序固定为:能力、区域、注册表健康、熔断状态、权重、上下文窗口、输出上限、延迟预算。第一个命中的原因就是最终原因,后面的检查不再执行。顺序固定让同一份输入永远得到同一份拒绝理由,也让测试可以直接比对。

上下文与输出上限是硬约束

上下文窗口要同时装下输入和输出。粗估输入 token 后,加上请求的 maxOutputTokens,如果超过模型窗口,这个模型就不能选。输出上限则是另一条独立的线:请求要 800 token,模型最多只会生成 512 token,即使上下文足够也一定会截断。

这两条检查看起来简单,却是长文摘要事故的直接防线。它们不需要调用模型,也不需要真实 tokenizer,只用请求里的数字和模型档案就能算出来。

延迟预算决定“值不值得试”

每个模型档案里有 P95 延迟,请求里有端到端截止预算。P95 大于预算的模型不进入候选,因为它在大多数情况下无法在预算内完成。这里用 P95 而不是平均值,是因为用户感知到的是慢的那一批请求,不是平均数。

延迟检查是保守的:P95 只是一个统计量,不代表这次一定超时。但在路由层,保守比乐观更合适。把明显来不及的模型排除掉,比先发出去再取消更省成本,也更少产生半截响应。

档位优先级:只允许逐级下降

tierPriority 定义档位顺序,默认是 frontier、standard、economy。路由按这个顺序排列候选,主选永远是当前可用的最高档。请求还可以带一个 qualityFloor:如果主选低于这个下限,决策会被标记为 degraded,调用方可以据此决定是否提示用户、是否改用模板,或者直接失败。

degraded 不是一个错误,而是一个显式状态。降级本身可以是正确决策,比如高峰期限流时用 standard 顶替 frontier;但它不能被静默发生。没有这个标记,业务侧只会看到“请求成功”,不知道质量已经下降。

档位顺序也限制了跨档跳跃。候选按档位分组,frontier 全不可用时才轮到 standard,standard 全不可用时才轮到 economy。这样可以保证降级是逐级的,不会因为某个 economy 模型分数特别高就直接跳过 standard。

加权:同档位内的确定性分散

同一档位往往有多个模型,比如两个 frontier 提供方。如果永远选字典序第一个,流量会全部压在一个提供方上,既浪费另一个的容量,也让故障影响被放大。加权解决的是同档位内的分配问题。

这里的加权不是 Math.random()。随机数无法复现:同一个请求重试一次可能落到另一个模型,事故复盘时也无法重建当时的选择。代码用 FNV-1a 把 requestId 与 modelId 映射到稳定桶,再用权重除以桶值得到分数。权重越高,分数在统计上越大;同一个请求重复计算,结果完全一致。

本机测试里给两个 frontier 模型配置 3:1 的权重,跑 400 个不同请求,frontier-a 赢约 330 次,frontier-b 赢约 70 次。这个比例不是精确保证,而是统计期望;测试只断言区间,不写死具体次数。

健康与熔断是两套状态

模型健康有两个层次,不能混在一起。

第一层是注册表里的 healthy 布尔值。它表示这个模型当前是否应该参与路由,通常由人工下架、提供方合同变更或长期故障触发。它是慢变量,改动需要发布或配置更新。

第二层是动态熔断状态。它记录连续失败次数和冷却结束时间,由网关在每次调用后更新。它是快变量,秒级变化,不能写进模型档案,也不能由业务缓存。

两层都检查:注册表不健康的模型直接淘汰,动态熔断打开的模型也淘汰。把两者合并成一个布尔值,会导致“熔断恢复后注册表仍然认为它不健康”,或者“人工下架后熔断器还在放流量”。

熔断后的再进入

熔断状态机只有三个状态:closed、open、half-open。

  • closed:正常参与路由。
  • open:冷却期内不参与路由,拒绝原因记为 circuit-open。
  • half-open:冷却结束后允许进入,但只作为探针,排在健康同档模型之后。

半开探针只允许少量流量进入,成功一次就回到 closed,失败则重新打开并延长冷却。这个设计避免了“冷却一结束就把全部流量打回去”的雪崩,也避免了“失败一次就永久拉黑”的过度反应。

recordOutcome 负责推进状态:连续失败达到阈值时把 openUntilMs 设为当前时间加冷却窗口;成功时清零。它不直接决定路由,只维护状态;circuitStatus 再根据当前时间判断处于哪个阶段。把“记录结果”和“判断状态”分开,测试可以分别覆盖。

实现:一个确定性的路由器

下面的模块把过滤、排序、加权与熔断串成一条纯函数链路。输入是模型清单、请求、健康状态和时钟,输出是候选列表、被拒原因和主选。它不调用任何模型,也不读写全局状态。

// file: src/routing/router.ts
// tier: L1
// verify: node --test test/routing/router.test.ts

import type { ChatMessage, ChatRequest, ModelProfile, QualityTier } from "../core/types.ts";

/** 档位从高到低;路由只按这个顺序降级。 */
export const TIER_ORDER: readonly QualityTier[] = ["frontier", "standard", "economy"];

export type CircuitStatus = "closed" | "open" | "half-open";

/** 动态健康状态;与 ModelProfile.healthy 的静态标记分开维护。 */
export interface ModelHealth {
  modelId: string;
  consecutiveFailures: number;
  /** 0 表示未熔断;大于 nowMs 表示熔断中;小于等于 nowMs 表示冷却结束。 */
  openUntilMs: number;
}

export interface RouterPolicy {
  tierPriority?: readonly QualityTier[];
  /** 同档位权重;缺省 1,小于等于 0 视为停用。 */
  weights?: Readonly<Record<string, number>>;
  failureThreshold?: number;
  cooldownMs?: number;
}

export interface RouteInput {
  request: ChatRequest;
  models: readonly ModelProfile[];
  /** 请求要求的最低档位;主选低于它时标记 degraded。 */
  qualityFloor?: QualityTier;
  health?: readonly ModelHealth[];
  policy?: RouterPolicy;
  /** 注入时钟,保证熔断逻辑可复现。 */
  nowMs: number;
}

export type RouteRejectReason =
  | "capability-mismatch"
  | "region-mismatch"
  | "unhealthy"
  | "circuit-open"
  | "zero-weight"
  | "context-too-small"
  | "output-limit-too-small"
  | "deadline-too-tight";

export interface RouteCandidate {
  model: ModelProfile;
  tier: QualityTier;
  score: number;
  weight: number;
  probe: boolean;
}

export interface RouteRejection {
  modelId: string;
  reason: RouteRejectReason;
  detail: string;
}

export interface RouteDecision {
  requestId: string;
  candidates: RouteCandidate[];
  rejected: RouteRejection[];
  primary: RouteCandidate | null;
  degraded: boolean;
}

/** 粗略估算:中文按 2 字符 1 token 偏保守。 */
export function estimateInputTokens(messages: readonly ChatMessage[]): number {
  const chars = messages.reduce((sum, message) => sum + message.content.length, 0);
  return Math.ceil(chars / 2);
}

/** FNV-1a 稳定分桶;同一 key 永远落在同一桶。 */
export function stableBucket(key: string, buckets: number): number {
  if (!Number.isInteger(buckets) || buckets <= 0) {
    throw new RangeError("buckets must be a positive integer");
  }
  let hash = 0x811c9dc5;
  for (let index = 0; index < key.length; index += 1) {
    hash ^= key.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash % buckets;
}

/** 加权 rendezvous:权重越高越常被选中,但同一请求结果固定。 */
export function weightedScore(requestId: string, modelId: string, weight: number): number {
  const bucket = stableBucket(`${requestId}:${modelId}`, 1_000_003);
  return weight / ((bucket + 1) / 1_000_004);
}

export function circuitStatus(health: ModelHealth | undefined, nowMs: number): CircuitStatus {
  if (!health || health.openUntilMs === 0) return "closed";
  return nowMs < health.openUntilMs ? "open" : "half-open";
}

/** 失败达到阈值后打开 cooldownMs;冷却结束后由 circuitStatus 返回 half-open。 */
export function recordOutcome(
  health: ModelHealth | undefined,
  modelId: string,
  ok: boolean,
  nowMs: number,
  policy: Pick<RouterPolicy, "failureThreshold" | "cooldownMs"> = {},
): ModelHealth {
  if (ok) return { modelId, consecutiveFailures: 0, openUntilMs: 0 };
  const threshold = policy.failureThreshold ?? 3;
  const cooldownMs = policy.cooldownMs ?? 30_000;
  const current = health ?? { modelId, consecutiveFailures: 0, openUntilMs: 0 };
  const consecutiveFailures = current.consecutiveFailures + 1;
  const openUntilMs = consecutiveFailures >= threshold ? nowMs + cooldownMs : 0;
  return { modelId, consecutiveFailures, openUntilMs };
}

function tierRank(tier: QualityTier, priority: readonly QualityTier[]): number {
  const index = priority.indexOf(tier);
  return index === -1 ? priority.length : index;
}

function reject(
  modelId: string,
  reason: RouteRejectReason,
  detail: string,
): RouteRejection {
  return { modelId, reason, detail };
}

/** 过滤顺序固定,第一个命中的原因即最终原因。 */
function rejectModel(
  model: ModelProfile,
  request: ChatRequest,
  weight: number,
  inputTokens: number,
  health: ModelHealth | undefined,
  nowMs: number,
): RouteRejection | null {
  if (!model.capabilities.includes(request.capability)) {
    return reject(model.id, "capability-mismatch", `未声明能力 ${request.capability}`);
  }
  if (request.region && !model.regions.includes(request.region)) {
    return reject(model.id, "region-mismatch", `不支持区域 ${request.region}`);
  }
  if (!model.healthy) {
    return reject(model.id, "unhealthy", "注册表标记为不健康");
  }
  if (circuitStatus(health, nowMs) === "open") {
    const remaining = (health?.openUntilMs ?? nowMs) - nowMs;
    return reject(model.id, "circuit-open", `熔断中,冷却剩余 ${remaining}ms`);
  }
  if (weight <= 0) {
    return reject(model.id, "zero-weight", "策略权重为 0,模型被停用");
  }
  if (inputTokens + request.maxOutputTokens > model.contextWindow) {
    return reject(
      model.id,
      "context-too-small",
      `估算输入 ${inputTokens} + 输出 ${request.maxOutputTokens} > 窗口 ${model.contextWindow}`,
    );
  }
  if (request.maxOutputTokens > model.maxOutputTokens) {
    return reject(
      model.id,
      "output-limit-too-small",
      `请求输出 ${request.maxOutputTokens} > 模型上限 ${model.maxOutputTokens}`,
    );
  }
  if (model.p95LatencyMs > request.deadlineMs) {
    return reject(
      model.id,
      "deadline-too-tight",
      `P95 ${model.p95LatencyMs}ms > 截止预算 ${request.deadlineMs}ms`,
    );
  }
  return null;
}

function compareCandidates(
  left: RouteCandidate,
  right: RouteCandidate,
  priority: readonly QualityTier[],
): number {
  const tierDiff = tierRank(left.tier, priority) - tierRank(right.tier, priority);
  if (tierDiff !== 0) return tierDiff;
  if (left.probe !== right.probe) return left.probe ? 1 : -1;
  if (left.score !== right.score) return right.score - left.score;
  return left.model.id < right.model.id ? -1 : left.model.id > right.model.id ? 1 : 0;
}

/**
 * 给定模型清单、请求与健康状态,返回排好序的候选与被拒原因。
 * 过滤顺序固定;同输入永远得到同输出。
 */
export function routeModels(input: RouteInput): RouteDecision {
  const { request, models, nowMs } = input;
  const policy = input.policy ?? {};
  const tierPriority = policy.tierPriority ?? TIER_ORDER;
  const healthById = new Map(
    (input.health ?? []).map((state) => [state.modelId, state] as const),
  );
  const inputTokens = estimateInputTokens(request.messages);
  const candidates: RouteCandidate[] = [];
  const rejected: RouteRejection[] = [];

  for (const model of models) {
    const weight = policy.weights?.[model.id] ?? 1;
    const rejection = rejectModel(
      model,
      request,
      weight,
      inputTokens,
      healthById.get(model.id),
      nowMs,
    );
    if (rejection) {
      rejected.push(rejection);
      continue;
    }
    candidates.push({
      model,
      tier: model.qualityTier,
      score: weightedScore(request.requestId, model.id, weight),
      weight,
      probe: circuitStatus(healthById.get(model.id), nowMs) === "half-open",
    });
  }

  candidates.sort((left, right) => compareCandidates(left, right, tierPriority));
  const primary = candidates[0] ?? null;
  const degraded =
    primary !== null &&
    input.qualityFloor !== undefined &&
    tierRank(primary.tier, tierPriority) > tierRank(input.qualityFloor, tierPriority);

  return { requestId: request.requestId, candidates, rejected, primary, degraded };
}

有几个实现细节值得说明。

第一,nowMs 是显式输入,不从 Date.now() 直接读。这样测试可以固定时间,熔断的打开、冷却和半开都能精确断言。生产代码在调用时传入系统时钟,测试传入常数。

第二,rejectModel 的检查顺序是固定的。能力不匹配的模型不会再被检查区域,区域不匹配的模型不会再被检查窗口。原因列表因此稳定,不会因为代码重构而改变顺序。

第三,候选排序先比档位,再比是否探针,再比分数,最后用模型 id 兜底。四级比较保证任何输入都有唯一顺序,不会出现“两个模型分数相同、排序随数组顺序变化”的不确定行为。

测试与真实输出

测试覆盖八种拒绝原因、档位排序、降级标记、加权比例、半开探针与熔断状态机。在实验工程根目录执行:

node --test test/routing/router.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 能力、区域、健康、熔断、权重、窗口、输出与延迟都能给出确定原因 (1.027459ms)
✔ 档位优先级决定主选,candidates 按优先级排序 (0.5315ms)
✔ 只剩低于质量下限的模型时标记 degraded (0.075667ms)
✔ 同档位加权在大量请求上接近权重比例,且可复现 (0.99375ms)
✔ 半开探针排在健康同档模型之后 (0.102792ms)
✔ 熔断达到阈值后打开,冷却结束进入半开,成功后关闭 (0.093583ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 127.755041

六组测试里,加权与熔断两组最容易在重构时被破坏。加权测试断言的是区间而不是精确次数,因为哈希分布只保证统计性质;熔断测试断言的是状态迁移,因为状态机才是正确性的核心。两类断言的写法不同,但目标一致:让“看起来能跑”变成“可以证明”。

一次把“加权”当成“随机”的复盘

某次版本里,同档位分配用了 Math.random(),代码只有一行,评审时没人觉得有问题。上线后客服反馈:同一个用户重试一次,回答质量明显不同。查日志发现,两次请求的输入几乎一样,但落到了两个不同的模型。

问题不在模型质量,而在路由不可复现。随机分配让重试变成了一次隐式的模型切换,而业务侧对此一无所知。更麻烦的是事故复盘:日志里只有“选了哪个模型”,没有“为什么选它”,无法判断是随机、权重还是健康状态导致的。

修复分两步:把随机数换成基于 requestId 与 modelId 的稳定哈希;把候选、拒绝原因和主选一起写进决策日志。修复后,同一个请求重试会得到同一个主选;如果健康状态变了,日志里会明确写出 circuit-open 或 unhealthy,而不是一句“随机到了另一个模型”。

这个复盘说明,路由的“确定性”不是测试洁癖,而是可运维性。一个不可复现的路由器,无法回答用户投诉,也无法做 A/B 归因。

小结

优先级、加权与健康回退是三件事:优先级决定先试哪个档位,加权决定同档位内怎么分散,健康回退决定某个模型暂时不可用时怎么办。三者必须分开实现,否则一旦出问题,很难判断是策略错了还是状态错了。

确定性是这一节的主线。过滤顺序固定、哈希稳定、时钟注入、排序有完整兜底,才能让同一个请求在相同输入下得到相同决策。路由不需要“聪明”,它需要“可解释、可复现、可回退”。

下一节会把决策变成人能读的解释,并讨论灰度:新模型如何按租户或按请求比例进入,而不是一次性全量切换。

阅读导航:上一节:4.1 路由的五个维度 · 下一节:4.3 路由的可解释性与灰度 。

常见错误与边界

  • 把优先级等同于价格。贵模型不一定满足窗口、输出上限或区域要求,硬约束必须先于价格。
  • 用数组下标当优先级。模型清单会增删,下标会漂移,优先级必须显式配置。
  • 先排序再过滤。淘汰模型参与排序会浪费计算,也会让拒绝原因丢失。
  • 只检查上下文窗口,不检查输出上限。窗口够大但输出上限太小的模型仍会截断。
  • 用平均延迟代替 P95。用户感知的是慢请求,平均值会掩盖长尾。
  • 用 Math.random() 做加权。随机分配无法复现,重试会变成隐式模型切换。
  • 把注册表健康与熔断状态合并。慢变量和快变量混在一起,恢复与下架都会失真。
  • 熔断冷却结束就全量放回。半开探针的作用是先放少量流量验证,不是直接恢复全量。
  • 降级不标记。业务侧只看到成功,不知道质量已经下降,用户成为唯一的感知者。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查