《互联网产品的 AI 架构·模型接入与能力边界》3.1 模型网关的职责边界

「回声」同时服务主站与两个合作方后,密钥、配额、重试与审计散落在业务代码里。本节给出模型网关的职责判据,区分必须收进来的公共策略与必须留在业务层的语义,并用可运行的阶段组合器把认证、配额、执行与收尾串成一条可观测链路,同时说明哪些逻辑一旦放进来就会形成新的耦合源。

网关不是反向代理

「回声」最早没有模型网关。内容流的摘要、私信的回复建议、站内下单的客服问答,三个业务各自读环境变量里的提供方密钥,各自拼请求,各自处理超时。上线两个月后,问题集中暴露:主站的密钥被复制到四个仓库;合作方 A 的流量高峰把提供方配额吃光,主站跟着报错;一次提供方 500 之后,三个业务用三种不同的重试策略把故障放大了三倍。

反向代理只负责转发,模型网关要负责策略。两者的区别可以用一句话概括:反向代理让请求到达上游,网关决定请求该不该到达、以什么身份到达、失败之后怎么办、以及这次调用留下了什么证据。

本节讨论网关的职责边界,不讨论具体路由算法。路由的五个维度留到第四章。这里的重点是:哪些逻辑必须收进网关,哪些逻辑放进来就会变成新的耦合源,以及用什么判据在评审会上快速做出决定。

放进网关的六件事

先给结论,再逐条解释。下面六类逻辑的共同点是:所有调用方都需要、可以用统一策略表达、并且失败语义能够归一。

职责解决的问题不放进网关的后果
认证与密钥托管调用方是谁、能代表哪个租户提供方密钥扩散到每个业务仓库
租户配额与限流谁可以用多少、超了怎么办一个租户的峰值拖垮全部租户
超时、重试与降级一次调用最多等多久、失败后做什么每个业务重复实现且策略互相冲突
幂等缓存同一个请求重复到达时是否复用结果用户重试造成重复计费与重复副作用
观测、计量与审计这次调用花了多少、谁在什么时候调用了什么成本无法归因,事故无法复盘
契约归一业务方只面对一套请求、响应与错误每接一个提供方,所有业务都要改代码

认证与密钥托管

网关要区分两把钥匙。调用方持有的是网关签发的 API key,网关持有的是提供方密钥。业务方永远拿不到提供方密钥,提供方也永远看不到业务方的 key。这个隔离不是洁癖,而是为了两件事:轮换提供方密钥时不需要通知任何业务方;某个租户的 key 泄漏时可以单独吊销,不影响其他租户。

「回声」有三个租户:主站、合作方 A、合作方 B。三者的 key 不同,映射到不同的配额与预算。网关在认证阶段完成 key 到租户的映射,后续所有阶段只认租户,不再解析 key。不要在业务请求体里相信 tenantId:它必须和 key 解析出的租户一致,否则就是越权。

密钥比较要用常量时间实现,日志里只记录 key 的指纹后缀。真实的网关还应该支持一租户多 key、过期时间与灰度吊销,这些都属于同一类职责,不应该下沉到业务。

租户配额与限流

限流和配额经常被混为一谈,但它们的失败语义不同。限流保护上游,回答“现在能不能发”;配额执行商业合同,回答“这个账期内还能用多少”。前者用令牌桶或滑动窗口,后者按周期累计,通常还要同时限制请求数与 token 数。

「回声」的一组案例假设值如下,用来演示隔离方式,不是线上真实配置:

租户每分钟请求每月 token 预算超出后的行为
主站6008 亿降级到小模型并告警
合作方 A1201 亿返回 quota 错误,不自动降级
合作方 B603000 万返回 quota 错误,等待下个周期

配额判断必须放在认证之后、模型调用之前。结构错误的请求不应该消耗配额,也不应该消耗模型预算。配额错误要带 retry-after,让调用方知道什么时候可以再来,而不是盲目重试。

超时、重试与降级

网关收到的是端到端预算 deadlineMs,不是“提供方超时”。这两个数字不一样:预算要覆盖排队、路由、上游调用和序列化。网关在进入每个阶段前检查剩余预算,剩余不足时直接失败,而不是把一个注定超时的请求发给上游。

重试放在网关,理由是调用方看不到提供方的瞬时故障,也不应该为每次提供方抖动写一遍退避。但重试有三个前提:错误可重试、请求幂等、预算还够。流式请求一旦开始输出就不能重试,因为客户端已经收到了部分内容;此时只能发错误事件并结束。

降级也属于网关,但降级的目标必须由业务声明。网关可以执行“主模型不可用时切到备用模型”,不能自己决定“没有模型时返回一句什么业务话术”。后者是产品语义,应该由业务提供模板,网关只负责在预算内调用模板并标记 degraded。

幂等缓存

缓存只对确定性请求有意义。同一个 idempotencyKey、同样的消息、同样的参数,在窗口内可以复用结果;带有用户实时状态、随机种子或时间敏感内容的请求不应该缓存。缓存命中时,网关用 stop 短路后续阶段,但仍然执行收尾钩子,保证计量与审计不丢。

缓存键不能只用消息文本。至少要把能力、模型族、参数、租户和区域放进键里,否则一次灰度切换会读到旧模型的结果。缓存值要带创建时间与模型版本,方便在模型升级后主动失效。

观测、计量与审计

观测回答“系统现在怎么样”,计量回答“这次调用花了多少”,审计回答“谁在什么时候做了什么”。三者共享同一份阶段耗时,但保留期限与访问权限不同。

计量必须在失败时也记录。一次超时的请求可能已经消耗了输入 token,忽略失败请求的成本会让账单和实际用量对不上。审计记录不应该默认保存完整提示词:其中可能包含私信内容与个人信息。默认只存哈希、长度、租户与请求标识,需要排查时再走受控的取样流程。

契约归一

最后一件事是把提供方的差异收敛成一套契约。业务方只描述能力、预算和租户,不指定模型;错误只暴露稳定的类别,不透出提供方错误码。这一条是 3.2 的主题,这里只需要记住边界:契约归一属于网关,业务语义不属于。

不放进网关的四件事

业务语义与提示词

“私信回复要温柔一点”“下单失败时要先道歉再给方案”,这些是产品语义,不是网关策略。网关可以限制提示词长度、拦截明显违规内容、强制附加系统约束,但不应该知道每个业务的话术。把提示词模板放进网关,第一次改文案就要发网关版本,耦合立刻形成。

会话状态

网关应该是无状态的。多轮对话的历史、用户画像、临时草稿属于产品数据,应该保存在业务自己的存储里。网关只接受本次请求需要的消息数组,不负责拼接历史,也不负责决定哪条历史应该被截断。唯一允许的状态是幂等键与配额窗口,它们都有明确的过期时间。

定价与结算

网关负责计量,不负责定价。costMicros 是按模型档案算出的成本,不是对客户的报价。报价涉及合同折扣、阶梯价、促销与结算周期,变化频率远高于网关。把价格写进网关,会把一个商业问题变成一次发布。

用户权限

租户不等于终端用户。网关认证的是调用方服务,回答“这个服务能不能代表这个租户调用模型”;终端用户能不能使用 AI 功能,是业务自己的权限系统要回答的问题。把用户权限塞进网关,会让网关依赖业务的用户表,边界彻底消失。

判据:四个问题

评审一个新逻辑该不该进网关时,依次问四个问题:

  1. 所有调用方是否都需要它?只有一个业务需要,先放业务。
  2. 它是否能被统一策略表达?每个业务参数都不同,说明它还不是平台能力。
  3. 失败语义能否归一?不能归一的失败,业务方无法用一套代码处理。
  4. 它是否能在一次请求内完成、且不依赖业务状态?依赖会话或用户数据的逻辑不该进来。

四个都是“是”,放进网关;任何一个“否”,留在业务层。再加一条反向检查:放进网关后,能否在不改业务代码的情况下单独关闭或替换?如果不能,说明它仍然带着业务耦合。

用阶段组合器固化职责

网关的每个职责都是一个阶段。阶段之间只需要约定三件事:输入输出是什么、什么时候短路、失败如何归一。下面的组合器不依赖任何 HTTP 框架,也不依赖具体的认证或路由实现;它只负责顺序、短路、耗时与收尾。3.3 会用它把网关真正跑起来。

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

import { AiError, normalizeError } from "../core/errors.ts";
import { systemClock } from "../core/types.ts";
import type { Clock } from "../core/types.ts";

/**
 * 阶段只有两种结局:继续,或者到此为止。
 * 缓存命中、模板兜底这类“已经拿到结果”的路径用 stop,后续阶段不再执行,
 * 但收尾钩子仍然会跑,审计不会因为短路而丢失。
 */
export interface StageResult<C> {
  readonly done: boolean;
  readonly context: C;
  readonly reason?: string;
}

export function proceed<C>(context: C): StageResult<C> {
  return { done: false, context };
}

export function stop<C>(context: C, reason: string): StageResult<C> {
  return { done: true, context, reason };
}

export interface Stage<C> {
  readonly name: string;
  run(context: C): StageResult<C> | Promise<StageResult<C>>;
}

export type SpanStatus = "ok" | "halted" | "failed";

/** 每个阶段一条耗时记录,名称固定,便于做指标聚合。 */
export interface StageSpan {
  name: string;
  durationMs: number;
  status: SpanStatus;
  reason?: string;
}

export interface PipelineOutcome<C> {
  /** 最后一个成功阶段产出的上下文;失败时也保留,供审计使用。 */
  context: C;
  error?: AiError;
}

export type Finalizer<C> = (outcome: PipelineOutcome<C>) => void | Promise<void>;

export interface PipelineRun<C> {
  context: C;
  spans: StageSpan[];
  haltedBy?: string;
}

export interface PipelineOptions<C> {
  clock?: Clock;
  /** 返回距离截止时刻还剩多少毫秒;undefined 表示本次不做截止检查。 */
  remainingMs?: (context: C) => number | undefined;
}

export interface Pipeline<C> {
  readonly name: string;
  use(stage: Stage<C>): Pipeline<C>;
  onFinish(finalizer: Finalizer<C>): Pipeline<C>;
  stageNames(): string[];
  run(initial: C): Promise<PipelineRun<C>>;
}

/**
 * 通用阶段组合器:只负责顺序、短路、耗时与错误归一,
 * 不关心认证、配额、路由的具体实现。网关之外的调用链也能复用。
 */
export function createPipeline<C>(
  name: string,
  options: PipelineOptions<C> = {},
): Pipeline<C> {
  if (name.trim() === "") {
    throw new Error("pipeline: name 不能为空");
  }

  const clock = options.clock ?? systemClock;
  const stages: Stage<C>[] = [];
  const finalizers: Finalizer<C>[] = [];
  const names = new Set<string>();

  return {
    name,

    use(stage: Stage<C>): Pipeline<C> {
      if (stage.name.trim() === "") {
        throw new Error("pipeline: stage.name 不能为空");
      }
      if (names.has(stage.name)) {
        throw new Error(`pipeline ${name}: 阶段名重复 ${stage.name}`);
      }
      names.add(stage.name);
      stages.push(stage);
      return this;
    },

    onFinish(finalizer: Finalizer<C>): Pipeline<C> {
      finalizers.push(finalizer);
      return this;
    },

    stageNames(): string[] {
      return [...names];
    },

    async run(initial: C): Promise<PipelineRun<C>> {
      let context = initial;
      const spans: StageSpan[] = [];
      let haltedBy: string | undefined;
      let failure: AiError | undefined;

      for (const stage of stages) {
        const remaining = options.remainingMs?.(context);
        if (remaining !== undefined && remaining <= 0) {
          failure = new AiError(
            "timeout",
            `pipeline ${name}: 进入阶段 ${stage.name} 前已超出截止时间`,
          );
          break;
        }

        const startedAt = clock.now();
        try {
          const result = await stage.run(context);
          context = result.context;
          const span: StageSpan = {
            name: stage.name,
            durationMs: Math.max(0, clock.now() - startedAt),
            status: result.done ? "halted" : "ok",
          };
          if (result.reason !== undefined) {
            span.reason = result.reason;
          }
          spans.push(span);
          if (result.done) {
            haltedBy = stage.name;
            break;
          }
        } catch (error) {
          spans.push({
            name: stage.name,
            durationMs: Math.max(0, clock.now() - startedAt),
            status: "failed",
          });
          failure = normalizeError(error);
          break;
        }
      }

      const finalizerErrors: AiError[] = [];
      for (const finalizer of [...finalizers].reverse()) {
        try {
          await finalizer({ context, ...(failure ? { error: failure } : {}) });
        } catch (error) {
          finalizerErrors.push(normalizeError(error));
        }
      }

      // 主流程的错误优先返回;收尾钩子的问题不能把真正的失败原因盖掉。
      if (failure) {
        throw failure;
      }
      if (finalizerErrors.length > 0) {
        throw finalizerErrors[0];
      }

      return haltedBy === undefined
        ? { context, spans }
        : { context, spans, haltedBy };
    },
  };
}

实现里有四个刻意的选择。

第一,阶段名必须唯一。指标、日志和告警都按阶段名聚合,重名会让两条完全不同的逻辑混进同一个时间序列。重复注册在构造期直接抛错,而不是等到线上才发现。

第二,短路用返回值表达,不用异常。缓存命中、模板兜底都是正常路径,用异常表达会让日志里充满“错误”,也会让收尾逻辑难以区分“失败”和“提前完成”。

第三,收尾钩子无论成功、短路还是失败都会执行。审计与计量最怕的不是慢,而是漏记。收尾钩子按注册顺序的逆序执行,符合资源释放的直觉。

第四,时钟是注入的。阶段耗时、截止时间检查都走同一个 Clock,测试可以手动推进时间,不需要真实等待,也不需要为了让测试变快而删掉超时逻辑。

阶段顺序与短路

「回声」的网关顺序是:认证 → 校验 → 配额 → 路由 → 执行 → 收尾。认证放在最前,因为不知道租户就无法判断配额;校验放在配额之前,因为结构错误的请求不应该消耗配额;路由与执行放在最后,因为它们依赖前面阶段产出的租户与预算。

阶段失败类别是否可重试是否执行收尾
认证auth否是
校验invalid-request否是
配额quota否是
路由unavailable是是
执行timeout / provider视情况是
缓存命中无(短路)不适用是

短路只能发生在“已经拿到合法结果”的阶段。校验失败不是短路,是失败;配额拒绝也不是短路,是失败。把失败当成短路会让调用方收到一个成功的空响应,比报错更难排查。

测试与真实输出

组合器的测试覆盖顺序、重名、短路、异常归一、截止时间、注入时钟、收尾上下文与收尾异常。在实验工程根目录执行:

node --test test/gateway/pipeline.test.ts

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

✔ 阶段按注册顺序执行并传递上下文 (1.15125ms)
✔ 同名阶段在注册时被拒绝 (0.267167ms)
✔ stop 跳过后继阶段,但收尾钩子仍然执行 (0.154458ms)
✔ 阶段异常被归一为 AiError,并记录失败阶段 (0.230167ms)
✔ 截止时间耗尽时,下一阶段不再执行并抛 timeout (0.144917ms)
✔ 注入时钟记录每个阶段的耗时 (0.132833ms)
✔ 失败时收尾钩子拿到最后一个成功上下文与原始错误 (0.150125ms)
✔ 收尾钩子抛错不能掩盖主流程错误 (0.134083ms)
✔ 收尾钩子自身的错误在没有主错误时会抛出 (0.153083ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 161.745584

其中两个用例值得单独说明。“截止时间耗尽”用假时钟把第一个阶段推进 500 毫秒,预算只有 100 毫秒,第二个阶段因此没有执行,错误类别是 timeout。“收尾钩子抛错不能掩盖主流程错误”则验证了优先级:主流程已经失败时,审计写入失败只应该被记录,不能把真正的失败原因替换成审计错误。

回声的三个租户

把上面的职责落到「回声」,边界就清楚了。主站的调用量最大,允许在配额接近上限时降级到小模型;合作方 A 与 B 的 key 独立、配额独立、预算独立,任何一方超限都不会影响另外两方。提供方密钥只存在于网关的密钥管理里,业务仓库里只有网关签发的 key。

这张表也解释了为什么配额不能放在业务层:主站与两个合作方共享同一个提供方账号,只有网关能看到全局用量。业务层各自计数,必然出现“每个人都以为自己没超”的集体超限。

小结

模型网关的职责不是“把所有 AI 调用收在一起”,而是把跨业务的公共策略收在一起:认证、配额、超时重试、幂等缓存、观测审计、契约归一。业务语义、会话状态、定价与用户权限不应该进来,它们的变化频率和失败语义都与网关不同。

判断一个逻辑是否进网关,用四个问题:是否所有调用方都需要、能否统一表达、失败能否归一、是否不依赖业务状态。四个都是“是”才进来,否则留在业务层。

下一节讨论网关对外的契约:请求如何只描述能力与预算、响应如何携带用量与降级、错误为什么要收敛成固定类别,以及提供方错误码为什么不能直接透给业务方。

阅读导航:上一节:2.3 多模型并存的现实 · 下一节:3.2 统一契约与流式协议 。

常见错误与边界

  • 把网关做成透明反向代理。只转发不执行策略,密钥、配额与重试仍然散落在业务里。
  • 在请求体里相信 tenantId。租户必须由 API key 解析得出,请求体只能用于一致性校验。
  • 把限流当配额。限流保护上游,配额执行合同,两者的错误语义与时间窗口都不同。
  • 对所有错误统一重试。鉴权失败、参数错误与内容拦截重试多少次都不会成功,只会放大故障。
  • 缓存带用户实时状态的请求。个性化结果复用会直接把 A 用户的内容返回给 B 用户。
  • 把提示词模板放进网关。第一次改文案就要发网关版本,平台能力变成了业务耦合。
  • 失败请求不计量。超时前已经消耗的输入 token 仍然产生成本,漏记会让账单对不上。
  • 审计默认保存完整提示词。私信与订单内容可能包含个人信息,默认只存哈希与长度。
  • 把收尾逻辑写在成功分支里。失败与短路路径同样需要审计,收尾必须无条件执行。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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