早上九点收到的一条额度告警
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 / RPM | Redis + Lua 滑动窗口 | 业务线之间隔离 |
| 用户级 | 每日请求数 | Redis INCR + 过期 | 防个别用户刷 |
| 供应商级 | 并发数 + RPM | Resilience4j 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 |
| 合同抽取 | 8000 | 7% | ¥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 被误改,模型开始啰嗦。没这个指标根本发现不了,只能等账单。
降级链路要提前设计
网关里预置了三级降级:
- 主模型超时/报错 → 切备用模型(配置里配的 fallback 链);
- 所有远程模型不可用 → 切本地小模型(我们部署了 Qwen2.5-7B,效果差但能用);
- 本地模型也挂 → 走规则引擎返回预设答案,同时前端显示"智能服务降级中"。
第三级看起来很土,但客服场景真的需要——总比白屏强。降级触发时我们会在响应头里带 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,强烈建议先加一层网关。不用做得很重,先把限流和审计加上,这两块能挡掉大部分事故。缓存和降级可以后面补。