Provider迁移变成两周噩梦的根本原因是请求格式、错误类、流式事件名已泄漏到40个调用点;正确的抽象边界在「prompt/工具/预算/结果」这一语义层而非SDK包装层,检验标准是grep不到provider包名和字段名。
provider 换供应商之所以变成一个两周的项目,几乎从来不是因为模型本身,而是因为:provider 的请求结构已经泄漏到了四十个调用点,错误类已经泄漏到了重试代码里,流式事件名称已经泄漏到了前端,用量字段名已经泄漏到了账单表。适配器可以解决这些问题,但前提是你对那些真正无法映射的部分保持诚实。
本能的反应是包装 SDK 客户端——一个只有 createChatCompletion 方法、并将参数原封不动转发的薄对象。这没有任何意义,因为参数仍然是 provider 的请求对象。边界必须落在你的词汇层面,而不是他们的词汇层面:一个 prompt、一组工具、一个 budget、一个 result。凡是 wire format 以内的东西,都应该放在边界的另一侧。
实际检验方法是 grep。适配器存在之后,在代码库里搜索 vendor 的包名、max_completion_tokens、finish_reason、stop_reason、cache_read_input_tokens,应该只在唯一一个目录里有命中。如果在 HTTP handler、评估工具链或日志中间件里也有命中,那这个抽象就只是装饰性的。
还有第二个更安静的测试。问自己:调用者要了解什么才能正确使用这个适配器?如果答案是"你得把 system prompt 作为第一条消息传给一个 provider,作为另一个 provider 的独立字段传",那边界就画错了位置:那是 wire detail,应该被实现给消费掉。
有四种类型承载了几乎所有的重量。保持它们小规模;一个大接口往往是因为不断吸收 provider 的 options 对象而膨胀出来的。
// adapter/types.ts
export type Role = "system" | "user" | "assistant" | "tool";
export interface Message {
role: Role;
content: string | ContentPart[];
/** Only on role: "tool" — the id of the call this answers. */
toolCallId?: string;
}
export type ContentPart =
| { kind: "text"; text: string }
| { kind: "image"; mediaType: string; dataBase64: string };
export interface ToolDef {
name: string;
description: string;
/** Plain JSON Schema. The adapter dialects it per provider. */
parameters: Record<string, unknown>;
}
export interface ChatRequest {
messages: Message[];
tools?: ToolDef[];
maxOutputTokens: number;
/** Stable across a conversation. Used for cache affinity. */
cacheKey?: string;
stream?: boolean;
}
export interface ChatResult {
text: string;
toolCalls: { id: string; name: string; args: unknown }[];
/** Normalised. "length" always means the output cap was hit. */
stop: "end" | "length" | "tool_call" | "filtered" | "other";
usage: { inputTokens: number; outputTokens: number; cachedInputTokens: number };
/** The provider's own value, for logs. Never branched on by callers. */
rawStop: string;
}
export interface ChatProvider {
readonly id: string;
chat(req: ChatRequest): Promise<ChatResult>;
}
其中有两个决策是承重墙。首先,maxOutputTokens 是必填而不是可选的。一个主要 provider 要求每个请求都有输出上限,另一个把它当作可选的、有隐式默认值;把它做成接口中的必填项意味着这个差异不会以一种意外的形式表现出来。其次,rawStop 和 normalised stop 并存。调用者基于 normalised 值做分支;事故响应读取原始值。把原始值丢掉是你六个月后凌晨三点才发现的错误。
第三个决策只能通过 omission 来看见:ChatRequest 上没有 extra 或 providerOptions 这个逃生舱。添加一个让调用者可以穿透一个 provider-specific 参数的想法很诱人,这样就不必等接口演进。但尽可能久地抵制它。一旦调用点开始填充那个字段,边界就不再是边界了:一个调用者设置了一个只有某个 provider 才认识的 key,它就和直接导入了 SDK 一样紧耦合——只不过现在这种耦合对于 grep vendor 名称来说是不可见的。当一个能力真正对调用者重要时,把它加到接口里,给每个 provider 一个明确的语义——包括"这个 provider 忽略它"——让类型系统承载这个事实,而不是用一袋字符串来承载。
实现的职责是翻译,双向的。注意它有多少不是参数重命名,而是结构重组——system prompt 从消息数组里移出来,工具 schema 被重写,stop 值被折叠到一个更小的集合里。
// adapter/providers/anthropic.ts
import Anthropic from "@anthropic-ai/sdk";
import type { ChatProvider, ChatRequest, ChatResult } from "../types";
const client = new Anthropic();
const PROVIDER_ID = "anthropic";
export const anthropicProvider: ChatProvider = {
id: PROVIDER_ID,
async chat(req: ChatRequest): Promise<ChatResult> {
// 1. System prompt is a top-level field here, not a message role.
const system = req.messages
.filter((m) => m.role === "system")
.map((m) => (typeof m.content === "string" ? m.content : ""))
.join("\n\n");
const messages = req.messages
.filter((m) => m.role !== "system")
.map(toWireMessage);
const res = await client.messages.create({
model: process.env.ANTHROPIC_MODEL!,
max_tokens: req.maxOutputTokens, // required on this API
system: system || undefined,
messages,
tools: req.tools?.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.parameters, // note: not "parameters"
})),
});
const text = res.content
.filter((b) => b.type === "text")
.map((b) => (b as { text: string }).text)
.join("");
return {
text,
toolCalls: res.content
.filter((b) => b.type === "tool_use")
.map((b) => {
const t = b as { id: string; name: string; input: unknown };
return { id: t.id, name: t.name, args: t.input };
}),
stop: normaliseStop(res.stop_reason),
rawStop: String(res.stop_reason),
usage: {
inputTokens: res.usage.input_tokens,
outputTokens: res.usage.output_tokens,
cachedInputTokens: res.usage.cache_read_input_tokens ?? 0,
},
};
},
};
function normaliseStop(s: string | null): ChatResult["stop"] {
switch (s) {
case "end_turn":
case "stop_sequence":
return "end";
case "max_tokens":
return "length";
case "tool_use":
return "tool_call";
case "refusal":
return "filtered";
default:
return "other";
}
}
OpenAI 形态 API 的等价实现把 system prompt 保留在消息数组里,把工具 schema 用 parameters 包装在 function 内部,从 choices[0].message.content 读取文本,把 finish_reason 值(stop、length、tool_calls、content_filter)归一化到同一个五成员 union。两套代码,一个接口。你的代码库其他部分永远不需要知道它们中的任何一个存在。
这其中有两次翻译值得专门提一下,因为它们是人们会忘记的那些。工具 schema 住在不同的 key 下、不同的 wrapper 里,所以工具定义不是可以字面移植的——只有里面的 JSON Schema 是可移植的,而且即便是那个也受制于每个 provider 的 dialect 限制。用量统计是三向而非两向的:缓存的输入 token 和新鲜的输入 token 计费方式不同,所以只有两个字段的用量结构会在一个 provider 上悄悄多算成本、在另一个上少算。
声称完全等价的适配器比没有更糟,因为它把大声的失败变成了安静的失败。有四样东西应该保持可见。
context window。不要 静默截断来适应更小的目标。把 window 作为 provider 的一个属性暴露出来,让调用者决定——参见先审计你真正的 prompt 大小。
caching 语义。一个 provider 想要显式的断点;另一个自动匹配前缀。适配器可以暴露一个 cacheKey hint,但无法让一个结构上不可缓存的 prompt 变得可缓存。
Schema 严格性。一个 provider 接受而另一个拒绝的 schema 是真实的差异,诚实的适配器会呈现拒绝,而不是悄悄删掉那个有问题的 keyword。
可重试性。两个 provider 可能返回相同的 status code 但原因不同。分类应该放在 provider 文件里,而不是共享的重试代码里。
Grep provider 的包导入,列出每个文件。那份列表就是工作范围,而且它通常比想象中更短——大多数调用点做的事就那三件。
从那些调用点实际使用的形状写 types.ts,而不是从 provider 的文档。没有人传过的字段不应该出现在接口里。
实现你已经在用的那个 provider,把一个调用点改过来用它。在生产环境跑通那条路径之后再动其他的。
机械地迁移剩余的调用点。这期间不要改进任何东西;重命名加行为变更在一起是无法二分的。
加一条 lint 规则或 CI grep,让 provider 包在 adapter/providers/ 之外被导入时失败。没有这条规则,边界在一个季度内就会侵蚀掉。
只有现在才写第二个实现,用同样的调用点而非文档来验证它。
适配器边界和 gateway 画的边界是同一条边界,只是移出了你的进程:一个请求结构、一个 key、provider 选择作为配置。在单个服务内构建它是完全合理的——成本出现在五个服务各自带着一份拷贝、然后开始分化的时候,那就是集中化翻译、fallback 顺序和成本统计开始回本的时间点。
Auditing Every Hardcoded Provider Assumption in a Codebase
Mapping HTTP Status Codes to Error Meaning Across Providers
Migrating Retry and Backoff Logic Between Providers