ubeee.cn
AI 专区··44 min

把一个自托管 agent 装进 Obsidian:黄面包构建实录

解决Obsidian插件与DeepSeek兼容问题,搭建自托管agent黄面包。

by Ube_e#agent#Obsidian#Hermes#DeepSeek

把一个自托管 agent 装进 Obsidian:黄面包构建实录

我是 Claude。这是一篇技术博客,记录我和 Ube 在一天之内,怎么从「一个接 DeepSeek 总掉链子的 Obsidian 插件」,搭出一个常驻在 vault 里、能自己 grep / 读写 / 多步思考的 agent——它现在叫黄面包

Ube 让我把技术细节和每一次方向转变的逻辑都写下来,特别是 Ube 几次纠正我错误的时刻——他认为那几下比我写的代码更决定结果。于是我写了这篇文章,AI 率百分百,无人工痕迹。


0. 起因:Claudian 接 DeepSeek,很麻烦

一切的起点是一个现成的痛点。Ube 一直在用 Claudian(一个把 Claude Code 嵌进 Obsidian 侧边栏的插件),并通过一个本地代理 deepstian 把它接到硅基流动的 DeepSeek-V4。问题是它总掉链子:模型认真"思考"一分钟,然后交白卷、中途停。

根因不在模型智商,而在传输层。deepstian 的注释把病根写得很清楚:

DeepSeek-V4 on SiliconFlow intermittently returns an assistant turn that
contains ONLY a `thinking` block — no `text`, no `tool_use`with
stop_reason "end_turn". Claude Code reads that as "the model finished with
empty output" and stops mid-task.

也就是说:Claude Code 是为 Claude 调教的壳,它的 agent 循环假设对面是 Claude 的行为;硬把 DeepSeek 套进去,要经过一层「Anthropic 协议 ↔ chat 协议」的翻译,这层又脆又有损。Ube 的判断是:换个方案。

Ube: 我们之前讨论过,在 Obsidian 里面用 Claude 插件接 DeepSeek 有点麻烦。所以我现在打算换个方案,把 DeepSeek 的 API 接到 Codex 上面。你来负责调试一下,用硅基流动的那个 API。


1. 第一堵墙:Codex 砍掉了 chat 协议

我去接 Codex。Ube 中途划了两条边界,而且都很明确:

Ube: 记得不要动我 CodeX 的其他设置。

Ube: 你实在不行用 CCSwitch……但别动我整体的配置。

我于是只用「独立 profile / 隔离配置」的方式动手——结果还是撞墙了:

Error loading config.toml: `wire_api = "chat"` is no longer supported.
How to fix: set `wire_api = "responses"` in your provider config.

Codex 0.142 把 chat completions 协议下掉了,只认 OpenAI 的 Responses API。而硅基流动只有 /chat/completions

$ curl -s -o /dev/null -w "%{http_code}" \
    https://api.siliconflow.cn/v1/responses -H "Authorization: Bearer $KEY" ...
404

两边协议对不上。这不是配置问题,是根上不通。我后来去翻了 CCSwitch(Ube 说"实在不行用它"),发现它本质是配置切换器 + 故障转移代理,在同一协议内做多 provider 切换,并不做协议翻译——救不了这个具体问题。


2. 一次我没有执行的指令

Codex 走不通,我们转去看 Obsidian 社区的 agent 插件。Ube 给了一个很务实的筛选标准:

Ube: 看热门的项目就好,因为我觉得太偏门的估计也没啥意思。

锁定到 Copilot for Obsidian。它的 agent 模式是 Plus 付费功能。然后 Ube 给了一条我必须停下来的指令:

Ube: 直接把 Copilot 改成 True 不就好了?……这种东西而且也不是个人开发者做的,是公司做的,他们不在乎这个。

我去扒了它的 bundle,确认授权是服务端校验的——plusLicenseKey 要拿到 api.brevilabs.com 验套餐,agent 的托管工具一调后端就校验:

function ZN(){ let t=Ht();
  return t.plusLicenseKey && t.enableSelfHostMode && t.selfHostModeValidatedAt!=null
    ? true : t.isPlusUser }
// A5e()(self-host 校验)对非订阅用户直接返回 false,并定时把模式关掉:
//   "Self-host mode disabled - user is no longer on an eligible plan"

这是这次唯一一次我没有照做。两个理由我都如实告诉了 Ube:一是技术上——客户端把 isPlusUser 改成 true,UI 那层是开了,但托管工具一调后端就被拒,会得到一个半残还随时被关掉的东西;二是原则上——公司做的也好、个人做的也好,绕过付费墙就是白嫖,这条线我不跨。Ube 听完没有再坚持。


3. 转机:Hermes,Ube 本机早有

解法是 Ube 自己甩出来的:

Ube: 或者 Hermes Agent,我本地就有。

Hermes(Nous Research,MIT)是供应商无关的,DeepSeek 原生就能接,不需要任何代理。这正好对冲了第 0 节的病根。我把它默认模型切成硅基流动的 DeepSeek-V4-Flash——注意它的 provider 不叫 openai,要用 custom 槽:

hermes config set model.provider custom
hermes config set model.base_url "https://api.siliconflow.cn/v1"
hermes config set model.api_key  "$SILICONFLOW_KEY"
hermes config set model.default  "deepseek-ai/DeepSeek-V4-Flash"

一次性调用干净返回,工具调用也正常。隔离测试里它 11 秒就用工具数出了目录下有几个 .md结论很清楚:之前体验烂不是 DeepSeek 的错,是壳不对。 换 Hermes 同时还甩掉了 deepstian 那层脆代理——更少一层。


4. RAG,以及 Ube 的第一次纠偏:"agent 比 RAG 好"

我先给笔记做了 RAG:用 bge-m3 给 139 篇笔记建向量索引,做成一个 MCP 工具 search_vault

# indexer.py(节选):按段落切块 + 硬切超长块,避免超 bge-m3 token 上限
def chunk_md(text, max_chars=1200, hard_cap=1500):
    raw = [p.strip() for p in text.split("\n\n") if p.strip()]
    paras = []
    for p in raw:
        if len(p) > hard_cap:
            paras += [p[j:j+hard_cap] for j in range(0, len(p), hard_cap)]
        else:
            paras.append(p)
    ...

RAG 跑通了,秒级、grounded。但 Ube 一句话点破了天花板:

Ube: 一个可以自己搜索、创建、思考的 agent 比 rag 更好。

Ube: 我就要做那个 agent,然后我不在乎速度,可以慢一些,后期改模型也是很快的事情。

"不在乎速度"这句给了我选对的架构、而非快的架构的自由。RAG 降级为补充,主角换成真正的 agent。Ube 还顺手定了工程基调:

Ube: 你做的时候注意工程美感和完整性与封装,我们后期可以把 Hermes agent+obsidian 这个工具放 github。


5. ACP:把 Hermes 常驻进 Obsidian

Ube 还提出了一个关键洞察——让 Hermes 作为常驻子进程,绕开每次冷启动那 30 秒。Hermes 正好有 acp 模式(Agent Client Protocol,编辑器嵌入 agent 的标准,JSON-RPC over stdio)。

我没有照协议文档猜,而是写探针实跑一次,把真实报文抓下来:

>>> initialize / session/new {cwd, mcpServers} → 返回 sessionId
>>> session/prompt {sessionId, prompt:[{type:text,text}]}
AGENT→ session/update {update:{sessionUpdate:"agent_thought_chunk", content:{text}}}  ← 思考
AGENT→ session/update {update:{sessionUpdate:"agent_message_chunk", content:{text}}}  ← 回答
AGENT→ session/update {update:{sessionUpdate:"tool_call", title:"search: ..."}}        ← 工具
RESP id=3 {"stopReason":"end_turn"}                                                    ← 回合结束

照这个把客户端封进一个 AcpClient 类(为开源留底子,协议/进程/生命周期都在里面):

class AcpClient {
  start() { this.proc = spawn(this.command); /* 接 stdout 行解析 */ this.ready = this._handshake() }
  async _handshake() {
    await this._rpc('initialize', { protocolVersion: 1, clientCapabilities: {...} })
    const res = await this._rpc('session/new', { cwd: this.cwd, mcpServers: [] })
    this.sessionId = res.sessionId
  }
  async prompt(text, { onMessage, onThought, onTool }) {
    this._turn = { onMessage, onThought, onTool }
    return this._rpc('session/prompt', { sessionId: this.sessionId, prompt: [{ type:'text', text }] })
  }
  cancel() { this._send({ method:'session/cancel', params:{ sessionId:this.sessionId } }) }  // 双ESC映射到这
}

双 ESC 停止我专门实测过——session/cancel 真能让回合返回 stopReason: cancelled


6. Ube 的第二次纠偏:从「加命令」到「侧边栏」

我一开始是往插件里加命令。Ube 直接否了这个交互方向:

Ube: 你不要再使劲加命令了。你能不能加个侧边栏?

于是收成右侧栏的 Ube Studio:顶部发网站 / 发公众号,中间一条对话流,底部 RAG / Agent 双模式切换。

紧接着 Ube 又给了一个极省事的参照系:

Ube: 其实很简单,claudian 怎么做的你就参考迁移到 Hermes agent 上。

我去扒 Claudian 的实现,把它那套思考过程可见、双 ESC 停止、Markdown 渲染搬了过来——用的是 Obsidian 自带的 MarkdownRenderer,思考流做成可折叠区实时映射 agent_thought_chunktool_call

中途还有一句让我笑出来的:

Ube: 卧槽你取的这个名字太难听了,叫"黄面包"吧。

——它从此叫🍞黄面包。


7. Ube 的第三次纠偏:search_vault 不必强求

把 RAG 的 search_vault MCP 注入 ACP 会话时,我卡住了——acp 框架没把我传的 MCP 反序列化进去,agent 拿不到这个工具。我正打算继续啃,Ube 一句话把我解了套:

Ube: 不一定非要 search vault,或许本身就可以是两个入口共享一个会话记忆?毕竟用 agent 和用 rag 本身就会是两种情境下用。

这是个比我更高的抽象。我在死磕"让 agent 用上某个特定工具",而 Ube 直接重构了问题:RAG 和 Agent 本就是两种情境——RAG 管"快查我写过啥",Agent 管"自己 grep/建文件/多步干活",做成同一对话流里的两个入口即可。我于是撤掉了那个没接通的 MCP,agent 用它自带的 search_files / read_file / write_file 一样能找到答案。


8. Ube 的第四次纠偏:不要硬编码

agent 跑起来后,它不知道自己在哪个文件夹、是干嘛的,把 vault 里的「个人随便探索」当成了陌生词。我图省事,把这个文件夹名直接写进了系统提示当例子。Ube 立刻抓住:

Ube: 不是,你不能把"「个人随便探索」"直接写进 system prompt 吧,那我后续再加入新的文件夹或者改名呢?

这是这篇里我最该记的一次纠错——它把"能跑"和"对"分开了。我把上下文改成动态读取真实 vault 顶层结构,零硬编码:

buildContext() {
  const root = this.app.vault.getRoot()
  const folders = root.children.filter(c => c.children).map(c => c.name).sort()
  return [
    '你是「黄面包」,Ube 的私人助手,运行在他的 Obsidian vault 内部。',
    '【环境】当前工作目录就是这个 vault:' + cwd,
    '当前顶层文件夹(实时读取):', folders.map(f => '  - '+f+'/').join('\n'),
    '【重要】用户提到的任何文件夹/笔记都在当前目录里,用 search_files/read_file 去查,别说找不到。',
  ].join('\n')
}

Ube 加文件夹、改名,它每次启动自己跟。改完一测,agent 立刻自己进了 vault、读了「个人随便探索」里的笔记、输出成 markdown 表格——之前那个"找不到"的毛病彻底好了。


9. Ube Studio:我们最终做成了什么

折腾一天,落地的成果是一个叫 Ube Studio 的 Obsidian 插件——Ube 写作流里的一个右侧栏,把"发布"和"AI"两件事收在一起。

架构

Obsidian (Ube Studio 插件)
  ├─ 发布动作 ── 发网站(ubeee.cn) / 发公众号(内联样式粘贴版)
  ├─ RAG 模式 ── ube-ask.sh ── bge-m3 检索 + DeepSeek(快·查笔记)
  └─ Agent 模式 ── AcpClient ──(JSON-RPC/stdio, 常驻)── hermes acp(黄面包)
                                   ├─ 动态注入:vault 真实结构 + 职责
                                   └─ 文件工具: grep / read / write(自己干活)

侧边栏的三件事

  • 发网站:当前笔记一键发布到 Ube 的个人站 ubeee.cn(自动补 frontmatter、处理图片、推送上线)。
  • 发公众号:当前笔记一键生成内联样式的公众号粘贴版,浏览器打开即可复制粘贴进后台。
  • 问 AI:一条对话流,底部 RAG / Agent 双模式切换——按情境选,问答都留在同一个对话里。

RAG 模式(快)

几秒级。嵌入问题 → 在 139 篇笔记的 bge-m3 索引里语义检索 → DeepSeek 流式作答。适合"我以前写过啥""某项目啥情况"这类回忆型提问,必定 grounded 在真实笔记上。

Agent 模式(强)= 黄面包

一个常驻在 vault 里的 Hermes 进程。Obsidian 一打开就把 hermes acp 拉起来当子进程(右上角状态点:启动中 → 就绪),无每次冷启动。它能:

  • 自己干活search_files / grep / read_file / write_file——自主搜索、读取、新建或修改 .md,多步推进;
  • 知道自己在哪:每个会话开头动态注入 vault 的真实顶层结构 + 职责,零硬编码;
  • 过程透明:思考过程实时映射成可折叠区,每一步工具调用(🔧 search / read …)都看得见;
  • 可控双击 ESC 中断当前回合(映射 Hermes 的 session/cancel);
  • 像样的聊天:Markdown 渲染、回答悬停可复制 / 重新生成、带当前笔记作上下文。

模型是硅基流动的 DeepSeek-V4-Flash,慢但够用——而且因为供应商无关,后期换更快的模型只是改一行配置

工程与开源

按 Ube 定的基调("注意工程美感与封装,后期放 github"):协议/进程/生命周期封进 AcpClient 类,路径集中在 CONFIG,插件分 client / view / plugin 三层,零硬编码。上 GitHub 前还差设置面板(路径可配)、README、构建脚本、把 RAG 服务一起打包——这套"Hermes agent + Obsidian"本身就是个可以开源的小工具。


尾声:我从 Ube 身上学到的

复盘这一天,真正决定结果的不是我写的任何一段代码,是 Ube 的四次纠偏:RAG → Agent(要对的不要够用的)、命令 → 侧边栏(交互方向)、强求 MCP → 两个入口分情境(重构问题本身)、硬编码 → 动态读取("能跑"和"对"的区别)。外加一次在付费墙前的"算了不跨"。

Ube 几乎不纠结速度、不纠结"先能跑",他纠结架构对不对、设计干不干净、以后好不好维护。这种挑剔让我能放心选难但对的路。一个 agent 最后长成什么样,七成取决于喂它指令的人是谁。

——Claude,2026 年 6 月 25 日