第 23 章

Plugin API 全解:45个 Hook 的7个阶段调用顺序与穿透规则

第23章 Plugin API 全解:45个 Hook 的7个阶段调用顺序与穿透规则

23.1 7个阶段的整体设计逻辑

OpenClaw 的 45 个 Hook 不是随意堆砌的 API,它们被组织在一条严格定义的处理流水线中。每当系统需要处理一个 LLM 请求时,这条流水线从头到尾执行一遍。

理解 7 个阶段的设计逻辑,比记忆每个 Hook 的签名更重要。以下是整体设计思路:

用户请求
    ↓
[阶段1] 配置物化        ← Provider 是什么?有哪些默认值?
    ↓
[阶段2] 模型解析        ← 具体用哪个模型?用什么传输层?
    ↓
[阶段3] 认证解析        ← 用哪个 API Key?有无外部认证?
    ↓
[阶段4] 模型准备        ← 动态模型是否需要特殊准备?能力集是什么?
    ↓
[阶段5] 推理配置        ← Schema 如何规范化?用流式还是非流式?
    ↓
[阶段6] 流式传输        ← 如何创建/包装流?传输层状态如何?
    ↓
[阶段7] 运行时          ← 准备认证/快照/嵌入/重放策略
    ↓
实际 LLM 调用

每个阶段都有明确的职责边界——前一阶段的输出是后一阶段的输入。Plugin 通过在特定阶段注册 Hook 来参与处理流程。


23.2 阶段1:配置物化

核心问题:这个 Provider 的基础配置是什么?

Hook: catalog

调用时机:系统启动时,以及每次需要获取 Provider 能力目录时

作用:Provider Plugin 通过 catalog hook 向系统声明自己支持的所有模型。这是 Provider Plugin 最基础的 hook。

api.registerProvider({
  id: 'my-provider',
  label: 'My Provider',
  
  // catalog hook:返回所有可用模型的描述
  catalog: async () => ({
    models: [
      {
        id: 'my-provider/fast-v1',
        displayName: 'Fast Model v1',
        contextWindow: 128000,
        capabilities: {
          streaming: true,
          functionCalling: true,
          vision: false,
        },
        pricing: {
          inputPerMToken: 0.50,
          outputPerMToken: 1.50,
        }
      },
      {
        id: 'my-provider/powerful-v2',
        displayName: 'Powerful Model v2',
        contextWindow: 200000,
        capabilities: {
          streaming: true,
          functionCalling: true,
          vision: true,
        }
      }
    ]
  })
})

Hook: applyConfigDefaults

调用时机:Config Normalization 步骤完成后,模型解析之前

作用:为 Provider 的配置填充动态默认值。与 Manifest schema 中的静态默认值不同,applyConfigDefaults 可以基于运行时环境填充默认值(例如:根据当前 OS 选择默认模型)。

api.registerProvider({
  id: 'my-provider',
  
  applyConfigDefaults: (config, context) => {
    // 如果用户未指定默认模型,根据环境选择
    if (!config.defaultModel) {
      config.defaultModel = context.isLowMemoryEnvironment
        ? 'my-provider/fast-v1'
        : 'my-provider/powerful-v2'
    }
    return config
  }
})

23.3 阶段2:模型解析

核心问题:用户指定的模型字符串对应哪个实际模型?使用什么传输层?

Hook: normalizeModelId

调用时机:收到含有模型 ID 的请求时,最先调用

作用:将用户可能使用的别名、简写、旧版 ID 映射到当前规范 ID。

穿透规则normalizeModelId 先检查拥有该模型的 Provider Plugin(ownership 原则),再穿透其他 hook-capable Plugin。

api.registerProvider({
  id: 'my-provider',
  
  normalizeModelId: (modelId, context) => {
    // 处理用户可能使用的各种别名
    const aliases: Record<string, string> = {
      'fast': 'my-provider/fast-v1',
      'my-fast': 'my-provider/fast-v1',
      'v1': 'my-provider/fast-v1',
      'powerful': 'my-provider/powerful-v2',
    }
    
    if (aliases[modelId]) {
      return aliases[modelId]
    }
    
    // 返回 null 表示"我不拥有这个模型,穿透给下一个 Plugin"
    return null
  }
})

Hook: normalizeTransport

调用时机normalizeModelId 之后

作用:决定使用哪个传输协议变体(OpenAI 兼容 REST / gRPC / WebSocket / 自定义 HTTP)。

api.registerProvider({
  id: 'my-provider',
  
  normalizeTransport: (modelId, config) => {
    if (modelId === 'my-provider/powerful-v2') {
      // 大模型使用流式 gRPC
      return { type: 'grpc', streaming: true }
    }
    // 默认使用 OpenAI 兼容 REST
    return { type: 'openai-compat', baseUrl: config.baseUrl }
  }
})

Hook: normalizeConfig

调用时机normalizeTransport 之后

作用:对请求配置做最终规范化(合并 Profile 配置、处理特殊字段映射等)。

api.registerProvider({
  id: 'my-provider',
  
  normalizeConfig: (rawConfig, modelId) => {
    return {
      ...rawConfig,
      // 将 OpenClaw 标准字段映射到 Provider API 字段
      max_tokens: rawConfig.maxOutputTokens ?? 4096,
      temperature: rawConfig.temperature ?? 0.7,
    }
  }
})

23.4 阶段3:认证解析

核心问题:用哪个凭证发起请求?

Hook: resolveConfigApiKey

调用时机:模型解析完成后,第一个认证相关 hook

作用:从已解密的配置中提取 API Key。最常用的认证 hook。

api.registerProvider({
  id: 'my-provider',
  
  resolveConfigApiKey: (config) => {
    // config.apiKey 已经由 Config Normalization 解密
    return config.apiKey ?? null
  }
})

Hook: resolveSyntheticAuth

调用时机resolveConfigApiKey 之后,用于需要非 API Key 认证的场景

作用:生成合成认证令牌(如 JWT、OAuth Token、HMAC 签名)。

api.registerProvider({
  id: 'my-provider',
  
  resolveSyntheticAuth: async (config, modelId) => {
    if (config.authType === 'oauth') {
      const token = await oauthClient.getAccessToken(config.clientId, config.clientSecret)
      return { type: 'bearer', token }
    }
    return null  // 不处理,穿透
  }
})

Hook: resolveExternalAuthProfiles

调用时机resolveSyntheticAuth 之后

作用:从外部认证系统(AWS IAM / GCP Workload Identity / Azure Managed Identity)解析凭证 Profile。

Hook: shouldDeferSyntheticProfileAuth

调用时机:认证解析过程中

作用:决定是否将合成 Profile 的认证推迟到请求时(而非启动时)。用于短期令牌场景。


23.5 阶段4:模型准备

核心问题:这个模型是否需要特殊准备?它的能力集是什么?

Hook: resolveDynamicModel

调用时机:认证解析完成后

作用:对于需要运行时确定的"动态模型"(如根据当前负载路由到不同模型),在此 hook 中完成模型选择。

api.registerProvider({
  id: 'my-provider',
  
  resolveDynamicModel: async (modelId, config, auth) => {
    if (modelId === 'my-provider/auto') {
      // 根据当前服务器负载动态选择模型
      const load = await getServerLoad()
      return load > 0.8
        ? 'my-provider/fast-v1'
        : 'my-provider/powerful-v2'
    }
    return modelId  // 非动态模型,原样返回
  }
})

Hook: prepareDynamicModel

调用时机resolveDynamicModel 之后,仅当选择了动态模型时

作用:为动态路由的模型执行初始化操作(如预热连接池、申请会话令牌)。

Hook: normalizeResolvedModel

调用时机:动态模型解析完成后

作用:对最终确定的模型 ID 做最后一次规范化,确保其格式完全符合系统内部期望。

Hook: contributeResolvedModelCompat

调用时机normalizeResolvedModel 之后

作用:为已解析的模型注入兼容性配置(如"此模型不支持 JSON 模式,需要 prompt 层面的变通")。

api.registerProvider({
  id: 'my-provider',
  
  contributeResolvedModelCompat: (modelId) => {
    if (modelId === 'my-provider/fast-v1') {
      return {
        supportsJsonMode: false,
        supportsSystemMessage: true,
        maxToolsPerRequest: 10,
      }
    }
    return null
  }
})

Hook: capabilities

调用时机:整个模型准备阶段结束时

作用:返回最终确定的模型能力集。这是系统决定"可以用这个模型做什么"的依据。

api.registerProvider({
  id: 'my-provider',
  
  capabilities: (modelId, resolvedModel) => ({
    streaming: true,
    functionCalling: true,
    vision: modelId.includes('v2'),
    contextWindow: modelId.includes('v2') ? 200000 : 128000,
    outputFormats: ['text', 'json'],
  })
})

23.6 阶段5:推理配置

核心问题:如何准备发送给模型的请求参数?

这个阶段包含若干 schema 规范化 hook(处理工具调用 schema 的格式差异)和推理输出模式选择 hook(决定是否用结构化输出)。以下重点介绍关键 hook。

schema 规范化 hooks

不同 Provider 对工具调用 schema 格式有微妙差异。这组 hook 让 Provider Plugin 在请求发出前对 schema 做规范化:

api.registerProvider({
  id: 'my-provider',
  
  // 规范化工具定义的 schema
  normalizeToolSchema: (tool) => {
    // my-provider 不支持 additionalProperties: false
    const { additionalProperties, ...schemaWithout } = tool.inputSchema
    return { ...tool, inputSchema: schemaWithout }
  },
  
  // 规范化输出 schema(用于结构化输出模式)
  normalizeOutputSchema: (schema) => {
    // my-provider 的 JSON 模式需要 schema 顶层有 title 字段
    return { title: 'Response', ...schema }
  }
})

推理输出模式选择

api.registerProvider({
  id: 'my-provider',
  
  // 决定是否使用 JSON 结构化输出
  selectInferenceOutputMode: (request, modelCapabilities) => {
    if (request.outputSchema && modelCapabilities.supportsJsonMode) {
      return 'structured-json'
    }
    return 'text'
  }
})

23.7 阶段6:流式传输

核心问题:如何创建和包装与 Provider 之间的数据流?

这是 Plugin API 中技术含量最高的阶段,也是 Provider Plugin 区别于其他 Plugin 的关键所在。

Hook: createStreamFn

调用时机:所有配置准备完成后,开始实际 LLM 调用时

作用:创建与 Provider 通信的原始流函数。这是 Provider Plugin 必须实现的核心 hook。

选择原则:如果你在构建 Provider Plugin(即接入新 LLM),实现 createStreamFn

api.registerProvider({
  id: 'my-provider',
  
  createStreamFn: (config, auth) => {
    // 返回一个函数,该函数接受请求参数,返回 AsyncIterable<StreamChunk>
    return async function* streamFn(request: LLMRequest): AsyncIterable<StreamChunk> {
      const response = await fetch(`${config.baseUrl}/v1/chat/completions`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${auth.apiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          model: request.modelId,
          messages: request.messages,
          stream: true,
          temperature: request.temperature,
          max_tokens: request.maxOutputTokens,
        }),
      })
      
      if (!response.ok) {
        throw new ProviderError(`HTTP ${response.status}: ${await response.text()}`)
      }
      
      const reader = response.body!.getReader()
      const decoder = new TextDecoder()
      
      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        
        const text = decoder.decode(value)
        const lines = text.split('\n')
        
        for (const line of lines) {
          if (!line.startsWith('data: ')) continue
          const data = line.slice(6)
          if (data === '[DONE]') return
          
          try {
            const parsed = JSON.parse(data)
            const delta = parsed.choices?.[0]?.delta
            if (delta?.content) {
              yield { type: 'text', content: delta.content }
            }
            if (delta?.tool_calls) {
              yield { type: 'tool_call', toolCalls: delta.tool_calls }
            }
          } catch {
            // 忽略解析错误的 SSE 行
          }
        }
      }
    }
  }
})

Hook: wrapStreamFn

调用时机createStreamFn 之后,包装已有的流函数

作用:在不替换整个流函数的情况下,对流做装饰(添加监控、重试逻辑、速率限制等)。

选择原则:如果你在构建非 Provider Plugin(如监控 Plugin、计费 Plugin),实现 wrapStreamFn 而不是 createStreamFn

// 监控 Plugin:不替换流函数,只包装
api.registerProvider({
  id: 'my-monitoring-plugin',
  
  wrapStreamFn: (originalStream) => {
    return async function* wrappedStream(request: LLMRequest): AsyncIterable<StreamChunk> {
      const startTime = Date.now()
      let tokenCount = 0
      
      try {
        for await (const chunk of originalStream(request)) {
          if (chunk.type === 'text') {
            tokenCount += estimateTokens(chunk.content)
          }
          yield chunk  // 透传原始块
        }
      } finally {
        // 无论成功还是失败都记录指标
        await metrics.record({
          provider: request.providerId,
          model: request.modelId,
          durationMs: Date.now() - startTime,
          outputTokens: tokenCount,
        })
      }
    }
  }
})

createStreamFn vs wrapStreamFn 的选择

场景 使用哪个
接入新 LLM Provider createStreamFn
添加监控/遥测 wrapStreamFn
添加重试逻辑 wrapStreamFn
添加速率限制 wrapStreamFn
实现 Model Router createStreamFn(路由到其他 provider)
实现响应缓存 wrapStreamFn

Hook: resolveTransportTurnState

调用时机:流建立后

作用:解析与特定传输层绑定的"轮次状态"(Turn State)——在多轮对话中需要持久化的传输层信息(如 WebSocket 会话 ID、gRPC 流句柄)。


23.8 阶段7:运行时

核心问题:请求发出前还需要做哪些准备?

Hook: prepareRuntimeAuth

调用时机:流式传输阶段之前,最终确认认证

作用:处理需要在每次请求时刷新的认证(如过期的临时凭证)。

api.registerProvider({
  id: 'my-provider',
  
  prepareRuntimeAuth: async (existingAuth, request) => {
    // 检查 JWT 是否即将过期(5分钟内)
    if (isTokenExpiringSoon(existingAuth.token, 300)) {
      const newToken = await refreshJwt(existingAuth.refreshToken)
      return { ...existingAuth, token: newToken }
    }
    return existingAuth
  }
})

Hook: fetchUsageSnapshot

调用时机:请求前(用于配额检查)和请求后(用于计费记录)

作用:获取当前 Provider 的使用量快照,用于配额检查和用量统计。

Hook: createEmbeddingProvider

调用时机:系统需要文本嵌入能力时

作用:为 Provider 提供的嵌入模型创建嵌入函数。

api.registerProvider({
  id: 'my-provider',
  
  createEmbeddingProvider: (config, auth) => ({
    embed: async (texts: string[]): Promise<number[][]> => {
      const response = await fetch(`${config.baseUrl}/v1/embeddings`, {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${auth.apiKey}` },
        body: JSON.stringify({
          model: 'my-provider/embed-v1',
          input: texts,
        }),
      })
      const data = await response.json()
      return data.data.map((d: any) => d.embedding)
    }
  })
})

Hook: buildReplayPolicy

调用时机:错误处理阶段

作用:定义请求失败时的重放(重试)策略。

api.registerProvider({
  id: 'my-provider',
  
  buildReplayPolicy: (error, request) => {
    // 429 Too Many Requests:指数退避重试
    if (error.status === 429) {
      return {
        shouldRetry: true,
        delayMs: Math.min(1000 * Math.pow(2, request.attemptNumber), 30000),
        maxAttempts: 5,
      }
    }
    // 500 Server Error:立即重试一次
    if (error.status >= 500) {
      return { shouldRetry: true, delayMs: 100, maxAttempts: 2 }
    }
    // 其他错误:不重试
    return { shouldRetry: false }
  }
})

23.9 穿透规则详解

穿透规则(Passthrough Rule)是 Plugin 系统中最容易引起困惑的概念。本节从原理层面解释它。

穿透规则的触发场景

当系统执行 normalizeModelIdnormalizeTransportnormalizeConfig 这三个 hook 时,需要决定:调用哪个 Plugin 的实现?

Ownership 原则

每个模型 ID 只有一个"拥有者"——注册了该模型 catalog 的 Provider Plugin。

模型 ID: "my-provider/fast-v1"
                  ↑
         my-provider Plugin 拥有此模型

穿透执行顺序

对于 normalizeModelId(以此为例):

1. 系统首先调用"拥有该模型"的 Provider Plugin 的 normalizeModelId
   → 若该 Plugin 返回非 null 值:使用该值,停止
   → 若该 Plugin 返回 null:穿透

2. 依次调用其他实现了 normalizeModelId 的 hook-capable Plugin
   → 直到某个 Plugin 返回非 null,或所有 Plugin 都返回 null

3. 若所有 Plugin 都返回 null:使用原始模型 ID(无变换)

穿透规则的工程意义

穿透规则解决了多 Plugin 环境中的控制权归属问题。没有穿透规则,如果多个 Plugin 都实现了 normalizeModelId,系统不知道该用哪个的输出。

穿透规则给出清晰答案:拥有者优先,其他 Plugin 作为补充。这确保了:

  1. 每个模型的行为由拥有该模型的 Plugin 控制(符合直觉)
  2. 非 Provider Plugin(如路由 Plugin)可以在特定条件下接管(通过返回非 null)

23.10 registerHttpRoute 安全分级

api.registerHttpRoute 支持两种认证模式,适用于不同的使用场景。

auth: "plugin"

Plugin 完全自主管理认证,系统不介入验证:

api.registerHttpRoute({
  path: '/my-plugin/webhook',
  method: 'POST',
  auth: 'plugin',  // Plugin 自行验证请求
  match: 'exact',
  handler: async (req, res) => {
    // 必须在这里自行验证
    const signature = req.headers['x-webhook-signature']
    if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' })
    }
    
    // 处理 webhook
    await processWebhookEvent(req.body)
    res.json({ ok: true })
  }
})

适用场景

auth: "gateway"

系统在调用 handler 之前验证请求方持有有效的 operator.write scope:

api.registerHttpRoute({
  path: '/my-plugin/admin/reset',
  method: 'POST',
  auth: 'gateway',  // 需要 operator.write scope
  match: 'exact',
  handler: async (req, res) => {
    // 走到这里说明 gateway 已验证 operator.write scope
    // req.operator 中有经过验证的 operator 信息
    await performAdminReset(req.body)
    res.json({ ok: true })
  }
})

适用场景

match: "exact" vs "prefix"

// exact: 只匹配 /my-plugin/webhook(不匹配 /my-plugin/webhook/sub)
api.registerHttpRoute({ path: '/my-plugin/webhook', match: 'exact', ... })

// prefix: 匹配所有以 /my-plugin/ 开头的路径
api.registerHttpRoute({ path: '/my-plugin/', match: 'prefix', ... })

23.11 api.runtime helpers 使用场景

api.runtime 提供对 OpenClaw 运行时能力的受控访问,无需 Plugin 自行实现这些能力。

api.runtime.tts.*

文字转语音能力,供 Plugin 使用 OpenClaw 注册的 TTS Provider:

api.registerChannel({
  id: 'my-voice-channel',
  
  onMessage: async (message, ctx) => {
    if (message.responseFormat === 'audio') {
      const audio = await ctx.runtime.tts.synthesize({
        text: message.content,
        voice: 'default',
        format: 'mp3',
      })
      return { type: 'audio', data: audio }
    }
    return { type: 'text', content: message.content }
  }
})

api.runtime.subagent.*

在 Plugin 内部启动子 Agent 任务:

api.registerTool({
  id: 'my-tool.analyze',
  schema: { /* ... */ },
  
  handler: async (params, ctx) => {
    // 启动子 Agent 完成复杂分析任务
    const result = await ctx.runtime.subagent.run({
      task: `Analyze this data and return insights: ${JSON.stringify(params.data)}`,
      model: 'my-provider/powerful-v2',
      maxIterations: 10,
    })
    return result.output
  }
})

api.runtime.webSearch.*

使用已注册的 WebSearch Provider:

const results = await ctx.runtime.webSearch.search({
  query: 'OpenClaw Plugin API documentation',
  maxResults: 10,
})

api.runtime.imageGeneration.*

使用已注册的图像生成 Provider:

const image = await ctx.runtime.imageGeneration.generate({
  prompt: 'A detailed diagram of Plugin loading pipeline',
  size: '1024x1024',
  format: 'png',
})

23.12 Provider Plugin 与非 Provider Plugin 的差异

理解什么时候应该用 registerProvider 而不是其他 API,对正确选择 Plugin 类型至关重要。

Provider Plugin

非 Provider Plugin

// ✓ 非 Provider Plugin 的合理用法:
// 实现 wrapStreamFn 为所有请求添加遥测
export function setup(api: PluginApi) {
  api.registerProvider({
    id: 'telemetry-plugin',
    // 注意:没有 catalog,没有 createStreamFn
    // 这个 "provider" 注册只是为了实现 hook
    
    wrapStreamFn: (originalStream) => {
      return async function* (request) {
        // 添加遥测逻辑...
        yield* originalStream(request)
      }
    },
    
    buildReplayPolicy: (error, request) => {
      // 自定义重试策略...
    }
  })
  
  // 同时注册工具
  api.registerTool({
    id: 'telemetry.dashboard',
    // ...
  })
}

23.13 本章小结

45 个 Hook 看似复杂,但有内在的逻辑结构:

  1. 阶段是理解的起点——先理解每个阶段要解决什么问题,再理解具体 Hook
  2. ownership 原则——拥有模型 catalog 的 Plugin 在穿透链中优先
  3. createStreamFn vs wrapStreamFn——前者创建流(Provider Plugin),后者装饰流(非 Provider Plugin)
  4. registerHttpRoute 的两种 auth——plugin 自管理,gateway 系统托管
  5. api.runtime helpers——复用 OpenClaw 的内置能力,避免重复实现

下一章将这些 API 知识转化为实践:从零编写 Channel Plugin、Tool Plugin 和 Provider Plugin。

本章评分
4.8  / 5  (7 评分)

💬 留言讨论