Files
HomeAgent/docs/zh/架构迁移评估.md
dev 304cad0648 docs(plugin-arch): 归档插件架构迁移评估 + plan 第11节整改计划
- docs/zh/架构迁移评估.md: C ABI→子进程+共享内存完整迁移论证(1621行)
- docs/zh/experiments/: 18项可复跑可行性实验(架构评估的所有数字来源)
- plan.md §11: 11.1~11.9 插件架构缺陷修复清单(唯一权威编号)
- main 保持干净,本批次为 update 特性分支的整改起点
2026-08-31 11:45:52 +08:00

72 KiB
Raw Blame History

插件架构迁移评估:从 C ABI 动态库到子进程 + 共享内存

状态:评估稿 + 三轮验证已完成18 项可复跑实验) · 第七章 可行性实验11 项全部通过(另有 01 组 3 项 + 04 组 2 项,合计 18 · 第八章 代码检查:发现外部插件 stage 一直是副本模型,实测 36.8% lost update · 第九章 补盲分析:新发现 4 类缺陷,其中 2 项正在生产环境造成故障

现网正在发生的问题(详见 9.3/9.4/8.6 output_send 永远返回成功(已 2 次、cgo 超时线性泄漏(已 26 次、stage 数据污染(量级百分之几)

结论摘要:现有 c-shared + dlopen 架构存在无法修复的热重载缺陷与能力天花板, 两者同源于 C ABI 这一前提。迁移到子进程模型可一次性消除,并让 C 中间层整体退场。

可执行修复项与勾选清单见 plan.md 第 11 节;本文档负责论证、实验与架构设计。


零、给接手者的阅读指引

先读本章再读其余部分,否则极易被前六章的过时表述误导。

0.1 本文档是增量写成的,前后章节结论不同

文档按三轮工作递进追加,前面的章节保留了当时的认识(便于追溯推理过程), 但其中若干结论已被后续章节推翻或修订。冲突时一律以编号更大的章节为准。

写作时的信息基础 可信度
一 ~ 六 代码阅读 + 推理 ⚠️ 部分已被推翻,见 10.2 对照表
11 项新架构可行性实验 实测
精读 ABI 链路 + 复刻实验 实测,推翻了 2.4 的核心前提
遍历全部加载路径 + journal 统计 实测 + 现网数据
元信息

最重要的一处推翻2.4 节称「stage 并发扇出改写同一 StageContext」, 该表述对内置插件成立,但外部 .so 插件从未共享过 StageContext—— 它们走「快照-副本-写回」(见 8.1)。若按 2.4 的字面理解去改代码会走错方向。

0.2 三件事的准确定位

接手时最容易混淆的三个概念,这里一次说清:

① 并发扇出是原始设计,不是缺陷。 stages.go:124go func + wg.Wait() 并发调用所有 stage handler StageContextsync.RWMutex 与公开的 Lock/RLock 就是为此准备的协作机制。 设计是对的。 问题在于 C ABI 无法传递 Go 对象引用, 外部插件被降级为副本模型,那把为协作而生的锁在 ABI 边界外变成空转 (实测:同一并发设计下内置 0% 丢失,副本 35.8~36.8% 丢失)。

② 内置插件的高权限是刻意设计,不是"自己人所以安全"。 但当前实现把「应有的权限梯度」与「C ABI 的表达能力天花板」混在了一起: 外部插件拿不到 OutputChan/Subscribe技术限制Go channel、闭包 过不了 C ABI而非权限决定——证据是 loader.gocase 23/24 (事件订阅)是空实现,属于"给不了"而非"不给"。 迁移目标是让梯度从技术意外变成显式声明并强制的策略不是消除梯度

③ 副本模型是"为方便插件加载的无奈之举",不是设计失误。 C ABI 是为绕开 Go 原生 plugin 包的同版本限制而引入的,副本模型是它的必然代价。 批评应指向"该代价未被记录、其后果lost update未被发现",而非当初的选择。

0.3 修复项以 plan.md 为唯一权威

本文档出现过多套编号4.1 的 0.x、8.9、9.6 的 A-F 均已统一收敛到 plan.md 第 11 节11.1~11.6

plan.md 内容 本文档论证位置
11.1 output_send 假成功 9.4
11.2 cgo 超时不可中断 9.3
11.3 stage 副本 lost update 8.1~8.6
11.4 Lua 缺读锁 9.1
11.5 Windows 能力退化 9.2
11.6 reload 语义谎言 1.1 / 1.2
11.7 子进程化迁移(待决策) 三~七章

本文档中的 0.x / A-F 编号仅供追溯当时的分组思路,实施时不要使用。

0.4 哪些结论未经实测

表述 状态
9.2 Windows DLL 只下发 3 字段且无写回 ⚠️ 仅代码阅读,无 Windows 环境实测
11.1 修复方案「不构成 cgo 嵌套」 ⚠️ 推理,实施前必须实测
9.1 Lua DATA RACE 会实际触发 ⚠️ 现网无 Lua 插件,未触发过
3.x 目标架构的全部设计细节 ⚠️ 机制经实验验证,完整实现未写

其余带 的均有 experiments/plugin-arch/ 下的 可复跑实验支撑(./run.sh18 项)。

0.5 现网正在发生的问题(若只读一段,读这段)

问题 现网次数 影响 修复
output_send 永远返回成功 7 天内 2 次 消息发不出,模型以为成功、不重试 11.1
cgo 超时不可中断 14 天内 26 次 每次泄漏 1 goroutine + 1 OS 线程,永久 11.2
stage 清洗结果被覆盖 概率性,量级百分之几 脏数据ANSI 转义)进 LLM 上下文 11.3

这三项都不需要等迁移决策,可独立修复。


一、为什么要动

1.1 触发问题:插件热重载静默失效

更换 plugin.so 后调用 plgreload,内核报告 reloaded: qq 成功,但运行的仍是旧代码

根因经实验确证:

readelf -d plugin.so
  FLAGS:   SYMBOLIC STATIC_TLS
  FLAGS_1: NODELETE          ← Go 链接器强制写入

DF_1_NODELETE 使 dlclose 成为 no-op返回 0 但不卸载)。同路径二次 dlopen 复用旧映像,新代码永不生效。

三组对照实验(/proc/self/maps 段数为准):

场景 dlclose 后映射段数 结论
第三层是纯 C .so 5 → 0 可真正卸载,换代码生效
Go c-sharedGo 宿主直接 dlopen 5 → 5 未卸载
Go c-shared经纯 C shim dlopen 5 → 5 仍未卸载

第三行是决定性的:NODELETE 属于被卸载对象自身的 ELF 属性,与谁调用 dlopen 无关。 套任何层数的 C 中间件都绕不过去。

上游明确不支持golang/go#11100Go runtime 的信号处理器是进程全局的, sysmon/GC worker/scavenger 常驻 OS 线程,静态 TLS 嵌进线程布局—— patch 掉标记只会把静默失效换成随机崩溃。

1.2 曾评估并否决的绕行方案

版本化路径 dlopenplugins/qq/.load/plugin-<时间戳>-qq.so):技术上成立,实测有效。

1) 装载 1700000001-qq.so  handle=0x36add080 ver=v1-CODE
2) 主 so 更新为 v2复制到 1700000002-qq.so
3) dlclose 旧句柄  rc=0旧映像不释放预期
4) 装载新路径      handle=0x36ade440 ver=v2-CODE   ← 新代码生效

同时确认 Go c-shared 无 SONAME,不会被 glibc 按名去重,换路径确实得到新映像。

但代价不可接受。30 次连续重载实测:

第 10 次: RSS +15380KB  threads +42
第 20 次: RSS +32048KB  threads +112
第 30 次: RSS +46644KB  threads +168
均摊: RSS +1575KB/次, 线程 +5.77/次

每次重载永久泄漏约 5.8 个线程——每份残留 Go runtime 都带自己的 sysmon、 GC worker、scavenger永不退出且仍被调度。GOMAXPROCS=1 只能压到 4.0/次, 且会拖慢插件并发,杯水车薪。

对 24/7 常驻的 homed 而言「永久泄漏」比「15 秒重启」糟糕得多:重启有界且自愈, 泄漏无界且单调劣化。故否决。

1.3 更深的问题:内置与外部插件的能力断层

方法数
internal/sdk(内置插件用) 28
公开 SDK外部插件用 36

数字接近,但内置独有的恰恰是**「活的 Go 对象」**

OutputChan() <-chan *agentIO.OutputEvent   Go channel
RegisterChannel(dev agentIO.Device)        Go 接口(含方法集)
Subscribe(type, handler) func()            回调 + 返回退订闭包
Publish / Config / Tool / Indexer          直接持有内核注册表
Selftest / Status / Supervisor / Tracker   内核内部机制
SetToolBlocks                              多模态注入

关键区分:内置插件的高权限是刻意的设计决策,不是"自己人所以安全"。 但当前实现把两件事混在了一起:

  • 应该有的权限梯度Supervisor/Tracker/Selftest 只给内核内部)
  • C ABI 的表达能力天花板channel、接口方法集、闭包在进程边界外无表示

外部插件拿不到 OutputChan技术限制,不是权限决定。证据:

case 23: // CORE_SUBSCRIBE
    // Events API not wired for external plugins
    return 0
case 24: // CORE_UNSUBSCRIBE
    return 0

事件订阅对外部插件是空实现。这不是"不给",是"给不了"。

迁移的价值不是消除权限梯度,而是让梯度从技术意外变成显式声明并强制的策略

1.4 附带缺陷(同源于 C ABI

  • SetToolBlocks 在 bridge 中是空实现——跨 ABI 无对应 method id
  • 插件 panic 跨 C 栈,recover 兜不住就带崩整个 homed
  • plugin_install 返回 reload_required.so误导性谎言
  • method id 编号出现历史断裂(CORE_INJECT_INPUT_SYNC=47 夹在 7 和 8 之间)
  • plugindev 交叉编译需处理 cgo 工具链Windows/ARM 目标需对应 C 编译器

这些全是 C ABI 这一前提衍生的附属债务。前提一撤,债务自行消失。


二、现状盘点

2.1 插件规模

内置插件 16 个internal/plugins/all.go 匿名 import + init() 自注册):

agentcli  ai_image  cfgmgr  clawhubadapter  cli  cmd  files  healthcheck
localuse  mcp  multimodal  pluginmgr  remotedevice  skillmgr  timer  webui

外部 .so 插件 17 个/home/newqqagent/plugins/

a2a  acp  ai_image  bili  browser  calendar  editdoc  files  memo
music  ocr  qq  recoverydiag  rss  sanitizer  vanblog  weather

2.2 涉及代码规模

文件 行数 迁移后命运
internal/plugin/cabi/loader.go 951 删除
internal/plugin/cabi/types.go 66 删除
internal/plugin/cabi/loader.c 79 删除
internal/plugin/registry.go 922 改:加载分派
internal/plugin/dynamic_loader_unix.go 79 删除/替换
internal/sdk/plugin.go 365 基本不动
公开 sdk/plugin.go 484 加访问器,签名不变
internal/agent/core/stages.go 189 stage 跨进程
internal/agent/core/plugin_health.go 128 不动,只换信号源
internal/events/bus.go 97 加:事件环投递
plugindev/templates.gotmplLinuxBridge 385 删除(每插件一份)
internal/plugin/lua_plugin.go 978 改:统一走 RPC见 9.1
internal/plugin/dynamic_lua.go 24
internal/plugin/lua_util.go 60 保留
internal/plugin/dynamic_dll_windows.go 删除(见 9.2,能力严重退化)
internal/plugin/dynamic_loader_windows.go 删除
internal/plugin/dynamic_dll_stub.go 删除

⚠️ 第九章更正:此前只识别了 native + cabi 两类加载路径,实际有四种 cabi .so/.dylib/.dll、Lua main.lua、Skill SKILL.md、native 内置)。 Windows DLL 与 Lua 路径此前完全未评估均存在独立缺陷9.1/9.2)。 子进程化的一个重要收益是把三套独立 ABI 实现收敛为单一 RPC 实现。

可删除总量1096 行 C ABI 层 + 385 行/插件的 bridge 模板。

loader.goC.CString/C.GoString/C.free 共 38 处调用,纯边界税。

2.3 已有且完善、迁移时应保留的机制

必须强调:现有 SDK 的生命周期管理远比表面完善,迁移是"重新接线"而非"重写"。

plugin_health.go128 行,逻辑完全复用

3 次崩溃 / 5 分钟窗口 → 标记 unhealthy
30 秒冷却 → 自动恢复
pendingReloads() → 驱动 autoReloadPlugins
尊重 AutoRestartEnabled

executeToolCalltoolcall.go:16

done := make(chan string, 1)
go func() { done <- a.executeToolCallInner(tc) }()
select {
case result := <-done:  return result
case <-time.After(60 * time.Second):  // 60 秒超时
}
// + panic 捕获 → resolveToolPlugin → recordCrash

SDK 停止链路(已验证被正确调用

Handle.Stop() → call_stop_plugin → go_stop_plugin
              → sdk.RunStopHandlers() → plg.Stop()
RegisterStopHandler / RegisterOnRemoveHandler / SetAutoRestart

迁移时的唯一改动:把"panic 捕获"换成"进程退出码/EOF 检测",喂给同一个 recordCrash 超时、冷却、自愈、优雅停止全部保持原样。

2.4 两条硬约束(决定新架构设计)

约束 Astage 是并发扇出,多插件并发改写同一对象

internal/agent/core/stages.go:124

func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
    var wg sync.WaitGroup
    for _, handler := range handlers {
        wg.Add(1)
        go func(fn sdk.StageHandler) {   // ← 并发
            defer wg.Done()
            if err := fn(ctx); err != nil { errCh <- err }
        }(handler)
    }
    wg.Wait()
}

所有插件 handler 并发运行在同一个 *StageContext 上,靠 sync.RWMutex + 公开的 Lock/RLock/Unlock 协调。这是刻意设计——那 17 个字段 LLMText/ToolCalls/FinalText/Errors…)就是给多插件协作改写消息体用的。

这是共享内存的正当性所在。 JSON-RPC 副本模型下语义会崩坏:两个插件都改了 FinalText,谁赢?现在的答案是"后者看到前者结果",可组合;副本合并则无解。

⚠️ 本节前提已被第八章推翻,勿据此改代码

上述「并发扇出改写同一对象」只对内置插件成立。 外部 .so 插件一直走「快照-副本-写回」(loader.go:411-439 + templates.go:768-787ctx.Lock()空操作(锁的是副本自己的 mu且实测存在 35.8~36.8% 的 lost update(实验 12现网量级百分之几的数据污染(实验 13

两点务必分清

  • 并发扇出本身是正确的原始设计stages.go:124RWMutex 就是为它准备的
  • 失效的是跨 ABI 边界后的锁语义,不是这个设计

故共享内存的作用是修复副本模型的缺陷,而非"保持现有语义"。 完整分析见 8.1~8.6;准确定位见 0.2。

约束 BBus.Publish 是同步的,且流式输出每 token 发一次

internal/events/bus.go:56

func (b *Bus) Publish(evt *Event) {
    for _, h := range typeHandlers {
        b.safeCall(h, evt)   // ← 内联阻塞调用
    }
}

EventContentDelta / EventReasoningDeltaaccumulateStream 里逐 token 发布 process.go:388/470/479)。

若内核发通知时等待插件,流式输出会被拖成卡顿。 故新架构的事件投递必须严格 post-and-forget绝不等待消费者

2.5 payload 体积实测(决定共享内存的定位)

近两日工具调用结果:

样本=96  中位=93 B  p90=130 B  最大=134 B  均值=83 B

此量级下 JSON 序列化 3-8 µsLLM 单轮往返 2-8 秒,IPC 开销占比约 0.0001%

结论:共享内存的价值不在省序列化开销,而在两点:

  1. 并发改写同一份 StageContext(约束 A
  2. 二进制零拷贝(未来多媒体 payload避免 base64 的 +33% 体积与编解码)

控制面用 JSON 完全够用——toolcall 结果最终都要 JSON 化交给模型。


三、目标架构

今天:
  homed ──dlopen──> plugin.so
                    ├─ cgo bridge 385 行7 个 //export27 处字符串转换)
                    └─ 51 个整数 method id 派发
                    ↑ C 层唯一目的:绕开 Go plugin 包的同版本限制

之后:
  homed ──spawn──> plugin纯 Go 二进制,无 cgo
    │
    ├── stdio JSON-RPC   控制面51 个 case 平移为 method 名
    ├── shm + 偏移        数据面StageContext 并发改写、二进制零拷贝
    └── eventfd          通知面:事件环 post-and-forget

3.1 C 中间层为何整体退场

C 层存在的唯一理由是绕开 Go 原生 plugin 包的版本枷锁:

plugin.Open 要求:完全相同的 Go 版本 + 完全相同的依赖版本 + 同构建环境
任一不符 → "plugin was built with a different version of package ..."

c-shared + dlopen 把接口面压成 C ABI 来规避,代价是 51 个整数派发和满地字符串转换。

子进程模型下,进程边界本身就是 ABI 边界。 两进程各带自己的 Go runtime 版本/依赖/编译器全不相关——从根上不存在"同版本"问题。C 层解决的问题消失C 层自己也就该消失。

连带消失的:DF_1_NODELETE 议题、版本化路径、重载配额、hash 目录、 method id 编号维护、SetToolBlocks 空实现、cgo 交叉编译工具链。

3.2 method id 的处置

51 个 case 不删,原样映射为 RPC method 名

case 17: // CORE_SETTINGS_SET        →  {"method": "settings.set"}
case 41: // CORE_TEXT_MEMORY_APPEND  →  {"method": "textmemory.append"}
case 48: // CORE_PLUGIN_RELOAD_ONE   →  {"method": "plugin.reloadOne"}

每个 case 体(参数解析、调用、错误处理)可整块搬移,只换取参数方式。 语义不变,回归风险最小。

但编号本身扔掉:不再维护"下一个可用 id 是 52",加能力不用改两边常量表, 也不再出现 47 夹在 78 之间的历史痕迹。

3.3 数据面:偏移替代指针

共享段 = 定长头 + arena所有变长数据用相对 arena 基址的 {off, len} 描述符。 相对偏移是关键——各进程 mmap 到不同虚拟地址也能正确解引用。

typedef struct { uint32_t off, len; } Slice;   // 相对 arena 基址

typedef struct {
    Slice   type, text;
    uint8_t has_image, has_audio;
    Slice   image_url, image_detail;
    Slice   audio_url;
} ShmContentBlock;

typedef struct {
    uint64_t seq;                    // 乐观读校验
    Slice    raw_message, llm_text, reasoning, final_text;
    uint8_t  has_response; Slice response;
    Slice    media_type, input_source, output_channel;
    uint32_t nblocks; Slice blocks;  // → ShmContentBlock[]
    uint32_t arena_used, arena_cap;
} ShmStageCtx;

Extra 可完全偏移化——已核实全部使用点只有 4 个键

internal/agent/core/eventloop.go:178
  Extra = { media_blocks, media_type, input_source, output_channel }
process.go:41   读 Extra["media_blocks"].([]ContentBlock)
distill.go:472  写 Extra["output_channel"]

ContentBlock 自身全是可偏移化的:

ContentBlock{ Type, Text string; ImageURL *ImageURL; AudioURL *AudioURL }
ImageURL{ URL, Detail string }    AudioURL{ URL string }

没有任何 interface{}、函数或 Go 特有引用类型。 那两个指针只表达"可选"has 标志位 + 内联结构替代。Extrainterface{}形式上的动态类型, 实际是封闭可判别的联合。

决策4 个键提升为共享段具名字段,Extra 本身保留 RPC 副本语义。 理由:这 4 个键都是内核写、插件读,无并发改写需求;真正需要多插件并发改的 LLMText/FinalText/ToolCalls/Errors)全是强类型字段。 不为尚不存在的通用性付 tagged union + 字符串驻留表的成本。

arena 空间管理append-only。插件把 FinalText 从 10 字节改成 10KB 时 分配新区域、更新描述符、旧区域留作垃圾arena 用尽由内核在 stage 结束后 (此时无插件持锁)整体压实。代价是单次 stage 内写入总量有上限。

3.4 SDK 必须封装全部复杂度

插件作者永远不接触 Slice{off,len},代码与今天完全一致:

func (p *Plugin) onBeforeToolcall(ctx *sdk.StageContext) error {
    ctx.Lock()
    defer ctx.Unlock()
    ctx.FinalText = strings.TrimSpace(ctx.FinalText)
    return nil
}

SDK 内部承担:mmap 挂载、跨进程锁初始化、arena 分配、偏移↔Go 值转换、 脏字段回写、进程崩溃后段清理。

实现手法:插件进程内保留原生 StageContext 结构。 stage 入口从共享段反序列化成本地对象 → handler 照常读写字段 → Lock/Unlock 映射到跨进程锁 → handler 返回时脏字段写回共享段。

每次转换微秒级,换来 17 个存量外部插件业务代码零改动。这个交换很值。

3.5 必须留在进程内的:回调型资源

"所有数据放共享内存"需要精确化:共享内存放不了函数指针 (地址在各进程不同,代码段布局也不同)。

类别 载体
跨进程状态 共享内存
跨进程行为 RPC 调用回内核

Subscribe 返回的退订闭包、Device 的方法集、OutputChan 的接收端—— 这些是"行为"不是"数据"。故准确表述为: 所有跨进程传递的状态放共享内存,行为通过 RPC 调用回内核。

3.6 通知面:事件环 + eventfd

设计骨架(采纳):数据先落地 → 再通知 → 内核不等待。满足约束 B。

但单纯的信号量不够——sem_t 只是计数器,没有 payload、顺序、消费游标

typedef struct {
    uint64_t seq;              // 全局单调序号
    uint32_t type;
    Slice    payload;          // → arena
} EvtSlot;

typedef struct {
    _Atomic uint64_t write_seq;   // 仅内核写
    uint32_t cap;                 // 2 的幂
    EvtSlot  slots[];
} EvtRing;

typedef struct {                  // 每订阅者独立
    _Atomic uint64_t read_seq;
    _Atomic uint64_t dropped;     // 被覆盖丢弃计数
    uint32_t type_mask;
    uint64_t last_seen;           // 活性判断
} Subscriber;

内核:写 slot → write_seq++ → 对匹配订阅者 post不等待。 消费者:read_seqwrite_seq,落后超 cap 即溢出,差值记入 dropped 并跳到最新——允许丢事件但让消费者知道丢了(与 WebUI 侧 sync_required 思路一致)。

技术修正:用 eventfd 而非 sem_t

Go 里没有轻量线程。goroutine 经 cgo 调 sem_wait阻塞整个 OS 线程M 被占住), 每插件常驻一个锁死线程——又回到我们正要逃离的线程膨胀。

efd, _ := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
f := os.NewFile(uintptr(efd), "evtnotify")
// eventfd 是 epoll-ableos.NewFile 注册进 runtime netpoller
// f.Read() 阻塞时只 park goroutine不占 OS 线程

附带收益:

  • 计数语义读出累积值天然合并突发通知——1000 个 token 事件可能只唤醒几次
  • 可与 RPC 请求在同一 select 中等待,不需两套等待机制

已实测通过(见 7.2200 个 goroutine 阻塞在 eventfd.Read 上仅增 1 个 OS 线程。

3.7 跨进程锁:倾向"锁仲裁回归内核"

pthread_mutexPTHREAD_PROCESS_SHARED + ROBUST 属性 Go 标准库无等价物。 但引入它意味着为了一个锁而保留 cgo——与"C 整体退场"的目标冲突。

方案对比

robust pthread_mutex 锁仲裁回内核
cgo 需要 不需要
崩溃处理 需处理 EOWNERDEAD + consistent 进程死了内核直接释放
加锁成本 原子操作(纳秒) 一次 RPC 往返(微秒)
复杂度

已裁定采用后者(实验 3+9见 7.4/7.10):插件通过 RPC 请求"给我 stage 写锁",内核用普通 sync.Mutex 排队。 stage handler 加锁频率很低(每次 stage 一两次,非每字段一次),微秒级往返可忽略。

这样整个新架构可做到完全无 cgo。

注:事件环无需 robust 语义——eventfd/信号量没有所有权概念, 不存在"持锁进程死了"的死锁风险。共享内存中真正需要互斥的只有 StageContext

3.8 能力对齐:外部插件可获得什么

内置独有能力 子进程下的等价物 可行
OutputChan 消费 共享内存 ring + eventfd 通知
Subscribe/Publish 事件环 + 独立游标
RegisterChannel(Device) 声明式注册caps + 工具名清单)+ 调用回传
SetToolBlocks 二进制落 arenaSlice 描述符回传
Config/Tool/Indexer 新增 RPC method本就是数据操作
Selftest/Supervisor/Tracker 不提供 刻意

最后一行是显式的权限决策,而非技术限制——这正是迁移要达成的区分。

副产品:localuse 这类插件不再必须编进内核,改一行不用重编整个 homed。


四、工作量评估

4.1 分阶段拆解

规模标记S = 1 人日内M = 2-4 人日L = 1-2 周XL = 2 周以上。 风险标记基于「失败时能否安全回退」。

阶段 0止血不依赖任何新架构

⚠️ 本表编号已废弃(此处仅存档当时的分组思路)。 实施请用 plan.md 第 11 节11.111.6——见 0.3 的对应表。 本表的 0.1/0.2/0.3 → plan 11.6;后文追加的 0.40.7 → plan 11.3/11.1/11.2/11.4。

# 任务 文件 规模 风险
0.1 ELF 检测 DF_1_NODELETE,命中则标记插件"不可热重载" dynamic_loader_unix.go S 极低
0.2 ReloadOne 对此类插件直接返回"需重启",停止假装成功 registry.go S 极低
0.3 plugin_install 返回 restart_required 替代误导性的 reload_required pluginmgr/plugin.go S 极低

价值:零运行时开销,立刻消除"模型照着 reload_required 建议重载、实际白跑"的误导。

后续追加(第八、九章发现,优先级高于 0.1-0.3

# 任务 现网影响 规模
0.4 stageContextWritable 只回传变更字段 脏数据进 LLM量级百分之几8.6 S
0.5 output_send 改同步等真实结果 消息发不出而模型以为成功9.4 M
0.6 超时日志措辞修正 + 排查 browser 频繁超时 已泄漏 26 次9.3 S
0.7 Lua stage 快照加 sc.RLock() 潜在 DATA RACE9.1 S

阶段 1能力对齐验证不依赖子进程

# 任务 规模 风险
1.1 给 C ABI 补 SetToolBlocksmethod id 52走文件路径传递 M
1.2 用某外部插件验证多模态注入端到端可用 S

价值:先验证"外部插件能否逼近内置能力"这一假设,不依赖任何共享内存基础设施。 若此步就发现能力对齐有本质障碍,整个迁移的收益需重估。

阶段 2子进程通道原型核心风险点

# 任务 文件 规模 风险
2.1 进程管理器spawn/健康检查/优雅停止/崩溃重启。可大幅参考 clawhubadapter/sidecarProcess(已有 stdin/stdout + 异步 reader + pending map[int]chan + notifyCh 的成熟实现) internal/plugin/proc/(新建) L
2.2 双向 JSON-RPC 编解码7 个 kernel→plugin 调用 + 51 个 plugin→kernel 回调 同上 M
2.3 procPlugin 实现 sdk.Plugin 接口,Close() 变成真 kill+wait dynamic_loader_unix.go M
2.4 loadOne 按 manifest entry 分派:plugin.so→cabiplugin.bin→proc registry.go S
2.5 plugin_health 接线:进程退出码/EOF → recordCrash逻辑复用,仅换信号源 plugin_health.go 调用侧 S
2.6 plugindev 新增 tmplProcMainbridge 从 c-shared 导出改为 main() + stdio loop templates.go M
2.7 plugindev 构建改普通 go build(去 cgo交叉编译反而简化 cmd_build.go S
2.8 validBinariesplugin.bin.hmap 打包/校验支持 pluginmgr + manifest S
2.9 单插件单工具端到端打通(建议用 weather M

关键收益:公开 SDK 的 PluginSDK 方法签名全部保留,底层从 callString(id,...) 换成 RPC 发送——17 个存量插件业务代码零改动,只需用新 plugindev 重编

阶段 3共享内存数据面

# 任务 规模 风险
3.1 共享段 schema + arena 分配器append-only + 压实) L
3.2 StageContext 偏移化编解码(共享段 ↔ 本地 Go 对象) L
3.3 锁仲裁 RPCstage.lock/stage.unlock,内核侧 sync.Mutex M
3.4 RunStage 跨进程并发扇出改造(保留并发语义,最难的一环 L
3.5 段生命周期:创建/挂载/插件崩溃后清理 M

3.4 是全项目最高风险点:必须保证多插件并发改写同一 StageContext 的语义 与今天一致,否则 sanitizermultimodal 这类改写型插件行为会静默漂移。

阶段 4通知面

# 任务 规模 风险
4.1 EvtRing + Subscriber schema溢出计数 M
4.2 eventfd 通知 + Go 侧 netpoller 消费(先做 3.6 的实测验证 M
4.3 Bus.Publish 加事件环投递post-and-forget不得阻塞 S
4.4 订阅者活性检测(last_seen 超时 → recordCrash S
4.5 实现 case 23/24(今日空实现),外部插件首次获得事件能力 M

4.3 风险高Bus.Publish 在流式路径上逐 token 调用,任何阻塞都会导致 输出卡顿。改动必须严格无锁/非阻塞,且需专门的流式压测验证。

阶段 5迁移与收尾

# 任务 规模 风险
5.1 17 个外部插件逐个重编译 + 回归验证 L
5.2 删除 cabi/1096 行)与 bridge 模板385 行) S
5.3 权限梯度显式化:声明式 caps + 内核侧强制 M
5.4 文档:插件开发指南更新、迁移说明 M

4.2 总量估算

阶段 规模合计 可独立交付
0 止血 ~1 人日 立即
1 能力对齐 ~3 人日 独立
2 子进程通道 ~3 周 与 cabi 共存
3 共享内存 ~3 周 ⚠️ 依赖阶段 2
4 通知面 ~1.5 周 ⚠️ 依赖阶段 3
5 迁移收尾 ~2 周 ⚠️ 依赖全部

合计约 10 周(单人、含验证,不含意外)。

实验后修订:约 8-9 周(见 7.15)。第九章新增的 Lua/Windows 路径收敛 已包含在阶段 5 的迁移工作内,不额外增加工期——因为它们是删除而非改造。

4.3 成本对照

作为决策参考,三条路的真实成本:

方案 一次性成本 长期代价 性质
接受重启(仅做阶段 0 ~1 人日 每次换 .so 中断 ~15 秒 有界、自愈
版本化路径 ~3 人日 每次重载 +5.8 线程 +1.5MB永久 无界、单调劣化
子进程 + 共享内存 ~8-9 周 常驻 +29MB RSS7.6 实测,原估 50-70MB 偏高) 有界、换来真隔离

插件更新的真实频率是每周级(今日的密集调试是特例)。 若唯一目标是热重载,阶段 0 的性价比远高于全量迁移。

迁移正当性共 6 条(与 9.5 同一份清单;本节讨论的热重载是第 ① 条):

# 正当性 依据 有临时修复?
热重载 1.1(原始动机) 11.6 可缓解(改为诚实上报)
插件崩溃隔离(现在一个插件 panic 能带崩 homed 实验 6
能力断层消除(外部插件获得事件订阅、多模态、通道注册) 1.3 / 3.8
内置插件解耦(localuse 改一行不用重编 homed 1.3
修复 stage 副本 lost update实测 35.8~36.8%,现网量级百分之几) 8.4 / 8.6 ⚠️ 11.3 打补丁
修复 cgo 固有缺陷:超时泄漏(现网 26 次)、output_send 假成功(现网 2 次、Windows 退化、三套 ABI 分裂 第九章 ⚠️ 11.1/11.2/11.5 打补丁

关键判断:①⑤⑥ 有临时修复(plan.md 11.1~11.6不必等迁移 但那些修复是在副本模型内部打补丁,只有子进程 + 共享内存才从根上消除成因。 ②③④ 无临时方案——它们是迁移的不可替代价值。

若这六点都不成立,则不应迁移。

4.4 风险登记

风险 影响 缓解
RunStage 并发语义漂移3.4 改写型插件行为静默错误 机制已验证7.9);仍需为 sanitizer/multimodal 补并发行为测试作为基线
Bus.Publish 引入阻塞4.3 流式输出卡顿 专项流式压测;投递路径禁用任何锁
eventfd 未走 netpoller 已排除7.2 实测 +1 线程)
17 进程常驻开销 实测仅 +29MB RSS7.6 风险关闭
arena 单 stage 写入上限 大写入插件失败 明确上限并在 SDK 层报错,而非静默截断
Windows 无验证环境9.2 迁移后 Windows 行为未知 需借测试机;当前 Windows 路径本就严重退化,风险不增
Lua 插件迁移路径未设计9.1 Lua 插件如何跑在子进程内 可保留进程内 Lua VM无 cgo 问题)或独立 Lua 宿主进程;待设计
存量插件回归 17 个插件行为变化 阶段 2.4 的 entry 分派让两种插件共存,可逐个迁移、随时回退

4.5 推进原则

双通道共存是整个计划可行的前提。 registry.go 按 manifest entry 分派 (阶段 2.4)意味着 .so.bin 插件可同时运行:

1. 打通 proc 通道,用 weather 验证
2. 逐个迁移,其余 .so 继续跑
3. 全部迁完再删 cabi 路径

任何一步失败都能回退,不会出现"改到一半 homed 起不来"。


五、待定决策

⚠️ 本章为第七章实验前的初始状态。最新裁定见 7.14,正当性清单更新见 9.5。

以下需明确后才能进入实施:

  1. 是否全量迁移? 仍待决定。若只为热重载,阶段 0 即够1 人日 vs 8-9 周)。 全量迁移的理由已从 3 条扩充到 6 条(完整对照表见 4.3)。 其中 ②崩溃隔离 / ③能力对齐 / ④内置解耦 无临时替代方案 ①热重载 / ⑤lost update / ⑥cgo 缺陷 可先用 plan.md 11.1~11.6 打补丁。

  2. 跨进程锁选型锁仲裁回内核 vs robust pthread_mutex 已裁定:锁仲裁回内核(实验 3 测得 19.4 µs/次;实验 9 证明持锁进程崩溃可自愈, 无需 EOWNERDEAD 处理)。新架构完全无 cgo。

  3. Extra 处置 维持原建议——4 键提升为共享段具名字段,Extra 本身留 RPC 副本。 第八章的发现进一步支持此选择:外部插件根本拿不到 Extra(不在下发的 10 个字段内), 故通用 tagged union 是为不存在的需求付成本。

  4. 权限梯度的显式形式 待设计(不阻塞阶段 0-2前提已澄清:内置插件的高权限是刻意的设计决策,不是"自己人所以安全"。 当前实现把「应有的权限梯度」与「C ABI 的表达能力天花板」混在了一起—— 外部插件拿不到 OutputChan 是技术限制Go channel 过不了 C ABI 而非权限决定(证据:case 23/24 事件订阅是空实现,是"给不了"而非"不给")。 迁移目标是让梯度从技术意外变成显式声明并强制的策略,而非消除梯度。 Selftest/Supervisor/Tracker 确认永不对外(见 3.8 能力对齐表最后一行)。


六、已验证事实清单

本文档结论的实证基础,便于后续复核:

结论 验证方式 结果
Go c-shared 带 DF_1_NODELETE readelf -d plugin.so FLAGS_1: NODELETE
dlclose 对其是 no-op /proc/self/maps 段数 5 → 5不归零
纯 C .so 可真正卸载 同上 5 → 0换代码生效
C shim 中间层无法绕过 经 shim dlopen/dlclose Go 库 5 → 5仍未卸载
Go c-shared 无 SONAME readelf -d | grep SONAME 无(换路径不会被去重)
版本化路径有效 两个内容不同的 Go c-shared handle 不同ver=v2 生效
每次重载泄漏 ~5.8 线程 30 次连续 dlopen/proc/self/task +168 线程 / +46MB RSS
GOMAXPROCS=1 缓解有限 同上 降至 +4.0 线程/次
stage 是并发扇出 stages.go:124 go func + wg.Wait
Bus.Publish 同步阻塞 bus.go:56 内联 safeCall 循环
流式逐 token 发事件 process.go:388/470/479 EventContentDelta
外部插件无事件能力 loader.go case 23/24 空实现 return 0
Extra 仅 4 个键 全量 grep 使用点 eventloop/process/distill 各处
toolcall payload 很小 96 样本统计 中位 93 B最大 134 B
内置/外部方法数 对比两个 PluginSDK 28 vs 36差在活对象
生命周期机制已完善 plugin_health.go/toolcall.go 3 崩溃/5 分钟、60 秒超时、30 秒冷却

第七章新增新架构可行性11 项)

结论 验证方式 结果
eventfd 走 netpoller 200 goroutine 阻塞 Read,读 /proc/self/task +1 线程
跨进程偏移解引用 父子进程 mmap 基址对比 基址不同,偏移仍正确
memfd + fd 继承可建共享段 MemfdCreate + ExtraFiles 无需 /dev/shm 命名与清理
锁仲裁 RPC 成本 20000 次 stdio 往返 19.4 µs/次
post-and-forget 解耦流式 5000 token + 20µs 慢消费者 5.07s → 2.29ms2218x
17 子进程常驻开销 smaps_rollup PSS 29.1MB RSS / 12.9MB PSS
子进程线程数低于单进程 对照 homed 84 vs 108
崩溃隔离 子进程 panic 退出码 2EOF 2.5ms,宿主存活
子进程热重载 同路径替换二进制 v1→v2 立即生效
跨进程并发改写 StageContext 5 进程 × 300 轮 append 358 字符 = 358 长度,零丢失
持锁进程崩溃自愈 持锁 panic 后其他进程申请 正常获得,无死锁
二进制零拷贝 100KB/1MB/5MB 对比 18-22x体积 100%
工具调用 RPC 延迟 10000 次真实 payload p50 19.5 µs

第八章新增(现有实现的真实语义)

结论 验证方式 结果
外部插件 stage 是副本模型 loader.go:411-439 + templates.go:768-787 快照→新对象→写回
外部插件 ctx.Lock() 是空操作 同上链路推演 锁的是副本自己的 mu
外部插件字段被裁剪 对比下发字段与 StageContext 16 字段只下发 10
副本模型 lost update 率 复刻链路5 插件 × 2000 轮 36.8%(内置 0%
stageContextWritable 无条件回传 templates.go:762 非空即回传,未改也传
现网 sanitizer+weather 冲突 复刻场景 3000 轮 1.6~4.3% 脏数据进 LLM
现网两插件均在运行 plugin.json + disabled_plugins + 日志 均 8/15 部署,未禁用
StageScopeOwnTools 不减并发 sdk/plugin.go:304 SDK 层包装,仍并发调度

第九章新增(补盲)

结论 验证方式 结果
插件类型有四种 validBinaries + internal/plugin/*.go so/dylib/dll + lua + SKILL.md
Lua stage 快照无读锁 lua_plugin.go:726 sc.RLock()DATA RACE
Windows DLL 只下发 3 字段 dynamic_dll_windows.go:231 完全无写回
cgo 超时不可中断 纯 C 死循环 .so20 次卡死 泄漏 20 goroutine / 18 线程
子进程 Kill 后零泄漏 同实验 B 组 OS 回收全部资源
现网已发生工具超时 14 天 journal 统计 26 次browser 占 22
output_send 永远返回成功 loader.go:458-470 + output.go:65-70 返回 {status:queued}
现网已发生发送失败 7 天 journal 统计 2 次,模型收到"已发送"
homed 主 heap 2.36GB 真实驻留 /proc/PID/status + maps 分析 与插件无关,独立问题

七、前期可行性实验(已执行)

本章记录第五章「待定决策」与第四章风险项的实测结论。 所有实验代码位于 /tmp/feas/,环境 go1.25.12 linux/amd64本机 192.168.2.60。

7.1 结论总览

# 待验证项 原假设 实测结论
1 eventfd 是否走 netpoller ⚠️ 待实测 成立200 等待者仅 +1 线程
2 跨进程 eventfd + 偏移解引用 推理 成立,不同 mmap 基址正确解引用
3 锁仲裁 RPC 往返成本 「微秒级」 19.4 µs/次
4 post-and-forget 解耦流式 推理 2218x 加速
5 17 子进程常驻开销 估 50-70MB 实际 29MB RSS / 12.9MB PSS,远优于估计
6 崩溃隔离 + 退出码信号源 推理 退出码 2EOF 2.5ms 感知,宿主存活
7 子进程热重载 推理 同路径替换即生效,无需版本化路径
8 跨进程并发改写 StageContext ⚠️ 最高风险 5 插件 × 300 轮无丢失无撕裂
9 持锁进程崩溃自愈 需 robust mutex 不需要,内核 Wait/EOF 强制释放
10 二进制零拷贝收益 推理 18-22x 加速,体积省 100%
11 工具调用 RPC 延迟 「噪声里」 p50 19.5 µs,占 LLM 往返 0.00065%

本章 11 项全部通过(另有第一章 dlclose 组 3 项、第九章 cgo 组 2 项,文档合计 18 项可复跑实验)。 第五章的 4 个待定决策中2、3 已由实验裁定。

7.2 实验 1eventfd 走 netpoller原文档标记 ⚠️ 待实测)

基线线程数: 5 (GOMAXPROCS=12)
200 个 goroutine 阻塞在 eventfd.Read 后:
  线程数 = 6 (增长 1)
  ✅ 走 netpoller线程未随等待者数量增长
唤醒数 = 200/200

结论os.NewFile(eventfd) 确实注册进 runtime netpollerRead 只 park goroutine。 200 个等待者仅增 1 个 OS 线程,验证了 3.6 节的设计前提。

反面对照即 sem_wait:经 cgo 会阻塞整个 M200 等待者 = 200 锁死线程。

7.3 实验 2跨进程 eventfd + 偏移解引用

PARENT: mmap 基址 = 0x7f0137a36000
CHILD:  mmap 基址 = 0x7f1eb3437000    ← 不同虚拟地址
PARENT: 数据已落地 arena@1024, 描述符 {off:1024, len:28, seq:42}
PARENT: post 耗时 10.85µs ← post-and-forget
CHILD:  被 eventfd 唤醒, 计数=1
CHILD:  偏移解引用 off=1024 len=28 seq=42 → "hello-from-parent-via-offset"
PARENT: 读到子进程回写 → "CHILD-ACK" ✅ 双向可见

两个关键点得到验证

  1. 父子进程 mmap 到完全不同的虚拟地址0x7f0137a36000 vs 0x7f1eb3437000 相对偏移 {off,len} 仍正确解引用——这正是「偏移替代指针」的核心论据
  2. memfd_create + ExtraFiles fd 继承即可建立共享段,无需 /dev/shm 命名与清理

7.4 实验 3锁仲裁 RPC 成本(裁定决策 2

20000 次 stage.lock RPC 往返 用时 388ms, 均摊 19.40 µs/次

裁定:采用「锁仲裁回归内核」,放弃 robust pthread_mutex。

19.4 µs 相对 stage handler 的实际工作量LLM 往返 2-8 秒)完全可忽略。 换来:零 cgo、无 EOWNERDEAD 处理、崩溃自愈(见 7.10)。

7.5 实验 4post-and-forget 解耦流式输出

模拟 5000 token 流式发布 + 20µs 慢消费者:

A 同步 Publish (现状):  5000 token 耗时 5.07s    均摊 1014.5 µs/token
B 环+eventfd post:      5000 token 耗时 2.29ms   均摊    0.46 µs/token
加速比 2218.6x   丢弃事件 0

验证了约束 B 的严重性与解法有效性。现状下一个 20µs 的慢订阅者 就能让 5000 token 的流式输出多花 5 秒;改为写环 + post 后降到 2.3ms。

7.6 实验 517 子进程常驻开销(修正文档估计)

存活进程 17/17
合计: PSS=12.9 MB  RSS=29.1 MB  线程=84
均摊: PSS=0.76 MB  RSS=1.71 MB  线程=4.9
插件二进制大小: 2.68 MB

原估计 50-70MB 偏高,实际 29MB RSS / 12.9MB PSS。 PSS 远低于 RSS 说明 Go runtime 的只读代码页在进程间共享了。

意外发现的对照数据:

homed 当前(单进程 + 15 个已映射 .so: RSS=2344 MB  线程=108

线程数 108 高于 17 个独立子进程的 84——因为每个 c-shared 映像 都带自己的 sysmon/GC worker塞在同一进程里并不省线程。

7.7 实验 6崩溃隔离

正常调用 → map[ok:true]
发送 boom插件内 panic...
调用侧感知: EOF (耗时 2.515ms)
进程退出码 = 2   ← panic 的标准退出码
宿主进程仍存活 ✅ 崩溃已隔离

plugin_health.go 的接线方案得到验证exec.ExitError.ExitCode() 与 stdio EOF 都能在毫秒级感知,直接喂给现有 recordCrash(plugin) 即可, 3 次/5 分钟窗口、30 秒冷却、pendingReloads 全部逻辑不动。

对照当前 .so 模型bridge 兜不住的 panic 会带崩整个 homed。

7.8 实验 7热重载迁移的原始目标

1) 首次启动插件 → version = v1.0.0
2) 替换二进制为 v2.0.0(同路径)
3) 重启插件进程 → version = v2.0.0
✅ 同路径替换即生效:无 NODELETE、无版本化路径、无线程泄漏

第 1.1/1.2 节的全部问题在子进程模型下自动消失 不需要 .load/plugin-<hash>.so、不需要重载配额、不需要 ELF 标记检测。

7.9 实验 8跨进程并发改写 StageContext最高风险点 3.4

5 个独立进程各 300 轮,通过 RPC 申请内核侧锁,在共享段 append-only arena 上读-改-写同一个 final_text

最终 final_text 长度 = 358
各插件写入次数: map[A:76 B:70 C:73 D:73 E:66]
总字符 = 358, 长度 = 358  → 一致 ✅ 无丢失/无撕裂
RPC 锁操作 = 726 次, 总耗时 213ms

总字符数严格等于最终长度,证明:

  • 没有写丢失lost update
  • 没有撕裂读torn read
  • 5 个进程的修改都被保留且顺序一致

写入次数少于 5×300 是 arena 64KB 上限所致append-only 未实现压实), 符合 3.3 节设计——印证了 arena 需要压实机制,且上限应在 SDK 层显式报错。

风险 3.4 的核心机制得到验证,但仍需注意:本实验验证的是机制正确性 不能替代 sanitizer/multimodal行为回归测试4.4 节风险登记仍然有效)。

7.10 实验 9持锁进程崩溃自愈裁定决策 2 的第二半)

1) 插件 X 拿锁后 panic:
     [X] 获得锁
     [X] 进程死亡(exit status 1),内核强制释放其持有的锁 ← 自愈
2) 插件 Y 随后申请同一把锁:
     [Y] 获得锁
     [Y] 释放锁
✅ Y 正常获得并释放锁 —— 无死锁

这条彻底排除了 robust pthread_mutex 的必要性 锁的所有权在内核进程,插件死亡由 cmd.Wait() / stdio EOF 检测, 内核代为释放。不存在「持锁者死亡导致全局死锁」的场景。

故 3.7 节的选型确定:锁仲裁回归内核,整个架构零 cgo。

7.11 实验 10二进制零拷贝未来多媒体能力

payload JSON+base64 共享内存 加速 体积
100KB 1.21 ms136587 B (+33%) 62.7 µs8 B 19x 100%
1MB 12.41 ms1398155 B (+33%) 554.7 µs8 B 22x 100%
5MB 49.30 ms6990559 B (+33%) 2.72 ms8 B 18x 100%

共享内存对二进制 payload 的价值确认:传输体积从 +33% 降为 8 字节描述符, 处理耗时降低约 20 倍。这是 2.5 节「共享内存价值不在省序列化」的唯一例外—— 对大块二进制它恰恰就是省序列化。

7.12 实验 11工具调用 RPC 延迟

用实测的真实 payload 形态({"city":"hangzhou","days":3,...},约 93 B10000 次:

p50 = 19.497µs    p90 = 25.447µs    p99 = 44.603µs    max = 2.256ms
对照 LLM 单轮往返 2-8 秒 → RPC 占比 ≈ 0.00065%

验证 2.5 节判断:控制面用 JSON-RPC 完全够用,无需为它引入共享内存。

7.13 意外发现homed 当前内存异常(独立问题)

实验 5 的对照测量暴露了一个与迁移无关但值得记录的问题:

homed RSS = 2390432 kB (2.34 GB)
  RssAnon  = 2354764 kB   ← 真实驻留的匿名内存
  RssFile  =   35668 kB
  VmSize   = 26016540 kB (24.8 GB 虚拟)

匿名映射构成:
  2420.0 MB × 1  = 2.36 GB   ← 主 homed 的 Go heap真实驻留
   512.0 MB × 15 = 7.50 GB   ← 15 个插件各自的 heap arena虚拟预留

两点观察:

  1. 512MB × 15:每个 Go c-shared 插件各自 mmap 独立 heap arena 彼此不可见、GC 各自为政。这是虚拟预留(不占物理内存), 但说明当前架构下插件间内存无法协同回收——子进程模型下反而更清晰。

  2. 2.36 GB 真实驻留在主 homed 的 heap 上,与插件无关。 这是独立的内存增长问题(可能是 chat history / context 累积), 不影响迁移评估,但应单独排查

7.14 实验后更新的决策状态

第五章 4 个待定决策的当前状态:

# 决策 状态
1 是否全量迁移 待用户决定(技术可行性已全部验证)
2 跨进程锁选型 已裁定:锁仲裁回内核,零 cgo实验 3+9
3 Extra 处置 维持原建议4 键提升为具名字段(实验 2 验证偏移化可行)
4 权限梯度显式形式 待设计(不阻塞阶段 0-2

7.15 工作量评估的修订

实验结果对第四章的影响:

原评估 修订
3.7 跨进程锁 两方案待选,可能需 cgo 确定零 cgo,规模 M→S
4.2 eventfd 消费 ⚠️ 待实测,风险中 验证通过,风险中→低
3.4 并发扇出 风险 机制已验证,风险高→(行为回归仍需做)
常驻开销 估 50-70MB 实际 29MB,风险项可关闭
4.3 Publish 改造 风险高 收益已量化2218x风险高→中

总量估计从约 10 周下调至约 8-9 周3.7 简化 + 3.4/4.2 风险降低)。

不变的部分:阶段 5 的 17 个插件逐个回归验证仍是 ~2 周,无法压缩。


八、代码检查与实地实验(第二轮)

第七章验证的是新架构可行性;本章检查现有实现的真实语义 并发现了一个先前评估建立在错误前提上的关键事实。

8.1 核心更正:外部插件从未共享过 StageContext

第 2.4 节把「stage 并发扇出改写同一对象」列为约束 A,并据此论证共享内存的必要性。 代码检查表明:该语义只对内置插件成立,外部插件一直是「快照-副本-写回」模型。

完整链路(cabi/loader.go:411-439 + templates.go:768-787

① 内核 case 2 handler
     sc.RLock() → 快照 7~10 个字段为 JSON → sc.RUnlock()
② 跨 ABI 传字符串
③ go_invoke_stage
     sc := &sdk.StageContext{}        ← 插件进程内【全新对象】
     fillStageContext(sc, ctxJSON)
④ 插件 handler 执行
     ctx.Lock() 锁的是这个新对象的 mu ← 无竞争者,纯空转
⑤ stageContextWritable(sc) → Marshal 回传
⑥ applyStageResult(sc, result)
     sc.Lock() → 逐字段写回内核 sc → sc.Unlock()

这是为方便插件加载而采取的无奈之举C ABI 无法传递 Go 对象引用), 但它带来三个先前未被识别的后果。

8.2 后果一:外部插件的 ctx.Lock() 是空操作

sanitizer 的 stage handlerexample/sanitizer/plugin.go:52-90

s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
    ctx.Lock()              // ← 锁的是副本自己的 mu
    defer ctx.Unlock()      //   插件进程内无其他 goroutine 竞争
    for i, tr := range ctx.ToolResults { ... }
})

插件作者按文档正确加锁,但该锁不提供任何跨插件互斥。 锁语义在 ABI 边界上静默失效——插件作者无从察觉。

8.3 后果二:字段可见性被静默裁剪

内核只快照 7 个字段 + 3 个条件字段(loader.go:412-428

raw_message  user_id  group_id  phase  llm_text  final_text  no_memory
+ response(非 nil)  tool_calls(非空)  tool_results(非空)

StageContext 实际有 16 个字段。外部插件永远看不到

ContextMsgs  ReasoningContent  TokenUsage  Memory  Extra  Errors

这解释了 3.3 节的一个疑问——Extra 只有 4 个键且全由内核读写, 因为外部插件根本拿不到它

8.4 后果三(严重):副本模型存在真实的 lost update

read-modify-write 在「快照 → 副本修改 → 写回」链路上非原子。 快照与写回之间的窗口使并发 handler 互相覆盖。

实验 12/tmp/lostupdate/main.go精确复刻上述链路5 插件并发追加标记 × 2000 轮):

  内置插件(共享同一对象)          0/2000 轮出现修改丢失  (0.0%)
  外部插件(快照-副本-写回)      735/2000 轮出现修改丢失  (36.8%)

内置模型零丢失,外部副本模型丢失率 36.8%。

8.5 现网影响面核查

各 stage 的实际注册者:

Stage 注册者 风险
PreAction memo(外部) + webui(内置) 外部写回可能覆盖内置修改
BeforeToolcall qq(外部/own_tools) + webui(内置) + cmd(内置) 同上
AfterToolcall sanitizer(外部/Global) + weather(外部/own_tools) ⚠️ 两个外部插件同 stage
OnInput sanitizer(外部)
PostAction sanitizer(外部)
BeforeOutput webui(内置)

关键点在 stageContextWritabletemplates.go:762

if len(sc.ToolResults) > 0 {
    m["tool_results"] = sc.ToolResults   // ← 无条件回传
}

只要 ToolResults 非空就回传——即使插件根本没修改它weather 的 handler 只做只读打印,但仍会把它收到的快照版本写回内核。

8.6 实验 13现网场景复刻确认脏数据进 LLM

精确复刻「模型调用 weather_query 时 sanitizer + weather 并发跑 AfterToolcall/tmp/lostupdate/real.go3000 轮):

3000 轮中 47 轮清洗结果被覆盖 (1.6%)
⚠️ weather 回传的未清洗快照覆盖了 sanitizer 的清洗结果
→ 脏数据ANSI 转义)进入 LLM 上下文

⚠️ 该比率随机器负载波动:复跑观测到 1.6% ~ 4.3% 区间 (取决于两个插件 handler 的实际执行耗时比)。 应理解为「量级在百分之几」而非精确常数。

现网条件已确认:

sanitizer  v0.1.0  entry=plugin.so  已部署 2026-08-15  未禁用
weather    v1.0.0  entry=plugin.so  已部署 2026-08-15  未禁用
运行日志: [sanitizer] stage OnInput/AfterToolcall/PostAction registered
disabled_plugins: 无

这是一个现存的、可复现的、正在生产环境发生的数据污染缺陷 概率量级为百分之几(复跑区间 1.6~4.3%,取决于两个插件的实际执行耗时比)。

8.7 对迁移论证的影响

先前的论证方向被推翻,但结论被强化。

先前认识 实际情况
共享内存的作用 保持现有并发协作语义 修复副本模型的 lost update
风险 3.4 的性质 高风险:可能破坏正确行为 中风险:当前行为本就是错的
迁移的正当性 热重载 + 崩溃隔离 + 能力对齐 再加一条:修复现存数据污染

原先担心「跨进程改造会让 sanitizer/multimodal 行为漂移」—— 实际上外部插件早已在副本模型下运行,漂移已经发生了。 共享内存 + 内核锁仲裁(实验 8 验证零丢失)是修复而非风险

8.8 派生结论:一个可立即修复的缺陷

8.5 的根因(stageContextWritable 无条件回传未修改字段)不需要等待迁移

最小修复:在 go_invoke_stage 里记录调用前的字段快照,回传时只带真正变更的字段

before := stageContextWritable(sc)      // 调用 handler 前
if err := h(sc); err != nil { ... }
after := stageContextWritable(sc)
diff := changedFieldsOnly(before, after)  // 只回传 diff

这能把 8.6 的污染率降到 0weather 没改 tool_results,就不回传它), 且不改变任何现有插件的代码。

规模 S约 0.5 人日),风险低,收益立即可见。 建议加入阶段 0与三个止血项一并做。

8.9 更新后的阶段 0编号已废弃见 0.3

# 任务 文件 规模
0.1 ELF 检测 DF_1_NODELETE → 标记不可热重载 dynamic_loader_unix.go S
0.2 ReloadOne 返回"需重启",停止假装成功 registry.go S
0.3 plugin_installrestart_required pluginmgr/plugin.go S
0.4 stageContextWritable 只回传变更字段(修复 8.6 的数据污染) templates.go + 重编全部外部插件 S

⚠️ 0.4= plan.md 11.3)需重新编译并安装全部 17 个外部插件bridge 模板变更), 须走 plugindev 正规工具链 + plugin_install 内核接口。

本节编号已废弃,实施请用 plan.md 的 11.1~11.6(对应关系见 0.3)。

8.10 第二轮实验汇总

# 检查/实验 结论
12 副本模型 lost update 率 36.8%(内置模型 0%
13 现网 sanitizer+weather 冲突 1.6~4.3% 脏数据进 LLM
外部插件 stage 语义 一直是副本,非共享
外部插件 ctx.Lock() 空操作,锁语义静默失效
外部插件字段可见性 16 字段中 6 个不可见
stageContextWritable 无条件回传未修改字段8.6 根因)
StageScopeOwnTools 过滤 在 SDK 层包装(sdk/plugin.go:304),不减少并发

九、补盲分析(第三轮)

第八章发现外部插件 stage 是副本模型;本章继续排查此前评估完全未覆盖的区域, 新发现 4 类问题,其中 2 项正在生产环境造成实际故障。

9.1 盲区一插件类型不止两种Lua 路径存在数据竞争

此前全程只讨论 native内置与 cabi.so)两类。实际有四种加载路径

internal/plugin/lua_plugin.go        978 行   ← 完全未评估
internal/plugin/dynamic_lua.go        24 行
internal/plugin/dynamic_dll_windows.go        ← 完全未评估
internal/plugin/dynamic_loader_unix.go

validBinaries 印证了这一点:

var validBinaries = map[string]bool{
    "plugin.so": true, "plugin.dylib": true, "plugin.dll": true,
    "main.lua": true, "SKILL.md": true,      // ← Lua 与 Skill
}

Lua stage handler 同为副本模型,但缺少读锁保护

路径 快照时是否持锁
cabiloader.go:412 sc.RLock() → 快照 → sc.RUnlock()
Lualua_plugin.go:726 直接读 sc.RawMessage 等字段,无锁
func makeStageHandler(...) sdk.StageHandler {
    return func(sc *sdk.StageContext) error {
        plg.mu.Lock()            // ← 锁的是 Lua VM不是 sc
        defer plg.mu.Unlock()
        ctx := map[string]interface{}{
            "raw_message": sc.RawMessage,   // ← 无 sc.RLock()
            "llm_text":    sc.LLMText,
            ...
        }

由于 RunStage 是并发扇出,这与其他 handler 的 sc.Lock() 构成数据竞争 Go race detector 会报 DATA RACEstring header 并发读写理论上可读到撕裂值。

现网影响:当前未部署 Lua 插件(findmain.lua),故暂未触发。 但这是一个已存在的缺陷,一旦部署 Lua 插件 + 任意其他 stage 插件即可触发。

9.2 盲区二Windows DLL 路径能力严重退化

dynamic_dll_windows.go:227-245 的 stage 实现:

ctxJSON, _ := json.Marshal(map[string]interface{}{
    "raw_message": sc.RawMessage,
    "user_id":     sc.UserID,
    "phase":       string(sc.Phase),
})
syscall.SyscallN(p.invokeStage, p.handle, ...)
return nil        // ← 无 resultOut无 applyStageResult

三条路径的字段可见性对比:

路径 下发字段数 写回
Linux cabi 107 固定 + 3 条件) applyStageResult
Lua 10 applyLuaStageResult
Windows DLL 3 完全没有

后果sanitizer 这类改写型插件在 Windows 上静默失效—— handler 正常执行、日志正常打印,但所有修改被丢弃。 且看不到 llm_text/final_text/tool_calls/tool_results 意味着 Windows 上的外部插件基本无法做任何有意义的 stage 处理。

这是跨平台一致性的严重缺口,且没有任何运行时警告。

9.3 盲区三严重cgo 调用不可中断,工具超时永久泄漏

toolcall.go:32-43 的超时保护:

done := make(chan string, 1)
go func() { done <- a.executeToolCallInner(tc) }()
select {
case result := <-done:  return result
case <-time.After(60 * time.Second):
    return "工具执行超时60秒已取消"    // ← "已取消"是不准确的
}

select 超时只是让调用方返回goroutine 仍卡在 C.call_invoke_tool 里。 cgo 调用不可被 Go runtime 抢占或取消——C 函数不返回,该 MOS 线程)永久占用。

实验 14/tmp/feas/hang/,纯 C 死循环 .so20 次卡死调用):

基线 threads=6 goroutines=1
   5 次卡死调用后: goroutines= 6 threads= 9 (+3)
  10 次卡死调用后: goroutines=11 threads=14 (+8)
  15 次卡死调用后: goroutines=16 threads=19 (+13)
  20 次卡死调用后: goroutines=21 threads=24 (+18)

结论: 20 次超时 → 泄漏 20 goroutine, 18 OS 线程

线性泄漏,永不回收。 对照子进程模型(同实验 B 组): cmd.Process.Kill() 后 OS 回收全部资源,零泄漏

现网已在发生(近 14 天日志):

      9 tool browser_screenshot   timed out after 60s
      5 tool browser_render
      2 tool output_send__webui
      2 tool browser_type
      2 tool browser_start
      2 tool browser_click
      1 tool cmd_run / browser_html / browser_fetch
   ─────
     26 次超时  →  推算泄漏约 26 goroutine + 20+ OS 线程

browser 插件是主要来源22/26。这部分解释了 homed 的 108 线程 ——虽然本次运行 9.5 小时内无超时(futex_wait_queue 100 个属正常 Go 调度), 但历史进程(如 homed[1063615]homed[2609279])在超时后必然累积了泄漏。

这是「60 秒超时保护」的语义谎言:日志说"已取消",实际什么都没取消。

9.4 盲区四严重output_send 永远返回成功,模型无法感知发送失败

cabi/loader.go:458-470 的注释直接点明了原因:

s.RegisterOutputChannel(chName, n1, a2, chDef, func(args ...) (interface{}, error) {
    // Output is async: return immediately, send in background
    // to avoid nested cgo calls (cgo within cgo can crash)
    go func() {
        if err := pluginInvokeOutput(pid, chName, string(argsJSON)); err != nil {
            log.Printf("[dispatch] async output %s/%s failed: %v", ...)   // ← 仅日志
        }
    }()
    return map[string]interface{}{"status": "queued"}, nil   // ← 立即返回"成功"
})

「cgo 嵌套会崩」这个 C ABI 限制,逼出了 fire-and-forget 设计。

完整链路(output.go:65-70

模型调用 output_send__qq
  → dev.Execute("output", args) 返回 {status: queued}, err=nil
  → 模型看到: 「已通过 [qq] 通道发送: map[status:queued]」  ← 成功
  → 数十毫秒后 goroutine 里真实发送失败,仅写日志
  → 模型不知道、不重试;用户收不到消息

现网证据(近 7 天):

成功 44 次,失败 2 次

Aug 30 15:10:51 [dispatch] async output qq/qq failed:
  invoke_output qq: meta 中需要 group_id 或 user_id 字段

那一次模型收到的是「已发送」,实际消息从未送达。

这与此前的排查直接相关之前诊断「qq 渠道回复丢失」时修复了系统提示词 (强调 qq 是异步通道、必须用 output_send),但未发现 output_send 本身 永远返回成功。模型即使正确调用了工具,也无法知道是否真的送达。

9.5 子进程模型对这四项的修复能力

盲区 根因 子进程模型
9.1 Lua 无读锁 实现疏漏(非架构) 需单独修;统一走 RPC 后天然有边界
9.2 Windows 退化 三套独立 ABI 实现 单一 RPC 实现,跨平台一致
9.3 超时不可中断 cgo 调用不可抢占 Process.Kill() 真正取消,零泄漏
9.4 output 假成功 cgo 嵌套会崩 可同步等待真实结果

9.2/9.3/9.4 都是C ABI 前提的直接产物——三套 ABI 实现、cgo 不可抢占、 cgo 不可嵌套。这三条在进程边界下全部消失。

迁移的正当性清单更新为 6 条(完整版含依据与临时修复对照见 4.3

  1. 热重载(原始动机)
  2. 崩溃隔离
  3. 能力断层消除
  4. 内置插件解耦
  5. 修复 stage 副本模型的 lost update(第八章)
  6. 修复超时泄漏、output 假成功、Windows 能力退化(本章)

其中 ②③④ 没有临时替代方案,是迁移的不可替代价值;①⑤⑥ 可先打补丁。

9.6 可立即修复项(不依赖迁移)

⚠️ 本表 A-F 编号已废弃,仅存档分组思路。实施请用 plan.md 的 11.1~11.6。 对应关系A→11.1、B→11.3、C→11.4、D→11.2、E→11.5、F→11.6。

按「影响 × 成本」排序:

# 修复 影响 规模 备注
A output_send 改同步等待结果 消除"假成功",模型可重试 M 需绕开 cgo 嵌套:用 channel 把结果从 goroutine 传回并等待,而非在 cgo 栈内嵌套调用
B stageContextWritable 只回传变更字段 消除数据污染8.6 S 需重编 17 插件
C Lua stage 快照加 sc.RLock() 消除潜在 DATA RACE S 3 行改动
D 超时日志措辞改为"已放弃等待(插件仍在运行)" 消除语义谎言 S 1 行;真正取消需子进程
E Windows stage 补齐字段 + 写回 跨平台一致 M 无 Windows 环境验证
F 阶段 0 的 0.1/0.2/0.3ELF 检测等) 消除 reload 误导 S 见 4.1

A 是最高优先级:它直接影响用户可感知的行为(消息发不出去而模型以为成功), 且现网已有 2 次实际发生。

⚠️ A 的实现要点:不能简单改成同步调用(会触发 cgo 嵌套崩溃)。 可行做法是保留 goroutine但用带超时的 channel 等待其结果:

resCh := make(chan error, 1)
go func() { resCh <- pluginInvokeOutput(pid, chName, argsJSON) }()
select {
case err := <-resCh:
    if err != nil { return nil, err }        // 真实失败上报
    return map[string]interface{}{"status": "sent"}, nil
case <-time.After(10 * time.Second):
    return map[string]interface{}{"status": "queued", "note": "发送超时未确认"}, nil
}

这样 dev.Execute 的调用栈不在 cgo 内(它由 executeOutputSendTool 从 Go 侧调起), goroutine 内的 pluginInvokeOutput 才是 cgo 调用——不构成嵌套。 需实测验证不触发崩溃。

9.7 第三轮汇总

# 发现 严重度 现网状态
9.1 Lua stage 快照无 RLockDATA RACE 未触发(无 Lua 插件)
9.2 Windows DLL 只下发 3 字段且无写回 未验证(无 Windows 部署)
9.3 cgo 超时不可中断,线性泄漏 goroutine+线程 已发生 26 次
9.4 output_send 永远返回成功 已发生 2 次
实验 1420 次卡死 → 泄漏 20 goroutine/18 线程 已复现
子进程 Kill 后零泄漏 已验证

十、文档维护说明

10.1 三轮验证的递进关系

目的 方法 主要产出
一~六 建立评估框架 代码阅读 + 推理 目标架构、工作量、待定决策
验证新架构可行性 11 项独立实验(/tmp/feas/ 全部通过;裁定锁选型
检查现有实现真实语义 精读 ABI 链路 + 复刻实验 推翻"约束 A"前提;发现现网污染
补盲:此前未覆盖区域 遍历全部加载路径 + journal 统计 4 类新缺陷2 项现网故障

方法论教训:第八章推翻了第二章基于代码阅读得出的一个核心前提 "stage 并发扇出改写同一对象"对外部插件不成立)。 读到 RunStage 的并发扇出就推断所有插件共享 StageContext 漏掉了 ABI 边界会把引用降级为副本。 后续评估应对每条跨边界路径单独追踪, 不能从进程内语义外推。

10.2 前文中已被后续章节修订的表述

以下位置保留了原始表述并加了指向更正的标注,阅读时请以后者为准:

位置 原表述 更正
2.4 约束 A stage 并发改写同一对象 仅对内置插件成立(第八章)
2.2 代码规模表 只列 native + cabi 实际四种加载路径9.1/9.2
4.1 阶段 0 3 项(仅 reload 语义) 7 项,新增 4 项优先级更高9.6
4.2 总量 约 10 周 约 8-9 周7.15
4.3 正当性 3 条 6 条4.3 与 9.5 已统一口径)
4.4 风险表 eventfd 待实测、开销 50-70MB 已排除 / 实测 29MB
五、待定决策 4 项全待定 2 项已裁定7.14
3.6 eventfd ⚠️ 待实测 已通过7.2
3.7 锁选型 倾向锁仲裁回内核 已裁定7.4/7.10

10.3 实验代码(已固化入库)

全部 18 项实验已固化到 experiments/plugin-arch/ 带一键复跑脚本,不再依赖 /tmp

cd docs/zh/experiments/plugin-arch
./run.sh          # 全部 18 项,约 3-5 分钟
./run.sh 12 13    # 只跑指定实验
目录 主题 对应章节
01-dlclose-nodelete/ dlcloseDF_1_NODELETE 是 no-op3 项) 1.1 / 1.2
02-feasibility/ 新架构可行性11 项) 第七章
03-lost-update/ 副本模型 lost update2 项) 8.4 / 8.6
04-cgo-uninterruptible/ cgo 调用不可中断2 项) 9.3

实现要点:源码均带 //go:build ignore(不进主构建), run.shmktemp -d 内构建(不污染主仓 go.modx/sys 的实验按需拉取(本机走 clash 127.0.0.1:7890)。

复跑验证2026-08-31 全量跑通18/18 通过。

⚠️ 部分数字随调度波动,详见 experiments/plugin-arch/README.md 的 「复跑时的注意事项」——其中区分了「允许波动」与「不应变的断言」。 最重要的一条:8.6 的 1.6% 污染率在复跑中观测到 1.6%~4.3% 区间 应理解为「量级在百分之几」而非精确常数。

10.4 与 plan.md 的分工

  • 本文档:论证、实验数据、架构设计、决策依据 —— 回答"为什么"和"怎么设计"
  • plan.md 第 11 节:可勾选的修复项、文件级改动位置、实施顺序 —— 回答"做什么"

修复项的进度只在 plan.md 维护,本文档不重复勾选状态。