dsh-memory-self-evolution 设计指南

August 31, 2026 · View on GitHub

一个为 DeepSeek Harness(DSH)设计的自进化长期记忆插件。它在每轮对话结束时自动挖掘值得沉淀的意图,以「置信度」量化一条记忆的可靠程度,让记忆随使用频率自然生长、随闲置时间缓慢衰减。

1. 核心设计目标

  1. 自动收集:不需要用户手动整理,每轮对话结束自动从会话里挖掘可沉淀的偏好、规则、习惯、项目事实。
  2. 语义去重:重复提到的同一件事只强化一次,而不是反复新建;主题相近但场景不同的(如「用中文回答」vs「沉淀记忆用中文」)保持独立、不强行合并。
  3. 置信度演化:置信度反映「被提及的频率 + 表达明确度」,而非一个静态分数。
  4. 低成本注入:用户级记忆默认全量注入;项目级记忆按需路由,只在任务相关时才读取,避免无谓消耗上下文。
  5. 可遗忘:久未观测的记忆随时间降权,但从不自动删除——删除永远由用户显式确认。

2. 架构总览

插件是双端结构:

文件运行环境职责
Host 半边lib/index.jsDSH Node 进程记忆的存储、挖掘、置信度演化、注入、memory_read 工具、HTTP RPC
Host 辅助lib/embedding.jsDSH Node 进程本地 nomic-embed-text 向量化,用于语义召回(第 10 节)
Client 半边lib/client.js浏览器页面三列看板 UI、「记忆」tab、-- 记忆选择弹窗
  • cordis.patch.yml:把一个 insert 行插入 profile 组合,加载包的 host 半边。
  • package.jsondsh 字段声明了 bundle.patchclient./client 导出浏览器半边)。
  • 安装方式:dsh plugin --profile <name> add dsh-memory-self-evolution

2.1 存储布局

记忆落在 sandboxPolicy.workspaceRoot + '/.dsh-memory/'(通常是 ~/.dsh-memory/):

.dsh-memory/
├── tags.json           # 标签数组(含默认标签)
├── <标签名>.jsonl      # 每个标签一个文件,每行一条记忆 JSON(主存储)
├── candidates.json     # 各 session 的「待确认候选」列表
├── embeddings.jsonl    # 向量索引(每行 {id, v:[...]},768 维,辅助索引)
└── memory.md           # 生成的注入用 markdown(由代码生成,勿手改)

jsonl真相源(主存储),embeddings.jsonl 只是辅助索引——用于「语义召回」,不替代 jsonl。所有读记忆的路径(注入、memory_read、UI)始终读 jsonl。

3. 记忆数据模型

每条记忆(jsonl 里的一行)字段:

字段说明
id唯一 ID
text一句清晰可执行的话(强制简体中文)
confidence原始置信度(可 > 1,见第 4 节)
categorypreference / rule / project / habit / feedback
evidence简短佐证(原文引文或理由)
tag所属分组(标签)
observations观测次数(被强化多少次)
firstSeen / lastSeen首次 / 最近一次观测时间
deprecated是否已进入低置信度区间(仅标记,不自动删)
sessionId / createdAt来源会话 / 创建时间

标签分两类:

  • 用户级(默认注入):如「编码规范」「用户习惯」——每次会话默认全量 inline 进 system prompt。
  • 项目级(按需路由):标签以「项目背景」开头,如「项目背景-输入输出模块」「项目背景-液态玻璃」——只在任务相关时按需读取。

4. 置信度演化模型

置信度回答一个问题:这条记忆有多可靠、多值得被优先遵循?

4.1 初始置信度

新记忆沉淀时:

confidence = max(0.5, LLM 给的明确度分)
  • LLM 在挖掘时会给一个 0~1 的「明确度分」(用户表达得越直接越高)。
  • 下限 0.5:即便模型给分很低,只要用户确认沉淀,也至少有中等置信度。

4.2 强化(观测累积,无上限)

每当一轮对话再次命中同一条语义等价的记忆(判定为「强化」而非「新建」):

confidence += 0.1        # REINFORCE_STEP
observations += 1
lastSeen = 当前时间

没有上限。置信度随观测次数线性累积,于是「提了 3 次」和「提了 10 次」在数值上可区分:

第 1 次观测  0.5(或 max(0.5, LLM分))
第 2 次      0.6
第 3 次      0.7
第 4 次      0.8

第 10 次     1.4

历史版本曾把置信度硬限制在 0.9(CAP),导致一条 100% 的记忆被「强化」时反而掉到 90%,且永远卡死。现版本已移除该上限。

4.3 有效置信度与衰减(遗忘的量化)

注入与排序时用的是 effectiveConfidence,它对原始置信度做时间衰减

effectiveConfidence = max(0, confidence − 0.05 × floor(距 lastSeen 的天数 / 30))

即:最近 30 天内不衰减,之后每满 30 天降 0.05DECAY_STEP=0.05DECAY_DAYS=30)。

  • 衰减只作用于「读出去用 / 排序 / 展示」时的有效值,不直接改写落盘的 confidence
  • 一旦再次被观测(强化),lastSeen 更新,衰减重新计时。

4.4 排序

记忆排序:

  1. 先按 confidence(原始置信度)降序 —— 无上限后,这等价于按「频率」排;
  2. 同分按 lastSeen 降序(最近观测的在前)。

observations 不单独参与排序——它通过 confidence 已经间接体现(每次观测 +0.1)。

4.5 展示

UI 与注入文本里的置信度一律显示为数值(如 0.91.2),不使用百分数,避免超过 100% 时的语义困惑。

5. 记忆的收集(挖掘流水线)

收集是「每轮结束」的增量流水线,由 4 个阶段串联:

5.1 收集(agent/inbox/claimed

每轮开始时,用户消息进入该 session 的 pending 队列。以下消息会被过滤,不进队列:

  • 工具产生的消息(source.kind === 'tool');
  • -- 快捷指令;
  • 注入前缀(【记忆注入)开头的消息。

5.2 触发(agent/turn-stopping

每轮结束时触发 analyzeSession(sessionId),串行处理(一个 session 同时只跑一次分析,analyzing 锁 + chain 队列)。

5.3 挖掘(extractIntents(texts, memories)

调用 deepseek-v4-flashhuoshan-engine provider),把两样东西喂给模型:

  1. 本轮新消息
  2. 已有记忆候选集(不是全量!)。

候选集(成本封顶):从已有记忆里筛 ≤ 15 条(SHORTLIST_MAX),由两部分拼成、去重:

  • 向量语义召回 top-15SHORTLIST_RECENT):把本轮消息转成向量,在向量索引里检索语义最相近的 15 条(见第 10 节);
  • 最近 15 条兜底(按 lastSeen 排序):向量不可用/为空时保证仍有召回。

只传记忆的 text,不传 evidence/时间戳。这样单次分析 token 成本有固定上限,不随记忆总数增长。

模型按两条判定标准输出(全部简体中文):

  • 语义等价(同一条规则/偏好/事实,仅措辞不同)→ 输出 reinforce,指向那条已有记忆;
  • 主题相近但场景/动作不同(如「用中文回答」vs「沉淀记忆用中文」)→ 视为不同意图,输出 new

关键设计:把「挖掘」和「去重」合一。模型在挖掘时就已知道历史记忆,因此能直接判断「这是新记忆还是旧记忆的重复」,而不是先盲目提取、再靠相似度算法事后兜底——后者对中文近义表达几乎失效。

5.4 归并与落盘(analyzeSession

  • reinforce 结果 → 对已有记忆执行强化(见 4.2);
  • new 结果 → 进入「分析结果」列作为候选,等待用户勾选;
  • 用户勾选「沉淀为记忆」→ 写入对应 <tag>.jsonl,并同步更新向量索引(ensureVecFor);删除记忆时同步移除向量(removeVec)。

6. 记忆的注入

注入回答一个问题:记忆如何进入模型上下文、影响后续行为? 有三条路径。

6.1 默认注入(system-prompt/assemble

每次组装 system prompt 时,把 memory.md 作为一个 section 注入。memory.md 分几部分:

  1. 用户级记忆(全量 inline):每个用户级标签下,按置信度降序、同分按 lastSeen 降序,取前 50 条(READ_LIMIT)直接写入;
  2. 项目背景记忆索引(按需路由):只列「标签 / 条数 / 是否已读」表格,并附强指令:当用户自然语言提到相关主题(如「输入输出模块」「液态玻璃」)时,必须先调 memory_read(标签) 再回答,不必等用户明说「读取记忆」;
  3. 读取规则-- 快捷指令生效规则

6.2 按需读取(memory_read 工具)

memory_read(tag) 是 host 侧注册的模型工具,返回某标签的记忆文本(≤ 50 条,超限截断)。触发场景:

  • AI 判断当前任务涉及某「项目背景-*」标签时主动调用(由 6.1 的强指令驱动);
  • 用户明确要求时。

每次成功调用会记录一条读取记录(见第 8 节 UI)。

6.3 手动注入(-- 快捷指令)

  • 输入 -- 弹出记忆选择器,或直接输入 -- <标签名/序号>

  • 现在不再把记忆正文灌进输入框(避免输入框被大量上下文占满),而是只填入一句话:

    使用 memory_read 工具读取「项目背景-输入输出模块」记忆。
    
  • AI 看到这句话后调用 memory_read 工具完成实际读取,读取记录由工具记录。

7. 记忆的遗忘

「遗忘」分三层,由弱到强:

  1. 时间衰减(自动、可逆)effectiveConfidence 每 30 天降 0.05(见 4.3)。被遗忘的记忆权重下降,排序靠后;一旦再次被提及,lastSeen 刷新、衰减重置。
  2. 低置信度标记(自动、只标记)effectiveConfidence < 0.55DEPRECATED_BELOW)时标记 deprecated。它只是被标注为「低置信度」,不会自动删除——设计上认为用户沉淀过的记忆都有价值,删除必须由人决定。
  3. 手动删除(显式、不可逆):在「已沉淀记忆」列点「删除」按钮,弹出确认框,确认后从 jsonl 移除。这是唯一的真正删除途径。

8. UI 看板(三列)

「记忆」tab 顶部是状态行(已沉淀 N 条 · 低置信度 N 条 · 待分析 N 条 · 上次分析时间)与操作按钮(立即分析 / 刷新 / 选择沉淀标签 / 新增标签 / 沉淀为记忆 / 删除)。下方三列等宽:

内容
读取记录本会话每次 memory_read 调用的记录(第几轮、读取/手动、标签、条数)。纯内存态,不落盘、不跨会话
分析结果本轮挖掘出的候选意图(按置信度降序),勾选后「沉淀为记忆」或「删除」。
已沉淀记忆全部已沉淀记忆(按置信度降序),顶部有分组筛选(全部 / 单个标签),每条展示正文、置信度、观测次数、最近观测时间、所属标签,右侧「删除」按钮。

9. 关键常量与阈值

常量含义
REINFORCE_STEP0.1每次观测的置信度增量
DECAY_STEP0.05每 30 天的衰减量
DECAY_DAYS30衰减周期(天)
DEPRECATED_BELOW0.55低置信度(deprecated)阈值
MATCH_THRESHOLD0.5reinforce 文本匹配兜底阈值(精确匹配失败时按字符 Jaccard 找目标)
DEDUP_THRESHOLD0.6候选之间的去重阈值(字符 Jaccard)
SHORTLIST_MAX15每次挖掘喂给 LLM 的候选集上限
SHORTLIST_RECENT15候选集里「最近 N 条」的数量
READ_LIMIT50单次读取/注入的记忆条数上限
MAX_PENDING50每轮待分析消息队列上限

分析模型:huoshan-engine / deepseek-v4-flash

10. 语义召回(embedding 向量)+ 字符 Jaccard 兜底

去重的关键是「从已有记忆里召回语义最相近的一批,再交给 LLM 精判」。召回分两层,语义判断最终仍由 LLM 拍板。

10.1 向量召回(主)

  • 模型nomic-ai/nomic-embed-text-v1.5(Nomic AI 开源),ONNX int8 量化,经 @huggingface/transformers 在 Node 本地推理。
  • 原理:把文本映射成 768 维向量,L2 归一化后两向量点积即余弦相似度;语义相近的文本向量夹角小。
  • 检索:本轮消息向量 → 与全部记忆向量算余弦 → 取 top-15(SHORTLIST_RECENT)。几百条记忆暴力计算毫秒级,无需 ANN。
  • 索引维护:沉淀时 ensureVecFor 增量写入、删除时 removeVec 移除;启动/分析时 ensureVecsFor 给缺失向量的历史记忆批量补算,保持 embeddings.jsonl 与 jsonl 一致。

10.2 字符 Jaccard 兜底(副)

sim(a, b) = max( Jaccard(英文 token), Jaccard(中文二字 bigram) )

  • 英文 token:[a-z0-9_]+ 词元集合;
  • 中文二字 bigram:连续两个非空白中文字符组成。

只用于两个不需要语义的场景:

  • 候选内部去重(dedupe,≥ DEDUP_THRESHOLD 0.6):LLM 刚输出的候选,去字面重复;
  • reinforce 文本匹配兜底(≥ MATCH_THRESHOLD 0.5):LLM 应一字不差复制目标记忆,没复制时按字面找。

中文近义表达在字符 bigram 层面重叠极少(「用中文回答」vs「沉淀记忆用中文」的 Jaccard 只有 ~0.14),所以字符 Jaccard 不做语义判断——这是向量召回存在的原因。

10.3 embedding 模型指标(实测)

指标
模型体积~145MB(int8 量化 model_quantized.onnx,首次下载后缓存在 transformers/.cache/
常驻内存模型 + onnxruntime 运行时约 200~300MB(占大头)
向量索引内存768 维 × 4B = 3KB/条:100 条 = 300KB、1000 条 = 3MB(可忽略)
首次加载~28s(含下载 145MB;模型缓存后该耗时消失)
冷启动加载200ms2s(模型已缓存,进程内首次加载)
单次 embedding 推理几十 ms,批量更优
语义表现「用中文回答」vs「沉淀记忆用中文」余弦 0.90;vs「今天天气不错」0.60(区分度明显,对比旧 Jaccard 仅 ~0.14)

暴力余弦匹配耗时(纯数值计算,不含 embedding)

历史记忆条数耗时
1000.63 ms
10000.76 ms
100005.79 ms

单次去重的耗时构成:暴力匹配本身可忽略,大头是 embedding 推理。历史记忆的向量是预计算缓存embeddings.jsonl + 内存 vecIndex),所以每次去重只 embed「本轮原始消息」一次,与历史记忆逐一 embed:

单次去重 ≈ 一次 embed(几十 ms)+ 全部记忆暴力余弦(100 条仅 0.6ms)

即便历史记忆涨到几千条,暴力匹配仍是毫秒级,瓶颈始终在「那一次 embed」。唯一会触发批量 embed 的是首次建索引ensureVecsFor 给缺失向量的历史记忆一次性补算,批量比逐条快)。

冷启动/推理/匹配耗时与内存来自本机(Apple Silicon)实测,不同机器有波动。模型懒加载:只在第一次去重时加载,之后常驻复用;加载失败自动回退到「最近 15 条」兜底,不影响主流程。