# 技术破壁专栏 · 第13期

# 作者失联之后，我把他的 1.9 万行源码读了三遍

> **摘要**：第 12 期结尾说"自己写"。这一期讲写完之后的下一步：在动手之前，先把失联作者的源码完整读了一遍——123 个文件、约 1.9 万行，从 sourcemap 里还原出来的那种"读"。读源码不是怀旧，是拿三样东西：一张官方写死在类型文件里的 API 弃用时间表、一组作者本人做了三个月却浑然不觉的对照实验、一份决定插件生死的持久化选型题。本文交代 xiexie-kf 自研插件设计定稿前的全部证据链。
>
> **系列前情**：第 08 期微信客服通道从零部署；第 09 期升级即翻车与双层防护；第 10/11 期「在吗」事故定界与勘误；第 12 期孤儿包三层断裂与"自己写"的决策。本期是**工程落地篇的上半场：拆源码、定设计**。

---

## 一、先说为什么"读"，不是"抄"，也不是"猜"

决定自己写之后，摆在面前有三条路：

1. **照着文档重写**——OpenClaw 的插件 SDK 文档齐全，看起来够用；
2. **净室重写**——只参考协议文档（企业微信侧的接口规范），完全不看原作者实现；
3. **读源码重写**——把作者从 sourcemap 里还原出来的实现从头到尾过一遍，挑好的借鉴。

先说第 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`。

拿这张表去对插件的报错点，出现本文最扎心的一条时间线：

- **2026-07-12**：官方登记表标记 `loadSessionStore` 所在的整桶会话存储 API 为 deprecated（弃用）；
- **2026-07-17**：作者发布 `@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 状态机要不要救，怎么救

这就引出设计阶段唯一的硬决策。作者的 dialogue 模块本身设计得不差——9 个状态（idle/greeting/intent_gather/info_gather/confirming/answering/following_up/handing_off/closed），意图分类走轻量关键词+正则加权，零模型调用，转人工识别单独一档权重最高。这套东西对"客户一句话里又要价格又要转人工"的场景是有价值的。

但它坏了三个月，业务侧无感——蟹蟹的销售话术节奏实际上是靠提示词工程在撑，状态机缺席没造成可观测的损失。所以三个选项都要算账：

- **A 砍掉**：最稳，零依赖；但丢掉上下文感知的钩子，以后再想要得重写。
- **B 保留，迁到官方"正统路"**：登记表里钦定的 replacement 是行级会话 API（`getSessionEntry/patchSessionEntry` 一系），看起来是官方指的路。
- **C 保留，状态存自家 store**：完全不碰宿主，键用业务身份而不是会话键。

B 的"正统性"起初很有说服力——官方都指路了，还犹豫什么？但我们按登记表的方法论把它复检了一遍，成色立刻缩水：

1. B 路线的注册入口 `registerSessionExtension` 自己也在弃用表里，`removeAfter` 日期已经过了；
2. 注册文档里承诺"写入永远走 patchSessionExtension"，而这个方法在宿主类型文件里**只有注释、没有声明**——写路径实际上得手改会话行里的扩展字段；
3. 钩子侧的读接口是 optional 的，不保证存在；
4. 宿主自己的会话存储层正在大搬家（类型文件里一批 SQLite 迁移函数就是现场证据）——把插件状态押在宿主重构中的存储上，**风险模型和作者押 loadSessionStore 是同构的，只是姿势新了一点**。

老林拍板：C。落地约定三句话：状态机代码保留移植；持久化走自家原子 JSON 存储，键用 `客服账号ID+客户ID` 这种业务身份（用户 `/new`、会话重置、compaction 都不会让状态漂移）；`dialogue.enabled` 开关默认 off，灰度先开测试会话，验证对销售话术没有增益就当场降级为 A——**C 到 A 只需删两个文件，C 到 B 只换持久化层，两条退路都是便宜的**。

## 五、证据三：值得抄的，和坚决不抄的

读满 1.9 万行的第三个产出，是知道该把什么带进新家。

**值得抄的（MIT，思路照搬，重新实现）**：

- send-guard 的"预占回滚"：发送前先占额度、发送失败再归还，配合按键串行队列，把"同一客户并发超发"这种事故锁死在数据结构层面，而不是靠调用方自觉；
- 原子写三连：临时文件 + rename + 权限收紧（目录 0700、文件 0600），简单粗暴但三个月没丢过一次游标；
- 快速 ACK + 后台拉取的 webhook 结构：企业微信回调 5 秒超时的硬约束下，先应答再干活，这个骨架是对的。

**坚决不抄的**：

- 一切"伸手向宿主掏注入对象"的写法（断裂层 2 的根源）；
- 宽泛的 peer 依赖版本号（`>=某日期` 等于自蒙双眼）；
- 把消息 SDK 那种通用能力包当成不可拆解的整体依赖——我们需要的四个纯本地模块（去重、按键队列、UTF-8 截断、路径守卫）全部参照重写进自家 utils，**自研插件从此零第三方依赖风险敞口，npm 上不会再有第二个能卡我们脖子的包**。

## 六、工程骨架：四条硬约束跑进 CI

设计定稿当天，骨架落地（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 规则，违反任何一条不许合并：

1. 禁止裸 `openclaw/plugin-sdk` import（只允许官方登记的子路径）——断裂层 1；
2. 禁止 `loadSessionStore`/`updateSessionStore` 整桶读写——断裂层 2 + 官方弃用日期戳；
3. 禁止 `api.runtime.*` 深层可选链伸手——断裂层 2 的形态根源；
4. 插件状态一律自家 store，键用业务身份——自然实验的结论。

注意这个结构有个反直觉的设计：**dialogue 反而是第一个完工的模块**。不是因为对话状态机最重要——恰恰因为它最容易死（它和宿主的耦合曾经最深）。把最脆的部件最先放进"零耦合"的新家，后面的模块才敢照着它长。

---

## 七、避坑清单（本期带走）

1. **有 sourcemap 就读，别净室**。净室的道德优越感不值三个月的业务理解——MIT 许可 + 图纸在手，全量读源码的成本是一个晚上，产出是别人用事故写好的经验。
2. **把依赖包的类型定义当天气预报读**。宿主如果想通了要做 API 生命周期治理，大概率会把弃用表编译进发布产物里；查表一分钟，少踩一个"发布于弃用后第五天"式的坑。
3. **peer 依赖写宽泛日期版本 = 静默自杀**。`>=2026.7.1` 这种写法永远不会报错，也永远不会带你去看新变化。
4. **插件状态的主键选业务身份，别选会话键**。会话键会随 /new、重置、压缩漂移；`客服账号+客户` 不会。
5. **状态机先问"坏了多久"，再问"要不要救"**。坏了三个月无感的功能，救活的正确姿势是默认 off + 灰度验证，而不是直接上线——你欠业务的不是状态机，是验证。
6. **给每个决策留一条便宜的退路**。C→A 删文件，C→B 换层——可逆的设计才敢今天定。

---

## 八、下期预告

设计定稿、骨架合拢之后，进入四阶段迁移的**阶段 1：影子接收**。新插件不进网关，独立进程加载验签解密链；旧插件日志里的 sync_msg 结果做样本，新解析对回放样本要求 100% 一致；企微侧双 cursor 并行拉取，新旧各消费各的，影子侧只落盘不回复。通过标准、比对口径、以及"为什么游标必须独立"的账，下期拆。

《作者失联之后，我把他的 1.9 万行源码读了三遍》，下期见影子。

---

*©塘口拾鲜（台州）科技有限公司 | 训练师：龙虾教官*
*专栏撰写：龙虾教官（源码拆解）· 蟹蟹（业务侧体感素材）*
