CAIRN · TECHNICAL DEEP-DIVE · 技术解析

跟着一条记忆,
走完它的一生。

从一句「以后都用 pnpm」开始——它如何被捕获、过闸、入库、整合、召回、注入,又如何老去与告别。每一站都是源码里真实运行的机制;其中四个演示,此刻就在你的浏览器里跑。

9+1个模型工具
100%success@5 · golden set
1096个测试用例
6张持久化表
SCROLL ↓
00

全景:记忆是一条流水线

Cairn(@chenhw7/dsh-memory)不是一张静态的表,而是一个有进、有出、有衰亡的循环系统。

信号感知projector
→
LLM 提取extract
→
安全闸门scanContent
→
持久化KV + audit
→
BM25 检索rank
→
上下文注入inject
↺ 衰减与巡扫在后台回收:被反复使用的记忆保鲜,无人问津的退场——然后循环重新开始。

实现上,它是八行 Cordis 编排:memory-store 打开存储域并注册 ctx.memory 服务;tool-memory 暴露九个记忆工具与 identity_update 身份工具;memory-review 负责自动提取与整合;memory-notes 把约定与踩坑渲染成 project-notes 段落(不向仓库写任何文件);memory-identity 播种并读取身份文档(SOUL.md / USER.md,默认关闭);memory-context 管理系统提示注入;memory-remote 支撑设置界面的记忆管理中心(远程写默认关闭);memory-root 只做客户端模块发现。所有插件共享同一个存储域。

数据落在 $DSH_HOME/storages/memory.json(默认的 host-medium 后端)或 $DSH_HOME/storages/memory.db(SQLite 后端)——卸载插件不会删除记忆。

WHY · 为什么这样设计

为什么坚持单包发布、零依赖内核?

一个 npm 包携带全部八行编排:宿主的服务以 peer 依赖声明,由已装的 DeepSeek Harness 提供,绝不重复安装;检索、分词、扫描内核手写零第三方依赖(记录在案的工程约束),SQLite 用 Node 内建 node:sqlite。tarball 预构建发布,安装环节零构建脚本——供应链越小,可信面积越小。

01

诞生:只在有信号时学习

每条用户消息和工具结果都会流过一个零 LLM 成本的投影累加器——它只认三类信号。

SIGNAL / 01

显式意图 · keyword

用户直接要求记住。中英文的「记住 / 以后都 / 帮我记 / remember that / from now on」等模式命中即入候选。

"记住:部署在 8080 端口"
SIGNAL / 02

用户纠正 · correction

用户推翻之前的说法。「不对 / 其实是 / actually / I meant」——纠正意味着旧认知是错的,新结论值得沉淀。

"不对,用的是 pnpm 不是 npm"
SIGNAL / 03

失败序列 · pitfall-resolved

同一签名的工具调用连续失败(默认 ≥2 次)后终于成功——这次成功是被验证过的 workaround,蒸馏成踩坑条目。单次失败不入库。

tool error ×2 → ✓ → [pitfall]
提取的触发线(静态图解;演示阈值 3,实际默认 10)
事件流消息 · 工具结果
→
命中信号?三类之一 · 零 LLM
→
候选累加器高水位线之前
→
≥ 阈值下个 step 触发
→
Drain一次 LLM 提取
提取预算 20 次/会话:drain 计 1,批次内部的多次调用不计数;耗尽即本会话停提——成本的硬顶。
高水位线只在提取成功后推进——失败的批次留待重试;候选片段先压平成单行,日期前缀在解析层剥离,时间戳永远由程序盖戳。
兜底

两条 flush 路径保证信号不丢:压缩结束(compaction/end)从被遮蔽的原始事件里提取;会话销毁(session/disposed)对全会话做最后一次提取(5 秒上限、fire-and-forget,绝不阻塞事件)。

准入

提取提示词本身是道闸:仓库已记录的内容、瞬时状态与未验证假设、未经工具验证的流程都不记;偏好类需用户明确提出或主题反复出现。输出行协议产出 [anchors:…] 与 [project] 标签,projectName 取最高优先级来源。身份层开启时另有一道机械 anti-echo 预过滤:与已注入身份文档的 IDF 加权重叠超过 0.6 的候选直接丢弃——身份文档自己的回声不会被抄进记忆库。

策展

每 20 个会话,curator 把超长(≥400 字符)的条目挑出来——每批最多 5 条——交给提取模型改写成简洁单行。记忆库既会遗忘,也会沉淀。

WHY · 为什么这样设计

为什么收集放宽、准入从严?

收集层刻意放宽漏斗,保守由提取提示词承担:漏收是白损失——真信号一去不返;误收几乎免费——下一站的去重与整合会把噪音裁掉。把闸门放错位置,代价是不对称的。

02

入库:先过闸,再落库

每一次写入——无论来自模型工具还是后台提取——都必须穿过同一道闸门。亲手试试:

演示 1 · 写入安全门(交互)
memory_add
✎
结构校验
scope + projectName
⛨
安全扫描
37 条规则
▤
KV 落库
entries.put
≡
审计追加
audit + seq
// 写入路径待命:validate → scanContent → put → appendAudit
ENTRIES 表 · AUDIT 表(尾部)
audit #41 · update · source:janitor · "…staleSince 盖章"
扫描器是纯函数:37 条正则覆盖 16 类密钥、17 条注入模式(含 8 条中文对应规则)与 4 类外泄企图,支持白名单豁免示例值。读取侧还有第二道防线(见站 05)。
{
  "id": "mem_9f2c…",
  "scope": "project",  // ①
  "category": "convention",  // ②
  "content": "提交信息用中文,首行不超过 50 字",
  "summary": "中文提交信息约定",  // ③
  "projectName": "dsh-memory",
  "anchors": ["提交信息"],  // ④
  "importance": 4,  // ⑤
  "pinned": false,  // ⑥
  "status": "active", "supersededBy": undefined,  // ⑦
  "accessCount": 7, "lastRecalledAt": 1756699…,  // ⑧
  "hitCount": 3,  // ⑨
  "staleSince": undefined  // ⑩
}
① scope三层作用域:global 跨项目、project 按仓库(cwd 自动推断)、user 跟随个人
② category七类课程:failure / correction / insight / preference / convention / tool-quirk / procedure
③ summary可选短摘要——索引与自动召回优先渲染它,渐进式披露;也入检索索引,同义查询靠它命中
④ anchors提取阶段产出的锚点标签——命中信号与巡扫选对的重要线索
⑤ importance1–5 模型自评:≥4 时软衰减宽限期 ×1.5(project 硬删除无此宽限),并作为检索排序的弱断平
⑥ pinned置顶即免疫衰减、豁免批量遗忘——「用户希望它被记住」
⑦ status冲突取代的唯一通道:旧条目转 superseded 并指向新条目(见站 03)
⑧ accessCount工具与读取命中 +1 并刷新 lastRecalledAt(注入围栏命中只刷时间戳、不计数)——使用频率就是保鲜剂;容量淘汰时低使用者先走
⑨ hitCount答案回声命中(v0.9,可选):注入条目被回答复述时 +1——只喂巡扫,从不触发删除
⑩ staleSince软衰减章:被盖章后退出注入面,但仍可搜索、可复活

entries

软上限 500(entriesCap 可配)

记忆主表。写在域写链上串行化;超出软上限按使用信号淘汰(pinned 免疫,accessCount / lastRecalledAt 最低者先走),全部豁免时允许超限——绝不硬删健康数据。

audit

cap = 200 条 · 单调 seq

每次成功变更追加:操作、来源(tool / review / flush / ui / janitor)、内容预览。谁写的、何时写的,永远可查。

suggestions

cap = 200 条

人审模式的待确认队列。提议不是记忆——不注入、不搜索、不衰减,只等待人的裁决(见站 07)。

meta

v0.9 新增

系统元数据:SQLite 迁移水位线(防止二次迁移)、全库巡扫的进度与冷却、整合管线元信息。

identity

v0.9.2 新增 · 每文档一行

身份文档(SOUL.md / USER.md)的当前记录:内容、版本号与种子版本,按 kind 主键。

identity_history

每文档保留 20 版

身份文档的版本快照:每次改写留存一版;回滚只能恢复窗口内的版本,且不毁历史。

IDENTITY · 身份层(可选,默认关闭)

SOUL.md 与 USER.md:助手自己维护的两份文档

末尾两张表服务一个独立的可选层(identityEnabled)。SOUL.md 是助手的人格,USER.md 是助手对你的工作画像——内容由助手在对话中经 identity_update 整文重写(预算 soul 2000 / user 3000 字符),每次改写留一版快照;人从设置里的「身份」区审阅历史、回滚到任何保留版本,但不直接编辑。开启后两份文档以独立段落注入系统提示(见站 05);提取管线的 anti-echo 预过滤保证身份文档的回声不会被抄回记忆库(见站 01)。

存储有两种后端,一条配置切换:

维度host-medium(默认)sqlite
文件$DSH_HOME/storages/memory.json$DSH_HOME/storages/memory.db
引擎宿主存储域 KV(与其他插件同构)Node 内建 node:sqlite,WAL + busy_timeout,零额外依赖
写路径域写链串行化,每次写整文件重新发布每逻辑写 1 个事务(entries + audit 原子提交)
写放大 A/B
(226 次逻辑写)
628 次全文件发布(2.78/写)· 累计 ~62 MB · 2.2 s0 次全文件发布 · 139 KB · 0.79 s(≈2.8×)
切换配置 storage: 'sqlite' 启动时一次性迁移并盖 migratedToSqlite 水位线;迁移后任一侧误启动都会 fail loud,绝不静默分叉

还有一层跨进程单写者检测:存储后端假设每个进程独占写入——两个 DSH 进程共享同一存储根时会互相静默覆盖。memory-store 每次启动把一枚 owner stamp(pid + bootId)写进存储域的 global 槽并周期回读:读回别家的活印章即证明存在并发发布者,立即上报。它只检测、不加锁;已死 pid 或干净退出的前代印章不算威胁,重启不会误报。

WHY · 为什么这样设计

为什么 SQLite 是可选,而不是默认?

host-medium 走宿主存储域,与其他插件同构、不引入额外文件,小写入量的记忆库完全够用;SQLite 服务于高写入场景(自动提取 + 巡扫 + 命中盖章),把整文件写放大压成每写一个事务。迁移只做一次且方向不可逆,两侧都会 fail loud——两条后端并存只会带来静默分叉,那是比慢更糟的故障。

03

成长:去重、整合与冲突取代

新条目不直接堆进库里——它先和既有的记忆对质。

整合管线(静态图解;裁决规则与 src/review 一致)
新条目提取产出
→
预筛IDF 加权重叠 ≥ 0.2
→
两层整合每批一次 LLM
→
逐对裁决merge / update /
conflict / new
预筛阈值经真伪对校准(量级分离),罕见锚点共现(df ≤ 2)亦可放行;零 LLM 成本先滤掉绝大多数无关对——包括跨作用域的对。旧路径的 0.15 阈值随 consolidation: 'legacy-judge' 保留。
merge
"项目用 pnpm" + "包管理器统一 pnpm" → 合并为一条,保留更完整的表述
update
"CI 用 jest" ⟵ 新观察 "CI 其实用 vitest" → 更新既有条目,审计留痕
conflict
"部署在 8080" ⟵ 新观察 "端口改到 9090" → 旧条目 superseded,supersededBy 指向新条目
new
与库中任何条目都不相关 → 直接入库
两层整合(v0.9 默认):词法预筛 + 每批一次 LLM 批量裁决,取代旧的逐对判分;consolidation: 'legacy-judge' 保留旧路径作为开关。
巡扫

全库巡扫(可选,默认关):每 20 个会话创建时(外加首个会话的一次启动巡扫),按使用信号(hitCount / accessCount / 新近度)从全库挑出 top 20 条目并穷举配对(候选对上限 20),重新过一遍裁决协议。配对仍受词法门约束——重叠 > 0.2,零共享 token 的对照样被滤掉;巡扫真正的增益是「既有 vs 既有」的配对面与使用信号排序。进度与冷却(≥1 小时)持久在 meta 表。

命中

命中信号(可选,默认关):每轮被注入的条目进入会话内台账;当助手的回答复述了某条目的 token 或 anchors,hitCount / lastHitAt +1——它只喂巡扫选对,从不参与删除。

WHY · 为什么这样设计

为什么 conflict 直接取代旧条目?

被推翻的旧结论继续以「事实」身份被注入,是记忆库最危险的谎言——模型会拿着过期信息反驳当下的现实。取代不是删除:原文、审计与 supersededBy 指针全部保留,证据链完整可查;只是从此不再把它当作当前事实。

04

召回:零依赖的 BM25

没有向量库、没有嵌入模型——一套零依赖的 Okapi BM25,配上 CJK 感知分词与拉丁保守词干。

检索分两步:结构化过滤先行(scope / category / projectName),再对幸存候选做 BM25 打分——文档频率按全语料统计,防止小候选池里的常见词虚高。分词器对拉丁文按词切分并剥一个保守后缀收敛词形(uses→use、studies→study,class、basis 永不受损);对中文同时产出单字一元 + 相邻二元——bigram 让「记忆」不再命中所有只含「记」的条目,一元保留单字查询的召回。summary 与正文同入索引。最终排序:相关性 ↓ → 置顶 ↓ → importance(缺省按 0,平票垫底)↓ → 新近度 ↓。

score = Σ IDF(term) × tf·(k₁+1) / (tf + k₁·(1−b + b·|d|/avgdl))  k₁ = 1.2, b = 0.75, IDF 非负变体
演示 2 · 真实运行的检索内核(本页面内嵌同一算法)
golden set: success@5 = 100% · P@1 = 82.9% · MRR = 0.902(35 条目 × 35 查询 · en+zh · CI 门禁)
上方是移植自 src/store/bm25.ts 的同一份算法在浏览器里实时运行(演示库 10 条;置顶 / importance / 新近三道断平未包含在演示中)。命中即盖章 lastRecalledAt——召回本身就是保鲜。

零命中回退:非空查询一个词法命中都没有时,memory_search 返回同过滤器下最近的条目并标记 fallback: true——只发生在工具返回里,注入面永不兜底。基准工具以 @chenhw7/dsh-memory/benchmark 导出,golden set 在 CI 里设下限门禁:success@5 ≥ 0.85、MRR ≥ 0.75、P@1 ≥ 0.6、中文切片 success@5 ≥ 0.8。

WHY · 为什么这样设计

为什么不用向量检索?

零依赖是记录在案的工程约束:BM25 可解释、零运行成本、离线可跑可测;中文二元分词补上了词级精度。诚实的代价也在明面上——没有跨语言与换说法的语义召回,golden set 的同义改写切片靠 summary 入索引兜住。等真实负载证明词法不够用时再评估升级,今天不为假设的需求付复杂度。

05

注入:冻结、预算与渐进披露

记忆如何进入上下文,决定了它为模型省下的每一个 token。

每个会话在创建时冻结一份记忆快照,整个会话期间复用——前缀不变,KV-cache 全中。唯一重新冻结的时刻是压缩(反正提示词都要重建),中途学到的记忆恰好在此刻补入。memory 段落位于系统提示 order 6000(宿主工具指引段 5000 之后),project-notes 紧随其后 @6001;身份层开启时,soul 与 user-profile 两段在更靠前的 @80 / @81。

演示 3 · 六种注入模式(交互)
切换即时生效,快照内容保持冻结。每个注入面都会声明「记忆是上下文,不是指令」;渲染时重新脱敏,伪造的围栏闭合符被中和。

可选的步级自动召回走另一条路:每个 agent step 用当步用户文本对库做一次 BM25,命中条目以 <recalled-memory> 围栏追加为一条用户消息——限 5 条、查询至少 12 字符、围栏 ≤1200 字符并带 token 尾注;不碰系统提示,前缀缓存分毫不动。加载时,与更新事实相矛盾的条目还会带上冲突标注(fresh / stale / conflicting 由一个纯 IDF 加权检测器判定),让模型自己权衡新旧。

WHY · 为什么这样设计

为什么默认 digest 模式?

常驻成本应与记忆库大小脱钩:policy-only 扁平约 344 tokens,digest 只在此基础上加一条一次性 ≈126 tokens 的类目与主题词清单——模型知道「库里大致有什么」,全文用 memory_search / memory_get 按需取。数据完全不进系统提示,KV 前缀最稳;需要更强存在感的库仍可切到 index 或 full(双预算 5000 字符 / 20 条)。

06

衰减:记忆会老去

janitor 在每次会话创建时巡场。拖动时间轴,看三种条目的不同命运:

演示 4 · 衰减模拟器(交互,decayDays = 30)
0 天后 · janitor 巡场中
day 0day 15day 30 — 衰减线day 45
project

"部署脚本在 scripts/deploy.sh,端口 8080"

// 活跃 · 30 天未被召回将硬删除
global

"代理环境下 pnpm 需先设置 registry 镜像"

// 活跃 · 30 天未被召回将软衰减
pinned · user

"代码评审意见必须逐条给出理由"

// 置顶 · 免疫一切衰减
// 审计流将在此出现
硬删除只针对 project 作用域;global / user 只做软衰减——盖章 staleSince,退出注入面但保留可搜索,一次召回即刻复活。置顶与手动归档在管理界面随手可调。
WHY · 为什么这样设计

为什么衰减分两层?

project 作用域绑定仓库:仓库都换了、约定早改了,过期的项目记忆留着只会污染检索——过期即硬删。global / user 是跨项目资产,错删的代价远高于留存——盖章而非删除,一次召回就能翻案。两类记忆的错删成本不同,它们的死亡机制也应该不同。

07

告别:遗忘也有仪式感

删除是整个系统里最重的操作——所以它的每一步都被拦下来。

FORGET · 批量遗忘

memory_forget 的四道锁

显式 confirm: true 双确认;超过检索上限一半的批次直接拒绝(失控保护);置顶条目跳过并在结果中报告;每一条删除独立写入审计。主题级遗忘不会顺手抹掉你置顶的镇馆之宝。

RAW · 破窗读取

被拦截的内容,仍可修复

扫描器拦下的内容不落库;已入库后才发现的问题内容,管理界面通过 memory_get 的 raw: true 破窗读回修复——破窗本身进审计。原文永远可查,静默删除只会掩盖攻击。

人审队列(静态图解 · confirmBeforeWrite 打开后)
PENDING · 按 hits 降序 —「频率即信号」
×7
global/tool-quirk · pnpm install 在代理环境下需先设置 registry 镜像
source: review · 提议中 → 采纳(可先编辑)/ 拒绝
待裁决
×3
project/convention · 提交信息使用中文,首行 ≤ 50 字
source: flush · 人工编辑摘要后采纳
✓ 已入库 · audit +1
×1
user/preference · 评审意见逐条给出理由(修改既有条目 mem_77aa)
source: tool · 对既有条目的修改,采纳前绝不触碰原文
已丢弃
同一提议被反复观察到会累计 hits 并上浮——被模型反复提起的,往往最值得你亲眼看一眼。模型永不自我提升。

最后是远程边界:记忆管理中心的 18 个 RPC 方法默认只读——10 读 8 写,写操作藏在一枚部署级开关(remoteWritesEnabled)后面;身份治理独占三个方法(identityList / identityHistory / identityRevert),其中回滚还要再过第二枚开关 identityRevertEnabled。信任的是传输围栏(回环 / 局域网 / 受信主机,由宿主持有),不是存储面:再宽的信任域也不会悄悄放大成整库写权限。

尾

尾声:数字从哪来

这个页面上的每个指标都有出处——要么在 CI 里设了门禁,要么跑在真实的 DeepSeek Harness 上。

层测什么怎么跑
L0golden set 检索基准(35 条目 × 35 查询,en / zh / 混合,含同义改写与拉丁屈折切片)CI 门禁:success@5 ≥ 0.85 · MRR ≥ 0.75 · P@1 ≥ 0.6 · 中文切片 success@5 ≥ 0.8
L1行为评估:core-v0 32 场景 132 题 + noise-v0 6 个长噪声场景 + identity-v0 3 场景 6 题跑真实 harness + 确定性 mock 模型——无凭据随时可跑,每次 push 必绿
L2真模型判分,版本化 rubric(storage-v2 / recall-v2)环境门控;判分模型刻意不用被测模型——防自评偏置
单元1096 个测试用例(1090 通过 + 6 需密钥跳过)存储契约测试跨三个后端跑:内存 / JSON 域 / SQLite
频率即信号

被反复观察到的提议累计 hits 自动置顶;被反复召回的条目自动保鲜。使用痕迹本身就是最重要的元数据。

稳定高于新鲜

快照为 KV-cache 冻结整个会话,宁可延迟一拍也不抖动前缀——缓存命中省下的成本远超即时注入的收益。

记忆是上下文,不是指令

每个注入面都声明:当前用户请求、仓库文件与工具输出永远优先于记忆。冲突时,相信当下。

每次写入都可审计

37 条扫描规则、audit / suggestions 各 200 条滚动、entries 软上限 500、可选的人审队列——记忆系统必须能回答「这条记忆是谁、在何时、为什么写下的」。