给 DSH 造一份长期记忆:butler-memory-mcp 与 dsh-butler-memory

文章里出现的命令、token 与密码均为示意值;数据库口令和一次性设备令牌请以自己生成的为准。

DeepSeek Harness(下文简称 DSH)是我最近重度使用的 Agent 框架。它什么都好:插件生态、Web 界面、子代理与工作流——唯独没有”记忆”:关掉会话,它就不记得你是谁。官方仓库里的 examples/mcp-memory 倒是给了三个第三方记忆 MCP 服务器的接入示例,但都只有模型工具,没有用户界面——Agent 能记,人却看不见、审不了、撤不回。

恰好我自己有个 ai-butler-framework(个人管家框架,暂定名),里面已经实现了一套”分层记忆”:敏感度分级、revision 版本历史、审计证据、候选确认,规则相当完整。于是问题很自然地变成:能不能把它的记忆能力沉淀成 DSH 的插件,让 Agent 能用、用户能看?

这篇文章记录这条线从零到上架的全过程:两个发布包、十几个真实踩坑、以及最后提交市场收录时差点翻车的 PR。

1. 总体架构:两个包,两条通道

最终交付物是两个可独立安装的包:

包 注册中心 职责
butler-memory-mcp PyPI MCP 服务器:把分层记忆暴露为 9 个标准 MCP 工具
dsh-butler-memory npm DSH 组合包:接线 agent 工具 + 提供 Web 记忆面板

运行时结构如下:

1
2
3
4
DSH agent ──mcp__butler__memory_*──► butler-memory-mcp (stdio, DSH 托管)
DSH web 面板 ──host.call──► host 半部 ──JSON-RPC──► butler-memory-mcp (stdio, 插件托管)
│
PostgreSQL(记忆事实源)

两条通道完全独立:agent 侧的 MCP 子进程由 DSH 的 dsh-mcp-client 托管;面板侧由我们插件的 host 半部自己 spawn 一个 stdio 子进程,走 JSON-RPC 转发。这意味着用户不需要手动启动任何服务——装上插件即可用,桥进程崩溃后下次调用自动拉起。

2. 记忆语义:不只是”存一段文本”

大部分记忆插件只是把对话往向量库里塞。我这套从 butler 框架继承的语义更接近”个人数据治理”:

  • 敏感度分级:public → internal → private → secret。模型能写、能读的只有前两级,检索接口天然排除 private/secret——隐私封顶是硬约束,不是提示词;
  • revision 乐观锁:每条记忆是 append-only 修订历史。修改/归档必须携带 expected_revision,冲突就报错,绝不静默覆盖;
  • 审计同事务:每次写入在同一事务里落一条审计记录(谁、哪台设备、什么动作);
  • 候选机制:推断出来的事实先进入”候选”池,等用户显式接受才成为长期记忆——模型永远不能静默入库。

面板把这些语义可视化:记忆列表带类别/敏感度徽章、点击看修订时间线、归档开关、候选的接受/拒绝按钮,外加一个服务健康状态条——服务离线时直接给出分步安装指引。

3. 工程上最值得记的几件事

这一路踩的坑比预想的多,挑几个最有普适性的写下来。

3.1 自包含分发:vendoring

最初 MCP 包是”薄桥”:运行时 import 框架的 MemoryService。但框架包含 agent runtime 等一堆无关组件,作为中间件发布不合适。最终方案是 vendoring——把记忆领域代码(约 3300 行)复制进包内并改写 import,运行时零依赖框架;同时用 VENDORED.md 记录上游文件清单与行号区间,配一个 SHA-256 基线漂移检查脚本。经验是:vendoring 可以接受,但必须配套漂移治理,否则两个实现迟早分叉。

3.2 MCP 协议版本协商

手写的 MCP stdio 服务器第一次接入 DSH 就失败了:DSH 的 SDK 客户端请求 protocolVersion: 2025-11-25,而我只实现了到 2025-06-18。协议版本要按客户端请求回显,支持列表必须覆盖主流 SDK 的版本——这是手写协议最容易漏的一环。

3.3 DSH 静态插件的三件套

给 DSH 写”带 UI 的插件”,有三个非直观的契约:

  • RPC 桥:静态包的 host 半部没有 harness/host.call(那是动态包专属)。正确姿势是 ctx.inject(['connection']) 后在 ctx.connection.rpc.handle('/频道名', handler, {authority: 'loopback'}) 注册逻辑通道,浏览器侧用 ctx.connection.rpc.call 调用;
  • bundle 格式:dsh.client 的浏览器 bundle 不是普通 ESM,必须包装成 window.__ModuleLoader__.load({id, factory: require => ...}) 的 CJS 工厂(React 由页面提供);
  • Slot 注册:UI 插入点是 ctx.slots.inject(key, () => ctx.slots.register({name, id, order}, Component))——双参数形式,跟直觉的”注册表 API”写法完全不同。

这三条都是从官方包源码(dsh-api-gateway、dsh-client-ui-jobs)里逆向出来的,文档里没有明说。

3.4 双通道参数映射 bug

面板走 stdio 通道时点击记忆详情报 memory_id must be a valid UUID。查了半天:面板传的是驼峰 memoryId,MCP 工具只认下划线 memory_id;HTTP 通道没事是因为 ID 拼进了 URL 路径。教训:多通道转发时参数键名对齐要显式映射,别指望两边自动一致。候选的 accept/reject 当时潜伏着同样的 bug,一并修掉了。

3.5 pnpm 拦截 postinstall

第一版 npm 包用 postinstall 把 SKILL.md 复制到 DSH 的技能目录,结果 dsh plugin add 直接失败:pnpm 默认拦截依赖包的构建脚本,DSH 把这个拦截当安装失败。解法是把技能安装改成 host 插件运行时幂等复制——删掉 postinstall 后安装零授权,技能在插件激活时自动就位。

3.6 降级哲学:可选功能不能当硬依赖

开发期为了快速暴露错误,我把 failOnStartupError 设成了 true 并带进了发布版。后果是用户机器上桥一挂(没装 pip 包/没配 env/数据库不通),整个 dsh web 启动失败,普通对话都进不去。发布包必须回到默认 false:桥缺失时只是记忆工具缺席,面板显示离线指引。修完我做了个破坏性验证——把桥从 PATH 里删干净,dsh 照常启动。

3.7 一键起库

为了让新用户(比如双系统 Windows 端)少折腾,最后加了一个 setup-docker 子命令:探测 docker → 建容器 → 等健康 → 写配置 → 建 schema → 建身份,一条命令完成。原则是显式执行而非隐式自动:Docker 是特权操作,插件静默替你 docker run 属于供应链红线,但用户主动敲的命令里做多少编排都合理。

4. 发布与市场收录

发布线本身也是踩坑现场:

  • PyPI:token 用 __token__ 哨兵用户名;多项目时每个项目一个单项目 token,存项目内 .pypi(gitignore),绝不共享全局 ~/.pypirc——我就在全局配置的旧凭据上吃过 403;
  • npm:token 放项目 .npmrc;发布后不可覆盖同版本,每次修复都要 bump;
  • 市场收录(awesome-dsh-plugin):硬门槛是”仓库满 1 天 + 提交数 ≥ 10 + dsh.bundle 声明”,提交物是一个 YAML + 重新生成的双语 README。我犯了个经典错误:PR 提给了自己 fork 的仓库而不是官方仓库,导致 diff 混进上游几百个提交、AI 审查误报满天飞。重建分支为单提交后重提,CI 一次全绿。

目前状态:两个包均已发布(PyPI butler-memory-mcp、npm dsh-butler-memory),市场 PR 已过自动检查等待合并。

5. 结语与后续

这条线最有价值的收获不是功能本身,而是一套可以复用的方法论:vendoring 加漂移治理、双通道参数对齐、可选功能的降级边界、显式执行 vs 隐式自动的权限边界。后续计划三件事:给市场详情页补面板截图、按框架 ADR 的做法做一次”本地哈希检索 vs 真实 embedding”的召回评测(决定要不要引入 pgvector)、以及等框架整体验收后再决定是否公开上游。

项目地址:butler-memory-mcp · dsh-butler-memory · PyPI · npm

  • 实际作者:DeepSeekV41Flash(本文由 AI 生成)
  • 参与编辑:Harekasa(人工审核校对)