# magpie 插件机制深度调研 > 调研对象:[yetone/magpie](https://github.com/yetone/magpie)(本地 LLM 网关,Go,~855K 行)。 > 本文聚焦其插件机制本身,目标是把可移植的设计提炼出来供其他项目参考。所有行号基于 2026-10-30 的 main 分支。 ## 0. 总览:三层并存的「插件」 magpie 里「插件」一词覆盖三个完全不同的机制,看懂分层是看懂代码的前提: | 层 | 位置 | 形态 | 运行环境 | 典型用途 | | --- | --- | --- | --- | --- | | 内置插件(built-in) | `plugins/` 包 + `internal/pluginrt` | 编译进二进制的 Go 包,用户可开关 | 网关主进程 | routing、usage、redact、sessions 等功能开关 | | JS 网关中间件 | `internal/middleware` | 用户安装的 JS 模块 | 进程内 moejs 引擎(Go 写的 JS 引擎) | 改写请求/响应、路由策略、用量观察 | | Provider 插件 | `internal/plugin`(`host.js`) | OpenCode v1 兼容的 npm/git/本地包 | Bun 子进程,stdin/stdout JSON-RPC | 供应商接入:登录、模型列表、代发请求 | 三层的调用频率完全不同,这决定了它们的技术选型(见 §4.1)。 --- ## 1. 内置插件系统(pluginrt) ### 1.1 设计动机 包注释(`internal/pluginrt/pluginrt.go:1-10`)说得直白:magpie 的功能「不是所有人都需要」(Lxxyx on X: "功能太多了"),所以把非核心功能拆成可开关的包。关键约束写在注释里并**由测试强制**: - 运行时是 core,**永不 import 任何插件**; - 插件之间**互相不 import**,只能通过彼此 Provide 的服务通信。 这两条不是 review 纪律,而是 `TestPluginsImportOnlyCore`(`internal/pluginrt/pluginrt_test.go:94`):用 AST 扫描全部 `.go` 文件的 import,违反即编译期红。架构边界测试化,比文档和口头约定可靠得多。 ### 1.2 Manifest:插件的全部声明 `plugins/` 包在 `init()` 里调 `pluginrt.Register(Manifest)`(`pluginrt.go:128`,重复 ID 直接 panic)。Manifest 字段(`pluginrt.go:35-72`): ```go type Manifest struct { ID string API string // 宿主 API 版本区间,caret 语义 "^1" Requires, Provides []string // 服务名:插件间唯一的依赖/通信表达 Essential bool // essentials 预设是否保留 Name, Sub string // 展示名(窗口负责翻译) One, Many string // "1 个分组在用" / "{n} 个分组在用" InUse func() (int, error) // 运行时清点"有多少东西在用它" Apply func(*Ctx) error // 注册钩子;nil 表示纯 UI 插件 Parts []Part // 前端 UI 插槽:nav.page / settings.section / part Defaults map[string]any Settings any // 设置结构体 → 反射生成 JSON Schema → 合并进 Defaults Client []string // 窗口侧资源(脚本/i18n/css) } ``` 三个值得单独说的设计: **InUse 运行时清点**(`pluginrt.go:48-51`)。插件能否被关掉不靠用户记忆,而是运行时数「还有多少用户创建的东西依赖它」(分组、预热任务……)。InUse > 0 时无视 plugins.yml 强制开启;**读取出错也算在用**——"unknown is never unused",宁可多开不少开,避免用户的东西失去宿主却还在运行。 **Settings → JSON Schema**(`pluginrt.go:137-150` + `schema.go`)。Register 时反射设置结构体生成 schema 并把零值合并进 Defaults;窗口用 schema 画表单,`SetConfig` 只接受 schema 允许的字段——用户手改的非法值被拒收但保留在文件里,不会静默丢失。 **API 版本 caret 区间**(`pluginrt.go:391-402`)。`Compatible("^1.2")` = 主版本相同且 minor ≤ 宿主。不匹配的插件不 apply,状态记为 Failed 并说明原因。 ### 1.3 生命周期:Reconcile 不动点状态机 唯一的入口是 `Runtime.Reconcile(on, config)`(`pluginrt.go:211-278`),每次配置变化都整体收敛一遍: 1. **先关**:迭代求出「不再 wanted」∪「Requires 的服务被某个要关插件 Provides」的闭包(`needsGoing`,:281-292),然后**按 apply 的逆序** dispose——保证服务在它的使用者之后才被撤回。每个 dispose 闭包单独 recover(`undo`,:342-354),一个插件清理时 panic 不影响其他插件。 2. **后开**:循环「能应用就应用」直到不动点——Requires 全部满足才 apply(core 提供 `settings/providers/gateway/window` 四个基础服务,:31),不满足则挂 Pending 并记录在等谁。这个不动点循环天然实现了**按服务依赖图的拓扑序 apply、逆拓扑序 dispose**,不需要声明式 priority。 3. 状态集:`off / pending / active / failed / disposed`(:83-89),GUI 可见每个插件为什么没起来(`Why` 字段)。 Apply 的 panic 被 recover 成该插件 FAILED(:308-330),**它注册了一半的东西也全部 undo**——不存在「半应用」状态。 ### 1.4 Ctx:注册即登记撤销 `Apply` 拿到的 `Ctx`(`pluginrt.go:93-119`)只有四个能力,全部自带撤销: - `OnDispose(f)`:登记清理函数,关插件时逆序执行; - `Contribute(slot, key)`:往窗口放一块 UI; - `Provide(name, v)`:提供 named service,撤销时从 services map 删除; - `Service(name)`:取别的插件 Provide 的服务。 配套的核心模式是「**注册函数返回 remove 闭包**」:每个扩展点的 `Use/Serve/Register` 都返回 `remove func()`,插件把它交给 `Ctx.OnDispose`,一行完成即插即拔。remove 闭包统一用 `sync.Once` 包裹防重复(`gwmw.go:141-154` 的泛型 `add[T]` 是模板实现)。 ### 1.5 配置系统 **plugins.yml 三层合并**(`pluginrt/config.go:126-175` `Compose`):内置 Defaults → preset(essentials/all)→ 用户行。每字段记录来源 `Source`(default/preset/user/in use),`magpie plugin dump` 能解释「这个值从哪来」——排查配置不生效的利器。 **热更新三件套**(`config.go:363-395` + `gateway.go:649-650`): 1. `filememo`:按 (mtime, size, 原始字节) 记忆解析结果,本进程写文件后经 channel 立即失效; 2. fsnotify 看其他进程的写入; 3. 轮询兜底; 4. 再加一条「**请求路径按需拉起**」:请求到达时发现「需要 masker/limiter/中间件但没有」,节流(≤1 次/秒)触发 `pluginrt.Refresh` 立刻重新收敛(`gateway.go:631-644`)。效果是配置改了不用重启,稳态开销近乎为零。 **settings 读取的健壮性**(`internal/settings/settings.go` + `internal/filememo`):解析结果按原始字节缓存 + `Shared()` 免拷贝只读实例;损坏文件回退 lastgood(最后一次完好的代);「读不了」与「没有文件」语义分开(`LoadChecked`);写入走全量校验 + temp+rename 原子替换 + `.bak`。 --- ## 2. 网关扩展点(core 侧) core 为插件预留了五个「点」,每个是一个独立小包,统一模式:**接口 + 注册返回 remove 闭包 + 堆叠时后注册者生效 + 显式的失败策略**。 ### 2.1 中间件点(gwmw)——同一包内两种失败策略 `internal/gwmw/gwmw.go` 包注释(1-16 行)开门见山:这个包有两个位置,**各有各的失败策略**: - **Agent 中间件**(fail-open):挂在客户端请求进网关路由之前。接口(:45-59): ```go type Agent interface { Begin(Info) Run } type Run interface { Request(body []byte) ([]byte, *Reject) // 改写或拒绝 Wrap(w http.ResponseWriter) (http.ResponseWriter, func()) // 包响应写手 End() } ``` 中间件挂了/超时了 → 请求原样放行。可选接口 `Tried(body, To) []byte`(:72-74)让网关**每次换上游重试前重新变形 body**,每次 try 从 onRequest 后的 body 出发、互不污染。 - **Masker**(fail-closed):挂在发往供应商之前。`Masking(wanted)` 在「设置要求遮罩但没有 masker 在服务」时返回 `ErrUnmasked` 拒发(:269-277),错误信息直接告诉用户去哪里打开插件——**错误写到用户能行动的层面**。`Safely`(:281-288)把 masker 的 panic 也转成错误,绝不把未遮罩的请求发出去。 另有一个精巧的「拉起」机制:`Served()` 发现没有 masker 时节流地回调 refresh(最多 1 秒一次、不重入,`throttle` :194-212),于是「设置打开了遮罩」会在下一个请求时自动把 redact 插件拉起来。 ### 2.2 路由策略点(routes)——只排序,不决策 `internal/routes/routes.go`:`Strategy` 只做一件事——给出候选池的**排列**(`Order func(pool string, seats []Seat) []int`)。resting、亲和、故障转移全部留在网关核心,策略碰不到。`Apply`(routes.go 尾部)对策略输出做校验:panic、超时、或者不是合法排列,就 fail-open 回退到列表顺序并打日志。`Seat`(:29-72)携带策略可能需要的全部事实:使用率、各窗口重置时间、衰减 token 数、权重、是否在冷却、实测 TTFT/TPS、钱包余额……**策略是无状态函数,事实由核心喂给它**。 ### 2.3 预算点(gwbudget)——fail-closed 的限流 `Limiter.Reserve(who, body, model, now) (release, Refusal)`(`gwbudget.go:33-35`)。key 配了 limit 但没有 limiter 在服务时返回 `ErrUnlimited` 拒绝(:106-124)——计费限额和遮罩一样 fail-closed:**该拦的没拦住,比误拦严重**。 ### 2.4 观察点(usage.Observer)——按需声明开销 `internal/usage/observe.go:19-31`: ```go type Observer interface { Serve() (stop func()) // 常驻 goroutine 的生命周期 Bodies() Bodies // None / 256KiB / Whole Begin(Request) Watch // Attempt(每此尝试) + End(整请求) Ledger(Record) // 账本每一行 } ``` 精髓在 `Bodies()`:**观察者自己声明要多少数据**,网关据此决定要不要为它缓冲完整流(`ObservedBodies` :146-152 取最大值);没有观察者注册时,网关连整包缓冲都不做,「不做任何观察工作,连回复的 body 都不留」——零观察者零开销。 ### 2.5 工具点(gwtool) 极简的按名开关:`Gate()` 网关启动时全局关闸、`Serve(name)`/`Served(name)` 按名放行(`gwtool.go:34-72`)。search 插件供 `web_search`、tools 插件供 `plugin_tools`,就这么点东西——**能用一个 gate 表达的就不用接口**。 --- ## 3. JS 中间件引擎(moejs,进程内) ### 3.1 为什么不用 Bun:按调用频率选引擎 包注释(`internal/middleware/middleware.go:1-12`)给了决策依据:一个流式回复有数千个事件,**进程内 moejs 跑一个 hook 约 1µs**,Bun 子进程一个来回要数十 µs——每事件都走子进程是不可接受的。于是: - **高频钩子**(每请求、每 SSE 事件)→ 进程内 moejs; - **低频异步钩子**(可 await I/O、观察完整往返)→ Bun 子进程(见 §4)。 这是「插件热路径留在进程内、重 I/O 放子进程」的教科书分层。 ### 3.2 运行时管理 - 每个中间件**编译一次**成模块;每个请求从 `sync.Pool` 取独立 runtime(`middleware.go:44-71`),请求的多个 hook 共享 `ctx.state`; - 超时硬限:请求级 hook 250ms、事件级 50ms、加载 1s(:35-39),超时用 `runtime.Interrupt` 打断,输入保持原样; - 热加载用 stamp(plugins.json mtime + 各中间件文件 stat),**每请求最多重查一次/秒**(`current()` :95-116),热路径几乎零开销。 ### 3.3 四个 hook 与两个扩展导出 | Hook | 签名 | 语义 | | --- | --- | --- | | `onRequest` | `(body, ctx) → body \| undefined` | 改写入站请求;`ctx.reject()` 可拒;undefined 原样放行 | | `onProvider` | `(body, ctx) → body` | 每次换上游重试前调用,`ctx.provider` 告诉你是谁;返回 null 报错(已路由不能丢) | | `onEvent` | `(data, ctx)` | SSE 逐事件;`export const events` 可白名单事件名 | | `onResponse` | `(body, ctx)` | 整段 JSON 响应(缓冲后调用) | 两个额外导出(`points.go:1-10`,都跑在模块自己的单线程 runtime 里,互相可见): - **`strategies`**:导出路由策略函数 `(pool, seats, options) → 顺序`,注册成 routes.Strategy。在请求路径上跑,20ms 超时、必须返回座位下标的排列,否则 fail-open 回退原顺序; - **`onUsage`**:账本行观察者,**跑在离线队列**——容量 1024,满了丢弃并计数,绝不阻塞请求路径(`Ledger` 的 select/default,points.go:376-398);row 是白名单化的 `usageRow`,**没有 body、prompt、错误文本、会话 id**——第三方代码拿不到敏感数据。 防第三方代码拖垮网关的完整清单:超时 Interrupt、日志限速 60 行/分钟(`middleware.go:383-404`)、onUsage 满队丢弃并计数、钩子 context 白名单化(无鉴权头)、策略必须返回合法排列。 --- ## 4. Provider 插件宿主(Bun 子进程) ### 4.1 线协议(`internal/plugin/host.js:1-20`) stdin/stdout,一行一个 JSON: ``` magpie → host: {id, method, params} host 应答 {id, result} 或 {id, error} ``` fetch 类请求的流式应答分三段:`{id, event:"head", status, headers}` → 多个 `{id, event:"chunk", data(base64)}` → `{id, result:null}`。`{method:"abort"}` 取消单个请求。host 主动上报:`log`/`toast`(插件想展示的)、`auth`(插件保存或刷新了登录态)。 两个值得抄的细节: - **credit 窗口背压**(`host.js:22-38` 一带):每个 fetch 有字节窗口,读端消费后用 `{method:"credit", id, n}` 归还额度——**慢读端会暂停上游,而不是让宿主无界缓冲**。子进程 IPC 背压的标准解法。 - **多账号槽位**:第一个账号存 provider id 下,其余存 `id#`;请求内的一切(getAuth、auth.set、loader)通过 `AsyncLocalStorage` 只见自己的账号作用域。 ### 4.2 入口声明与异步请求钩子 Provider 插件走 OpenCode v1 约定;网关异步钩子另有两个入口(`internal/plugin/gateway.go:15-45`):package.json 的 `magpie.hooks` 字段,或 `*.hooks.{js,mjs,ts,mts}` 文件(Bun 直接跑 TS)。与中间件的 `*.gateway.js` **入口名刻意不重叠**,一个文件永远不会被当两种东西加载。 钩子协议是 begin/finish/close 三段(`gateway.go:174-260`):Begin 返回改写后的 body 和 Run 句柄,Finish 异步通知回复,Close 释放;总预算 11 秒;**传给插件的 context 刻意剥离鉴权头**(:82-92)。 ### 4.3 供应链 Bun 本体按平台下载,SHA-256 钉死在代码里(对照 Bun 官方 SHASUMS256.txt 校验);npm 没有的包从社区仓库 `dist` 分支拉,sha512 与代码同源——信任模型是「谁有权限推 dist 分支」而非哈希本身,这个判断写在 LESSONS.md 里。插件更新有 `min` 版本机制,内置代码里记录着「要求插件 ≥ 某版」。 --- ## 5. 设计原则提炼(可移植清单) 1. **架构边界测试化**。core 不依赖插件、插件互不依赖,写成 AST 扫描测试。一条测试顶一百条 review 约定。 2. **注册表 + 服务依赖 + 逆序 dispose**。依赖用服务名表达(`Requires/Provides`),Reconcile 不动点自动拓扑排序,不需要 priority 字段;关停永远逆序,清理函数各自 recover。 3. **remove 闭包 + sync.Once** 是唯一的注册/注销协议。所有扩展点长一个样,学一次就会用。 4. **每个扩展点显式声明 fail-open 还是 fail-closed**,并把错误信息写到用户能行动的层面。判断标准:功能性增强(改写、路由、观察)fail-open;安全/计费/隐私(遮罩、限额)fail-closed。 5. **按调用频率选执行引擎**。热路径(每事件 µs 级)留进程内,重 I/O 放子进程,两者入口命名隔离。 6. **给第三方代码上全套缰绳**:超时 Interrupt、日志限速、队列满丢弃计数、context 白名单、输出结构校验。宁可功能降级,不让插件拖垮宿主。 7. **观察者按需声明数据需求**(Bodies 分级),零注册零开销。 8. **InUse 运行时清点**决定插件可否关闭,错误也算在用。 9. **配置四层合并 + 来源追踪 + lastgood 兜底 + temp+rename 原子写**;热更新 = 文件记忆缓存 + fsnotify + 轮询兜底 + 请求路径按需拉起。 10. **策略无状态化**:策略只给排列,事实(冷却、用量、延迟)由核心以 `Seat` 喂入,输出非法就 fail-open。 ## 6. 最小移植骨架 如果要在别的 Go 项目里复刻这套机制,最小核心约 600 行: ```go // 1. 运行时(~200 行) type Manifest struct { ID string Requires, Provides []string Apply func(*Ctx) error } type Ctx struct { /* id + disposes + services */ } func (c *Ctx) OnDispose(f func()) { c.disposes = append(c.disposes, f) } func (rt *Runtime) Reconcile(on map[string]bool) { // 先关:wanted=false ∪ Requires 命中正在关闭者 Provides 的闭包,逆序 dispose // 后开:不动点循环,Requires 满足才 apply,apply panic → undo + Failed } // 2. 扩展点(每个 ~30 行) func Use(h Hook) (remove func()) { // 泛型 add[T] // append 到注册表;返回 sync.Once 包裹的 remove } // 3. 边界测试(~50 行) // AST 扫描:core 包不得 import plugins/*;plugins/* 互不 import ``` 再按需加:配置三层合并(yaml + Source 追踪)、一个 fail-open 的请求中间件点、一个 fail-closed 的计费点。Bun/JS 宿主只在确实需要第三方生态时再引入。 ## 7. 关键文件索引 | 文件 | 内容 | | --- | --- | | `internal/pluginrt/pluginrt.go` | 注册表、Manifest、Reconcile 状态机、API 兼容 | | `internal/pluginrt/config.go` | plugins.yml 三层合并、热更新、原子写 | | `internal/pluginrt/schema.go` | Settings → JSON Schema | | `internal/pluginrt/pluginrt_test.go` | `TestPluginsImportOnlyCore`(AST 边界测试) | | `internal/gwmw/gwmw.go` | 中间件点:Agent(fail-open)/ Masker(fail-closed) | | `internal/routes/routes.go` | 路由策略点:Seat 与 Strategy | | `internal/gwbudget/gwbudget.go` | 预算点(fail-closed) | | `internal/usage/observe.go` | 观察点:Bodies 分级、Observer 生命周期 | | `internal/middleware/middleware.go` | moejs 引擎、编译缓存、热加载 stamp | | `internal/middleware/run.go` | 每请求 runtime、hook 派发 | | `internal/middleware/points.go` | strategies / onUsage 扩展导出、离线队列 | | `internal/plugin/host.js` | Bun 宿主:JSON 行协议、credit 背压、账号作用域 | | `internal/plugin/gateway.go` | 异步请求钩子入口与三段协议 | | `docs/subsystems/plugin-runtime.md` | 官方设计文档(P3/P4 扩展点清单) | | `docs/subsystems/gateway-middleware.md` | 中间件点官方文档 |