网关不是反向代理
「回声」最早没有模型网关。内容流的摘要、私信的回复建议、站内下单的客服问答,三个业务各自读环境变量里的提供方密钥,各自拼请求,各自处理超时。上线两个月后,问题集中暴露:主站的密钥被复制到四个仓库;合作方 A 的流量高峰把提供方配额吃光,主站跟着报错;一次提供方 500 之后,三个业务用三种不同的重试策略把故障放大了三倍。
反向代理只负责转发,模型网关要负责策略。两者的区别可以用一句话概括:反向代理让请求到达上游,网关决定请求该不该到达、以什么身份到达、失败之后怎么办、以及这次调用留下了什么证据。
本节讨论网关的职责边界,不讨论具体路由算法。路由的五个维度留到第四章。这里的重点是:哪些逻辑必须收进网关,哪些逻辑放进来就会变成新的耦合源,以及用什么判据在评审会上快速做出决定。
放进网关的六件事
先给结论,再逐条解释。下面六类逻辑的共同点是:所有调用方都需要、可以用统一策略表达、并且失败语义能够归一。
| 职责 | 解决的问题 | 不放进网关的后果 |
|---|---|---|
| 认证与密钥托管 | 调用方是谁、能代表哪个租户 | 提供方密钥扩散到每个业务仓库 |
| 租户配额与限流 | 谁可以用多少、超了怎么办 | 一个租户的峰值拖垮全部租户 |
| 超时、重试与降级 | 一次调用最多等多久、失败后做什么 | 每个业务重复实现且策略互相冲突 |
| 幂等缓存 | 同一个请求重复到达时是否复用结果 | 用户重试造成重复计费与重复副作用 |
| 观测、计量与审计 | 这次调用花了多少、谁在什么时候调用了什么 | 成本无法归因,事故无法复盘 |
| 契约归一 | 业务方只面对一套请求、响应与错误 | 每接一个提供方,所有业务都要改代码 |
认证与密钥托管
网关要区分两把钥匙。调用方持有的是网关签发的 API key,网关持有的是提供方密钥。业务方永远拿不到提供方密钥,提供方也永远看不到业务方的 key。这个隔离不是洁癖,而是为了两件事:轮换提供方密钥时不需要通知任何业务方;某个租户的 key 泄漏时可以单独吊销,不影响其他租户。
「回声」有三个租户:主站、合作方 A、合作方 B。三者的 key 不同,映射到不同的配额与预算。网关在认证阶段完成 key 到租户的映射,后续所有阶段只认租户,不再解析 key。不要在业务请求体里相信 tenantId:它必须和 key 解析出的租户一致,否则就是越权。
密钥比较要用常量时间实现,日志里只记录 key 的指纹后缀。真实的网关还应该支持一租户多 key、过期时间与灰度吊销,这些都属于同一类职责,不应该下沉到业务。
租户配额与限流
限流和配额经常被混为一谈,但它们的失败语义不同。限流保护上游,回答“现在能不能发”;配额执行商业合同,回答“这个账期内还能用多少”。前者用令牌桶或滑动窗口,后者按周期累计,通常还要同时限制请求数与 token 数。
「回声」的一组案例假设值如下,用来演示隔离方式,不是线上真实配置:
| 租户 | 每分钟请求 | 每月 token 预算 | 超出后的行为 |
|---|---|---|---|
| 主站 | 600 | 8 亿 | 降级到小模型并告警 |
| 合作方 A | 120 | 1 亿 | 返回 quota 错误,不自动降级 |
| 合作方 B | 60 | 3000 万 | 返回 quota 错误,等待下个周期 |
配额判断必须放在认证之后、模型调用之前。结构错误的请求不应该消耗配额,也不应该消耗模型预算。配额错误要带 retry-after,让调用方知道什么时候可以再来,而不是盲目重试。
超时、重试与降级
网关收到的是端到端预算 deadlineMs,不是“提供方超时”。这两个数字不一样:预算要覆盖排队、路由、上游调用和序列化。网关在进入每个阶段前检查剩余预算,剩余不足时直接失败,而不是把一个注定超时的请求发给上游。
重试放在网关,理由是调用方看不到提供方的瞬时故障,也不应该为每次提供方抖动写一遍退避。但重试有三个前提:错误可重试、请求幂等、预算还够。流式请求一旦开始输出就不能重试,因为客户端已经收到了部分内容;此时只能发错误事件并结束。
降级也属于网关,但降级的目标必须由业务声明。网关可以执行“主模型不可用时切到备用模型”,不能自己决定“没有模型时返回一句什么业务话术”。后者是产品语义,应该由业务提供模板,网关只负责在预算内调用模板并标记 degraded。
幂等缓存
缓存只对确定性请求有意义。同一个 idempotencyKey、同样的消息、同样的参数,在窗口内可以复用结果;带有用户实时状态、随机种子或时间敏感内容的请求不应该缓存。缓存命中时,网关用 stop 短路后续阶段,但仍然执行收尾钩子,保证计量与审计不丢。
缓存键不能只用消息文本。至少要把能力、模型族、参数、租户和区域放进键里,否则一次灰度切换会读到旧模型的结果。缓存值要带创建时间与模型版本,方便在模型升级后主动失效。
观测、计量与审计
观测回答“系统现在怎么样”,计量回答“这次调用花了多少”,审计回答“谁在什么时候做了什么”。三者共享同一份阶段耗时,但保留期限与访问权限不同。
计量必须在失败时也记录。一次超时的请求可能已经消耗了输入 token,忽略失败请求的成本会让账单和实际用量对不上。审计记录不应该默认保存完整提示词:其中可能包含私信内容与个人信息。默认只存哈希、长度、租户与请求标识,需要排查时再走受控的取样流程。
契约归一
最后一件事是把提供方的差异收敛成一套契约。业务方只描述能力、预算和租户,不指定模型;错误只暴露稳定的类别,不透出提供方错误码。这一条是 3.2 的主题,这里只需要记住边界:契约归一属于网关,业务语义不属于。
不放进网关的四件事
业务语义与提示词
“私信回复要温柔一点”“下单失败时要先道歉再给方案”,这些是产品语义,不是网关策略。网关可以限制提示词长度、拦截明显违规内容、强制附加系统约束,但不应该知道每个业务的话术。把提示词模板放进网关,第一次改文案就要发网关版本,耦合立刻形成。
会话状态
网关应该是无状态的。多轮对话的历史、用户画像、临时草稿属于产品数据,应该保存在业务自己的存储里。网关只接受本次请求需要的消息数组,不负责拼接历史,也不负责决定哪条历史应该被截断。唯一允许的状态是幂等键与配额窗口,它们都有明确的过期时间。
定价与结算
网关负责计量,不负责定价。costMicros 是按模型档案算出的成本,不是对客户的报价。报价涉及合同折扣、阶梯价、促销与结算周期,变化频率远高于网关。把价格写进网关,会把一个商业问题变成一次发布。
用户权限
租户不等于终端用户。网关认证的是调用方服务,回答“这个服务能不能代表这个租户调用模型”;终端用户能不能使用 AI 功能,是业务自己的权限系统要回答的问题。把用户权限塞进网关,会让网关依赖业务的用户表,边界彻底消失。
判据:四个问题
评审一个新逻辑该不该进网关时,依次问四个问题:
- 所有调用方是否都需要它?只有一个业务需要,先放业务。
- 它是否能被统一策略表达?每个业务参数都不同,说明它还不是平台能力。
- 失败语义能否归一?不能归一的失败,业务方无法用一套代码处理。
- 它是否能在一次请求内完成、且不依赖业务状态?依赖会话或用户数据的逻辑不该进来。
四个都是“是”,放进网关;任何一个“否”,留在业务层。再加一条反向检查:放进网关后,能否在不改业务代码的情况下单独关闭或替换?如果不能,说明它仍然带着业务耦合。
用阶段组合器固化职责
网关的每个职责都是一个阶段。阶段之间只需要约定三件事:输入输出是什么、什么时候短路、失败如何归一。下面的组合器不依赖任何 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 仍然产生成本,漏记会让账单对不上。
- 审计默认保存完整提示词。私信与订单内容可能包含个人信息,默认只存哈希与长度。
- 把收尾逻辑写在成功分支里。失败与短路路径同样需要审计,收尾必须无条件执行。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。