1. 多模型混用架构里“接口碎片化”是怎么发生的1.1 从一次真实接入说起如果你所在的技术团队正在做一个AI原生的应用十有八九会走到这一步一开始只用一家模型服务后面发现不同场景需要不同的模型。有的模型擅长长文本理解有的模型响应快、便宜有的模型输出的格式更适合结构化解析。然后你开始同时对接多家服务商每家的鉴权方式不一样有的用请求头带密钥有的要动态签名每家的请求结构不一样有的用messages数组有的用prompt字符串有的还要塞一堆参数进去返回的格式更是五花八门字段命名、错误码、限流头、计费单位全不一致。我见过很多团队在这个阶段会踩同一个坑业务代码里直接写服务商的SDK或者HTTP调用逻辑。刚开始只有两三家时还好等规模上来上游业务一多你会发现自己实际上维护着好几套调用代码。改一家服务商的接口版本要牵动所有下游。换模型供应商要改业务代码。想做一个简单的A/B对比实验也要写一堆胶水代码。我们把这种现象叫“接口碎片化”——表面上看是多了一个翻译层的问题实际上已经蔓延到业务流程、监控、计费、限流、容错各个层面。DMXAPI 是我在一次实际项目中为收拾这个局面做的统一接入层。它的定位不复杂在所有模型服务商和业务代码之间放一个独立的网关服务统一对外暴露一套规范的接口内部再做适配、路由、容错、计量。这篇文章就记录这整个过程——包含设计思路、代码结构、上线后踩过的坑以及一些我事后觉得“如果早点知道能节省两周”的经验。1.2 碎片化不光是“格式不一样”很多团队对接口碎片化的第一反应是做“格式转换”把A家的返回改成B家的样子。但真正做起来会发现碎片化包含至少四个层面的问题协议层有的服务商是纯 REST有的要求 SSE 流式返回有的支持 WebSocket有的鉴权方式需要每次动态计算签名并放在特定请求头里有的还要带时间戳防重放。光鉴权这块就得按供应商各写一套。语义层同样是“对话补全”这个动作A 家的 messages 字段可以是纯文本B 家要求 content 必须是数组结构C 家在系统提示词上有单独参数。返回内容上有的按 choices[0].text 返回有的按 output[0].content 返回有的把内容包了一层 thinking 字段。行为层各家对超时、重试、限流的处理预期完全不同。A 家的限流是 QPSB 家是 TPMC 家是 RPM 加并发数双重限制。有的服务商返回 429 还会带上 Retry-After有的根本不带。你要做统一的降级策略得先理解每种限流属性的含义。可观测与计费层日志里要统计 token 消耗但有的接口返回 usage 字段有的不返回有的返回的 token 计算维度都不一致。做成本分摊的时候如果没在网关层统一打点靠业务方上报数据基本没法对账。所以 DMXAPI 的核心不是“转换格式”而是做一个“语义统一网关”上游只认识一种协议、一种数据模型、一种错误语义网关负责把各家的差异消化在内部。1.3 这笔投入到底值不值有人会问我直接选一家服务商不就行了吗为什么一定要做一个网关层答案取决于你的业务规模。如果只是做一个 Demo调用量小用选型规避碎片化完全成立。但是当调用量每天超过十万次、上游同时接多个业务线、你需要频繁切换或对比模型效果时没有统一接入层的成本会迅速超过建设它的成本。我算过一笔简单的账。假设一个人日成本按中等水平估算维护四家服务商直连的业务代码每次服务商接口升级或字段变更平均要改动 6 到 8 处代码位置算上测试和联调一次升级大约 2 到 3 人日。一年下来每家升级两三次就是 20 多个人日。如果再加一个新供应商接入从调研、联调、适配到灰度至少 5 人日起。而做统一网关后新增供应商基本只是加一个实现类加一份配置大部分情况下小于 1 人日。再加上统一限流、统一重试、统一降级带来的稳定性收益这笔投入在项目周期超过半年、调用量稳定增长时回本是板上钉钉的事。当然网关本身也是新的复杂度来源。下文会详细讲它的设计和实现以及怎么控制这个复杂度。2. DMXAPI 的整体设计思路2.1 一句话概括让上游只看见一套接口DMXAPI 的设计目标用一个词概括就是“收敛”。上游调用方不再关心模型供应商是谁、用什么鉴权方式、返回结构什么样他们只需要给一套包含 model、messages、temperature 之类参数的请求然后拿到一个格式统一的返回。至于网关背后是 A 还是 B或者同时用了 A 和 B 做加权分发上游不感知。所以 DMXAPI 自研了一套面向应用层的 API 规范所有请求进来先过鉴权、计量、路由三层再进入各供应商的适配器。适配器内部做三件事把统一请求翻译成供应商要求的请求格式把供应商返回的响应翻译成统一响应格式把供应商侧的异常、错误码、限流信息翻译成统一的内部错误类型。这个过程说起来轻巧但细节非常考验工程能力。后面我会逐个部分拆。2.2 核心模块拆解整个系统在代码层面分成六个模块Gateway Server暴露统一的 HTTP 接口负责鉴权、限流、请求日志不参与任何供应商逻辑Schema Registry / 模型定义中心维护统一数据模型包括请求结构、返回结构、错误结构以及它们之间的版本关系Router / 策略路由根据上游请求里的 model 字段或路由标签决定这个请求交给哪个供应商以及使用什么策略单发、灰度、加权分发、主备切换Provider Adapter每个供应商一个实现类负责协议适配。这是整个系统里最繁琐但最核心的部分Resilience Layer超时控制、重试、熔断、降级、排队逻辑统一收口在这一层做Observability / 计量组件记录每次请求的耗时、状态、token 消耗、成本分账标签上报到监控和账单系统。这里有一个关键决定把“路由”和“适配”分开而不是把路由逻辑埋进各供应商实现里。这样新增一个供应商时不需要碰路由逻辑调整路由策略时也不需要碰供应商实现。边界清晰两个方向的演进都很快。2.3 为什么用“网关”而不是“公共 SDK”也有人提出另一种方案写一个公共 SDK让各个业务方在代码里引入调用 SDK 提供的统一方法。这种方案在内部复用场景下有一定吸引力但它有几个问题SDK 要跟着业务方一起发版升级成本高。业务方不主动升级你就永远不知道他们用的什么版本语言隔离问题。业务方是多种技术栈时每个语言都要维护一套 SDK无法做集中治理。限流、熔断、审计这些能力散落在业务进程里出了问题定位困难。而网关方案把所有的差异和策略收口在一个独立服务里上游只发 HTTP 请求。虽然多了一次网络开销但换来的是升级简单、集中治理、多语言天然支持。DMXAPI 选择了网关方案实测单次调用的额外延迟基本可以控制在 1-3ms 以内和上游业务自身的逻辑开销相比完全可接受。3. 核心实现细节与实操要点3.1 供应商适配器的抽象供应商适配器是 DMXAPI 里最容易被低估的部分。写出第一个适配器很容易写出一个能扛住生产流量的适配器很难。我抽象了四个接口方法来统一所有适配器interface ProviderAdapter { // 将上游统一请求翻译为供应商请求模型 translateRequest(unitReq: UnifiedRequest): ProviderRequest; // 调用供应商接口并拿到原始响应 execute(providerReq: ProviderRequest): PromiseProviderResponse; // 解析供应商响应为统一响应模型 translateResponse(providerResp: ProviderResponse): UnifiedResponse; // 异常与错误码映射 mapError(error: unknown, providerResp?: ProviderResponse): UnifiedError; }这里最值得说的细节是execute方法。之前我犯过错把 execute 只写成“发一个 HTTP 请求”后来发现不同供应商的超时设置差异很大。有的服务商用 30 秒才算超时有的 10 秒就必须断开。如果统一用一个超时值要么频繁误杀慢接口要么让用户等太久。所以我把超时策略做成了适配器级别可配置的一等公民每个适配器必须显式声明“建议超时时间”再由上层的 Resilience Layer 结合业务设置的期望值做最终计算。另一个关键是流式响应的处理。流式接口不能等全部内容返回后再解析需要在数据分块到达时边收边转发。DMXAPI 专门定义了一个StreamEvent类型适配器把供应商的流式事件统一转换成这个类型网关再按 SSE 格式往上转发。这一步如果设计不好后续做流式输出的日志追踪会非常痛苦。3.2 统一的并发与调度策略多模型混用架构里不同服务商的限流维度不同这是最容易出问题的点。我见过最经典的事故是某家服务商按每分钟 token 数限流团队没有做本地计数结果上游的突发流量直接把配额打爆服务商返回 429系统把这个 429 当成正常错误一路重试把重试音量又叠加上去最后触发了更严格的限流。DMXAPI 把限流做成了分层的第一层是网关级限流按上游 AppId 接口维度做 QPS 限制这是为了自我保护第二层是供应商级限流每个供应商实例维护一个本地滑动窗口计数器按该供应商的限流语义做预计算比如按 QPS、RPM、TPM 分别计数第三层是熔断供应商连续错误率达到阈值时触发快速失败避免调用方无谓等待。这里有个工程经验非常关键限流维度的预计算不能只靠请求次数还要结合消息的大小。Token 消耗型限流需要用“预估 token 数”来提前卡控。没有精确 tokenizer 时可以用“字符数除以 3 或 4”的粗略方式先估算宁可估得略高一点也不要估低。实测下来这个粗略估算的误差在 15% 以内足够用来做前置保护。3.3 降级、重试与熔断生产环境里最怕的不是某个供应商挂了而是挂了之后上游全链路都跟着等超时。DMXAPI 的做法是在路由层就内置了降级逻辑而不是等上游自己处理。我设计了一个RoutePolicy配置routePolicy: primary: provider-a fallback: - provider-b - provider-c degradationPercent: 20 retry: maxAttempts: 2 backoffBaseMs: 200 retryableErrorCodes: [timeout, rate_limit, 5xx]这个配置的含义是默认走 A挂了以后先切一部分流量20%到 B 做灰度验证再逐步放量重试只处理特定错误类型像 4xx 这种参数错误绝不重试。熔断器做成供应商级别的半开状态熔断后每隔一段时间放一个小流量试探成功率达到阈值就恢复达到不到就继续熔断。关于重试有个反直觉的点不要对“已经送到供应商手里但没拿到响应”的请求盲目重试。像生成回复这种幂等性差的场景重试会带来重复代码或重复扣费。DMXAPI 的做法是只对连接失败TCP 层断开、TLS 握手失败和明确的 5xx/限流做重试对“中间响应超时”的情况交给上游自行决策同时通过响应头把“是否可能重复”的标记传给上游。3.4 计量、计费与可观测性接口碎片化的一个隐性成本是“算不清楚账”。每家服务商的计费单位不一样有的按输入输出 token 分开计价有的按字符计价有的并发收费。DMXAPI 在统一响应模型里设计了标准化的计量字段{ usage: { promptTokens: 320, completionTokens: 128, totalTokens: 448, currency: CNY, estimatedCost: 0.0021 } }如果供应商不返回 usage适配器会按模型单价用本地估算逻辑补一个 estimatedCost并在isEstimated字段里做标记。所有计费原始数据落到独立的账单表里按天跑对账任务和供应商后台的账单做核对。这么做之后成本归因基本能做到 99% 以上可解释。可观测性方面每条请求进来时生成一个全局requestId在网关层和适配器层都打上该 ID 的日志业务方可以在响应头拿到这个 ID 用于排障。耗时拆分到三段网关内部耗时、供应商耗时、传输耗时。这样出了问题一眼就能看出瓶颈是在镜像服务本身还是直连服务商。4. 实操过程与关键代码实现4.1 最小可用的统一数据模型这部分直接给出我在项目中实际用的数据结构。上游请求的 JSON 被我收敛到最小集{ model: text-embedding-3, messages: [ { role: user, content: 你好 } ], temperature: 0.7, maxTokens: 2048, stream: false, routeTag: stable }这里的routeTag是 DMXAPI 的一个特色设计。上游不直接指定供应商而是指定业务语义stable / beta / cheap / fast路由层根据标签去匹配具体供应商。这样业务方完全不感知供应商切换哪怕某天你把某一家换成了另一家上游代码一行都不用改。统一响应模型固定为{ id: req_xxx, object: chat.completion, created: 1712700000, choices: [ { index: 0, message: { role: assistant, content: ... }, finishReason: stop } ], usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, provider: provider-a }注意provider字段要不要对上游暴露取决于业务需求。我做的是暴露给可信内部调用方方便排查如果是面向外部开放建议隐藏或只给 provider 代号。4.2 请求转换链的实现每个适配器内部我建议把“转换”拆成两部分请求转换器和响应转换器。请求转换器负责把上游请求结构映射到供应商结构。以某个供应商的 messages 结构为例上游是[{role, content}]供应商要求 content 是数组需要转成这样const translated { model: req.model.replace(provider-a-, ), // 去掉网关前缀 messages: req.messages.map((m) ({ role: m.role, content: [{ type: text, text: m.content }] })), temperature: req.temperature ?? 1.0, max_tokens: req.maxTokens ?? 2048, stream: req.stream ?? false, };响应转换器反向操作。供应商返回的格式里内容是output.choices[0].message但我们统一模型里叫choices[0].message转换时要做字段映射。同时要处理供应商特有的字段比如某个供应商返回的financial字段在统一模型里没有对应位置就放进扩展字段extra里宁可多保留也不要丢。这里要特别提醒不要试图把每个供应商的所有能力都映射进统一模型那样会让模型变得庞大且难以维护。设计上只保留 80% 的通用能力剩下 20% 的独特能力通过providerSpecific扩展透传。否则适配器越多模型越大最后把网关变成一个四不像。4.3 路由与负载均衡逻辑路由模块是 DMXAPI 里逻辑最灵活的部分。核心实现是根据 routeTag 查路由表得到一组候选供应商再按权重做选择def select_provider(route_tag: str, model: str) - ProviderRoute: routes route_table.get(route_tag) if not routes: raise UnknownRouteError(route_tag) candidates [ r for r in routes if r.enabled and model in r.supported_models and not breaker.is_open(r.provider_name) ] if not candidates: return fallback_route(route_tag) return weighted_random(candidates)这个函数看起来简单但有几层细节值得展开。第一weighted_random按权重选择后如果选中的供应商随后发现限流或错误率升高路由层会做一次“即时降级”选另一个供应商重试这个逻辑只在阈值内执行一次避免无限跳转。第二每个供应商的容量要从多个维度度量权重不能只配一个固定值。我见过一个供应商因为某个模型特别强导致所有流量都被调度到它那里结果直接把它的配额打满。后来给权重加了“按模型维度可调”的配置才解决。第三回退路由不能乱配。有一个原则主供应商和回退供应商在能力上必须能互相替代。如果主线是支持长上下文的模型回退却选了一个短上下文的模型上游业务会出诡异的行为问题。4.4 配置化管理供应商每接入一个新供应商除了写适配器代码还需要一份供应商配置文件。我把配置分成两块静态配置供应商名称、基础 URL、支持的模型列表、鉴权方式这类信息写死没风险启动时加载到内存即可。动态配置每个模型的价格参数、超时时间、最大并发、熔断阈值、启停状态这类信息要支持运行时修改我这里用存储配置中心加本地缓存实现修改后不需要重启服务。这里贴一份简化配置providers: - name: provider-a baseUrl: https://api.provider-a.example.com/v1 authType: header authKey: Authorization authValuePrefix: Bearer models: - text-embedding-3 timeoutMs: 15000 maxConcurrency: 12 circuitBreaker: errorRateThreshold: 0.3 windowSec: 60 halfOpenRequests: 3所有配置项在校验通过前不生效。特别是动态配置变更我会做一个“先下发到灰度环境验证、再全量生效”的流程。曾经有一次我直接在生产环境改了某供应商的最大并发数结果是并发调太高供应商侧告警我这边被限流连排查都要先绕道。那个教训让我把配置变更流程固化下来再也没跳过。5. 上线之后踩过的坑与排查实录5.1 超时参数不一致引发的“假慢”上线初期监控面板显示服务商 A 的 p99 延迟异常升高达到 12 秒但看服务商控制台它的服务端平均处理时间只有 800ms。排查日志发现网关设置的供应商超时时间是 60 秒供应商正常响应在 10 秒左右网络传输也不算慢为什么 p99 会到 12 秒最后定位到是代理层和网关的 HTTP 客户端连接池问题。网关对每个供应商维护了连接池默认的maxConnectionsPerRoute只在并发量高时不够用请求排队等待连接分配的时间全部计入了 p99。当时服务商 A 的模型特别慢单请求占住连接时间长后续请求只能排队所以 p99 被拉高。解决方法是按供应商配置独立连接池参数maxConnectionsPerRoute调大同时给每个适配器设置独立的readTimeout不要全局共用一个超时值。改完之后同样的流量下 p99 降到 1.2 秒左右。这个坑验证了之前做的“超时时间做适配器级配置”的设计是正确的。5.2 Token 计数的误差问题有段时间账单对账出现 5% 左右的差额排查发现来自嵌入模型。供应商 A 的 usage 字段统计了输入和输出的 token 总数但我们网关里做计费时只用了promptTokens completionTokens而供应商方计价时把 embedding 请求的输入 token 数乘以了一个系数所以两边对不上。解决办法是在适配器层增加一个“计费维度归一化”逻辑把每个供应商的 usage 结构映射到统一计量模型时额外记录一个billingUnits字段表示“扣减配额时使用的单位数”。这个字段只用作成本核算不参与业务逻辑。加了之后对账差额从 5% 降到了 0.1% 以内剩余的小差额基本是小数点取舍造成的可以接受。5.3 流式输出“卡死”假象有一次业务方反馈某个模型流式输出到一半就停了但服务日志显示请求是成功的没有报错。这个问题的诡异之处在于网关认为请求完成了但上游业务迟迟收不到完整的结束标志。排查发现是某个供应商的流式协议在最后一帧没有按标准 SSE 格式发送[DONE]标记而是直接关闭连接。我们的适配器在解析流时如果没收到特定结束标记就会一直等下一个事件最后超时。虽然有超时兜底但超时时间配得偏长给用户造成了“卡死”的感觉。修复思路是对每个适配器单独定义“流结束判定规则”有的以[DONE]为结束有的以特定数据帧为结束有的以连接关闭加待处理数据为空为结束。统一抽象成一个isStreamEnd(event)方法每个适配器自己去实现。这个经验也说明流式适配器不能只按标准协议假设必须实际抓包确认。5.4 错误码映射混乱导致重试风暴某个供应商在请求体格式错误时返回 400在 JSON 解析失败时也返回 400但这两类错误的处理策略完全不同前者是上游传参有问题重试也不会成功后者是网络中间层把响应截断了重试可能成功。一开始我们的适配器把所有 400 都映射成invalid_request错误重试策略不处理 400导致那些“响应被截断导致 JSON 解析失败”的请求全部失败。这个问题是加了一层“错误细分”解决的。适配器在解析供应商响应时如果发现是 JSON 解析异常会再检查底层异常类型。如果底层异常是连接重置或 SSL 异常就映射成network_error允许重试如果确实是 HTTP 400 带业务错误码就映射成invalid_request不重试。注意错误码映射是适配器里最不能偷懒的部分。宁可多花时间把每个可能的错误分支列清楚也不要笼统地按状态码一刀切否则上层重试策略和降级策略再完善也会被打穿。5.5 上游把“供应商报错”当“正常回复”处理还有一个非常隐蔽的坑出现在非流式接口上。某个供应商在某些情况下会返回 HTTP 200但 body 里的内容是一段错误提示文本而不是正常的完成消息。比如超过最大令牌数时它不返回 400而是返回一段“抱歉我无法继续”的文本。这让上游误以为模型只是拒绝了请求在模型能力评估时把这类响应当成了“安全拒绝”数据统计完全失真。解决方法是适配器层对响应内容做“启发式异常检测”如果发现 body 里的 message 内容以特定错误前缀开头就把它识别为供应商侧错误并改写为明确的错误类型返回。这个方法不能覆盖所有情况但能抓住供应商已知的坑给上层一个清晰的信号。6. 如果让你来做一套同样的网关6.1 关键的几个设计取舍复盘做完这个项目后复盘我觉得几个设计决策是这套方案成立的核心统一模型要克制不要追求全能力覆盖。只统一 80% 高频能力独特能力通过扩展字段透传能让系统演进非常快。否则每接一家新供应商都在改统一模型那这套方案也撑不了多久。适配器是系统的灵魂值得投入最多精力。统一模型定义的是“长什么样”适配器决定的是“怎么做到”。适配器写得好不好直接决定了网关的稳定性、准确性和扩展性。与其在路由策略上堆花活不如先把适配器做扎实。路由和适配器两层必须独立演进。路由策略调整是高频操作供应商适配器新增是低频操作但它们很容易被耦合在一起。我在项目中硬性规定路由代码不允许出现任何供应商特定逻辑适配器代码不允许做流量控制。这个约束在代码评审里反复强调确实有效。可观测性要从第一天就设计而不是上线后补。给每条请求分配全局 requestId 这步如果等到出问题再补很多历史调用链都无从追溯。DMXAPI 的日志规范在代码阶段就定好所以上线排障的时候效率明显高于以前的项目。6.2 什么情况下这套方案会“过重”也不是所有场景都适合做统一网关。我总结了一些触发条件如果你的项目命中这些信号我建议先不要上这么完整的方案你只接入了一家供应商且没有短期内切换的计划团队对各家服务商的接口差异还不了解没有积累足够的适配经验业务并发量和调用量还很小直接改调用代码的成本远低于维护一套网关没有独立的运维人力来保证网关的高可用。这几个条件如果中了 3 个以上可以先做一个轻量版的“接口转换工具包”把模型定义和转换函数抽出来复用但不做独立服务。等量起来以后再演进成网关不要一开始就背上完整架构。我们当时是明确了未来半年内一定会接入至少三家服务商、且上游有多个业务线才最终定了网关方案。6.3 一个可以立刻用起来的小技巧如果你已经决定要做类似的接入层哪怕初期只是一个转换函数库我建议从第一天就定义一个“供应商适配器接口”哪怕你的实现只有一个供应商。它可以只是一个约定、一个接口声明甚至是一份接口规范文档。这样做的好处是后来每增加一个供应商时你就有一个固定的“形状”去填充不会每写一次都从头想一遍流程。实际项目里这个约束带来的好处立竿见影第四家供应商接入时我们只花了半天就完成了主流程联调因为前三家已经把可复用的坑都趟平了。接口碎片化是典型的多模型混用架构演进到一定规模后必然出现的瓶颈。它不会因为第一天的技术选型聪明而消失也不会因为后面多写几个转换函数而根治。真正的解决方案是在架构层面建立一个稳定的统一入口把差异化收敛到适配层再由集中策略来保障稳定性。DMXAPI 就是按这个思路落地的。这套代码跑了大半年期间又接入了两家新的模型服务每次新增的改动量都控制在一个适配器加一份配置文件以内。如果当初没有做这个网关我大概还在业务代码里为每个供应商差异加班。
阅读完成 · 觉得有帮助?