《互联网产品的 AI 架构·模型接入与能力边界》13.2 网关的组合:路由、缓存、降级

前十二章的部件都写好了,却没有一条流水线把它们按正确顺序串起来。本节把认证与配额、缓存、路由、调用、校验和降级组合成基线:说明缓存为什么在路由之前、预算如何逐段扣减、命中与降级各记录哪些字段,以及失败时由哪一层负责,并给出可运行实现与九个确定性测试。

部件都在,顺序没人负责

到第 12 章为止,ai-lab 里已经有路由、缓存、降级、观测、计量等模块。每个模块的测试都是绿的,但把它们放进同一次请求时,团队仍然吵了三天。

争点不是某个模块怎么写,而是顺序:

  • 缓存放在路由前还是路由后?放前面,缓存键里没有具体模型;放后面,又要先付一次路由成本。
  • 降级放在校验前还是校验后?放前面,非法输出会被当成模型不可用;放后面,校验失败又要重新走一遍降级判断。
  • 配额在认证时扣,还是调用成功后扣?前者会多扣,后者会漏扣。
  • 调用超时后,已经产生的 token 成本记在哪个阶段?

这些问题没有一个是单模块能回答的。它们只在组合时出现,也只能在组合时解决。

本节把六个阶段串成一条基线:认证与配额 → 缓存 → 路由 → 调用 → 校验 → 降级。顺序固定,每一段都记录自己的状态、耗时、成本与责任人。

为什么是这个顺序

顺序不是偏好,而是由依赖关系决定的。

阶段为什么在这个位置提前或推后会怎样
认证与配额结构不合法、身份不明、额度不足的请求不应该消耗任何模型预算放到调用后,坏请求也会产生成本
缓存命中可以直接返回,省掉路由与调用放到路由后,缓存键被迫包含模型,换模型就全量失效
路由只有通过认证与缓存未命中的请求才需要选模型放到缓存前,路由成本无法被命中省掉
调用真正消耗提供方资源的一段放到校验后,等于没有校验就调用
校验检查结构、空输出与内容拦截放到降级后,非法输出会被当成正常结果
降级只处理前面阶段明确交出来的失败放到前面,所有失败都会被同一个兜底掩盖

缓存放在路由之前,会带来一个必须写清楚的设计选择:缓存键里不能有具体模型,只能用 modelId: "auto" 这样的逻辑占位。代价是换模型后旧缓存仍然可能被命中;收益是路由策略调整不会让缓存整体失效。两种选择都合理,但不能假装没有取舍。这里选择前者,并用提示版本与能力标识控制失效范围。

预算逐段扣减

基线里的预算有两个维度:时间与成本。时间预算从 deadlineMs 开始,成本预算从租户额度开始,每经过一段就扣一次。

前五段用声明值扣,调用段用所选模型的 P95 估计耗时,真实网关应替换成实测值。以「回声」的 mock 模型为例:

阶段扣减耗时扣减成本说明
auth1ms0认证与配额检查
cache1ms0命中时也付出一次查找成本
route1ms0选模型不调用提供方
call模型 P95实际 usage成本以返回值为准,不用估算值
validate0ms0纯本地校验
degrade2ms0模板兜底

一次 2000ms 预算、1000 微元额度的成功请求,走完前五段后剩 1947ms;调用返回 7 微元后,成本剩 993 微元。这两个数字在测试里是逐项断言的,不是「大概」。

预算检查放在每段入口。剩余时间小于等于 0 时,下一段不会执行,直接交给降级;调用成本超过剩余额度时,调用已经发生,但结果不会返回给用户,而是在调用层收敛为 budget-exceeded。

缓存命中与降级记录什么

缓存命中不是「提前返回」四个字就够。基线在结果里单独记录 cache.hit、cache.kind 与 cache.similarity,并把阶段状态标成 hit。命中后只保留认证与缓存两段,后续阶段不再出现。

命中结果还要做两件事:第一,把 requestId 换成当前请求,否则用户会拿到别人的请求号;第二,把 usage 清零,因为这次没有发生模型调用。前者是正确性问题,后者是计费问题,两者都不能省。

降级记录则要回答三件事:为什么降级、谁导致的、产品动作是什么。

  • degrade.reason 使用网关统一的降级原因,不透传提供方错误码。
  • degrade.owner 记录责任层,可能是 route、call 或 validate。
  • degrade.action 是 serve、degrade、hide 或 block,由能力策略决定。

失败时哪一层负责什么

失败责任层基线行为是否继续主流程
结构非法、身份不明、额度不足auth直接 blocked,不进入缓存否
缓存读写异常cache记录 failed,继续走路由是
没有可用模型route按能力策略降级或阻断视策略
超时、限流、提供方不可用call按能力策略降级或阻断视策略
空输出、格式错误、内容拦截validate按能力策略降级或阻断视策略
降级本身失败degrade返回 blocked,不伪造结果否

内容拦截被归到 validate,而不是 call。模型调用成功了,只是输出不可用;把它算成提供方不可用,会触发不必要的模型切换,也会让错误率虚高。

实现

下面的实现把六段串成一次调用,返回统一的 BaselineResult。它复用已有的路由、缓存与降级模块,不重新实现它们;新增的只是顺序、账本与阶段记录。

// file: src/gateway/baseline.ts
// tier: L1
// verify: node --test test/gateway/baseline.test.ts

import type { CacheLookup } from "../cache/cache.ts";
import type { CacheScope } from "../cache/keys.ts";
import { AiError, normalizeError } from "../core/errors.ts";
import type { AiErrorKind } from "../core/errors.ts";
import type { ChatRequest, ChatResponse, DegradeReason, ModelProfile, QualityTier } from "../core/types.ts";
import { planFor } from "../degrade/plan.ts";
import type { Availability, CapabilityPolicy, DegradationPlan, PlanAction } from "../degrade/plan.ts";
import { validateRequest } from "../protocol/chat.ts";
import type { ChatProvider } from "../protocol/chat.ts";
import { routeModels } from "../routing/router.ts";
import type { ModelHealth, RouterPolicy } from "../routing/router.ts";

export type BaselineStage = "auth" | "cache" | "route" | "call" | "validate" | "degrade";
export type StageCost = { ms: number; micros: number };
export type StageStatus = "ok" | "hit" | "miss" | "failed" | "degraded" | "blocked";

/** 前五段用声明值扣预算,call 的耗时取模型 P95;真实网关换成实测耗时。 */
export const DEFAULT_STAGE_COSTS: Record<Exclude<BaselineStage, "call">, StageCost> = {
  auth: { ms: 1, micros: 0 }, cache: { ms: 1, micros: 0 }, route: { ms: 1, micros: 0 },
  validate: { ms: 0, micros: 0 }, degrade: { ms: 2, micros: 0 },
};

export interface BaselineStageRecord {
  stage: BaselineStage; status: StageStatus; elapsedMs: number; costMicros: number;
  remainingMs: number; remainingMicros: number;
  /** 谁对这一刻的结果负责,而不是谁执行了这段代码。 */
  owner: BaselineStage; detail?: string;
}

export interface AuthDecision {
  allowed: boolean; kind?: AiErrorKind; detail?: string;
  /** 租户当日剩余额度;缺省沿用 options.costBudgetMicros。 */
  budgetMicros?: number;
}

export type AuthGate = (request: ChatRequest) => AuthDecision | Promise<AuthDecision>;

export interface BaselineCache {
  find(scope: CacheScope, input: string): CacheLookup<ChatResponse>;
  set(scope: CacheScope, input: string, value: ChatResponse, options?: { ttlMs?: number; subjectIds?: string[] }): unknown;
}

export interface BaselineOptions {
  models: readonly ModelProfile[]; provider: ChatProvider; cache: BaselineCache;
  authorize: AuthGate; promptVersion: string; permissionScope?: string;
  policy?: CapabilityPolicy; costBudgetMicros?: number; routerPolicy?: RouterPolicy;
  qualityFloor?: QualityTier; health?: readonly ModelHealth[]; nowMs?: number;
  stageCosts?: Partial<Record<Exclude<BaselineStage, "call">, StageCost>>;
  /** 能力层校验;抛错即视为 invalid-output。 */
  validate?: (response: ChatResponse) => void;
  fallback?: (request: ChatRequest, plan: DegradationPlan, reason: DegradeReason) => ChatResponse | null;
}

export type BaselineOutcome = "ok" | "cache-hit" | "degraded" | "blocked";

export interface BaselineResult {
  requestId: string; outcome: BaselineOutcome; response: ChatResponse | null; modelId: string | null;
  stages: BaselineStageRecord[];
  cache: { hit: boolean; kind: "exact" | "semantic" | null; similarity: number };
  route: { candidates: number; rejected: number; degraded: boolean } | null;
  degrade: { reason: DegradeReason; owner: BaselineStage; action: PlanAction } | null;
  failure: { owner: BaselineStage; kind: AiErrorKind } | null;
  budget: { totalMs: number; remainingMs: number; totalMicros: number; remainingMicros: number };
}

function defaultPolicy(capability: string): CapabilityPolicy {
  return {
    capability, criticality: "degradable", fallbackAllowed: true, blocksFlow: false,
    copy: { degraded: "已切换为简化结果", unavailable: "该能力暂不可用" },
  };
}

function lastUserContent(request: ChatRequest): string {
  const user = [...request.messages].reverse().find((message) => message.role === "user");
  return user?.content ?? request.messages.map((message) => message.content).join("\n");
}

function scopeFor(request: ChatRequest, options: BaselineOptions): CacheScope {
  return {
    tenantId: request.tenantId, capability: request.capability,
    permissionScope: options.permissionScope ?? "default",
    // 缓存位于路由之前,因此键里不能写具体模型。
    modelId: "auto", promptVersion: options.promptVersion,
  };
}

function templateResponse(request: ChatRequest, plan: DegradationPlan, reason: DegradeReason): ChatResponse {
  return {
    requestId: request.requestId, modelId: "template",
    output: plan.message ?? "已切换为简化结果",
    usage: { inputTokens: 0, outputTokens: 0, costMicros: 0 },
    finishReason: "stop", degraded: reason,
  };
}

function validateResponse(response: ChatResponse, extra?: (value: ChatResponse) => void): void {
  if (response.finishReason === "content_filter") throw new AiError("content-blocked", "输出被内容策略拦截");
  if (response.output.trim() === "") throw new AiError("invalid-output", "能力层输出为空");
  extra?.(response);
}

/** 把认证、缓存、路由、调用、校验与降级串成一条可复现的基线。 */
export async function runBaseline(request: ChatRequest, options: BaselineOptions): Promise<BaselineResult> {
  const policy = options.policy ?? defaultPolicy(request.capability);
  const costs = { ...DEFAULT_STAGE_COSTS, ...options.stageCosts };
  const stages: BaselineStageRecord[] = [];
  let remainingMs = request.deadlineMs;
  let remainingMicros = options.costBudgetMicros ?? 10_000;
  let totalMicros = remainingMicros;

  const record = (
    stage: BaselineStage, status: StageStatus, cost: StageCost, owner: BaselineStage, detail?: string,
  ): void => {
    remainingMs = Math.max(0, remainingMs - cost.ms);
    remainingMicros = Math.max(0, remainingMicros - cost.micros);
    stages.push({
      stage, status, elapsedMs: cost.ms, costMicros: cost.micros,
      remainingMs, remainingMicros, owner,
      ...(detail === undefined ? {} : { detail }),
    });
  };

  const finish = (outcome: BaselineOutcome, extra: Partial<BaselineResult> = {}): BaselineResult => ({
    requestId: request.requestId, outcome, response: null, modelId: null, stages,
    cache: { hit: false, kind: null, similarity: 0 }, route: null, degrade: null, failure: null,
    budget: { totalMs: request.deadlineMs, remainingMs, totalMicros, remainingMicros },
    ...extra,
  });

  const finishDegraded = (
    owner: BaselineStage, kind: AiErrorKind, reason: DegradeReason,
    availability: Availability, modelId: string | null = null,
  ): BaselineResult => {
    const plan = planFor(policy, availability);
    const blocked = plan.action === "block" || plan.action === "hide";
    record("degrade", blocked ? "blocked" : "degraded", costs.degrade, owner, `action=${plan.action} reason=${reason}`);
    const failure = { owner, kind };
    const degrade = { reason, owner, action: plan.action };
    if (blocked) return finish("blocked", { modelId, failure, degrade });
    const response = options.fallback
      ? options.fallback(request, plan, reason)
      : templateResponse(request, plan, reason);
    if (response === null) return finish("blocked", { modelId, failure, degrade });
    return finish("degraded", { response, modelId, failure, degrade });
  };

  try {
    validateRequest(request);
  } catch (error) {
    const aiError = normalizeError(error);
    record("auth", "blocked", { ms: 0, micros: 0 }, "auth", aiError.kind);
    return finish("blocked", { failure: { owner: "auth", kind: aiError.kind } });
  }

  const decision = await options.authorize(request);
  if (!decision.allowed) {
    const kind = decision.kind ?? "auth";
    record("auth", "blocked", { ms: 0, micros: 0 }, "auth", decision.detail ?? kind);
    return finish("blocked", { failure: { owner: "auth", kind } });
  }
  if (decision.budgetMicros !== undefined) {
    remainingMicros = Math.min(remainingMicros, decision.budgetMicros);
    totalMicros = remainingMicros;
  }
  record("auth", "ok", costs.auth, "auth", decision.detail);

  if (remainingMs <= 0) return finishDegraded("cache", "timeout", "deadline-exceeded", "degraded");

  const scope = scopeFor(request, options);
  const cacheInput = lastUserContent(request);
  let lookup: CacheLookup<ChatResponse> | null = null;
  try {
    lookup = options.cache.find(scope, cacheInput);
  } catch (error) {
    record("cache", "failed", costs.cache, "cache", normalizeError(error).kind);
  }
  if (lookup !== null && lookup.kind !== "miss") {
    record("cache", "hit", costs.cache, "cache", lookup.kind);
    const response: ChatResponse = {
      ...lookup.entry.value, requestId: request.requestId,
      usage: { inputTokens: 0, outputTokens: 0, costMicros: 0 },
    };
    return finish("cache-hit", {
      response, cache: { hit: true, kind: lookup.kind, similarity: lookup.similarity },
    });
  }
  if (lookup !== null) record("cache", "miss", costs.cache, "cache", lookup.reason);

  if (remainingMs <= 0) return finishDegraded("route", "timeout", "deadline-exceeded", "degraded");

  const route = routeModels({
    request, models: options.models, nowMs: options.nowMs ?? 0,
    ...(options.health === undefined ? {} : { health: options.health }),
    ...(options.routerPolicy === undefined ? {} : { policy: options.routerPolicy }),
    ...(options.qualityFloor === undefined ? {} : { qualityFloor: options.qualityFloor }),
  });
  const routeInfo = {
    candidates: route.candidates.length, rejected: route.rejected.length, degraded: route.degraded,
  };
  if (route.primary === null) {
    record("route", "failed", costs.route, "route", `${route.rejected.length} 个模型被拒`);
    return finishDegraded("route", "unavailable", "model-unavailable", "unavailable");
  }
  record("route", "ok", costs.route, "route", route.primary.model.id);
  const model = route.primary.model;

  if (remainingMs <= 0 || model.p95LatencyMs > remainingMs) {
    record("call", "failed", { ms: 0, micros: 0 }, "call", `P95 ${model.p95LatencyMs}ms > 剩余 ${remainingMs}ms`);
    return finishDegraded("call", "timeout", "deadline-exceeded", "degraded", model.id);
  }

  let response: ChatResponse;
  try {
    response = await options.provider.complete(request, model);
  } catch (error) {
    const aiError = normalizeError(error);
    const spent = aiError.usage?.costMicros ?? 0;
    record("call", "failed", { ms: model.p95LatencyMs, micros: spent }, "call", aiError.kind);
    const availability: Availability = aiError.kind === "content-blocked" ? "degraded" : "unavailable";
    const reason: DegradeReason =
      aiError.kind === "timeout" ? "deadline-exceeded"
        : aiError.kind === "quota" ? "budget-exceeded"
          : aiError.kind === "content-blocked" ? "content-blocked" : "model-unavailable";
    return finishDegraded("call", aiError.kind, reason, availability, model.id);
  }

  const costMicros = response.usage.costMicros;
  if (costMicros > remainingMicros) {
    record("call", "failed", { ms: model.p95LatencyMs, micros: costMicros }, "call", `成本 ${costMicros} > 剩余 ${remainingMicros}`);
    return finishDegraded("call", "quota", "budget-exceeded", "degraded", model.id);
  }
  record("call", "ok", { ms: model.p95LatencyMs, micros: costMicros }, "call");

  try {
    validateResponse(response, options.validate);
  } catch (error) {
    const aiError = normalizeError(error);
    const reason: DegradeReason = aiError.kind === "content-blocked" ? "content-blocked" : "fallback-template";
    record("validate", "failed", costs.validate, "validate", aiError.kind);
    return finishDegraded("validate", aiError.kind, reason, "degraded", model.id);
  }
  record("validate", "ok", costs.validate, "validate");

  try {
    options.cache.set(scope, cacheInput, response, { subjectIds: [request.tenantId] });
  } catch {
    // 缓存写入失败不应影响已经拿到的结果。
  }
  return finish("ok", {
    response, modelId: model.id, route: routeInfo,
    cache: { hit: false, kind: null, similarity: 0 },
  });
}

record 是唯一的记账入口:它同时更新剩余时间与剩余成本,并把阶段记录追加到数组。任何一段想绕过账本直接返回,都会在结果里留下缺口,测试能立刻发现。

finish 与 finishDegraded 把所有返回路径收敛到同一个结构。成功、缓存命中、降级与阻断都带完整阶段记录,调用方不需要为不同结局写不同的解析逻辑。

测试与运行结果

九个测试覆盖成功路径、缓存命中、提供方超时、无可用模型、内容拦截、认证拒绝、阻断型能力、成本超预算与截止预算耗尽。执行:

node --test test/gateway/baseline.test.ts

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

ℹ tests 9
ℹ pass 9
ℹ fail 0

「成功路径」断言阶段顺序为 auth、cache、route、call、validate,并逐项核对剩余预算 1947ms 与 993 微元。「缓存命中」断言第二次请求只剩 auth 与 cache 两段,requestId 已替换,usage 归零。这两条测试合起来,才能证明「短路」没有把账本和身份一起短路掉。

小结

网关的组合不是把模块按任意顺序调用,而是按依赖关系固定顺序:先认证与配额,再缓存,再路由,再调用,再校验,最后降级。预算在每一段入口检查、在每一段出口扣减;缓存命中要替换请求号并清零用量;降级要记录原因、责任层与产品动作。

这条基线不追求覆盖所有生产细节,它追求的是「同样的输入,在任何机器上都得到同样的阶段记录」。有了这条可复现的基线,下一节才能用它产出指标与风险清单。

阅读导航:上一节:13.1 能力清单与契约矩阵 · 下一节:13.3 基线度量与风险清单 。

常见错误与边界

  • 把缓存放在路由之后,又在缓存键里写死模型。换一次路由策略,缓存全量失效。
  • 缓存命中直接返回原响应。请求号与用量都没有换,用户和账单都会拿到别人的数据。
  • 认证通过后才做结构校验。坏请求已经进入网关,缓存和路由都被无谓消耗。
  • 配额只在调用成功后扣。失败请求已经消耗了 token,账本却记成零。
  • 用实际耗时做预算扣减,却没有注入时钟。测试只能靠 sleep,结果无法复现。
  • 把缓存异常当成请求失败。缓存是优化层,挂了应该变慢,而不是让能力不可用。
  • 把所有失败都归到降级阶段。责任层丢失,复盘时只能看到「降级发生了」。
  • 内容拦截算成提供方不可用。一次正常拦截会触发模型切换,错误率也会虚高。
  • 降级失败后返回空字符串。用户看到的是空白,系统记的是成功,两边都在说谎。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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