设计一个大模型网关

深入系统设计场景题约 11 分钟读完

一句话回答

大模型网关是公司内所有应用调用模型的统一入口。对外提供统一的接口格式(常见做法是兼容 OpenAI 的接口,业务方只改 baseURL 和 Key);内部负责多供应商、多模型的路由和故障切换,用虚拟 Key 做鉴权和多租户隔离,按 RPM、TPM、并发和预算做限流和配额,按 token 计费并统计用量,提供可选的缓存,流式透传并转换各家的事件格式,同时记录请求日志和审计并做脱敏。设计要点是网关本身无状态、可水平扩展,记账和日志异步化,不拖慢请求。

详细解析

第一步:澄清需求

  • 调用方:只给公司内部的业务用,还是也对外部客户开放?对外开放需要更严格的隔离和计费
  • 上游:接哪些供应商、云厂商托管的模型、自部署的模型(如用 vLLM 部署的)
  • 规模:峰值 QPS、同时进行的流式请求数;网关自身增加的延迟要尽量小
  • 合规:哪些数据不能出境、哪些业务只能用私有化模型、日志里能不能保存对话内容
  • 可用性:网关是所有 AI 功能的必经之路,可用性要求比任何一个业务都高

第二步:整体架构

文本
业务应用 A / B / C(用 OpenAI 兼容的 SDK,只换 baseURL 和 Key)
        ▼
负载均衡
        ▼
网关集群(无状态,多副本、跨可用区)
  1 鉴权:虚拟 Key → 租户、项目、可用的模型
  2 限流和配额:RPM、TPM、并发数、预算(Redis)
  3 请求处理:参数校验、敏感信息脱敏、查询缓存
  4 路由:模型别名 → 具体部署(按权重、优先级、健康状态、租户限制)
  5 协议适配:统一格式 ↔ 各家格式,包括流式事件
  6 调用上游:分阶段超时、重试、故障切换、熔断
  7 记账:用量和费用写入消息队列
        ▼
供应商 A │ 供应商 B │ 云厂商托管的模型 │ 自部署的模型

旁路:配置中心(模型、路由、价格)、日志与审计存储、计费汇总、监控告警

第三步:核心模块和数据模型

统一接口和协议适配:很多 SDK 和工具都支持自定义 baseURL,对外兼容 OpenAI 的格式,业务方几乎不用改代码。内部为每家供应商写一个适配器,处理系统提示的位置、工具调用的格式、流式事件、用量字段和错误码的差异。某个模型不支持的参数要明确报错,不要悄悄忽略。

虚拟 Key:网关给每个应用签发自己的 Key,供应商的真实 Key 只保存在网关的密钥管理服务里。某个业务的 Key 泄露了,作废它即可,不影响其他应用,也不用轮换供应商的 Key。数据库里只存 Key 的哈希。

路由和故障切换:业务方请求的是模型别名(如 chat-default),网关把它映射到具体的部署,按权重分流、按优先级兜底。每个部署有健康状态:错误率超过阈值就熔断一段时间,之后放少量请求试探是否恢复。只有可重试的错误(429、5xx、超时、连接失败)才切换,而且只能在还没向客户端输出任何内容之前切换(见代码示例)。

核心数据表(另有部署表保存供应商、上游模型名、地区和密钥引用,价格表按生效时间保存历史版本):

SQL
CREATE TABLE api_keys (
  id         BIGINT PRIMARY KEY,
  tenant_id  BIGINT NOT NULL,
  key_hash   CHAR(64) NOT NULL UNIQUE,  -- 只存 SHA-256 哈希
  key_prefix VARCHAR(16) NOT NULL,      -- 展示和排查用,如 sk-gw-ab12
  models     JSON,                      -- 允许使用的模型别名
  rpm_limit INT, tpm_limit INT, daily_budget DECIMAL(12, 4),
  status     VARCHAR(16) NOT NULL       -- active / revoked
);

CREATE TABLE model_routes (             -- 模型别名 → 具体部署
  alias VARCHAR(64) NOT NULL, deployment_id BIGINT NOT NULL,
  weight   INT NOT NULL DEFAULT 100,
  priority INT NOT NULL DEFAULT 0       -- 数字小的优先,失败后尝试下一级
);

CREATE TABLE usage_logs (               -- 按天分区;费用按请求发生时生效的价格计算
  request_id CHAR(36) NOT NULL,
  tenant_id BIGINT, key_id BIGINT, alias VARCHAR(64), deployment_id BIGINT,
  input_tokens INT, output_tokens INT, cached_tokens INT, cost DECIMAL(12, 6),
  ttft_ms INT, latency_ms INT, status VARCHAR(16),
  created_at DATETIME NOT NULL,         -- 请求开始的时间,重发的用量事件里也是同一个值
  PRIMARY KEY (request_id, created_at)  -- 幂等写入;MySQL 分区表的主键必须包含分区列
);

第四步:关键难点

按 token 限流:TPM 限流要在调用前判断,但这时还不知道会用多少 token。做法是调用前按"输入 token 的估算值 + max_tokens"预占额度,结束后按实际用量退还多占的部分。网关还要按供应商的额度做全局限流,避免某个业务把公司共享的上游额度用光。实现可以用 Redis + Lua 保证原子性,见用 Redis 实现限流。

计费:每个请求记录输入、输出、缓存命中的 token 数,按当时的价格算出费用,写入消息队列,再汇总成按小时、按租户、按模型的报表,用于预算告警和成本分摊。流式请求的用量从最后的事件里取;客户端中途断开时上游可能不返回用量,用分词器估算。定期和供应商的账单对账。

缓存:

  • 精确缓存:对规范化后的请求(模型、消息、参数)算哈希,只对业务显式声明可以复用结果的请求生效(比如分类、抽取这类同样输入应该得到同样结果的任务),key 里带上租户
  • 提示缓存:按前缀匹配,网关不要在消息开头插入变化的内容;有多个部署时,相同前缀的请求尽量路由到同一个部署,提高命中率,见 Prompt Caching
  • 语义缓存风险较大,默认不开,见语义缓存

流式透传:关闭网关自身的响应缓冲,边收、边转换格式、边转发;客户端断开时立即取消上游请求。超时分阶段设置:连接超时、首 token 超时、两个片段之间的空闲超时,而不是一个总超时,因为长回答本来就要生成很久。

日志、审计和脱敏:每个请求都记录元数据(租户、Key、模型、token、延迟、状态);对话内容默认不记录,或按租户配置抽样记录,记录前脱敏、加密存储并设置保留期限,查看日志的操作本身也要审计。Key 的创建、配额和路由的修改都写入审计日志。

第五步:扩展与优化

  • 高可用:配置在本地内存缓存,变更时推送;记账和日志全部异步,数据库慢了不影响请求。Redis 不可用时降级为单机限流,宁可短时间内限得不准,也不能让所有 AI 功能停摆
  • 开源方案:例如 LiteLLM、One API 这类项目已经实现了统一接口、路由和用量统计,可以先用它们验证需求,再决定是否自研
  • 治理:按租户配置可用的模型和数据出境策略,敏感业务强制路由到私有化模型

代码示例

按优先级尝试各个部署,只有还没向客户端输出内容时才能切换:

TypeScript
async function routeChat(alias: string, req: ChatRequest, out: StreamWriter) {
  let lastError: unknown
  for (const dep of pickDeployments(alias)) { // 已按优先级、权重、健康状态排好序
    if (breaker.isOpen(dep.id)) continue // 熔断中的部署直接跳过
    let started = false
    try {
      // 适配器把统一格式转成各家的请求,再把各家的流式事件转回统一格式
      for await (const chunk of adapters[dep.provider].stream(dep, req)) {
        started = true
        out.write(chunk)
      }
      breaker.recordSuccess(dep.id)
      return
    } catch (err) {
      lastError = err
      // 参数错误这类请求换哪个部署都会失败,也不算部署的故障,不计入熔断
      if (!isRetryable(err)) throw err
      breaker.recordFailure(dep.id)
      // 已经输出过内容,不能换一个模型接着说
      if (started) throw err
    }
  }
  throw lastError ?? new Error(`没有可用的部署:${alias}`)
}

面试官可能追问

流式请求中途上游断了,网关能自动切到备用模型吗?

一般不能。客户端已经收到了前半段内容,换一个模型接着生成,内容和风格都可能对不上,还会重复计费。网关应该发送一个错误事件,说明在哪里中断了,由业务决定是重试整个请求,还是保留已有的内容。所以故障切换只发生在第一个片段输出之前。

网关成了所有 AI 调用的必经之路,怎么保证它不拖垮业务?

让网关尽量"薄":无状态、多副本、跨可用区;请求路径上不做同步的数据库写入,记账和日志走消息队列;依赖的 Redis 出问题时有降级方案。上线前压测,确认网关增加的延迟和资源占用在可接受的范围内,并为网关单独设置告警。

多个租户共用网关,怎么避免互相影响?

每个租户有独立的 Key、配额和并发上限,一个租户的突发流量只会耗尽自己的额度;上游额度紧张时按租户公平排队,而不是先到先得;缓存的 key 和日志的存储都按租户隔离;对隔离要求高的租户,可以给它单独的供应商 Key 或独立的部署。

业务方想知道这次请求实际是哪个模型处理的,怎么办?

在响应里返回实际服务的模型和部署(比如放在响应头,或者响应体的 model 字段里),日志里也记录下来。故障切换会让同一个别名背后的模型发生变化,评测、排查问题和成本分析都需要这个信息。备用模型上线前,要用评测集确认它在主要业务上的效果可以接受。

易错点

  • 失败了就无条件重试或切换:参数错误重试没有意义,已经开始输出的流式请求不能切换
  • 只按请求次数限流,不管 token 用量和上游的共享额度
  • 记账、写日志放在请求的同步路径上,让网关变成瓶颈
  • 默认完整记录所有对话内容,网关反而成了最大的泄露点

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。