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

发布时间:2026/10/31 9:02:22
多模型API接口碎片化治理:用适配器模式构建统一调用层

我接手这个项目时,代码里已经同时接了四家模型服务。业务方提的需求本身并不复杂:同一个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里role=system单独system字符串参数支持messages里的system角色支持messages里的system角色
多模态内容content数组,可含image_urlcontent数组,可含type=image兼容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_tokens
Anthropiccontent[0].textstop_reasonusage.input_tokens
DashScopeoutput.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 exceeded,Anthropic是400带prompt is too long,DashScope可能是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): Promise<ChatResponse>; stream( request: ChatRequest, onEvent: (event: ChatStreamEvent) => void ): Promise<void>; }

chat和stream两个方法就够了。一开始不需要设计更细粒度的能力方法,比如“专门拿JSON”“专门出摘要”,那些都应该是业务层的组合,而不是适配器的方法。适配器越薄越好。

3.4 两个适配器实现的核心差异

我拿OpenAI和Anthropic的适配器做对比,最能说明问题。

OpenAI适配器的核心是把内部请求映射到Chat Completions格式:

export class OpenAIAdapter implements ModelAdapter { constructor(private client: any) {} async chat(request: ChatRequest): Promise<ChatResponse> { // 内部模型 -> 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): Promise<ChatResponse> { 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。再比如extractToolUse,Claude的内容块里tool_use的input是一个对象,而OpenAI的arguments是JSON字符串,统一到内部模型时建议直接字符串化,方便上层后续JSON.parse。

3.5 统一调用层:路由的入口

适配器只是“翻译官”,真正让上层舒服的是统一调用层。它不需要多复杂,只要做到两件事:按模型名找到适配器,拿到统一响应就返回。核心代码很短:

// unified/chat.ts export async function chatCompletion(request: ChatRequest): Promise<ChatResponse> { 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 ): Promise<void> { 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: Record<string, 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-After
OpenAIHTTP 401/403auth_error否
OpenAIHTTP 400,提示context lengthcontext_length_exceeded否,需截断后重发
AnthropicHTTP 529rate_limit是,短退避
AnthropicHTTP 400,提示messages must alternateinvalid_request否,需要修消息结构
DashScopeHTTP 200但Body.code=Throttlingrate_limit是
DashScopeHTTP 200但Body.code=InvalidParameterinvalid_request否
智谱HTTP 429rate_limit是

这张表看着简单,但它是整个适配层稳定性的地基。没有这张表,你没法正确回答“这个错到底要不要重试”。

5.3 重试策略:不是无脑重试三次

重试是解决限流的常用手段,但如果策略不对,会适得其反。我先给一个通用的带重试调用包装函数:

async function callWithRetry<T>( operation: () => Promise<T>, classify: (err: unknown) => ApiError, options: { maxAttempts?: number } = {} ): Promise<T> { 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分钟。
  • 所有超时都要单独映射成ErrorClass='timeout',并且标记为可重试。

不要小看超时与碎片化的关系。如果每家模型的“慢”阈值不一样,而你只用同一个超时值,那些本就慢的模型会被过早中断,造成大量无意义的失败;反过来说,对本来就快的模型给了太长超时,重试的有效性又会下降。统一调用层正好是一个可以按模型配置超时的地方。

6. 适配器层怎么测试,新增模型要改哪几处

6.1 测试分层:单元、契约、集成

适配器层是最值得做测试的地方,因为它是全系统唯一一个“天天跟外部不稳定系统打交道”的层。我实践下来比较有效的分法是三层:

第一层是映射函数单元测试。主要测那些纯函数,比如toOpenAIMessage、mapStopReason、normalizeToolCalls。构造各种响应样本喂进去,断言转换后的内部结构。这一层测试速度最快,覆盖率也最容易做高。

第二层是契约测试。它做的事情是:针对同一个内部请求,所有已注册的适配器都必须产出结构一致的结果。我维护了一份固定的测试集,比如“带系统消息的请求”“带工具调用的请求”“空回复的响应”“超长截断的响应”,每个适配器跑一遍,断言返回的ChatResponse结构符合统一类型,关键字段数值合理。

第三层是极少量真实API集成测试。真实测试一定要有,但不能多,因为每调一次都是钱,而且不稳定。我们只保留一个最小的冒烟用例:每个新适配器上线前,用512 token以内的短文本真实调用一次,确认认证、网络、协议转换都没有问题。

6.2 新增一家模型到底要动哪些代码

这个清单我把它写成了团队的接入SOP,照着走基本不会漏:

  1. 在registry里注册模型,配置能力、价格、优先级。
  2. 新建适配器类,实现chat和stream两个方法。
  3. 在适配器里实现所有映射函数,包括消息转换、工具调用、错误分类。
  4. 在错误映射表里补充该厂商的特殊错误码。
  5. 给该适配器写单元测试和契约测试,至少覆盖系统消息、多模态、工具调用、流式、错误五类样例。
  6. 跑一批“金句提示词集”做对拍,观察同一个Prompt在不同模型上的回复是否满足业务要求。
  7. 灰度5%流量跑24小时,看错误率、耗时、成本三个指标。
  8. 观察通过后再逐步放大流量。

这里面最容易被忽视的是第6步对拍。接口碎片化问题不只是技术上的结构差异,还有“行为差异”。同一个Prompt,不同模型的措辞风格、拒绝回答的倾向、JSON输出的稳定性都不一样。不做对拍,技术上接入了,业务上可能根本不敢用。

6.3 几个月后回过头看,这套方案值不值

这套方案看上去增加了“适配器层”的初期成本,但它的收益会在你接第二家、第三家、第四家模型时指数级显现。我们后来再接新模型,基本按SOP半天到一天就能完成接入,核心业务代码一行都不用动。这个体验跟之前“接一次模型改半个服务”比起来,完全是两种工程形态。

我个人的体会是:多模型应用开发的本质,不是跟某个模型厂商绑定,而是跟“不断变化的模型生态”共存。统一调用层不追求消灭各家的差异,只追求把差异收敛到一个可控的位置,然后让上层专注于业务。至于缓存、限流、计费、审计这些能力,也都是建立在这层抽象之上的,否则每接一个新模型,这些横切能力又要重写一遍。

最后分享一个我一直用的习惯:在所有适配器里保留raw字段。任何转换逻辑都可能出错,当你线上怀疑“适配器是不是把某家模型的字段转错了”的时候,raw就是唯一能还原现场的证据。别为了省那一点日志空间把它删掉——它在关键时刻能救你一命。