路由需要回答两个问题
一个能工作的路由器,和一个能被信任的路由器,差别在于它能否回答两个问题:为什么选它,以及为什么没选它。
第一个问题在顺利时看起来不重要。请求成功,用户满意,没人会追问模型是怎么选出来的。但一旦质量下降,第一个问题就会变成“为什么这次和上次不一样”。如果路由器只能说“根据策略选的”,排查就变成了猜。
第二个问题在故障时更重要。「回声」的模型清单里有十几个模型,一次请求只用一个,其余都被排除。被排除的原因是能力不匹配、区域不支持、上下文不够、熔断中,还是仅仅排序靠后?不同原因的处置方式完全不同:能力不匹配要改配置,熔断要等冷却,排序靠后可能只需要调权重。
这一节把决策翻译成结构化解释,并讨论灰度:新模型如何按租户或按请求比例进入,而不是一次性全量切换。
解释不是日志,是数据结构
很多团队把解释写成一行日志:routed to model-x。这行日志只能回答“选了谁”,不能回答“为什么”。要回答为什么,解释必须先是一个数据结构,再被渲染成文本。
结构化的解释至少包含三部分:选中的理由、淘汰的理由、候选的排序。选中理由说明档位、分数、是否探针、是否降级;淘汰理由说明每个模型被哪个检查拦住;排序说明可服务但没被选中的模型排在第几。
把解释做成结构还有两个好处。第一,日志系统可以按 code 聚合,比如统计一周内 rejected.circuit-open 出现了多少次,判断某个提供方是否不稳定。第二,前端或客服工具可以按需展示,不必把整段中文文本塞进日志。
解释行的三个层次
选中理由
选中理由回答“它凭什么排第一”。至少要有三项:档位、加权分数、是否处于半开探针。如果主选低于请求的质量下限,还要明确写出这是一次降级。
档位说明优先级,分数说明同档位内的排序依据,探针说明这个模型刚刚从熔断中恢复,可靠性还没有被完全证明。三项合在一起,才能让读者判断这次选择是“正常最优”还是“故障下的次优”。
淘汰理由
淘汰理由按过滤顺序生成:能力、区域、注册表健康、熔断、权重、上下文、输出上限、延迟。每条理由都带模型 id 和具体数字,比如“估算输入 6000 + 输出 800 > 窗口 4096”,而不是一句“上下文不足”。
具体数字很重要。复盘时经常需要判断“差一点”还是“差很多”:窗口差 100 token 可以调裁剪策略,差 10000 token 就只能换模型。只写原因不写数字,等于把判断成本推给下一个人。
排序理由
可服务但没被选中的模型,不算“淘汰”,它们只是排在后面。解释里要单独标出 rejected.ranked-lower,并给出排名。否则读者会误以为这些模型不可用,进而做出错误的容量判断。
排序理由和淘汰理由分开,也让指标更准确:淘汰率高说明配置或健康有问题,排序靠后说明权重或档位策略需要调整。两者混在一起,就无法区分“不能用”和“没轮上”。
稳定代码与人类文本分开
解释行有两个字段:code 与 text。code 是稳定代码,供日志聚合和前端判断;text 是人类文本,供人阅读。两者不能合并成一个字符串。
原因很实际。人类文本会改:今天写“熔断中”,明天可能改成“提供方暂时不可用”。如果日志聚合依赖文本,每次改文案都会让历史指标断档。稳定代码不会随文案变化,它只随语义变化,而语义变化应该是一次显式的版本升级。
代码里用 rejected.${reason} 生成代码,reason 来自路由器定义的联合类型。TypeScript 会在编译期检查 REJECT_TEXT 是否覆盖所有原因;新增一个拒绝原因却忘了写解释,类型检查会直接报错。
灰度:按租户还是按请求
新模型上线时,直接全量切换风险太大。灰度要解决的是“让一小部分流量先试,并且这部分流量可以稳定复现”。按什么维度切流量,决定了灰度能回答什么问题。
租户灰度
按租户灰度,同一个租户的所有请求要么全部走新模型,要么全部走旧模型。它适合回答“这个模型对某类客户是否可用”:企业客户可以单独开白名单,免费租户继续走旧模型。
租户灰度的缺点是个体差异大。一个高频租户可能贡献了大量请求,一旦它被选中,实际灰度比例会远高于配置的 5%。所以按租户灰度时,比例应该按“租户数”理解,而不是按“请求数”理解。
请求灰度
按请求灰度,同一个租户的不同请求会落到不同侧。它适合回答“新模型在真实流量分布下表现如何”,因为样本更接近整体请求分布,不会被少数大租户带偏。
请求灰度的缺点是用户体验可能不一致:同一个用户连续两次操作,可能一次走新模型、一次走旧模型。对于智能回复这类需要稳定语气的场景,这种不一致本身就是一个风险。选择哪种灰度,要先回答“不一致是否可接受”。
确定性哈希与基点
两种灰度都依赖确定性哈希。代码用 FNV-1a 把 rule.id 与租户 id 或请求 id 拼成的 key 映射到 0 到 9999 的桶,再与基点比较。percentageBps 用基点而不是浮点比例,是为了避免 0.1 + 0.2 这类精度问题,也让配置值可以直接写成整数。
确定性带来三个好处:同一租户不会今天在灰度里、明天掉出去;服务重启后灰度名单不变;事故复盘时可以拿同样的 key 重算出当时的判断。这些性质不是“锦上添花”,而是灰度能否被信任的前提。
哈希分布只保证统计性质,不保证精确比例。配置 5000 基点、跑 200 个租户,命中数可能在 90 到 110 之间,不会正好是 100。测试因此断言区间,而不是精确值。需要精确配额时,应该用显式的租户名单,而不是调低比例。
实现:解释器与灰度
下面的模块接收上一节的路由决策,输出解释结构,并提供按租户或按请求的灰度规则。它不重新计算路由,只负责翻译与提升,保证解释与决策来自同一份数据。
// file: src/routing/explain.ts
// tier: L1
// verify: node --test test/routing/explain.test.ts
import { stableBucket } from "./router.ts";
import type { RouteDecision, RouteRejectReason } from "./router.ts";
import type { QualityTier, TenantId } from "../core/types.ts";
export interface ExplanationLine {
/** 稳定代码,便于日志聚合与前端按需展示。 */
code: string;
text: string;
}
export interface RankedCandidate {
modelId: string;
tier: QualityTier;
rank: number;
/** 保留三位小数,避免解释文本随浮点尾数抖动。 */
score: number;
probe: boolean;
selected: boolean;
}
export interface RouteExplanation {
requestId: string;
selected: string | null;
summary: string;
/** 为什么选它。 */
chosen: ExplanationLine[];
/** 为什么没选它:既包括被过滤的,也包括可服务但排序靠后的。 */
rejected: ExplanationLine[];
ranking: RankedCandidate[];
}
const REJECT_TEXT: Record<RouteRejectReason, string> = {
"capability-mismatch": "没有声明请求所需能力",
"region-mismatch": "不支持请求所在区域",
unhealthy: "注册表标记为不健康",
"circuit-open": "熔断冷却中,暂不参与路由",
"zero-weight": "策略权重为 0,已被停用",
"context-too-small": "上下文窗口装不下估算输入与输出",
"output-limit-too-small": "模型输出上限低于请求",
"deadline-too-tight": "P95 延迟超过端到端截止预算",
};
/** 把一次路由决策翻译成人能读、机器也能聚合的解释。 */
export function explainDecision(decision: RouteDecision): RouteExplanation {
const chosen: ExplanationLine[] = [];
const rejected: ExplanationLine[] = [];
const primary = decision.primary;
if (primary) {
chosen.push({
code: "selected.tier",
text: `选中 ${primary.model.id}:档位 ${primary.tier},在候选排序中位列第一`,
});
chosen.push({
code: "selected.score",
text: `加权分数 ${primary.score.toFixed(3)}(权重 ${primary.weight}),同档位按分数从高到低排序`,
});
if (primary.probe) {
chosen.push({
code: "selected.probe",
text: "该模型处于熔断冷却后的半开探针,成功一次即可恢复",
});
}
if (decision.degraded) {
chosen.push({
code: "selected.degraded",
text: "主选档位低于请求的质量下限,这是一次显式降级",
});
}
}
for (const rejection of decision.rejected) {
rejected.push({
code: `rejected.${rejection.reason}`,
text: `${rejection.modelId}:${REJECT_TEXT[rejection.reason]};${rejection.detail}`,
});
}
const ranking: RankedCandidate[] = decision.candidates.map((candidate, index) => ({
modelId: candidate.model.id,
tier: candidate.tier,
rank: index + 1,
score: Number(candidate.score.toFixed(3)),
probe: candidate.probe,
selected: index === 0,
}));
for (const candidate of ranking.slice(1)) {
rejected.push({
code: "rejected.ranked-lower",
text: `${candidate.modelId}:可服务但排序第 ${candidate.rank},低于主选`,
});
}
const healthRejected = decision.rejected.filter(
(item) => item.reason === "unhealthy" || item.reason === "circuit-open",
).length;
const summary = primary
? `选中 ${primary.model.id};候选 ${decision.candidates.length} 个,淘汰 ${decision.rejected.length} 个,其中健康或熔断原因 ${healthRejected} 个。`
: `没有可用候选:${decision.rejected.length} 个模型全部被排除。`;
return {
requestId: decision.requestId,
selected: primary?.model.id ?? null,
summary,
chosen,
rejected,
ranking,
};
}
export type CanaryKey = "tenant" | "request";
export interface CanaryRule {
id: string;
/** 灰度命中的目标模型;必须已经出现在候选里才会生效。 */
targetModelId: string;
/** 基点:10000 表示 100%,避免浮点比例。 */
percentageBps: number;
/** 按租户稳定命中,还是按请求稳定命中。 */
key: CanaryKey;
/** 命中后是否把目标模型提升到第一位。 */
promote: boolean;
}
export interface CanaryContext {
tenantId: TenantId;
requestId: string;
}
export interface CanaryResult {
decision: RouteDecision;
applied: string[];
skipped: string[];
}
/** 稳定判断一次请求是否落入灰度;同一 key 的结果不会随进程重启改变。 */
export function canaryHit(rule: CanaryRule, context: CanaryContext): boolean {
if (rule.percentageBps <= 0) {
return false;
}
if (rule.percentageBps >= 10_000) {
return true;
}
const key =
rule.key === "tenant" ? `tenant:${context.tenantId}` : `request:${context.requestId}`;
return stableBucket(`${rule.id}:${key}`, 10_000) < rule.percentageBps;
}
/**
* 应用灰度规则。目标模型不在候选中时记入 skipped,而不是临时创造候选。
* promote 会改变候选顺序与 primary;degraded 仍表示相对质量下限的原始判断,
* 因此灰度目标应优先选择同档位模型。
*/
export function applyCanary(
decision: RouteDecision,
rules: readonly CanaryRule[],
context: CanaryContext,
): CanaryResult {
let candidates = [...decision.candidates];
const applied: string[] = [];
const skipped: string[] = [];
for (const rule of rules) {
if (!canaryHit(rule, context)) {
continue;
}
const index = candidates.findIndex(
(candidate) => candidate.model.id === rule.targetModelId,
);
if (index === -1) {
skipped.push(`${rule.id}:target-not-candidate`);
continue;
}
if (rule.promote && index > 0) {
const [target] = candidates.splice(index, 1);
candidates = [target, ...candidates];
}
applied.push(rule.id);
}
return {
decision: {
...decision,
candidates,
primary: candidates[0] ?? null,
},
applied,
skipped,
};
}
applyCanary 有一个刻意的限制:目标模型必须已经出现在候选里。灰度不是“绕过路由”,而是“在可用候选里优先试新模型”。如果目标模型因为能力、区域或健康被淘汰,灰度规则会记入 skipped,而不是强行把它加回候选。
另一个细节是 promote。命中灰度后,代码把目标模型移到候选第一位,并同步更新 primary。这让灰度与解释保持一致:解释读到的 primary 就是灰度后的主选,不会出现“解释说选了 A、实际调用 B”的分裂。
degraded 在灰度后保持不变,因为它表示的是相对质量下限的原始判断。如果灰度目标跨了档位,调用方应该重新评估降级状态;实际使用中,灰度目标应尽量选同档位模型,避免把一次灰度变成一次隐式降级。
测试与真实输出
测试覆盖解释的选中理由、淘汰理由、排序理由、降级标记,以及租户灰度、请求灰度和提升逻辑。在实验工程根目录执行:
node --test test/routing/explain.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 解释给出选中理由、排序与被淘汰原因 (1.277584ms)
✔ 没有候选时解释明确指出全部被排除 (0.107333ms)
✔ 显式降级会写进选中理由 (0.09375ms)
✔ 租户灰度命中稳定,0 与 10000 基点边界明确 (0.411959ms)
✔ 按请求灰度会让同一租户的不同请求落到不同侧 (0.198959ms)
✔ 灰度命中后提升目标模型,目标不在候选中则跳过 (0.167583ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 148.340833
六组测试里,灰度两组用的是统计断言,解释三组用的是结构断言。结构断言可以精确比对代码与排序,统计断言只能断言区间与稳定性。两类断言的分工,正好对应解释与灰度两种不同的正确性。
复盘:一次无法复现的路由
某次线上问题:用户反馈同一个问题问两遍,第一遍回答很好,第二遍答非所问。客服查不到原因,因为日志里只有模型 id,没有决策依据。
事后用请求 id 重放,发现两次请求都命中了同一个 frontier 模型,但第一次的候选列表里有三个模型,第二次只剩一个。第二次另外两个模型都被标记为 circuit-open,而熔断状态在两次请求之间刚好发生变化。
如果没有结构化解释,这个结论很难得出。日志只写“用了 model-x”,会让人误以为是模型本身不稳定;有了拒绝原因,才能看出是健康状态变化导致候选集合缩小,而剩下的模型恰好不擅长这个问题。
修复不是换模型,而是补两条规则:同一请求的重试应该复用第一次的候选快照,除非健康状态发生显式变化;熔断状态变化要写入决策日志,并带上冷却剩余时间。修复后,重试的行为可以被解释,也可以被测试。
这个复盘说明,可解释性不是“给领导看的报表”,而是故障定位的基础设施。解释结构越稳定,复盘越不依赖某个人对系统的记忆。
灰度上线检查表
| 检查项 | 问题 | 不通过时的做法 |
|---|---|---|
| 灰度维度 | 按租户还是会话,能否接受体验不一致 | 改为按租户,或只在低风险能力上按请求 |
| 比例口径 | 5% 是租户数还是请求数 | 在配置名与文档里写清楚 |
| 目标候选 | 目标模型是否在候选里 | 先修能力、区域或健康,不要绕过路由 |
| 回滚开关 | 关闭灰度是否立即生效 | 把规则做成可热更新的配置 |
| 观测字段 | 是否记录 rule id 与命中结果 | 在决策日志里补 applied/skipped |
| 降级标记 | 灰度目标是否跨档位 | 优先同档位,跨档时重新评估 degraded |
小结
可解释性回答“为什么选它、为什么没选它”,灰度回答“新模型如何小步进入”。两者共用同一份路由决策:解释读取候选、拒绝原因与排序,灰度在候选内提升目标模型,不绕过过滤。
稳定代码让解释可以被聚合,确定性哈希让灰度可以被复现,候选内提升让灰度不会破坏路由的硬约束。三者合在一起,路由才从“能跑”变成“能运营”。
下一节进入流式输出:一次请求从网关到用户屏幕,中间要经过哪些环节,以及为什么流式会把错误处理的时间窗口压缩得更短。
阅读导航:上一节:4.2 优先级、加权与健康回退 · 下一节:5.1 流式的端到端链路 。
常见错误与边界
- 只记录选中的模型,不记录拒绝原因。故障时只能猜,无法判断是模型问题还是候选集合变化。
- 把解释写成一段拼接文本。文本会改,日志聚合会断,稳定代码必须单独存在。
- 淘汰理由不写具体数字。只写“上下文不足”无法判断差一点还是差很多。
- 把排序靠后当成不可用。两者处置方式不同,指标也应该分开统计。
- 用随机数做灰度。同一租户会来回跳,重启后名单变化,事故无法复现。
- 把比例理解成精确配额。哈希只保证统计分布,需要精确名单就显式配置。
- 灰度绕过路由过滤。目标模型不在候选里时应该跳过,而不是强行加入。
- 灰度提升后不更新主选。解释与实际调用会分裂,日志失去可信度。
- 灰度目标跨档位却不重新评估降级。一次灰度可能变成一次静默的质量下降。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。