
1. 从 WorkBuddy 的定时轮询说起为什么我把 Obsidian 触发改成了事件驱动早期我用 WorkBuddy 接 Obsidian 的时候方案是最朴素的定时轮询每半小时扫一遍库发现文件变更就推一次知识更新任务。这套东西在笔记量小的时候还能忍笔记一多就露馅——刚写完的项目背景要等下一个轮询周期才被识别中间这段时间知识库是过期的更麻烦的是高风险操作没有人工确认环节脚本一跑就是一串自动召回改错了只能事后补。后来我把整条链路换成 DeepSeek Harness 加自写的 TypeScript 监听插件把定时扫描改成文件一变就感知同时把审批卡在触发之前。真正让这套链路能跑起来的其实不是 Harness 本身而是 Key 和 Base URL 这两项配置——我先在 TaoToken 官网 注册并创建了 API Key再把 Base URL 指向https://taotoken.net/api插件里的模型调用才真正通。这篇文章面向的是愿意动手改代码的 Obsidian 插件开发者所以我会把监听器、防抖、内容指纹、frontmatter 校验这四段核心逻辑拆开讲并给出可以直接粘贴运行的 TypeScript 片段和 Claude Code / Codex 两套供应商配置。如果你只是想了解思路读第 1、2、4 节和调用次数对照表就够了第 3、5 节的源码可以跳过。先把结论摆出来这套方案把定时更新变成了按需触发。下面这张对照表是我在自己库里跑了三天之后统计的示例数据你的实际值会随写作频率浮动场景文件事件次数通过防抖通过指纹比对实际触发 Harness 调用单次保存编辑器自动保存 3 次3111frontmatter 缺必填项1110只改了空行和缩进2100一天正常写作40 左右141212从 40 次事件压到 12 次真实调用省下的不是钱虽然确实省 Token而是无效召回把知识库搅乱的概率。2. 工程准备在 TaoToken 取 Key、确认 Base URL并搭好 Obsidian 插件骨架2.1 Key 与控制台的三个入口注册和申请 Key 的流程我已经全部迁到 TaoToken 官网不再走任何第三方面板打开 TaoToken 官网 完成注册进入控制台的 API Keys 页面创建一个新 Key权限按最小可用原则勾选把 Key 复制到一个安全的地方后面插件设置、Claude Code、Codex 都要用到同一个值。如果你还没决定用哪个模型可以先在模型对话页面里试跑一次确认 DeepSeek 系列的某个具体模型 ID 在你账号下可用再把 ID 填进插件设置。2.2 Base URL 到底填什么这是最容易填错的一项单独强调所有走 OpenAI 兼容协议或 Anthropic 兼容协议的工具Base URL 统一用https://taotoken.net/apiCodex 的config.toml里如果走 OpenAI 兼容接口客户端会自己在后面拼/v1所以base_url依然写https://taotoken.net/api不要自己再加一层插件里手动发 HTTP 请求时完整的 endpoint 是https://taotoken.net/api/v1/chat/completions。不要在代码里硬编码 Key。Obsidian 插件的数据是明文存在 vault 的.obsidian/plugins/下的所以下面的骨架里我把 Key 放在设置面板里读取并且只在你自己的机器上填写。2.3 插件工程骨架用官方的 sample plugin 做起点main.ts先搭出一个最小可编译的壳import { App, Plugin, PluginSettingTab, Setting, TFile, requestUrl, Notice, } from obsidian; interface HarnessSettings { apiKey: string; baseUrl: string; model: string; debounceMs: number; requiredKeys: string[]; triggerField: string; } const DEFAULT_SETTINGS: HarnessSettings { apiKey: , baseUrl: https://taotoken.net/api, model: deepseek-chat, debounceMs: 1200, requiredKeys: [project, goal, status], triggerField: auto-update, }; export default class HarnessBridgePlugin extends Plugin { settings!: HarnessSettings; async onload() { await this.loadSettings(); this.addSettingTab(new HarnessSettingTab(this.app, this)); // 监听器注册放在第 3 节 } async loadSettings() { this.settings Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } } class HarnessSettingTab extends PluginSettingTab { plugin: HarnessBridgePlugin; constructor(app: App, plugin: HarnessBridgePlugin) { super(app, plugin); this.plugin plugin; } display(): void { const { containerEl } this; containerEl.empty(); new Setting(containerEl) .setName(API Key) .setDesc(在 TaoToken 控制台的 API Keys 页面创建) .addText((t) t .setPlaceholder(YOUR_API_KEY) .setValue(this.plugin.settings.apiKey) .onChange(async (v) { this.plugin.settings.apiKey v.trim(); await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName(Base URL) .addText((t) t .setValue(this.plugin.settings.baseUrl) .onChange(async (v) { this.plugin.settings.baseUrl v.trim(); await this.plugin.saveSettings(); }) ); } }编译方式用 esbuildesbuild.config.mjs里把obsidian标记为 external 即可这部分和官方模板一致不再展开。装好插件后回到 Obsidian在第三方插件设置里打开它把 Key 填进去。3. 监听器实现vault 事件回调、防抖和 SHA-256 内容指纹3.1 为什么不能直接在回调里干活Obsidian 的vault.on(modify, ...)触发频率远比你想象的高。编辑器自动保存、同步插件回写、其他插件批量改写 frontmatter都会各触发一次。如果回调里直接发 HTTP 请求一次保存可能打出三四个并发调用知识更新 Agent 会被同一份内容反复唤醒。所以监听器要做三件事先收事件、再防抖、最后比指纹。3.2 防抖器自己写一个基于 Map 的防抖器按文件路径分桶互不干扰export class Debouncer { private timers new Mapstring, ReturnTypetypeof setTimeout(); constructor(private readonly waitMs: number) {} schedule(key: string, fn: () void): void { const prev this.timers.get(key); if (prev ! undefined) { clearTimeout(prev); } const timer setTimeout(() { this.timers.delete(key); fn(); }, this.waitMs); this.timers.set(key, timer); } cancel(key: string): void { const prev this.timers.get(key); if (prev ! undefined) { clearTimeout(prev); this.timers.delete(key); } } clearAll(): void { for (const t of this.timers.values()) { clearTimeout(t); } this.timers.clear(); } }waitMs默认给 1200 毫秒。实测低于 800 毫秒时Obsidian 同步插件回写还没结束防抖窗口内又会被打断高于 2000 毫秒则体感上改完很久才反应。1200 是一个折中值你可以在设置面板里调。3.3 内容指纹指纹的作用是判断内容真的变了。不能只看mtime因为元数据同步、编码转换都会改 mtime 但正文没动。这里用 Node 内置的crypto模块算 SHA-256取前 16 位十六进制就够区分import { createHash } from crypto; export function contentFingerprint(raw: string): string { return createHash(sha256).update(raw, utf8).digest(hex).slice(0, 16); }指纹要基于正文而不是整文件。因为 frontmatter 里可能带updated这类每次保存都会变的字段如果把它们算进去指纹永远不同去重就失效了。所以在算指纹之前先把 frontmatter 从原文里剥掉。3.4 事件注册与主流程把上面三块拼起来import { TFile, Notice } from obsidian; const FRONTMATTER_RE /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; private debouncer new Debouncer(1200); private seen new Mapstring, string(); private onVaultChange(file: TFile): void { if (!(file instanceof TFile)) return; if (file.extension ! md) return; if (file.path.startsWith(.)) return; this.debouncer.schedule(file.path, () { void this.handleStableChange(file); }); } private async handleStableChange(file: TFile): Promisevoid { const raw await this.app.vault.cachedRead(file); const stripped raw.replace(FRONTMATTER_RE, ); const fp contentFingerprint(stripped); const last this.seen.get(file.path); if (last fp) { return; // 内容没变直接短路 } const cache this.app.metadataCache.getFileCache(file); const fm (cache?.frontmatter ?? {}) as Recordstring, unknown; if (!this.passesGate(fm)) { return; // frontmatter 不满足放行条件 } this.seen.set(file.path, fp); try { const answer await this.callHarness(file.path, stripped, fm); new Notice(知识更新完成${file.basename}); } catch (err) { console.error([harness-bridge], err); new Notice(知识更新失败详见控制台); } }注册事件的部分放在onload里注意 rename 和 delete 要单独处理避免脏指纹残留this.registerEvent(this.app.vault.on(modify, (f) this.onVaultChange(f as TFile))); this.registerEvent(this.app.vault.on(create, (f) this.onVaultChange(f as TFile))); this.registerEvent( this.app.vault.on(rename, (f, oldPath) { this.seen.delete(oldPath); this.debouncer.cancel(oldPath); this.onVaultChange(f as TFile); }) ); this.registerEvent( this.app.vault.on(delete, (f) { this.seen.delete(f.path); this.debouncer.cancel(f.path); }) );this.registerEvent是必须的否则插件卸载后回调仍然挂在 vault 上下次启用会重复触发。4. 条件放行与调用次数对照让 Harness 只在该更新的时候被唤醒4.1 frontmatter 放行规则不是所有笔记都值得触发知识更新。会议纪要、临时草稿、每日随笔都会频繁改动如果都往 Harness 里灌向量库会被大量噪声污染。我在插件里加了两道闸。第一道是显式开关。只有 frontmatter 里出现auto-update: true的笔记才参与private passesGate(fm: Recordstring, unknown): boolean { const flag fm[this.settings.triggerField]; if (flag ! true flag ! true) { return false; } for (const key of this.settings.requiredKeys) { const v fm[key]; if (v undefined || v null) return false; if (typeof v string v.trim() ) return false; } const status fm[status]; if (status archived || status draft) { return false; } return true; }第二道是必填项校验。我要求项目类笔记至少填齐project、goal、status三个字段。缺任何一个说明这条笔记还没到可以被召回的成熟度直接跳过。这个规则的价值在于把判断权交还给写作时的自己而不是让 Agent 去猜哪些内容该进库。一份能通过闸门的笔记头部大概长这样--- auto-update: true project: payment-gateway goal: 梳理退款链路的幂等设计边界 status: active ---4.2 调用 Harness 实体真正消耗 Token 的就是这一步。注意 endpoint 的拼法以及 Key 从设置里读private async callHarness( path: string, body: string, fm: Recordstring, unknown ): Promisestring { const url ${this.settings.baseUrl.replace(/\/$/, )}/v1/chat/completions; const res await requestUrl({ url, method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${this.settings.apiKey}, }, body: JSON.stringify({ model: this.settings.model, stream: false, temperature: 0.2, messages: [ { role: system, content: 你是知识库更新 Agent。只依据用户提供的笔记正文做增量抽取 输出结构化摘要与需要更新的知识点条目不要臆造未出现的事实。, }, { role: user, content: 文件路径${path}\n 项目${String(fm[project] ?? )}\n 目标${String(fm[goal] ?? )}\n\n 正文如下\n${body.slice(0, 8000)}, }, ], }), }); if (res.status 400) { throw new Error(HTTP ${res.status}: ${res.text.slice(0, 200)}); } return res.json?.choices?.[0]?.message?.content ?? ; }body.slice(0, 8000)是为了控制单次请求的输入体量。超过这个长度建议在前置流程里做分段而不是指望模型一次吞下整篇。4.3 三种去重粒度的取舍我在实现时试过三种粒度最后保留了第三种只按文件路径去重改一次触发一次等于没有去重按mtime去重同步插件回写会刷新 mtime误判率很高按正文指纹去重当前方案只关注内容是否真的不同代价是一次cachedRead几乎无感。如果用指纹方案记得在插件卸载时清掉seen里的条目或者在设置面板里放一个重置指纹缓存的按钮避免某次手动改回旧内容时被误判为没变化。5. 把上游供应商切到 TaoTokenClaude Code、Codex 与 CC Switch 三套配置插件里的 HTTP 调用只是整条链路的一半。如果你的日常流程里还挂着 Claude Code 或 Codex它们也应该走同一个 Key、同一个 Base URL否则账目和限流口径会分裂成好几份。5.1 Claude Codesettings.json ANTHROPIC_*Claude Code 走 Anthropic 兼容协议配置写进~/.claude/settings.json{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_AUTH_TOKEN: YOUR_API_KEY, ANTHROPIC_MODEL: claude-sonnet-4-5 } }要点只有三个ANTHROPIC_BASE_URL用 TaoToken 的https://taotoken.net/apiANTHROPIC_AUTH_TOKEN填你创建的 Key模型 ID 换成你在 TaoToken 控制台里实际可用的那一个。改完重启 Claude Code 生效。5.2 Codexconfig.toml 里的 model_providersCodex 用的是 TOML路径通常是~/.codex/config.toml。不要把ANTHROPIC_*那套搬过来协议不一样会直接报错model_provider taotoken model gpt-5-codex [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY wire_api chatKey 通过环境变量传进去不要在 TOML 里明文写export TAOTOKEN_API_KEYYOUR_API_KEYwire_api按你实际使用的接口形式选本文示例用chat走 Responses 形式的话改成对应值即可。5.3 CC Switch 的三件套如果你在同一台机器上要来回切多套供应商配置用 CC Switch 管理最省事。它的核心是三件套供应商配置文件一份settings.jsonClaude Code或一份config.tomlCodex把 Base URL 和 Key 的环境变量名写死环境变量注入在启动脚本里export对应的 Key一键切换入口让你在官方直连和TaoToken之间切换而不是手动改文件。三件套的关键纪律是同一时刻只激活一套。两个供应商配置同时生效时Base URL 会互相覆盖排障会非常难受。5.4 一份配置同时服务插件与 CLI把插件和 CLI 放在同一套配置下管理能省掉很多对齐成本。我的做法是插件设置面板里填的 Base URL 固定为https://taotoken.net/apiClaude Code / Codex 的配置文件里也是同一个地址所有地方引用同一个环境变量名Key 只在 TaoToken 控制台轮换时改一次。这样当 API Key 需要轮换时你只需要改一处剩下的位置全部自动跟着变。6. 排障清单401、429、重复触发、指纹误判上线之后最容易撞到的问题就那么几个按这个顺序排查效率最高。401 / 403 鉴权失败先确认 Key 有没有多余的引号和空格再确认请求头是Authorization: Bearer YOUR_API_KEY。Claude Code 用的是ANTHROPIC_AUTH_TOKEN而不是ANTHROPIC_API_KEY这两个名字搞混会直接 401而且报错信息不会告诉你是哪一项写错了。404 路径不对插件里如果收到 404八成是 endpoint 拼错了。Base URL 只写到https://taotoken.net/api/v1/chat/completions由代码拼Codex 的config.toml里则连/v1都不要手写客户端会加。429 触发过频先看防抖窗口是不是被调到了 500 毫秒以下再看是不是有笔记的 frontmatter 里status字段在active和别的值之间反复横跳导致每次都比出新的指纹。临时办法是把debounceMs提到 2000同时给callHarness加一个简单的串行队列保证同一时刻只有一个请求在飞。同一份内容被反复触发大概率是seen这个 Map 没有在正确的时机写入。注意我的写法是先把指纹写进seen再发请求。如果反过来请求期间又来了一个事件就会重复触发。反过来也成立请求失败时不要把指纹回滚否则下一轮还会再试一次同样的内容形成重试风暴。rename 之后旧路径的指纹残留如果你把foo.md改名成bar.mdseen里foo.md的条目不会自动消失。事件回调里必须显式this.seen.delete(oldPath)否则改名后再改回来会被误判成内容没变。第 3.4 节的代码已经处理了这一点。Obsidian 移动端不同步移动端插件的crypto模块行为和桌面端不完全一致如果你的库要在手机上打开建议把指纹逻辑抽成一个可替换的函数在移动端降级为长度 首尾各 64 字符的简易比对虽然误判率略高但不会崩。改完不生效Obsidian 插件不用重启应用但在设置里关闭再打开一次插件是必要的否则注册到 vault 上的旧事件回调还在会和新注册的回调叠在一起表现为一次保存触发两次调用。7. 落地顺序建议与下一步如果你准备照着抄一遍我建议按这个顺序推进每一步都能单独验证先在 TaoToken 控制台创建 Key用curl手动测一次https://taotoken.net/api/v1/chat/completions确认 Key 和 Base URL 都对把插件骨架跑起来只加监听和console.log观察一次保存到底触发几次事件加防抖器把事件数压到 1加指纹比对验证只改空行不触发加 frontmatter 闸门验证缺必填项不触发最后才把callHarness接上用小样本跑通再放开全库。这套流程跑顺之后你在 Obsidian 里写完一段就保存下一步动作是自动来的中间不需要你点任何按钮。风险控制靠的是前置的 frontmatter 校验而不是事后的回滚这一点是它比定时轮询更适合长期维护的知识库的原因。想先体验一下调用效果可以直接在 模型对话 页面里发一条消息试试如果你打算把这类自动化任务长期跑在自己的机器上Coding Plan 里对额度与并发有更明确的说明。Key 还没建的直接去 创建 API KeyClaude Code 与 Codex 的完整参数对照可以翻 Claude Code 文档三处配置对一遍链路就闭环了。