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 系统中最容易引起困惑的概念。本节从原理层面解释它。
穿透规则的触发场景
当系统执行 normalizeModelId、normalizeTransport、normalizeConfig 这三个 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 作为补充。这确保了:
- 每个模型的行为由拥有该模型的 Plugin 控制(符合直觉)
- 非 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 })
}
})
适用场景:
- 接收第三方 webhook(Slack、GitHub、Stripe 等)
- 不需要 OpenClaw operator scope 验证的内部 API
- Plugin 有自己的认证方案(HMAC 签名、自定义 Token 等)
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 })
}
})
适用场景:
- 需要 operator 级别权限的管理 API
- 需要与 OpenClaw 主认证系统集成的端点
- 敏感操作(重置配置、清除缓存等)
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
- 使用
api.registerProvider({ id, label, auth, catalog, ...hooks }) - 必须实现
cataloghook(声明支持的模型) - 通常实现
createStreamFn(提供实际 LLM 调用能力) - 通过穿透规则获得对特定模型的"控制权"
- 参与 7 个阶段的全部 hook 流程
非 Provider Plugin
- 使用
registerChannel、registerTool、registerCommand、registerHttpRoute等 - 不实现
catalog和createStreamFn - 可以实现
wrapStreamFn(在不拥有模型的情况下参与流处理) - 可以实现部分 hook(如
buildReplayPolicy)来影响所有模型的行为
// ✓ 非 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 看似复杂,但有内在的逻辑结构:
- 阶段是理解的起点——先理解每个阶段要解决什么问题,再理解具体 Hook
- ownership 原则——拥有模型 catalog 的 Plugin 在穿透链中优先
- createStreamFn vs wrapStreamFn——前者创建流(Provider Plugin),后者装饰流(非 Provider Plugin)
- registerHttpRoute 的两种 auth——
plugin自管理,gateway系统托管 - api.runtime helpers——复用 OpenClaw 的内置能力,避免重复实现
下一章将这些 API 知识转化为实践:从零编写 Channel Plugin、Tool Plugin 和 Provider Plugin。