LLM 多供应商 API 适配层生产实战:用能力矩阵、参数归一化与响应契约降低迁移成本
背景:模型供应商越多,真正难的是”契约漂移”
很多团队最初接入大模型时,代码里只有一个供应商、一个模型和一套请求格式。业务跑起来之后,才会出现更复杂的诉求:有的任务要用 OpenAI,有的任务要用 Claude,有的任务要走 Gemini,有的客户要求走私有云或本地模型,还有的场景需要按价格、延迟、上下文窗口、视觉能力或结构化输出能力切换模型。
这时,最容易做错的一件事,是把”换一个 SDK”当成模型迁移。表面看,所有调用都是 messages -> response;实际到了生产环境,差异会出现在每个细节里:
- system instruction 放在哪里
- tool call 如何返回
- 流式事件如何结束
- usage 字段是否完整
- 拒答如何表达
- 结构化输出失败时是否还能解析
- max token 参数含义是否一致
OpenAI 的 Responses API 支持文本、图像和文件输入,可以使用内置工具、MCP 工具和自定义函数调用,并支持流式输出;Anthropic Messages API 强调直接模型提示与自定义 agent loop,响应中会出现 stop_reason 和 usage;Gemini 的 generateContent 使用 contents[]、tools[]、system_instruction、response_schema、usageMetadata 等结构;LiteLLM 则提供统一接口,将多个供应商包装成近似 OpenAI 的格式。参考资料看似都在讲”调用模型”,但它们实际暴露的是一个工程事实:统一入口不等于统一语义。
因此,一个可长期维护的 LLM 多供应商接入层,不应该只做 SDK 封装,而应该做成一套明确的 Provider Adapter Contract:上游业务只依赖平台内部契约;下游供应商差异由适配层显式声明、转换、降级和回放验证。
核心原则:先定义内部契约,再接供应商 API
1. 内部请求模型要稳定
不要让业务代码直接感知 OpenAI、Anthropic、Gemini 或某个开源推理服务的原始请求体。平台内部应该定义一个相对稳定的请求模型,例如:
type LlmRequest = {
requestId: string;
tenantId: string;
taskType: "chat" | "extract" | "tool_agent" | "vision" | "classification";
modelPolicy: {
preferredModel?: string;
requiredCapabilities: Capability[];
fallbackAllowed: boolean;
};
instructions?: string;
messages: InternalMessage[];
tools?: InternalToolSpec[];
responseContract?: ResponseContract;
generation: {
maxOutputTokens?: number;
temperature?: number;
topP?: number;
stop?: string[];
};
metadata: Record<string, string>;
};
这个模型不是为了追求”抽象优雅”,而是为了切断业务和供应商之间的硬耦合。业务只表达它需要什么:是否需要工具调用、是否必须 JSON、是否需要视觉输入、是否允许 fallback、输出最大 token 是多少。至于某个供应商怎么写请求体,由 adapter 负责。
2. 能力矩阵要显式,而不是靠运行时报错发现
多供应商接入最重要的表不是路由表,而是 capability matrix。每个模型都应该声明自己支持什么、不支持什么、支持到什么程度。
建议至少记录以下维度:
| 能力维度 | 需要记录的内容 |
|---|---|
| 输入类型 | text、image、audio、video、file、URL、PDF 等 |
| 输出类型 | text、JSON schema、tool call、multi-part response |
| 工具调用 | 是否支持 parallel tool calls、strict tool use、server tools、MCP |
| 上下文窗口 | 最大输入 token、最大输出 token、是否支持自动截断 |
| 流式输出 | 是否支持 SSE、chunk 格式、usage 是否随流返回 |
| 安全与拒答 | refusal、block reason、safety rating、stop reason 表达方式 |
| 缓存与状态 | 是否支持 prompt cache、previous response、conversation state |
| 成本统计 | usage 字段粒度、缓存 token、reasoning token 是否可见 |
| 供应商限制 | 不支持的采样参数、特殊模型限制、区域或服务层限制 |
这张表必须是运行时可读的,而不是放在文档里。调用前先做能力校验:如果业务要求 JSON schema,但目标模型只支持普通文本,就应该在请求前拒绝或切换模型,而不是等线上解析失败。
参数归一化:不要假设同名参数语义相同
temperature、top_p 和 top_k
很多模型都有 temperature,但是否支持、默认值、推荐范围、与其他采样参数的组合方式并不完全相同。Anthropic 文档中已经明确指出,部分 Claude Opus 新模型不支持非默认的 temperature、top_p、top_k,设置非默认值会返回 400 错误。这说明参数归一化不能只做字段改名。
更安全的做法是:
type ParamPolicy = {
supported: boolean;
providerField?: string;
defaultValue?: unknown;
allowedRange?: [number, number];
unsupportedBehavior: "drop" | "fail" | "warn_and_drop";
};
对于强业务语义参数,例如 response_schema、tool_choice、max_output_tokens,不建议静默丢弃。对于弱控制参数,例如某些采样参数,可以按策略降级,但必须写入 trace:原始参数是什么、实际发送给供应商的参数是什么、为什么被删除。
max token 的边界
max_tokens、max_output_tokens、max_completion_tokens 看起来都是限制输出长度,但不同 API 中可能包含 visible output、reasoning token 或其他内部 token。生产系统不应该只记录一个 max_tokens,而应该拆成:
type TokenBudget = {
maxInputTokens?: number;
maxVisibleOutputTokens?: number;
maxReasoningTokens?: number;
hardContextLimit?: number;
truncationPolicy: "fail" | "truncate_oldest" | "summarize_then_call";
};
适配层负责把内部预算映射到供应商字段。如果供应商无法表达某个预算,例如无法单独控制 reasoning token,就要在能力矩阵中标记,并在灰度时重点观察成本和延迟。
响应契约:解析结果比发送请求更容易翻车
统一响应对象
建议平台内部不要直接返回供应商原始响应,而是返回统一的 LlmResult:
type LlmResult = {
requestId: string;
provider: string;
model: string;
status: "completed" | "refused" | "blocked" | "tool_call" | "length_exceeded" | "failed";
content: Array<
| { type: "text"; text: string }
| { type: "json"; value: unknown; raw: string }
| { type: "tool_call"; name: string; arguments: unknown; callId: string }
>;
finish: {
reason: "stop" | "length" | "tool_call" | "content_filter" | "error" | "unknown";
providerReason?: string;
};
usage?: {
inputTokens?: number;
outputTokens?: number;
reasoningTokens?: number;
cachedTokens?: number;
totalTokens?: number;
};
rawRef: string;
};
这里最关键的是保留 providerReason 和 rawRef。统一契约方便业务处理,但排障时必须能追溯原始响应。否则一旦迁移后出现”偶发空结果""工具调用被解析成文本""JSON 缺字段”,平台团队会很难定位问题在模型、适配层、调用参数还是业务解析器。
stop reason 不能粗暴合并
不同供应商都有”结束原因”,但语义不完全相同。Anthropic 示例响应中包含 stop_reason 和 usage;Gemini 响应中有候选结果、finishReason、promptFeedback、safetyRatings 和 usageMetadata;OpenAI Responses API 有 response 状态、output item、streaming events、usage 以及工具调用对象。
适配层可以把它们映射成内部枚举,但不能丢失原始值。推荐做法是:
{
"finish": {
"reason": "content_filter",
"providerReason": "SAFETY",
"providerPath": "promptFeedback.blockReason"
}
}
这样业务只需要判断 reason,而平台排障可以看到真实来源。
工具调用适配:不要只统一 schema,还要统一生命周期
Tool calling 是多供应商适配中最容易出问题的部分。表面上都是函数名和参数,实际生命周期不同:
- 模型如何声明要调用工具。
- 是否支持并行工具调用。
- 工具调用 ID 是否稳定。
- 工具结果如何回填。
- 工具错误如何反馈给模型。
- 流式输出中工具参数是否分片返回。
- 工具调用结束后是否继续生成最终文本。
因此,工具适配层需要把一次工具调用拆成状态机,而不是简单字段转换:
type ToolCallState =
| { state: "requested"; callId: string; name: string; argsText: string }
| { state: "parsed"; callId: string; name: string; args: unknown }
| { state: "executing"; callId: string }
| { state: "succeeded"; callId: string; result: unknown }
| { state: "failed"; callId: string; errorType: string; retryable: boolean }
| { state: "returned_to_model"; callId: string };
迁移模型时,工具调用测试不能只看”是否能调用成功”。还要测试:
- 参数顺序变化
- 字段默认值
- 缺失字段
- 空数组
- 非法 enum
- 连续多次工具调用
- 模型拒绝调用工具
- 工具返回过长
- 工具错误重试
流式输出适配:统一 chunk,不统一事件会出事故
流式输出不是”把文本一段段返回”这么简单。生产系统至少要处理:
- 文本 delta
- tool call delta
- refusal 或 safety block
- finish event
- usage event
- provider error event
- client cancel
- 网络中断后的部分结果
推荐内部统一成事件流:
type LlmStreamEvent =
| { type: "text_delta"; text: string }
| { type: "tool_call_delta"; callId: string; name?: string; argsDelta?: string }
| { type: "usage"; usage: LlmResult["usage"] }
| { type: "finish"; finish: LlmResult["finish"] }
| { type: "error"; code: string; retryable: boolean; providerError?: unknown };
这里的重点不是格式漂亮,而是让 Web 前端、日志系统、Agent Runtime、计费系统和审计系统看到同一套事件。否则一旦切换供应商,前端可能还能显示文本,但工具调用、usage 统计、异常结束和取消请求都可能悄悄失真。
迁移治理:上线前必须做回放测试
多供应商适配层上线前,最重要的不是单元测试,而是 request replay。
建议从真实生产请求中脱敏抽样,构建迁移回放集:
| 回放类别 | 样本要求 |
|---|---|
| 普通对话 | 多轮历史、system instruction、长输入 |
| JSON 输出 | 必填字段、嵌套数组、枚举、空值 |
| 工具调用 | 单工具、多工具、并行工具、工具错误 |
| 安全边界 | 拒答、内容过滤、敏感输入、低风险误杀样本 |
| 超限边界 | 接近上下文窗口、输出过长、截断策略 |
| 流式输出 | 用户中断、网络断开、工具参数分片 |
| 成本边界 | 高 token 输入、缓存命中、reasoning 开关 |
每条样本要记录旧模型结果、新模型结果、适配后内部响应、解析结果、业务侧最终结果。迁移是否通过,不应该只看文本相似度,而要看契约是否稳定:状态码是否一致、JSON 是否可解析、字段是否完整、工具调用是否可执行、拒答是否可解释、usage 是否能计费。
工程落地:一套适配层的最小架构
一个可维护的 LLM API 适配层可以拆成五层:
1. Capability Registry
负责维护供应商、模型和能力矩阵。模型升级时必须先更新能力声明,再开放流量。
2. Request Normalizer
把业务请求转成内部标准请求,做基本校验、token 预估、参数默认值填充和 trace metadata 注入。
3. Provider Adapter
每个供应商一个 adapter,负责把内部请求映射成真实 API 请求,并把响应映射回统一契约。
4. Replay & Contract Test
保存典型请求样本,定期对 adapter、模型版本和供应商 API 变更做回放测试。
5. Runtime Ledger
记录每次请求的原始参数、转换后参数、供应商响应摘要、usage、成本、错误、fallback、重试和最终业务状态。
一个简化的目录结构可以是:
llm-platform/
adapters/
openai-responses.ts
anthropic-messages.ts
gemini-generate-content.ts
local-openai-compatible.ts
contracts/
request.ts
response.ts
stream-events.ts
capability.ts
registry/
models.yaml
provider-capabilities.yaml
replay/
fixtures/
expected/
runner.ts
ledger/
request-ledger.ts
常见误区
误区一:统一 SDK 就等于统一能力
LiteLLM 这类工具可以显著减少接入成本,并提供统一接口、输出格式、重试、fallback、成本追踪等能力。但生产团队仍然要维护自己的业务契约。统一 SDK 解决的是”怎么调用”,不是”这个模型是否满足当前业务语义”。
误区二:只测成功路径
模型迁移最容易出问题的通常不是普通问答,而是异常路径:内容过滤、长度超限、工具参数非法、JSON 半截、流式中断、usage 缺失。适配层测试必须覆盖失败路径。
误区三:静默丢弃不支持参数
有些参数可以降级,有些不行。比如 temperature 可能可以删除并写日志,但 response_schema、tool_choice、max_output_tokens、safety_identifier 这类参数如果无法映射,应该显式失败或切换模型。
误区四:只保存统一响应,不保存原始引用
统一响应方便业务,但排障需要原始证据。建议原始请求和响应不要直接全量落库,至少要保存安全脱敏后的摘要、对象 ID、供应商 request ID、错误码、usage、finish reason 和 adapter 版本。
上线检查清单
上线前建议逐项确认:
- 能力矩阵已经覆盖所有目标模型,并区分”支持""部分支持""不支持”。
- 参数映射有单元测试,尤其是 token、temperature、tool_choice、response_schema、stream。
- 响应契约覆盖 text、JSON、tool call、refusal、block、length、error。
- 流式事件覆盖 text delta、tool delta、usage、finish、error、cancel。
- 回放集包含真实脱敏样本,并覆盖高频路径和边界路径。
- 灰度策略支持按租户、任务类型、模型能力和成本预算逐步放量。
- 回滚策略不仅能切回旧供应商,还能处理已经落库的新格式响应。
- 请求账本记录 adapter 版本、模型版本、参数转换、usage、成本、错误和 fallback。
- 监控指标至少包括成功率、解析失败率、结构化输出失败率、tool call 失败率、P95/P99 延迟、平均 token 成本、fallback 比例。
适用场景
这套方法适合以下团队:
- 已经接入两个以上模型供应商。
- 正在从 Chat Completions 迁移到 Responses、Messages 或 Gemini GenerateContent。
- 需要在 OpenAI、Claude、Gemini、Bedrock、Vertex AI、本地模型之间保留迁移能力。
- 有 Agent、工具调用、结构化输出、流式响应、内容安全或成本追踪要求。
- 业务不允许因模型切换导致 JSON 解析失败、工具调用异常或计费统计失真。
如果只是个人项目或简单聊天应用,可以先使用统一 SDK。但一旦进入生产系统,尤其是涉及多租户、审计、SLA、成本核算和模型替换,就应该尽早把 adapter contract 做出来。