做AI编程平台的朋友应该都有同感:最开始我们接第一个大模型的时候,整个系统简单得像一张白纸,SDK一拉、API一调、流式一接就完事了。但等HagiCode开始接第二个、第三个AI Provider,问题就来了——各家接口风格不一样、参数语义不一样、计费单位不一样、能力边界不一样,如果还是“每接一个模型就改一遍业务代码”的节奏,整个系统很快会变得没法维护。
HagiCode是一个以代码生成为核心的AI辅助开发平台,它要对用户同时提供代码补全、代码评审、自动修复、Agent化编程这些能力。这些能力不能绑死在某一家的模型上,原因很简单:没人能保证某一家模型永远不宕机、永远不涨价、永远在代码场景里保持最好的效果。所以我们的核心架构目标从一开始就定成“多AI Provider统一接入、动态路由、故障隔离、成本可算”。这篇文章就把我在HagiCode里设计多Provider架构的完整思路、踩过的坑、以及最终落地的方案整理出来,给正在做类似事情的同学一个参考。
1. 为什么 HagiCode 必须做多 AI Provider 架构
1.1 单一 Provider 的痛点,不是“怕被卡脖子”这一个理由
很多人一听多Provider,第一反应是“防止被某一家厂商绑定”,这个理由当然对,但在实际产品里更迫切的痛点是稳定性、成本和质量这三件事。
先说稳定性。2023年到2024年那阵子,大厂模型的API隔三差五出高延迟,特别是高峰期,请求排队能排到几十秒。如果HagiCode只依赖某一个Provider,用户那边就是代码生成转圈圈、几分钟不给结果,投诉率直接拉满。我们在早期也确实因为某个Provider的故障导致整个代码补全功能不可用,那一次之后,我们就把多Provider从“可选项”变成了“必选项”。
再说成本。不同模型的定价差距非常大,同一个代码生成任务的成本可能差出5倍以上。如果把所有请求都往最贵的模型上送,预算根本撑不住。反过来,如果都用便宜的模型,代码质量又会下滑。所以我们要做的是让不同成本、不同能力的模型各司其职:简单任务走便宜的快模型,复杂任务走贵的强模型。这个诉求技术上其实就是路由,但路由的前提是你得先有多个Provider可以选。
再说质量。代码生成这件事非常看重模型的代码专项能力,今天这个模型在某类语言上表现好,明天那个模型在架构设计上更强,而且新模型层出不穷。产品如果没有多Provider架构,想换一个更好的模型就得动核心链路,风险极高。有了统一抽象层之后,换模型只是加一条配置、改一下路由权重,业务代码一行不用动。
1.2 多 Provider 要解决的核心问题,不只是“能联通”
接口联通只是最底层的事。真正难的是把差异全部吞掉,让上层业务感觉不到背后有好几家厂商在服务。
最核心的是三个问题:一是请求与响应的语义统一,比如“上下文长度”这个概念,有的家叫max_tokens,有的叫max_output_tokens,有的用token计数,有的按字符计数;二是流式输出的协议对齐,有的用OpenAI风格的事件流,有的用Message API的增量结构,如果不对齐,前端就要为每一家写一套解析逻辑;三是工具调用(Function Calling)的格式归一化,在Agent化编程场景下,模型要能返回调用代码搜索工具、执行Shell命令的指令,而每家厂商返回的结构都不一样。
另外还有一类问题是不可忽视的——能力差异。同一个请求发给不同模型,token消耗可能一样,但效果天差地别;同一个问题时序下,有的模型支持128K上下文,有的只支持32K。如果架构不去管这些差异,最终呈现给用户的就是“同一个功能,换个模型就变了一个样子”。
所以多Provider架构的本质是:给上层业务一张稳定、统一、可预期的接口,把下层混乱的Provider差异全部包在适配层里消化掉。这张接口一旦设计得好,后面的路由、熔断、成本核算全部都是锦上添花;如果接口设计拉胯,那就只能是无穷无尽地打补丁。
1.3 Agent 化场景让架构复杂度提升了一个量级
HagiCode的主要场景是代码生成和代码修复,但到了Agent化阶段复杂度就完全不一样了。Agent不再是简单的一问一答,而是一个多轮循环:模型思考、调用工具、拿到结果、继续思考、再调用工具,直到任务完成。这个过程中每一步都可能涉及到与Provider的交互,而且每轮都需要把历史上下文重新发一遍。
在这个场景下,多Provider架构的困难点就变成了:模型A支持Function Calling,模型B不支持或者格式很奇怪;模型C在循环调用工具时上下文管理有缺陷,用着用着就开始丢前面的消息;模型D返回了工具调用结果但格式和模型A完全不同。如果不在抽象层统一样式,上层Agent就没法用统一逻辑去驱动所有模型。我们后来把工具调用彻底归一化成内部统一的ToolCall/ToolResult结构,Agent只管消费这种结构,根本不关心背后是谁在回复。
2. 三层架构设计:API 层、编排层、Provider 适配层
2.1 分层的核心思想:把“业务”和“供应商”彻底拆开
HagiCode的多Provider架构最后收敛成了三层,从外到内分别是API层、编排层、Provider适配层。这个分层结构跟很多同学熟悉的微服务三层还不一样,它不是按服务拆的,而是按职责拆的。
API层负责对外暴露统一的接口,不管是聊天补全、代码评审还是Agent任务,业务侧只面向这一层编程。API层的请求体和响应体全部是HagiCode自定义的协议,比如消息结构、工具结构、用量结构,都由我们自己定义。好处是前端SDK、业务后端、Agent编排逻辑永远面对同一套语义,哪怕底层Provider全换一遍,对外协议也稳定不变。
编排层是核心大脑,负责路由选路、负载均衡、熔断降级、重试、成本计算这些横向逻辑。所有进入API层的请求,编排层会根据任务类型、模型能力、成本预算、当前Provider健康状态,决定把请求发给谁。这一层的设计原则是“不碰业务语义、只做决策和流量控制”,所以它不关心你是要生成代码还是审代码,它只关心该走哪条链路、如果失败要不要换一条。
Provider适配层是最脏最累的一层,也是我们花时间最多的一层。每个Provider对应一个Adapter,它负责把HagiCode的统一请求翻译成对应厂商的API请求,再把厂商的响应翻译回HagiCode的统一响应。这一层必须做到“一个Provider一个Adapter,互不依赖,可以独立发布”。这样当上游新出一个模型时,我们只需要写一个Adapter然后注册进系统,整个平台就能用了。
2.2 统一请求与响应模型:一张表把差异拉平
统一模型是整个架构的基石。我们在设计内部协议时,参考了主流Provider的接口,定了下面这套最小约定。
内部统一请求模型包含几个字段:任务类型、模型选择器、消息列表、工具声明、生成参数、上下文策略。其中生成参数特意做成“宽松语义”,比如温度我们允许传0到2之间的浮点数,但在Adapter里各家的解释不同,我们就由Adapter自己去做映射。请求里还会带一个requestId,这个ID是全程审计和幂等的基础,后面讲重试时会用到。
统一响应模型则包含三类关键信息:结果的文本内容、工具调用序列、用量信息。文本和工具调用比较容易理解,用量信息这里要单独强调一下。每家厂商返回的用量字段不一样,有的叫prompt_tokens,有的叫input_tokens,有的直接给usage对象。我们在统一模型里统一成promptTokens、completionTokens、totalTokens,由Adapter负责换算。这样上层成本系统和监控系统才能拿到一致的数字。
class ChatRequest: request_id: str task_type: str # code_generation / code_review / agentic_task messages: list[Message] tools: list[ToolSpec] | None temperature: float | None max_tokens: int | None model_selector: ModelSelector class ChatResponse: request_id: str content: str | None tool_calls: list[ToolCall] | None usage: Usage provider_name: str model_name: str这套模型看起来简单,但它在整个架构里承担的角色很重。我们的经验是:内部统一协议一定要往“最小化”设计,不要试图把所有Provider的花哨特性都收进来,否则Adapter会越写越复杂。如果某个Provider有独特能力,比如视觉输入,那就用“能力标签+可选字段”的方式做扩展,而不是污染核心结构。
2.3 流式输出与工具调用的适配:最考验细节的地方
流式输出是代码生成产品的基本需求,用户希望像打字机一样看到代码逐字输出,而不是等十几秒一次性返回。但各家Provider的流式响应结构差异很大,我们踩过不少坑。
OpenAI风格的流式返回是增量事件,每段文本更新在delta.content里;Anthropic风格的流式则是按事件类型区分,content_block_delta、message_start、message_stop等事件。Gemini也是自己的结构。如果不做归一化,这些差异会被直接暴露给前端,前端就要为一个功能写多套解析器。
我们的做法是把流式输出统一成内部事件流:文本增量、工具调用片段、结束事件、错误事件。Adapter层负责把各家的事件翻译成内部事件,翻译工作是流式进行的,不做整段缓冲。这里要特别小心的是工具调用的流式返回,OpenAI会增量地拼装参数,所以我们内部事件里设计了tool_call_id和argumentsDelta,让上层按片段累积,而不是等完整JSON。
class StreamEvent: event_type: str # text_delta / tool_call_delta / done / error text_delta: str | None tool_call_id: str | None tool_name_delta: str | None arguments_delta: str | None工具调用的归一化是另一个重灾区。各家对工具调用的命名、字段、结构都不一样,有的叫tool_calls,有的叫tool_use,而且内部的JSON格式也不同。我们不约而同地选了“内部统一结构、Adapter转换”的方式:统一请求把工具定义转成内部格式,Adapter负责映射;返回时不管原始结构长什么样,都统一成id、name、arguments三个字段。这看起来像笨办法,但Agent调度层只认这个结构,开发效率提升非常明显。
2.4 为什么没有把 Provider 拆成独立微服务
在设计过程中确实考虑过要不要给每个Provider单独起一个服务,后来放弃了。原因是HagiCode的大部分请求都是同步等待模型返回,本身天然就是一个IO密集型操作,进程内异步调用完全足够。如果拆成微服务,每加一个Provider就是一套独立部署和运维,网络开销也会增加,对一个早期项目来说负担太重。
拆微服务的唯一好处是不同Provider之间物理隔离,一个Provider的崩溃不会拖垮其他Provider。但实际上我们用进程内隔离加熔断也达到了类似效果:Adapter进程内由编排层统一调度,单个Provider的错误率超标后直接熔断,不再往这个Adapter发流量,效果上和一个服务宕机被摘除是近似的。
当然如果后面HagiCode的某个Provider流量大到需要独立扩缩容,或者某个Provider的SDK依赖和主进程有冲突,那拆出去也有充分理由。我的建议是:不要上来就微服务,先把进程内分层做扎实,等数据证明哪个Provider需要独立部署再拆,运维复杂度和成本都会可控很多。
3. 路由、熔断、重试、计费这四个机制怎么落地
3.1 路由规则:不是随机分发,而是按任务语义和成本预算选择
路由是多Provider架构里最体现产品策略的地方。简单随机负载均衡肯定不行,因为不同任务的模型需求完全不同。代码补全需要低延迟小模型,复杂重构需要强逻辑大模型,Agent任务需要Function Calling能力强的模型。所以我们给每个模型打上了能力标签,再配合路由规则表。
路由规则表是一张可配置的JSON结构,包含任务类型、可用的Provider列表、权重、以及匹配条件。每次请求进来,编排层先根据任务类型和条件过滤出候选Provider列表,再按权重做加权随机选出一个。权重的作用是流量分配和灰度:比如先给新模型10%流量,观察一段时间没问题再逐步调高到30%、50%。
{ "router": { "rules": [ { "task_type": "code_generation", "candidates": ["deepseek-v3", "claude-sonnet", "gpt-4o"], "weights": [60, 25, 15], "condition": {"context_length_required": "<=64k"} }, { "task_type": "agentic_task", "candidates": ["claude-opus", "gpt-4o"], "weights": [70, 30], "condition": {"tool_calling": true} } ] } }为什么不用纯随机或者轮询?因为不同Provider的处理速度、成功率和成本不一样。如果两个Provider各50%权重,但A比B慢一倍,用户感受到的延迟就会被拉高。我们后来引入了动态评分路由,根据最近时间窗口内的平均延迟、错误率、成本综合计算分数,再按分数比例分配流量。这个机制要等到基础路由跑稳定了再加,否则问题太多,分不清是路由问题还是数据问题。
3.2 熔断与降级:让一个 Provider 的故障不影响整个产品
熔断的核心思路是:每个Provider维护一个健康状态,当在滑动窗口内的错误率达到阈值时,状态变为“开路”,直白说就是不再往这个Provider发流量。过一段时间后再放一小部分请求试探,如果恢复就重新闭合,如果还是失败就继续打开。
这个机制对一个多Provider系统至关重要。某个Provider的API挂了,如果没有熔断,所有请求照样往它那里打,结果就是大量超时和堆积,整个平台的请求都被拖住。有了熔断,Provider一进入开路状态,编排层马上把流量切到备用Provider,用户的体验几乎感知不到变化。
熔断的实现我们用的是滑动窗口计数器,每30秒统计一次总请求数和失败数。失败的定义不光是HTTP错误,还有超时、流式中断、无效响应这些。注意不要把“上游返回业务错误”也算成故障,比如模型因为内容审核拒绝回答,这种错误是正常的,不应该触发熔断,否则用户一触发内容风控就会把整个Provider熔断掉,影响面太大。
class CircuitBreaker: def __init__(self, window_size=30, error_threshold=0.5, min_requests=20): self.window_size = window_size self.error_threshold = error_threshold self.min_requests = min_requests self.state = "closed" # closed / open / half_open self.requests = [] self.failures = 0 def record(self, ok: bool): # 滑动窗口记录请求结果 ... def allow(self) -> bool: if self.state == "open": if self.half_open_timeout_expired(): self.state = "half_open" return True return False if self.state == "half_open": # 只放单个请求探测 return self.probe_used is False return True降级链的设置也很有讲究,每个任务类型都有一条候选Provider链,比如主选A,备选B,再备选C。熔断后自动跳到链上的下一个Provider。这里有一个细节我们吃过亏:不要把两条链设计成完全一样的权重顺序,否则A故障时所有流量同时打到B,B马上也会被打爆。正确做法是把B作为主选的场景分散开,让故障流量撞上的是“平时已经在分担流量”的Provider,扩容压力小得多。
3.3 重试与幂等:流式场景下的重试不能无脑重发
重试看起来简单,但在流式输出场景下非常容易出事故。非流式请求失败后直接重发就好,但流式请求用户可能已经看到了一半输出,此时如果重新把整个请求发一遍,用户就会看到一段重复代码又从头开始输出,体验很差。
我们的策略是分场景处理:如果流式请求在“还没有吐出任何内容”之前失败,可以自动重试,因为用户没有感知;但如果已经吐了一部分内容,就不能自动重发,需要让上游返回一个可恢复的标记,由编排层决定是否调用Provider的续传能力或者走其他Provider把剩余部分补完。大部分Provider没有续传能力,所以我们更保守:一旦有内容吐出,宁可让用户手动重试,也不自动重发。
幂等的核心是requestId。在重试场景里,每次重试都带上同一个requestId,这样上游如果支持幂等,可以避免重复扣费。另外我们在内部审计系统里用requestId串联所有日志,一张请求从进入平台到最终返回,经历了哪些Provider、哪几次重试、花费多少,全部可以追踪。这个设计一开始就要做,后面再补会非常痛苦。
3.4 Token 计量与成本核算:模型一多,钱的问题就会失控
多Provider接进来之后,成本核算的复杂度立刻上升。每个Provider的计费方式不一样,有的按token计费,有的按字符计费,有的甚至按调用次数计费。而HagiCode需要给用户展示使用量、给内部做预算控制、给运营看不同模型的成本分析,所有这些的前提是——所有Provider的计费数据必须归一化到同一个口径。
我们的做法是在统一响应模型里,由Adapter把不同Provider的用量全部换算成token口径,并额外记录一条原始计费单位信息。成本系统再根据“内部换算后的token数 + 当时该Provider的单价”计算出真实成本。因为价格会变动,所以我们必须记录的是“当时”的单价快照,而不是实时去查价格表,否则历史账单算出来会漂移。
成本控制还有一个重要手段是语义缓存。代码生成场景里有很多典型的重复请求,比如“解释这段代码”用户可能会反复问类似问题。我们在编排层加了语义缓存,用输入内容做向量相似度匹配,如果命中缓存且该任务允许走缓存,就直接返回缓存结果,不再调用Provider。这个机制在很多任务上能省下30%以上的成本,而且因为返回速度极快,用户体验也是正向的。
4. 实操一步:从单 Provider 平滑演进到多 Provider
4.1 第一步:先定义统一客户端 SDK,而不是先写 Adapter
很多团队接一个新Provider时,第一反应是“拉个SDK直接调”,这是最容易埋坑的做法。正确的第一步是先把统一客户端SDK定义出来,也就是把内部协议、接口签名、数据结构确定下来。SDK定了,业务端就不用再变,后面所有Provider接入都只发生在适配层。
我们在HagiCode里把统一客户端SDK拆成三部分:一是内部协议定义,包括请求、响应、流事件、错误码;二是Provider接口,也就是每个Adapter都必须实现的抽象接口;三是路由和熔断的接口,给编排层调用。定义SDK时不要贪多,先把最常用的chat和stream_chat两个方法做扎实,再考虑Agent工具调用等高级接口。
class ChatProvider(Protocol): name: str def chat(self, request: ChatRequest) -> ChatResponse: ... def stream_chat(self, request: ChatRequest) -> AsyncIterator[StreamEvent]: ... def get_model_capabilities(self) -> ModelCapabilities: ...接口定完之后,写Adapter就有章可循了。每个Adapter都是一个独立模块,内部再分三层:参数转换、HTTP调用、响应解析。参数转换负责把内部协议翻译成厂商参数;HTTP调用管理连接、超时、鉴权;响应解析负责把厂商返回解析成内部协议。无论对接哪家Provider,都是这个固定套路,写起来很快。
4.2 第二步:引入 Provider Registry 和动态配置
只把Adapter写好还不够,还要让平台能管理这些Provider。我们的方式是一个Provider注册中心,维护所有已注册Provider的元数据:能力标签、默认模型、支持的上下文长度、当前是否启用、路由权重等。注册信息存在配置中心里,支持热更新,改完配置不需要重新部署就能生效。
这个机制实用性非常高。新Adapter上线时,先在注册中心把权重设为0,然后慢慢调高;发现某个Provider有问题,可以在配置中心一键把权重降到0,流量立马切走。如果没有这个动态能力,每次调整都要改代码、发版,在多Provider场景下根本没法玩。
还注册需要有一个基础保障,就是启动时做健康检查和能力校验。每个Adapter注册后,系统会自动发一个极小体积的探针请求,确认鉴权没问题、响应结构能被正确解析,然后把结果记录到注册信息里。这样能避免那种“配置看起来都正常,但一调用就报错”的尴尬。
4.3 第三步:灰度切换与 A/B 对比
当我们接入一个新模型,特别是想用新模型替换某个旧模型时,不能一下子全量切过去。我们在路由权重里让新模型先承担5%左右流量,观察一段时间,对比新老模型在相同任务上的成功率、首Token延迟、生成速度、用户反馈等指标,确认没问题后再逐步放量。
灰度切换有个很关键的细节:要确保同一个用户在一次会话中尽量始终使用同一个模型,否则会出现“用户问完问题,下一个提问换了模型”的情况,多轮上下文的效果会受影响。我们的做法是按用户ID哈希分桶,将分桶结果与模型选择绑定。比如把用户hash后分到100个桶,前5个桶走新模型,其余95个桶走老模型,这样同一个用户始终落在同一个桶里,不会出现会话中途换模型的割裂感。
A/B对比的数据指标也很有讲究。除了通用的成功率和延迟,代码生成场景我们还会统计“代码被采纳率”“单次生成后的修改次数”这些质量指标。新模型虽然延迟高一点,但如果代码采纳率明显提升,用户整体满意度可能反而更好。所以灰度阶段的对比要“多指标综合看”,不能只看延迟这一个数字。
4.4 兼容旧接口的版本管理
多Provider架构还有一个容易忽视的问题——上游模型的迭代和废弃。经常是某个模型发布了新版本,或者我们要把某个旧模型下线,但平台上还有一批老用户、老请求在用旧能力。如果直接把旧Provider下线,可能引发大量报错。
我们的做法是给统一协议和Adapter都加上版本概念。内部协议以主版本号标记,非兼容变更必须升级主版本,比如v1切v2;兼容变更直接平滑推进,不需要通知调用方。每个Provider Adapter则独立版本迭代,废弃旧模型时,Adapter保留但标记为灰度下线,只对存量请求开放,新请求全部路由到新模型。等存量请求耗尽,再彻底删除Adapter。
在这个版本管理思路下,HagiCode接新模型变成了一个非常快的动作:写Adapter、注册、调权重、观察、放量。整个流程很少再改动上游业务代码,这就是多Provider架构带来的最大红利。
5. 多 Provider 架构的坑我都踩过,整理成清单给你
5.1 流式响应中断但上游账单是全的,怎么对账
这个问题困扰了我们很久。用户侧只收到了一半的流式响应,但月底看账单,上游显示那次调用是正常的、完整的,费用全额扣了。原因是流式请求在网络上传输时,连接中断,上游已经完成了生成并继续计费,但用户端没有收全。
要解决这个问题,必须得做“客户端接收对账”。我们每条流式响应都带一个事件序号,前端或者后端接收端会定期上报最后收到的序号。如果用户端显示中断,我们就把这个requestId对应的上游用量和用户端实际接收量做对比,用量不一致的,在账单侧做修正。这个机制虽然不能挽回已经扣掉的钱,但至少能让我们把账算清楚,不会错误地再把同样的费用转嫁给用户。
5.2 同一个参数,不同 Provider 的行为差异非常大
我们最早以为温度、top_p这些参数是通用的,后来发现完全不是。同一个temperature=0.7,有的模型输出非常跳跃,有的模型几乎不变;max_tokens在有的模型里表示“本次生成的最多token数”,在有的模型里却表示“包括输入在内的总上下文长度”,一个理解错误,直接导致长文档场景大量截断。
我们的应对方式是把所有生成参数在内部协议里做成“语义化”字段,由Adapter去具体映射。也就是内部只表达“我希望输出”的意图,至于怎么映射到各厂商的参数区间,全部由适配层决定。内部协议里还增加了一个“参数解析置信度”字段,适配层发现拿不准的参数会打上预警标记,方便排障时快速锁定问题。
5.3 超时设置不能一刀切,首 Token 延迟和总耗时要分开管
模型接口有两个时间维度:一是从请求发出到收到第一个Token,称为首Token延迟;二是从请求发出到整个流式结束,称为总耗时。这两个维度分别对应不同的故障。首Token延迟高,说明上游排队或网络链路有问题;总耗时过长但首Token很快,说明模型生成速度慢。
我们的超时策略是分别设置connectTimeout、firstTokenTimeout、idleTimeout。connectTimeout一般设为10秒,首TokenTimeout给到60秒,idleTimeout控制在流式过程中连续没有新事件的时间上限,通常是30秒。这样一旦出现问题,日志里能直接看出是哪个阶段超时,排查效率高很多。
不同模型的速度差异很大,超时参数必须支持按Provider覆盖。我们在路由配置里给每个Provider单独配置一套超时参数,而不是在代码里写死全局值。曾经就是吃了全局超时的亏——慢模型经常被误杀,误杀又触发重试,重试又增加上游压力,最后形成雪崩。
5.4 上下文窗口不一致,截断问题是怎么处理的
多Provider接进来后,不可避免要面对“这个模型支持32K上下文,那个模型支持128K上下文”的现实。如果上层不管,统一按128K的窗口组装上下文,然后把请求发给只支持32K的模型,要么报错,要么静默截断,用户就会看到模型“忘记”了前面讨论过的背景。
我们设计了一个上下文适配层,在所有请求发给Provider之前做一次上下文长度检查。根据当前模型的上下文上限,系统会对历史消息做分层压缩:最先被压缩的是代码块等大段内容——先做摘要,摘要还放不下就丢弃最老的消息。如果任务本身就要求保留完整长上下文,则再走另一条路由规则,将请求强制路由到支持长上下文的模型上。这个“上下文长度感知”是路由规则的一个重要维度,也让平台能够根据任务复杂度和上下文需求自动选择Provider。
5.5 排查神器:Mock Provider、审计日志和全链路 Trace
多Provider架构排障最怕的就是“不知道问题出在哪个环节”。请求到底有没有发出去?Adapter转换后的参数长什么样?上游返回了什么?我们用了三个工具组合来解决。
第一个是Mock Provider,也就是一个内置的模拟Provider,不调用任何外部API,直接返回预设响应。它可以模拟成功、超时、随机错误、慢响应、不完整流式等各种异常情况。因为不依赖外部,问题总能复现,这在测试路由和熔断逻辑时非常好用。
第二个是审计日志,每一条内部请求都记录一份完整摘要,包含请求ID、路由选路、候选列表、实际命中的Provider、重试次数、各阶段耗时、最终Usage。不做全量报文留档,太贵,但摘要必须全。排查问题时,一句话就能还原链路。
第三个是全链路Trace,我们用OpenTelemetry把API层、编排层和Provider适配层串联起来,每层都打上Span。这样出现一个慢请求,直接看Trace就能定位是编排层决策慢了,还是Provider响应慢了,还是流式传输久了。讲真,没有这套链路追踪,多Provider架构的运维根本做不下去。
回到多Provider这件事本身,我在HagiCode实践下来的最大感受是:这个架构最难的从来不是写代码,而是“做减法”。一开始你会想把所有Provider的特性都抽象进统一层,结果就是适配层越来越臃肿;但如果你一开始太激进地砍特性,业务需求又会马上让你返工。正确节奏是先搭最小可用协议,用两三个Provider跑通全链路,再根据实际需求一点点扩展,同时把路由、熔断、对账、成本这些机制逐步补齐。这套东西做完以后,平台再添新模型已经变成一件很顺手的事,团队里的人也从“天天救火”变成了“按配置更新就行”——我觉得这就是架构最终该有的样子。