ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多AI Provider架构设计:从统一接口到动态路由的接入层重构

多AI Provider架构设计:从统一接口到动态路由的接入层重构

一个多月前,我把 HagiCode 平台的核心接入层从“单 Provider 写死”重构成了“多 AI Provider 架构”。今天把整个过程中的设计取舍、代码落地方案以及踩过的坑整理出来,算是给后端团队和做 AI 应用集成的朋友一份参考。这里说的 AI Provider,不是指某个具体的模型文件,而是指模型服务的提供方——可以是 OpenAI 系的 API、Claude API、国内大厂的开源模型服务,也可以是本地用 vLLM 或 Ollama 拉起来的推理节点。HagiCode 的特点在于它不是一个单模型的玩具项目,而是同时面向代码生成、问答、Agent 任务编排等多个场景,不同场景对模型的延迟、上下文长度、成本、能力边界要求都不一样,所以从一开始,接入层就不能绑定在某一家服务商上。

如果你现在正处在“先接一家跑通,后面再说”的阶段,那这篇文章尤其适合你。我会把顶层的接口设计思路、统一请求模型怎么定、适配器怎么写、动态路由怎么做,以及上线后我们真实遇到的那些日志异常、超时雪崩、配额打满问题,全部摊开讲。

1. 多 AI Provider 架构的整体设计思路

1.1 核心问题:模型服务碎片化

先聊清楚我们到底在解决什么问题。HagiCode 平台上跑的每一个业务请求,最终都要变成一个“模型调用”。表面上看,这不就是调一个 HTTP 接口吗?但真的把 OpenAI、Claude、通义、智谱、本地 vLLM 全部接入之后,你会发现每一家的请求格式、鉴权方式、限流语义、流式返回格式、错误码体系都不一样。如果你在业务代码里直接为每一家写一个客户端,那这个“客户端”的维护成本会随着接入家数线性上涨,而且每增加一个新场景,都要在各个客户端里改一遍参数拼装逻辑。

更麻烦的是模型本身的能力差异。比如代码生成场景需要 32K 甚至更长的上下文,Agent 场景对工具调用(function calling)的稳定性要求极高,而短问答场景更在乎首字延迟。这些差异如果都塞进业务逻辑里判断,代码很快就会烂掉。所以我们做的第一件事,就是把“调用哪个模型”这件事从业务代码里抽出去,让上层只关心“我想要什么效果”,由架构层去决定“找谁、怎么调、失败了怎么办”。

1.2 架构目标:标准化、可切换、可扩展

动手设计之前,我们先定了三条架构原则。第一条是标准化:对外只暴露一套统一接口,屏蔽所有 Provider 的差异;第二条是可切换:同一个业务场景,能在不发布新版本的前提下,通过配置把流量从 Provider A 切换到 Provider B;第三条是可扩展:接入新 Provider 时,不能改动上层任何业务代码,只需要新增一个适配器。

这三条原则听起来像是教科书里的口号,但落地时每一条都会逼你做出一堆具体决策。比如“标准化”要求你必须先定义清楚什么叫一次模型调用,它的输入有哪些字段,输出有哪些字段,错误怎么表达。“可切换”要求你不能把 Provider 的 API Key、base_url、模型名直接写死在配置文件里,而是要有一个动态的配置中心和路由表。“可扩展”则要求你设计好 Provider 注册机制,让新增适配器像插 U 盘一样简单。

1.3 方案选型:为什么用适配器模式

最终我们选用了经典的适配器模式作为骨架。每个 Provider 实现同一个ProviderClient接口,接口内部做协议转换。这个模式本身没什么新鲜的,真正关键的是接口里应该放什么方法、不放什么方法。我们见过很多团队把接口定义得过大,什么generate(),generateStream(),embed(),rerank(),countToken()全塞进去,结果每个新适配器都要实现一堆用不到的方法,成本反而更高。

我建议的接口粒度是:只保留你当前业务真正依赖的能力。以 HagiCode 为例,核心就三件事——普通对话补全、流式对话补全、工具调用。所以ProviderClient只需要暴露chat()和chatStream()两个方法,工具调用通过请求参数里的tools字段表达,而不是单独拆一个方法。Embedding 我们单独做了一个EmbeddingClient接口,因为它的输入输出形态和对话完全不一样,硬放一起只会互相妥协。

2. 核心细节解析与实操要点

2.1 统一请求/响应模型:屏蔽各家差异

接口抽象好之后,最考验功底的就是统一请求模型的设计。我们定义了一个HagiRequest类,核心字段包括messages、tools、model_preference、temperature、max_tokens、stream、timeout_ms。有几个字段是我们踩坑之后才补上的,这里重点说。

第一个是model_preference。这不是让你指定具体模型名,而是表达一种倾向,比如“代码生成场景优先用 32K 上下文的模型”“Agent 场景优先用工具调用稳定的模型”。真正的模型名映射放在 Provider 适配器内部。这样做的原因是,不同 Provider 的模型名随时可能变化,比如某个开源模型的版本从 1.5 升到 2.0,名字可能变成 v2,如果上层业务直接写死模型名,那每次模型升级都要联动改上层代码。通过 preference 间接映射,模型升级只改适配器里的映射表。

第二个是timeout_ms,这个后面会单独讲,但它必须出现在请求模型里,因为不同 Provider 的响应速度差异实在太大,同一个超时配置根本行不通。第三个是allowed_failover_providers,用于指定当首选 Provider 失败时,可以自动切换的备选列表。这个字段一开始没设计,后来线上出现了“首选 Provider 抖动,但备选 Provider 一直空闲”的情况,才补上。

响应模型相对简单,核心就是一个HagiResponse,包含content、tool_calls、finish_reason、usage和raw_metadata。其中raw_metadata保存 Provider 返回的原始 JSON,这个字段在排查问题时极其有用。很多团队把 Provider 的原始响应丢弃只保留自己解析后的字段,结果出了问题根本没法定位是谁的问题。

2.2 能力矩阵与动态降级策略

多 Provider 架构里,最容易被低估的是“能力管理”。不同 Provider 的能力差异不是只有上下文长度,还包括是否支持 streaming、是否支持 tools、是否支持 system prompt、最大输出 token 数、并发上限等。我们用一个能力矩阵表来管理这些信息,并且把这张表放在配置中心里动态更新,而不是硬编码在代码里。

Provider上下文长度支持流式支持工具调用最大输出备注
Provider A128K是是8K默认代码场景
Provider B32K是是4K工具稳定
Provider C8K是否2K低价备用
Local vLLM动态是部分动态内网高可用

这张表不只是给人看的,它会被加载到路由器的内存里。请求进来后,路由器先根据场景筛选出满足能力的 Provider 列表,再结合优先级、健康状态、当前负载做最终选择。如果首选 Provider 在能力矩阵里就不满足请求要求,比如请求需要 64K 上下文,但首选只有 32K,那就直接跳过它选下一个。

动态降级的思路也很直接。每个 Provider 都有一个健康状态,由最近一段时间的成功率、平均延迟、错误率共同决定。当一个 Provider 连续多个请求失败或超时,健康状态置为“亚健康”,路由器会自动降低它的权重,甚至直接从候选列表里摘掉。这个过程不需要人工介入,但要配合一个重要的前置条件:所有请求必须有可观测性数据,否则降级就是瞎子摸象。

2.3 路由规则:场景优先还是成本优先

路由策略我们做了两层。第一层叫“能力过滤”,就是上面说的能力矩阵;第二层叫“加权选择”,根据场景配置的权重来做最终决策。比如代码生成场景的配置是:Provider A 权重 70,Provider B 权重 30;短问答场景的配置是:Provider C 权重 50,Provider B 权重 50。权重是动态的,运维可以随时在配置中心调整。

这种做法比“写死首选 Provider”灵活很多。举个例子,某个时段 Provider A 因为官方限流频繁返回 429,我们把 A 的权重临时降到 10,流量就会自动灌到 B 上。等 A 恢复了,再把权重调回来。整个过程不需要发布版本,配置中心一刷新就生效。

成本控制我们放在路由之后。请求已经选中某个 Provider 时,再判断当前 Provider 的已用配额是否超限。这里要注意,成本控制不能做成“全局计数器”这种简单形式,因为多实例部署时本地计数器根本不准。我们用的是 Redis + Lua 脚本做分布式配额统计,每 5 秒从内存往 Redis 同步一次,允许一定的松弛量,避免频繁访问 Redis 拖慢主链路。

3. 实操过程与核心环节实现

3.1 从零封装一个 Provider 适配器

下面我把 Provider 适配器的写法拆开讲,以我们实际在用的ProviderClient接口为例。定义如下:

class ProviderClient(ABC): @abstractmethod def chat(self, request: HagiRequest) -> HagiResponse: """非流式对话补全""" pass @abstractmethod def chat_stream(self, request: HagiRequest) -> AsyncIterator[HagiResponse]: """流式对话补全,返回异步迭代器""" pass @abstractmethod def provider_name(self) -> str: """返回 Provider 标识""" pass @abstractmethod def check_health(self) -> bool: """健康检查""" pass

以接 OpenAI 兼容协议的服务为例,适配器的核心逻辑就是把HagiRequest映射成 OpenAI 的ChatCompletion请求格式。这里有一个非常容易踩的坑:messages里的角色映射。我们平台内部使用的角色只有system、user、assistant和tool,但各家对工具调用返回消息的格式要求差异很大。OpenAI 要求工具调用结果用role=tool的消息回传,并且必须带tool_call_id;而某些国产厂商要求把工具结果放在role=user的消息里,用纯文本拼进去。这个差异如果不屏蔽,上层业务根本无法复用一套消息组装逻辑。

我的做法是:平台内部统一用“标准化消息序列”,适配器负责把标准化消息转换为各 Provider 的格式。这样上层业务永远只处理一种消息结构,所有兼容性逻辑都封装在适配器里。有人说这样会增加适配器复杂度,但换来的是业务层的长期稳定,绝对值。

3.2 配置中心与热更新机制

配置是这个架构的命脉。我们用了 Nacos 作为配置中心,所有 Provider 的 API Key、base_url、模型映射表、能力矩阵、路由权重、超时时间全部放在配置中心管理。每个服务实例启动时拉一次全量配置,之后订阅配置变更事件。

配置项按 JSON 结构组织,大致长这样:

{ "providers": { "provider_a": { "base_url": "https://api.example.com/v1", "api_key": "${ENC:xxx}", "models": { "code-32k": "actual-model-name-v2", "chat-fast": "actual-model-name-lite" }, "capabilities": { "max_context": 128000, "support_stream": true, "support_tools": true, "max_output": 8192 }, "weight": 70, "timeout_ms": 30000 } }, "router": { "code_scene": ["provider_a", "provider_b"], "chat_scene": ["provider_c", "provider_b"] } }

这里分享一个经验:API Key 不要明文存在配置中心里。我们在配置中心集成了一层的加解密插件,配置里存的是密文,服务启动时用本地密钥解密后加载到内存。这样即使配置中心被脱库,也不会直接泄露密钥。这个细节可能很多小团队会忽略,但它其实是生产环境的底线。

热更新要注意一个问题:内存中已经存在的长连接或者正在进行的请求,不能被配置变更打断。我们实现时用了双缓冲方案,新配置加载到新的 map 里,等内存切换完成后再释放旧 map。整个过程对业务无感,但如果在切换时恰好有请求正在使用某个 Provider 客户端,不能让它在调用中途被销毁。Java 里可以用AtomicReference指向当前配置快照,每次请求时从快照里获取客户端,更新时直接替换快照引用,这样正在执行的请求不受影响。

3.3 流式响应的链路实现

流式输出是多 Provider 架构里技术含量最高的环节,因为流式天然就是“长连接 + 后台任务 + 客户端推送”的组合,链路比普通请求长得多。我们的实现链路是:客户端发起流式请求 → API 网关接收 → 消息队列或直接 HTTP 长连接转发给后端服务 → 后端服务调用 Provider 的流式接口 → 数据块回来之后立即推送给客户端。

这里面有一个很关键的设计决策:要不要在中间层缓冲整个流式响应?我们的结论是坚决不缓冲,必须边收边推。如果你把 Provider 的完整输出都收完了再一次性推给用户,那流式交互的体验就完全没有意义了,用户等了半天一个字符都没看到,直到全部生成结束才突然冒出一大段文字。这种体验在 AI 对话场景里是不可接受的。

实现流式时,Python 用 async generator 加上asyncio.Queue做逐块转发非常顺手。核心流程是:一个任务负责从 Provider 拉数据块、放入 queue;另一个任务负责从 queue 取数据块、推给客户端。这样即便 Provider 长时间没有返回新数据,连接也不会断开,因为我们的推流任务一直在等待 queue 里的数据。

async def chat_stream(self, request: HagiRequest, provider_client: ProviderClient) -> AsyncIterator[bytes]: queue: asyncio.Queue = asyncio.Queue(maxsize=100) async def producer(): try: async for chunk in provider_client.chat_stream(request): queue.put_nowait(chunk) except Exception as e: queue.put_nowait(ERROR_SIGNAL) finally: queue.put_nowait(END_SIGNAL) task = asyncio.create_task(producer()) while True: item = await queue.get() if item is END_SIGNAL: break if item is ERROR_SIGNAL: yield format_error(...) break yield item await task

maxsize=100的队列起到一个背压作用,如果客户端消费速度跟不上 Provider 的生产速度,队列满了之后生产者会暂停,避免内存无限制增长。这个细节在高峰期特别重要,因为流式响应的数据块可能非常密集,处理不当就会把内存打爆。

3.4 可观测性建设:日志、指标与链路追踪

多 Provider 架构上线后,可观测性不是锦上添花,是命脉。想象一个场景:用户在平台上提了一个问题,整个响应链路涉及客户端 → 网关 → 后端 → Provider → 后端 → 客户端,中间任何一环出问题都会导致体验异常。如果没有全链路追踪,出了问题你根本不知道用户在哪个环节卡住了。

我们为每一次模型调用生成了一个唯一的trace_id,贯穿整个请求链路。日志里会记录:Provider 名称、请求的 preference、实际命中的模型名、首字延迟、总耗时、输入 token 数、输出 token 数、错误类型、重试次数、是否触发了降级。这样每次出问题,都能用trace_id把散落在各处的日志串起来。

埋点指标我们主要关注四类:QPS、平均首字延迟、平均完成延迟、错误率。每类指标都按 Provider 维度打标签,方便对比各家的服务质量。上线一段时间后我们用一个简单的报表发现,某个 Provider 的流式首字延迟一直偏高,但非流式请求延迟正常,最后定位出是它服务端的流式通道有排队问题。这个结论如果没有按 Provider 维度拆指标,根本看不出来。

4. 生产环境踩坑实录

4.1 各家 API 的“假对齐”

多 Provider 架构里最隐蔽的坑,是各家 API 声称兼容 OpenAI 格式,但实际上有不少细微差异。我们接过的某家厂商文档写着“兼容 OpenAI”,结果实际调用时机试发现它的system消息支持度很差,有时候会被静默忽略,有时又会插入到对话历史最前面导致角色顺序乱掉。这种问题,单测根本发现不了,因为单测的 prompt 太短、太规整,只有真实业务里那种长多轮对话才会触发。

解决方案是写一个兼容性压测脚本,专门构造各种边界案例,比如空 system、超长 user 消息、连续多条 assistant 消息、工具调用结果带特殊字符等,逐一验证每个 Provider 的行为。我们每接一个 Provider 都要跑这套脚本,不合规的直接拉黑或要求厂商修复。这个脚本值得你花时间做好,它能帮你规避掉 80% 的“假对齐”坑。

4.2 限流与配额的分布式一致性

说到限流,第一反应是“简单的计数器”。但多实例部署时,本地计数器各统计各的,同一个 Provider 的配额被多个实例各自消耗,总量根本对不上。我们曾出现过这样的问题:配置中心里设置某个 Provider 每小时最多调用 1 万次,结果因为有 6 个后端实例,实际调用了接近 6 万次才被触发限流。这不仅导致成本失控,还直接打到 Provider 的硬限流接口上,换来一堆 429。

最终我们用 Redis + Lua 脚本做分布式计数。核心逻辑是:每次调用前,执行一个原子的 INCR 命令,如果计数超过阈值,就拒绝本次调用。时间窗口用 Redis 的过期时间实现,每小时一个 key。这里要注意的是,分布式计数会引入额外的 Redis 访问延迟,所以我们采用了“本地配额缓存 + 定期同步”的策略,每个实例预取一定量的配额(比如总量的 10%)放在本地内存里消耗,不够了再去 Redis 取。这样既保证了整体可控,又不至于把主调用的延迟拖高。

4.3 流式连接的稳定性与超时策略

流式请求的超时处理比非流式复杂得多。非流式的逻辑很简单,超过timeout_ms就报超时;但流式请求可能是合法的长时间思考,也可能是 Provider 卡死没了心跳。如果简单地用总耗时判断,很容易误杀“合法长思考”;如果完全不设超时,又可能被一个坏连接拖住线程池。

我们最后用的方案是“空闲超时 + 最大时长”双阈值。空闲超时是 60 秒,意思是如果连续 60 秒没有收到任何数据块,就判定连接异常,主动断开并触发降级。最大时长是 10 分钟,防止一些异常情况导致连接不关闭。实际运行下来,60 秒的空闲超时误伤率极低,因为正常的流式输出即使思考再久,通常也会有心跳包或阶段性输出。

另一个容易被忽略的细节是:Provider 返回的 HTTP 状态码虽然是 200,但流式中间可能穿插着data: [DONE]之前就发来的错误信息。有些 Provider 会把错误信息封装成最后一段数据块,而不是返回 HTTP 5xx。我们必须在解析层判断:数据块里如果携带了error字段,即使在流式中间,也要终止流并抛出业务异常。否则客户端会拿到一个截断的半成品内容,用户都搞不清是生成完了还是崩了。

4.4 降级风暴与雪崩防护

最后分享一个我们真实经历的“降级风暴”。有一次首选 Provider 官方出现大范围故障,所有请求都超时。我们的健康检查机制很快把它的状态置为不健康,流量自动切换到备选 Provider。但问题是备选 Provider 平时只承担 30% 的流量,突然窜升到 100%,直接把它也打爆了。两个 Provider 都返回错误之后,我们的重试逻辑开始疯转,健康状态反复摇摆,整个平台响应质量急剧下降。

这次事故让我们补了两个机制。第一个是重试必须带退避,不能无限重试。我们的默认策略是:第一次失败等 500ms 重试,第二次失败等 1s,最多重试 2 次。第二个是降级保护:当系统整体的错误率超过一个阈值时,自动进入“熔断模式”,后续请求不再路由到已故障 Provider,而是直接返回一个降级响应(比如让用户稍后重试)。熔断恢复采用半开策略,每 10 秒放一个探测请求过去,成功率达到要求才逐步恢复流量。这个机制非常老套,但在多 Provider 场景下价值巨大,它能保住平台在依赖方大规模故障时不至于一起死。

5. 架构演进过程中的个人体会

多 AI Provider 架构听起来高大上,本质上做的是一件朴素的事:把不稳定、多样化、快速变化的外部依赖,隔离在一个可控的边界之内。边界内的业务代码不感知外部变化,边界外的适配器负责吞噬一切差异。只要你认可这个前提,很多设计决策就会变得顺理成章:为什么接口要最小化,为什么配置要动态化,为什么要做能力矩阵,为什么要把可观测性当第一公民。

如果后续要在这个架构上继续演进,我会优先做两件事。一是把模型路由从“规则配置”升级成“基于反馈的自动路由”,也就是通过采集每次调用的质量评分,让路由自动偏向效果更好的 Provider,而不是靠人去调权重。二是把适配器的工具调用部分做成一个独立的 Agent 协议层,让各家工具调用格式的差异收敛得更彻底。这两块做完,多 Provider 架构才算真正进入了智能调度阶段。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进