dsh-memory-self-evolution 设计指南
August 31, 2026 · View on GitHub
一个为 DeepSeek Harness(DSH)设计的自进化长期记忆插件。它在每轮对话结束时自动挖掘值得沉淀的意图,以「置信度」量化一条记忆的可靠程度,让记忆随使用频率自然生长、随闲置时间缓慢衰减。
1. 核心设计目标
- 自动收集:不需要用户手动整理,每轮对话结束自动从会话里挖掘可沉淀的偏好、规则、习惯、项目事实。
- 语义去重:重复提到的同一件事只强化一次,而不是反复新建;主题相近但场景不同的(如「用中文回答」vs「沉淀记忆用中文」)保持独立、不强行合并。
- 置信度演化:置信度反映「被提及的频率 + 表达明确度」,而非一个静态分数。
- 低成本注入:用户级记忆默认全量注入;项目级记忆按需路由,只在任务相关时才读取,避免无谓消耗上下文。
- 可遗忘:久未观测的记忆随时间降权,但从不自动删除——删除永远由用户显式确认。
2. 架构总览
插件是双端结构:
| 端 | 文件 | 运行环境 | 职责 |
|---|---|---|---|
| Host 半边 | lib/index.js | DSH Node 进程 | 记忆的存储、挖掘、置信度演化、注入、memory_read 工具、HTTP RPC |
| Host 辅助 | lib/embedding.js | DSH Node 进程 | 本地 nomic-embed-text 向量化,用于语义召回(第 10 节) |
| Client 半边 | lib/client.js | 浏览器页面 | 三列看板 UI、「记忆」tab、-- 记忆选择弹窗 |
cordis.patch.yml:把一个insert行插入 profile 组合,加载包的 host 半边。package.json的dsh字段声明了bundle.patch与client(./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 节) |
category | preference / 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.05(DECAY_STEP=0.05,DECAY_DAYS=30)。
- 衰减只作用于「读出去用 / 排序 / 展示」时的有效值,不直接改写落盘的
confidence。 - 一旦再次被观测(强化),
lastSeen更新,衰减重新计时。
4.4 排序
记忆排序:
- 先按
confidence(原始置信度)降序 —— 无上限后,这等价于按「频率」排; - 同分按
lastSeen降序(最近观测的在前)。
observations 不单独参与排序——它通过 confidence 已经间接体现(每次观测 +0.1)。
4.5 展示
UI 与注入文本里的置信度一律显示为数值(如 0.9、1.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-flash(huoshan-engine provider),把两样东西喂给模型:
- 本轮新消息;
- 已有记忆候选集(不是全量!)。
候选集(成本封顶):从已有记忆里筛 ≤ 15 条(SHORTLIST_MAX),由两部分拼成、去重:
- 向量语义召回 top-15(
SHORTLIST_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 分几部分:
- 用户级记忆(全量 inline):每个用户级标签下,按置信度降序、同分按 lastSeen 降序,取前 50 条(
READ_LIMIT)直接写入; - 项目背景记忆索引(按需路由):只列「标签 / 条数 / 是否已读」表格,并附强指令:当用户自然语言提到相关主题(如「输入输出模块」「液态玻璃」)时,必须先调
memory_read(标签)再回答,不必等用户明说「读取记忆」; - 读取规则、
--快捷指令、生效规则。
6.2 按需读取(memory_read 工具)
memory_read(tag) 是 host 侧注册的模型工具,返回某标签的记忆文本(≤ 50 条,超限截断)。触发场景:
- AI 判断当前任务涉及某「项目背景-*」标签时主动调用(由 6.1 的强指令驱动);
- 用户明确要求时。
每次成功调用会记录一条读取记录(见第 8 节 UI)。
6.3 手动注入(-- 快捷指令)
-
输入
--弹出记忆选择器,或直接输入-- <标签名/序号>; -
现在不再把记忆正文灌进输入框(避免输入框被大量上下文占满),而是只填入一句话:
使用 memory_read 工具读取「项目背景-输入输出模块」记忆。 -
AI 看到这句话后调用
memory_read工具完成实际读取,读取记录由工具记录。
7. 记忆的遗忘
「遗忘」分三层,由弱到强:
- 时间衰减(自动、可逆):
effectiveConfidence每 30 天降 0.05(见 4.3)。被遗忘的记忆权重下降,排序靠后;一旦再次被提及,lastSeen刷新、衰减重置。 - 低置信度标记(自动、只标记):
effectiveConfidence < 0.55(DEPRECATED_BELOW)时标记deprecated。它只是被标注为「低置信度」,不会自动删除——设计上认为用户沉淀过的记忆都有价值,删除必须由人决定。 - 手动删除(显式、不可逆):在「已沉淀记忆」列点「删除」按钮,弹出确认框,确认后从 jsonl 移除。这是唯一的真正删除途径。
8. UI 看板(三列)
「记忆」tab 顶部是状态行(已沉淀 N 条 · 低置信度 N 条 · 待分析 N 条 · 上次分析时间)与操作按钮(立即分析 / 刷新 / 选择沉淀标签 / 新增标签 / 沉淀为记忆 / 删除)。下方三列等宽:
| 列 | 内容 |
|---|---|
| 读取记录 | 本会话每次 memory_read 调用的记录(第几轮、读取/手动、标签、条数)。纯内存态,不落盘、不跨会话。 |
| 分析结果 | 本轮挖掘出的候选意图(按置信度降序),勾选后「沉淀为记忆」或「删除」。 |
| 已沉淀记忆 | 全部已沉淀记忆(按置信度降序),顶部有分组筛选(全部 / 单个标签),每条展示正文、置信度、观测次数、最近观测时间、所属标签,右侧「删除」按钮。 |
9. 关键常量与阈值
| 常量 | 值 | 含义 |
|---|---|---|
REINFORCE_STEP | 0.1 | 每次观测的置信度增量 |
DECAY_STEP | 0.05 | 每 30 天的衰减量 |
DECAY_DAYS | 30 | 衰减周期(天) |
DEPRECATED_BELOW | 0.55 | 低置信度(deprecated)阈值 |
MATCH_THRESHOLD | 0.5 | reinforce 文本匹配兜底阈值(精确匹配失败时按字符 Jaccard 找目标) |
DEDUP_THRESHOLD | 0.6 | 候选之间的去重阈值(字符 Jaccard) |
SHORTLIST_MAX | 15 | 每次挖掘喂给 LLM 的候选集上限 |
SHORTLIST_RECENT | 15 | 候选集里「最近 N 条」的数量 |
READ_LIMIT | 50 | 单次读取/注入的记忆条数上限 |
MAX_PENDING | 50 | 每轮待分析消息队列上限 |
分析模型: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_THRESHOLD0.6):LLM 刚输出的候选,去字面重复; - reinforce 文本匹配兜底(≥
MATCH_THRESHOLD0.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;模型缓存后该耗时消失) |
| 冷启动加载 | |
| 单次 embedding 推理 | 几十 ms,批量更优 |
| 语义表现 | 「用中文回答」vs「沉淀记忆用中文」余弦 0.90;vs「今天天气不错」0.60(区分度明显,对比旧 Jaccard 仅 ~0.14) |
暴力余弦匹配耗时(纯数值计算,不含 embedding):
| 历史记忆条数 | 耗时 |
|---|---|
| 100 | 0.63 ms |
| 1000 | 0.76 ms |
| 10000 | 5.79 ms |
单次去重的耗时构成:暴力匹配本身可忽略,大头是 embedding 推理。历史记忆的向量是预计算缓存(embeddings.jsonl + 内存 vecIndex),所以每次去重只 embed「本轮原始消息」一次,不与历史记忆逐一 embed:
单次去重 ≈ 一次 embed(几十 ms)+ 全部记忆暴力余弦(100 条仅 0.6ms)
即便历史记忆涨到几千条,暴力匹配仍是毫秒级,瓶颈始终在「那一次 embed」。唯一会触发批量 embed 的是首次建索引(ensureVecsFor 给缺失向量的历史记忆一次性补算,批量比逐条快)。
冷启动/推理/匹配耗时与内存来自本机(Apple Silicon)实测,不同机器有波动。模型懒加载:只在第一次去重时加载,之后常驻复用;加载失败自动回退到「最近 15 条」兜底,不影响主流程。