OpenClaw 深度解析(五):模型与提供商系统

0 阅读9分钟

场景:用户想在 Claude 和 Kimi 之间切换

你在用 OpenClaw 处理日常工作,大多数时候用 claude-sonnet-4-6,但对于某些中文任务你更喜欢 Kimi(月之暗面)。你在配置文件里同时保存了两个提供商的密钥,并且希望能用 anthropic/claude-sonnet-4-6 或者 kimi-coding/k2p5 来指定它们,还想在 Claude 触发速率限制时自动回退到备用模型。

这个场景暴露了五个具体问题:

  1. 寻址:系统怎么知道 kimi-coding/k2p5 是"月之暗面,k2p5 模型"?
  2. 认证:每个提供商的 API 密钥存在哪、怎么取?
  3. 自动发现:不需要在配置里把每个提供商手写一遍吗?
  4. 回退:主模型失败后,系统怎么自动切换?
  5. 扩展:MiniMax 等新提供商是怎么通过 Plugin SDK 接入的?

一、模型地址:ModelRef

所有的模型操作都从一个基本单元开始:

// src/agents/model-selection.ts
export type ModelRef = {
  provider: string;  // "anthropic" | "kimi-coding" | "ollama" | ...
  model: string;     // "claude-sonnet-4-6" | "k2p5" | "llama3.2" | ...
};

模型在所有地方都以 provider/model 格式书写,例如 anthropic/claude-sonnet-4-6parseModelRef 负责解析这个字符串:

export function parseModelRef(raw: string, defaultProvider: string): ModelRef | null {
  const slash = raw.indexOf("/");
  if (slash === -1) {
    // 没有斜杠:使用默认提供商(anthropic)
    return normalizeModelRef(defaultProvider, raw);
  }
  const provider = raw.slice(0, slash);
  const model = raw.slice(slash + 1);
  return normalizeModelRef(provider, model);
}

默认值src/agents/defaults.ts):

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

当用户写 model: claude-sonnet-4-6(不带斜杠),系统自动补全为 anthropic/claude-sonnet-4-6 并给出弃用警告,引导用户使用完整格式。

提供商名称归一化

不同用户可能用不同的名称指代同一个提供商,normalizeProviderId 处理所有已知别名:

export function normalizeProviderId(provider: string): string {
  const normalized = provider.trim().toLowerCase();
  if (normalized === "z.ai" || normalized === "z-ai")    return "zai";
  if (normalized === "qwen")                              return "qwen-portal";
  if (normalized === "kimi-code")                         return "kimi-coding";
  if (normalized === "bedrock" || normalized === "aws-bedrock") return "amazon-bedrock";
  if (normalized === "bytedance" || normalized === "doubao")    return "volcengine";
  return normalized;
}

这让用户在配置里写 doubaobytedance 都能正确路由到 VolcEngine 提供商,而不用关心内部 ID。


二、提供商配置:静态声明与自动发现

静态配置

用户在 openclaw.yml 里为每个提供商声明:

models:
  providers:
    anthropic:
      apiKey: ANTHROPIC_API_KEY    # 环境变量名
    kimi-coding:
      baseUrl: https://api.kimi.com/coding/
      apiKey: KIMI_CODING_API_KEY
      api: anthropic-messages       # 协议类型
      models:
        - id: k2p5
          name: "Kimi K2.5"
          contextWindow: 262144

每个 ProviderConfig 包含:

  • apiKey:可以是实际密钥字符串、环境变量名(系统自动读取)、或 OAuth 占位符
  • baseUrl:API 端点
  • api:协议类型(anthropic-messagesopenai-compatiblegoogle-ai 等)
  • models:该提供商的模型列表,含上下文窗口、推理能力、输入类型等元数据

隐式自动发现

但要求用户手写每个提供商太繁琐了。resolveImplicitProviders() 在 Gateway 启动时自动扫描:

export async function resolveImplicitProviders(params: {
  agentDir: string;
  explicitProviders?: Record<string, ProviderConfig> | null;
}): Promise<ModelsConfig["providers"]> {
  const providers: Record<string, ProviderConfig> = {};
  const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });

  // 检查 MiniMax:有 API Key 环境变量或认证档案就自动激活
  const minimaxKey = resolveEnvApiKeyVarName("minimax")
    ?? resolveApiKeyFromProfiles({ provider: "minimax", store: authStore });
  if (minimaxKey) {
    providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey };
  }

  // 检查 Kimi:同样逻辑
  const kimiCodingKey = resolveEnvApiKeyVarName("kimi-coding")
    ?? resolveApiKeyFromProfiles({ provider: "kimi-coding", store: authStore });
  if (kimiCodingKey) {
    providers["kimi-coding"] = { ...buildKimiCodingProvider(), apiKey: kimiCodingKey };
  }

  // ... 同样的模式处理 20+ 个提供商
}

这意味着用户只需要设置 KIMI_CODING_API_KEY 环境变量,OpenClaw 下次启动时就会自动找到 Kimi 并注入它的完整模型列表——不需要改任何配置。

合并策略:merge 模式

当隐式发现和显式配置同时存在时,models.mode = "merge"(默认值)将两者合并:

function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig {
  // 合并规则:
  // - 显式配置的用户字段(cost、headers、compat)保留
  // - 从内置目录刷新 input、contextWindow、maxTokens(避免用户手动维护)
  // - reasoning 字段:用户显式设置的优先;否则采用内置默认值
  return {
    ...explicitModel,
    input: implicitModel.input,
    reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning,
    contextWindow: implicitModel.contextWindow,
    maxTokens: implicitModel.maxTokens,
  };
}

这个设计解决了一个实际痛点:当 Anthropic 发布新模型时,用户不需要手动更新配置里的 contextWindow 值,内置目录会自动刷新——但用户自定义的 cost 覆盖和 headers 注入依然生效。


三、模型目录与认证档案

模型目录(Model Catalog)

ModelCatalogEntry 是对"一个可用模型"的完整描述:

type ModelCatalogEntry = {
  id: string;                              // "claude-sonnet-4-6"
  name: string;                            // "Claude Sonnet 4.6"
  provider: string;                        // "anthropic"
  contextWindow?: number;                  // 200000
  reasoning?: boolean;                     // 是否支持扩展思考
  input?: Array<"text" | "image">;         // 支持的输入类型
};

目录通过 @mariozechner/pi-coding-agent SDK 的 ModelRegistry 获取——这是 OpenClaw 底层的推理 SDK,内置了 Anthropic、OpenAI、Google 等主流提供商的最新模型列表。自定义提供商的模型会追加到这个目录之后。

认证档案(Auth Profiles)

为了支持多账户速率限制轮转,OpenClaw 不直接存储 API Key,而是使用认证档案(Auth Profile)系统。

每个 Profile 是这样的结构:

type ApiKeyCredential  = { type: "api_key"; provider: string; apiKey: string };
type OAuthCredential   = { type: "oauth"; provider: string; access: string; refresh: string; expires: number };
type TokenCredential   = { type: "token"; provider: string; token: string };
type AuthProfileCredential = ApiKeyCredential | OAuthCredential | TokenCredential;

档案存储在 ~/.openclaw/agents/<agentId>/auth.json。用户可以为同一个提供商配置多个档案(例如两个 Anthropic 账户)。

冷却机制:当一个 Profile 触发 429(速率限制)时,系统调用 markAuthProfileCooldown(),为该 Profile 设置冷却期。下次请求时,resolveAuthProfileOrder() 会优先选择不在冷却期的 Profile——这实现了多账户之间的自动轮转,无需用户手动干预。

请求 → resolveAuthProfileOrder() → 选 Profile(跳过冷却中的)
                                  ↓
                             触发 429? → markAuthProfileCooldown()
                             成功? → markAuthProfileGood() + markAuthProfileUsed()

四、模型选择与别名

模型配置块:既是白名单也是别名表

agents.defaults.models 是一个双重用途的配置块:

agents:
  defaults:
    model: anthropic/claude-sonnet-4-6
    models:
      anthropic/claude-sonnet-4-6:
        alias: sonnet       # 给这个模型起一个短名字
      kimi-coding/k2p5:
        alias: kimi
      anthropic/claude-haiku-4-5:
        alias: haiku
  • models 非空时:只有列出的模型才被允许使用——这是一个白名单。任何未在列表中的 provider/model 会被拒绝("model not allowed: ...")。
  • alias 字段:允许用户用 kimi 代替完整的 kimi-coding/k2p5。别名解析在每次模型引用时都会检查。

白名单的例外:显式配置的 fallback 列表绕过白名单。这是合理的——用户既然配置了回退链,就是在明确授权这些模型。

模型别名系统

export function buildModelAliasIndex(params: {
  cfg: OpenClawConfig;
  defaultProvider: string;
}): ModelAliasIndex {
  const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
  // ...
  // 填充:alias "sonnet" → { provider: "anthropic", model: "claude-sonnet-4-6" }
}

解析时优先检查别名:

export function resolveModelRefFromString(params: {
  raw: string;
  defaultProvider: string;
  aliasIndex?: ModelAliasIndex;
}): { ref: ModelRef; alias?: string } | null {
  if (!params.raw.includes("/")) {
    // 没有斜杠:先查别名表
    const aliasMatch = params.aliasIndex?.byAlias.get(params.raw.toLowerCase());
    if (aliasMatch) return { ref: aliasMatch.ref, alias: aliasMatch.alias };
  }
  return { ref: parseModelRef(params.raw, params.defaultProvider) };
}

所以在 Telegram 消息里发 /model kimi 就能切换到 kimi-coding/k2p5,不需要记住完整路径。


五、回退链:主模型失败时的自动降级

问题:主模型触发速率限制或宕机

配置 fallback 链:

agents:
  defaults:
    model:
      primary: anthropic/claude-sonnet-4-6
      fallbacks:
        - anthropic/claude-haiku-4-5   # 先降级到小模型
        - kimi-coding/k2p5             # 再试 Kimi

resolveFallbackCandidates() 按顺序构建候选列表,去重并过滤无效项。实际的轮转逻辑在 pi-embedded-runner.ts 的外层重试循环里——我们在上一篇分析 Agent 引擎时已经看到了这个结构:

// 简化版本
const candidates = resolveFallbackCandidates({ cfg, provider, model });
for (const candidate of candidates) {
  try {
    return await runWithModel(candidate);  // 成功则返回
  } catch (err) {
    if (isFailoverError(err)) continue;    // 可降级的错误:试下一个
    if (isFallbackAbortError(err)) throw;  // 用户主动中止:不降级
  }
}
throw new Error(`All fallbacks failed: ...`);

探针机制(Probe)

冷却中的主模型不会被永久跳过。shouldProbePrimaryDuringCooldown 实现了一个**探针(probe)**逻辑:

  • 每隔 30 秒,即使主模型仍在冷却期,也允许发一次探针请求
  • 如果冷却过期时间在未来 2 分钟内,提前开始探针
  • 探针成功 → 主模型恢复,markAuthProfileGood()

这保证了速率限制解除后系统能快速恢复到主模型,而不是永远停留在降级状态。


六、通过 Plugin SDK 注册新提供商

问题:第三方提供商如何接入?

以 MiniMax 为例。它的 OAuth 流程不是标准 API Key,而是需要一个带浏览器跳转的 Device Code 流程。这需要:

  1. 一个交互式引导用户完成 OAuth 的 auth 方法
  2. 认证成功后,往配置写入 models.providers.minimax-portalconfigPatch
  3. 写入认证档案(access token + refresh token)

这些都通过 ProviderPlugin 类型和 api.registerProvider() 实现:

// extensions/minimax-portal-auth/index.ts
const minimaxPortalPlugin = {
  id: "minimax-portal-auth",
  configSchema: emptyPluginConfigSchema(),
  register(api: OpenClawPluginApi) {
    api.registerProvider({
      id: "minimax-portal",
      label: "MiniMax",
      docsPath: "/providers/minimax",
      aliases: ["minimax"],
      auth: [
        {
          id: "oauth",
          label: "MiniMax OAuth (Global)",
          kind: "device_code",         // 触发 Device Code 流程
          run: createOAuthHandler("global"),
        },
        {
          id: "oauth-cn",
          label: "MiniMax OAuth (CN)",
          kind: "device_code",
          run: createOAuthHandler("cn"),
        },
      ],
    });
  },
};

ProviderAuthMethod.run 返回的 ProviderAuthResult 包含:

return {
  // 写入认证档案:access token + refresh token
  profiles: [{
    profileId: "minimax-portal:default",
    credential: { type: "oauth", access: "...", refresh: "...", expires: ... },
  }],

  // 写入 openclaw.yml 的 configPatch:提供商配置 + 模型列表
  configPatch: {
    models: {
      providers: {
        "minimax-portal": {
          baseUrl: "https://api.minimax.io/anthropic",
          apiKey: "minimax-oauth",  // OAuth 占位符
          api: "anthropic-messages",
          models: [{ id: "MiniMax-M2.1", ... }, { id: "MiniMax-M2.5", ... }],
        },
      },
    },
    agents: { defaults: { models: {
      "minimax-portal/MiniMax-M2.1": { alias: "minimax-m2.1" },
      "minimax-portal/MiniMax-M2.5": { alias: "minimax-m2.5" },
    }}},
  },

  // 引导用户立即切换到这个模型
  defaultModel: "minimax-portal/MiniMax-M2.5",
};

用户运行 openclaw login 选择 MiniMax,完成 OAuth 跳转后,这一切自动写入——从此 minimax-m2.5 别名就可以使用了。


七、reasoning 字段与思考等级

部分模型(如 Claude Sonnet 4.6 的 extended thinking 模式)支持"慢思考"——花更多 token 推理后再给出答案。OpenClaw 用 ThinkLevel 统一管理这个维度:

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

resolveThinkingDefault 从模型目录读取 reasoning: true 字段,自动将支持推理的模型的默认思考等级设为 "low"

export function resolveThinkingDefault(params: {
  cfg: OpenClawConfig;
  provider: string;
  model: string;
  catalog?: ModelCatalogEntry[];
}): ThinkLevel {
  const configured = params.cfg.agents?.defaults?.thinkingDefault;
  if (configured) return configured;

  const candidate = params.catalog?.find(
    (entry) => entry.provider === params.provider && entry.id === params.model,
  );
  // 有推理能力的模型默认开启 low 等级
  return candidate?.reasoning ? "low" : "off";
}

这让用户不用手动配置"这个模型需要开启 thinking"——模型目录里的能力声明自动决定了默认行为。


小结

模型与提供商系统的核心是一套多层次的寻址与路由机制:

层次机制作用
地址层ModelRef = { provider, model }统一的"提供商/模型"坐标
归一化层normalizeProviderId() + normalizeProviderModelId()处理用户输入的多样性
别名层ModelAliasIndex允许用户用短名字代替完整路径
目录层ModelCatalogEntry[]声明模型的能力(上下文、推理、输入类型)
发现层resolveImplicitProviders()扫描环境变量/认证档案,自动激活提供商
合并层mergeProviderModels()内置目录刷新元数据,保留用户自定义字段
认证层Auth Profiles + 冷却机制多账户轮转,速率限制自动规避
回退层resolveFallbackCandidates() + 探针主模型失败时自动降级,冷却恢复后探针恢复
扩展层ProviderPlugin + api.registerProvider()第三方提供商通过 Plugin SDK 注入 OAuth 流程和模型配置

下一篇,我们将进入 OpenClaw 的节点系统与 Canvas——探索 Pi 框架中的"节点"概念如何支撑多 Agent 协作,以及 Canvas 是什么。