OpenClaw 设计与实现

第4章 Provider 抽象层

作者 杨艺韬 · 11,336 字 · 发布于 · 更新于

第4章 Provider 抽象层

模型供应商的 API 是最不可靠的依赖之一——它的变更不遵循语义版本,它的故障不提前通知,它的限速策略随时可能收紧。你的架构必须假设任何模型都可能在下一秒消失。

本章要点

  • 理解多模型适配架构:如何用统一接口屏蔽 OpenAI、Anthropic、Google 等供应商差异
  • 掌握模型路由与降级策略:Auth Profile 轮转、冷却期、自动故障切换
  • 深入 Token 计量与成本控制:预算感知的上下文管理
  • 解析流式响应处理的工程挑战与解决方案

上一章,我们剖析了 Gateway 如何管理系统的生命周期与配置。Gateway 是控制中心,但它本身不做 AI 推理——推理的重担,交给了 Provider 抽象层。

模型供应商的服务中断不是假想问题——各家的状态页(如 status.anthropic.com、status.openai.com)上,降级与部分不可用的记录并不罕见。如果你的 Agent 系统只接入了一家,那段时间里你的所有用户——无论身在 Telegram、Discord 还是 WhatsApp——都在面对一个沉默的 Agent。

但如果你运行的是 OpenClaw 并配好了降级链,用户多半不会察觉这次故障。

OpenClaw 的 Provider 抽象层在检测到主模型返回 429 或超时后,会先轮换同一 Provider 下的其他 Auth Profile,仍不行再沿降级链切到下一个候选模型,同时完整保留对话上下文;触发限速的 Auth Profile 进入冷却,冷却期结束后再重新参与轮转——整个过程,对用户基本是透明的。

🔥 深度洞察:供应商关系的本质

Provider 抽象层的设计哲学,与国际贸易中的供应链多元化策略如出一辙。一家成熟的制造企业不会把全部原材料押注在单一供应商身上——不是因为那个供应商不好,而是因为任何单一依赖都是系统性风险。AI 模型市场正在经历同样的演变:今天最好的模型可能是 Claude,明天可能是 GPT-6,后天可能是一个开源模型。Provider 抽象层保证的不是"永远选到最好的模型",而是"无论最好的模型是谁,你都能在秒级切换过去"。这才是真正的战略优势。

要实现这种丝滑的模型切换,系统必须攻克三道难关:如何抹平不同供应商的 API 差异?如何在模型之间智能路由和降级?如何统一计量来自不同供应商的 Token 消耗?三个问题,环环相扣,层层递进。本章将逐一剖析 OpenClaw 给出的答案。

4.1 多模型适配架构

"多模型适配"是 AI 时代软件工程最明显的"新增任务"之一——在传统 Web 开发里、你可能会写一段调用 MySQL 的代码、它和 MySQL 的协议深度绑定;但在 AI 应用里、如果你的代码和 OpenAI API 深度绑定、几个月后你就会后悔——因为你想换 Claude、换 Gemini、换 Azure OpenAI、换自部署模型。这种"多提供者、可切换"的需求、在传统软件里相对少见(换数据库厂商是罕见事件)、在 AI 领域却是常态(几乎每周都有新模型发布)。OpenClaw 的 Provider 抽象架构、就是为这种"频繁切换模型"的现实量身定做的。它的核心思路是"适配器模式 + 配置驱动 + 能力声明"——让每个 Provider 实现一个标准适配器、通过配置切换、同时声明自己在协议细节上的差异(工具 schema 的形态、思维签名要不要保留或清洗、tool call id 的格式等)。这种设计让你的业务代码可以"写一次、跑所有模型"——这种"一次编写、到处运行"的愿景、在 Sun Microsystems 的 Java 标语之后、终于在 AI 时代有了新的回响。

下图展示了 Provider 抽象层的核心类型层次结构,所有类名和字段均来自源码实现:

图 4-1:Provider 核心类型层次图

classDiagram
    class ModelRef {
        +string provider
        +string model
    }
    class ModelCatalogEntry {
        +string id
        +string name
        +string provider
        +number contextWindow
        +boolean reasoning
        +ModelInputType[] input
    }
    class ProviderCapabilities {
        +"native"|"openai-functions" anthropicToolSchemaMode
        +"native"|"openai-string-modes" anthropicToolChoiceMode
        +"default"|"moonshot-thinking" openAiPayloadNormalizationMode
        +"default"|"openai"|"anthropic" providerFamily
        +boolean preserveAnthropicThinkingSignatures
        +boolean openAiCompatTurnValidation
        +boolean geminiThoughtSignatureSanitization
        +"default"|"strict9" transcriptToolCallIdMode
        +string[] transcriptToolCallIdModelHints
        +string[] geminiThoughtSignatureModelHints
        +string[] dropThinkingBlockModelHints
    }
    class ModelCandidate {
        +string provider
        +string model
    }
    class FallbackAttempt {
        +string provider
        +string model
        +string error
        +FailoverReason reason
        +number status
        +string code
    }
    class NormalizedUsage {
        +number input
        +number output
        +number cacheRead
        +number cacheWrite
        +number total
    }
    class AssistantUsageSnapshot {
        +number input
        +number output
        +number cacheRead
        +number cacheWrite
        +number totalTokens
        +object cost
    }
    class ContextWindowInfo {
        +number tokens
        +ContextWindowSource source
    }
    ModelRef <|-- ModelCandidate : 同构
    ModelCandidate <|-- FallbackAttempt : 扩展错误信息
    ModelCatalogEntry --> ModelRef : 包含 provider+model
    NormalizedUsage <|-- AssistantUsageSnapshot : 扩展成本字段
    ProviderCapabilities ..> ModelCatalogEntry : 约束模型行为
    ContextWindowInfo ..> ModelCatalogEntry : 来自 contextWindow

该图基于 src/agents/model-selection.ts、src/agents/model-catalog.ts、src/agents/provider-capabilities.ts、src/agents/model-fallback.types.ts、src/agents/usage.ts 和 src/agents/context-window-guard.ts 中的类型定义绘制。所有字段名与源码一致(AssistantUsageSnapshot.cost 在源码中是内联对象类型,含 input/output/cacheRead/cacheWrite/total)。

4.1.1 Provider 标识与规范化

OpenClaw 将每个模型供应商抽象为一个 Provider。Provider 的核心标识是一个字符串 ID,经过规范化处理后存储和匹配。

规范化逻辑位于 src/agents/provider-id.ts:

export function normalizeProviderId(provider: string): string {
  const normalized = provider.trim().toLowerCase();
  if (normalized === "z.ai" || normalized === "z-ai") {
    return "zai";
  }
  if (normalized === "opencode-zen") {
    return "opencode";
  }
  if (normalized === "opencode-go-auth") {
    return "opencode-go";
  }
  if (normalized === "kimi" || normalized === "kimi-code" || normalized === "kimi-coding") {
    return "kimi";
  }
  if (normalized === "bedrock" || normalized === "aws-bedrock") {
    return "amazon-bedrock";
  }
  // Backward compatibility for older provider naming.
  if (normalized === "bytedance" || normalized === "doubao") {
    return "volcengine";
  }
  return normalized;
}

这个函数的设计思想值得注意:它通过一系列 if 分支将历史遗留名称、厂商别名统一映射到规范形式。例如,"bytedance" 和 "doubao" 统一映射为 "volcengine"(火山引擎),"bedrock" 和 "aws-bedrock" 统一为 "amazon-bedrock"。

这种规范化机制允许用户在配置中使用各种惯用名称,而系统内部始终使用统一标识。

关键概念:Auth Profile(认证配置) Auth Profile 是 OpenClaw 管理凭据的核心抽象。每个 Auth Profile 是一份凭据(API Key、静态 token 或 OAuth,见 src/agents/auth-profiles/types.ts),同一供应商可以配置多个 Profile,支持轮转(多个 Profile 交替使用)和冷却期(Profile 出错或被限速后暂时停用)。Provider 层通过 Auth Profile 而非直接持有密钥来访问 LLM API,实现密钥管理与模型调用的解耦。

4.1.2 模型引用与选择

模型的引用采用 provider/model 的复合键形式。核心类型定义在 src/agents/model-selection.ts:

export type ModelRef = {
  provider: string;
  model: string;
};

export type ThinkLevel = "off" | "minimal" | "low" | "medium"
  | "high" | "xhigh" | "adaptive";

模型键的构建逻辑简洁而巧妙:

// src/agents/model-selection.ts:76
export function modelKey(provider: string, model: string) {
  const providerId = provider.trim();
  const modelId = model.trim();
  if (!providerId) return modelId;
  if (!modelId) return providerId;
  return modelId.toLowerCase().startsWith(`${providerId.toLowerCase()}/`)
    ? modelId
    : `${providerId}/${modelId}`;
}

如果模型 ID 本身已经包含了 Provider 前缀(例如 "anthropic/claude-opus-4-6"),函数会避免重复拼接。这种防御性设计在多层调用栈中非常重要。

4.1.3 默认模型与上下文窗口

系统的默认配置定义在 src/agents/defaults.ts:

export const DEFAULT_PROVIDER = "anthropic";
export const DEFAULT_MODEL = "claude-opus-4-6";
export const DEFAULT_CONTEXT_TOKENS = 200_000;

这里透露了 OpenClaw 的设计偏好:以 Anthropic 的 Claude 系列作为默认模型,200K 的上下文窗口作为保守回退值。

4.1.4 Provider 能力声明

不同 Provider 对工具调用、消息格式等有不同的支持方式。src/agents/provider-capabilities.ts 定义了能力声明机制:

export type ProviderCapabilities = {
  anthropicToolSchemaMode: "native" | "openai-functions";
  anthropicToolChoiceMode: "native" | "openai-string-modes";
  openAiPayloadNormalizationMode: "default" | "moonshot-thinking";
  providerFamily: "default" | "openai" | "anthropic";
  preserveAnthropicThinkingSignatures: boolean;
  openAiCompatTurnValidation: boolean;
  geminiThoughtSignatureSanitization: boolean;
  transcriptToolCallIdMode: "default" | "strict9";
  // ... 更多能力声明
};

能力解析采用三层叠加策略(默认值 → 内置兜底表 → 插件声明):

// src/agents/provider-capabilities.ts:95
export function resolveProviderCapabilities(
  provider?: string | null,
  options?: ProviderCapabilityLookupOptions,
): ProviderCapabilities {
  const normalized = normalizeProviderId(provider ?? "");
  const pluginCapabilities = normalized
    ? providerCapabilityDeps.resolveProviderCapabilitiesWithPlugin({ ... })
    : undefined;
  return {
    ...DEFAULT_PROVIDER_CAPABILITIES,             // 第1层:全局默认值
    ...PLUGIN_CAPABILITIES_FALLBACKS[normalized], // 第2层:内置兜底表
    ...pluginCapabilities,                        // 第3层:插件声明(优先级最高)
  };
}

这种叠加模式遵循"约定优于配置"原则:大多数 Provider 使用默认值即可工作;PLUGIN_CAPABILITIES_FALLBACKS 为 anthropic、mistral、moonshot、kimi、opencode、opencode-go、openai 七个 Provider 预置了兜底能力(插件没装或没声明时生效);插件自己声明的能力优先级最高,直接覆盖前两层。

下图展示了 Provider 的注册与发现机制——从配置文件到运行时可用模型列表的完整流转:

图 4-2:Provider 注册与模型发现机制

flowchart TD
    A["openclaw.json / 环境变量"] --> B["resolveModelsConfigInput()"]
    B --> C["ensureOpenClawModelsJson()"]
    C --> D{"plan.action?"}
    D -- "skip / noop" --> E["复用已有 models.json"]
    D -- "write" --> F["writeModelsFileAtomic()\n先写临时文件 → rename 原子替换"]
    F --> G["models.json"]
    H["Pi SDK 运行时"] --> I["loadModelCatalog()"]
    I --> J{"modelCatalogPromise\n是否已缓存?"}
    J -- "是" --> K["复用缓存的 Promise"]
    J -- "否" --> L["触发模型发现"]
    L --> M["Pi SDK 发现\n+ 插件注册的模型"]
    M --> N["ModelCatalogEntry[]"]
    L -. "先调用" .-> C
    G -. "Pi 模型注册表读取" .-> M
    G --> O["Agent 运行时\nrunEmbeddedPiAgent()"]
    N --> O
    P["plugins/\nprovider-runtime.ts"] --> Q["resolveProviderCapabilitiesWithPlugin()"]
    Q --> R["ProviderCapabilities\n三层叠加解析"]
    R --> O
    subgraph 写锁保护
        C
        F
    end
    style A fill:#e1f5fe
    style G fill:#c8e6c9
    style N fill:#c8e6c9
    style O fill:#fff3e0

此图基于 src/agents/models-config.ts 的 ensureOpenClawModelsJson()、src/agents/model-catalog.ts 的 loadModelCatalog() 和 src/agents/provider-capabilities.ts 的 resolveProviderCapabilities() 绘制。writeModelsFileAtomic 的原子写入和 withModelsJsonWriteLock 的写锁机制保证了并发安全。两条链路并非各自独立:loadModelCatalog() 构建目录前会先调用 ensureOpenClawModelsJson(),Pi 的模型注册表再读取这份 models.json(src/agents/model-catalog.ts:196)。

4.2 模型路由与降级策略

4.2.1 模型目录

模型目录(Model Catalog)是 Provider 抽象层的动态注册表。src/agents/model-catalog.ts 定义了目录的核心数据结构:

export type ModelCatalogEntry = {
  id: string;
  name: string;
  provider: string;
  contextWindow?: number;
  reasoning?: boolean;
  input?: ModelInputType[];
};

目录的构建是一个懒加载过程:

// src/agents/model-catalog.ts:41
let modelCatalogPromise: Promise<ModelCatalogEntry[]> | null = null;

export async function loadModelCatalog(params?: {
  config?: OpenClawConfig;
  useCache?: boolean;
}): Promise<ModelCatalogEntry[]> {
  if (params?.useCache === false) {
    modelCatalogPromise = null;
  }
  // ... 从 Pi SDK 和配置中发现模型
}

通过模块级的 Promise 变量实现单例式缓存——首次调用触发发现流程,后续调用复用结果。useCache: false 可强制刷新。

4.2.2 模型配置生成

src/agents/models-config.ts 负责将用户配置转化为运行时模型文件。核心流程:

  1. 读取 OpenClaw 配置(~/.openclaw/openclaw.json / 环境变量)
  2. 发现可用的 Provider 及其模型
  3. 生成 models.json 到 Agent 目录
// 简化:省略了按配置指纹复用结果的 MODELS_JSON_READY_CACHE、目录创建与文件权限处理
export async function ensureOpenClawModelsJson(
  config?: OpenClawConfig,
  agentDirOverride?: string,
): Promise<{ agentDir: string; wrote: boolean }> {
  const resolved = resolveModelsConfigInput(config);
  const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir();
  const targetPath = path.join(agentDir, "models.json");

  return await withModelsJsonWriteLock(targetPath, async () => {
    const plan = await planOpenClawModelsJson({ ... });
    if (plan.action === "skip" || plan.action === "noop") {
      return { agentDir, wrote: false };
    }
    await writeModelsFileAtomic(targetPath, plan.contents);
    return { agentDir, wrote: true };
  });
}

注意 writeModelsFileAtomic 的原子写入策略:先写临时文件,再通过 rename 操作替换,避免读写竞态导致的文件损坏。写锁(withModelsJsonWriteLock,按目标路径串起一条 Promise 链的进程内锁)则保证同一进程内并发写入的序列化。

4.2.3 Fallback 降级机制

图 4-3:模型路由与降级流程

下图展示了一次模型调用从主模型尝试到降级候选列表轮转的完整决策路径。核心入口是 runWithModelFallback():它先由 resolveFallbackCandidates()(内部调用 resolveConfiguredModelRef() 解析配置的主模型)借助 createModelCandidateCollector() 构建去重后的候选列表,每次失败都会记录 FallbackAttempt 并推进到下一个候选者。注意分工:同一 Provider 内的 Auth Profile 轮转与冷却标记发生在内层 runner(src/agents/pi-embedded-runner/run.ts 的 advanceAuthProfile() / markAuthProfileFailure()),降级层只读取冷却状态来决定跳过还是探测。

flowchart TD
    REQ["用户请求到达"] --> RESOLVE["resolveFallbackCandidates()\n当前模型 + fallbacks + 配置的 primary"]
    RESOLVE --> COLLECT["createModelCandidateCollector(allowlist)\n去重后的候选列表"]
    COLLECT --> PROFILE{"resolveAuthProfileOrder() + isProfileInCooldown()\n该候选的 Profile 全部冷却?"}
    PROFILE -- "是,按原因跳过" --> SKIP["记录 FallbackAttempt\n跳过该候选"]
    PROFILE -- "否 / 允许探测" --> TRY["runFallbackCandidate()\n内层 runner 负责 Profile 轮转与冷却标记"]
    TRY --> CHECK{调用结果?}
    CHECK -- "ok: true" --> SUCCESS["buildFallbackSuccess()\n返回结果 + attempts"]
    CHECK -- "AbortError 且非超时\nshouldRethrowAbort()" --> ABORT["立即重新抛出,不降级"]
    CHECK -- "ok: false" --> OVERFLOW{"isLikelyContextOverflowError()?"}
    OVERFLOW -- "是" --> RETHROW["直接重新抛出\n交内层压缩重试,不换模型"]
    OVERFLOW -- "否" --> RECORD["coerceToFailoverError()\n记录 FallbackAttempt\n{provider, model, error, reason, status, code}"]
    SKIP --> NEXT{"还有候选模型?"}
    RECORD --> NEXT
    NEXT -- "是" --> PROFILE
    NEXT -- "否" --> FAIL["throwFallbackFailureSummary()\n抛出汇总错误"]
    style REQ fill:#e1f5fe
    style SUCCESS fill:#c8e6c9
    style ABORT fill:#ffcdd2
    style RETHROW fill:#ffcdd2
    style FAIL fill:#ffcdd2

此图基于 src/agents/model-fallback.ts 中的 runWithModelFallback()、resolveFallbackCandidates()、createModelCandidateCollector()、runFallbackCandidate()、shouldRethrowAbort() 和 buildFallbackSuccess() 绘制。FallbackAttempt 类型定义在 src/agents/model-fallback.types.ts。Auth Profile 的冷却计算来自 src/agents/auth-profiles/usage.ts,轮转发生在 src/agents/pi-embedded-runner/run.ts。

降级策略是 Provider 抽象层最核心的设计之一。在深入实现之前,先明确什么时候会触发降级:

触发条件 具体场景 系统行为
HTTP 429 Too Many Requests API 密钥达到供应商的速率限制 先轮转到同 Provider 的下一个 Auth Profile;所有 Profile 冷却中则降级到下一个模型
HTTP 500/502/503 Provider 服务端故障或维护 按 timeout 类处理(503 且报错文本表明过载时归为 overloaded,见 classifyFailoverReasonFromHttpStatus());timeout 不写入 Auth Profile 冷却,内层先换下一个 Profile,再不行就降级模型
上下文溢出错误 对话历史超过模型的上下文窗口(如发送 250K token 给 128K 窗口的模型) 由内层 runner 压缩后重试;若溢出错误仍抛到降级层,isLikelyContextOverflowError() 命中后直接重新抛出,不会换模型——下一个候选的窗口可能更小
认证失败 (401/403) API 密钥过期、被吊销或权限不足 轮转到下一个 Auth Profile;所有 Profile 失败则降级模型
网络超时 请求超过配置的超时时间 视为可降级错误(isTimeoutError() 会阻止它被当成用户取消),尝试下一个候选模型
模型不存在 请求了已下线的模型 ID(按报错文本识别,isModelNotFoundErrorMessage();HTTP 状态码表里并没有 404 分支) 降级到下一个候选模型
HTTP 402 / 529 余额不足(billing)/ 过载(overloaded) src/agents/auth-profiles/usage.ts:484 对 billing 与 auth_permanent 走 disabledUntil 的长退避(billingBackoffMs/billingMaxMs),而不是普通冷却;其余按 calculateAuthProfileCooldownMs() 的阶梯冷却处理

💡 最佳实践:同一个 Provider 可以配置多个 Auth Profile(不同的凭据),系统会在当前 Profile 出错或被限速时自动轮转到下一个。注意多数厂商的速率限制按组织/账号计算(Anthropic 官方文档:限额设在组织层面),同一组织下的多个 Key 共享同一份配额——要靠轮转分散限流,这些凭据得分属不同的组织或账号。

不触发降级的情况:用户主动取消(AbortError)——系统识别为用户意图,立即终止,不浪费资源尝试其他模型;上下文溢出——直接重新抛出,交给内层的压缩重试。

src/agents/model-fallback.ts 实现了完整的降级链:

type ModelFallbackRunFn<T> = (
  provider: string,
  model: string,
  options?: ModelFallbackRunOptions,
) => Promise<T>;

降级的核心逻辑是候选收集器(Candidate Collector):

function createModelCandidateCollector(allowlist) {
  const seen = new Set<string>();
  const candidates: ModelCandidate[] = [];

  const addCandidate = (candidate, enforceAllowlist) => {
    if (!candidate.provider || !candidate.model) return;
    const key = modelKey(candidate.provider, candidate.model);
    if (seen.has(key)) return;      // 去重
    if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
    seen.add(key);
    candidates.push(candidate);
  };
  // ...
}

降级流程的执行策略:

  1. 当前模型优先:先尝试本次请求的模型(通常就是配置的主模型)
  2. 配置的 fallback 列表:按顺序尝试 agents.defaults.model.fallbacks;不带 provider/ 前缀的条目会按主模型的 Provider 解析。若当前模型与配置主模型不属于同一 Provider,只有当前模型本身就在降级链里时才沿用这份列表
  3. 兜底回到配置的主模型:未显式覆盖 fallbacks 时,配置里的 primary 会被追加为最后一个候选(已在列表里则去重)。显式配置的 fallback 不受模型允许列表过滤——源码注释写明它们是用户的明确意图
  4. Abort 信号识别:区分用户主动取消和模型端错误——用户取消立即终止,不走降级

关键的错误分类逻辑:

function isFallbackAbortError(err: unknown): boolean {
  if (!err || typeof err !== "object") return false;
  if (isFailoverError(err)) return false;  // 模型错误,继续降级
  const name = "name" in err ? String(err.name) : "";
  return name === "AbortError";  // 仅识别真正的用户取消
}

function shouldRethrowAbort(err: unknown): boolean {
  return isFallbackAbortError(err) && !isTimeoutError(err);
}

isTimeoutError 的检查尤其精妙——它确保超时不会被误判为用户取消,从而跳过降级。

4.2.4 认证配置轮转

Auth Profile 机制(入口 src/agents/auth-profiles.ts 汇总导出,实现在 src/agents/auth-profiles/ 目录下)为每个 Provider 管理多套凭证:

export function resolveAuthProfileOrder(...): ...
export function markAuthProfileCooldown(...): ...
export function isProfileInCooldown(...): ...
export function getSoonestCooldownExpiry(...): ...

当某个 Profile 触发限流时,内层 runner(src/agents/pi-embedded-runner/run.ts)通过 markAuthProfileFailure() 记下失败原因与冷却期(阶梯为 30 秒、1 分钟、5 分钟,见 calculateAuthProfileCooldownMs()),再用 advanceAuthProfile() 切换到下一个可用配置。只要这些凭据的配额彼此独立(分属不同组织或账号),这种轮转就能在高负载场景下把多份配额都用起来。

4.3 Token 计量与成本控制

图 4-4:Token 计量与成本控制流程

下图展示了 Token 使用量从 Provider 原始响应到标准化 NormalizedUsage 再到成本计算的完整链路。关键挑战在于不同厂商(OpenAI、Anthropic、Moonshot 等)使用完全不同的字段命名,UsageLike 类型把各家字段都列成可选字段,再由 normalizeUsage() 按优先级逐个探测、实现统一归一化。右下分支的上下文窗口守卫检查的是模型的窗口大小,与本次用量无关,放在同一张图里只为展示 src/agents/ 下这一组数值护栏。

flowchart TD
    RAW["Provider 原始响应\n各厂商字段命名各异"] --> DETECT{"字段名探测\n(UsageLike 可选字段)"}
    DETECT --> OAI["OpenAI 风格\nprompt_tokens / completion_tokens\nprompt_tokens_details.cached_tokens"]
    DETECT --> ANT["Anthropic 风格\ninput_tokens / output_tokens\ncache_read_input_tokens\ncache_creation_input_tokens"]
    DETECT --> CAMEL["驼峰风格\ninputTokens / outputTokens"]
    DETECT --> MOON["Moonshot/Kimi 风格\ncached_tokens"]
    OAI --> NORM["normalizeUsage()\n归一化处理"]
    ANT --> NORM
    CAMEL --> NORM
    MOON --> NORM
    NORM --> CLAMP{"rawInput < 0 ?\n(缓存命中导致负数)"}
    CLAMP -- "是" --> ZERO["夹紧到 0"]
    CLAMP -- "否" --> KEEP["保留原值"]
    ZERO --> NU["NormalizedUsage\n{input, output, cacheRead, cacheWrite, total}"]
    KEEP --> NU
    NU --> SNAP["AssistantUsageSnapshot\n关联成本计算"]
    SNAP --> COST["cost: {\n  input: 输入单价 × input,\n  output: 输出单价 × output,\n  cacheRead: 缓存读单价 × cacheRead,\n  cacheWrite: 缓存写单价 × cacheWrite,\n  total: 以上之和\n}"]
    CTX["resolveContextWindowInfo()\n模型的有效上下文窗口"] --> GUARD["evaluateContextWindowGuard()"]
    GUARD --> BLOCK{"窗口 tokens < 16K ?"}
    BLOCK -- "是" --> SHOULDBLOCK["shouldBlock: true\n(shouldWarn 同时为 true)\n拒绝使用该模型"]
    BLOCK -- "否" --> WARN{"窗口 tokens < 32K ?"}
    WARN -- "是" --> SHOULDWARN["shouldWarn: true\n发出告警"]
    WARN -- "否" --> PASS["正常运行"]
    style RAW fill:#e1f5fe
    style NU fill:#c8e6c9
    style COST fill:#fff3e0
    style SHOULDWARN fill:#fff9c4
    style SHOULDBLOCK fill:#ffcdd2

此图基于 src/agents/usage.ts 的 UsageLike、normalizeUsage()、NormalizedUsage、AssistantUsageSnapshot 和 src/agents/context-window-guard.ts 的 evaluateContextWindowGuard()(阈值 CONTEXT_WINDOW_HARD_MIN_TOKENS = 16_000、CONTEXT_WINDOW_WARN_BELOW_TOKENS = 32_000)绘制。

4.3.1 Usage 规范化

不同供应商返回的 Token 使用量字段命名各不相同。src/agents/usage.ts 定义了统一的归一化层:

// src/agents/usage.ts:1 — UsageLike(字段来自不同供应商命名惯例,全部可选;节选)
export type UsageLike = {
  input?: number;  output?: number;  total?: number;
  // OpenAI 风格:prompt_tokens / completion_tokens
  prompt_tokens?: number;  completion_tokens?: number;
  prompt_tokens_details?: { cached_tokens?: number };
  // Anthropic 风格:input_tokens / cache_read_input_tokens
  input_tokens?: number;  output_tokens?: number;
  cache_read_input_tokens?: number;  cache_creation_input_tokens?: number;
  // 驼峰风格 + Moonshot 专用
  inputTokens?: number;  outputTokens?: number;  cached_tokens?: number;
  // ... 更多变体(共 20+ 字段)
};

// 归一化后的统一结构,消除供应商差异
export type NormalizedUsage = {
  input?: number;  output?: number;
  cacheRead?: number;  cacheWrite?: number;  total?: number;
};

⚠️ 注意:不同 Provider 的 Token 计量方式存在显著差异。Anthropic 的缓存 Token(cache_read_input_tokens)不计入输入 Token 配额但有独立计费;OpenAI 的 cached_tokens 可能导致 prompt_tokens - cached_tokens 为负数。OpenClaw 的归一化层会自动处理这些边界情况,但在核算成本时需要注意各厂商的定价模型差异。

规范化函数处理了大量边界情况:

export function normalizeUsage(raw?: UsageLike | null): NormalizedUsage | undefined {
  if (!raw) {
    return undefined;
  }
  const rawInput = asFiniteNumber(
    raw.input ?? raw.inputTokens ?? raw.input_tokens
    ?? raw.promptTokens ?? raw.prompt_tokens,
  );
  // 当 cached_tokens > prompt_tokens 时结果为负,夹紧到 0
  const input = rawInput !== undefined && rawInput < 0 ? 0 : rawInput;
  // ...
}

rawInput < 0 的处理是一个典型的防御性编程案例:据源码注释,上游 pi-ai 处理 OpenAI 格式响应时会先从 prompt_tokens 里减去 cached_tokens,当 cached_tokens 大于 prompt_tokens 时就会出现负数。

4.3.2 成本快照

AssistantUsageSnapshot 将 Token 使用量与成本关联:

export type AssistantUsageSnapshot = {
  input: number;
  output: number;
  cacheRead: number;
  cacheWrite: number;
  totalTokens: number;
  cost: {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
    total: number;
  };
};

这个结构支持精细的成本归因:输入、输出、缓存读取、缓存写入各自独立计价(单价取自模型配置的 cost,按每百万 token 计,见 src/utils/usage-format.ts 的 estimateUsageCost()),使运营者可以精确了解每次对话的成本构成。

4.3.3 上下文窗口守卫

src/agents/context-window-guard.ts 提供上下文窗口大小的安全边界——它检查的是模型可用的窗口有多大,而不是当前对话用了多少:

export const CONTEXT_WINDOW_HARD_MIN_TOKENS = 16_000;
export const CONTEXT_WINDOW_WARN_BELOW_TOKENS = 32_000;

export function resolveContextWindowInfo(params: {
  cfg: OpenClawConfig | undefined;
  provider: string;
  modelId: string;
  modelContextWindow?: number;
  defaultTokens: number;
}): ContextWindowInfo {
  // 优先级:modelsConfig > model元数据 > 默认值
  // 如果用户设置了 contextTokens 上限且小于推断值,则以用户设置为准
}

上下文窗口的解析先按三级优先级取基础值,再用全局上限封顶(结果里的 source 字段记录最终取值来源):

顺序 来源 说明
1 modelsConfig 用户在 models.providers 中为该模型显式配置的 contextWindow
2 model 模型自身元数据报告
3 default 系统默认的 200K
封顶 agentContextTokens 用户设置的全局上限 agents.defaults.contextTokens,比上面取到的值小时取它

守卫系统还提供双级告警(调用方在 src/agents/pi-embedded-runner/run/setup.ts:告警只打日志,阻止则抛出 FailoverError 拒绝使用这个窗口过小的模型):

export function evaluateContextWindowGuard(params): ContextWindowGuardResult {
  // ... 省略:由参数(缺省为上面两个常量)算出 warnBelow / hardMin,并把 info.tokens 取整为 tokens
  return {
    ...params.info,
    tokens,  // 取整后的窗口大小
    shouldWarn: tokens > 0 && tokens < warnBelow,   // 窗口不足 32K 告警
    shouldBlock: tokens > 0 && tokens < hardMin,      // 窗口不足 16K 阻止
  };
}

4.4 流式响应处理

图 4-5:流式响应处理序列图

下图展示了流式响应从 LLM Provider 到用户屏幕的完整处理链路。subscribeEmbeddedPiSession() 作为流式事件的核心分发器,将 LLM 返回的 token 流实时转发给通道层,同时通过 EmbeddedPiSubscribeState 跟踪工具调用状态和上下文更新。

sequenceDiagram
    participant User as 用户/通道
    participant Runner as runEmbeddedPiAgent()
    participant Sub as subscribeEmbeddedPiSession()
    participant State as EmbeddedPiSubscribeState
    participant Handler as EventHandler
    participant LLM as LLM Provider
    User->>Runner: 发送消息
    Runner->>LLM: 请求推理 (stream: true)
    Runner->>Sub: 创建流式订阅
    Sub->>State: 初始化状态<br/>deltaBuffer=""<br/>blockState.thinking=false<br/>compactionInFlight=false
    loop 流式数据块到达
        LLM-->>Handler: SSE 数据块 (delta)
        Handler->>State: 累积到 deltaBuffer
        alt 检测到 thinking 标签
            Handler->>State: blockState.thinking = true
            alt reasoningMode == "stream"
                Handler->>User: onReasoningStream(chunk)
            else reasoningMode == "on"
                Handler->>State: 缓存思维内容
            else reasoningMode == "off"
                Handler->>State: 静默过滤
            end
        end
        alt 检测到 final 标签
            Handler->>State: blockState.final = true
            Handler->>State: blockState.thinking = false
        end
        Handler->>Handler: stripDowngradedToolCallText()
        Handler->>User: onPartialReply(cleaned)
    end
    alt pi 框架触发自动压缩
        LLM-->>Handler: auto_compaction_start
        Handler->>State: compactionInFlight = true
        LLM-->>Handler: auto_compaction_end (willRetry?)
        Handler->>State: compactionInFlight = false<br/>willRetry 时 pendingCompactionRetry += 1
    end
    LLM-->>Handler: 流结束 (message_end)
    Handler->>State: 合并 assistantTexts[]
    Handler->>Sub: normalizeUsage(rawUsage) 累加用量
    Runner->>Sub: waitForCompactionRetry()
    Runner-->>User: 返回运行结果

此图基于 src/agents/pi-embedded-runner.ts 的模块导出、src/agents/pi-embedded-subscribe.ts 的 subscribeEmbeddedPiSession() 和 EmbeddedPiSubscribeState 状态字段、以及 src/agents/pi-embedded-subscribe.handlers.ts 中的事件处理器绘制。思维链过滤使用 THINKING_TAG_SCAN_RE 和 FINAL_TAG_SCAN_RE 正则表达式,支持 <thinking>、<thought>、<antthinking> 等变体。

4.4.1 嵌入式 Pi 代理运行器

OpenClaw 使用 Pi Agent 作为底层的 LLM 交互引擎。src/agents/pi-embedded-runner.ts 是一个模块聚合入口,暴露了核心运行 API:

export { runEmbeddedPiAgent } from "./pi-embedded-runner/run.js";
export {
  abortEmbeddedPiRun,
  isEmbeddedPiRunActive,
  isEmbeddedPiRunStreaming,
  queueEmbeddedPiMessage,
  waitForEmbeddedPiRunEnd,
} from "./pi-embedded-runner/runs.js";

这些 API 揭示了 OpenClaw 的流式运行模型:

  • runEmbeddedPiAgent:启动一次 Agent 运行
  • isEmbeddedPiRunStreaming:检查是否正在流式输出
  • queueEmbeddedPiMessage:向正在运行的会话注入消息
  • abortEmbeddedPiRun:中止当前运行
  • waitForEmbeddedPiRunEnd:等待运行完成

4.4.2 流式订阅系统

src/agents/pi-embedded-subscribe.ts 实现了流式事件的订阅与处理。其核心是 subscribeEmbeddedPiSession 函数:

export function subscribeEmbeddedPiSession(
  params: SubscribeEmbeddedPiSessionParams
) {
  const state: EmbeddedPiSubscribeState = {
    assistantTexts: [],
    toolMetas: [],
    deltaBuffer: "",
    blockBuffer: "",
    blockState: { thinking: false, final: false,
                  inlineCode: createInlineCodeState() },
    compactionInFlight: false,
    pendingCompactionRetry: 0,
    messagingToolSentTexts: [],
    // ... 大量状态字段
  };
  // ...
}

状态管理的复杂度反映了流式处理的内在挑战:

  1. 文本增量缓冲(deltaBuffer):模型返回的文本片段需要累积后才能进行格式处理
  2. 思维链追踪(blockState.thinking):识别 <thinking> 标签,决定是否向用户暴露推理过程
  3. 压缩飞行状态(compactionInFlight):流式传输中可能触发上下文压缩(详见第5章 5.2节 Context Engine),需要协调两个异步流程
  4. 消息去重(messagingToolSentTexts):防止工具调用导致的消息重复发送

4.4.3 思维链过滤

流式订阅中的一个关键特性是思维链(thinking)内容的过滤:

const THINKING_TAG_SCAN_RE =
  /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
const FINAL_TAG_SCAN_RE = /<\s*(\/?)\s*final\s*>/gi;

这些正则表达式识别各种变体的思维标签(<thinking>、<thought>、<antthinking> 等),支持三种推理模式:

  • off:完全过滤思维内容
  • on:等思维完成后一次性发送
  • stream:实时流式传输思维过程

4.4.4 压缩与流式的协调

当流式响应正在进行时,上下文可能接近窗口上限,触发自动压缩。compactionInFlight 和 pendingCompactionRetry 等字段协调这个并发过程:

compactionInFlight: false,
pendingCompactionRetry: 0,
compactionRetryResolve: undefined,
compactionRetryReject: undefined,
compactionRetryPromise: null,

压缩本身由 pi 框架的自动压缩执行,订阅层收到 auto_compaction_start / auto_compaction_end 事件后只做记账(src/agents/pi-embedded-subscribe.handlers.compaction.ts):compactionInFlight 标记压缩进行中;压缩结束且框架要重试请求(willRetry)时 pendingCompactionRetry 加一;compactionRetryPromise 在压缩与待重试都清零后才 resolve,运行器通过 waitForCompactionRetry() 等它收尾,再结束本轮——压缩的成败就这样沿 Promise 传回主流程。

4.5 主流 LLM Provider 对比

下表对比了 OpenClaw 所支持的主流 Provider 在关键特性上的差异。这些差异正是 Provider 抽象层需要抹平的核心挑战:

表 4-1:主流 LLM Provider 特性对比

特性 Anthropic OpenAI Google Gemini Amazon Bedrock 火山引擎 (Volcengine) Moonshot/Kimi
providerFamily anthropic openai default anthropic default default
normalizeProviderId 别名 — — — bedrock, aws-bedrock bytedance, doubao 无;kimi-code、kimi-coding 归到另一个 Provider kimi(Kimi Coding)
anthropicToolSchemaMode native native native native native native
dropThinkingBlockModelHints ["claude"] — — ["claude"] — —
transcriptToolCallIdMode default default default default default default
Token 用量字段 input_tokens, output_tokens, cache_read_input_tokens prompt_tokens, completion_tokens, prompt_tokens_details.cached_tokens 原生 API 为 usageMetadata.promptTokenCount / candidatesTokenCount 同 Anthropic 同 OpenAI 风格 OpenAI 风格字段 + cached_tokens (专用缓存字段)
流式响应 SSE (原生) Responses API 默认走 WebSocket(src/agents/openai-ws-stream.ts),取不到 API Key 时回落 HTTP SSE ConverseStream(AWS 事件流编码) SSE SSE
上下文窗口 默认 200K;开 context1m 走 1M beta 内置目录里 GPT-5.4 为 272K、GPT-5.4 Pro 为 1.05M 约 1M 级 取决于底层模型 内置目录为 128K–256K 内置目录为 256K 级(262144)
geminiThoughtSignatureSanitization false false false(仅当经 opencode、opencode-go、openrouter、kilocode 转发 Gemini 模型时为 true) false false false

此表中的 providerFamily、normalizeProviderId、dropThinkingBlockModelHints 等字段来自 src/agents/provider-capabilities.ts、src/agents/provider-id.ts,以及各 Provider 插件自己声明的 capabilities(如 extensions/amazon-bedrock/index.ts 里的 providerFamily: "anthropic" + dropThinkingBlockModelHints: ["claude"])。注意 anthropicToolSchemaMode 虽然类型上支持 "openai-functions",但仓库里没有任何内置 Provider 覆盖它——它是留给第三方插件的开关。Mistral 系列使用 transcriptToolCallIdMode: "strict9",此处省略未列。「上下文窗口」一行取自 pin 上的内置模型目录(extensions/openai/openai-provider.ts、extensions/volcengine/models.ts、extensions/moonshot/provider-catalog.ts)与 src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts 的 context-1m beta 处理,Gemini 以 Google 官方模型页为准,都会随版本变化。4.4.3 节对 <think>、<thinking> 等标签的扫描是对正文文本的通用处理,不按厂商区分。

4.6 Provider 能力发现与自动注册

4.6.1 Ollama 本地模型自动发现

OpenClaw 支持自动发现本地运行的 Ollama 模型实例。extensions/ollama/index.ts 给 Ollama Provider 注册了一个 discovery 钩子(order: "late"),生成 models.json 时的隐式 Provider 发现阶段会调用 Ollama API 获取可用模型列表,写成该 Provider 的模型条目;若已在 models.providers.ollama.models 里显式列出模型,则直接用显式列表,不再查询:

// extensions/ollama/src/provider-models.ts
// fetchOllamaModels() 调用 `${apiBase}/api/tags`
// 默认 apiBase = "http://127.0.0.1:11434",由 resolveOllamaApiBase() 决定
// 返回的 OllamaTagsResponse.models 经 buildOllamaModelDefinition 注册到 Provider 注册表

这使得用户只需在配置中指定 ollama 作为 Provider,无需手动列出每个模型名称。重新生成 models.json 时,模型列表会跟着 Ollama 实例的变化更新。

4.6.2 GitHub Copilot 集成

OpenClaw 支持通过 GitHub Copilot 访问模型,利用用户已有的 Copilot 订阅。这种集成由 extensions/github-copilot/ 插件实现:GitHub 设备登录得到的 token(或环境变量 COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN)作为一种 Auth Profile 保存,运行时插件的 prepareRuntimeAuth 再用它向 GitHub 换取 Copilot API token(src/agents/github-copilot-token.ts)。

4.6.3 自定义 Provider 注册

Provider 插件可以通过 createStreamFn 钩子提供一个满足 StreamFn 签名的函数,把任何 LLM API 接入 OpenClaw。src/agents/provider-stream.ts 的 registerProviderStreamForModel() 取到这个函数后,交给 src/agents/custom-api-registry.ts 的 ensureCustomApiRegistered(),按模型的 api 协议标识注册进 pi-ai 的 API 注册表(该标识已注册则跳过):

// src/agents/provider-stream.ts(节选)
const streamFn = resolveProviderStreamFn({ provider: params.model.provider, /* ... */ });
if (!streamFn) {
  return undefined;
}
ensureCustomApiRegistered(params.model.api, streamFn);
return streamFn;

这种设计使得 OpenClaw 能够适配任何新出现的 LLM 服务,包括私有部署的模型。

⚠️ 常见陷阱:降级链配置中的模型名拼写

模型降级链中的每个模型名必须与 Provider 注册的模型 ID 精确匹配,并且最好带上 provider/ 前缀——不带前缀的条目会按主模型的 Provider 解析,"gpt-4-turbo" 在 Anthropic 主模型下会变成 anthropic/gpt-4-turbo。常见错误:

// ❌ 错误:模型名使用了旧的命名格式,且没写 Provider 前缀
{ "fallbacks": ["claude-3-opus-20240229", "gpt-4-turbo"] }

// ✅ 正确:使用当前有效的模型 ID,并写全 provider/model
{ "fallbacks": ["anthropic/claude-opus-4-6", "openai/gpt-5.4"] }

如果降级链中包含已下线或不存在的模型 ID,该候选并不会被静默跳过:它照样会被尝试,失败后记成一条 FallbackAttempt(报错被识别为模型不存在时 reason 为 model_not_found),再轮到下一个候选;后续候选成功时日志还会打一行 Model "…" not found. Fell back to "…" 警告。当所有候选都失效时,throwFallbackFailureSummary() 会抛出汇总错误。通过查看 Gateway 日志中的降级记录(model-fallback 子系统)可以排查降级链中哪些模型不可用。

⚠️ 常见陷阱:normalizeUsage() 返回 undefined

某些 Provider(尤其是通过 OpenAI 兼容端点接入的本地模型)不返回 Token 用量信息。此时 normalizeUsage() 返回 undefined,而非零值对象。依赖用量数据做成本核算时,务必处理 undefined 情况:

const usage = normalizeUsage(rawResponse.usage);
// ❌ 错误:直接访问可能为 undefined 的对象
const totalCost = usage.total * pricePerToken;

// ✅ 正确:先检查 undefined
const totalCost = usage?.total ? usage.total * pricePerToken : 0;

4.7 实战:Provider 故障排查

4.7.1 常见问题与诊断

在生产环境中,Provider 相关的问题是最常见的故障源。以下是典型问题和排查路径:

问题 1:所有模型调用都返回 401

  • 检查 API 密钥是否在 ~/.openclaw/openclaw.json(或其 env 段 / 环境变量)中正确配置
  • 检查 Auth Profile 是否处于 cooldown 状态(resolveProfilesUnavailableReason())
  • 查看日志中的 model-fallback 子系统输出

问题 2:模型降级到了不期望的模型

  • 检查 agents.defaults.model.fallbacks 的写法:不带 provider/ 前缀的条目会按主模型的 Provider 解析;未显式覆盖时,配置的 primary 还会被追加为最后一个候选
  • 查看 logModelFallbackDecision() 的日志输出
  • 确认降级候选链中的模型都可用

问题 3:Token 用量统计不准确

  • 不同 Provider 的 token 统计字段可能不同(input_tokens vs prompt_tokens)
  • 缓存 token 可能不被计入使用量
  • 检查 Provider 的能力声明中的 token 字段映射

4.7.2 Provider 性能调优

  1. 配置多个 Auth Profile:同一 Provider 使用多个 API 密钥,分散限流风险(前提是这些密钥的配额彼此独立,见 4.2.3 节的最佳实践)
  2. 合理设置降级链:将低延迟模型放在前面,高性能模型作为主力
  3. 启用缓存:对于支持提示词缓存的 Provider(如 Anthropic),利用缓存减少 token 消耗
  4. 监控冷却期:如果某个 Profile 频繁进入冷却期,考虑降低调用频率或增加密钥

4.8 Provider 系统的设计决策分析

4.8.1 为什么选择函数式 StreamFn 而非类继承?

传统的适配器模式通常使用类继承:每个 Provider 继承一个基类,实现抽象方法。OpenClaw 选择了函数式的 StreamFn 签名:

// 示意:StreamFn 由依赖 @mariozechner/pi-agent-core 导出(仓库未安装依赖,按仓库内调用处归纳);
// 它与 pi-ai 的 streamSimple 同形,可以直接拿 streamSimple 当默认实现
type StreamFn = (
  model: Model<Api>,           // 模型对象(含 id、api、provider 等),不是字符串
  context: Context,
  options?: SimpleStreamOptions,
) => AssistantMessageEventStream; // 事件流对象,而非 async generator

这个选择的原因:

  1. 轻量性:添加新 Provider 只需一个函数,无需定义类、构造函数、继承链
  2. 可组合性:函数可以被高阶函数包装(如添加日志、重试、缓存),而类继承不容易组合
  3. 测试友好:测试中只需 mock 一个函数,而非整个类实例
  4. 避免菱形继承:如果某个 Provider 需要同时使用 OpenAI 兼容协议和自定义认证,类继承会面临多继承问题

4.8.2 Auth Profile 轮转 vs 单密钥配置

不少 Agent 框架只支持单 API 密钥配置。OpenClaw 的 Auth Profile 轮转机制允许为同一个 Provider 配置多个密钥,并在密钥被限流时自动切换。

这个设计针对的是实际使用中的痛点:单个账号的速率限制往往不足以支撑多 Agent 并发调用。只要各密钥的配额彼此独立(厂商通常按组织/账号计限额,同一组织下的多个 Key 共享配额),多密钥轮转就能在不增加单价的情况下提升有效吞吐量。

实战场景:高并发下的密钥轮转

想象你运营一个面向 200 人的 Discord 社区 Agent,配置了分属三个 Anthropic 组织的三个 API 密钥(Anthropic 的限额按组织计算,同一组织的 Key 共享配额,轮转救不了)。周五晚上社区活跃度激增,密钥 A 在 10 秒内收到 5 个并发请求,触发了 Anthropic 的 429 速率限制。

OpenClaw 的 Auth Profile 轮转机制立即介入:

  1. 密钥 A 被标记为"冷却中",冷却计时器启动
  2. 后续请求自动路由到密钥 B
  3. 密钥 B 同样触发限流时,继续切换到密钥 C
  4. 当密钥 A 的冷却期结束后,重新加入可用轮转池

如果三个密钥同时耗尽,降级链接管——系统自动切换到配置的备用 Provider(如 GPT-4.1)。降级本身对用户静默:只有开了 verbose 的会话才会在回复里多出一行可见提示 ↪️ Model Fallback: <生效模型> (selected <原选模型>; <原因>)(src/auto-reply/fallback-state.ts:61-75,由 src/auto-reply/reply/agent-runner.ts:693-703 在 verboseEnabled 时才追加);非 verbose 下只留 fallback 生命周期事件与 fallbackNotice* 会话状态,回复正文一个字都不加。整个过程中,社区成员只会感觉到"Agent 偶尔换了种说话风格",不会遇到任何错误或超时。

4.8.3 Provider 能力声明的三层叠加

为什么不用一个大的配置文件定义所有 Provider 的能力?

三层叠加(DEFAULT_PROVIDER_CAPABILITIES → PLUGIN_CAPABILITIES_FALLBACKS → 插件声明)的设计考量:

  • 全局默认:确保系统在零配置、Provider 未知时也能正常工作
  • 内置兜底表:为 anthropic、mistral、moonshot、kimi、opencode、opencode-go、openai 这几个高频 Provider 预置正确行为,即使对应插件没装也不会跑偏
  • 插件声明:插件自己上报能力,优先级最高,覆盖前两层

这种渐进式的能力声明最大化了开箱即用体验,同时保留了完全的可定制性。

4.9 小结:模型是可替换的,协议是统一的

"模型是可替换的、协议是统一的"——这句话可以作为整个 AI 时代基础设施设计的"第一原理"。模型会更新换代、厂商会起起落落、价格会涨涨跌跌——这些都是变量;但"如何调用一个模型"这件事在协议层可以保持稳定、"如何处理用户会话"这件事在抽象层可以保持稳定——这些是常量。区分变量和常量、是所有长寿系统设计的起点。一个系统把变量错当常量(比如硬编码模型 ID、硬编码 API 路径)、几个月后就会被现实逼着重写;把常量错当变量(比如过度抽象、到处都是 if-else)、又会让代码难以维护。OpenClaw 的 Provider 抽象、在这两者之间找到了精妙的平衡——该稳定的部分(统一接口、统一错误处理、统一监控)极其稳定;该灵活的部分(具体模型、认证方式、特性开关)极其灵活。这种平衡力、是架构师最稀缺也最珍贵的能力。

本章和《Claude Code 源码深度解析》专栏第 11 章(MCP 协议集成)、《Tokio 源码深度解析》专栏第 8 章(Reactor 与 I/O Driver 架构)共同构成了一组"如何用协议统一异构世界"的案例集。MCP 让不同工具共享同一个交互协议、Tokio 的 I/O 抽象让不同平台的系统调用被包装成同一个 Future 接口、Vue 3 的渲染器抽象让同一套代码能生成 Web DOM 或 Canvas 或原生 UI——它们和 OpenClaw Provider 抽象做的事情、本质上完全一致:在异构的现实上方、构造一个同构的接口。这种"用统一接口驯服异构"的能力、是现代软件工程最重要的能力之一——它让一个设计良好的系统能对抗时间的侵蚀、能跨越具体技术的更迭、能在技术潮流变化时依然保持核心稳定。

回到本章开头的场景:Anthropic 服务降级,你的用户浑然不知。

这个体验的背后,是 Provider 抽象层的五大支柱在协同工作:

  1. 规范化标识——把 bedrock/aws-bedrock、doubao/bytedance 这类 Provider 别名统一为一个身份,把 opus-4.6 这样的简写规整为 claude-opus-4-6,modelKey() 再避免 anthropic/anthropic/… 式的重复前缀。混乱的命名不再是你的问题。
  2. 三层能力声明——全局默认 → 内置兜底表 → 插件声明。零配置即可用,插件声明优先级最高。
  3. 降级链 + Auth 轮转——当主模型倒下,备用模型沿候选列表接管。多密钥轮转让 API 配额的利用率最大化。
  4. Token 归一化——Anthropic 的 input_tokens 和 OpenAI 的 prompt_tokens 最终都变成了 NormalizedUsage.input,成本控制从此有了统一的度量衡。
  5. 流式状态机——处理思维链过滤和压缩协调的并发挑战,让用户看到的始终是干净、流畅的响应。

在 AI 领域,今天的最强模型可能是明天的次优选择。唯一不变的,是变化本身。 Provider 抽象层的价值不在于支持了哪些模型,而在于它让"更换模型"变成了一个配置变更而非代码重写。

这个"模型无关"的基础,为下一章的 Session 系统铺平了道路——因为只有当底层模型可以自由切换时,上层的会话管理才能真正做到持久化和可恢复。

从产品策略角度再品味一下 Provider 层的价值——它让 OpenClaw 的使用者能够"在模型之间套利"。什么意思? LLM 市场、是一个价格和性能差异巨大的市场——旗舰模型贵但能力强、长上下文模型适合长文本、多模态模型擅长看图听音、开源模型性价比高、本地 Ollama 没有调用费但需要显卡。没有 Provider 抽象、你的业务代码和某个具体模型深度绑定、切换成本巨大。有了 Provider 抽象、你可以"按任务类型选模型"——简单对话用便宜的、长文本分析用强的、创意生成用有个性的、敏感数据处理用本地的。这种"模型组合拳"的能力、让你的实际运营成本可以显著低于"所有任务都打到同一个最贵模型"的方案——这在规模化运营场景下是实打实的商业优势。OpenClaw 的 Provider 层、不只是工程上的优雅、更是商业上的重要基础设施。

读完这一章、你应该带走三点:(1) Provider 抽象是"让应用在模型生态变化中保持稳定"的基石——没有它、每次模型厂商更新 API 你都要改代码;(2) 能力声明的三层叠加是"零配置友好 + 深度定制可能"的平衡——让新手和资深用户都满意;(3) 降级链和认证轮转是"把单点故障变成系统韧性"的工程手段——让单个供应商的抖动不再等于你整个服务的抖动。这三个能力、是一个生产级 AI 系统的 Provider 层应该具备的核心要素。

🛠️ 试一试:配置一个新 Provider

在已有的 Gateway 基础上,尝试添加第二个 Provider 并体验降级链:

# 编辑 ~/.openclaw/openclaw.json,配置主模型与降级链:
# {
#   "env": {
#     "ANTHROPIC_API_KEY": "sk-ant-...",
#     "OPENAI_API_KEY": "sk-..."
#   },
#   "agents": {
#     "defaults": {
#       "model": {
#         "primary": "anthropic/claude-opus-4-6",   // ← 主模型
#         "fallbacks": ["openai/gpt-5.4"]           // ← 降级备选,按顺序尝试
#       }
#     }
#   }
# }

# 设置环境变量
export ANTHROPIC_API_KEY="sk-ant-..."  # ← 替换为你的真实 Key
export OPENAI_API_KEY="sk-..."         # ← 替换为你的真实 Key

# 重启 Gateway 使配置生效
openclaw gateway restart

# 测试降级:尝试用一个无效的 Anthropic Key,观察是否自动切换到 OpenAI

💡 观察要点:故意把 ANTHROPIC_API_KEY 设为无效值,然后发送请求。观察日志中的降级过程——这就是 4.2.3 节描述的降级链在实际运作。注意 Gateway 不会回写任何自定义响应头来告诉你降级去了哪里:要看实际生效的模型,得查日志里的 logModelFallbackDecision() 输出、fallback 生命周期事件,或者开 verbose 让回复里带上 ↪️ Model Fallback: 提示。


思考题

  1. 概念理解:Provider 抽象层为什么采用"不可变凭证快照"而非直接读取环境变量?这种设计在热重载场景下有什么关键优势?
  2. 实践应用:假设你需要为 OpenClaw 接入一个新的 LLM 提供商(例如 Mistral),你需要实现哪些接口?流式响应和工具调用的适配有何难点?
  3. 开放讨论:随着 LLM 提供商的 API 日趋标准化(如 OpenAI 兼容端点成为事实标准),Provider 抽象层的价值会减弱还是增强?为什么?

工程价值:Provider 抽象是"抗厂商锁定"的工程护城河。它让"切换 LLM 厂商"从一次大手术,变成改一行配置——OpenAI 涨价三天切到 Claude,Anthropic 限流一天切到 Gemini。这种"切换能力"本身就是议价筹码。

函数式 Provider 定义:每个 Provider 插件导出的是一个对象字面量(由 definePluginEntry() / defineSingleProviderPluginEntry() 包装,在 register 里通过 api.registerProvider() 登记),Provider 本身是一组钩子函数,而不是一个类,契合 TypeScript 的语言特性——函数是一等公民、闭包封装状态、对象字面量直接实现接口。OpenClaw 没有引入 NestJS 式的 DI 容器,因为 Provider 间依赖极简(钩子只接收一个上下文对象),不需要复杂的生命周期管理——DI 在这里反而是大炮打蚊子。

下一章:Session 子系统,讲 OpenClaw 如何把"有状态、跨多次请求、可能很长"的用户对话,映射到持久化、可恢复、可搜索的存储系统中。

📚 推荐阅读