Day 12 / 共 20 天 · 第 3 周 技能与渠道

扩展系统 extensions

昨天(Day 11)讲的技能是"数据/说明书";今天讲的扩展是"代码"。extension(代码里叫 plugin)是可执行 TS 模块,能向宿主注入 provider / channel / tool / hook / 服务 / HTTP 路由。今天看它怎么被发现、加载、注册——这也为后两天(Day 13/14)讲"渠道其实就是一种扩展"打基础。

📍 你在整门课的位置(第 3 周 · 技能与渠道)
W2 大脑· Day11 技能 Day12 扩展 Day13 渠道抽象 Day14 渠道实现 Day15 归一化· W4 安全/部署
💡 用一个类比先兜住今天(延续 Day 11「便签 vs App」世界观) 接着昨天:"技能像贴在冰箱上的一张便签(数据,教你怎么做);扩展像给手机装一个 App(代码,能真调用系统能力)。"App 装进去要通过系统的注册接口register(api))声明"我要用相机、通讯录"(注入 provider/channel/tool/hook)。而应用商店先审清单、后运行:先读 App 的说明书(openclaw.plugin.json)决定能不能装,确认没问题才真正运行它的代码。抓住"装 App + 先审后跑",今天就通了。
L01

extension = plugin

🤔 痛点:想让助理支持一个全新聊天平台、或接一个新的模型来源,光写 markdown 说明书够吗? 昨天的技能只能"教模型用已有工具"——但它变不出新工具。要让助理"多一个 Telegram 渠道""多一个 ollama 本地模型来源""多一个能真执行的新工具",就得能跑代码、往系统底层插东西。markdown 说明书做不到这一层。
💡 本质:扩展 = 可执行 TS 模块,通过 register(api) 往宿主"插能力插槽" 扩展像给手机装 App:装进去后能调用系统能力。它导出一个 register(api),在里面调 api.registerProvider/registerChannel/registerTool/on(...) 把新能力插进 OpenClaw。技能扩"知识玩法",扩展扩"底层能力"——这是两者的根本分工。

先厘清术语:目录叫 extensions/,但类型/清单/代码里都叫 pluginOpenClawPlugin*openclaw.plugin.jsonsrc/plugins/)。本课"扩展"和"插件"是同一个东西。

读法:真正的插件 API 是 OpenClawPluginApisrc/plugins/types.ts:366-409);面向插件作者的 SDK 入口是 src/plugin-sdk/(扩展 import "openclaw/plugin-sdk/<子路径>")。src/extensionAPI.ts 只是 15 行的再导出壳,不是真 API。)
L02

扩展 vs 技能

技能 skill(Day 11)扩展 extension(今天)
形态SKILL.md(markdown 数据)TS 模块(可执行代码)
怎么生效提示词 + 模型读文件照做被 import 并调 register(api) 注入能力
能干什么教模型用已有工具完成任务注入新 provider/channel/tool/hook/服务
门槛写 markdown,零代码写 TS 代码
读法:技能扩"知识/玩法",扩展扩"底层能力"。想让助理"会查天气"→ 写技能;想让助理"支持一个新聊天平台/新模型来源"→ 写扩展。两者可连接:扩展清单可带 skills?: string[] 字段附带技能目录。
L03

扩展的结构

每个 extensions/<名>/ 含三样:

  • openclaw.plugin.json:清单(必需),含 id + configSchema
  • package.json:含 openclaw.extensions: ["./index.ts"] 声明入口。
  • index.ts:默认导出注册逻辑。

真实例子 extensions/ollama/(provider 型):

// openclaw.plugin.json
{ "id": "ollama", "providers": ["ollama"], "configSchema": { ... } }
读法:清单声明"我是谁、我提供什么(providers/channels)、我的配置长啥样"。configSchema 用于校验 + 生成配置向导。清单只被"读"(不执行代码),加载时先扫清单再决定要不要真正 import(安全,见 L07)。
L04

register(api)

入口 index.tsregister(api) 是扩展往宿主"插能力"的地方。四类真实例子:

// Provider 型(ollama):注入新模型来源
register(api) { api.registerProvider({ id, label, auth, discovery, wizard, onModelSelected }); }

// Channel 型(telegram):注入新聊天渠道
register(api) { api.registerChannel({ plugin: telegramPlugin }); }

// Tool 型(llm-task):注入新工具
export default function register(api) { api.registerTool(createLlmTaskTool(api), { optional: true }); }

// Memory 型(memory-lancedb):注入工具 + 钩子 + 服务
register(api) {
  api.registerTool({ name: "memory_recall", parameters, execute });
  api.on("before_agent_start", autoRecall);
  api.on("agent_end", autoCapture);
  api.registerService(...);
}
读法:api.registerX(...) 系列是扩展的"能力插槽":Provider/Channel/Tool/Hook/Service/HttpRoute/Cli/Command/ContextEngine。register 必须同步执行(加载器要求)。createApisrc/plugins/registry.ts:186,各 registerX 把东西推进 PluginRegistry
L05

加载流水线

loadOpenClawPlugins()loader.ts:517-897)五步:

  1. 发现 discoverOpenClawPlugins:扫 4 类 root(bundled extensions/、global <configDir>/extensions、workspace <ws>/.openclaw/extensions、config loadPaths)。
  2. 清单注册:只读 openclaw.plugin.json(不执行),按来源优先级 config > workspace > global > bundled(用户可覆盖内建)。
  3. 逐个加载:去重、解析 enable、memory 槽位互斥(只允许一个 memory 扩展生效)、要求 configSchema。
  4. import + register:用 jitiloader.ts:753)import TS 入口,校验配置,createApi() 后调 register(api)
  5. 激活 activatePluginRegistryinitializeGlobalHookRunner 让钩子生效。
为什么"先读清单、后执行代码"? 执行第三方代码有风险。OpenClaw 先只读所有扩展的清单(纯数据、安全),据此决定哪些启用、有没有冲突、memory 槽位归谁;确定后才真正 import 那些要启用的、执行其 register。这样能在"跑代码"前做完所有决策和安全检查。jiti 是个能直接 import TS 的运行时(免预编译),方便本地扩展开发。
加载流水线:安全的部分先做,危险的(跑代码)最后做 ①发现扫 4 类 root ②读清单只读 json·不执行按优先级去重 ③逐个加载解析 enablememory 槽互斥 ④import+register⚠ 首次跑第三方代码jiti · createApi() ⑤激活钩子生效 纯数据·安全区(决策+冲突检查都在这做完) 才进入"执行代码"区
图注:①②③是纯数据、安全的;只有确定要启用后,④才第一次真正执行扩展代码——"先审后跑"。
📝 举个例子:ollama 扩展被加载后发生了什么 发现 extensions/ollama/ → 读它的 openclaw.plugin.json{"id":"ollama","providers":["ollama"]})→ 决定启用、无冲突 → jiti import index.ts → 调 register(api),其中 api.registerProvider({id:"ollama",...})结果:助理的"可选模型来源"里多了一个本地 ollama,你在配置里就能选它。全程只有最后一步才真跑了 ollama 扩展的代码。
L06

生命周期钩子

扩展可用 api.on(hookName, handler, {priority}) 挂钩到 Agent 生命周期各点(PluginHookNametypes.ts:424-448):

before_model_resolve   // 换模型(Day 06)
before_prompt_build    // 注入系统提示(Day 09,受策略门控)
before_agent_start / agent_end       // 运行前后(memory 扩展的自动召回/捕获)
llm_input / llm_output               // 模型输入输出
before_tool_call / after_tool_call   // 工具调用前后(before 可 {block:true} 否决!)
message_received / message_sending / message_sent
session_start / session_end / gateway_start / gateway_stop
before_compaction / after_compaction / subagent_*
读法:钩子让扩展"插进 Agent 的关键时刻"做事:memory 扩展在 before_agent_start 自动检索相关记忆、agent_end 自动保存新记忆;安全扩展在 before_tool_call 拦截危险调用。这是典型的"事件钩子/中间件"架构,和你学过的 crewAI events、OpenHands 事件流同源。

👶 小白:before_tool_call 那个 {block:true} 有啥特别?听起来就是普通回调。

👨‍🏫 老师:它不只是"旁听",而是能否决。举个出事场景:模型被一段恶意网页内容诱导,准备调 exec("rm -rf ~")。如果没有拦截点,工具就真跑了。有了 before_tool_call 钩子,安全扩展能在执行之前检查这次调用,返回 {block:true} 把它挡下来。大多数钩子是"通知型"(发生了就告诉你),而 before_* 系列是"守门型"(能改甚至能拦)——这正是把安全策略做成可插拔扩展的关键(Day 17 详讲)。

L07

安全门控

扩展是"可信计算基"(装了就等于本地代码同权限,Day 17 会展开),但加载器仍有多道门控:

  • 路径安全:isUnsafePluginCandidate 拒绝全局可写/越界目录(discovery.ts:250-272)。
  • 注册冲突:拒绝重复 provider id、重叠 HTTP 路由。
  • 提示注入门控:before_prompt_build 注入受 allowPromptInjection 策略约束(registry.ts:520-567)。
  • memory 槽位互斥:多个 memory 扩展只放行一个。
读法:虽然信任模型认为"你自己装的扩展是可信的",加载器还是做了防呆和冲突检测——避免误装到危险目录、避免两个扩展抢同一资源。这是"信任但验证"的工程姿态。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • extension 和 plugin 什么关系?
  • 扩展和技能的四点区别?
  • 扩展的三个文件 + register(api) 干什么?
  • 加载流水线为什么"先读清单后执行代码"?
  • 生命周期钩子举 3 个用途?before_tool_call 能做什么特别的事?

✋ 动手

cd /Users/bitmart/work/codes/github/openclaw
ls extensions/ | head -40
cat extensions/ollama/openclaw.plugin.json
grep -n 'register(api)\|registerProvider\|registerChannel' extensions/ollama/index.ts extensions/telegram/index.ts
grep -n 'PluginHookName' src/plugins/types.ts
明天预告 · Day 13渠道抽象接口——所有聊天平台怎么统一?ChannelPlugin 用"组合式 adapter"(不是基类继承)定义收/发/连接/归一化契约。
← Day 11 技能 Day 13 · 渠道抽象接口 →