部件都在,顺序没人负责
到第 12 章为止,ai-lab 里已经有路由、缓存、降级、观测、计量等模块。每个模块的测试都是绿的,但把它们放进同一次请求时,团队仍然吵了三天。
争点不是某个模块怎么写,而是顺序:
- 缓存放在路由前还是路由后?放前面,缓存键里没有具体模型;放后面,又要先付一次路由成本。
- 降级放在校验前还是校验后?放前面,非法输出会被当成模型不可用;放后面,校验失败又要重新走一遍降级判断。
- 配额在认证时扣,还是调用成功后扣?前者会多扣,后者会漏扣。
- 调用超时后,已经产生的 token 成本记在哪个阶段?
这些问题没有一个是单模块能回答的。它们只在组合时出现,也只能在组合时解决。
本节把六个阶段串成一条基线:认证与配额 → 缓存 → 路由 → 调用 → 校验 → 降级。顺序固定,每一段都记录自己的状态、耗时、成本与责任人。
为什么是这个顺序
顺序不是偏好,而是由依赖关系决定的。
| 阶段 | 为什么在这个位置 | 提前或推后会怎样 |
|---|---|---|
| 认证与配额 | 结构不合法、身份不明、额度不足的请求不应该消耗任何模型预算 | 放到调用后,坏请求也会产生成本 |
| 缓存 | 命中可以直接返回,省掉路由与调用 | 放到路由后,缓存键被迫包含模型,换模型就全量失效 |
| 路由 | 只有通过认证与缓存未命中的请求才需要选模型 | 放到缓存前,路由成本无法被命中省掉 |
| 调用 | 真正消耗提供方资源的一段 | 放到校验后,等于没有校验就调用 |
| 校验 | 检查结构、空输出与内容拦截 | 放到降级后,非法输出会被当成正常结果 |
| 降级 | 只处理前面阶段明确交出来的失败 | 放到前面,所有失败都会被同一个兜底掩盖 |
缓存放在路由之前,会带来一个必须写清楚的设计选择:缓存键里不能有具体模型,只能用 modelId: "auto" 这样的逻辑占位。代价是换模型后旧缓存仍然可能被命中;收益是路由策略调整不会让缓存整体失效。两种选择都合理,但不能假装没有取舍。这里选择前者,并用提示版本与能力标识控制失效范围。
预算逐段扣减
基线里的预算有两个维度:时间与成本。时间预算从 deadlineMs 开始,成本预算从租户额度开始,每经过一段就扣一次。
前五段用声明值扣,调用段用所选模型的 P95 估计耗时,真实网关应替换成实测值。以「回声」的 mock 模型为例:
| 阶段 | 扣减耗时 | 扣减成本 | 说明 |
|---|---|---|---|
| auth | 1ms | 0 | 认证与配额检查 |
| cache | 1ms | 0 | 命中时也付出一次查找成本 |
| route | 1ms | 0 | 选模型不调用提供方 |
| call | 模型 P95 | 实际 usage | 成本以返回值为准,不用估算值 |
| validate | 0ms | 0 | 纯本地校验 |
| degrade | 2ms | 0 | 模板兜底 |
一次 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,结果无法复现。
- 把缓存异常当成请求失败。缓存是优化层,挂了应该变慢,而不是让能力不可用。
- 把所有失败都归到降级阶段。责任层丢失,复盘时只能看到「降级发生了」。
- 内容拦截算成提供方不可用。一次正常拦截会触发模型切换,错误率也会虚高。
- 降级失败后返回空字符串。用户看到的是空白,系统记的是成功,两边都在说谎。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。