Administrator
发布于 2025-03-13 / 4848 阅读
62

AI 网关设计:统一模型接入与流量治理

早上九点收到的一条额度告警

2025 年 2 月底的一个周一,我刚进公司就收到运维的消息:"模型供应商账户额度用了 78%,平时这个时候只有 15%。" 紧接着业务群炸了,客服系统开始大面积报 429 Too Many Requests

查了半小时,根因很朴素:上周五上线的智能质检功能有个循环调用的 bug,每个工单会重复调用 20 多次;而当时我们四个业务线各自持有模型 API Key,直连供应商,没有任何统一的限流。质检这个新功能一晚上把整个账户的 RPM 配额吃光,把客服系统一起拖下水。

这次事故之后,我们花了三周把所有模型调用收敛到一个统一的 AI 网关。这篇记录设计过程和踩过的坑。

为什么必须收敛

直连模式的四个问题在那次事故里全暴露了:

  • 配额无隔离:一个 Key 被四个业务共用,谁出问题全陪葬;
  • 无法限流:每家供应商的限流规则不一样(RPM、TPM、并发数),业务代码里各写各的,基本等于没写;
  • 没有缓存:质检场景有大量重复请求,同样的输入反复烧钱;
  • 账单算不清:月底拿着供应商的总账单,分不出哪个业务花了多少。

网关的整体结构

网关用 Spring Boot 3.4 + 虚拟线程写的,核心是四层:

请求 ──> 鉴权/配额层 ──> 缓存层 ──> 路由层 ──> 供应商适配层
              │              │           │              │
              └──────────────┴───────────┴──────────────┘
                              │
                       审计日志 + Metrics

对外我们只暴露一个 OpenAI 兼容的 /v1/chat/completions 接口,业务侧几乎零改造,把 base_url 指过来就行。这点很重要——降低迁移成本是网关能推下去的前提,如果要求业务重写 SDK,这个项目推不动。

多模型代理的抽象

各家模型的请求体长得差不多,但细节差异一堆:temperature 范围、是否支持 response_format、流式响应的 chunk 格式、token 计数的字段位置。我们定义了一个内部规范,每个供应商写一个 Adapter:

public interface ModelProvider {
    boolean supports(String modelId);
    ChatResponse invoke(ChatRequest req);
    Flux<ChatChunk> stream(ChatRequest req);
    Usage normalizeUsage(Object rawUsage);  // 各家 usage 字段不统一
}

路由配置放在 Nacos 里,支持热更新:

gateway:
  routes:
    - model: gpt-4o-mini
      provider: openai
      weight: 100
    - model: qwen-plus
      provider: dashscope
      weight: 100
    - model: deepseek-v3
      provider: deepseek
      weight: 100
  fallback:
    - from: gpt-4o
      to: [qwen-max, deepseek-v3]

这里有个设计取舍:我们没有在网关层做请求体转换(比如把 OpenAI 格式转成 Anthropic 格式)。原因是转换逻辑复杂且容易出错,不如直接用 Spring AI 的 ChatModel 抽象,每个 provider 一个实现,让 Spring AI 去处理协议差异。网关自己只管治理。

限流配额:三层都要有

这是事故的直接原因,也是我们做得最细的一块。三个维度分开限:

层级限流维度实现作用
租户级TPM / RPMRedis + Lua 滑动窗口业务线之间隔离
用户级每日请求数Redis INCR + 过期防个别用户刷
供应商级并发数 + RPMResilience4j Bulkhead + RateLimiter保护下游配额

租户级用 Lua 脚本保证原子性,这点很关键,先读后写在并发下一定会超发:

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local used = redis.call('ZCARD', key)
if used < limit then
    redis.call('ZADD', key, now, now .. '-' .. ARGV[4])
    redis.call('PEXPIRE', key, window)
    return 1
end
return 0

供应商级并发数必须按实际容量设。我们一开始拍脑袋设了 200 并发,结果某家供应商实际只能扛 80,压测时一片 429。后来改成从配置中心动态调,压测出来的真实容量写进去。

缓存:最大的省钱项

缓存的 key 是规范化后的请求体哈希(模型 + messages + 关键参数),value 是完整响应,存 Redis,TTL 按业务配。规范化这一步容易忽略——messages 里如果有随机顺序的字段或者无意义的空格,哈希就对不上了,我们做了排序和 trim。

更重要的是明确哪些请求不能缓存

  • temperature > 0 的,除非业务明确接受重复结果;
  • 带工具调用的,中间状态无法缓存;
  • 涉及用户隐私数据的,缓存等于把敏感信息明文放 Redis,我们在 key 计算前做了一次脱敏字段剔除,缓存内容本身也加密。

质检场景命中率高得离谱,因为它本身就是在重复处理相似工单。上线一个月的数据:

业务线日请求数缓存命中率日成本(优化前 → 后)
客服问答42 万18%¥3120 → ¥2610
智能质检15 万63%¥4800 → ¥1790
合同抽取80007%¥1450 → ¥1350

质检那 63% 完全是捡钱,因为之前没做过任何去重。缓存加的延迟开销不到 3ms(Redis 内网),相比动辄几百毫秒的模型调用可以忽略。

审计与可观测

审计日志我们落了两份:一份结构化进 Elasticsearch 保留 180 天,一份按天归档到对象存储保留 3 年(合规要求)。一条记录长这样:

{
  "traceId": "a1b2c3d4...",
  "tenant": "customer-service",
  "userId": "u_88213",
  "model": "qwen-plus",
  "promptTokens": 1243,
  "completionTokens": 287,
  "cost": 0.0031,
  "latencyMs": 842,
  "cacheHit": false,
  "status": "OK",
  "promptHash": "sha256:9f2e..."
}

注意 promptHash 而不是存原文——合规不让我们长期保存用户的原始输入,但又要能追责,所以存哈希 + 单独加密存原文(30 天自动删)。这个妥协是跟法务拉扯了两周定下来的。

Metrics 用 Micrometer 打到 Prometheus,Grafana 上几个关键面板:

  • 按 tenant + model 的 QPS、P50/P95/P99;
  • 供应商 429/5xx 错误率(超过 1% 告警);
  • 实时成本累计,按天/按业务线;
  • 缓存命中率(低于 10% 就该看看是不是 TTL 设短了)。

还有个反直觉的指标:平均输出 token 数。有次这个指标从 320 涨到 890,排查发现是某个 prompt 被误改,模型开始啰嗦。没这个指标根本发现不了,只能等账单。

降级链路要提前设计

网关里预置了三级降级:

  1. 主模型超时/报错 → 切备用模型(配置里配的 fallback 链);
  2. 所有远程模型不可用 → 切本地小模型(我们部署了 Qwen2.5-7B,效果差但能用);
  3. 本地模型也挂 → 走规则引擎返回预设答案,同时前端显示"智能服务降级中"。

第三级看起来很土,但客服场景真的需要——总比白屏强。降级触发时我们会在响应头里带 X-AI-Degraded: level-2,前端据此决定是否给用户提示。

上线后的效果

网关 3 月初全量上线,跑了两周的数据:

  • 供应商 429 从日均 1.2 万次降到 340 次(剩下的都是压测流量);
  • 整体 P99 从 2100ms 降到 980ms(主要是缓存和连接池复用生效);
  • 月度模型成本从 ¥31 万降到 ¥19 万,降幅 38%,其中缓存贡献 27%,模型分级贡献 11%;
  • 出问题时平均定位时间从 40 分钟降到 5 分钟以内(traceId 打通了全链路)。

几个没做好的地方

说实话也有没做对的:

  • 流式响应的 token 统计不准。流式返回时供应商不返回 usage,我们只能在结束时自己估算,误差大概 ±8%。账单对账时发现过差异,后来改成流式结束后再发一次不带 body 的请求拿准确 usage;
  • 缓存 key 的设计改过两版。第一版没考虑 system prompt 里的动态内容(比如当前日期),导致每天零点缓存集体失效;
  • 限流返回没做排队。现在超限是直接拒绝,业务侧体验不好。后面打算加一层短队列(200ms 以内等待),能救回一部分毛刺。

小结

AI 网关的本质是把"调用模型"从业务代码里的一个 HTTP 请求,升级成一项可治理的基础设施。它解决的事情跟十年前的 API 网关没有本质区别:统一入口、限流、缓存、审计、降级。差别只在几个 AI 特有的点上——按 token 计费、输出不确定、延迟高且抖动大、成本敏感。

如果你现在还在让业务直连模型 API,强烈建议先加一层网关。不用做得很重,先把限流和审计加上,这两块能挡掉大部分事故。缓存和降级可以后面补。

参考