# dsh-memory-self-evolution 设计指南 > 一个为 DeepSeek Harness(DSH)设计的自进化长期记忆插件。它在每轮对话结束时自动挖掘值得沉淀的意图,以「置信度」量化一条记忆的可靠程度,让记忆随使用频率自然生长、随闲置时间缓慢衰减。 ## 1. 核心设计目标 1. **自动收集**:不需要用户手动整理,每轮对话结束自动从会话里挖掘可沉淀的偏好、规则、习惯、项目事实。 2. **语义去重**:重复提到的同一件事只强化一次,而不是反复新建;主题相近但场景不同的(如「用中文回答」vs「沉淀记忆用中文」)保持独立、不强行合并。 3. **置信度演化**:置信度反映「被提及的频率 + 表达明确度」,而非一个静态分数。 4. **低成本注入**:用户级记忆默认全量注入;项目级记忆按需路由,只在任务相关时才读取,避免无谓消耗上下文。 5. **可遗忘**:久未观测的记忆随时间降权,但**从不自动删除**——删除永远由用户显式确认。 ## 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 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 排序 记忆排序: 1. 先按 `confidence`(原始置信度)**降序** —— 无上限后,这等价于按「频率」排; 2. 同分按 `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),把两样东西喂给模型: 1. **本轮新消息**; 2. **已有记忆候选集**(不是全量!)。 **候选集(成本封顶)**:从已有记忆里筛 ≤ 15 条(`SHORTLIST_MAX`),由两部分拼成、去重: - **向量语义召回 top-15**(`SHORTLIST_RECENT`):把本轮消息转成向量,在向量索引里检索语义最相近的 15 条(见第 10 节); - **最近 15 条兜底**(按 `lastSeen` 排序):向量不可用/为空时保证仍有召回。 只传记忆的 `text`,不传 evidence/时间戳。这样单次分析 token 成本有固定上限,不随记忆总数增长。 模型按两条判定标准输出(全部简体中文): - **语义等价**(同一条规则/偏好/事实,仅措辞不同)→ 输出 `reinforce`,指向那条已有记忆; - **主题相近但场景/动作不同**(如「用中文回答」vs「沉淀记忆用中文」)→ 视为不同意图,输出 `new`。 > 关键设计:把「挖掘」和「去重」合一。模型在挖掘时就已知道历史记忆,因此能直接判断「这是新记忆还是旧记忆的重复」,而不是先盲目提取、再靠相似度算法事后兜底——后者对中文近义表达几乎失效。 ### 5.4 归并与落盘(`analyzeSession`) - `reinforce` 结果 → 对已有记忆执行强化(见 4.2); - `new` 结果 → 进入「分析结果」列作为**候选**,等待用户勾选; - 用户勾选「沉淀为记忆」→ 写入对应 `.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.55`(`DEPRECATED_BELOW`)时标记 `deprecated`。它只是被标注为「低置信度」,**不会自动删除**——设计上认为用户沉淀过的记忆都有价值,删除必须由人决定。 3. **手动删除(显式、不可逆)**:在「已沉淀记忆」列点「删除」按钮,弹出确认框,确认后从 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_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;模型缓存后该耗时消失) | | 冷启动加载 | ~200ms~2s(模型已缓存,进程内首次加载) | | 单次 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 条」兜底,不影响主流程。