Files
openteam/docs/magpie-plugin-mechanism.md
Sakurasan 979973ddde feat: 流式首包缓冲容错(hold)与失败分类冷却
流式请求在首个内容事件到达前由网关缓冲(借鉴 magpie 的 holdWriter):上游的错误
事件、空流、断连、协议形态不符(要流给了整包)都在放行前换渠道消化,客户端无感知。
扣住期间照常发 SSE 注释心跳;缓冲超过 1MB 直接放行退化为普通转发,判定失效不吞正文。

- 新增 proxy/hold.go:streamHold(缓冲/放行/心跳/首 token 计时与放行互斥)、
  sseEventHasContent(chat/messages/responses 三种协议的内容判定)、
  sseUpstreamError(200+流式形式的错误事件识别)、classifyUpstreamError +
  quotaSignals(余额/配额特征识别)
- 非流式改为先读完整响应再写头:读失败(网络错误/超时)发生在写出之前,可换渠道
  重试;此前先写 200 头再读,超时只能给客户端留一个 200 空响应
- 协议形态校验:要流却给了整包(didNotStream)按失败换渠道,反复如此的渠道会被
  健康计数剔除
- 记账:每次尝试独立 trySink,只有成功走到 EOF 才并入请求级 sink——失败渠道的
  message_start 用量不计入账单
- 冷却按原因分类(channel.FailKind):余额/配额类第一次失败即冷却 30 分钟(429 不
  一定是限流,智谱 429「余额不足」也在此列);其余按连续进冷却次数指数退避
  30s→60s→120s,封顶 10 分钟;短于退避值的 Retry-After 不再缩短冷却
- 新增 OT_PROXY_FIRST_TOKEN_WAIT(默认 0=不限):放行前等内容的最长时间,超时即
  取消本次尝试换下一个渠道
- 渠道列表接口返回 cooldown_reason,后台可区分「限流」与「余额耗尽」
- 测试:hold 缓冲/放行/心跳、内容与错误事件判定、失败分类与指数退避、容量热生效
- docs/magpie-plugin-mechanism.md:参考实现的插件机制调研
2026-10-30 02:43:23 +08:00

274 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# magpie 插件机制深度调研
> 调研对象:[yetone/magpie](https://github.com/yetone/magpie)(本地 LLM 网关,Go,~855K 行)。
> 本文聚焦其插件机制本身,目标是把可移植的设计提炼出来供其他项目参考。所有行号基于 2026-10-30 的 main 分支。
## 0. 总览:三层并存的「插件」
magpie 里「插件」一词覆盖三个完全不同的机制,看懂分层是看懂代码的前提:
| 层 | 位置 | 形态 | 运行环境 | 典型用途 |
| --- | --- | --- | --- | --- |
| 内置插件(built-in) | `plugins/<id>` 包 + `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/<id>` 包在 `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#<slot>`;请求内的一切(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` | 中间件点官方文档 |