《互联网产品的 AI 架构·模型接入与能力边界》4.3 路由的可解释性与灰度

路由必须回答两个问题:为什么选它,以及为什么没选它。本节把路由决策翻译成稳定代码的解释行与排序表,再给出按租户和按请求两种确定性灰度方式,用基点与哈希保证同一租户或同一请求的结果可复现,并说明灰度目标为何必须在候选内提升,以及解释如何用于故障复盘,附完整测试。

路由需要回答两个问题

一个能工作的路由器,和一个能被信任的路由器,差别在于它能否回答两个问题:为什么选它,以及为什么没选它。

第一个问题在顺利时看起来不重要。请求成功,用户满意,没人会追问模型是怎么选出来的。但一旦质量下降,第一个问题就会变成“为什么这次和上次不一样”。如果路由器只能说“根据策略选的”,排查就变成了猜。

第二个问题在故障时更重要。「回声」的模型清单里有十几个模型,一次请求只用一个,其余都被排除。被排除的原因是能力不匹配、区域不支持、上下文不够、熔断中,还是仅仅排序靠后?不同原因的处置方式完全不同:能力不匹配要改配置,熔断要等冷却,排序靠后可能只需要调权重。

这一节把决策翻译成结构化解释,并讨论灰度:新模型如何按租户或按请求比例进入,而不是一次性全量切换。

解释不是日志,是数据结构

很多团队把解释写成一行日志: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 流式的端到端链路 。

常见错误与边界

  • 只记录选中的模型,不记录拒绝原因。故障时只能猜,无法判断是模型问题还是候选集合变化。
  • 把解释写成一段拼接文本。文本会改,日志聚合会断,稳定代码必须单独存在。
  • 淘汰理由不写具体数字。只写“上下文不足”无法判断差一点还是差很多。
  • 把排序靠后当成不可用。两者处置方式不同,指标也应该分开统计。
  • 用随机数做灰度。同一租户会来回跳,重启后名单变化,事故无法复现。
  • 把比例理解成精确配额。哈希只保证统计分布,需要精确名单就显式配置。
  • 灰度绕过路由过滤。目标模型不在候选里时应该跳过,而不是强行加入。
  • 灰度提升后不更新主选。解释与实际调用会分裂,日志失去可信度。
  • 灰度目标跨档位却不重新评估降级。一次灰度可能变成一次静默的质量下降。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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