大模型API聚合网关选型:缓存、并发与账单透明度实战
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 fbiz:{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 天但如果是实时数据查询类的 promptTTL 超过几分钟就是错的。我踩过的一个坑是某次产品改了退款政策但缓存里的旧答案还在用户看到的还是老规则客服被投诉了一整天。后来我加了两道保险版本号前缀业务方在 prompt 里带上内容版本比如[policy:v2024-11]版本一变 key 就变旧缓存自然失效。主动清除接口网关暴露一个DELETE /cache?tagrefund_policy的接口运营改完政策后手动触发。提示语义缓存的失效比精确缓存麻烦得多因为向量库里删一条记录后相似的历史记录可能还在。我的做法是给每条缓存记录打上业务标签失效时按标签批量删除而不是按向量相似度删。3. 并发控制限流、排队、熔断三件套怎么配3.1 先搞清楚你限的是谁的流聚合网关的并发控制比单体应用复杂因为你要同时面对两个方向的压力上游业务方打进来的请求和下游厂商能承受的调用量。这两者的限流策略完全不同。对上游你要保护自己不被业务方打垮用的是入口限流。对下游你要保护自己不被厂商封禁或拖慢用的是出口限流。很多网关只做了入口限流结果业务方没超限但下游厂商的 QPS 上限被突破了直接返回 429整个链路雪崩。我的配置思路是这样的限流方向维度典型阈值超限动作入口按 API Key100 QPS返回 429带 Retry-After入口按用户 ID10 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(fgateway:payload:{request_id}, mapping{data: payload}) def dequeue(): items r.zrange(gateway:queue, 0, 0, withscoresTrue) if not items: return None request_id, _ items[0] r.zrem(gateway:queue, request_id) payload r.hget(fgateway:payload:{request_id}, data) r.delete(fgateway: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_tokensoutput_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% 收费。如果价格表不区分账单会虚高。这套东西搭下来大概花了我两周的业余时间但上线后第一个月就发现了一个业务方用错模型的问题省下的钱远超投入。聚合网关这个事选型时多花点心思后面能省很多事。