资讯详情

资讯详情

从3个到200+模型:企业AI模型统一网关架构实战剖析

刚把公司内部的 AI 模型网关从 3 个模型的“玩具”逐步演进成支撑 200 模型的统一接入层时我最大的感受不是“爽”而是“当初怎么没早做”。在 2024 年之前你根本想象不到一个企业内部会同时存在多少个供应商、多少种模型形态——开源对话模型、商业大模型、图像生成模型、语音识别模型、向量化模型、多模态理解模型每个都有自己的 API 风格、鉴权方式、限流策略、计费逻辑和错误码体系。如果让每个业务方直接对接这些模型任何一个上游的细微变化都会导致下游连锁修改。这篇架构剖析我会把整个统一 API 网关的设计思路、核心模块实现、性能优化手段、200 模型接入的工程化流程以及我在实操中踩过的坑一条条讲清楚。这一篇适合谁看后端工程师、平台架构师、正在搭建内部模型平台的同学还有那些手里已经接了十几个模型、正在纠结要不要做统一层的人。内容偏工程实践我会尽量把每个决策背后的“为什么”也讲出来。1. 为什么非要为 200 个 AI 模型做统一网关1.1 模型满天飞接入成本失控先说个真实场景。最开始我们内部只有三五个模型客户端直接调供应商接口勉强能跑。但随着业务侧需求越来越多模型数量快速膨胀到几十个这时候问题开始失控。每个供应商的接口风格完全不一样。有的用 OAuth2 动态令牌有的用静态 API Key有的还要在请求头里塞特殊签名请求体有的兼容 OpenAI 格式有的是私有 JSON-RPC有的甚至传的是二进制流返回内容有的是标准 JSON有的是 SSE 流式还有的是 MessagePack 压缩格式错误码更离谱同样一个“上下文过长”有的返回 400有的返回 422还有的返回一个 200 但 body 里塞了个 error 字段。最要命的是客户端每接一个新模型都要改一遍代码。A 业务方接对话模型B 业务方接图像模型C 业务方接向量模型底层 SDK 各写各的公共逻辑全部重复造轮子。我记得有一个客户端项目为了切换模型供应商两个月内发了 22 个版本每次都是改 URL、改鉴权头、改超时参数。这种团队协作成本完全不可接受。所以统一网关的核心目标很清楚切断“模型数量”和“客户端复杂度”之间的正比关系。无论内部接入 10 个还是 200 个模型客户端始终面对一套面向上游的 API新模型接入只需要在平台侧完成一次适配。1.2 网关到底管了哪些事一个 AI 模型网关听起来只是个“反向代理”但真的落地时会发现它要管的事远超预期。我梳理下来至少包含八个方面动态路由、协议转换、鉴权与租户隔离、限流与排队、配额与计费、熔断与降级、可观测性、灰度与回滚。我用一个生活化的类比来理解如果没有网关每个业务方就像一个用户要记住十几个公司的所有分机号码有了网关你只需要拨总机接线员帮你转接、帮你确认权限、帮你记录通话时长和费用。总机就是网关分机就是上游 200 个模型。从数据面看我们的网关最终承载了日均约 8000 万次模型调用最长的是流式对话任务单请求时长能到 10 分钟以上。在这个量级下网关不能只是一个“转发壳”它必须有自己的稳定性设计和治理能力。下面这张图是我整个架构的核心逻辑我会在后面的小节逐一拆解。2. 整体架构设计与关键选型2.1 分层架构总览整个网关我采用了经典的四层结构接入层、路由层、适配层、上游连接层。接入层负责暴露统一 API处理客户端的 HTTP 请求、鉴权校验、参数校验路由层根据模型名、租户信息、灰度规则决定请求去哪里适配层是核心它把上游千奇百怪的协议和数据结构翻译成内部统一格式上游连接层管理连接池、超时、重试、熔断等出站动作。分层带来的最大好处是隔离。接入层完全不知道上游是 gRPC 还是 HTTP适配层完全不需要关心客户端是 Web 还是服务端调用。每层各司其职模型接入这件事就退化成了“写一个适配器 填一张配置表”而不是改整个链路。我设计时给自己定了四个硬性目标网关自身增加的延迟不超过 5ms不包含上游耗时新模型接入不需要发版网关可以独立于业务系统部署和扩容多租户之间的配额、账单互相隔离。2.2 协议转换层把一切变成 HTTP SSE内部模型的上游协议五花八门有 gRPC、HTTP、WebSocket甚至还有纯 TCP 私有协议。客户端不可能为每一种协议做适配所以我在接入层统一暴露 HTTP SSE。为什么选 SSE 而不是 WebSocket对于 AI 长文本生成这类“单向下发、断续返回”的场景SSE 实现成本最低浏览器、移动端、服务端都能很轻松解析WebSocket 虽然双向但多了一层连接管理且很多负载均衡器对 WebSocket 长连接支持不好。流式响应统一用 SSE 格式data: {id:chatcmpl-001,delta:你好,finish:false} data: {id:chatcmpl-001,delta:世界} event: done data: [DONE]网关内部把上游任意格式不管是 gRPC 的流、WebSocket 的消息还是普通 JSON 分片都翻译成上面这种标准 SSE 事件流。客户端不需要知道上游用的是哪家、什么协议只要读 SSE 就行。2.3 路由与模型抽象层配置驱动的模型注册表这是整个网关里我认为最重要的设计——配置驱动的模型注册表。在数据库里建了一张模型配置表一个模型一行核心字段大概长这样字段说明示例值model_name对外暴露的模型名text-chat-v3upstream_provider上游供应商标识provider_alphaupstream_model上游真实模型名alpha-chat-7bauth_type鉴权类型oauth2 / api_key / noneurl_template上游地址模板https://api.provider.com/v1/chattimeout_ms超时配置300000max_retries最大重试次数2rate_limit每秒请求上限100capabilities能力标签如流式、多模态stream, visionstatus启用状态active / inactivepriority路由优先级1客户端调用网关时比如 POST /v1/chat/completions带上 model_name。网关通过 model_name 查注册表拿到对应的上游信息再走路由决策。这里的路由决策不只是“找到上游地址”这么简单。我们还要考虑模型的可用状态、灰度版本、优先级和租户级指定。比如某个模型租户在灰度组路由规则会把他的请求打到新版本上游实例某个模型被熔断路由会自动切换到一个质量略低的备用模型而不是直接报错。2.4 技术栈选型为什么用 Go Redis技术选型部分我常被问到这里统一回答。网关主体我选的是 Go。原因有三第一Go 的 goroutine 在处理 SSE 流式转发时非常自然一个连接对应一个 goroutine并发模型比 Java 的线程模型直观得多第二Go 部署是单一二进制Kubernetes 里快速扩容非常省心第三内存占用相对可控长连接多时不容易被 JVM 堆打爆。分布式协同部分选 Redis。限流、配额扣减、锁、缓存全部通过 Redis 完成。Redis 的单线程模型 Lua 脚本能保证多个网关实例之间的原子操作这对配额准确性和限流一致性非常关键。入口负载均衡用的 Nginx 做四层转发网关实例无状态横向扩容只需要加 Pod。账单和日志数据进 ClickHouse因为这类数据写入量大、读取多为聚合统计ClickHouse 再适合不过。3. 核心模块的实战实现拆解3.1 动态路由与模型寻址动态路由的第一步是解析模型名。我们在网关里维护了一层“模型别名映射”客户端可以传入带日期后缀的版本化模型名比如text-chat-v3-20250101网关会去掉日期后缀找到模型基准名再根据路由规则选择哪个版本生效。路由伪代码我简化一下func routeByModel(ctx, modelName, tenantID) (*UpstreamTarget, error) { baseName : stripVersion(modelName) cfg : globalModelRegistry.Get(baseName) if cfg nil { return nil, ErrModelNotFound } target, err : chooseTarget(ctx, cfg) // 灰度判断 if grayEnabled(tenantID, cfg.GrayPolicy) { target cfg.GrayTarget } // 熔断判断 if breakerStore.IsOpen(cfg.ModelName) { target cfg.FallbackTarget } return target, nil }同一模型有多个上游实例时网关用带权轮询做负载均衡并配合主动健康检查。主动健康检查我每 15 秒对每个上游实例发一次轻量探测请求连续失败 3 次就临时摘除该实例。这个设计有效解决了上游某个实例内存溢出但还没有彻底宕机时的无效转发问题。3.2 鉴权、租户隔离与配额管理网关的鉴权流程分三步第一步校验 API Key 是否有效第二步校验该 Key 是否有权访问目标模型第三步校验租户配额是否充足。API Key 本质上是一个租户维度、模型维度的权限凭证。我们在 Key 的元数据里存了允许的模型列表、限流档位、账单归属。校验动作全部在 Redis 里完成Key 查询走本地缓存 Redis 兜底缓存时间 60 秒这样既快又不会因为 Key 吊销而失效太长。配额扣减是最容易出错的环节。直接用“查余额再扣减”这种非原子操作并发一上来一定会超发。我最终用 Lua 脚本保证原子性local balance tonumber(redis.call(GET, KEYS[1]) or 0) if balance tonumber(ARGV[1]) then redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 else return 0 end这个脚本在 Redis 里是原子执行的多个网关实例并发扣减也不会超卖。配额维度我们做了三层时间窗口配额每分钟/每小时/每天、总量配额、成本配额。成本配额是后期加的因为不同模型价格差距很大某些租户烧钱速度远超预期必须按成本上限来卡。3.3 多层限流与排队机制限流我最终做的四层接入层总限流、租户级限流、模型级限流、上游实例级保护。这四层不是重复而是各管一段。接入层总限流保护网关自身不被突发流量打垮租户级限流保证某个租户的疯狂调用不影响其他租户模型级限流配合上游 SLA防止把某个模型供应商打爆上游实例级保护避免单实例过载。算法我混合用了令牌桶和排队。普通短请求非流式用令牌桶超限直接返回 429 Retry-After但流式长任务不能直接 429因为一个长任务可能占住连接几分钟客户端如果频繁重试会造成雪崩。对这类请求我用排队机制超出并发上限时进入 FIFO 队列队列有最大深度超过深度直接丢弃并返回 429。关于容量计算我举个例子某个上游模型 TPS 上限是 100平均每个长请求耗时 120 秒。这意味着系统并发承载上限约为 100 × 120 12000 个在飞请求。但如果任意时刻只开 100 个并发连接给上游实际瞬时吞吐就是 100 TPS排队深度设为 2000超过 2000 直接抛错。这样既不会打爆上游也不会让客户端无限等待。3.4 熔断、降级与重试策略熔断我用的是经典的三态关闭、开启、半开。当某个上游连续失败率超过 50% 或者 P99 延迟超过 3 秒当前状态切换为开启后续请求走降级逻辑。半开状态下放少量探测流量成功率达到阈值才恢复关闭。降级的核心是维护一张“模型替代表”。比如主模型text-chat-large熔断时降级到text-chat-medium图像理解模型挂掉时降级到text-chat-multi。这个降级策略不能一刀切不同业务场景对质量和成本的容错不同所以替代表是租户可配置的。重试策略最容易踩坑。我的经验是4xx 一律不重试429 除外5xx 和网络错误可以重试。429 的重试要用指数退避否则上游还没恢复你重试反而给它雪上加霜。这里有个细节流式请求重试要非常小心。如果上游已经返回了一部分内容然后连接断开盲目重试会导致客户端拿到重复内容进而被重复计费。所以我只对“未收到任何字节”的流式请求做重试一旦开始收到数据就只报错不重试。4. 性能与稳定性优化实录4.1 连接池减少握手开销网关作为高并发转发层最容易被忽略的性能瓶颈是向上游发起新连接时的 TCPTLS 握手。我们接入初期网关 P99 尾延迟高得离谱排查发现大量时间花在 TLS 握手上。后面给所有出站 HTTP 客户端配置了连接池。Go 里主要调Transport的几个参数transport : http.Transport{ MaxIdleConns: 2000, MaxIdleConnsPerHost: 200, MaxConnsPerHost: 0, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 3 * time.Second, }连接池复用之后P99 尾延迟降了将近 40%。但连接池也不是越大越好空闲连接会占用内存和文件描述符。我最终把MaxIdleConnsPerHost控制在 200同时在连接空闲超过 90 秒后主动关闭。4.2 缓存Token 计数的加速Token 计数是 AI 网关里一个意外的性能热点。每个请求进来都要统计输入 Token 数用于配额扣减和计费但有些模型的 Tokenizer 本身很重中文场景下尤其慢。如果每个请求都现算一遍网关自身开销会大幅上升。我的优化方案是缓存 prefix Token 计数。对于大多数请求System Prompt 和 Few-Shot 模板往往是固定不变的我对这些前缀做 Hash缓存其对应的 Token 数然后只对动态部分做增量统计。实测这个方法能把 Token 计数耗时降低 60% 以上。有个坑必须提醒Token 计数缓存一定带上 Tokenizer 版本号。模型供应商升级 Tokenizer 后同样的文本 Token 数会变如果你还在用旧缓存会导致账单偏差。我们的缓存 Key 是tokenizer_version content_hash升级即自动失效。4.3 流式响应的背压与排队SSE 流式转发有一个隐藏问题如果上游生成速度远快于客户端消费速度内存缓冲会无限增长最终 OOM。我在网关内部给每个流式连接分配一个有界 ChannelBuffer 大小 1024。当 Channel 满了写入方阻塞这就天然形成了背压——上游不会无脑往下推。另外要规划连接时长上限。有些长对话任务单请求时长超过 10 分钟如果网关层设置的读超时太短请求会在中途断裂。我们的策略是连接超时短读超时长。连接超时 5 秒确保快速失败读超时取上游硬上限 5 秒避免误杀正常长任务。同时记录两个指标首字延迟和总完成时间。对 AI 场景来说首字延迟往往比总耗时更影响体验。4.4 可观测性度量、日志、链路追踪没有可观测性的网关出问题只能抓瞎。我定义了四个核心黄金指标请求量、成功率、首字延迟 P99、上游调用计费准确率。Prometheus 指标做了三层维度全局、模型维度、租户维度。比如model_total_chat_tokens这个指标会带上model_name和tenant_id两个标签这样月底做账单核对时可以直接从指标系统拉数字和 ClickHouse 对账。链路追踪用 OpenTelemetry从客户端请求进入网关到网关转发上游再到上游返回全链路串起来。排障时最常用的场景是“某个租户说请求变慢了”我们直接看 Trace能立刻定位是网关耗时、上游耗时还是网络耗时。日志设计上我记录每个请求的结构化日志包括模型名、租户、请求耗时、输入/输出 Token 数、计费金额、错误码。这些日志灌进 ClickHouse按天分区支撑后续的账单分析和用量报表。5. 200 模型的接入工程化流程5.1 模型接入清单与配置化当模型数量从 10 个增长到 200 个时接入流程如果不标准化就会变成一场灾难。我推动建立了一个“模型接入 checklist”新模型必须满足全部条件才允许上生产。检查项说明不通过的影响适配器代码协议转换逻辑完整已覆盖流式与非流式无法统一返回格式模型注册配置已填写注册表全部字段路由无法工作鉴权打通上游鉴权配置验证通过请求无法到达上游限流策略已配置模型级和租户级限流上游可能被打爆契约测试核心场景测试用例全绿回归风险不可控计费验证上游账单与网关账单对照一致账务出错标准化以后我团队接入一个新模型的时间从最初的 3 天降到 3 小时。大多数模型只要写一个适配器 yaml 配置甚至不需要动代码。这一点对 200 这个量级至关重要——人力根本不是按模型数量线性增长的。5.2 统一数据契约与归一化200 个模型如果每个适配器各写各的数据结构网关内部会变成一锅粥。所以在适配层我定义了统一的数据契约。以对话类模型为例内部统一用ChatMessage、ChatRequest、ChatResponse三个结构体。上游的字段差异在 transform 层解决有的模型用role: system有的用role: instruction有的还把多模态图片放在单独的字段里这些全部在适配器里映射成统一结构。还有一个比较关键的点错误码归一化。我建了一张错误码映射表上游各种奇形怪状的错误都会映射到网关标准错误码。比如“上下文超长”统一映射为ERR_CONTEXT_TOO_LONG“模型过载”统一映射为ERR_MODEL_OVERLOAD。客户端只需要处理一套错误码而不是每个供应商一套。5.3 灰度发布与一键回滚模型版本升级是高频操作尤其开源模型经常发新版。我们允许通过管理平台为某个模型发布新版本并配置灰度比例。比如先让 5% 的流量打到新版本观察指标稳定后再逐渐放大。灰度维度有两个按租户灰度和按流量比例灰度。按租户灰度特别适合内部核心客户可以先让他们验证按流量比例灰度适合面比较广的模型暴露问题后能快速缩流量。回滚方面模型注册表做了版本化。每次变更都会生成一条新记录保留旧版本数据。如果新版本模型上线后出现严重问题管理平台上点一下“回滚”网关节点收到配置变更消息后 1 秒内切回旧版本。这套机制在几次上游模型“偷偷变差”的事件中救了大命。6. 踩坑实录与排查经验6.1 超时配置的坑网关超时设置看起来简单实际非常容易配错。最坑的一次是某个对话模型首字返回要 20 秒而我们当时网关的读超时统一配了 10 秒结果所有长距离请求全部中途断裂客户端疯狂报错。排查过程很经典日志里能看到网关已经拿到上游响应头但 body 还没读完就超时了。后来我把超时拆成三个独立配置连接超时、读超时、写超时。连接超时只是建立 TCP 连接不能设太长读超时要根据模型特性单独配置不能全局一刀切写超时对 SSE 场景基本可以放宽。针对每个模型的注册表里都加了一组超时参数支持模型级覆盖全局默认值。6.2 Token 计数的坑Token 计数直接关系钱出错会非常麻烦。有一次我们发现某个模型的中文输入网关预估的 Token 数和上游账单差了很多。排查发现是 Tokenizer 在开头加了 BOS 特殊 Token而我们的预估算法没有把这个算进去导致每个请求都少算了 1 个 Token。虽然单次量小但一天上千万请求积累下来就是巨大差额。解决办法是“预扣 结算”模型。请求进来时按输入 Token 预估输出 Token 预扣配额响应结束后用上游返回的实际 usage 字段做结算多退少补。这样即使预估不准最终账单也一定和上游对齐。所有上游返回的usage字段我都会透传并落库这是后续对账的黄金数据。6.3 流式返回的坑流式转发最典型的坑是客户端断开后上游还在继续生成。最开始我们没有处理这种情况结果每次用户取消请求上游计算资源还在白白消耗成本飙升。修复方案是网关检测到客户端连接断开后主动通过 Context Cancel 取消上游请求。对 gRPC 上游直接发送取消消息对 HTTP 上游直接关闭连接。注意这里的取消不能太激进要留一个短暂缓冲防止客户端只是网络抖动一下就被彻底取消。另一个中文场景的坑是 SSE 分片被截断。上游的流式数据按字节推过来如果一个汉字被截成两个字节放在两个 chunk 里客户端解析 JSON 就会失败。处理方式是网关在转发前对 chunk 做 rune 边界补齐保证每个转发出去的事件里不会出现半个字符。6.4 线程池与事件循环耗尽Go 版本的网关遇到过一个经典问题goroutine 数量持续上升最后导致内存暴涨。排查时用 pprof 一看大量 goroutine 阻塞在“等待上游响应”上。原因是有几个上游模型响应非常慢客户端已经断开了但我们的代码没有设置 Context 超时导致 goroutine 一直挂着。后来我砍掉了“无限等待”的逻辑给所有上游调用强制加上 Context 超时并且在每次客户端断开时手动 cancel。上线后 goroutine 数量稳定在预期范围内。这个经验对 Java 版本同样适用——不要用同步阻塞模式去连上游要么用异步要么用带超时有界线程池否则一旦上游故障线程池耗尽只是时间问题。最后分享一点个人体会。做这个网关最大的收获不是写了多少代码而是意识到“模型接入”这件事必须工程化、配置化。200 模型的量级下靠人肉写胶水代码绝对死路一条。网关把所有多样性收敛到可控的抽象内把接入成本从“项目级”降到了“配置级”这才是架构真正的价值。如果你也在搭类似的东西建议一开始就把注册表、契约测试、计费对账这三件事做扎实后面会省掉非常多麻烦。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →