文章

LLM 多供应商 API 适配层生产实战:用能力矩阵、参数归一化与响应契约降低迁移成本

多模型供应商接入不能只靠统一 SDK。本文从能力矩阵、参数归一化、响应契约、流式事件适配、工具调用生命周期到迁移回放测试,系统讲解 LLM 适配层的生产治理方法,帮助团队降低模型迁移成本与线上风险。

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_reasonusage;Gemini 的 generateContent 使用 contents[]tools[]system_instructionresponse_schemausageMetadata 等结构;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 新模型不支持非默认的 temperaturetop_ptop_k,设置非默认值会返回 400 错误。这说明参数归一化不能只做字段改名。

更安全的做法是:

type ParamPolicy = {
  supported: boolean;
  providerField?: string;
  defaultValue?: unknown;
  allowedRange?: [number, number];
  unsupportedBehavior: "drop" | "fail" | "warn_and_drop";
};

对于强业务语义参数,例如 response_schematool_choicemax_output_tokens,不建议静默丢弃。对于弱控制参数,例如某些采样参数,可以按策略降级,但必须写入 trace:原始参数是什么、实际发送给供应商的参数是什么、为什么被删除。

max token 的边界

max_tokensmax_output_tokensmax_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;
};

这里最关键的是保留 providerReasonrawRef。统一契约方便业务处理,但排障时必须能追溯原始响应。否则一旦迁移后出现”偶发空结果""工具调用被解析成文本""JSON 缺字段”,平台团队会很难定位问题在模型、适配层、调用参数还是业务解析器。

stop reason 不能粗暴合并

不同供应商都有”结束原因”,但语义不完全相同。Anthropic 示例响应中包含 stop_reasonusage;Gemini 响应中有候选结果、finishReasonpromptFeedbacksafetyRatingsusageMetadata;OpenAI Responses API 有 response 状态、output item、streaming events、usage 以及工具调用对象。

适配层可以把它们映射成内部枚举,但不能丢失原始值。推荐做法是:

{
  "finish": {
    "reason": "content_filter",
    "providerReason": "SAFETY",
    "providerPath": "promptFeedback.blockReason"
  }
}

这样业务只需要判断 reason,而平台排障可以看到真实来源。

工具调用适配:不要只统一 schema,还要统一生命周期

Tool calling 是多供应商适配中最容易出问题的部分。表面上都是函数名和参数,实际生命周期不同:

  1. 模型如何声明要调用工具。
  2. 是否支持并行工具调用。
  3. 工具调用 ID 是否稳定。
  4. 工具结果如何回填。
  5. 工具错误如何反馈给模型。
  6. 流式输出中工具参数是否分片返回。
  7. 工具调用结束后是否继续生成最终文本。

因此,工具适配层需要把一次工具调用拆成状态机,而不是简单字段转换:

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_schematool_choicemax_output_tokenssafety_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 比例。

适用场景

这套方法适合以下团队:

  1. 已经接入两个以上模型供应商。
  2. 正在从 Chat Completions 迁移到 Responses、Messages 或 Gemini GenerateContent。
  3. 需要在 OpenAI、Claude、Gemini、Bedrock、Vertex AI、本地模型之间保留迁移能力。
  4. 有 Agent、工具调用、结构化输出、流式响应、内容安全或成本追踪要求。
  5. 业务不允许因模型切换导致 JSON 解析失败、工具调用异常或计费统计失真。

如果只是个人项目或简单聊天应用,可以先使用统一 SDK。但一旦进入生产系统,尤其是涉及多租户、审计、SLA、成本核算和模型替换,就应该尽早把 adapter contract 做出来。

参考资料

  1. OpenAI API Reference - Responses API
  2. Anthropic Claude Docs - Using the Messages API
  3. Anthropic Claude Docs - Tool use with Claude
  4. Google AI for Developers - Gemini GenerateContent API
  5. LiteLLM Documentation - Getting Started

常见问题

多供应商 LLM API 适配层和 LLM Gateway 是一回事吗?
不是。Gateway 更偏流量入口、鉴权、限流、路由和成本治理;API 适配层更偏应用侧契约,包括请求参数归一化、能力声明、响应解析、错误映射和迁移回放。两者可以合并部署,但职责要分开设计。
为什么不能把所有供应商都强行包装成 OpenAI Chat Completions 格式?
可以作为兼容入口,但不能掩盖能力差异。系统指令位置、工具调用协议、结构化输出、流式事件、token usage、拒答与 stop reason 都可能不同,生产系统需要显式记录能力矩阵。
模型迁移时最容易漏测什么?
最容易漏测的是边界语义:工具调用参数、流式中断、空输出、拒答、超长输入截断、usage 统计、结构化输出失败、以及模型返回多段内容时的解析顺序。
是否应该直接使用 LiteLLM?
可以。LiteLLM 适合作为底层兼容层或代理入口,尤其是快速接入多个供应商、统一 OpenAI 风格输出、做 fallback 和成本追踪。但在严肃生产环境里,仍建议在它上面定义自己的能力矩阵、响应契约和回放测试,避免业务完全依赖第三方抽象。