先说个背景吧。
我所在的小组主要负责给公司内部各种业务方提供 AI 模型接口,最开始只有 10 来个模型,大家各自调各自的,业务代码里塞了十几套 SDK,还能勉强运转。后来模型数量一路膨胀,不到两年涨到了 200+,而且大部分是不同云厂商、不同开源项目、不同微调版本的模型,有的走 HTTP 原生接口,有的走 gRPC,有的能流式返回,有的只能一次性吐完。到这个时候,我每天干得最多的事情已经不是写代码,而是帮业务同学查“为什么某个模型又超时了”“为什么同一个模型在两个渠道返回格式不一样”,以及最让人头疼的——“这个模型是不是已经下线了?业务代码里还在调它”。
于是项目就顺理成章启动了:为 200+ 个 AI 模型构建统一 API 网关。
这篇文章不打算讲那种“从零到一的高大上架构升华”,我尽量把真实的问题、选型逻辑、参数设计、线上踩坑都摊开来说。目标读者是那些正在被多模型、多渠道、多协议折磨的后端工程师,或者准备做模型接入平台、AI 基础设施的人。看完你至少能回答几个问题:为什么需要统一网关?网关内部到底分层分哪些?路由怎么做?限流怎么做?以及最关键的,当你接入第 201 个模型时,为什么新增成本可以低到“只改两行配置”。
下面是正文。
1. 为什么必须自己搞一个统一网关
先说结论:我们并没有直接从开源方案里挑一个拉起来就用,而是花了大量时间梳理需求,最终做了一套带着“模型归一化”能力的网关。核心原因在于:常规 API 网关解决的是“路由、鉴权、限流”,但 AI 模型接入场景里最痛的点,反而是“协议不一样、参数语义不一样、返回结构不一样”。
1.1 多个模型混用的真实痛点
我举一个很典型的例子。早期业务方调用模型,代码里大概长这样:
# 调模型A resp = model_a_client.chat( model="model-a-v2", messages=[{"role": "user", "content": "你好"}], temperature=0.7, ) # 调模型B resp = model_b_sdk.send( model="model-b", prompt="你好", top_p=0.9, max_new_tokens=512, )这两个还只是参数名不同,有的模型用temperature,有的叫temperature_scale;有的最大生成长度叫max_tokens,有的叫max_new_tokens;有的返回choices[0].message.content,有的直接返回data.text;有的错误信息里带 HTTP 状态码,有的所有异常都返回 200 然后把错误码塞在 body 里。
这不是接口风格差异,这是灾难。
当模型数量到 20 个的时候,业务方还能忍;到 100 个的时候,业务方直接拒绝在代码里再新接一套 SDK。他们不关心底层是哪个模型,他们只想要一个稳定的接口,传进去一段话,拿到一段回复。我们的核心任务就是:把上面这种混乱收敛成一个标准接口,所有底层差异由网关内部消化。
1.2 为什么普通 API 网关解决不了这个问题
我并不是说现成的 API 网关不好,而是它们大多数在设计时没有考虑“模型调用”的特殊性。通用网关擅长的是流量治理:路由到某个后端服务、灰度、限流、鉴权。这些特性我们也要,但它们只解决了外层问题。
内层问题有几个是 AI 场景独有的:
- 参数语义归一化:不同模型的
temperature、top_p、max_tokens虽然含义相似,但取值范围、默认值和行为存在细微差别。比如有的模型 temperature 支持 0 到 1,有的支持 0 到 2,直接透传会导致某些模型返回奇怪结果。 - 返回格式归一化:把所有上游响应统一成 OpenAI 兼容格式,是业务方最喜欢的一个设计。他们不用再关心上游是“文本直接返回”还是“choices 嵌套结构”。
- 流式协议差异:有的模型用 SSE 流式,有的用 WebSocket,有的原本不支持流式但我们要在网关侧做伪流式。这个后面专门说。
- 模型下线和替换:今天 A 模型还跑得好好的,明天因为生态原因要全部切到 B 模型。没有归一化层,这种切换只能靠业务方改代码,有了网关,只需要改一条路由规则。
所以我们的定位很明确:它不是传统 API 网关的替代品,而是在传统网关能力之上叠加了一层“模型语义适配层”。
2. 统一网关整体架构:核心是语义适配层
如果你现在打开我们网关的架构图,能看到下面这几层。我按从外到内的顺序讲,这样更好理解一次请求从进入网关到返回给用户,到底经历了什么。
2.1 整体分层概览
分层大概是这样的:
- 接入层:负责接收客户端请求,维护 HTTP/HTTPS 连接,支持 SSE 长连接。这一层只做最基本的连接管理、TLS 终止、header 解析,不碰业务逻辑。
- 解析与鉴权层:解析 API Key、识别租户、解析统一请求体,校验参数格式,做基础限流。
- 路由决策层:根据请求里的模型名,结合路由表和上游健康状态,决定转给哪个具体的模型实例。这一层还负责版本调度、灰度切流。
- 语义适配层:这是最核心的一层,它把统一协议的请求转换成上游模型自己的协议,再把上游返回转换成统一协议。参数映射、提示词模板注入、错误归一化都在这里。
- 出口转发层:维护到各个上游的连接池、超时控制、重试策略,负责把适配后的请求真正发出去。
- 可观测与计量层:记录 token 消耗、请求耗时、错误码、成本信息,输出指标和日志。
2.2 为什么把协议统一成 OpenAI 兼容格式
选择哪种协议作为对外统一协议,我们内部吵过几次。有人说直接用 gRPC,效率更高;有人说用自己的内部协议,不受外界影响。最后还是选了 OpenAI 兼容的 HTTP 协议。
原因特别现实:业务方对 OpenAI 的请求结构已经很熟了,外部生态里也有大把现成的 SDK、工具链可以直接用。我们的内部协议再优雅,业务方学习成本高,生态工具用不上,注定推广困难。
统一格式的关键路径是这样:
请求体标准格式: - model: 虚拟模型名,比如 qwen-max、maid-plus - messages: 消息列表,role + content - temperature: 0~2 浮点数,不传用默认 - max_tokens: 最大生成长度 - stream: true/false,是否流式 响应体标准格式: - id: 请求ID - choices: [{message: {role: "assistant", content: "..."}, finish_reason: "stop"}] - usage: {prompt_tokens, completion_tokens, total_tokens} - model: 实际被路由到的模型名所有上游返回,不管原本是 JSON 直接返回还是 lines 分割,最终都要映射到这个结构。这样对外部来说,200+ 个模型只是一个 model 字符串的区别,请求和响应结构完全一致。
2.3 适配器模式:新增一个模型为什么只要写配置
这里要展开讲一个核心设计:Provider Adapter。
网关内部不允许出现“if 模型名 == 'xxx' 则处理”这种代码。因为 200+ 个模型如果写成几百个 if,代码会膨胀到完全没法维护。我们用的是适配器模式:每种上游协议风格,对应一种适配器实现;模型之间的差异通过配置项来完成,而不是靠写新代码。
对于不同渠道,我们抽象了以下基础接口:
build_request(model_config, unified_request):把统一请求转换成上游协议请求。parse_response(model_config, upstream_response):把上游响应解析成统一响应。build_stream_request(...)和parse_stream_chunk(...):处理流式情况。map_error(...):把上游错误码映射成统一错误码。
如果你要新接入一个风格完全一样的模型,比如同一家云厂商的新模型,很可能只需要在配置中心添加一条模型映射记录,不用写任何代码。只有当上游协议出现新风格时,才需要新增一个适配器实现,这大概是我们能支撑 200+ 模型的底气。
3. 路由层设计:模型名背后的虚拟映射
路由层是整个网关容易被低估的部分。很多人觉得路由就是把 model 字段直接拿来转发,但实际上,我们用的是一套“虚拟模型名 + 路由策略”的设计。
3.1 虚拟模型名与实际模型实例解耦
业务方看到的 model 名是一个虚拟模型名。它不是某个物理模型的标识,而是一条路由规则的入口。
举个例子:
route: - name: "chat-zh" # 业务方看到的虚拟模型名 priority: 1 strategy: weighted # 权重策略 targets: - provider: "aliyun" model: "qwen-plus" weight: 70 - provider: "self-host" model: "qwen-14b-gptq" weight: 30 fallback: - provider: "openai" model: "gpt-4o-mini"当一个请求带着model=chat-zh进来时,网关会根据当前权重和目标可用性,分派到两个实例中一个。如果这两个实例都失败,再降级到 fallback。业务方完全感知不到这个灰度过程。
这种设计给我们的运维带来了极大弹性。比如某厂商模型定价突然上调了,我们想切一部分流量到自建模型,只需要调整一下权重,不需要通知业务方改任何代码。这在直接调用上游的模式下是做不到的。
3.2 路由决策不只看权重,还要看可用性
权重只是静态配置,线上运行还要考虑目标上游是否健康。我们在路由决策层维护了一张“上游健康状态表”,由后台定时探活任务更新。每次路由时,如果某个目标连续失败次数超过阈值,会在短时间内自动摘除,直到探活恢复。
摘除逻辑借鉴了熔断的思路:
- 连续失败 5 次,标记为不健康;
- 进入探测期,每分钟探测一次;
- 探测成功恢复到健康状态;
- 从摘除到恢复,全程业务无感。
这套机制曾经帮我们扛过一次很大的事故:某个外部模型在下午突然开始大面积超时,路由层在几分钟内自动把流量摘走,切到了备用通道。业务方只看到 P95 延迟稍微涨了一点,没有看到大面积报错。
4. 流式响应处理:200+ 模型里最容易被忽略的硬骨头
统一网关开发周期里,流式处理消耗的时间比想象中多得多。流式背后的坑太细了,不做大规模接入根本意识不到。
4.1 三种流式协议的统一
上游流式风格主要有三种:
- OpenAI 风格 SSE:按
data: {json}分隔,最后发data: [DONE]。 - Claude 风格 SSE:事件类型很多,有
message_start、content_block_delta、message_delta等。 - 普通增量文本:没有标准事件格式,直接吐文本片段。
我们在适配层做了一层“统一流式语法”:网关向上游发送流式请求时,根据适配器转换为对应协议;上游返回流式数据后,适配层把每个 chunk 归一化为:
{"choices": [{"delta": {"content": "片段"}, "finish_reason": null}]}然后由网关接入层对外统一输出 SSE 格式。
这里有一个特别容易踩的坑:SSE 格式中换行符和空行不能随便处理。很多上游会在流式数据里杂带注释行,或者把多个 JSON 拼在同一行,如果接入层直接按行切割,就会出现解析失败。我们最终实现的是一个相对健壮的 SSE 解析器,核心逻辑是按事件类型区分数据行和注释行,并且兼容 CRLF 和 LF。
4.2 流式转发里的背压与缓冲
流式接口的另一个难点是背压。上游生成速度是我们无法控制的,如果下游客户端读取速度慢,网关如果无脑把数据往连接里写,内存会被拉满。
我们用了两套策略:
- 缓冲区上限:每个流式响应连接维护一个有限缓冲队列,超限时暂停从上游读取,让上游自然阻塞。
- 客户端断连感知:如果客户端断开,必须立刻取消上游的流式请求,而不是继续读完整个生成。
这两个策略看着简单,但实现细节很磨人。尤其是 Go 语言场景下,要特别小心 goroutine 泄漏:客户端断连后,上游 stream 的终止信号没有及时传递,导致后台一直在读数据,直到把上游的生成流程读完才释放连接。这个问题在高峰期可能表现为连接数暴涨、内存持续增长,排查起来非常隐蔽。
4.3 伪流式:上游不支持流式也得让它“能流式”
还有一批上游模型压根不支持流式,但业务方的需求是:“所有接口都要支持流式输出,产品交互统一。”
我们做了伪流式方案:网关向上游发送非流式请求,拿到完整文本后,模拟成多个带延迟的 SSE chunk 一个个推给客户端。这样产品体验没问题,但代价是首字延迟变高了,因为必须等上游完整生成完才开始推送。
这个方案的取舍很清晰:
- 对延迟不敏感的场景(如写邮件、生成离线文案),完全可用。
- 对实时交互场景(聊天对话),建议还是接原生流式。
- 所有伪流式请求必须在指标上打上
fake_stream=true的标签,避免和真流式数据混在一起造成评估偏差。
5. 鉴权、限流与配额:多租户场景怎么做才不翻车
统一网关一旦被多个业务方共用,权限和配额就不再是一个简单维度。我们踩过很多坑,下面说几个关键设计。
5.1 API Key 与租户隔离
每个业务方通过网关的 API Key 标识。同一租户下可以创建多个 Key,支持不同权限范围,比如有的 Key 只能访问模型 A,有的 Key 只能访问模型 B。
实现上用中间件统一处理:解析 Key -> 查租户 -> 加载权限 -> 绑定上下文。这里唯一的忠告是:权限校验结果一定要缓存,而且要设置合理过期时间。一开始我们每个请求都实时查数据库,压测时数据库直接成为瓶颈;后来改成本地缓存 + 分布式缓存两级,权限变更最多 1 分钟生效,性能提升了不止一个量级。
5.2 限流的粒度:不只有 QPS
AI 场景的限流和普通 API 限流差别很大,因为一个请求可能持续几十秒,且消耗的资源不是固定值。我们最终采用了多维限流:
| 限制维度 | 单位 | 说明 |
|---|---|---|
| QPS | 个/秒 | 限制每秒请求次数 |
| 并发数 | 个 | 限制同时在途的请求数 |
| Token 速率 | tokens/分钟 | 限制每分钟消耗的 token 总量 |
| 成本配额 | 元/天 | 限制每天的调用成本上限 |
每一个维度单独计数,任何一个维度超限都会触发拒绝或排队。特别是成本配额,这个一开始我们没有做,结果某个业务方的脚本跑飞,一次性调用了大量高价格模型,账单一出来我们都傻眼了。
限流算法我们用的令牌桶,支持平滑突发流量。比如 QPS 限流 100,允许短时间冲到 150,但如果持续超限就会开始拒绝。为什么不用固定窗口?固定窗口在窗口边界容易产生双倍突发,令牌桶更平稳。
5.3 配额超限后的降级提示
限流触发后的错误信息必须足够清晰,不能只给一个 429 状态码。我们要告诉调用方是哪个维度超限了。比如:
{ "error": { "code": "rate_limit_exceeded", "message": "concurrency limit exceeded", "detail": {"limit": 20, "current": 21, "reset_after": "30s"} } }这样业务方才能自动化处理,比如重试或者降低并发。很多网关限流只给一个笼统的 429,用户看一眼完全不知道怎么办,这是体验的败笔。
6. 可观测性:没有多维指标,根本无从维护 200+ 模型
如果网关只有一两个接口,随便打打日志就够了。但面对 200+ 个模型,可观测性直接决定了你能不能快速定位问题。
6.1 核心指标维度
我们最终沉淀下来一套“四维指标”:模型维度、渠道维度、租户维度、错误维度。
- 模型维度:虚拟模型名、实际模型名,关注延迟、成功率、token 消耗。
- 渠道维度:按上游提供方聚合,关注可用性、超时率、流式中断率。
- 租户维度:按业务方聚合,关注 QPS、配额使用率、成本消耗。
- 错误维度:按错误类型聚合,关注参数错误、上游返回错误、限流拒绝、超时。
任何一个线上问题,我们第一反应就是打开看板,先按模型看有没有异常,再按渠道看是不是某个上游在抖动,再按租户看是不是某条业务线流量异常。这套路径几乎能覆盖 80% 的日常事故定位。
6.2 token 计量为什么那么重要
token 计量不能依赖上游返回的 usage 字段,因为有些上游不返回完整 usage,有些模型返回的 token 数明显不对。我们统一采用“自己估算 + 上游校准”的模式:
- 请求侧:用 tokenizer 对输入 messages 做估算,记录 prompt_tokens。
- 响应侧:优先用上游返回的 completion_tokens,如果没有,用字符数除以一个经验系数估算。
- 成本侧:根据单位 token 价格换算成本,按租户、模型、时间聚合。
这套数据对技术运营太重要了。比如某租户这个月成本涨了 30%,我们可以很快看到底是哪个模型涨了、哪个时间窗口涨的、是调用量涨了还是价格涨了。没有这层统计,财务对账就是一笔糊涂账。
6.3 链路追踪与日志采样
网关内部一次请求会经历接入、鉴权、路由、适配、转发、响应六个阶段,任何一个阶段出问题都需要能快速定位。我们给每个请求分配了唯一的 request_id,并在全链路日志中携带这个 ID,所有阶段日志都打上去。
日志采样分两层:所有限流拒绝和错误请求全量记录,正常请求按 1% 比例采样。切忌正常请求也全量打日志,流量一上来磁盘 IO 和日志系统的压力会非常恐怖。
7. 稳定性建设:多级降级和容灾预案
200+ 个模型里,总有几个上游不够稳定。我们把稳定性建设拆成多级降级链,让不同级别的故障对应不同策略。
7.1 下游模型故障时的路由降级
这个在路由层说过。核心流程是:
- 当主目标连续失败次数超阈值,自动触发熔断;
- 熔断后进入降级路由,优先选同渠道同系列的备选模型;
- 如果备选也不可用,再降级到跨渠道模型;
- 如果全部失败,返回统一的 502 错误,并且错误信息里带上“当前模型均不可用,请稍后重试”。
为了避免降级逻辑把故障扩散,降级路由的尝试次数有限制,最多试两个备选,再失败就直接返回,防止请求耗时被拖到十几秒。
7.2 模型下线时的动态迁移
AI 模型的生命周期比传统服务短得多,经常有模型发布新版本、旧版本强制下线的情况。如果某个旧模型被上游强制下线,而我们还在往它那里发请求,会持续报错。
我们备了一套“模型下线迁移”的流程:先通过配置把该模型从路由表中标记为deprecated,同时设定一个过渡用的目标模型映射关系,比如旧版 all-migrate 到新版。在过渡期内,旧模型名依然可用,但实际流量已经全部转到新模型。业务方完全无感,等旧模型名彻底没有流量后,再从代码中移除。
7.3 连接池与超时参数
连接池参数需要反复压测。我们最终用的几个关键参数是:
- MaxIdleConnsPerHost: 64
- MaxConnsPerHost: 256
- 客户端拨号超时: 5s
- 上游响应头超时: 30s
- 整体请求超时: 分场景设置
不同模型的响应时间天差地别,有的模型 2 秒就出完了,有的模型可能要 60 秒。给所有模型一个固定的 30 秒超时会导致长任务模型大量失败。我们的做法是按模型类型设置超时模板,比如对话类 60 秒、向量化类 10 秒、图像生成类 120 秒。
这里有一个血泪教训:一开始我们把整体超时设得太大,当上游模型假死时,大量请求挂在网关这边占着连接不释放,最终把整个网关的连接池打爆,后面所有正常请求都被阻塞。后来我们把超时拆成“等待响应头超时”和“整体响应超时”两个维度,假死情况很快就能在上游响应头阶段被识别并掐断。
8. 踩过的坑:线上事故记录与排查思路
写完上面这些,我觉得还应该把这两年遇到的几个典型问题完整记录下来,这些问题单靠看文档是碰不到的。
8.1 上游返回非标准 JSON,解析直接崩溃
现象:某个模型偶尔返回的流式 chunk 里混了业务原始文本,文本里含换行符、注释行,直接导致我们的 SSE 解析器崩掉,后续所有 chunk 都无法解析,最终客户端只能拿到半截回复。
排查:先抓现场报文,发现不是我们的解析器不兼容,而是上游根本没有按标准 SSE 协议输出。有些自建模型在生成过程中会打印调试日志到标准输出,这些日志顺着流式响应混进了 data 通道。
解决:解析器兼容“非 JSON 数据行直接跳过”,同时对不完整 JSON 的 chunk 做缓存拼接,等一个完整 JSON 出现后再解析。这能兜住大部分非标准输出。
8.2 gRPC 上游并发不足导致雪崩
现象:压测时发现某个自建 gRPC 模型服务的 P99 延迟从 200ms 一路涨到 5s,随后大量请求超时。
排查:看网关指标发现连接池连接数很高,但服务端 p95 延迟也高。最后定位到是 gRPC 服务端的最大并发流数量限制,客户端一高并发就排队。
解决:在适配层给 gRPC 上游加并发信号量,限制同时发往该服务的请求数,宁可让客户端排队,也不要让上游服务被压垮。类似的做法我们在很多模型服务上做了适配,避免单个模型影响全局。
8.3 客户端断连后网关还在继续生成
现象:有业务方在用户取消对话后直接关闭连接,但网关日志显示上游那个模型调用仍然跑了很久,token 还在继续涨。
排查:SSE 转发逻辑中,忽略了客户端连接关闭事件,没有立刻向上游发送取消信号,导致上游继续生成完整个回复。
解决:在流式转发时监听客户端连接上下文,一旦context.Context被取消,立即向上游发出一个cancel请求,并关闭对应的上游流式连接,阻断 token 继续消耗。这个问题既是性能问题也是成本问题,后面做了专项测试。
8.4 限流参数太紧,误伤正常业务
现象:某个租户反馈部分请求报 429,但我们看他们的 QPS 远没有达到限制。
排查:发现并发数和 token 速率两个维度同时生效,租户平时有大量长请求,并发数很高;大量长请求同时运算导致 token 消耗速率瞬时突增,触发了 token 维度限流。
解决:把维度之间的触发关系改成“任一维度超限则只对该维度告警,连续超限超过 N 秒才真正拒绝”,拉长容忍窗口,避免瞬时波动直接拒绝请求。长期来看,还是需要按租户的业务形态做个性化配额设置,不能一刀切。
9. 常见问题速查表
| 问题 | 可能原因 | 快速排查方法 | 解决方案 |
|---|---|---|---|
| 某个模型频繁超时 | 上游服务不稳定 | 看该模型渠道延迟指标 | 开启自动熔断降级到备用模型 |
| 流式回复半截 | SSE 解析失败 | 抓原始报文流 | 解析器支持非标准行跳过 |
| 客户端断连后还在计费 | 取消信号未传递 | 看流式连接释放日志 | 监听 Context,立即向上游发送取消 |
| 请求偶发 429 但 QPS 不高 | 并发数或 token 维度超限 | 看限流拒绝详情 | 调整维度阈值和容忍窗口 |
| 新增模型接入成本高 | 无适配器 | 看该模型协议类型 | 优先写配置,其次写适配器 |
| token 计数不准确 | 上游 usage 缺失 | 对比 token 估算值 | 自研估算 + 上游校准 |
| 上游假死堵塞网关 | 超时设置不合理 | 看响应头耗时指标 | 拆分响应头超时和整体超时 |
10. 一点个人体会
如果你问我,构建 200+ 模型统一 API 网关,最重要的东西是什么,我会说不是技术栈选得多新、不是结构设计得多炫,而是“语义适配层”这个东西要做得足够抽象。它才是所有模型差异的缓冲区,也是后续无限扩展的立足点。只要这层能保持稳定,哪怕明天接入的新模型风格完全不一样,我们也只需要新增一个适配器实现,而不需要改任何对外协议。
另一个体会是,网关这类中间件,永远不要追求“一步到位”。我们的第一版也很简陋,路由表甚至放在 JSON 文件里。但是边接模型、边踩坑、边优化,架构是在演进过程中自然长出来的。与其一开始就设计一个完美但没法落地的方案,不如先把一条链路跑通,再逐步把适配层、路由层、可观测层一层层加厚。
踩过这么多坑之后,我现在的原则很简单:对外,协议永远保持统一;对内,差异全部隔离在配置和适配器里。剩下的事,就是让模型们在网关后面安安静静地干活,别让业务方费心去理解它们各自的小脾气。
这也算是这些年最值得分享的一点经验吧。