设计一个生产级插件系统,必须先回答五个核心问题:插件如何被发现和加载、能力契约由谁定义、插件间如何隔离、生命周期钩子怎么编排、安全边界画在哪里。OpenClaw 的解法是把插件 API 集中到一个类型化的 OpenClawPluginApi 接口上,核心拥有能力契约,插件拥有厂商实现,注册时强制校验,运行时不做沙箱。这套设计让第三方可以注册新渠道、工具、Hook 而不碰核心代码。
一、设计插件系统必须回答的五个问题
插件系统的本质是”在不修改核心代码的前提下扩展行为”。要落地这套能力,五个关键问题绕不开:
| 关键问题 | 核心矛盾 | OpenClaw 的解法 |
|---|---|---|
| 插件发现与加载 | 如何安全地找到并加载第三方代码 | 清单优先,manifest 声明能力,运行时按需加载 |
| 能力契约归属 | 契约放在核心还是插件 | 核心定义契约,插件注册实现 |
| 插件隔离 | 插件崩溃是否拖垮核心 | 原生插件进程内运行,不做沙箱 |
| 生命周期钩子 | 插件何时介入处理流程 | 24 个命名钩子覆盖全链路 |
| 安全边界 | 信任哪些插件、暴露哪些接口 | 清单声明信任面,路径安全门禁 |
二、OpenClaw 插件 API 的整体结构
OpenClaw 的插件 API 表面刻意集中在一个类型化接口 OpenClawPluginApi 上。这个契约定义了所有支持的注册点和运行时辅助函数,插件只能通过这个对象告诉核心自己能做什么,不能直接操作核心内部状态(来源:docs.openclaw.ai 插件架构文档)。
2.1 插件定义入口
每个插件通过 definePluginEntry 声明自己的身份和能力:
import { Type } from "typebox";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export default definePluginEntry({
id: "my-plugin",
name: "My Plugin",
description: "Adds a custom tool to OpenClaw",
register(api) {
api.registerTool({
name: "my_tool",
description: "Echo one input value",
parameters: Type.Object({ input: Type.String() }),
async execute(_id, params) {
return { content: [{ type: "text", text: `Got: ${params.input}` }] };
},
});
},
});
register(api) 是插件的唯一入口点。核心在加载插件时调用这个函数,注入 OpenClawPluginApi 对象,插件通过它声明能力(来源:docs.openclaw.ai 插件开发文档)。
2.2 OpenClawPluginApi 的注册能力
OpenClawPluginApi 暴露的注册方法覆盖了 Agent 系统的各个扩展面:
| 注册方法 | 扩展能力 | 典型使用者 |
|---|---|---|
registerTool |
注册 Agent 可调用的函数 | memory-core 注册记忆搜索工具 |
registerChannel |
注册消息平台通道 | Zalo、微信等第三方渠道插件 |
registerProvider |
注册 LLM 提供商 | Anthropic、OpenAI、Ollama 适配 |
registerHook / on |
注册生命周期钩子 | 消息过滤、内容审查 |
registerHttpHandler |
注册 HTTP 端点 | Web UI 调用的接口 |
registerService |
注册后台服务 | OpenTelemetry 遥测 |
registerCli |
注册 CLI 子命令 | openclaw memory 命令 |
registerCommand |
注册绕过 LLM 的斜杠命令 | /tts 语音合成 |
registerGatewayMethod |
注册 Gateway 控制面方法 | 自定义 Gateway 操作 |
2.3 能力契约的归属模型
OpenClaw 的设计原则是”核心拥有契约,插件拥有实现”。以媒体理解能力为例,这个模型分三层运作:
- 核心定义媒体理解契约,声明
describeImage、transcribeAudio、describeVideo三个接口 - 厂商插件按需注册实现,只实现自己支持的能力
- 渠道和功能插件通过
api.runtime.*辅助函数消费核心行为,不直接连接厂商代码
api.registerMediaUnderstandingProvider({
id: "exampleai",
capabilities: ["image", "audio", "video"],
describeImage: (req) => exampleAiMedia.describeImage(req),
transcribeAudio: (req) => exampleAiMedia.transcribeAudio(req),
describeVideo: (req) => exampleAiMedia.describeVideo(req),
});
这种分层避免了”把单个厂商对视频的假设固化到核心中”。插件拥有厂商接口,核心拥有能力契约和后备行为(来源:docs.openclaw.ai 插件架构文档)。
三、清单优先的加载机制
OpenClaw 采用清单优先(manifest-first)策略。清单文件 openclaw.plugin.json 是控制面的唯一真实来源,运行时模块是数据面。
3.1 清单文件
{
"id": "my-plugin",
"name": "My Plugin",
"description": "Adds a custom tool to OpenClaw",
"contracts": {
"tools": ["my_tool"]
},
"activation": {
"onStartup": true
},
"configSchema": {
"type": "object",
"additionalProperties": false
}
}
清单的核心作用:
| 清单字段 | 作用 |
|---|---|
contracts.tools |
声明插件拥有的工具,核心可不经加载运行时即发现归属 |
activation.onStartup |
是否在 Gateway 启动时加载 |
configSchema |
JSON Schema 定义配置项,加载前用 AJV 校验 |
contracts.agentToolResultMiddleware |
声明可信工具结果中间件 |
contracts.trustedToolPolicies |
声明可信工具策略 ID |
3.2 加载管线流程
OpenClaw 启动时的插件加载流程如下:
- 发掘候选插件根目录,读取原生或兼容包的清单和元数据
- 安全门禁检查:入口路径是否逃逸插件根目录、目录是否全局可写、非内置插件路径所有权是否匹配当前用户
- 规范化插件配置,按
plugins.enabled、allow、deny、entries决定启用状态 - 加载启用的原生模块:内置模块用原生加载器,第三方 TypeScript 源码用 Jiti 回退加载
- 调用
register(api)钩子,收集注册信息到插件注册表 - 暴露注册表给命令和运行时面板
安全门禁在运行时执行前运行。世界可写的非内置目录会被拦截;内置目录若权限为 0777,先尝试就地修复权限再重新检查(来源:docs.openclaw.ai 插件架构内部文档)。
四、通道插件的分离设计
接入新的消息平台时,OpenClaw 把通道插件拆成两个对象:ChannelDock(能力声明)和 ChannelPlugin(生命周期实现)。
4.1 ChannelDock 与 ChannelPlugin 的分工
| 对象 | 职责 | 特点 |
|---|---|---|
| ChannelDock | 声明平台能力、路由决策、白名单解析 | 快、纯、无副作用 |
| ChannelPlugin | 实现连接、断开、消息收发逻辑 | 有状态、涉及网络 |
// ChannelDock:静态能力声明
export const zaloDock: ChannelDock = {
id: "zalo",
capabilities: {
chatTypes: ["direct", "group"],
media: true,
blockStreaming: true, // Zalo 不支持流式输出
},
outbound: { textChunkLimit: 2000 },
config: {
resolveAllowFrom: /* 从配置解析白名单 */,
formatAllowFrom: /* 格式化展示白名单 */,
},
groups: { resolveRequireMention: () => true },
threading: { resolveReplyToMode: () => "off" },
};
// ChannelPlugin:有状态的生命周期实现
interface ChannelPlugin {
dock: ChannelDock;
config?: ConfigAdapter; // 配置验证与实例化
security?: SecurityAdapter; // 签名验证、防重放
outbound?: OutboundAdapter; // 发送消息、上传媒体
pairing?: PairingAdapter; // 设备配对流程
groups?: GroupsAdapter; // 群组成员查询
gateway?: GatewayAdapter; // Gateway 启动钩子
agentTools?: AgentToolsAdapter; // 注入 Agent 专用工具
}
分离的原因在于生命周期不同。ChannelDock 的判断在每条消息到达时都需要执行,必须快、纯、无副作用。ChannelPlugin 的逻辑涉及网络连接和有状态的 Adapter 实现,只在 Gateway 启动和关闭时才需要(来源:blog.csdn.net 插件 SDK 解析)。
4.2 稳定的 SDK 导入路径
第三方插件直接从内部路径导入类型会导致每次重构都破坏所有扩展。OpenClaw 用 openclaw/plugin-sdk 作为公开 API 面:
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
运行时由 Jiti(TypeScript 运行时加载器)将这个路径 alias 重定向到核心的 src/plugin-sdk/index.ts(开发环境)或 dist/plugin-sdk/index.js(生产环境)。这个 alias 机制解决了三个问题:扩展可以用 .ts 源码发布,Jiti 负责实时转译;扩展可以发布编译后的 .js,正常加载;openclaw/plugin-sdk 总是解析到正在运行的那份核心代码,不会出现版本不匹配(来源:blog.csdn.net 插件 SDK 解析)。
五、生命周期钩子的编排
OpenClaw 为插件设计了 24 个命名钩子,覆盖从 Gateway 启动到消息处理再到 Agent 执行的完整链路:
| 钩子层 | 代表性钩子 | 可执行操作 |
|---|---|---|
| Gateway 层 | gateway_start、gateway_stop |
初始化/清理资源 |
| 消息层 | message_received、message_sending、message_sent |
拦截入站消息、修改出站内容 |
| Agent 层 | before_model_resolve、before_prompt_build、before_agent_start |
替换模型、注入系统提示、中止执行 |
| LLM 层 | llm_input、llm_output |
观察完整输入输出 |
message_received 在入站消息到达路由层时触发,可以拦截消息。message_sending 在回复即将发送时触发,可以修改回复内容——比如把敏感词替换掉再发出去。
六、契约强制执行的两层保障
6.1 运行时注册强制
插件加载时,插件注册表会验证注册信息。重复的提供商 ID、重复的语音提供商 ID、格式错误的注册,都会产生插件诊断信息而非导致未定义行为。核心拒绝重复所有权——两个插件注册同一个提供商 ID 会直接报错(来源:docs.openclaw.ai 插件架构文档)。
6.2 契约测试
测试执行期间,内置插件被捕获到契约注册表中,OpenClaw 能显式断言所有权。当前这用于模型提供商、语音提供商、网页搜索提供商和内置注册的所有权。契约测试防止”无声偏移”——内置插件悄悄丢失了某个注册点而没人发现。
6.3 导出边界
OpenClaw 导出的是能力而非实现便利。公开的只有能力注册接口,非契约辅助导出会被裁剪:内置插件特定的辅助子路径、运行时管道子路径、厂商特定的便利辅助、设置和引导辅助都属于内部实现细节,不暴露给第三方(来源:docs.openclaw.ai 插件架构文档)。
常见问题(FAQ)
Q1:第三方插件和内置插件的信任级别一样吗? 不一样。原生插件进程内运行无沙箱,第三方需走 allowlist 和安全门禁。
Q2:插件注册时 ID 重复会怎样? 核心直接拒绝并产生诊断信息,不会导致未定义行为。
Q3:ChannelDock 和 ChannelPlugin 为什么要分开? 前者无副作用、每次消息到达都执行,后者有状态、仅在 Gateway 启停时运行。