如果你接手过一个同时接入了多家大模型供应商的业务系统,一定对下面这种场景不陌生:各家SDK的调用方式不一样、鉴权逻辑不一样、超时语义不一样、错误码更是各说各话。业务代码里为了适配每家的细微差别,被迫写出一堆if/else和重复的封装,每次新增一个模型供应商就要把链路重新走一遍。这就是“多模型混用架构”里最常见的“接口碎片化”问题。
这篇文章记录的是我用一个聚合网关项目解决这个问题的完整过程。项目本身不复杂,核心就一件事:把多个上游模型接口收敛成一个统一入口,让业务方只对接一套协议,由网关侧负责路由、适配、容错和计量。文章会从碎片化的具体表现、网关的边界设计、协议归一化实现、路由与容错策略,一直讲到上线两周实测中踩过的三个坑,以及改造前后的量化数据对比。适合正在搭建或维护多模型服务的后端开发、架构师和技术负责人阅读,无论你目前是三家供应商还是十家,思路基本通用。
1. “接口碎片化”落到代码里:同一笔业务要掰成三份去写
1.1 一套业务逻辑,背着三套SDK和三份鉴权
先说清楚什么叫“接口碎片化”。它不是指接口文档不统一这种表面问题,而是说你的业务代码为了能同时调用多个模型,被迫同时理解多套通信协议、多套数据结构和多套错误语义。我接手的项目原本已经接入了三家模型供应商,代码里最典型的一段长这样:
// 伪代码:业务层直接依赖三家供应商SDK if (provider == "A") { AClient client = new AClient(apiKeyA, secretA); ARequest req = new ARequest(); req.prompt = userInput; req.maxTokens = 2048; AResponse resp = client.chat(req); text = resp.getChoices().get(0).getContent(); } else if (provider == "B") { BClient client = new BClient(accessTokenB, extraHeaderB); BBody body = new BBody(); body.messages = buildMessages(userInput); body.samplingParams.setTemperature(0.7); BResult result = client.complete(body); text = result.getOutput().getText(); } else if (provider == "C") { // 第三家:连鉴权方式都不一样,走的是自研签名 ... }看着只是三段分支,其实每行背后都是一整套需要长期维护的逻辑:A家的鉴权是API Key加签名,B家的Access Token需要定时刷新,C家要求请求头里塞自定义时间戳和MD5摘要。三家SDK版本更新节奏也不一致,A家升级一个内部的日志库就可能影响整个调用链。
更麻烦的是,只要业务方提出“帮我在三个模型之间做自动切换”这种需求,这段代码就要继续膨胀。切模型本来应该是一件很普通的事,结果变成了在一个类里堆满各家的特殊处理。代码评审的时候大家都皱眉,但没人敢把这段统一抽掉,因为一旦抽掉了,就相当于把团队逼到必须做一次大重构的墙角。
1.2 错误码、超时语义、限流策略各说各话
接口碎片化最隐蔽的伤害,是错误语义的混乱。不同供应商对同一个失败场景,返回的错误码可能是完全不同的。我整理过一份关系表,那段时间对着三家文档来回翻,翻到怀疑人生:
| 失败场景 | A家返回 | B家返回 | C家返回 |
|---|---|---|---|
| 请求超时 | 408 / request_timeout | code=3 / Gateway Timeout | 5xx / RD_0001 |
| 鉴权失败 | 401 / invalid_api_key | 10020 / Unauthorized | 403 / S_1002 |
| 触发限流 | 429 / rate_limit_exceeded | 10050 / Too Many Requests | 429 / QPS_LIMIT |
| 服务不可用 | 503 / service_unavailable | 5xx / Internal Error | 503 / E_BUSY |
| 输入内容被拒 | 400 / content_filter | 200 / success但返回empty | 200 / blocked=true |
注意B家和C家那个最坑的地方:内容被拦截时,C家居然返回HTTP 200,只是Body里多了个标记字段。如果业务代码只判断HTTP状态码,就会把一次被拒的请求当成正常结果返回给用户。这种情况纯粹靠业务层去做兼容,你永远补不完,因为每家的“正常返回”里都可能藏着一套自己的暗語。
超时语义就更乱了。有的供应商超时默认是30秒,有的是60秒,而且超时之后是重试还是报错,各家推荐策略也不一样。业务方根本不该关心这些问题,但在碎片化架构里,这些细节全都变成了业务代码里不能删的“死肌肉”。每次产品联动调模型参数,改一处就要重新验证三家。
1.3 模型升级换个参数,业务代码要跟着抖三抖
还有一个几乎所有多模型项目都会经历的痛:模型供应商升级模型版本,或者调整参数名称,你的业务代码就得跟着改。比如A家把maxTokens改成了max_completion_tokens,虽然语义几乎一样,但它只兼容新的名字。此时你的业务类里所有针对A家的构造都要动一遍。
更离谱的是,有的供应商更新之后连响应结构都变了,原来拿choices[0].message.content,新版本变成了output[0].content。你在业务层辛辛苦苦兼容的结果是:代码里全是“老版本”“新版本”的分支判断,连当时写这段代码的人,过两周回来都未必记得每个分支对应的场景。
这些零零碎碎的问题单独拎出来看都不大,但叠加在一起就会形成非常可怕的维护负担。我当时做了一个粗略估算:业务代码里跟模型供应商直接相关的逻辑,占了整个服务核心代码将近四成,而且这部分代码几乎不具备复用性。每接一家新供应商,不只是“加一个分支”的事,还需要处理鉴权、调参、错误兼容、日志追踪等各种琐碎问题。团队里没人愿意动这块代码,空气里都飘着技术债的味道。
2. 聚合网关的位置:不是替代SDK,是在SDK外面收一层口子
2.1 为什么不在业务层做封装,而要单独抽一层
面对这种碎片化,第一直觉是在业务代码里做一个统一封装类,把所有供应商的逻辑都塞进去。我们最初也是这么想的,但很快就发现这条路走不通。业务层的封装有天然缺陷:首先,它仍然是业务服务的一部分,没法和业务代码隔离,一旦封装类的实现有Bug,直接影响所有业务的发布和回滚;其次,封装类很难独立扩展新能力,比如你想加一层针对所有模型的缓存、熔断或计费统计,在业务服务里做这些事,代码的定位会很模糊;最后,业务服务本身有多个实例,封装类做得再完善,也只是单点服务里的一个类,没法做跨实例的流量调度。
所以最终决定是把这层收口逻辑单独拆成服务,也就是标题里说的聚合网关。它放在业务服务和上游模型供应商之间,对业务暴露一套稳定协议,对上游分别适配各家真实接口。业务服务不再需要感知供应商差异,网关替它把脏活累活全干了。
2.2 网关侧要管什么、坚决不管什么
确定单独建一层之后,最容易犯的错是职责泛化,什么都想往网关里塞。我当时定了一个很简单的边界原则:凡是“一个请求进来之后,不管选哪家供应商都需要做的事”,放网关;凡是“只有某个具体业务场景才需要的逻辑”,绝对不进网关。
网关负责的事包括:
- 统一请求协议:所有业务方用同一套字段描述对话、参数、流式开关;
- 统一响应协议:不管上游返回什么结构,网关都转成统一格式;
- 路由与调度:根据业务标注的模型组、优先级、成本约束选择合适的供应商;
- 鉴权归一化:业务方只需要带网关自己的Key,各家的密钥由网关保管;
- 故障容错:超时重试、限流降级、健康检查、熔断;
- 计量埋点:所有请求统一记录计费用例,方便后续对账。
坚决不管的事包括:
- 业务提示词模板、业务知识库检索、业务功能链路编排;
- 用户维度会话管理、多轮对话的历史状态;
- 模型微调、评测、数据回流。
一句话总结:网关管的是“通道”,不管“内容”。业务逻辑还是留在业务服务里,网关只保证请求能稳定、快速地到达正确的模型并拿到能被业务方直接消费的响应。这个边界划清楚之后,团队里的讨论效率高了很多,因为所有人明确了“这个问题该在网关解决,还是该在业务侧解决”。
2.3 整体请求链路长什么样
设计完边界,整个服务的链路就非常清晰了。一个典型请求经过以下环节:
业务服务发起对话请求 → 网关统一入口收到请求 → 鉴权校验 → 路由匹配(选模型组、选供应商) → 适配器转换参数和协议 → 发送给上游供应商 → 收到响应 → 适配器转换响应 → 统一计费埋点 → 返回给业务服务。
如果开启流式输出,链路会稍微复杂一点:适配器需要把上游的流式事件逐帧读取、逐帧转换,再以网关自己的流式格式推给业务方。这一步在后面踩坑中有大文章,我先按下不表。
架构上我选了无状态的轻量部署方案,网关实例可以横向扩容,路由信息存在配置中心,上游供应商的密钥统一放进密钥管理服务。这样换供应商、扩容量都不需要重启业务服务。
3. 协议归一化的实现:统一模型、适配器、错误映射三条线
3.1 一套请求模型和一套响应模型,把话说死
协议归一化的第一步,是设计“话说死”的请求和响应模型。所谓“话说死”,就是让所有业务方在可预见的未来内不需要变更参数格式。我定义了一套偏中立的对话接口格式,刻意避免照搬任何一家供应商的原生结构:
{ "request_id": "9f0d5c1e-8b7a-4f2e-a1c3-6b7d8e9f0a1b", "model_group": "chat-fast", "messages": [ { "role": "user", "content": "用一句话解释聚合网关" } ], "params": { "temperature": 0.7, "max_tokens": 1024, "top_p": 0.9 }, "stream": false }request_id是全局唯一请求号,后面所有日志、追踪、重试幂等都靠它。model_group是路由入口,业务方不需要指定“具体哪一家模型”,只需要声明“我要哪一类能力的模型”,至于具体选哪家,那是网关路由的事。params保持相对通用,如果某家模型有特殊参数,通过扩展字段向下传,但不会反向污染统一协议。
响应模型也做了归一化:
{ "request_id": "9f0d5c1e-8b7a-4f2e-a1c3-6b7d8e9f0a1b", "model": "provider-a-v2", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "聚合网关就像是一个翻译官..." }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 32, "completion_tokens": 48, "total_tokens": 80 } }这里有个很重要的决策:响应里必须把真实调用的模型和供应商带出来,字段叫model。因为路由可能在运行过程中发生降级,业务方以为自己调的模型组A,实际可能因为A不可用被降级到了模型组B。如果响应里不写清楚真实模型,后面排查问题和计量对账都会很痛苦。
3.2 适配器模式:一个供应商一个实现类
请求和响应模型定好之后,接下来就是最核心的适配层。我用了经典的适配器模式,给所有上游供应商定义一个统一接口:
public interface ModelAdapter { UnifiedResponse chat(UnifiedRequest request) throws UpstreamException; void stream(UnifiedRequest request, StreamCallback<UnifiedEvent> callback) throws UpstreamException; HealthStatus healthCheck(); String providerName(); }每个供应商实现一个Adapter,内部做字段映射、鉴权注入、协议转换。对于业务代码来说,永远只需要调用chat或stream,完全不需要知道内部到底是哪家供应商。这个设计最大的优点,是新增模型供应商时业务代码零改动。后来我们接入第四家供应商时,团队里一个后端同学花了一天时间写Adapter,第二天联调就通了,这放在改造前是不敢想的。
写第一个Adapter时很费劲,因为要把上游的映射关系捋清楚;但写完第一个,第二个和第三个就会越来越快。为了降低适配器的重复劳动,我还写了一个基础模板类,把公共逻辑抽出来:超时处理、重试骨架、日志埋点、统一异常转换。新的Adapter只需要实现“特定供应商的参数映射”和“特定供应商的鉴权逻辑”。
3.3 鉴权、错误映射、超时语义的三重归一化
适配器里最需要花心思的是鉴权归一化。业务方不再需要关心每家供应商的密钥规则,统一使用网关颁发的Key,网关侧校验通过之后,再从密钥管理服务里取对应供应商的密钥,注入到适配器发出的真实请求里。这样还有一个额外好处:万一某家供应商的密钥泄露,只需要在密钥管理服务里轮换,不需要让所有业务方重新配置。
错误映射是适配器里的另一个重头戏。每个Adapter必须把上游的异常转换成网关统一的异常类型,再由网关统一映射成HTTP状态码和错误码。我做了这样一张映射表:
| 网关定义错误码 | 网关HTTP状态 | A家来源 | B家来源 | C家来源 |
|---|---|---|---|---|
| UPSTREAM_TIMEOUT | 504 | 408 / request_timeout | code=3 / Gateway Timeout | 5xx / RD_0001 |
| AUTH_FAILED | 401 | 401 / invalid_api_key | 10020 / Unauthorized | 403 / S_1002 |
| UPSTREAM_RATE_LIMIT | 429 | 429 / rate_limit_exceeded | 10050 / Too Many Requests | 429 / QPS_LIMIT |
| UPSTREAM_BUSY | 503 | 503 / service_unavailable | 5xx / Internal Error | 503 / E_BUSY |
| CONTENT_FILTERED | 400 | 400 / content_filter | 200 / empty输出 | 200 / blocked=true |
注意C家那个“200但blocked”的情况,在Adapter层就把blocked=true转成了CONTENT_FILTERED异常,不会让这种错误流到上层。这种细腻的转换工作,放在原来的业务代码里根本没人愿意做,但是在网关里它是必须完成的基本功。
超时归一化也做了统一设置。网关对上游的请求超时默认设为30秒,重试一次;但对业务方承诺的总超时是45秒。这样即使上游很慢,业务方也能拿到确定性的超时行为。原来各家超时时间不一致导致的“有些请求30秒就挂,有些等到60秒”的乱象,在网关层面被消除了。
4. 路由与容错:请求进网关后,怎么决定走哪家、失败怎么办
4.1 静态路由:按模型组、权重和成本偏好分发
统一协议搞定之后,网关的下一个关键能力是路由。设计路由时我先分了两个层次:静态路由和动态路由。
静态路由解决的是“正常情况下,这个模型组应该优先用谁”的问题。我在网关里定义了一个模型组概念,同一种能力可以放入多个模型供应商,并为每组配置一个hash权重。比如“chat-fast”这一组,我配置了A家容量40%、B家容量60%,当A家新版本模型上线时,可以先手动调低A的权重,灰度观察一段时间再满量切换。
model_groups: chat-fast: strategy: weighted vendors: - name: provider-a weight: 40 max_retries: 1 - name: provider-b weight: 60 max_retries: 1 fallback_order: - provider-c这样的配置方式对运维非常友好:想调整流量分配,只需要改配置中心里的YAML,网关能实时感知并重新加载,不需要发布代码。静态权重路由最大的价值是让“切模型”这件事变成配置操作,而不是代码操作。
4.2 动态路由:让上游健康状态参与决策
静态路由解决正常情况,但正常情况在线上总是会被打破。某家供应商限流、机房网络抖动、模型接口开始大面积超时……如果路由只靠静态权重,这些故障就会顺着权重比例波及所有请求。因此我加了一层动态路由:网关每个实例会周期性调用每个已配置ModelAdapter的healthCheck(),并把探测结果上报到网关集群内部的一个内存状态表里。
处理请求时,路由层先剔除健康状态异常或近期错误率超过阈值的供应商,再从剩下的池子里按权重选择。这个逻辑让网关具备了一定的“故障感知能力”。有次A家模型因为发布变更导致大量5xx,动态路由在不到一分钟内就把流量全部切到了B家,业务方几乎没有感知。事后回滚A家,再慢慢恢复权重,整个过程像做了一次无声的流量手术。
4.3 降级链路与幂等重试,怎么做到不重复计费
路由还必须有兜底逻辑。当模型组里所有主选供应商都不可用时,我配置了降级顺序:比如chat-fast的一组不可用,自动降级到chat-base模型组。降级链路很重要,但也很危险,因为你不能让一次失败的请求无限重试下去。我设定了全局重试总次数上限:最多重试2次,且只在“连接失败、超时、5xx”这些可重试错误上触发;鉴权失败、参数错误、内容被拒这类错误一律不重试。
幂等重试是另一个必须解决的问题。大模型接口的计费通常按调用次数或token计算,如果上游已经处理了请求,但响应在网络上超时了,网关重试一次就会导致上游执行两次,业务侧就要承担双倍费用。为了尽可能避免这种问题,我在请求体里保留了request_id,并在Adapter发送请求时把它透传给上游,作为幂等键。有的供应商原生支持幂等键,那自然最好;不支持幂等键的供应商,我会采用“超时后先查一次上游状态”的策略,确认这个request_id是否已经有结果,再决定是否重试。
这套策略肯定不是完美的,但它把重复计费的概率降到了很低的水平。我实测运行了大半个月,因为重试导致的重复计费基本可以忽略。真正想百分百避免重复计费,需要各家供应商都支持严格的幂等语义,目前部分厂商还做不到,所以做好日志记录非常关键,每天凌晨要对一遍账。
5. 上线两周实测:流式截断、连接池打满、计量对不上
5.1 流式响应在网关里被截断和乱序,排查过程记录
网关改造完成后,我们分批切流量,前三天非流式请求一切正常,大家还以为能平稳落地。结果第四天开始,接流式输出的那几条业务线陆续报“回答突然断开”“打印出来的文字顺序不对”。最开始我以为是业务侧对SSE协议没处理好,但直接拿原始响应做对比,发现上游返回的内容是完整的,问题出在网关转发这层。
排查过程是这样的:网关内部用的是HTTP客户端的默认配置发起上游请求,默认情况下客户端会缓冲响应体,而且读取时按“行”处理。但SSE流的边界不是换行,而是空行分隔的event帧。我一开始用按行读取的方式解析上游的SSE,遇到某些模型在流式中含有非常长的单行数据,超过缓冲区就被截断,导致一行被拆成了两半,JSON解析失败,整个流就被迫中断了。
另外还出现过“乱序”假象:网关为了减少连接占用,对多个流式响应做了并发转发。理论上每个流内部顺序不会乱,但多个流之间的字节在网关日志里交杂在一起,排查时看起来就像乱序。后来我在日志里给每个流都加了独立的request_id前缀,才把观察口径理清楚。
5.2 根因处理:流式转发得用字节流逐帧解析,不能按行糊弄
理清原因之后,修复方向就明确了。我把流式转发的解析逻辑从“按行读”改成“按SSE帧解析”:读取数据块时,按data:前缀和空行分隔符寻找event边界,每种向上游发送的请求都关闭缓冲压缩,保证传输通道不引入意外的数据合并。同时给流式接口设置专门的read timeout,避免因为某个流长时间不发数据导致整个HTTP连接被判定超时。
我还对网关自身的SSE重封装格式做了简化,把上游event里的原始字段塞进一个统一的data字段里:
event: message data: {"request_id":"9f0d...", "delta":"这是第一段增量内容", "finish_reason":null} event: done data: {"request_id":"9f0d...", "usage":{"prompt_tokens":32,"completion_tokens":48,"total_tokens":80}}这种统一格式让业务侧消费流式响应变得极其简单:不需要知道上游event命名是什么,只需要监听message事件拿增量,监听done事件拿最终用量。之前业务代码里因为各家SSE字段名不一样写出的兼容逻辑,全部可以删掉了。
5.3 连接池被打满:流式连接占用时间太长,池子不够用
流式问题修复后,又冒出一个新的性能瓶颈。当时网关的HTTP客户端复用了同一个连接池连接所有上游,理论上连接池大小配得不算小,但流式响应有个特性:一个流一旦建立,就要一直占着这个连接直到生成完毕。慢一点的模型生成一篇长文可能要30秒甚至更久,十几个并发流式请求就能把连接池占满,连普通非流式请求都拿不到连接了。
排查时先看到的现象是所有请求的等待时间变长,连接池指标很高,但CPU和内存都很健康。看了很久才反应过来,这不是计算资源问题,而是长连接被流式请求长期占用导致的有效连接数不足。
修复方式我用了两步:第一步,把HTTP客户端拆成两个连接池,一个给流式请求,一个给非流式请求,避免互相挤占;第二步,给每个上游供应商的并发流式连接数加一个信号量限流,超过阈值就直接告诉业务方“当前该模型组流式并发已达上限”,而不是让它排队把连接池拖垮。这个信号量限流在业务高峰时非常重要,它能保证有限的连接始终服务在最有价值的请求上。
5.4 token统计口径不统一,计费对不上账
上线第二周,财务反馈对账不平:某天的账单和业务侧自己统计的消耗差了将近15%。再次展开排查,发现还是出在各家usage字段的“口径差异”上。
有的供应商返回的completion_tokens只统计到生成结束,不包括被截断的部分;有的供应商在不同模型推理模式下,token计算方式也不一样;还有的供应商在流式模式下返回的usage是各帧累计值,但中间某帧会漏发。原本这些细节对业务侧没什么影响,但网关统一计量时,如果只是粗暴地把上游usage透传,就会导致统计数据五花八门。
解决方法是网关在适配层做计量归一化:每家Adapter都必须把上游usage转换成统一的prompt_tokens、completion_tokens、total_tokens三个字段,并且约定按最终响应或结束帧里的usage为准。对于上游完全不提供usage的情况,网关根据请求参数和响应文本做一次估算兜底,保证计量日志里永远有数字可查。修复之后,第二天的对账误差降到1%以内,剩下的误差主要是因为估算兜底导致的,属于可接受范围。
6. 改造前后的数据账:延迟损失换来多大收益
6.1 量化对比:延迟、错误率、接入成本的关键指标
改造是否成功,不能只靠“感觉上顺了”,得有数据支撑。我把网关上线稳定运行一个月后的数据,和改造前同一业务量级做了对比:
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 业务侧调用P95延迟 | 850ms | 880ms左右 | +30ms(网关损耗) |
| 上游错误率(4xx/5xx) | 2.1% | 0.8% | -62% |
| 因供应商故障导致的业务报错率 | 1.7% | 0.2% | -88% |
| 新增一家模型供应商的平均耗时 | 5个工作日 | 1个工作日 | -80% |
| 模型相关业务代码行数 | 约2400行 | 约300行 | -87% |
延迟增加30ms的损失,换来的是错误率、接入成本、维护成本的全面下降。这个交换我认为非常值。网关的损耗主要在序列化、路由判断、适配器转换这几步,通过优化配置把损耗压到30ms以内,之后基本没再波动过。
6.2 错误率下降到底来自哪里
错误率下降这么明显,主要是三个机制叠加的效果。第一是超时归一化后,业务侧不会因为某个供应商卡到60秒才报错,网关在30秒就会快速失败并重试,用户侧的感知时间大幅缩短;第二是动态路由能在供应商异常时快速切走流量,避免故障扩散;第三是错误码映射让业务侧终于可以做精准的重试和降级决策。原来业务代码里“看见什么错误码都重试”的粗放逻辑被替换成了“只有特定错误才重试”的精细逻辑,无效请求大幅减少。
6.3 这套方案同样适用的边界与注意点
最后说一下这套方案的适用边界,不是所有场景都适合上聚合网关。如果你的业务只调用一家模型供应商,且短期内没有切换计划,那独立网关的投入产出比很低,一个简单的SDK封装就够用了。如果调用的供应商数量到了三家以上,并且业务侧存在模型切换、降级、计量等需求,那么聚合网关的收益会非常明显。
需要注意的点我也列一下:网关会成为新的依赖点,所以它的高可用等级必须和业务服务一致,建议至少两实例异地部署;上游供应商的密钥集中管理后,密钥管理服务的权限控制要收紧;适配器的维护需要专人负责,因为上游模型接口一旦变更,适配器要及时跟进。这里没有一劳永逸的银弹,只有把边界划清楚、把机制建好,后续才会越来越省力。
最后再分享一个小体会:我做这个网关项目过程中最大的收获,不是代码写得多漂亮,而是学会了对“接口碎片化”这类问题做分层。很多技术问题看起来是代码层面的乱,本质是职责边界没划清。给碎片化接口加一层统一收口,不只是解决眼前的if/else,更是把整个系统的演化空间打开了。后面不管接多少家新模型,这层口子都会一直按同样的规则运转,这对做技术的人来说,是一种很踏实的确定感。