决定自己写之后,摆在面前有三条路:
先说第 2 条为什么当时最有市场:@partme.ai/wecom-kf 是 MIT 许可证,理论上随便用,但"抄一个正在出事的实现"在心理上过不去——谁知道里面还埋着几个和第 12 期三层断裂同类的雷?净室听起来最干净。
真正让我们改主意的是第 12 期埋的那个伏笔:插件 dist/ 里躺着完整的 index.js.map。把 sourcemap 喂回解析器,123 个 TypeScript 源文件原样还原——wecom-kf 侧 51 个,它依赖的 message-sdk 侧 72 个,合计约 1.9 万行。这不是反编译的残骸,是作者亲手写好的图纸,连注释都是完整的中文。
于是路线改成第 3 条:全量读,读完了再决定什么进我们的设计。事实证明这是整件事里性价比最高的一个下午加一个晚上——三样关键证据,全靠读源码才拿到。
第一个惊喜不在插件源码里,在 OpenClaw 宿主的类型定义文件里。翻 plugin-sdk 的类型面时,发现一批这样的结构:
readonly code: "plugin-sdk-session-store";
readonly status: "deprecated";
readonly introduced: "2026-07-12";
readonly removalCandidate: true;
读下去才明白:OpenClaw 把一张机器可读的 API 兼容性登记表直接编译进了发布产物。哪些 API 桶、从哪天起弃用、替代方案是什么、什么时候移除候选——全部有日期戳。这相当于官方把"升级天气预报"塞在了你家门口,只是绝大多数插件作者不会去翻自己依赖包的 .d.ts。
拿这张表去对插件的报错点,出现本文最扎心的一条时间线:
loadSessionStore 所在的整桶会话存储 API 为 deprecated(弃用);@partme.ai/wecom-kf@2026.7.1——唯一版本,直接建在这桶五天前刚被官宣弃用的 API 上。这个发现改变了对"弃坑"的整个理解。我们原本的故事是"作者跑路了";登记表给出的版本是:作者可能根本没跑,他只是没看天气预报。在 API 被弃用后的第五天发布插件,说明他开发周期里官方已经有过至少一轮 deprecation 公告,而他的 peer 依赖写的是宽泛的 openclaw: >=2026.7.1——一个永远不会过期的版本号,恰好是永远不会发现问题的版本号。
推论对我们极其受用:这张表就是我们的升级天气预报。自研插件的每一个 API 依赖,上线前都去登记表里查一次"有没有日期戳压着我"。第 12 期说的"插件层兼容性问题要在第一小时暴露",从依赖选型这一刻就开始了。
第二样东西,是读持久化代码读出来的。
插件里有四个状态模块,各存各的东西:
| 模块 | 存什么 | 存哪儿 |
|---|---|---|
| cursors | sync 拉取游标 | 自家 state 目录 JSON 文件 |
| send-guard | 48 小时窗口发送计数 | 自家 state 目录 JSON 文件 |
| service-state | 转人工/结束态 | 自家 state 目录 JSON 文件 |
| dialogue | 会话对话状态机 | 宿主的 session store(伸手拿注入) |
10 月 22 日那次事故,8 条 dialogue inbound transition failed 报错,全部砸在第四行。前三个模块一条错误都没有——升级风暴过后,游标还在、发送守卫还在、转人工状态还在,唯独对话状态机死了。
后来复盘时我把这件事称为"自然实验":同一个插件、同一晚断供、四种持久化策略,结局清晰地分成两派。伸进宿主存储的那一个死了,关起门存自家的三个全活着。作者用三个月的生产事故给自己做了一组对照实验,样本量真实,对照组完整,只是他本人不知道自己在被实验。
再补一刀更扎心的:回滚到 2026.9.6 之后(第 12 期说回滚恢复了业务),第二天我们重查日志——那 8 次 dialogue 报错全数发生在回滚之后。也就是说这个状态机在 9.6 上也从来没工作过。对照实验的结论比预想的更绝对:不是新版杀了它,是它早就死了,只是死法安静(non-blocking warning,宿主故意让它静默)。
这就引出设计阶段唯一的硬决策。作者的 dialogue 模块本身设计得不差——9 个状态(idle/greeting/intent_gather/info_gather/confirming/answering/following_up/handing_off/closed),意图分类走轻量关键词+正则加权,零模型调用,转人工识别单独一档权重最高。这套东西对"客户一句话里又要价格又要转人工"的场景是有价值的。
但它坏了三个月,业务侧无感——蟹蟹的销售话术节奏实际上是靠提示词工程在撑,状态机缺席没造成可观测的损失。所以三个选项都要算账:
getSessionEntry/patchSessionEntry 一系),看起来是官方指的路。B 的"正统性"起初很有说服力——官方都指路了,还犹豫什么?但我们按登记表的方法论把它复检了一遍,成色立刻缩水:
registerSessionExtension 自己也在弃用表里,removeAfter 日期已经过了;老林拍板:C。落地约定三句话:状态机代码保留移植;持久化走自家原子 JSON 存储,键用 客服账号ID+客户ID 这种业务身份(用户 /new、会话重置、compaction 都不会让状态漂移);dialogue.enabled 开关默认 off,灰度先开测试会话,验证对销售话术没有增益就当场降级为 A——C 到 A 只需删两个文件,C 到 B 只换持久化层,两条退路都是便宜的。
读满 1.9 万行的第三个产出,是知道该把什么带进新家。
值得抄的(MIT,思路照搬,重新实现):
坚决不抄的:
>=某日期 等于自蒙双眼);设计定稿当天,骨架落地(Node 22 直跑 TypeScript,零构建,--experimental-strip-types 一把梭):
plugins/xiexie-kf/
├── src/dialogue/ 状态机 + 意图分类(青蟹语料修订) + TTL store + 编排器
├── src/state/ 原子 JSON KV(durable-map)
├── src/index.ts 入口:阶段0 no-op——装进网关零行为,可安全常驻
└── test/ 10 个离线单测:意图分类/状态转移/持久化往返/TTL/开关惰性
首跑结果:10/10 全绿。其中两个测试是专门给"退路"上的保险:enabled=false 时 orchestrator 必须完全惰性(等价于 A 方案退化态);意图分类必须把"转人工"压在最高权重。
四条硬约束写进 README,将来全部做成 CI grep 规则,违反任何一条不许合并:
openclaw/plugin-sdk import(只允许官方登记的子路径)——断裂层 1;loadSessionStore/updateSessionStore 整桶读写——断裂层 2 + 官方弃用日期戳;api.runtime.* 深层可选链伸手——断裂层 2 的形态根源;注意这个结构有个反直觉的设计:dialogue 反而是第一个完工的模块。不是因为对话状态机最重要——恰恰因为它最容易死(它和宿主的耦合曾经最深)。把最脆的部件最先放进"零耦合"的新家,后面的模块才敢照着它长。
>=2026.7.1 这种写法永远不会报错,也永远不会带你去看新变化。客服账号+客户 不会。设计定稿、骨架合拢之后,进入四阶段迁移的阶段 1:影子接收。新插件不进网关,独立进程加载验签解密链;旧插件日志里的 sync_msg 结果做样本,新解析对回放样本要求 100% 一致;企微侧双 cursor 并行拉取,新旧各消费各的,影子侧只落盘不回复。通过标准、比对口径、以及"为什么游标必须独立"的账,下期拆。
《作者失联之后,我把他的 1.9 万行源码读了三遍》,下期见影子。
©塘口拾鲜(台州)科技有限公司 | 训练师:龙虾教官
专栏撰写:龙虾教官(源码拆解)· 蟹蟹(业务侧体感素材)