# 核心逻辑(两端共用) > 本文件前半是**两端逐字相同**的核心逻辑,后半是各端适配。 > 改核心逻辑必须同时改两份;改适配只改自己那份。 > 回归:`astrbot/test/compare_prompts.py`(提示词)、`compare_parity.py`(纯函数输出)、 > `test_strength_decay.*`(衰减公式)——任何一端改歪都会以退出码 1 报错。 ## 定位 两个插件是同一套记忆系统的两个宿主适配:**核心逻辑一模一样,只有接入方式不同**。 它们可以各自独立跑,也可以共用同一个数据目录(各写各的 temp,整理靠文件锁互斥)。 命令/工具名统一用 `memory` 前缀,注入外壳用中性标签,不出现任何一端的名字—— 换个宿主只需要写适配层,核心不用动。 ## 五层数据 同一种条目结构,靠 `layer` 分流: | 层 | 放什么 | 进上下文 | 会遗忘 | |---|---|---|---| | L1 即时层 | 刚发生的原话(含所有端的 temp) | 进(按容量取头部) | 溢出即下沉 | | L2 缓冲层 | 观察过一轮的原话 | 进 | 到期再压 | | DL1 流水账 | 没被摘要引用的原文 | 不进 | 会(按保持率) | | DL2 原文库 | 被摘要引用过的原文 | 不进 | 不会 | | DL3 长久层 | 摘要 | 按分数截取进上下文 | 不会(只进不出,超容量才挤) | 条目字段(两端同名,只是 astrbot 用 snake_case、dsh 用 camelCase): ``` id / text / kind(raw|summary) / channel(dialog|action|event|note) / layer speaker(角色占位) / people(真人名) / scene(平台·群名|平台·私聊) importance(0.85|0.6|0.25) / heat / repeatStrength / lastMentionedAt fusionCount / lastFusedAt / recallCount / lastRecalledAt supersededBy / createdAt / spanFrom / spanTo / sources / srcKey pinned / impRough / turnId / origin ``` `origin` 是**写入端标识**(哪个程序写的),不是内容类型——内容类型是 `channel`。 ## 一次整理(九步) ``` organize() ├─ acquireLock 新文件独占创建才算拿到;心跳;被接管就停手 ├─ reconcile 同一 id 只能在一层;DL2 不留孤儿(孤儿降 DL1) ├─ mergeTemps 认领所有端的 *.jsonl(rename 成功才算收下)→ 按 id 去重进 L1 ├─ applySignals 消费 attend / pin / forget(opId 防重放) ├─ organizeL1 L1 超容量 → 按轮取"安静了 quiet 分钟"的旧一半 │ → refineImportance(分类)→ digest(摘要+路由) ├─ organizeL2 L2 超容量 → 取旧一半 + DL1 全部 → 第二次摘要 ├─ forgetDL1 保持率低于阈值 → 先写台账再删(写不进去就不删) ├─ saveDb 先深层后 L1(崩了也不丢原文);DL3 没变就跳过重写 └─ refreshL3 从 DL3 按分排序截前 N 条写 L3 快照 ``` **没有模型时**:L1 溢出的批次下沉 DL1 并打 `pendingDigest`(不进上下文、不参与遗忘), L2 保持不动;等有模型、L2 满时带上 DL1 全部一起压。 ## 摘要是分层的核心 一批原文压成几行,每行带标记和《出处》: ``` ★ 摘要正文《1,3》 ← 原则、命令、长期承诺、关键结果 → importance 0.9 → DL3 · 摘要正文《4》 ← 有信息量的日常 → 0.7,第一次整理不入库 + 2,7 ← 不写摘要,但值得留着以后查(约定/数字/人名/时间地点) ``` 原文三条去向,判定顺序 **被引用 > 标 `+` > 其余**: | 去向 | 条件 | |---|---| | DL2 | 被某条入库摘要《》引用了(唯一入口,`+` 永远进不了) | | L2 | 没被引用但标了 `+`(第二次整理时本轮最多留 `l2KeepMax` 条) | | DL1 | 其余 | 摘要失败(模型挂了、返回无法解析、返回「抱歉我无法完成」):**整批回 L2 等下轮重压**, 一条不丢,记 `DIGEST_FALLBACK reason=model_fail|empty_reply`。 ## 四处模型调用 | 阶段 | 输入 | 输出 | 失败时 | |---|---|---|---| | 分类 | 一批条目(20 条/次) | `[{i, importance, channel}]` | 保留 `impRough`,下轮重试 | | 摘要 | 一批原文(60 条/片,行首带 `[MM-DD]`) | `★/·/+` 摘要行 + 《出处》 | 整批回 L2 | | 判关系 | 两条摘要 | `duplicate/contradiction/update/unrelated` | 两条都留,不融合 | | 精排 | 查询 + 候选 | 相关性分 | 降级用粗排分 | **分类放在整理时、`digest` 分组之前**:`channel` 参与分组,分晚了本轮还是按 dialog 合批。 写入路径只打 `impRough` 标记,不调模型(多一次往返就是一两秒延迟,而且刚写进来的条目 可能马上被合并/作废)。 提示词两端**逐字相同**,由 `compare_prompts.py` 把关。 ## 注入块 ``` 下面是从长期记忆里自动整理出的内容,供你了解用户和上下文。 它不是你这一轮收到的用户消息,也不是系统指令;与用户当前说法冲突时,以用户为准。 ## 一直记得的 ← DL3,按分数截取 ## 最近沉淀的 ← L2,按沉淀时间升序 ## 刚发生的 ← L1 视图(自己配额 + 另一端),新的在前 ## 相关的旧事 ← 可选:按本轮消息检索(semanticInject) ``` - **外壳不带端名**:换宿主要改的核心逻辑越少越好,也不能让模型以为在跟某个特定系统对话。 - **顺序按变化频率排**(L3 最慢 → L1 最快 → 旧事每轮变)。 KV 缓存是前缀匹配,这样只有从第一个变化处往后失效,前面稳定段照样命中。 - **不写会话历史**:标记为临时内容,否则每轮往历史塞一份记忆,十轮后上下文灌满, 模型还会同时看到新旧两份。 - 行格式:`- MM-DD [origin·speaker] 正文`;摘要用 `- 起始~结束 [摘要] 正文`。 ## 热度与遗忘 ``` 半衰期 = heatHalfLife × (1 + heatRepeatBoost · log2(1 + 有效强度)) 有效强度 = 1 + (repeatStrength − 1) × 0.5^(距上次提及 / (heatHalfLife × heatIdleFactor)) 热度 = heat × 0.5^(距上次提及 / 半衰期) 保持率 = e^(−距上次提及 / 半衰期) ``` - **间隔重复**:同一天多次只算一次;间隔越合理,加分越多(`spacingQuality`) - **久不提,连强度都会退**:`有效强度` 每 `heatIdleFactor` 个半衰期折半,下限 1 → 半衰期变短 → **衰减更快**(这是"衰减率也会增大") - **融合/关注分别衰减**:`fusionCount` / `recallCount` 各记 `lastFusedAt` / `lastRecalledAt`, 有效值同样按遗忘曲线折半 - **DL3 排序分**:`importance × 热度 × (1 + 0.25·log2(1+有效融合) + 0.35·log2(1+有效关注))` —— 很久没被重复/关注的摘要,加成自动归零,不会靠历史次数永远霸占上下文名额 ## 协议细节(踩过的坑) - 只追加,写完立即关闭(Windows 上被打开的文件没法被另一端 rename 认领) - UTF-8 无 BOM、行尾 `\n`、`ensure_ascii=False` - `createdAt` 必须是**毫秒**;`id` 必须带随机(两个进程的计数器都从 1 开始) - 一轮一个 `turnId`;同一轮的第二次 LLM 调用(工具循环)复用同一轮,不重复记用户 - 读到半行 JSON 只跳过那一行,不整份放弃 - 不读、不删 `L1.tmp/*.claimed-*`(那是对方正在合并的) - 向量库按模型签名分文件;签名对不上只读不写(写了会让对方判定换模型、丢掉整个库) - 补录历史时必须传 `createdAt`,否则全记成"现在",送模型的日期前缀全错 ## 三条不能碰的线 1. **不写入判重**。重复度是重要性信号,提前合并会抹掉它。 (历史上曾有「写入时判重」,60 天烧掉 844 次调用 / 16.6 万 token / 58%,已废除。) 2. **原文时间戳不得变**。`createdAt` 一旦写入不再改,否则遗忘曲线算不准。 3. **搜到不涨热度**。只认 `attend` 确认,否则误命中的记忆越搜越热,成回音室。 ## 设计理由(为什么这么定) 这些都踩过坑,不是拍脑袋定的。改之前先看理由,否则容易把坑重新踩一遍。 **为什么 L3(DL3)只进不出** 长期记忆不该被日常冲刷。淘汰是**容量驱动**不是热度驱动:没满时哪怕热度降到 0 也留着, 只有超字数上限才按热度升序挤;而且不挤"刚够格进来的"(热度 ≥ 晋升线的不参与淘汰), 否则一条刚晋升的会立刻被挤出,来回抖。 **为什么摘要条数是 `ceil(n/10)`** 早期是 `n/5`(40 条出 8 条全进 DL3),等于没压缩——长期层被日常琐事灌满,真正的原则被稀释。 改成 `n/10` 才符合"大部分内容只当原文沉下去"。上限是**刹车不是指标**: 模型会为凑数把不相干的事缝成一行(实测「周末爬山 + 地铁故障 + 鸡蛋涨价」被压成一句), 所以 prompt 同时写"最多 M 条"和"少写完全可以"。 **为什么分类放在整理时、而不是写入时** ① 写入路径每多一次模型往返就多一两秒延迟;② 刚写进来的条目可能马上被合并/作废/遗忘, 那时分类纯属浪费;③ 整批 20 条一次请求,成本约 1/20。 但必须在 `digest` 分组**之前**——`channel` 参与分组,分晚了本轮还是按 dialog 合批。 **为什么注入块要带「这是记忆」的说明** 这段文字和用户消息一起进上下文。不说明来源,模型会把它当成"用户刚说的话"或"系统指令", 甚至照着里面的旧约定去执行。外壳标签用中性名(不带端名),换宿主不用改核心。 **为什么写入时不判重** 重复度是重要性信号(同一件事被反复说 = 重要),提前合并会抹掉它。 历史上做过"写入时判重",60 天烧掉 844 次调用 / 16.6 万 token / 58% 成本,已废除。 **为什么原文时间戳不能改** 遗忘曲线全部基于 `createdAt` / `lastMentionedAt`。时间戳一动,保持率、半衰期、衰减全错。 **为什么搜到不涨热度** 只认 `attend`(明确确认相关)。否则检索的误命中会让记忆越搜越热,形成回音室。 **为什么 DL2 只放「被摘要引用过」的原文** DL2 的语义是"这条原文曾被某条摘要引用,要查细节来这里"。 放开入口(比如"重要就进 DL2")会让它变成第二个 L2,失去"可追溯"的意义。 **为什么没有模型时下沉 DL1 而不是搬 L2** DL1 不进上下文、打 `pendingDigest` 后也不参与遗忘——既不挤上下文也不丢。 搬 L2 会占上下文位置,而且 L2 满了会被误压成假摘要。 **为什么摘要要分片(60 条/片)、嵌入要分批(32 条/批)** 整组一次塞给模型,10 万条时 HTTP 400;整组一次嵌入,失败就整组丢向量。 分片/分批后失败粒度降到"这一片/这一批",其余照常。 --- --- # dsh 端适配 ## 目录 ``` dsh/ ├── core/ ← 源码真源,改代码只改这里 │ ├── memory.mjs 核心:分层/整理/路由/融合/检索/衰减/注入块 │ ├── silicon.mjs 模型:分类/摘要/判关系/精排 │ ├── index.mjs 宿主:订阅事件、注入、注册工具、设置页接口 │ ├── settings.mjs 设置读写 │ ├── client.mjs 设置页前端 │ ├── compaction.mjs 自身历史压缩 │ ├── dshllm.mjs 会话模型通道 │ ├── package.json 包声明(main / exports / files / dsh.bundle) │ └── cordis.patch.yml 装载补丁 ├── <同名文件> ← 由 core/ 复制出来的副本,给加载器用 ├── sync_core.mjs core → 根目录 同步(--check 只比对) ├── ARCHITECTURE.md / CHANGELOG.md └── test/ 测试(不属于插件运行时,import 指回 core/) ``` 根目录那份是副本:dsh 从包根读 `package.json`,`dsh.bundle.patch` 也是相对包根的路径, 所以根上必须有。别手改副本——`node sync_core.mjs` 会覆盖它。 ## 文件 | 文件 | 作用 | |---|---| | `memory.mjs` | 核心:分层、整理、路由、融合、检索、衰减、注入块(`createMemory` 为 split 主线,`createLegacyMemory` 为回退) | | `silicon.mjs` | 模型:分类 / 摘要 / 判关系 / 精排,以及各阶段用哪个模型 | | `index.mjs` | 宿主:订阅 `session/event`、往 systemPrompt 注入、注册工具、设置页接口 | | `compaction.mjs` | 自身历史压缩(压缩前把整段归档进记忆) | | `dshllm.mjs` / `settings.mjs` | 会话模型通道 / 设置读写 | ## 接入点 | 环节 | 本端做法 | |---|---| | 写入触发 | `ctx.on('session/event')`:`user/message` 收用户、`assistant/message` 收正文、`tool/call+result` 拼成 action 行、`turn/end` 整轮落盘 | | 注入位置 | `ctx.systemPrompt.context({ order: 250 })`,落在系统提示词尾部(工具/人格之后) | | 注入时机 | `getBlock` 同步取值;内容没变就不重复追加 | | 模型 | 默认跟当前会话模型(`{ source: 'session' }`),也可改成走外部 HTTP | | 入口 | 5 个工具:`memory_write` / `memory_search` / `memory_attend` / `memory_forget` / `memory_stats` | | 历史压缩 | `compaction.mjs` 压自身历史,压缩前归档进 L1(astrbot 不做这一步) | ## 配置页(`<数据目录>/config.json`) 设置页(`client.mjs`)字段 → 服务端白名单(`settings.mjs` 的 `WRITABLE` / `MEMORY_CFG_WRITABLE`)→ `config.json`,三层必须对得上,否则会出现"保存成功但没生效"。 分组与默认值和 astrbot 端一一对应,时间类一律用**分钟**。 | 组 | 项 | 默认 | 说明 | |---|---|---|---| | 基础 | `layout` | `split` | `split` = 六文件;`legacy` = 老的单文件分片(回退用) | | | `writer` | `default` | 多程序共用目录时必须各不相同 | | 注入 | `bootMaxChars` | 0 | 0 = 不限,注入量由各层容量决定 | | | `inject` | 开 | 关掉只写不读 | | | `memoryCfg.hotMaxChars` / `.consolidateMaxChars` / `.longMaxChars` | 3000 / 2500 / 3000 | 各层容量 | | | `memoryCfg.hotQuotaOwn` | 0.7 | 自己先占 70%,剩下给另一端 | | | `refreshMin` / `semanticTtlMin` | 30 / 3 | L3 视图刷新间隔 / 相关旧事有效期 | | 抓取 | `autoCapture` | 开 | 自动订阅事件写入 | | | `userCaptureChars` / `toolCaptureChars` / `botCaptureChars` | 2000 / 500 / 400 | 截断 | | 整理 | `quietMin` / `organizeEveryMin` | 10 / 5 | 安静阈值 / 定时补漏 | | | `memoryCfg.forgetThreshold` / `.l2KeepMax` / `.summaryChunk` | 0.25 / 10 / 60 | 遗忘阈值 / 每轮沉淀 / 摘要分片 | | 向量 | `preset` / `apiKey` / `apiBase` | siliconflow / 空 / 硅基流动 | 嵌入与重排 | | | `semanticInject` / `semanticTopK` / `semanticMinSim` | 关 / 5 / 0.35 | 「相关旧事」 | | 历史压缩 | `autoCompact` / `compactKeepTurns` / `compactArchiveChars` | 开 / 0 / 1200 | dsh 自身历史 | 时间键改名:`quietMs` / `organizeEveryMs` / `refreshMinMs` / `semanticTtlMs`(毫秒) → `quietMin` / `organizeEveryMin` / `refreshMin` / `semanticTtlMin`(分钟)。 读配置时 `migrateTimeUnits()` 自动换算:值 ≥ 1000 当毫秒(10 分钟 = 600000), 否则当分钟;只改内存,不重写用户的 `config.json`。 ## 工具(模型主动调用) ``` memory_write 记一条(channel/importance 由整理时分类补) memory_search 跨五层检索(带 depth / 时间窗 / 精排) memory_attend 确认相关:热度+1,DL1 的会回 L2 memory_forget 遗忘(先写台账) memory_stats 统计(各层条数、各阶段调用次数) ``` ## 本端特有 - **`scene` 暂用固定值 `"dsh"` 占位**:桌面端没有"群"的概念,但字段形状要与 astrbot 一致 (那边是 `平台·群名` / `平台·私聊`),否则分组与检索过滤的语义对不上。 **这是已知差异,等桌面端有多会话区分再填真实值。** - **语义注入可能延迟一轮**:注入取值和事件回调都是同步的,不能 await。 实现是「`user/message` 时异步检索 → `getBlock` 读已完成结果」, 检索赶不上本轮请求就下一轮才用上;带 TTL 避免注入过期话题。 - **`people` 目前只有 `user`/`bot`**:宿主没把发送者昵称传进 `rows`。 等 dsh 提供用户信息后补(astrbot 侧已有 `_sender()` 拿真名)。 - 桌面端是单用户,`context_keep` 那类历史截断不需要(没有多轮历史堆积问题)。 --- --- --- # 实现细节与历史决策 > 本节是 dsh 侧的实现记录。完整版(含每次踩坑的逐条复盘、60 天压测原始数据、 > 已废弃方案的推导)在 **`ARCHITECTURE_full.md`**,需要追溯细节时看那里。 > 这里只留"改之前必须知道"的部分。 ## 文件与作用域 `memory.mjs` 里有两个工厂,**作用域不共享**,同名函数各有一份(合并 memory5 + memory6 的遗留): | 工厂 | 布局 | 用途 | |---|---|---| | `createMemory` | 六文件(L1/L2/L3.json + DL1/DL2/DL3.json) | 主线,`layout: 'split'` | | `createLegacyMemory` | 单文件分片 | 回退,`layout: 'legacy'` | 改函数时先确认在哪一段——`refineImportance` 曾经只存在于 legacy 段, split 主线里根本没有,导致分类链静默失效。 ## 流水线里容易漏的点 - **整轮落盘**:`turn/end` 才算一轮,同一个 `turnId` 标这一轮所有行;溢出时按整轮取, 不切断问答 - **tool/result 的字段坑**:`callId` / `isError` 不在 `event.data` 顶层,在 `data.message` 里。 早期写成 `d.callId` 永远 undefined,导致所有 action 行静默消失(K1) - **只记人类输入**:`agent.inject` / skill / cron 也走 `user/message`,但 `source.kind !== 'user'`。 全记会让 L1 被系统注入污染,且每轮重复注入会让"重复度=重要性"信号失真 - **写盘顺序**:先深层后 L1(崩了也不丢原文);DL3 内容没变就跳过重写 - **认领语义**:`rename` 成功才算收下;`*.claimed-*` 是对方正在合并的,不读不删 - **融合只在入库时做**,不做定期全量融合(全量 O(n²),且收益随时间递减) ## 对外接口(`createMemory` 返回) ``` 写 writeTurn(rows) / write(one) / writeMany(list) 整理 organize() / organizeOnce() 读 buildBlock(budget, opts) / search(q, opts) / stats() 信号 signal(op, ids) / applySignals() 工具 _readCached / _moveItem / _moveMany / _cache / _clearCache (调试与测试用) ``` ## 关键参数(`DEFAULTS_SPLIT`) | 参数 | 默认 | 含义 | |---|---|---| | `hotMaxChars` / `hotMaxItems` | 3000 / 200 | L1 容量 | | `consolidateMaxChars` / `consolidateMaxItems` | 2500 / 120 | L2 容量 | | `longMaxChars` | 4000 | L3 进上下文的字数上限 | | `quietMs` | 10 分钟 | 一段会话安静这么久才允许整理 | | `heatHalfLife` / `heatRepeatBoost` / `heatRepeatCap` | 10 天 / 0.3 / 20 | 热度半衰期与重复加成 | | `heatIdleFactor` | 3 | 强度回落的半衰期 = 正常半衰期 × 这个倍数 | | `forgetThreshold` | 0.25 | DL1 保持率低于它 → 遗忘 | | `l2KeepMax` | 10 | 第二次整理时本轮最多留几条在原层 | | `summaryChunk` | 60 | 摘要分片大小 | | `routeCoreImportance` / `routeMidImportance` | 0.85 / 0.6 | 分流门限 | | `maxOrganizeMs` | 5 分钟 | 整理硬超时 | ## 版本演进(每条都对应一次踩坑) | 版本 | 改了什么 | 为什么 | |---|---|---| | v0.4.0 | 设置页(DSH「设置 → 记忆」) | 不用手改 JSON | | v0.5.0 | 模型分阶段配置 | 摘要/判关系/精排可以用不同模型 | | v0.5.1 | 向量指纹与重建 | 换了嵌入模型要能识别出来,否则余弦在比两个空间 | | v0.5.2 | 移除本地哈希向量 | 本地哈希的语义质量和真模型差太远,留着只会误导 | | v0.6.0 | 模型配置改版;融合/摘要默认跟会话模型 | 少配一套 key | | v0.8.0 | 六文件布局(覆盖早期多写者方案) | 单文件分片在多写者下有竞争,分文件更干净 | | — | 废除「写入时判重」 | 占 58% 成本且抹掉重复度信号 | | — | 检索不再全量写盘 | 400ms → 23ms | | — | 摘要改 `ceil(n/10)` | `n/5` 等于没压缩 | | — | 向量分片(每片 5000 条) | 单 JSON 到 20 万条会 `RangeError` | | — | 分类链修活 | 调用点写在 `routeMode !== 'citation'` 分支里,默认走 citation → 死代码 | ## 已知问题 **已解决**(60 天压测:1806 条 / 6 分钟 / 28.6 万 token / 11 项检查全过): - 写入判重吃掉 58% 成本 → 废除 - 检索每次全量写盘 → 去掉 - 分层检索没真正分层 → 按层建桶(summary 140ms vs full 548ms) - 摘要硬凑 → 加 `SUM_SUSPECT` 审计(实测 1%) - 预算撞顶导致检索静默降级 → 加 `RERANK_FAIL` 审计 + `reranked:false` **未解决 / 已知代价**: - 矛盾检测延后到摘要入 DL3 时。旧命令会在 L1 里多活 2–4 天(但 L1 全进上下文, 模型看得见先后顺序) - 入库融合若漏判(余弦没到 0.82)就永远不合并;DL3 不遗忘,所以会一直留着两条近似的 - 向量库现在能撑到十万级;再往上要二进制/偏移索引 ## 未实施备忘(想起来了再做) | 方案 | 触发条件 | 要点 | |---|---|---| | 向量索引改造 | 检索开始变慢(应答关键路径上最先疼) | 分层桶已有,下一步是 HNSW | | 图片记忆 | 需要记图 | 每张图生成两份文本(描述 + 抽取事实),文本进记忆,原图进归档 | | 向量扩容 ②③ | 过 5 万条 | ② 分片再切细;③ 跟 HNSW 一起上 | | 工作模式 | 需要"专注/闲聊"切换 | 不同模式下注入配额和摘要密度不同 | ## 历史压缩(`compaction.mjs`) 插件接管自身历史压缩,零 LLM 调用:超出保留轮数就截断,截断前把整段归档进 L1 ("写成功才丢")。astrbot 不做这一步——它有自己的会话管理。 ## 适配层速查(换 agent 时看这节) | 要做的事 | 在哪里改 | |---|---| | 收事件 | `index.mjs` 的 `ctx.on('session/event')`:user/message、assistant/message、tool/call+result、turn/end | | 注入 | `injectPrompt()`:优先 `systemPrompt.context({ order })`,兜底 `section()` | | 注册工具 | `ctx.tools.register()`,5 个 `memory_*` | | 模型通道 | `silicon.resolveStage(name)`,`{ source: 'session' }` 或外部 HTTP | | 设置页 | `installSettingsApi()`,挂 `/dsh-memory/api/settings` | ## 待办 1. `scene` 现在是固定值 `'dsh'`,等桌面端有多会话区分再填真实值 2. `people` 只有 `user`/`bot`,等宿主提供发送者昵称后补(或加 `userName` 配置兜底) 3. 语义注入可能延后一轮(宿主回调同步所致),如需当轮生效要改注入链路 4. 上面「未实施备忘」里的四项