《互联网产品的 AI 架构·模型接入与能力边界》4.1 路由的五个维度

从「回声」的三类 AI 调用出发,把多模型路由拆成任务、租户、区域、成本与健康五个维度,说明哪些事实该由业务侧声明、哪些必须由网关实时掌握,并用可运行的 TypeScript 规划器与审计函数把维度主权固定下来,最后复盘一次便宜模型把长文摘要截断的事故。

一次截断事故暴露的问题

「回声」同时跑着三类 AI 调用:面向用户的智能回复走 frontier,内容审核走 economy,搜索重排按用户所在区域选择提供方。三类调用共用同一个网关,网关手里有一张模型清单,每个模型标注能力、档位、上下文窗口、价格和区域。

某天运营反馈:长文摘要功能在部分文章上只输出半句话。查日志发现,请求命中的是一个 economy 模型,输出上限 512 token,而文章正文有六千多字。网关没有报错,它只是忠实地执行了“在成本上限内选最便宜的模型”。问题不在模型,也不在网关的排序算法,而在请求进入网关之前:业务侧只声明了“我要摘要”,没有声明“摘要至少要完整覆盖正文”和“输出至少 800 token”。

这就是路由的第一个事实:模型选择不是网关单方面的事。网关能算价格、能探健康、能看区域,但它不知道一篇长文摘要“怎样才算没被截断”。这类判断必须由业务侧声明,网关只负责执行。

文中出现的模型名、价格、延迟与比例,除本机测试输出外都是案例假设值,用来演示判断过程,不是任何真实提供方的报价或 SLA。

五个维度分别回答什么

一次路由决策要同时回答五个问题。把它们叫作维度,是因为每个维度都会独立地缩小候选集合,任何一个维度缺输入,路由就只能猜。

任务:要完成什么

任务维度回答的是能力标识、质量下限和输出形态。能力标识决定哪些模型有资格,质量下限决定能降到哪一档,输出形态决定上下文窗口和最大输出是否够用。任务维度最容易被写成一句“调用摘要接口”,然后丢掉后面两个约束。

租户:谁在调用

租户维度回答的是调用方身份与合约。免费租户、付费租户和企业租户能用的档位不同,数据能否出域、能否使用某个提供方也不同。租户维度的输入来自商务合约与产品策略,不是网关能从请求头推断出来的。

区域:请求落在哪里

区域维度回答的是请求应该落在哪个区域,以及哪些提供方在该区域可用。用户在香港、法兰克福还是弗吉尼亚,可选模型、延迟和合规要求都不一样。区域事实变化快,必须由网关在请求时读取提供方区域表,不能由业务写死。

成本:这一次能花多少

成本维度回答的是预算与计价。业务侧可以给出一个软上限,但具体模型的单价、输入输出 token 比例、实际扣费必须由网关用最新价格表计算。业务自己算价,价格表一更新就会全面失真。

健康:现在谁能用

健康维度回答的是当下可用性与恢复节奏。模型可能因为提供方故障、限流或自身错误率升高而暂时不可用,也可能在熔断冷却后需要一次半开探针。健康状态是秒级变化的运行时事实,只有网关掌握。

为什么任务与租户归业务侧

任务与租户的共同点是:它们描述“想要什么”,而不是“现在有什么”。这类事实只有业务侧掌握,而且一旦缺失,网关的任何默认值都会变成猜测。

任务维度放业务侧,因为“摘要是否完整”“回复是否可以直接发给用户”“审核漏放和误杀的代价哪个更高”是产品判断。网关可以校验输出长度,但它无法判断一段三百字摘要是否覆盖了原文的五个要点。

租户维度放业务侧,因为“这个租户买了什么”是合约事实。网关可以执行配额和隔离,但不应该自己推断某个租户“看起来像付费用户”。把租户等级交给网关猜,最常见的后果是免费租户被误升到 frontier,月底账单出现一批无法解释的成本。

为什么区域、成本与健康归网关

区域、成本与健康的共同点是:它们描述“现在有什么”,并且变化频率远高于业务发布频率。放到业务侧会产生三种典型问题:缓存过期、口径分裂、恢复滞后。

区域如果由业务写死,跨境流量和合规区域会随用户移动而漂移;成本如果由业务计算,每个调用方都会维护一份价格表,任何一次调价都要改所有业务;健康如果由业务缓存,提供方恢复后业务仍然绕开可用模型,故障被“记住”得比实际更久。

这里要区分“主权”和“执行”。区域、成本、健康由网关决策,不代表业务完全不能提供输入。业务可以给区域提示、成本软上限和降级偏好,但最终选哪个模型、是否熔断、何时半开,必须由网关统一裁决,否则同一个租户在不同业务线会得到不同的可用性。

把维度写成可测试的输入

下面的模块把五个维度写成固定顺序的规格,并提供一个规划器:业务意图进去,维度归属、阻断项和网关待补事实出来。它不连接任何模型,只负责回答“这一维该由谁决策”。

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

import type { CapabilityId, QualityTier } from "../core/types.ts";

/** 路由必须同时回答的五个问题;顺序固定,便于比较与测试。 */
export type RoutingDimension = "task" | "tenant" | "region" | "cost" | "health";

/** 决策主权:业务侧声明意图,网关侧执行机制。 */
export type DecisionOwner = "business" | "gateway";

export type TenantPlan = "free" | "plus" | "enterprise";

export interface DimensionSpec {
  dimension: RoutingDimension;
  owner: DecisionOwner;
  /** 这一维回答的问题。 */
  question: string;
  /** 业务侧能提供的输入(可以为空);网关负责补全运行时事实。 */
  businessInput: string;
  /** 放到错误一侧时最常见的后果。 */
  misplacedCost: string;
}

export const ROUTING_DIMENSIONS: readonly RoutingDimension[] = [
  "task",
  "tenant",
  "region",
  "cost",
  "health",
];

export const DIMENSION_SPECS: readonly DimensionSpec[] = [
  {
    dimension: "task",
    owner: "business",
    question: "这次请求要完成什么,质量下限与输出形态是什么",
    businessInput: "capability + qualityFloor + maxOutputTokens",
    misplacedCost: "网关按价格猜任务,长文摘要被短输出上限截断",
  },
  {
    dimension: "tenant",
    owner: "business",
    question: "哪个租户、买了什么档位、能否使用高成本模型",
    businessInput: "tenantPlan + tenant policy",
    misplacedCost: "网关自行推断租户等级,免费租户被误升到 frontier",
  },
  {
    dimension: "region",
    owner: "gateway",
    question: "请求应该落在哪个区域,哪些提供方在该区域可用",
    businessInput: "userRegion(可缺省,由边缘推断)",
    misplacedCost: "业务写死区域,跨境流量与合规区域发生漂移",
  },
  {
    dimension: "cost",
    owner: "gateway",
    question: "这次请求在预算内能选哪些模型,实际花费多少",
    businessInput: "costCeilingMicros(软上限)",
    misplacedCost: "业务自己算价,价格表更新后所有预算同时失真",
  },
  {
    dimension: "health",
    owner: "gateway",
    question: "哪些模型当下可用,熔断后何时允许再进入",
    businessInput: "无(由网关探测)",
    misplacedCost: "业务缓存健康状态,故障恢复后仍长期绕开可用模型",
  },
];

/** 按维度取规格;未知维度属于配置错误,直接抛出而不是静默返回 undefined。 */
export function dimensionSpec(dimension: RoutingDimension): DimensionSpec {
  const spec = DIMENSION_SPECS.find((item) => item.dimension === dimension);
  if (!spec) {
    throw new Error(`unknown routing dimension: ${dimension}`);
  }
  return spec;
}

export interface RoutingIntent {
  capability: CapabilityId;
  qualityFloor: QualityTier | null;
  tenantPlan: TenantPlan | null;
  /** 用户区域;业务侧可以缺省,由网关从边缘推断。 */
  userRegion: string | null;
  /** 单次请求的成本软上限;小于等于 0 表示未声明。 */
  costCeilingMicros: number;
}

export interface DimensionPlan {
  dimensions: DimensionSpec[];
  businessOwned: DimensionSpec[];
  gatewayOwned: DimensionSpec[];
  /** 业务侧声明缺失;网关不得用默认值掩盖。 */
  blockers: string[];
  /** 网关需要在请求时补齐的运行时事实。 */
  gatewayFacts: string[];
}

/**
 * 把业务意图拆成五个维度,并标出谁负责决策。
 * 业务侧缺失的输入进入 blockers,而不是被网关的默认值吸收。
 */
export function planDimensions(intent: RoutingIntent): DimensionPlan {
  const blockers: string[] = [];
  if (intent.capability.trim() === "") {
    blockers.push("task:capability-missing");
  }
  if (intent.qualityFloor === null) {
    blockers.push("task:quality-floor-missing");
  }
  if (intent.tenantPlan === null) {
    blockers.push("tenant:plan-missing");
  }
  if (!Number.isFinite(intent.costCeilingMicros) || intent.costCeilingMicros <= 0) {
    blockers.push("cost:ceiling-missing");
  }

  const specs = ROUTING_DIMENSIONS.map(dimensionSpec);
  const gatewayFacts = [
    "cost:live-price-sheet",
    "health:probe-window",
    "region:provider-region-table",
  ];
  if (intent.userRegion === null || intent.userRegion.trim() === "") {
    gatewayFacts.push("region:infer-from-edge");
  }

  return {
    dimensions: specs,
    businessOwned: specs.filter((spec) => spec.owner === "business"),
    gatewayOwned: specs.filter((spec) => spec.owner === "gateway"),
    blockers,
    gatewayFacts: gatewayFacts.sort(),
  };
}

export interface Placement {
  dimension: RoutingDimension;
  owner: DecisionOwner;
}

/**
 * 审计维度主权划分。返回空数组表示五个维度都恰好由正确的一侧决策。
 * 重复与缺失也会被记录,避免“只审了关心的两个维度”。
 */
export function auditPlacements(placements: readonly Placement[]): string[] {
  const issues: string[] = [];
  const seen = new Set<RoutingDimension>();
  for (const placement of placements) {
    if (seen.has(placement.dimension)) {
      issues.push(`${placement.dimension}:duplicate`);
      continue;
    }
    seen.add(placement.dimension);
    const spec = dimensionSpec(placement.dimension);
    if (placement.owner !== spec.owner) {
      issues.push(
        `${placement.dimension}:owned-by-${placement.owner}-expected-${spec.owner}`,
      );
    }
  }
  for (const dimension of ROUTING_DIMENSIONS) {
    if (!seen.has(dimension)) {
      issues.push(`${dimension}:missing`);
    }
  }
  return issues;
}

这段代码有三个刻意的设计。

第一,维度顺序固定为任务、租户、区域、成本、健康。顺序固定不是为了好看,而是为了让测试可以直接比对,也让日志里的维度列表不会因为对象遍历顺序而变化。

第二,业务侧缺失的输入进入 blockers,不会被网关的默认值吸收。比如 qualityFloor 为 null 时,规划器不会替业务选一个 standard,而是明确返回 task:quality-floor-missing。默认值看起来让系统更“健壮”,实际上是把一个产品决策偷偷变成了网关决策。

第三,gatewayFacts 只列网关需要补齐的运行时事实,不列具体模型。区域表、价格表、探测窗口都会变,规划器只承诺“网关会补”,不承诺“补成什么”。这让维度规划与具体提供方解耦。

auditPlacements 是配套的审计函数。它接收一组“维度 → 决策方”的声明,检查是否有维度放错侧、重复或遗漏。评审时把架构图里的归属填进去跑一遍,比在会上争论“健康状态到底谁维护”更快。

测试与真实输出

测试覆盖五个维度的顺序、主权划分、阻断项、区域缺省、审计与确定性。在实验工程根目录执行:

node --test test/routing/dimensions.test.ts

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

✔ 五个维度顺序固定且规格齐全 (0.74725ms)
✔ 任务与租户归业务侧,区域、成本、健康归网关 (0.147125ms)
✔ 完整意图没有阻断项,网关只补运行时事实 (0.067958ms)
✔ 业务侧声明缺失时逐项进入 blockers (0.066208ms)
✔ 区域可以缺省,由网关从边缘推断 (0.558042ms)
✔ 主权审计能发现放错侧、重复与遗漏 (0.263625ms)
✔ 同一份意图重复规划得到相同结果 (0.114584ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 146.467084

七个测试里,最重要的是“业务侧声明缺失时逐项进入 blockers”。它保证了规划器不会在缺少质量下限时悄悄给出一个默认档位。只要这条测试还在,任何“先跑起来再说”的默认值都进不了主路径。

复盘:便宜模型把长文摘要截断

回到开头的事故。事后复盘列出三个叠加因素:

  • 业务侧只传了能力标识与正文,没有传质量下限和期望输出长度。
  • 网关的成本维度看到 3000 微元的软上限,在 economy 档位里选了一个单价最低的模型。
  • 输出校验只检查“是否为空”,没有检查 finishReason 是否为 length。

第一项是任务维度缺输入,第二项是成本维度正常工作,第三项是结果校验缺失。三者叠加,系统就会“成功地”返回一段被截断的摘要:HTTP 200,日志无错误,用户看到半句话。

修复不是把 economy 模型拉黑,而是补齐三个约束:业务侧声明 qualityFloor: "standard" 与 maxOutputTokens: 800;网关在筛选阶段检查“估算输入 + 请求输出 ≤ 上下文窗口”和“请求输出 ≤ 模型输出上限”;调用结束后检查 finishReason,为 length 时标记降级而不是当成成功。

修复后同一个请求会在候选筛选阶段被 economy 模型拒绝,原因写为 output-limit-too-small。用户可能仍然拿到较慢或较贵的结果,但不会再拿到一段假装完整的半句话。

复盘项事故做法修复做法
任务输入只传能力标识传质量下限与输出上限
成本决策业务给上限,网关按最低价选网关在预算内先过滤不满足输出的模型
结果校验只检查非空检查 finishReason 与长度
责任归属认为“网关选错了”业务补声明,网关补校验

这张表说明,路由事故很少是单一维度的问题。更常见的形态是:业务侧少声明一个约束,网关侧少做一次校验,两个“看起来都不算错”的决定叠在一起,最终变成一个用户可见的缺陷。

一张表看清维度主权

维度回答的问题决策方业务输入放错侧的后果
任务要完成什么业务能力、质量下限、输出上限按价格猜任务,长文被截断
租户谁在调用业务租户档位与策略免费租户被误升档
区域落在哪里网关区域提示(可缺省)写死区域,合规漂移
成本能花多少网关成本软上限各自算价,口径分裂
健康现在谁能用网关无缓存故障,恢复滞后

小结

路由的五个维度是任务、租户、区域、成本与健康。任务与租户描述“想要什么”,归业务侧声明;区域、成本与健康描述“现在有什么”,归网关实时决策。主权划分清楚,网关才不会用默认值替业务做产品判断,业务也不会用缓存替网关做运行时判断。

维度不是文档里的分类,而是请求里的输入。把缺失输入写成 blockers,把归属写成可审计的声明,路由才有机会在事故发生前拦住“所有参数都合法、结果却不可用”的请求。

下一节会把五个维度变成真正的候选筛选与排序:优先级、加权、健康回退,以及熔断冷却后如何重新进入。

阅读导航:上一节:3.3 ai-lab:实现零依赖模型网关 · 下一节:4.2 优先级、加权与健康回退 。

常见错误与边界

  • 把“路由”理解成按价格排序。价格只是成本维度的输入,任务质量下限与输出形态同样会一票否决。
  • 让网关替业务补默认质量档位。默认值会把产品决策变成基础设施决策,而且很难在事故后追溯。
  • 把租户等级写进请求头由网关猜。租户档位来自合约,应该由业务侧解析后传入。
  • 业务自己维护模型价格表。价格更新频繁,多处维护必然出现口径分裂。
  • 业务缓存模型健康状态。缓存会让恢复滞后,故障被记住得比实际更久。
  • 只按区域选模型,不校验模型是否真的声明了该区域。区域表是网关事实,不是模型名的隐含属性。
  • 用“请求成功”代替“结果可用”。HTTP 200 与 finishReason: "length" 可以同时成立。
  • 维度归属一次定死。组织边界和提供方能力都会变,归属需要随版本审计。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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