优先级不是“越贵越好”
上一节把路由拆成五个维度,这一节要把维度变成排序。最常见的误解是:优先级就是价格从高到低,越贵的模型越先试。这个理解只在“所有模型都能完成同一个任务”时成立,而现实里模型的能力、窗口、输出上限和区域支持都不同。
「回声」的三类调用各有自己的优先级:智能回复要 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()做加权。随机分配无法复现,重试会变成隐式模型切换。 - 把注册表健康与熔断状态合并。慢变量和快变量混在一起,恢复与下架都会失真。
- 熔断冷却结束就全量放回。半开探针的作用是先放少量流量验证,不是直接恢复全量。
- 降级不标记。业务侧只看到成功,不知道质量已经下降,用户成为唯一的感知者。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。