1. 从一次账单翻车说起:为什么聚合网关不是"接上就能用"
去年冬天,团队里一个做智能客服的兄弟半夜给我发消息,说他们线上环境的模型调用费用一夜之间涨了六倍,老板第二天要复盘。我让他先把网关的请求日志拉出来,结果发现两个致命问题:一是同一批用户问句在高峰期被重复提交了三次,网关没有任何去重或缓存机制;二是他们用的聚合网关把不同厂商的计费口径混在一起,日志里只记了"调用成功",没记 token 消耗,导致根本算不清钱花在哪。
这件事之后,我把市面上主流的几类大模型 API 聚合网关重新梳理了一遍,也自己搭了一套做压测和账单核对。这篇就聊聊选型时真正要盯住的三个东西:缓存策略、并发控制、账单透明度。不是泛泛而谈的对比,而是把每个环节拆到能直接抄作业的程度。
先说清楚聚合网关到底解决什么问题。当你只调一家模型厂商的 API 时,直接写个 HTTP 客户端就够了。但现实是,一个稍微像样的应用往往要同时对接好几家:有的任务用便宜的小模型跑,有的任务必须上旗舰模型;某家厂商偶尔抽风,需要自动切到备用;不同厂商的 SDK、鉴权方式、返回格式、错误码全不一样。聚合网关就是夹在你的业务代码和这些厂商之间的中间层,对外暴露一套统一的接口,对内做路由、重试、限流、缓存、计费统计。
听起来很美好,但坑也恰恰藏在这些"对内"的逻辑里。我见过太多团队选网关时只看"支持多少家厂商""接口文档好不好看",上线后才发现缓存命中率低得可怜、并发一高就雪崩、月底对账全靠猜。下面按我实际踩过的顺序,一块一块拆。
2. 缓存层:省下的钱和埋下的雷往往同时到来
2.1 语义缓存和精确缓存,选错一个就是灾难
缓存是聚合网关里最诱人的功能,也是最容易出事的。先分清两种缓存:
精确缓存:请求的 prompt、参数、模型名完全一致时,直接返回上次的结果。实现简单,用 Redis 存一个 hash 就行,key 是请求体的哈希值。
语义缓存:把 prompt 转成向量,在向量库里找相似度超过阈值的历史请求,命中就返回。听起来很智能,但阈值调不好就是灾难。
我做过一组实测,用同一批客服问句跑两种缓存:
| 缓存类型 | 命中率 | 误命中率 | 平均响应 | 适用场景 |
|---|---|---|---|---|
| 精确缓存 | 12% | 0% | 8ms | 模板化问答、固定指令 |
| 语义缓存(阈值0.92) | 41% | 3.2% | 45ms | 开放问答、意图识别 |
| 语义缓存(阈值0.85) | 67% | 14% | 45ms | 不推荐,误命中太高 |
那个 14% 的误命中率意味着什么?用户问"怎么退款",系统返回了"怎么退货"的答案,因为两句话向量距离很近。在客服场景里这是直接引发投诉的。所以我的建议很明确:语义缓存的阈值不要低于 0.90,且必须对命中结果做二次校验,比如把命中的原始 prompt 和当前 prompt 一起丢给一个小模型判断是否真的等价,这一步多花的那点钱远比误答的代价低。
2.2 缓存 key 的设计决定了命中率的天花板
很多人缓存命中率上不去,不是缓存算法的问题,是 key 设计得太死。举个真实例子:某团队的 key 里包含了完整的请求时间戳,那命中率永远是零。还有人把 temperature、top_p 这些采样参数也塞进 key,结果同一个问题因为参数微调就缓存失效。
我的做法是把 key 拆成两层:
- 业务层 key:由业务方显式指定,比如
faq:refund_policy:v3,同一个业务问题共用。 - 内容层 key:对规范化后的 prompt 做哈希,规范化包括去空格、统一标点、小写化。
import hashlib import re def normalize_prompt(text: str) -> str: text = text.strip().lower() text = re.sub(r'\s+', ' ', text) text = re.sub(r'[,。!?;:]', '', text) return text def cache_key(prompt: str, model: str, biz_tag: str = None) -> str: if biz_tag: return f"biz:{biz_tag}" norm = normalize_prompt(prompt) raw = f"{model}::{norm}" return "content:" + hashlib.sha256(raw.encode()).hexdigest()[:32]注意这里没有把 temperature 放进 key。原因很简单:对于 temperature 较低(0 到 0.3)的请求,输出差异很小,缓存复用完全可接受;对于高 temperature 的创意类请求,本来就不该走缓存。所以我在网关配置里加了一条规则:temperature 大于 0.5 的请求直接跳过缓存层。
2.3 TTL 和失效策略:别让过期答案继续服务用户
缓存 TTL 设多长,取决于你的内容更新频率。政策类问答可能一周才变一次,TTL 可以设 7 天;但如果是实时数据查询类的 prompt,TTL 超过几分钟就是错的。
我踩过的一个坑是:某次产品改了退款政策,但缓存里的旧答案还在,用户看到的还是老规则,客服被投诉了一整天。后来我加了两道保险:
- 版本号前缀:业务方在 prompt 里带上内容版本,比如
[policy:v2024-11],版本一变 key 就变,旧缓存自然失效。 - 主动清除接口:网关暴露一个
DELETE /cache?tag=refund_policy的接口,运营改完政策后手动触发。
提示:语义缓存的失效比精确缓存麻烦得多,因为向量库里删一条记录后,相似的历史记录可能还在。我的做法是给每条缓存记录打上业务标签,失效时按标签批量删除,而不是按向量相似度删。
3. 并发控制:限流、排队、熔断三件套怎么配
3.1 先搞清楚你限的是谁的流
聚合网关的并发控制比单体应用复杂,因为你要同时面对两个方向的压力:上游业务方打进来的请求,和下游厂商能承受的调用量。这两者的限流策略完全不同。
对上游,你要保护自己不被业务方打垮,用的是入口限流。对下游,你要保护自己不被厂商封禁或拖慢,用的是出口限流。很多网关只做了入口限流,结果业务方没超限,但下游厂商的 QPS 上限被突破了,直接返回 429,整个链路雪崩。
我的配置思路是这样的:
| 限流方向 | 维度 | 典型阈值 | 超限动作 |
|---|---|---|---|
| 入口 | 按 API Key | 100 QPS | 返回 429,带 Retry-After |
| 入口 | 按用户 ID | 10 QPS | 排队,队列满则拒绝 |
| 出口 | 按厂商 | 厂商文档上限的 80% | 切换备用厂商 |
| 出口 | 按模型 | 单模型并发 50 | 排队等待 |
出口限流留 20% 余量是经验值。厂商文档写的 QPS 上限往往是理论值,实际在高峰期会打折扣,留余量能避免大量 429。
3.2 排队还是拒绝,取决于你的业务能不能等
限流触发后有两种处理方式:排队等待或直接拒绝。这个选择不能拍脑袋,要看业务场景。
同步对话类请求(比如聊天窗口),用户盯着屏幕等,排队超过 2 秒体验就崩了,所以应该快速拒绝并提示"当前繁忙"。异步任务类请求(比如批量生成报告),用户提交后可以去干别的,排队几分钟完全没问题,这时候排队反而能提高整体吞吐。
我在网关里做了一个优先级队列,用 Redis 的 sorted set 实现,score 是请求的优先级加时间戳:
import time import redis r = redis.Redis() def enqueue(request_id: str, priority: int, payload: str): score = priority * 1e12 + time.time() r.zadd("gateway:queue", {request_id: score}) r.hset(f"gateway:payload:{request_id}", mapping={"data": payload}) def dequeue(): items = r.zrange("gateway:queue", 0, 0, withscores=True) if not items: return None request_id, _ = items[0] r.zrem("gateway:queue", request_id) payload = r.hget(f"gateway:payload:{request_id}", "data") r.delete(f"gateway:payload:{request_id}") return request_id, payload优先级数值越小越优先,VIP 用户给 1,普通用户给 5,后台任务给 9。这样高峰期 VIP 的请求能插队,普通用户的请求在队列里等,后台任务基本被饿死但不会影响主流程。
3.3 熔断不是"断了就行",恢复策略才是关键
下游厂商挂了,网关要熔断,这个大家都知道。但很多人忽略了熔断后的恢复。我见过一个网关,熔断后直接永久切到备用厂商,主厂商恢复了也不切回来,结果备用厂商的额度被跑爆。
正确的做法是半开状态:熔断后每隔一段时间放一个探测请求过去,成功了就逐步恢复流量。具体参数我一般这样设:
- 连续失败 5 次触发熔断
- 熔断持续 30 秒
- 半开状态放行 10% 流量
- 半开状态下连续成功 20 次则完全恢复
这套参数不是固定的,要根据厂商的稳定性调整。如果某厂商一周挂三次,那熔断阈值可以放宽到 10 次,避免频繁切换带来的抖动。
注意:熔断和重试要配合使用,但重试次数不能太多。我一般设最多重试 2 次,且只在 5xx 和超时错误上重试,4xx 错误(比如参数错误)重试没有意义,只会浪费额度。
4. 账单透明度:从"月底猜账"到"实时对账"
4.1 为什么你的账单永远对不上
账单对不上,根因通常有三个:
第一,token 计数口径不一致。不同厂商对 token 的计算方式不同,有的把系统提示词算进去,有的不算;有的对中文按字符算,有的按分词算。网关如果不统一口径,你拿到的数字就是一堆没法比的数。
第二,流式响应的 token 统计缺失。流式返回时,很多网关只统计了请求的 token,没统计响应的 token,导致输出侧的费用完全丢失。而输出 token 往往比输入贵好几倍。
第三,重试和缓存的费用归属混乱。一次业务请求可能触发了两次重试,实际调了三次厂商 API,但账单上只记了一次。缓存命中的请求没花钱,但如果也记成一次调用,成本统计就虚高了。
4.2 统一 token 计数的实操方案
我的做法是在网关层做一次归一化计数,不管厂商怎么算,统一转换成"标准 token"再记账。具体来说,对每个请求记录以下字段:
{ "request_id": "req_abc123", "biz_tag": "customer_service", "model": "gpt-4o", "provider": "openai", "input_tokens": 1520, "output_tokens": 380, "cached": false, "retry_count": 0, "latency_ms": 2340, "cost_usd": 0.0234, "timestamp": "2024-11-15T10:23:45Z" }关键是cost_usd这个字段要在网关层算好,而不是等到月底拿厂商账单去对。网关里维护一张价格表,每个模型每百万 token 的输入输出单价都配好,请求完成时直接算出费用。
PRICE_TABLE = { "gpt-4o": {"input": 2.5, "output": 10.0}, "claude-3-5-sonnet": {"input": 3.0, "output": 15.0}, "qwen-max": {"input": 1.6, "output": 6.4}, } def calc_cost(model: str, input_tokens: int, output_tokens: int) -> float: price = PRICE_TABLE.get(model) if not price: return 0.0 return (input_tokens * price["input"] + output_tokens * price["output"]) / 1_000_000价格表要定期更新,厂商调价是常事。我一般每周核对一次官方定价页,改完价格表后旧数据不动,新请求用新价格,这样历史账单不会因为调价而失真。
4.3 流式响应的 token 统计:别漏了输出侧
流式响应是账单黑洞的重灾区。因为响应是一个 chunk 一个 chunk 回来的,很多网关在第一个 chunk 到达时就返回了,后面的 token 根本没统计。
我的处理方式是在网关里做流式拦截:把厂商返回的 SSE 流先经过网关,网关一边转发给业务方,一边累积统计。具体来说,对 OpenAI 兼容的流式格式,最后一个 chunk 通常带usage字段,但有些厂商不返回,那就只能自己数。
async def stream_with_usage(upstream_stream): output_tokens = 0 async for chunk in upstream_stream: if chunk.startswith("data: ") and chunk.strip() != "data: [DONE]": data = json.loads(chunk[6:]) delta = data.get("choices", [{}])[0].get("delta", {}) content = delta.get("content", "") if content: output_tokens += estimate_tokens(content) yield chunk # 流结束后记录 usage record_usage(output_tokens=output_tokens)estimate_tokens对英文可以用len(text) / 4粗略估算,对中文用len(text) * 0.6。这个估算不精确,但比完全不统计强得多。如果厂商在最后一个 chunk 里返回了精确的 usage,就用厂商的值覆盖估算值。
4.4 实时对账面板:让每一分钱都有迹可循
光有数据不够,还要能看。我在网关旁边搭了一个简单的对账面板,用 Grafana 或者自己写个前端都行,核心是展示这几个维度:
- 按业务标签的费用分布:哪个业务在烧钱,一目了然。
- 按模型的调用量和费用:是不是有任务用错了模型,拿旗舰模型跑简单任务。
- 缓存命中率与节省费用:缓存到底省了多少钱,值不值得继续投入。
- 重试率与重试费用:重试花了多少冤枉钱。
- 实时 QPS 与费用速率:当前烧钱速度,异常时能立刻发现。
我特别推荐做一个费用速率告警:当每分钟费用超过过去 7 天同时段均值的 3 倍时,直接发告警。那个半夜费用涨六倍的事故,如果有这个告警,十分钟内就能发现,而不是等到第二天。
5. 选型时我真正会问的几个问题
5.1 网关是自建还是用现成的
这是第一个要回答的问题。我的判断标准很简单:
- 如果你的团队小于 5 人,且没有专职的基础设施工程师,用现成的开源网关(比如 One API 这类)改改就行,别自建。
- 如果日调用量超过百万次,或者对账单精度要求极高,自建更可控。
- 如果业务涉及敏感数据不能出内网,那必须自建。
自建的成本主要在维护:价格表要更新、厂商接口变了要适配、缓存要调优、告警要配置。这些工作量不小,但换来的是完全的控制权。我自己的项目是自建的,因为账单精度对我们太重要了。
5.2 缓存、并发、账单,哪个先做
如果资源有限只能先做一个,我的建议是先做账单透明度。原因很实际:缓存和并发优化是省钱和提性能,但账单不透明是"不知道钱花哪了",后者带来的决策风险更大。你连钱花在哪都不知道,怎么知道该优化什么?
账单做扎实之后,再做并发控制,最后做缓存。因为缓存的效果依赖你对请求模式的理解,而请求模式的数据恰恰来自账单系统。
5.3 那些文档里不会写的坑
最后分享几个我在实际使用中踩过的、文档里绝对不会写的坑:
坑一:厂商的 token 计数和网关的计数对不上。这是常态,差异通常在 5% 以内。不要试图追求完全一致,设定一个可接受的偏差范围(比如 3%),超出才排查。
坑二:流式请求的超时设置。流式响应的总时长可能很长,但每个 chunk 之间的间隔很短。超时要设在 chunk 间隔上,而不是总时长上。我一般设 chunk 间隔超时 30 秒,总时长不设限。
坑三:并发限制要按模型分别设。同一个厂商的不同模型,QPS 上限可能差十倍。用一个统一的并发数去限,要么浪费了高上限模型的额度,要么把低上限模型打爆。
坑四:缓存和限流的顺序。一定要先查缓存再限流。缓存命中的请求不消耗下游额度,如果先限流,缓存命中的请求也被挡了,白白浪费了缓存的价值。
坑五:价格表要区分输入输出,还要区分缓存命中。有些厂商对缓存命中的输入 token 打折,比如按 10% 收费。如果价格表不区分,账单会虚高。
这套东西搭下来大概花了我两周的业余时间,但上线后第一个月就发现了一个业务方用错模型的问题,省下的钱远超投入。聚合网关这个事,选型时多花点心思,后面能省很多事。