简介:在人工智能应用开发中,大模型API的接入往往面临多服务商、多密钥、接口协议差异等复杂问题。API网关作为微服务架构中的关键组件,天然适合承担大模型调用的统一治理职责。所谓LLM API网关,是通过集中式路由将不同厂商的模型接口封装成规范协议,并在此基础上实现统一鉴权、限流、计量计费与容灾切换。这种设计不仅能够收敛散落的API Key、降低泄露风险,还能通过权重路由与健康检查机制保障服务高可用。实际工程中,借助令牌桶算法控制请求频率、基于Lua脚本保证限流原子性,并以token预占策略实现成本配额管理,已经成为多模型场景下的标准实践。对于企业内部多个业务方共用大模型能力的场景,部署一套轻量级统一网关,配合可观测性指标与自动化告警,能显著提升故障定位效率与资源利用水平。本文即从网关设计角度,完整拆解这一系统的核心模块与落地经验。 做LLM应用这段时间,我最大的感触是:模型能力本身已经不是瓶颈,真正折磨人的是API管理。团队里三四个人,每人手里握着DeepSeek、GLM、通义、豆包好几把Key,代码里到处是硬编码的base_url,生产环境报错还得逐个去查是哪家服务商挂了。后来我干脆花了两周时间,把整个LLM API调用层重构成一个统一管理系统,顺便把论文和源码都整理出来了,这套东西到现在还在稳定跑着。今天就把设计和实现思路完整拆一遍,包括路由、鉴权、限流、计费、容灾这些核心模块,以及我踩过的坑。
这个系统适合什么场景?如果你也在做多模型接入的AI应用、公司内部有多个业务方在调大模型接口、或者你手里有几个Key想统一管理和分配,那这套设计可以直接拿来当参考。它解决的核心问题就三个:Key收敛、路由分发、成本可控。下面从设计思路开始,一步步说清楚。
1. 为什么需要统一管理LLM API——先讲清楚根因
1.1 多模型多密钥带来的管理混乱
先说个我自己的真实情况。项目初期我们同时在用四家国产大模型API,外加一个开源模型的私有化部署,每个模型都有自己的API Key、计费方式、限流策略。业务代码里大概有七八处直接拼接HTTP请求的地方,每处都要单独处理鉴权头、超时配置、错误重试。最要命的是,前端同学在调试时为了方便,把Key直接写进了前端代码里,后来被安全扫描发现,搞得整个Key作废重发。
这种散养式接入的麻烦远不止这些:
- Key泄露风险不可控:每多一个人接触Key,泄露概率就高一分,而且泄露了很难追溯是谁干的。
- 接口语义不统一:有的服务商用
Authorization: Bearer,有的用自定义header,有的响应里tokens用usage.total_tokens,有的却是usage.completion_tokens,业务方接入成本极高。 - 故障定位困难:线上偶发超时,到底是网络波动、对方服务限流、还是我们自己代码的问题?没有任何统一的观测手段。
1.2 接口格式差异与迁移成本
各家大模型API虽然都兼容OpenAI格式,但细节差异非常多。比如:
- 模型名称映射:同一个能力在A家叫
deepseek-chat,在B家叫glm-4-flash,业务方根本不需要关心这个,但每换一家服务商就要改一遍代码。 - 参数兼容性:有的服务商支持
thinking_budget,有的不支持;有的支持response_format传JSON Schema,有的只支持json_object。这些细微差异在接入时全是坑。 - 流式输出格式:SSE流里
delta字段的嵌套结构各家略有不同,解析逻辑写得不健壮就会漏内容。
统一管理系统的第一个价值,就是把这些差异全部在网关层抹平。业务方只对接我们定义的统一接口,底层换成哪个模型、哪家服务商,业务代码一行都不用动。
1.3 成本治理与配额控制需要集中化
散养接入还有个隐性成本问题:谁在用、用了多少、花多少钱,完全是一笔糊涂账。月度账单下来,只看到一个总金额,没法拆分到具体业务线、具体功能、具体用户。
集中管理之后,每一笔请求都能记录下业务方标识、模型、输入输出token数、花费金额。有了这些数据,你才能回答老板三个问题:AI功能到底花了多少钱?哪个业务方最费钱?哪些调用是浪费的可以砍掉?
我现在每天开盘第一件事就是看前一天的token消耗报表,哪些业务在刷量、哪些功能夜间请求异常,一眼就能看出来。这在散养模式下是根本做不到的。
2. 系统整体设计:从零搭一个LLM API网关
2.1 核心模块划分
整个系统我是按网关的思路设计的,没有把逻辑塞进单个服务里,而是拆成了六个清晰的模块。这样每个模块职责单一,后续扩展也不怕牵一发动全身。
| 模块 | 职责 | 关键技术点 |
|---|---|---|
| 接入层 | 对外暴露统一HTTP接口 | RESTful/SSE流式转发 |
| 鉴权模块 | API Key校验、权限控制 | JWT+本地Key双轨 |
| 路由模块 | 模型名映射、上游选择 | 权重路由、健康检查 |
| 治理模块 | 限流、配额、熔断 | 令牌桶算法、滑动窗口 |
| 计量模块 | Token统计、费用计算 | 按模型单价实时累加 |
| 审计模块 | 请求日志、调用链路 | 结构化日志、Trace ID |
2.2 设计选型背后的考量
技术栈我当时选的是Java + Spring Boot 3 + PostgreSQL + Redis,原因有三条。
第一,团队主力语言是Java,Spring Boot生态成熟,做网关类项目有现成的WebFlux能用,但这里我特意用了Servlet栈而不是WebFlux。为什么?因为LLM API的SSE流式转发在Servlet栈里实现起来更直观,而且我们大部分调用是同步请求,WebFlux的响应式模型对团队心智负担太大了。
第二,PostgreSQL存计量数据。其实最初考虑过用ClickHouse或者MongoDB,但考虑到项目里还要存用户、应用、Key等关系型数据,不想维护两套存储,就统一用PG了。事实证明,每天几十万条token计量记录,PG配合按天分区表完全扛得住,没必要引入更重的组件。
第三,Redis在这里作用很重:限流计数、令牌桶桶令牌、分布式锁、热点Key缓存都在它上面。注意限流一定要用Lua脚本保证原子性,不然并发一高就超发。
2.3 为什么不上APISIX这类开源网关
有朋友问我:这不就是API网关吗?直接用APISIX或者Kong不行吗?我的回答是:能用,但不划算。通用网关确实能做转发、限流、鉴权,但LLM API有太多专用逻辑是通用网关不具备的:
- Token级计量计费:普通网关最多按请求次数计数,而我们按
prompt_tokens + completion_tokens精确到每笔请求计费。 - 模型名动态映射:路由规则不只是路径匹配,还需要根据模型名、优先级、成本策略做多级路由。
- 上游健康状态感知:LLM服务商挂掉是常态,网关要能自动感知并切换备用通道。
这些逻辑硬塞进OpenResty或者APISIX插件里,开发调试效率远不如用业务语言写一个独立服务。通用网关适合统一流量入口,不通用来适配大模型这种有明显领域特点的协议。
3. 核心实现细节:路由、鉴权与限流
3.1 统一路由与模型映射
路由模块是整个系统的心脏。它的逻辑很简单:业务方在请求体里传model字段,系统根据配置找到对应的上游渠道列表,按优先级和权重选一个,然后把请求转发过去。
先看统一请求模型怎么设计。我用的是OpenAI协议的超集,业务方传的请求大概长这样:
{ "model": "", "messages": [{"role": "user", "content": "Hello"}], "stream": false, "temperature": 0.7, "max_tokens": 2048 }重点是model字段不用传具体服务商的名字,而是传业务自定义的模型别名。比如"model": "chat",系统自动把它映射到deepseek-chat,权重70%走DeepSeek,30%走GLM-4-Flash降本。
核心路由伪代码如下:
public RouteTarget route(String modelAlias, String user, int estimatedTokens) { ModelRoute route = modelRouteTable.get(modelAlias); if (route == null) { throw new BizException("MODEL_NOT_FOUND", "模型别名不存在: " + modelAlias); } List<Upstream> candidates = route.getUpstreams().stream() .filter(up -> up.isHealthy()) .filter(up -> up.remainQuota(user) > estimatedTokens) .sorted(Comparator .comparing((Upstream up) -> up.priority) .thenComparing(up -> up.weightRandom())) .collect(Collectors.toList()); if (candidates.isEmpty()) { throw new BizException("NO_AVAILABLE_UPSTREAM", "模型" + modelAlias + "暂无可用的上游服务"); } return new RouteTarget(candidates.get(0), route.needRecordUsage()); }这里有几个细节值得展开说:
第一,权重随机。我实现的是加权随机而不是轮询。为什么?因为LLM请求响应时长差异太大(有的请求3秒,有的30秒),轮询会在某次慢请求上堵住后续流量,加权随机配合健康检查能更平滑地把负载分散开。
第二,健康检查用被动探测。系统不主动发心跳,而是把每次请求的响应状态记录下来,连续失败N次就摘除该上游,过一段时间再放回去试。主动心跳对LLM服务商来说负担大,而且很多服务商根本没有健康检查接口。
第三,按预估token数前置过滤。estimatedTokens可以从消息字数粗略估计,如果某条渠道剩余配额不足以支撑这次请求,直接跳过,避免请求发过去才被上游拒绝。
3.2 鉴权与密钥管理
统一管理后,业务方拿到的不再是各家模型的Key,而是我们自己签发的AppKey。格式类似ak_6f8c34a2e1d9f0b3,一个Key对应一个应用。
每个应用可以配置:
- 可访问的模型范围(比如只让某个应用调
chat,不允许调embedding) - 每日配额上限(按token或者按金额)
- 所属业务线,用于计量拆账
- 状态开关(紧急熔断用)
鉴权流程不复杂:从请求头取Key,查Redis缓存,校验状态和权限,然后进入限流环节。Redis里的Key缓存设置5分钟过期,被禁用后最多5分钟生效,这个时间窗口可以接受。
我额外做了一个小功能:子Key机制。一个应用可以签发多个子Key,每个子Key挂在不同团队成员或不同环境上。这样万一某个Key泄露,只需要吊销子Key即可,不用把整个应用的Key换掉。
3.3 限流与配额控制
限流这块我用了两把尺子:请求频率限流和Token额度限流。
请求频率用的是令牌桶,每秒补充速率和桶容量按应用维度配置,实现用Redis Lua脚本保证原子性:
-- KEYS[1] = rate, KEYS[2] = capacity, KEYS[3] = now local key = KEYS[1] local rate = tonumber(ARGV[1]) local capacity = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local tokens = tonumber(redis.call('HGET', key, 'tokens') or capacity) local last = tonumber(redis.call('HGET', key, 'last') or now) local delta = math.max(0, now - last) tokens = math.min(capacity, tokens + delta * rate) if tokens < 1 then return 0 end redis.call('HSET', key, 'tokens', tokens - 1) redis.call('HSET', key, 'last', now) return 1Token额度限流的做法不太一样。由于token数只有在请求完成后才知道,所以我的策略是:请求前按预估token预占额度,请求完成后按实际使用量回补/扣减。比如某个应用每天额度100万token,一次请求预估消耗2000,就先扣2000;实际返回用了1500,再补回500。预占不是绝对准确的,但能有效防止突发流量把额度瞬间打穿。
配额超限时返回429状态码,业务方可以根据这个状态码做自己的降级逻辑。这里有个教训:限流状态码一定要和错误码分开。我见过有系统把限流也返回500,导致业务方无法区分是服务端故障还是配额不足,最终只能往上游疯狂重试,反而加重了系统压力。
4. 计费、审计与可观测性
4.1 Token计量与按量计费
LLM API统一管理系统的另一大核心是计量计费。这个不搞好,前面那些治理规则全都落不了地。
我的实现思路是:在路由模块拿到上游响应后,统一从响应体里解析usage字段,然后把计量事件异步发到消息队列,由计量服务批量写入数据库。
为什么走消息队列而不是同步写库?因为大流量下同步写库会拖慢主链路响应。一次写操作按5ms算,每秒1000并发就有5秒的写负载,虽然PG能扛,但不值得冒这个险。
计量数据结构长这样:
CREATE TABLE usage_record ( id BIGSERIAL PRIMARY KEY, trace_id VARCHAR(64) NOT NULL, app_key_hash VARCHAR(64) NOT NULL, model_alias VARCHAR(64) NOT NULL, upstream_model VARCHAR(64) NOT NULL, prompt_tokens INT NOT NULL DEFAULT 0, completion_tokens INT NOT NULL DEFAULT 0, total_tokens INT NOT NULL DEFAULT 0, cost_amount DECIMAL(12, 6) NOT NULL DEFAULT 0, request_time TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_usage_time ON usage_record (request_time DESC); CREATE INDEX idx_usage_app ON usage_record (app_key_hash, request_time DESC);费用计算规则维护在另一个模型单价表里。每个上游模型都有一个单价,按百万token计费。单价表要支持按时间段生效,因为服务商经常调价,我不能每调一次价就改代码。
手动调过一轮之后我发现,计费精度要求其实没想象中那么高。小数点后四位足够,关键是口径要一致。有的服务商按prompt_tokens和completion_tokens分开计价,有的是一个打包价。统一到同一个口径后,报表上看到的数据才有可比性。
4.2 全链路审计日志
调用审计这块我曾经犹豫过:要不要记录完整的请求和响应内容?记录的话,涉及用户隐私和数据合规;不记录的话,排查问题少了关键证据。
最终的方案是:默认只记录元数据,不记录body。元数据包括:traceId、appKey哈希、请求模型、上游模型、耗时、输入输出token数、HTTP状态码、错误信息(截断到200字符)。
针对线上问题诊断,我在响应头里加了一个X-Trace-Id,业务方带着这个ID来找我,我可以反查到当前这条请求的所有元信息。如果需要body级别的采样,系统支持按1%概率抽样保存,用于模型质量分析,但不会默认全量存。
还有一个细节:日志里不要记录用户的敏感信息,比如prompt里如果带了身份证号、手机号之类的内容,审计日志会变成数据泄露源。第一次设计时我就把prompt的内容直接丢了,只保存哈希值用于去重分析,安全很多。
4.3 指标监控与告警
可观测性三个支柱:日志、指标、链路追踪。日志是审计模块做的,指标这块用Prometheus + Grafana,链路追踪通过TraceId串联。
我在代码里暴露的指标主要有:
llm_proxy_requests_total(按应用、模型、状态码分类的请求计数)llm_proxy_request_duration_seconds(响应延迟直方图)llm_proxy_tokens_total(token消耗计数,按应用分类)llm_proxy_upstream_failures_total(上游调用失败计数,按上游地址分类)llm_proxy_limit_reject_total(限流拒绝次数)
告警规则我设置了三类:
- 上游错误率5分钟内超过20%,告警「上游可能故障」
- 单应用请求量5分钟内翻了三倍,告警「疑似异常流量」
- 系统整体错误率超过5%,告警「入口异常」
这套指标上线后,我最直观的感受是:以前线上有问题靠用户反馈,现在基本靠告警主动发现。有一次凌晨某个服务商限流,我们比上游的通告邮件更早感知到异常,提前切了备用渠道,一点都没影响到业务。
5. 高可用与容错:超时、重试与降级
5.1 超时与重试策略
LLM服务商的可信度,说实话不太行。超时、连接中断、半路返回错误码,这些情况我全遇到过。所以系统的高可用能力不是可选项,而是必选项。
先设置超时。总超时必须分两段:连接超时和读超时。连接超时通常设置5秒,读超时根据模型和场景来:
- 普通对话模型:30秒
- 长文本生成模型:120秒
- 流式请求:不断流阈值60秒(超过60秒没有新的SSE分片则视为超时)
读超时设太久有个副作用:如果上游彻底挂了,请求会在我们的线程池里堆积,最终拖垮网关自身。所以读超时设完,还必须配套线程池隔离。我用的是信号量隔离,每个上游渠道一个独立信号量,超过阈值直接拒绝新请求,不让故障扩散到其他渠道。
重试策略这块,我的经验是只有幂等请求才能安全重试。LLM调到一半连接断了,上游可能已经完成了这轮生成,重试会导致重复消耗token,还会让结果不一致。所以:
- 超时类的错误:不重试,直接返回502,由业务方决定要不要换模型重新生成。
- 429限流错误:如果上游有Retry-After头,按它等;没有则退避重试,最多2次。
- 网络连接错误(比如连接被拒绝):安全重试一次,因为请求大概率没有到达上游。
- 5xx错误:默认不重试,但可以配置为切到备用渠道重试一次。
5.2 故障转移与降级
前面提到健康检查和权重路由,这里展开讲运维上的自动化切换。
我给每个模型别名配置了多个上游渠道。当主渠道连续失败超过阈值时,路由模块自动把它标记为不健康,请求全部转到备用渠道。实现上用了一个简单的状态机:
HEALTHY -> (连续3次失败) -> DEGRADED -> (失败继续累加) -> DOWN DOWN -> (冷却时间到,探活成功) -> HEALTHY这三个状态各自对应不同的行为:
- HEALTHY:参与路由加权
- DEGRADED:降低权重(比如从70%降到10%),保留少量真实流量做探活
- DOWN:不参与路由,冷却5分钟后自动转为DEGRADED探活
这套机制上线后,一个很典型的效果是:主渠道服务商半夜公告升级,我们完全无感,流量自动都走到了备用渠道上。
还有一个降级策略也在配置里:成本降级。当主渠道价格上调时,可以把一部分流量自动切到更便宜的低优先级模型上。比如chat别名下面配了三个渠道:精准增强型(贵)、标准型(中)、精简型(便宜),按不同业务方或不同请求类型路由到不同档位。
6. 部署落地与避坑清单
6.1 环境准备与部署步骤
整套系统部署其实不复杂,依赖就是数据库和Redis。我用Docker Compose一键启动:
version: "3.8" services: llm-gateway: image: llm-gateway:1.0.0 ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: prod DB_URL: jdbc:postgresql://postgres:5432/llm_gateway REDIS_HOST: redis depends_on: - postgres - redis postgres: image: postgres:16-alpine environment: POSTGRES_DB: llm_gateway POSTGRES_USER: gateway POSTGRES_PASSWORD: change_me volumes: - pgdata:/var/lib/postgresql/data redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: pgdata:服务起来之后要做四件事:
- 初始化数据库表结构(项目里带了一个init.sql,直接执行)。
- 在控制台里创建一个应用,拿到AppKey。
- 配置模型路由表,把模型别名绑定到上游服务商。
- 用curl验证一个最简单的对话请求通不通。
6.2 常见问题与排查速查表
这部分是我实际运行中遇到的典型问题和排查思路,直接整理给大家参考。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
请求返回400,报错thinking_budget parameter must be a positive integer | 请求参数里thinking_budget传了非正整数 | 在网关层增加参数校验,捕获非法参数并返回友好错误 |
请求返回400,报错maximum context length is 1048576 tokens | 输入messages太长,超过模型上下文窗口 | 网关层估算token数,超限提前拒绝并提示截断 |
| 请求返回403 | 上游鉴权失败,Key错误或已被禁用 | 检查路由配置的密钥是否过期,查看审计日志确认走的是哪条上游 |
连接中途断掉,报connection lost mid-response | 上游服务不稳定,或请求超时被断开 | 查询该时间段的upstream错误率和响应延迟,对应调整超时策略 |
| 限流不生效,高并发时照样打爆上游 | Lua限流脚本有问题,或限流Key维度设置不对 | 核对Redis中的限流计数是否在增长,检查是否按应用维度生效 |
| 某条渠道失败但流量仍然打过去 | 健康检查失败阈值太高,冷却时间太长 | 查看该上游的状态机,适当调低失败阈值 |
6.3 动手做这套系统要注意的几个坑
最后整理几个我在开发过程中踩过的坑,给要动手做的人提个醒。
第一个坑:SSE流式响应做不对,监控数据全是空的。流式请求的计费和指标收集跟普通请求完全不同,因为响应是分片的,usage字段一般被放在最后一个分片里。如果网关层直接透传SSE流,不做拦截和聚合,计费模块就永远拿不到token数。我的方案是把SSE流在网关层做一次“解包-处理-再打包”,虽然多花一点CPU,但每个分片经过网关时都能被观测到。
第二个坑:不要把限流和实际上游的限流混为一谈。我们自己的限流是保护自己,上游也有自己的限流。即使你感觉单应用请求不算多,一旦上游某个渠道的共享配额被打满,所有走该渠道的应用都会一起遭殃。所以路由模块要增加一个上游维度总流量限流,防止某个应用把一条上游渠道打满。
第三个坑:模型别名映射表一定要做版本管理。我一开始用配置文件管理模型路由,改一次要重新发布。后来发现一天可能要改好几次(上游加模型、调权重、换Key),配置文件完全不够用。最后把路由配置挪到了数据库里,控制台可视化编辑,改了实时生效。这个改动直接省了运维一半的时间。
第四个坑:Key加密存储,别明文落库。上游服务商的API Key是敏感信息,存数据库时必须加密,而且要在代码里脱敏。日志里打印上游Key只有前四位和后四位,中间打码。这不仅是安全规范问题,也是出了问题能不能追责的问题。
最后说一点体会
做这套系统,前期最花时间的不是代码,而是想清楚“统一到什么程度”。我把所有上游接口都包装成OpenAI协议,看起来是省事了,但也丢掉了一些上游特有的能力,比如某些模型支持多模态输入,某些支持特殊的推理参数。如果一开始就想面面俱到,系统会变得极其复杂,根本推不动。先统一核心的对话和Embedding能力,其他能力后续通过扩展字段透传,这是我认为最务实的路径。
还有个小技巧我可以分享:在做成本治理时,不要只盯着模型单价。实际上,请求体里的max_tokens设置对费用的影响往往比模型选型更大。很多业务方习惯性把max_tokens调到4096甚至8192,但实际输出只有几百token,这是极大的浪费。我在网关里加了一个“最大输出token钳制”的功能,按业务方配置上限,比如普通对话不许超过1024,超了自动截断。上线这个功能后,当月的token费用直接降了30%左右,效果立竿见影。
本文还有配套的精品资源,点击获取