工程手记

让模型决定分类,但不让它碰文件系统

设计一套通过微信私聊向 Claude 发送内容,自动分类、起标题、格式化后落入本机 Obsidian 的管线,核心是让 Claude 只做决策,Node 确定性写盘。

在微信里记笔记,内容要进 Obsidian 知识库,手工转换既慢又容易出错。写一个 Obsidian 插件把格式规则固定化,也无法适应微信和公众号笔记五花八门的样式。我决定把 Claude 加进来:收消息→分类→起标题→格式化→落盘,再回执通知。

核心架构:Claude 决策,Node 写盘

首先明确职责边界,这是避免混乱的关键。

Claude 的工作是纯决策:给定一条消息和分类表,输出结构化结果——归入哪个分类、建新文档还是追加到现有文档、标题是什么、标签列表、正文格式化后长什么样。结果都用 JSON 表示。

Node 的工作是确定性执行:接收 Claude 的决策结果后,负责所有文件读写——新建文档、追加到已有文档、处理图片、管理去重、并发序列化。不让 Claude 直接操盘文件系统

这个分工的好处:

  1. 风险隔离:LLM 的输出有概率不确定(分类可能不准),但文件操作必须确定。如果 Claude 直接写盘,分类抖动可能污染已有文档、重复建文件、覆盖历史记录。现在坏决策最多只会建错新文件,不会改坏既有内容。
  2. 幂等与并发:并发来的消息按消息 ID 去重,串行通过写入队列写盘。没有 LLM 的非决定性卡在关键路径上。
  3. 可测试性:Claude 的逻辑用假实现隔离测试,vault-writer 的逻辑用临时目录单测。

数据流与核心组件

单条消息的完整生命周期:

  1. listener:WeChatFerry 监听主号私聊,提取消息ID、文本、图片,构造 Capture 对象。
  2. dedup:msgId 按已处理集合去重(持久化到 processed.json,防 WCF 重发导致重复落盘)。
  3. classifier:遍历 vault 列出已有文档按分类分组,把消息文本+分类表描述发给 Claude。Claude 一次性决定归类、action(new|append)、新标题、标签、正文。失败或超时降级为 00-Inbox 原样保存。
  4. vault-writer:按决策新建或追加文件。图片从临时区移到 vault 附件目录。所有操作走串行队列,天然排斥并发冲突。
  5. receipt:回执主号:"✓ 新建 → 01-技术,标题:Redis 锁续期"。

失败处理分两层:

  • 第一层:classifier 异常→降级到 Inbox 分类,原消息文本作为 body。
  • 第二层:vault-writer 也失败→回执错误信息,原消息不丢(至少进了 dedup)。

Claude 分类的提示词设计

Claude 的 prompt 包三部分信息:

【可选分类】
- 01-技术: 技术笔记、踩坑、方案、命令、代码片段
- 02-项目: 具体项目的记录、进展、待办
...
- 00-Inbox: 兜底:拿不准归哪类的进这里

【各分类下已有文档标题】
- 01-技术: Redis 分布式锁 | OTA 增量包大小优化 | Python 装饰器
- 02-项目: Q2 KPI 指标 | 客户需求评审
...

【规则】
1. 只能选上面列出的分类 key
2. 若与某个"已有文档标题"主题相似且确信,用 action=append,targetFile 填该标题;否则 action=new
3. 拿不准就 00-Inbox + action=new
4. 只输出一个 ```json 代码块,字段:category, action(new|append), targetFile, title, tags(数组), body

关键设计点:

  • 传已有标题清单而不是全部文档:vault 大了之后这会撑爆 token,后续优化改向量检索 Top-N 候选。v1 库空,列全量可接受。
  • action=append 时 targetFile 必须来自该分类的已有标题清单:确保 append 不会创建新文件。如果 Claude 编造了不存在的标题,validator 改回 action=new。
  • 拿不准就 new:错建新文件<错污染已有文档。

文件写入的不可变原则

vault-writer 的核心逻辑:

新建

路径: <vault>/<category>/<安全标题>.md
内容:
---
title: Redis 分布式锁续期方案
category: 01-技术
tags: [redis, 分布式锁, 并发]
created: 2026-06-13 14:30
source: wechat
---

<Claude 格式化后的正文>

![[20260613-1430-image.png]]
  • 文件名去除 Windows 非法字符、限长 80 字。
  • 重名加紧凑时间后缀(Redis 锁-1430.md),不覆盖。

追加

原内容 <不改一字>

## 14:30 追加

<新增内容>

![[img2.png]]

只在文末加小节,绝不改写旧内容。追加同样走串行队列,多个消息对同一文档的追加不会交错。

图片处理

从临时区移到 <vault>/99-附件/<紧凑时间戳>-<原文件名>.png,正文用 wikilink 引用 ![[20260613-1430-image.png]]。Obsidian 自动刷新并识别嵌入。

去重与幂等

WCF 有重发行为,msgId 相同的消息可能来两次。processed.json 记录已处理的 msgId:

["msg_id_1", "msg_id_2", "msg_id_3"]

listener 每条消息来时检查,命中过则忽略。标记操作在最后,确保消息全部处理成功才记录。

这设计下,网络抖动导致的消息重发、甚至小号掉线重连后的历史消息再次推送,都不会重复落盘。

中转站密钥隔离

Claude Code CLI 的调用走 spawn 子进程,环境变量注入:

const env = {
  ANTHROPIC_BASE_URL: config.baseUrl,      // https://key.agtk.cn
  ANTHROPIC_AUTH_TOKEN: config.authToken,  // 用户自注册的 key
};
await runCommand(nodePath, args, { timeoutMs, env });

.env 文件里存关键数据:

ANTHROPIC_BASE_URL=https://key.agtk.cn
ANTHROPIC_AUTH_TOKEN=sk_xxxx

.gitignore 排除 .env,不进代码库。自包含发行包也不带任何密钥——用户在 setup 时从 key.agtk.cn 自己取 key 粘贴,由客户自付费用。

这样做的风险隐含:内容会经第三方中转站再到 Claude(而非直连 claude.ai)。setup 里明确风险告知和确认。

配置的参数化

三个配置文件:

config.yaml:业务配置

vault_path: "D:\\Obsidian\\MyVault"
attachments_dir: "99-附件"
owner_wxid: "wxid_main"           # 首条私聊自动绑定
model: "claude-opus-4-8"          # 用户选择
request_timeout_ms: 30000

categories.yaml:分类表,改它即改 Claude 的分类依据

- key: "01-技术"
  desc: "技术笔记、踩坑、方案、命令、代码片段"
- key: "02-项目"
  desc: "具体项目的记录、进展、待办"
...

.env:敏感信息(密钥、中转站地址)

ANTHROPIC_BASE_URL=https://key.agtk.cn
ANTHROPIC_AUTH_TOKEN=sk_xxxx

三层分离使得:业务逻辑能复用,分类策略能快速迭代(改 YAML 即可),敏感信息不泄露。

自包含发行包

最终产物是一个 zip,无需用户装 Node、不连公共 npm 源:

obsidian-weix-0.1.0.zip
├─ node\node.exe              (便携 Node)
├─ app\
│  ├─ src\                      (核心代码)
│  ├─ node_modules\            (预装依赖)
│  └─ templates\               (配置模板)
├─ install.bat
└─ start.bat

用户下载、解压、双击 install.bat→setup 向导→粘贴 key→输入 vault 路径→风险确认→生成配置。然后双击 start.bat 启动。

构建机在 Windows x64 编译,执行 npm install(得到目标平台的 native 产物),打包后上传到项目专用 CDN 分发。

为什么不是 Obsidian 插件

Obsidian 插件跑在浏览器沙盒里,无法直接调 WCF、spawn CLI、管理进程生命周期。插件能做的是提供 UI 让用户手工输入内容,或者轮询本地文件监听外部写入。

相比之下,独立 Node 进程的好处:

  1. 常驻内存,实时监听 WCF 消息事件。
  2. 能 spawn Claude Code CLI 并注入中转站密钥(需要环境变量隔离)。
  3. 确定性串行写盘,无沙盒限制。

代价是多了一个进程,但换来的是解耦和可靠性。

工程检查清单

这套管线从设计到代码,需要覆盖的点:

  • 单元测试:文件名净化、时间格式化、Markdown 渲染、去重、分类降级、append 格式。
  • 集成测试:classifier 对中转站打桩;vault-writer 写临时目录校验产物;并发投递验证队列串行。
  • 幂等性:msgId 去重持久化,重发消息不重建文件。
  • 并发安全:写入队列保证串行,追加操作不交错。
  • 失败降级:Claude 超时 30s 后重试 1 次,仍失败降级 Inbox;vault-writer 失败也降级。
  • 脱敏:密钥进 .env 不进代码库,配置项参数化。

写盘前必须问清自己:如果这条消息落地失败了,会不会丢掉?不会——最坏情况进 00-Inbox 原文保存。如果 Claude 分类抖动,会改坏其他文档吗?不会——append 时校验 targetFile 存在性,不存在改成 new。

这些约束叠加起来,保证了管线的韧性。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。