首页 / 资讯中心 / 文章详情

多模型API接口碎片化治理:用适配器模式构建统一调用层

多模型API接口碎片化治理:用适配器模式构建统一调用层 ★ FEATURED ARTICLE
我接手这个项目时代码里已经同时接了四家模型服务。业务方提的需求本身并不复杂同一个Prompt能不能自动选最便宜的模型模型A挂了能不能无缝切到模型B新来一家模型能不能一天接入听起来都是平台该干的事但前两任同事留下的代码把这些能力全写死在业务层里了。最经典的一幕是在同一个createCompletion方法里用if (modelName openai)、else if (modelName claude)一路分支下去每个分支各自写一套HTTP调用和字段解析。光“取回复文本”这一步就有四种写法有人直接读choices[0].message.content有人读data.output.text有人把响应先JSON序列化再从中截一段还有人干脆调对方SDK的toJSON再猜。这就是多模型应用开发里最典型、也最隐蔽的坑接口碎片化。它不是某个算法难题而是一种会自我繁殖的工程债务——你每接一个模型就多一套私有协议每多一套私有协议未来每一次改动就要重新摸一遍四套逻辑。这篇实录是我把那个烂摊子收拢成统一调用层之后回看整个过程记录下的方案、踩过的坑和思考后的取舍。1. 同一个方法里四套解析逻辑接口碎片化到底毁掉了什么1.1 我接手时的真实代码症状先说症状。我接手时那个网关服务大概不到一万行代码但光“判断当前是哪个模型”这个逻辑就出现了几十次。散落在业务里的典型写法有这些请求前判断模型名拼接不同的URL和请求头。响应后对resp.data做类型断言各种as any、as unknown as OpenAIReponse满天飞。同一个字段在不同模型里有完全不同的含义有的叫content有的叫output.text有的要取choices[0].text。日志里打印的是各家原始响应排查问题时得先看懂“这一坨是谁家的结构”。更恶心的是这种碎片化会“传染”。新来的同事要接第五家模型他看一眼代码大概率不会想着去重构而是照着前人的样子再抄一个分支。于是每接一家代码量不是线性增长而是近似平方级地增长——因为新模型要兼容老模型的所有调用点。1.2 碎片化的三个根源我在重构之前特意梳理过接口碎片化不是凭空出现的根源基本逃不出三条第一每家模型的“协议”都不同。这里说的协议不是TCP/IP那种而是应用层面的消息结构、参数命名、返回封装层级、语义约束。OpenAI用messages数组里的role区分系统消息Claude要单独传system字段阿里系旧版本甚至要求把角色信息塞在messages里靠字符串拼。协议不一致是碎片化的原材料。第二业务层缺少统一抽象。早期图快调通一个模型就直接在service里写死调用逻辑没有人定义“我们内部统一用什么样的ChatMessage”。没有抽象就是直接拿OpenAI的数据结构当全局数据结构后续接Claude时发现对不齐就只能在业务层打补丁。第三模型之间的能力确实有差异。有的模型原生支持function calling有的只支持JSON输出有的连JSON mode都没有只能靠Prompt硬约束。能力差异如果不在底层收敛就会一路渗透到上层让业务代码不停地判断“这个模型能不能干这个”。1.3 碎片化如何利滚利代码烂还不是最可怕的真正可怕的是它拖垮了迭代速度。我统计过当时一次普通的“模型切换”需求大概要触碰12个文件调用入口、解析逻辑、错误处理、日志埋点、参数拼装、降级判断……每一个都不难但组合起来就是噩梦。而且碎片化特别影响线上稳定性。有一家模型报错时返回的HTTP状态码是200错误信息放在Body的code字段里另一家模型限流时返回429但响应头里没有Retry-After。因为这些差异没有被统一处理上层服务只能靠try/catch兜底一旦哪里漏了就直接抛出一堆谁都看不懂的原始错误。我后来跟团队吐槽接口碎片化的本质是让“模型供应商”的差异变成了全团队的公共税负。只要有一条调用链路没有收口这个税就永远存在。2. 四家模型API的真实差异清单以及它们各自挖的坑2.1 请求体与消息格式的分歧我做适配层之前先做了一件事把四家模型的请求格式和返回格式全部列出来对照。这一步非常值得因为很多差异你不落到纸面上是感受不到有多碎的。维度OpenAI Chat CompletionsAnthropic Claude Messages阿里DashScope智谱GLM系统消息messages里rolesystem单独system字符串参数支持messages里的system角色支持messages里的system角色多模态内容content数组可含image_urlcontent数组可含typeimage兼容OpenAI风格部分模型支持命名略有差异工具调用tool_calls/tool_call_idtool_use/tool_resulttools/tool_callstools/tool_calls输出长度参数max_tokensmax_output_tokens新版必填max_tokensmax_tokens停止符参数stopstop_sequencesstopstop温度范围0~20~1部分模型0~1部分0~20~1这张表只是第一层真正麻烦的是语义细节。比如Claude要求messages必须交替出现user和assistant如果你上一轮是连续两条user消息它会直接报错而OpenAI没这个限制中间没有assistant时它会自己补空。再比如DashScope有部分模型不传System角色会静默忽略但真实行为跟OpenAI完全不同。这些差异单独看都是小问题但叠加起来足以让一个“通用对话平台”变成“四套各写各的业务系统”。2.2 返回结构的差异都在“取文本”写法全不一样返回结构的差异是我踩得最疼的坑。同样一句“请总结一下这段内容”四家返回的主字段完全对不上厂商正文位置结束原因字段用量字段OpenAIchoices[0].message.contentchoices[0].finish_reasonusage.prompt_tokensAnthropiccontent[0].textstop_reasonusage.input_tokensDashScopeoutput.textoutput.finish_reasonusage.input_tokens智谱choices[0].message.contentchoices[0].finish_reasonusage.prompt_tokens这里还有一个特别隐蔽的坑OpenAI的流式输出和一次性返回结构不同流式是choices[0].delta.content而一次性是choices[0].message.content。如果适配层不做转换上层业务一旦切了流式模式代码就得再分裂一次。DashScope的流式更离谱它的output.text在流式模式下会直接拼上一个完整字符串而不是增量块这意味着你要自己实现增量提取逻辑否则重复内容会叠在一起。2.3 流式输出格式的差异流式SSE也是碎片化重灾区。OpenAI的流式事件是data: {choices:[{delta:{content:x}}]}最后发一个data: [DONE]Claude的流式事件分了好几种类型有message_start、content_block_delta、message_delta你得自己维护一个状态机知道“当前到底在发文本块还是在发工具调用参数”DashScope的流式事件也有自己的output包一层而且不同模型版本之间还有格式变化。我在项目实施中吃过一个大亏第一次对接Claude流式时只监听了content_block_delta没有处理message_delta里的stop_reason结果前端“思考中”动画永远不结束因为流式结束时后端根本不知道“到底完没完”。后来才知道Claude的流式完整信号藏在message_delta事件里必须单独解析。2.4 错误语义你以为的HTTP 200不一定是成功错误处理可能是接口碎片化里最反直觉的一块。我遇到过一家厂商限流时不是返回429而是返回HTTP 200然后Body里放一个Throttling错误码也有一家厂商的“无效参数”直接返回400但错误信息字段在message里列成数组另一家则是字符串拼接日志时还得判断类型。还有各家对“上下文超长”的表示也不一样。OpenAI是400带maximum context length exceededAnthropic是400带prompt is too longDashScope可能是InvalidParameter。如果你的重试逻辑只按HTTP状态码判断会用完全不同的标准去处理同一类问题该重试的没重试不该重试的拼命重试。我后来总结了一句话不要相信任何一家的“官方错误格式”要相信你自己定义的内部错误体系。因为供应商今天给你的文档下个版本可能就变了而业务层不能跟着变。3. 解法核心用统一调用层和适配器把差异关进笼子3.1 为什么是适配器而不是再包一层“统一SDK”当时有人建议直接找一个开源的多模型SDK把所有模型都收编。我试过但最终放弃了。原因很简单第三方SDK确实能省掉“接入第一家”的时间但它没法替你回答“你们内部统一的数据模型是什么”。它的抽象是为了覆盖大多数场景做的通用抽象到了你的业务里你还是得写一堆映射逻辑还是得在业务层判断“这个SDK返回的结构是哪种”。适配器模式就不一样。它不是在“SDK之上再包一层”而是主动定义一份你自己的协议——内部统一的请求对象、响应对象、错误对象、流式事件。每家模型对应一个适配器适配器只干一件事把你的协议翻译成该模型的协议再把模型的协议翻译回你的协议。业务层永远只看到你的协议。打个比方统一调用层是USB-C接口各家模型是各种设备——有的输出HDMI有的输出DisplayPort有的输出Thunderbolt但宿主端只认USB-C设备的厂商差异全被转接头消化掉了。3.2 内部统一模型先定义Message、Request、Response这套方案的第一步是把你自己的数据结构定清楚这一步千万别偷懒。我们内部定义了一组TypeScript接口业务层只跟这组类型打交道// unified/model.ts export type Role system | user | assistant | tool; export interface ContentBlock { type: text | image; text?: string; imageUrl?: string; } export interface ToolCall { id: string; name: string; arguments: string; } export interface ChatMessage { role: Role; content: string | ContentBlock[]; name?: string; toolCallId?: string; toolCalls?: ToolCall[]; } export interface ChatRequest { model: string; messages: ChatMessage[]; temperature?: number; maxTokens?: number; stop?: string[]; tools?: Tool[]; responseFormat?: text | json; stream?: boolean; } export interface ChatResponse { id: string; model: string; content: string; toolCalls: ToolCall[]; finishReason: stop | length | tool_calls | content_filter | error; usage: { inputTokens: number; outputTokens: number; totalTokens: number; }; raw?: unknown; // 保留原始响应排查问题用 }注意几点。第一content既可以是字符串也可以是内容块数组这个设计是为了兼容多模态。第二工具调用统一成ToolCall[]不区分OpenAI的tool_calls和Claude的tool_use。第三保留raw字段这是一个我后来极其庆幸的决定——线上排查问题的时候光有转换后的对象往往不够你必须能随时看到“模型真实回的是什么”。流式事件也用联合类型定义export type ChatStreamEvent | { type: chunk; content: string; toolCallsDelta?: ToolCall[] } | { type: done; usage: { inputTokens: number; outputTokens: number; totalTokens: number } } | { type: error; error: ApiError };3.3 标准适配器接口有了统一模型适配器的标准接口就呼之欲出。所有适配器必须实现同一个接口业务层根本不需要知道背后是哪家export interface ModelAdapter { readonly name: string; chat(request: ChatRequest): PromiseChatResponse; stream( request: ChatRequest, onEvent: (event: ChatStreamEvent) void ): Promisevoid; }chat和stream两个方法就够了。一开始不需要设计更细粒度的能力方法比如“专门拿JSON”“专门出摘要”那些都应该是业务层的组合而不是适配器的方法。适配器越薄越好。3.4 两个适配器实现的核心差异我拿OpenAI和Anthropic的适配器做对比最能说明问题。OpenAI适配器的核心是把内部请求映射到Chat Completions格式export class OpenAIAdapter implements ModelAdapter { constructor(private client: any) {} async chat(request: ChatRequest): PromiseChatResponse { // 内部模型 - OpenAI原生协议 const payload: any { model: this.name, messages: request.messages.map(toOpenAIMessage), temperature: request.temperature, max_tokens: request.maxTokens, stop: request.stop, tools: request.tools, }; if (request.responseFormat json) { payload.response_format { type: json_object }; } const resp await this.client.chat.completions.create(payload); // OpenAI原生 - 内部模型 return { id: resp.id, model: resp.model, content: resp.choices?.[0]?.message?.content ?? , toolCalls: normalizeToolCalls(resp.choices?.[0]?.message?.tool_calls), finishReason: normalizeFinishReason(resp.choices?.[0]?.finish_reason), usage: { inputTokens: resp.usage?.prompt_tokens ?? 0, outputTokens: resp.usage?.completion_tokens ?? 0, totalTokens: resp.usage?.total_tokens ?? 0, }, raw: resp, }; } }Anthropic适配器的重点完全不一样系统消息要拆出来、消息里不能出现连续同角色、max_output_tokens必须显式传、内容是ContentBlock数组export class AnthropicAdapter implements ModelAdapter { constructor(private client: any) {} async chat(request: ChatRequest): PromiseChatResponse { const system request.messages .filter((m) m.role system) .map((m) (typeof m.content string ? m.content : )) .join(\n); const messages request.messages .filter((m) m.role ! system) .map(toAnthropicMessage); // role映射、连续消息合并等 const resp await this.client.messages.create({ model: this.name, max_tokens: request.maxTokens ?? 4096, system: system || undefined, messages, // 注意这里不能再有 system role temperature: request.temperature, stop_sequences: request.stop, tools: request.tools, }); return { id: resp.id, model: resp.model, content: resp.content?.find((b: any) b.type text)?.text ?? , toolCalls: extractToolUse(resp.content), finishReason: mapStopReason(resp.stop_reason), usage: { inputTokens: resp.usage?.input_tokens ?? 0, outputTokens: resp.usage?.output_tokens ?? 0, totalTokens: (resp.usage?.input_tokens ?? 0) (resp.usage?.output_tokens ?? 0), }, raw: resp, }; } }这段代码看着简单但每一个映射函数里都有坑。比如toAnthropicMessage里如果发现连续两条同角色消息需要把后面一条的内容以换行符拼到前一条后面否则Claude会报messages must alternate。再比如extractToolUseClaude的内容块里tool_use的input是一个对象而OpenAI的arguments是JSON字符串统一到内部模型时建议直接字符串化方便上层后续JSON.parse。3.5 统一调用层路由的入口适配器只是“翻译官”真正让上层舒服的是统一调用层。它不需要多复杂只要做到两件事按模型名找到适配器拿到统一响应就返回。核心代码很短// unified/chat.ts export async function chatCompletion(request: ChatRequest): PromiseChatResponse { const adapter registry.getAdapter(request.model); if (!adapter) throw new ApiError(invalid_request, system, 400, false, model not supported: ${request.model}); return adapter.chat(request); }所有上层业务比如智能问答、摘要生成、结构化抽取都只调chatCompletion。它们不需要知道模型是OpenAI家的还是Claude家的不需要知道响应里是content[0].text还是choices[0].message.content全部由统一调用层消化。流式同理chatCompletionStream负责管理适配器的stream方法把事件统一转发给上层export async function chatCompletionStream( request: ChatRequest, onEvent: (event: ChatStreamEvent) void ): Promisevoid { const adapter registry.getAdapter(request.model); if (!adapter) throw new ApiError(invalid_request, system, 400, false, model not supported: ${request.model}); return adapter.stream(request, onEvent); }这套东西搭好后上层代码里再也见不到任何一家模型的原生字段。这个收益是立竿见影的。4. 模型能力注册表与动态路由把if-else变成配置4.1 能力注册表解决什么统一调用层把“结构差异”关进了笼子但还有一个问题没解决业务层怎么知道该用哪个模型很多团队的做法是写死一个模型名或者做一个简单的随机负载均衡——这两种做法都很浪费因为不同模型的价格、能力、延迟差异很大。更好的做法是建立一个模型能力注册表。每个模型在注册表里声明自己能干什么、干一次多少钱、上下文多长、优先级多高。export interface RegisteredModel { provider: string; model: string; adapter: ModelAdapter; supportsFunctionCalling: boolean; supportsJsonMode: boolean; supportsVision: boolean; maxContextTokens: number; maxOutputTokens: number; inputPricePer1K: number; outputPricePer1K: number; priority: number; // 数值越大越优先 }注册表是一个描述模型池的配置它把“哪个模型适合什么任务”这个决策从代码里剥离到了配置层。上新模型时不用改任何路由代码只需在配置里加一行。4.2 路由选择先能力过滤再按成本和优先级排序选模型不是单纯的“谁便宜选谁”。必须先做能力过滤再做成本排序否则一定会踩大坑。type TaskRequirements { needFunctionCalling?: boolean; needJsonOutput?: boolean; needVision?: boolean; estimatedInputTokens?: number; estimatedOutputTokens?: number; }; export function selectModel( task: TaskRequirements, candidates: RegisteredModel[] ): RegisteredModel { const eligible candidates.filter( (m) (!task.needFunctionCalling || m.supportsFunctionCalling) (!task.needJsonOutput || m.supportsJsonMode) (!task.needVision || m.supportsVision) (task.estimatedInputTokens ?? 0) m.maxContextTokens ); if (eligible.length 0) { throw new ApiError( invalid_request, system, 400, false, no model satisfies the task requirements ); } const topPriority Math.max(...eligible.map((m) m.priority)); return eligible .filter((m) m.priority topPriority) .sort( (a, b) costOf(a, task) - costOf(b, task) // 同优先级内选最便宜 )[0]; } function costOf(m: RegisteredModel, task: TaskRequirements): number { const inputTokens task.estimatedInputTokens ?? 0; const outputTokens task.estimatedOutputTokens ?? 0; return (inputTokens / 1000) * m.inputPricePer1K (outputTokens / 1000) * m.outputPricePer1K; }我为什么强调先过滤再排序因为我真踩过“只看价格排序”的坑。当时某个模型价格极低但响应格式不稳定强行让它输出JSON经常失败。由于路由代码只按价格选结果大量请求打到这个模型上JSON解析错误率飙升到20%以上。后来把supportsJsonMode加进过滤条件错误率才降回1%以下。4.3 降级链路从“模型挂了”到“无缝切换”多模型应用的最大优势就是降级。主模型超时或报错时自动切到备用模型。这块要特别注意一个原则降级只能发生在请求还没发出流式首包之前流式已经开始发块之后不要切模型否则客户端会收到一段A模型的内容再接一段B模型的内容用户会以为产品出了幻觉。一个稳妥的做法是定义降级链配置const failoverChains: Recordstring, string[] { gpt-4o: [gpt-4o-mini, claude-3-5-sonnet, qwen-max], claude-3-5-sonnet: [qwen-max, glm-4], };然后在统一调用层做一次包装非流式请求可以整链重试流式请求只在“模型尚未返回任何事件”时允许切换一旦收到chunk事件后续任何错误都只能直接抛给上层。我这里还吃过另一个亏降级后日志没打清楚。线上某段时间响应慢查了半小时才发现流量全降级到了备用模型因为主模型密钥过期了。后来我在路由层强制记录“实际模型名”每次调用都输出selected_model与degraded_from这个排查成本才降下来。5. 错误码归一化、重试与超时最容易翻车的一层5.1 先定义一套内部错误分类很多团队自认为有统一错误处理结果只是在catch里console.error把原始错误直接抛上去。这在多模型场景下等于没有处理。我的做法是先定义错误类别export type ErrorClass | rate_limit | auth_error | invalid_request | context_length_exceeded | server_error | timeout | unknown; export class ApiError extends Error { constructor( public errorClass: ErrorClass, public provider: string, public providerCode: string | number, public retryable: boolean, message: string ) { super(message); } }适配器的每一处catch都必须把原始错误“翻译”成ApiError。比如OpenAI适配器里catch (e: any) { const status e?.status ?? e?.statusCode; if (status 429) { throw new ApiError(rate_limit, openai, status, true, rate limited: ${e.message}); } if (status 400 /context|token|length/i.test(e?.message ?? )) { throw new ApiError(context_length_exceeded, openai, status, false, e.message); } if (status 401 || status 403) { throw new ApiError(auth_error, openai, status, false, e.message); } throw new ApiError(server_error, openai, status, status 500, e.message); }DashScope适配器里最特殊的是“HTTP 200但业务失败”的情况一定要在成功响应的分支里检查Body.code如果发现InvalidParameter或Throttling要主动抛错不能被“状态码是200”骗过去。5.2 各家错误码的映射对照我把常见错误场景整理成了一张映射表这张表对后面的维护特别有用厂家外层表现内部ErrorClass是否可重试OpenAIHTTP 429rate_limit是按Retry-AfterOpenAIHTTP 401/403auth_error否OpenAIHTTP 400提示context lengthcontext_length_exceeded否需截断后重发AnthropicHTTP 529rate_limit是短退避AnthropicHTTP 400提示messages must alternateinvalid_request否需要修消息结构DashScopeHTTP 200但Body.codeThrottlingrate_limit是DashScopeHTTP 200但Body.codeInvalidParameterinvalid_request否智谱HTTP 429rate_limit是这张表看着简单但它是整个适配层稳定性的地基。没有这张表你没法正确回答“这个错到底要不要重试”。5.3 重试策略不是无脑重试三次重试是解决限流的常用手段但如果策略不对会适得其反。我先给一个通用的带重试调用包装函数async function callWithRetryT( operation: () PromiseT, classify: (err: unknown) ApiError, options: { maxAttempts?: number } {} ): PromiseT { const maxAttempts options.maxAttempts ?? 3; let attempt 0; while (true) { try { return await operation(); } catch (err) { const apiError classify(err); if (!apiError.retryable || attempt maxAttempts) { throw apiError; } const delayMs apiError.errorClass rate_limit ? respectRetryAfter(apiError, attempt) : exponentialBackoff(attempt, 200); await new Promise((r) setTimeout(r, delayMs)); attempt; } } }这里有几个关键点第一rate_limit要优先读响应头里的Retry-After或x-ratelimit-reset-*如果读不到再用指数退避。在OpenAI上我踩过坑它某些限流响应带Retry-After数值可能是秒DashScope不带这个头只能用指数退避。第二context_length_exceeded不可重试但可以在业务层做一次截断后再发。怎么截断要看消息结构优先丢历史消息再丢无关的工具返回最后才截断系统提示词。第三server_error可以重试但退避不能太长200毫秒起步单次重试上限一般不要超过5次。由于很多模型服务的500其实是瞬时的短退避的成功率很高。第四所有重试都要考虑幂等。像“生成摘要”这类操作重复调用没风险但如果是“扣费后再调用模型”重试会重复扣费所以重试逻辑必须放在计费动作之前完成。5.4 超时设置的经验值超时跟重试是一对难兄难弟。多模型场景下你不能用一个全局超时时间因为长文本生成和短问答的耗时差一个量级。我采用的策略是分场景设置非流式短任务问答、分类15秒超时模型侧设max_tokens不超过1024。非流式长任务文章生成按预估输出token数动态计算一般每1K token给30~40秒。流式任务首包超时5秒包间空闲超时8秒整条流最长不超过10分钟。所有超时都要单独映射成ErrorClasstimeout并且标记为可重试。不要小看超时与碎片化的关系。如果每家模型的“慢”阈值不一样而你只用同一个超时值那些本就慢的模型会被过早中断造成大量无意义的失败反过来说对本来就快的模型给了太长超时重试的有效性又会下降。统一调用层正好是一个可以按模型配置超时的地方。6. 适配器层怎么测试新增模型要改哪几处6.1 测试分层单元、契约、集成适配器层是最值得做测试的地方因为它是全系统唯一一个“天天跟外部不稳定系统打交道”的层。我实践下来比较有效的分法是三层第一层是映射函数单元测试。主要测那些纯函数比如toOpenAIMessage、mapStopReason、normalizeToolCalls。构造各种响应样本喂进去断言转换后的内部结构。这一层测试速度最快覆盖率也最容易做高。第二层是契约测试。它做的事情是针对同一个内部请求所有已注册的适配器都必须产出结构一致的结果。我维护了一份固定的测试集比如“带系统消息的请求”“带工具调用的请求”“空回复的响应”“超长截断的响应”每个适配器跑一遍断言返回的ChatResponse结构符合统一类型关键字段数值合理。第三层是极少量真实API集成测试。真实测试一定要有但不能多因为每调一次都是钱而且不稳定。我们只保留一个最小的冒烟用例每个新适配器上线前用512 token以内的短文本真实调用一次确认认证、网络、协议转换都没有问题。6.2 新增一家模型到底要动哪些代码这个清单我把它写成了团队的接入SOP照着走基本不会漏在registry里注册模型配置能力、价格、优先级。新建适配器类实现chat和stream两个方法。在适配器里实现所有映射函数包括消息转换、工具调用、错误分类。在错误映射表里补充该厂商的特殊错误码。给该适配器写单元测试和契约测试至少覆盖系统消息、多模态、工具调用、流式、错误五类样例。跑一批“金句提示词集”做对拍观察同一个Prompt在不同模型上的回复是否满足业务要求。灰度5%流量跑24小时看错误率、耗时、成本三个指标。观察通过后再逐步放大流量。这里面最容易被忽视的是第6步对拍。接口碎片化问题不只是技术上的结构差异还有“行为差异”。同一个Prompt不同模型的措辞风格、拒绝回答的倾向、JSON输出的稳定性都不一样。不做对拍技术上接入了业务上可能根本不敢用。6.3 几个月后回过头看这套方案值不值这套方案看上去增加了“适配器层”的初期成本但它的收益会在你接第二家、第三家、第四家模型时指数级显现。我们后来再接新模型基本按SOP半天到一天就能完成接入核心业务代码一行都不用动。这个体验跟之前“接一次模型改半个服务”比起来完全是两种工程形态。我个人的体会是多模型应用开发的本质不是跟某个模型厂商绑定而是跟“不断变化的模型生态”共存。统一调用层不追求消灭各家的差异只追求把差异收敛到一个可控的位置然后让上层专注于业务。至于缓存、限流、计费、审计这些能力也都是建立在这层抽象之上的否则每接一个新模型这些横切能力又要重写一遍。最后分享一个我一直用的习惯在所有适配器里保留raw字段。任何转换逻辑都可能出错当你线上怀疑“适配器是不是把某家模型的字段转错了”的时候raw就是唯一能还原现场的证据。别为了省那一点日志空间把它删掉——它在关键时刻能救你一命。
阅读完成 · 觉得有帮助?
咨询建站