过去半年,我一直在折腾一件事:给公司内部的AI大模型网关做一次服务升级,核心就两个功能——部门费用明细和限流配额。说起来简单,真正落地才发现,这两个功能背后牵扯的是企业内部AI治理的整个体系。这篇博文就把整个升级过程、设计思路、踩坑经历都摊开讲讲,适合正在做AI平台、模型接入层、或者准备在公司内部统一管理大模型调用的朋友参考,尤其是那些眼看着部门AI账单越来越乱、线上算力快被某个业务线打爆的团队。
先交代一下背景。我们公司今年AI应用增长很快,研发、市场、客服、运营都在接大模型API,一开始是各搞各的,有人用云厂商的key,有人自己部署了开源模型,还有人直接拿个人账号在测试。结果就是月底对账对不上,多个模型的调用量也没人说得清,线上并发一高,重要业务的请求就被顶掉了。这次网关升级的目的,就是把所有大模型调用统一收口,做到“谁用了、用了多少、花了多少钱、该不该让ta用”全部有据可查。下面按设计和落地的顺序,把核心方案和代码逻辑完整拆解出来。
1. 企业内部AI调用失控?网关就是来兜底的
1.1 没有网关的几个月,账目和算力都失控了
在没有统一网关之前,我们踩过两个非常典型的坑。第一个是费用失控。有一个团队用主账号的API key接了一个长文本批量处理任务,每天凌晨跑几万条数据,用的是价格比较高的pro模型,月底账单直接翻了三倍。问题是这个key是公共的,好几个项目都在用,财务找过来的时候根本说不清这钱是谁花的、花在哪了。
第二个是算力挤兑。另一个团队用开源模型做了个实时客服助手,本来是好事,但某天活动流量进来,所有请求全部打到同一个推理服务上,直接把GPU显存打满,导致另一个核心业务线的接口超时率飙到40%。当时我们的架构里根本没有租户隔离和优先级的概念,所有请求一视同仁,出问题只能靠运维临时重启服务。
这两个问题反映出来的本质是:企业内部一旦多个部门同时接入AI能力,就必须要有一个统一的入口来管理“身份、权限、计量、配额”。没有这个入口,业务跑得越快,后面欠的账就越多。
1.2 网关不只是路由代理,它更像门禁闸机
很多同学一听到“网关”,第一反应就是反向代理、转发请求。但AI大模型网关和普通的API网关有很大区别。普通网关关注的是URL路由、认证、限流,而AI网关必须额外关注token计量、模型路由、成本核算、流式响应透传。用个生活化的类比:普通网关是小区大门,只管让不让车进;AI网关是办公楼里的门禁闸机,刷卡才能进,刷了卡系统知道是谁进的、进了哪层楼、待了多久、这次访问消耗了多少访客额度。
这次升级我们把网关的职责拆成了四层:
- 路由层:根据业务方传入的模型名称和配置,决定把请求转发到云厂商API、私有化部署的模型还是内部其他网关服务。
- 计量层:统计每个请求的输入token、输出token、响应时间、费用估算,并把明细写入流水表。
- 配额层:按部门、按应用、按时间窗口控制调用量和费用上限,超限自动拦截或降级。
- 审计层:保存完整的请求日志和调用链信息,方便事后排查问题和对账。
这四个职责缺一不可。尤其是计量和配额,如果不和“部门”这个维度绑定,那收口就失去了意义,后面所有报表和治理都是空中楼阁。
2. 部门费用明细:从一笔糊涂账到分部门账单
2.1 费用归集维度设计:别只记一个总数
我们最开始做费用统计时,犯过一个特别低级的错误:只按“模型名”记了每天的调用次数和token量。结果领导问“这个月市场部花了多少钱”,我们答不上来,因为调用记录里根本没有部门字段。后来重构时,我们把费用归集的维度拆成了四个必选维度和三个可选维度,必选维度是所有流水记录都必须带上的:
- 部门(dept_id):费用最终归属,必须从应用解析出来,不允许请求方自己传什么就记什么,避免伪造。
- 应用(app_id):每个部门下可能有多个应用,比如市场部有内容生成应用、社媒监测应用,分开记录才能定位到具体业务的成本。
- 模型(model):不同模型价格差异很大,必须单独记录。
- 接口类型(api_type):chat、embedding、rerank这些接口计费方式不同,分开存。
可选维度包括用户ID、请求来源IP、业务标签(比如活动ID)。这些字段在不影响性能的前提下尽量都记上,后面做成本分析时会非常有用。比如我们后来发现某个部门周末的调用量比工作日还高,顺着用户ID一查,发现是有人在用公司key跑个人脚本,从源头堵住了滥用。
2.2 从请求到账单的完整链路
费用明细不能靠事后从模型服务商账单里反推,那样永远对不上。正确的做法是在请求链路上实时计量,把每一笔调用都登记成一条不可修改的流水。大致链路是:
- 客户端请求到达网关,携带应用标识和密钥。
- 网关完成鉴权,解析出部门ID、应用ID、目标模型。
- 网关向模型服务发起转发请求。对于普通同步请求,模型返回的结果里通常会带usage字段(input_tokens和output_tokens)。
- 网关根据模型计价表计算本次调用的估算费用。
- 把调用流水写入存储(MySQL或ClickHouse),关联部门、应用、模型、token数和费用。
- 定时任务按天/月聚合流水,生成部门费用报表。
看起来不复杂,但有一个环节特别容易出问题,就是流式响应(SSE)的token统计。如果模型接口是流式返回,很多模型服务商不会把usage放在每个chunk里,只在最后一段返回。如果网关只是简单透传而不解析最后的结束帧,这笔调用的token就是0,费用明细自然就漏了。这个问题我们上线第一周就遇到了,后面细说。
2.3 计价模型与流式token统计的坑
大模型的计费通常是输入和输出分开计价,输入token便宜、输出token贵,部分模型还有缓存命中token,价格更低。一个简单的计价公式:
费用 = 输入token数 × 输入单价 + 输出token数 × 输出单价以我们内部一个pro模型为例,输入每百万token约20元,输出每百万token约60元。一个请求如果输入3000 token、输出1000 token,费用大约是0.06 + 0.06 = 0.12元。单个请求看起来不贵,但一天几百万次调用,费用就非常可观了。
流式统计的关键在于:网关必须识别SSE流的结束标记。以OpenAI兼容接口为例,返回流最后会有一个data: [DONE],而在这之前,会有一帧data: {"choices":[...],"usage":{...}}携带usage信息。我们当时的处理方式是:
- 把SSE流按行解析,逐行透传给客户端。
- 当遇到包含
"usage"字段的帧时,解析并保存到请求上下文。 - 当遇到
[DONE]时,确定本次请求结束,使用已保存的usage计算费用。 - 如果服务商实现不规范,没有返回usage,就退化用“估算模式”:累加所有delta中的content字符数,按经验比例估算token(中文字符约0.6 token/字,英文约1.3 token/词,仅供兜底)。
实际上,后来我们干脆在网关侧把SSE解析做成了标准组件,不管是接云厂商还是私有化模型,都能正确取到usage。这个组件是整个费用明细功能最核心的模块。
2.4 数据落库:流水表一定要设计成不可修改
费用流水表是审计和账单的基础,必须设计成只追加、不可修改、不可删除。我们在MySQL里建了一张call_ledger表,核心字段如下:
CREATE TABLE `call_ledger` ( `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `request_id` VARCHAR(64) NOT NULL COMMENT '全局请求ID', `dept_id` BIGINT NOT NULL COMMENT '部门ID', `app_id` BIGINT NOT NULL COMMENT '应用ID', `model_name` VARCHAR(64) NOT NULL COMMENT '模型名称', `api_type` VARCHAR(16) NOT NULL DEFAULT 'chat' COMMENT '接口类型', `input_tokens` INT UNSIGNED NOT NULL DEFAULT 0, `output_tokens` INT UNSIGNED NOT NULL DEFAULT 0, `cost` DECIMAL(10, 6) NOT NULL DEFAULT 0 COMMENT '估算费用', `latency_ms` INT UNSIGNED NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_dept_created` (`dept_id`, `created_at`), KEY `idx_app_created` (`app_id`, `created_at`), KEY `idx_request_id` (`request_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表的特点就是宽、索引多、不带更新操作。所有费用统计都基于这张表做聚合,业务上谁也不许改历史数据。如果要订正,只能新插入一条冲正记录,保证流水完整可审计。
报表查询可以用SQL直接聚合,也可以定时把流水同步到ClickHouse,用更灵活的方式做多维分析。数据量小时MySQL就够用,数据量大了建议加一层OLAP存储。
3. 限流配额:把稀缺算力公平分给每个部门
3.1 先把概念分开:QPS限流和额度配额不是一回事
刚开始做限流时,我们以为就是简单的“每秒最多X个请求”,后来发现完全不够。大模型调用有两个核心限制维度:
- 速率限制(QPS/并发):防止瞬间流量打爆推理服务。
- 额度限制(token/费用):防止某个部门把月度预算提前烧光。
这两件事必须分开配置。举个例子:市场部做内容生成,单个任务会并发调用100次,但每个请求量不大,总量可控;研发部的测试任务可能QPS不高,但每个请求都是万字长文,token消耗是前者的几十倍。如果只限QPS,研发部一个下午就能把整个月的预算跑完。
| 维度 | 控制目标 | 典型配置项 | 超出后的处理 |
|---|---|---|---|
| 速率限制 | 保护后端推理服务 | 每秒最大请求数、最大并发数 | 请求被拦截,返回429 |
| 额度限制 | 保护部门预算 | 每日/每月最多消耗token或费用 | 请求被拦截,或自动降级到低成本模型 |
所以我们的配额配置拆成了两部分:一部分挂在部门上,用于费用和token的额度管控;另一部分挂在应用上,用于QPS和并发限制。两者独立生效,任何一个超出都会触发保护。
3.2 限流算法选型:滑动窗口、令牌桶到底选哪个
限流算法有很多种,固定窗口、滑动窗口、令牌桶、漏桶。我在这次升级里把常见算法都对比了一遍,最后结论是:QPS控制用滑动窗口,额度控制用Redis计数器,允许突发的场景再叠加令牌桶。
- 固定窗口:实现最简单,但两个窗口交界处会出现流量尖峰。比如限制每分钟100次,第一个59.9秒内放了100次、最后一个0.1秒又放100次,前后一秒钟打了200次进来。
- 滑动窗口:比固定窗口平滑很多,对时间边界的处理更合理。用Redis ZSET实现,精度高,但每个请求都要记录一个时间戳,内存开销稍大。
- 令牌桶:允许一定程度突发,适合处理大模型调用中“某个请求内部需要多次重试”的场景。限制的是平均速率,同时允许瞬间冲一下。
- 漏桶:强制恒定速率,对AI这种单请求耗时不稳定的场景不太适合,容易拖慢整体响应。
我们的生产实现是:QPS限流采用Redis ZSET做滑动窗口,并发数控制用“计数器+过期时间”实现,部门和应用的额度用Redis INCR配合Lua脚本。如果公司已经有Sentinel或Hystrix,也可以借用来做调用端保护,但网关层的集中管控还是得自己在网关上做。
3.3 配额扣减的原子性:Redis Lua才是正解
配额控制最容易出问题的是并发场景。网关一般是多实例部署,多个请求同时到达,如果先用GET读当前已用额度、判断是否超限、然后用SET写回,在并发下一定会出现竞态条件,导致实际超限。我们这个坑踩得很惨:上线第一天,某个部门的瞬时并发冲高,实际消耗超过了月配额的20%,就是因为读写没做成原子操作。
正确做法是把“判断+扣减”放进一个Redis Lua脚本里,脚本在Redis服务端原子执行。核心逻辑如下:
-- KEYS[1]:配额计数key,例如 quota:dept:12:month:202501:cost -- ARGV[1]:本次请求预计消耗的量 -- ARGV[2]:配额上限 local current = tonumber(redis.call('GET', KEYS[1]) or '0') local cost = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) if current + cost > limit then return 0 end redis.call('INCRBY', KEYS[1], cost) return 1用法也简单,检查时直接调用脚本。如果返回0,说明配额不足,拒绝请求;返回1说明扣减成功,放行。这里有一个细节:对于token型配额,预扣后实际usage返回时会有偏差,我们的做法是先把预估token预扣,等实际usage回来后再做一次“修正扣减”,把差额补上或退回。所以配额表需要额外记录“预扣量”和“实际量”,靠定时任务做对账。
3.4 超限之后怎么办:拒绝、降级、排队
超限后的策略一开始我们只做了“拒绝”,后来发现太粗暴。核心业务被限流直接报错,业务方体验很差。于是我们加了两层优化:
第一层是降级。超额不严重的部门,自动把模型从pro降到lite,保证功能可用但成本下降。这需要在配置里给每个部门设置降级路径,比如gpt-4o -> gpt-4o-mini,或者在国内模型之间切换。降级有两种触发方式:时间窗口(如晚上低峰期自动降级)和成本阈值(如本月花费达到预算80%后自动降级)。
第二层是排队。对于非实时任务,比如批量数据处理、离线生成,超额后不直接拒绝,而是丢进任务队列,等配额恢复或系统空闲时再执行。这个在网关层做成“异步转发”模式,很多批量场景都适用。
对于真正实时性要求高的场景,比如智能客服、在线对话,超限后只能返回429并带上Retry-After头,让客户端稍后重试。我们实测下来,客户端只要正确遵循429语义,用户体验基本无感。
4. 实操落地:一版可以直接参考的网关方案
4.1 架构与技术选型
这套网关我们生产环境用Go写了核心转发组件,下面演示用Python代码表达同样的逻辑,因为可读性更好,核心流程是完全一致的。整体架构分三层:
- 接入层:Nginx负责TLS终止、域名路由、最基础的IP黑名单。这个层面不做过重的业务逻辑,避免拖慢转发。
- 网关服务层:负责鉴权、路由、配额检查、费用计量、流式透传、审计日志。这是升级的核心,也是代码量最大的部分。
- 存储层:Redis保存配额计数、滑动窗口数据和应用配置;MySQL保存部门、应用、调用流水;报表服务定时从MySQL聚合数据生成账单。
模型服务这一层,我们统一使用OpenAI兼容的接口协议,不管是接云厂商还是内部私有化模型,网关都以标准格式转发,这样路由和计量逻辑不用为每个厂商单独写一套。
4.2 核心中间件实现:从鉴权到记账
网关服务的核心是一个请求处理中间件,按顺序执行鉴权、配额检查、转发、计量。用Python伪代码表达核心流程:
async def proxy(request): # 1. 从请求头/app_key解析应用身份 app_key = request.headers.get("X-App-Key") app_info = await get_app_info(app_key) if not app_info: return JSONResponse(status_code=401, content={"error": "invalid app_key"}) # 2. 解析目标模型和调用方式 model = request.json().get("model") api_type = request.json().get("api_type", "chat") # 3. 配额检查(Redis Lua,原子操作) estimated_tokens = estimate_tokens(request) allowed = await check_quota( dept_id=app_info.dept_id, app_id=app_info.id, model=model, cost=estimated_tokens ) if not allowed: return JSONResponse(status_code=429, content={"error": "quota exceeded"}) # 4. 转发到模型服务(支持SSE流式) async with httpx.AsyncClient() as client: async with client.stream("POST", upstream_url, json=request.json()) as resp: # 流式场景:逐帧解析并透传,同时收集usage async for line in resp.aiter_lines(): if '"usage"' in line: usage = parse_usage(line) await record_usage(request_id, usage) yield line # 5. 记录流水 await write_ledger(request_id, app_info, model, usage)这个流程有三个关键点:应用鉴权不能只校验key存在,还要校验“这个应用是否被允许调用这个模型”;配额检查必须在转发之前完成,否则超额请求已经打到模型服务了,成本已经产生;流式场景下计量不能等整个流结束,要边转发边收集usage,否则流异常中断时连补救数据都没有。
4.3 限额配置与应用权限管理
配额配置我们设计成了两层JSON配置,存Redis里,修改后即时生效,不需要重启网关。部门级配置示例:
{ "dept_id": 12, "quota": { "month_cost_cn": 5000, "day_input_tokens": 2000000, "day_output_tokens": 500000, "qps": 20, "concurrency": 5 }, "auto_degrade": { "enabled": true, "threshold_percent": 0.8, "fallback_model": "pro-lite" } }应用级配置则记录在MySQL,网关启动时全量加载到本地缓存,变更通过管理后台发布到Redis,各网关实例订阅变更后更新本地缓存的版本号。这里要注意:配置变更一定要有版本号,否则多实例之间缓存不一致,会导致同样的请求在不同实例上判定结果不同。
4.4 参数调优与部署注意点
部署上最值得提醒的是Redis不能成为单点。网关多实例共享一个Redis,如果Redis挂了,配额检查和滑动窗口数据全部拿不到,会导致网关拒绝所有请求。我们做了两层保障:一是Redis做主从加哨兵,自动切换;二是网关本地缓存了配置数据和分钟级的配额计数,Redis不可用时降级为“只记账不限流”,优先保证业务可用,等Redis恢复后再补同步。
超时参数也需要调。大模型接口本身响应慢,一个请求可能几十秒才完成,但网关和上游的连接不能无限等。我们把“连接超时”设置成5秒、“读超时”按模型差异化配置:chat类模型30秒,embedding类10秒。网关到模型服务的连接复用开启keepalive,降低握手开销。
5. 常见问题与排查实录
5.1 我踩过的五个坑,希望你一次都别踩
第一个坑是流式响应漏记usage,前面已经详细说了。上线第一周,所有SSE调用的费用都是0,导致部门账单严重偏低。排查时发现是网关直接透传了流,根本没解析最后带usage的帧。
第二个坑是配额预扣和实际用量偏差过大。我们最初按请求里的max_tokens参数预扣额度,但很多业务方把这个值设得非常大,导致预扣了一次就把配额扣光了,后续正常请求全被拦截。后来改成“按历史同应用的平均token量预扣,实际回来后再修正”,才解决。
第三个坑是Redis缓存抖动导致网关雪崩。报价和配置经常读Redis,大促流量进来时Redis连接数暴涨,部分超时重试又加剧了压力,最后网关整体变慢。后来加了本地缓存和“弱一致”策略,Redis只作为变更通知源,才稳定下来。
第四个坑是限流单位搞混。配置项里“每分钟6000 token”和“每请求6000 token”完全是两个概念。我们有同事把后者当成了前者配进去,结果所有请求都被拦截,排查了两小时。建议所有配额配置强制带单位,并在管理后台展示换算关系。
第五个坑是废弃的应用key没有回收。某个部门换人后key没删,旧系统还在定时调用,费用一直在涨。后来加了“应用活跃度告警”,超过30天未调用的key自动进入回收流程。
5.2 问题排查速查表
| 现象 | 可能原因 | 排查思路 | 解决建议 |
|---|---|---|---|
| 报表里费用为0 | 流式响应未解析usage | 查看网关日志,确认是否收到带usage的SSE帧 | 升级SSE解析组件 |
| 部分请求报429 | 配额设置过小或预扣逻辑过激 | 查看配额计数、对比实际token量 | 改用平均值预扣,事后修正 |
| 部门间配额互相影响 | 共用同一配额key | 检查key命名是否包含dept_id | 确保dept_id隔离 |
| 网关响应变慢 | Redis连接打满 | 查看Redis监控、连接数 | 增加本地缓存,扩大连接池 |
| 月度账单和模型方账单对不上 | 模型方有缓存token计价差异 | 拉取模型方原始账单对比 | 补充缓存token计费规则 |
5.3 上线前自测清单
网关上线前,我们建议至少跑一遍以下自测,能避开大部分生产问题:
- 用压测工具模拟每个部门的正常流量,确认配额统计和费用统计与预期一致。
- 人为把配额调低到极限值,确认超限后返回429、降级、排队三种策略都符合预期。
- 用流式请求压测10分钟,确认usage统计没有漏记。
- 模拟Redis主从切换,确认网关能降级到“只记账不限流”模式,业务不中断。
- 对比网关流水总费用和模型服务商账单,误差控制在1%以内。
6. 一点个人体会
这次升级做下来,我最大的感受是:部门费用明细和限流配额,表面上是两个技术功能,本质上是一家公司AI治理规则的落地。技术方案再漂亮,如果费用归集维度不清晰、配额规则定得不合理,上线后还是会一地鸡毛。所以做这件事之前,一定要先和财务、部门负责人把规则对齐:哪些成本算研发、哪些算业务线成本、超出预算后是断还是降级,这些问题不先谈明白,写代码都是白写。
最后再分享一个小技巧:上线之后不要急着把旧API key全部禁用,先让网关记录一段时间的“影子流量”——所有请求正常转发,但同时在日志里标记出“如果按新配额规则,这个请求会不会被拦截”。跑一到两周,用真实流量验证配额配置合不合理,再切换成强制模式。我们就是靠这个方式,把上线后的线上故障几乎降到了零。