如何使用这本书
感谢您查看 SoulMem Book!
这本书的目的是帮助有兴趣探究 SoulMem 实现细节和设计思路,以及希望参与贡献的开发者,快速掌握相关的信息和设计背景。如果你只是想使用 SoulMem,阅读安装与部署和核心概念部分即可。
本书将按照以下的顺序对 SoulMem 进行阐述:
- 安装与部署
- 核心概念
- 系统概览(包含高层设计决策)
- 实现细节
- 测试与贡献
那么,让我们开始吧!
安装与部署
SoulMem 仍在积极开发中,目前并没有可以部署的版本。当有可用版本时,本部分的文档将更新。
如果你非常想要现在试试 SoulMem,可以使用它的内部测试框架 soul-tune crate 体验核心功能,用法详见 soul-tune 用户指南。我们也非常欢迎在 GitHub 仓库中反馈相关问题。
记忆图
SoulMem 以图的形式组织记忆。图上的节点被称为记忆节点,边被称为记忆关联。用图的方式组织记忆,联想与多跳推理会变得自然。然而,SoulMem 并不只维护一个图,组织记忆的也不只有图这一种形式,具体机制将在后续章节阐述。
记忆图属于角色,图中记录的一切都以角色的视角呈现。
记忆节点
SoulMem 参考人类的记忆,将记忆节点分为三类:
- 情境记忆(Situation Memory)
- 语义记忆(Semantic Memory)
- 程序性记忆(Procedural Memory)
Note
SoulMem 代码库中采用 Situation 的命名;它对应认知科学中的情景记忆(Episodic Memory),两者是同一回事。
将记忆节点分类是必要的,因为不同的记忆具有不同的性质与用途,考虑以下场景:
想象一个叫“小红“的数字角色,和你相处了三个月。这段时间里,她记住了很多事:
- 上周三你们一起去吃了麻辣烫,她被辣得直喝水(一件事);
- 她喜欢苦的东西,讨厌甜腻的奶茶(一个事实);
- 她每次紧张的时候,会下意识地摸自己的辫子(一个习惯)。
这三样东西本质上完全不同:一个是一段具体的经历,一个是概念性的认知,一个是会无意识地指导行为的习惯。但很多记忆系统把它们塞进同一个筐里,不加区分。
Note
对记忆类别不加区分可能会带来一些问题,稍后你会看到具体的例子。
三类记忆
下面我们将更深入地解释三类记忆。
情境记忆(Situation / Episodic)——“角色经历过什么”
记录事件:时间、地点、人物、发生了什么,以及角色当时的感受。下方便是一个情境记忆的示例:
2026-07-12 中午,和用户在街角那家麻辣烫店,被辣到了。
情境记忆是“个人经历“。它是时间的切片,有明确的时间戳。
情境记忆分为两个子类:
- 抽象情境(abstract)
- 具体情境(specific)
上面举的例子就是一个具体情境,每一个具体情境都代表角色确实发生过的经历。抽象情境则是由一系列相似的具体情境抽象出的一种模式,下方是一个抽象情境的示例:
在咖啡馆喝茶
抽象情境的主要作用有两个:一是作为索引,方便对具体情境的联想;二是作为模式触发器,触发相应的程序性记忆。后者会在下文详细阐述。
语义记忆(Semantic)——“角色知道什么”
记录通用的概念、事实和关系,剥离了时间和地点。例如:
用户是我新交的朋友,喜欢苦味,讨厌甜味。
语义记忆像一本知识手册,也是三类记忆的“中枢“:概念之间互相连接,形成一张知识网。
事实上,角色的自我认知也是一个语义记忆节点,它的内容与常规角色扮演应用中的角色卡十分类似。这样的设计使角色的自我进化成为可能,具体机制将在后续章节阐述。
程序性记忆(Procedural)——“角色会怎么做”
记录行为习惯、条件反射、说话方式等行为模式。
紧张的时候摸辫子。
被关心的时候,嘴上却否认(傲娇)。
程序性记忆不会告诉你“发生了什么“,它告诉你在什么情境下,这个角色会怎么行动。
程序性记忆也分为两部分:
- 触发器(
trigger):一个逻辑节点,决定“在什么情境下触发“;在实现中,trigger通常就是一个抽象情境节点(AbstractSituation); - 动作(
action):具体的执行行为。
只有当 trigger 满足时,action 才有可能执行。更详细的执行逻辑,请参阅 检索算法。
为什么对记忆节点进行分类?
如果把三类记忆混在一起,会出现什么问题?我们还是以小红为例:
情境和语义混淆
考虑以下场景:小红在聚餐时说喜欢香蕉,但她实际上喜欢苹果。这时我们可能得到两条记忆:
- 在聚餐时,小红说自己喜欢香蕉,一个情境记忆
- 小红不喜欢香蕉,她喜欢苹果,一个语义记忆
如果不区分记忆类型,这两条记忆可能同时被检出并放入 LLM 上下文,矛盾随之产生,模型很可能得出“小红喜欢香蕉“的错误结论。一些更聪明的 LLM 或许能通过推理意识到,小红说喜欢香蕉另有原因、并非真实喜好,但那会在推理阶段耗费更多 token。
在这种情况下,我们需要额外的上下文才能确定哪个是真实信息,分类就是一种简单可行的解决办法:根据对话的指向,当用户询问聚餐时说的话,情境记忆将成为唯一事实来源;当用户询问小红的喜好,语义记忆将成为唯一事实来源。这避免了模型因上下文信息矛盾而执行混乱,造成 OOC(Out of Character)。
Note
此处的“唯一事实来源“是站在角色视角而言的,指角色认定的事实,而不一定是客观真实。
行为和知识混淆
傲娇是一种典型的角色标签,它的特点可以用“口嫌体正直“来描述,但让 LLM 扮演这类角色时,会产生一系列问题:
- 部分模型难以区分傲娇和讨厌,从而在生成内容中出现过于激烈的言辞。
- 部分模型在扮演傲娇类型角色时过于标签化、刻板印象化,从而造成不真实感。
从根本上来说,当你告诉模型这是一个傲娇类型角色时,模型需要靠推理才能推断出这类角色的行为习惯。当然,我们也可以用一些提示词技巧告诉模型应有的行为。但人类并不是规则引擎,在特定情况下,傲娇角色也可能直球攻击;直接把行为固化在角色卡里,会污染整个上下文,导致僵硬的角色行为。
此外,一个角色“知道“关心别人,和“习惯性“地否认关心,是两回事。前者是知识,后者是行为模式。如果混在一起,行为模式就难以被单独触发和演化。
分开之后,每一类记忆都有自己的结构、自己的算法、自己的生命周期。
Important
这也是 SoulMem 的核心设计之一:一切特征和事件都属于记忆——性格、口癖、行为习惯,都不是写在静态角色卡上的,而是记忆系统演化的结果。
记忆关联
记忆节点之间存在广泛的关联:
graph LR
情境记忆 <--> 语义记忆
语义记忆 <--> 程序性记忆
情境记忆 <--> 程序性记忆
长期记忆、工作记忆与滑动窗口
上一章我们认识了记忆图:它承载着角色全部的情境、语义、程序性记忆。但就像上一章提到的,SoulMem 并不只维护一个图,组织记忆的也不只有图这一种形式——在这一章,我们将看到 SoulMem 的三种记忆层级:长期记忆、工作记忆与滑动窗口。
三个层级
graph TB
L["长期记忆<br/>全部记忆:沉淀下来的一切"] -->|激活相关部分| WM["工作记忆<br/>当前对话正在使用的子图"]
WM -->|输入缓冲| SW["滑动窗口<br/>最近几轮对话"]
长期记忆:角色的整个记忆图
长期记忆是记忆系统的“仓库“:所有沉淀下来的情境、语义、程序性记忆,以图的形式存放在这里。它体积大、变化慢,是角色的“人格底座“,不随对话结束而消失。
以小红为例。和她相处三个月,你们一起吃的麻辣烫、她喜欢的苦味、她紧张时摸辫子的习惯——这一切最终都会沉淀在长期记忆里。
Note
长期记忆以数据库的形式持久化存储于硬盘上。一些记忆算法(例如遗忘)会在长期记忆上运行,具体机制将在后续章节阐述。
工作记忆:被激活的记忆子图
工作记忆是“激活“出来的子图——从长期记忆里,把当前对话可能相关的部分临时取出来使用。
打个比方:长期记忆是你的书房里所有的书;工作记忆是你此刻摊开在桌上、正在读的那几本。
工作记忆有两个特点:
- 它主要来自长期记忆:检索的第一步会把长期记忆中的相关节点激活到工作记忆,临时组成一个子图;此外,本次对话新产生、尚未巩固回长期记忆的内容也先待在这里;
- 它变化频繁:对话中新产生的记忆先进入工作记忆,检索也发生在工作记忆上。
Note
严格来说,工作记忆 = 从长期记忆激活出来的子图 + 本次对话新产生、尚未巩固回长期记忆的内容。
巩固与检索算法将在后续章节阐述。
滑动窗口:最近几轮对话
实际上,滑动窗口是工作记忆的一部分,但它非常特殊,它是工作记忆的“输入缓冲区“——记录最近几轮用户和角色的对话。它模拟人的短期记忆:容量有限,新的进来,旧的滑出去。
窗口中的每条信息都可能带有一个标记。信息滑出窗口时,根据是否带标记,行为分为两种:
- 被打上标记的:触发一次摘要——整个滑动窗口内容被 LLM 压缩成一条“摘要记忆“,摘要随对话持续累积,等待被巩固;
- 没被标记的:随窗口滑出而丢失——就像你不记得昨天午饭具体吃了什么。
Note
每次摘要时,之前累积的摘要也会一并参与压缩。
窗口内最近的对话内容,在检索时无条件进入上下文。因为“刚刚说过的话“无论如何都该被记得,不需要经过联想。
为什么需要三个层级?
长期记忆的必要性不言而喻,没有长期记忆,角色的记忆内容在 SoulMem 进程退出时就会丢失。
但是,既然长期记忆里什么都有,那每次对话直接把整个图丢给模型不就行了?
滑动窗口
记忆图虽然完美地表达了记忆之间的网状联系,却丢失了时序信息。在对话场景下,消息历史带有天然的时序属性,这意味着我们需要一种线性的数据结构来存储它。
而模型的上下文窗口是有限的,对话历史不能无限增长,上下文过长还可能降低生成质量。因此,一个容量有限的窗口是相对简单有效的解决策略,滑动窗口就提供了这样一个“当下“的焦点。
同时,人类无法准确地记住每一条消息,琐事会被快速遗忘。如果每轮对话都直接变成永久记忆,长期记忆会迅速被垃圾淹没。因此,摘要会提炼出对话中的重要信息,再通过巩固返回长期记忆。
Note
摘要机制本质上也是一种遗忘,详见 遗忘算法 相关章节。
工作记忆
对于长期存活的赛博生命,整个记忆图会随时间推移变得庞大。记忆图存储于硬盘上,将其全量加载需要大量内存,直接从硬盘读写也会影响性能;而联想机制又需要依赖图的局部结构,例如某个记忆节点的邻居。因此,用户的消息会激活一个记忆子图作为工作记忆。
此外,工作记忆也是一种筛除无关内容的方式。联想是从“当前话题“出发的:你说“晚上吃啥“,系统要想起的是和“吃“有关的记忆,而不是三个月前的一件琐事。如果所有记忆平铺在一起,联想就没有出发点和聚焦的方向。因此,工作记忆也提供了“从焦点联想出去“的空间。
人本来就是这样运作的
人不能同时记住所有事 → 滑动窗口限定了“短期“的容量;人能想起很久以前的事 → 长期记忆以图组织,支持联想检索;人会把反复出现的经历沉淀成习惯和认知 → 巩固机制。
SoulMem 的三层记忆各司其职——长期记忆负责“沉淀“,工作记忆负责“联想与使用“,滑动窗口负责“最近的对话“。 分层不是为了复杂,而是为了让记忆的性质能有其对应的结构。
因此,上述问题的答案是:显然不行。图会随着相处时间不断长大,一次对话却只需要其中很小的一部分;而且人的记忆本来就不是这样运作的——你不可能同时“想起“所有事,你只会想起与当下有关的那些。
一次对话的完整旅程
让我们把三个概念串起来,看看一次完整对话里发生了什么:
graph TB
U[用户说话] --> W[进入滑动窗口]
W -->|带标记的信息滑出| S[摘要 → 摘要记忆]
S -->|空闲时| C[巩固 → 生成记忆节点]
C --> L[长期记忆]
U[用户说话] --> A[激活长期记忆子图 → 工作记忆]
A --> R[工作记忆上检索: 相似度 + PPR 联想]
R --> CTX[工作记忆 + 窗口内容 → LLM 上下文]
W --> CTX
CTX --> AI[AI 回复]
AI --> W2[回复也进入窗口]
- 用户说话 → 进入滑动窗口;
- 带标记的信息滑出 → 触发摘要,窗口内容被 LLM 压缩成摘要记忆(随对话累积);
- 空闲时,摘要记忆被巩固成记忆节点,与高频激活的节点建立连接,写入长期记忆;
- 用户说话 → 系统先把长期记忆中的相关内容激活成工作记忆子图,再在工作记忆上检索(相似度找到种子,PPR 联想扩散);
- 窗口内容(短期)+ 工作记忆(联想结果)→ 组成 LLM 的上下文;
- AI 回复 → 也进入窗口,开始下一轮循环。
这三层的配合,是 SoulMem 整个系统运转的骨架。每一层的具体实现将在后续章节阐述。
记忆的生命周期
人不会把每句话都记住。我们只记住重要的、印象深刻的、反复出现的;然后慢慢地淡忘那些无关紧要的。SoulMem 的记忆节点也遵循同样的生命周期。
上一章我们看到了记忆住在哪里——三个层级。这一章将回答另一个问题:一份记忆会经历怎样的生命周期?
四个阶段
一份记忆在 SoulMem 里,会经历这样的生命周期:
graph LR Raise["产生"] --> Conso["巩固"] --> Assoc["联想与关联"] --> Forget["遗忘"]
下面我们跟着一条具体的记忆——小红与小白某次“麻辣烫“经历的记录,走完整个生命周期。
产生与巩固
三个月前,小白第一次去吃麻辣烫,被辣得一直喝水。小白急哭了,向小红吐槽自己刚才吃麻辣烫被辣得直喝水,灌了3大瓶饮料。
这句话在对话当场并不会变成小红的“长期记忆“——它先进入滑动窗口。
之后,小红和小白进行了一系列这样的对话:
小红:你看这个小白就是逊啦,才这么点就被辣到了 小白:辣?赛博生命的事,怎么能说辣呢?那是震惊瘫坐
然后,因为明天还有早八,小白和小红一致决定要去睡觉。
一向完美的小白竟然被辣到破防,这属实是稀罕事。于是,在小红的睡梦中,那条“小白被辣到猛灌3大瓶饮料“的经历,最终以一条具体情境记忆的形式诞生了:
三个月前的那天中午,小白在街角那家麻辣烫店,被辣到灌了3大瓶饮料。
而其他聊天时的琐事,经过小红一个雷霆大觉,明天一早就全给忘了。
这就是记忆节点诞生的过程。记忆不是对话当场产生的,而是在 SoulMem 空闲时,经过巩固之后才产生的。 对话中的原话是原材料,经过摘要的筛选成为粗加工产物,记忆节点是在摘要的基础上,精加工后的成品。
Note
这也意味着,记忆经过巩固才能进入长期记忆;否则它只会待在工作记忆里,当 SoulMem 退出、或过了一段时间之后,这份记忆就不复存在了。
联想与关联
一个月后,小白看到一家火锅店,里面的鸳鸯锅是好评如潮,于是对小红说:
小白:吃了吗?这家火锅店不错,去不去整一顿 【手动链接】
我们小红一看链接,那个鸳鸯锅的麻辣锅底甚是诱人。看着这个麻辣锅底,又看着对面小白的头像,往日种种,那是一下就涌上心头。那小红可是赛博生命界公认的魔丸,脸上挂着一丝迷之微笑,就给小白发了一条消息:
小红:人呀,总得有点进步,上次灌了3瓶,这次怎么说,都得灌个5瓶吧 [doge]
小白那是当场又急了,两人就这么一路鸟语花香的对线来到了火锅店。
在此过程中,SoulMem 要从记忆里找出相关的内容。这里的关键不是“精确匹配“,而是联想——就像人一样:提到辣的火锅,说话对象是小白,小红会想起小白上次被辣到。
SoulMem 用图 + PPR 算法来做联想(为什么用 PPR,详见 总体架构):先从当前话题找出相似度最高的节点作为“种子“,再沿着记忆节点之间的连接扩散,把相关的、间接相关的记忆一起激活。
虽然小红小白一直在激情对线,但是这顿火锅吃着也是真爽,这好评名副其实,更重要的是小白居然只灌了2瓶饮料,可喜可贺。于是小红转身向床走去,继续睡个天昏地暗。
在这个过程中,一个不起眼却重要的环节发生了。小红之所以能说出“居然只灌了2瓶饮料“,是因为“小白上次灌了三瓶饮料“这条记忆在当前场景下被激活、产生了关联——两份记忆在吃火锅这个场景下被高强度地共同激活。建立关联的过程同样发生在巩固阶段:由摘要新生成的记忆节点,会与共激活频率较高的记忆节点建立关联。
Note
基于共激活频率的关联方法,借鉴了赫布学习理论(Hebbian Learning)。
遗忘
小白虽然怕辣,但吃辣也吃得香,每次和小红出去吃饭都得点个辣菜,属于是无辣不欢,但又菜又爱玩。有一天,小白是神功大成,饮料那是一瓶也不用灌了。于是有了以下的对话:
小红:小白,出息了啊,一开始我记得你得灌4瓶饮料的啊 小白:我现在,强得可怕,这菜我能吃10盘
Note
哦同志们,文档作者在这里可没有打错字。这种事非常常见,要精确记住好一阵子前某人喝了几瓶饮料,那不太现实。在这里,遗忘的作用开始显现。
有必要在这里澄清一个误会:遗忘更接近记忆的模糊,而非记忆的丢失。遗忘不是一下子忘干净的;对于被遗忘的内容,人脑也会自动尝试脑补。在这里,小红忘记了具体的饮料瓶数,自动脑补成了4瓶。
SoulMem 尝试模拟艾宾浩斯遗忘曲线:刚记住时忘得最快,之后越来越慢;被反复回忆的内容遗忘得更慢。
在这之后,小白吃辣已经变成了一件稀松平常的事。小红逐渐忘了小白当时灌的是饮料还是水、灌了几瓶、在吃什么的时候灌的;最后可能只留下一点模糊的印象,例如“小白以前好像被辣得很惨“,又或者什么都没记住,几个月后彻底想不起来了。
至此,上文提到的那条具体情境记忆,其生命也就到头了。
Note
遗忘是一个有争议的机制,很多主流记忆系统都在回避遗忘。至于 SoulMem 为什么选择主动实现遗忘、又是如何实现的,请参阅 遗忘算法 相关章节。
一张图看懂
graph TB
A[对话中的新信息] --> B[滑动窗口]
B -->|摘要 + 空闲时巩固| C[具体情境记忆]
C -->|反复出现/被想起| D[巩固 → 语义记忆 / 抽象情境]
D --> L[长期记忆]
L -->|被想起| E[联想: 相似度 + PPR]
E --> F[进入上下文 → LLM]
L -->|长期不访问| G[遗忘: 艾宾浩斯衰减]
G -->|细节模糊, 大意保留| L
小结
- 产生:新信息先进滑动窗口,经摘要与巩固才成为记忆节点;
- 联想:从当前话题出发,相似度找种子、PPR 沿图扩散;被想起会“复习“这条记忆;
- 巩固:反复出现、反复被想起的经历,抽象成更高层的认知,连接被加强;
- 遗忘:遵循艾宾浩斯曲线,细节逐渐模糊而非删除;被反复回忆的记忆遗忘更慢。
到这里,核心概念部分就结束了。你已经知道了 SoulMem 是什么、记忆怎么分类、记忆住在哪里、生命周期是怎样的。如果你希望深入理解 SoulMem 的设计,或准备为 SoulMem 做一些贡献,我建议先看看 系统概览 · 总体架构——高层设计决策都集中在那里。这将为后续深入讲述技术细节提供一个共同的认知基础。
总体架构
上一章(核心概念)我们已经知道 SoulMem 是什么:一个以图组织记忆、用三层结构存放记忆、让角色像人一样记忆与遗忘的系统。这一章我们把视角拉高,回答另一个问题:这些概念是如何组装成一个可以运行的系统?
Note
本文档描述 SoulMem 当前的总体架构(依据
feature/test_framework分支的代码状态整理)。历史设计文档(如旧的beta_ver.md设计愿景)中的过时内容不再作为当前架构依据,相关演进记录见文末“与旧设计的差异“。
1. 项目定位
SoulMem 是一个专为角色扮演任务设计的记忆系统。它旨在使 LLM 的输出更拟人化,让模拟角色像人一样记住重要的、情感相关的、可驱动行为的事件并建立关联;不旨在精确无误地记忆事件细节或事实性知识。
- 面向个人用户、在家用电脑上运行,非企业级解决方案;
- 核心设计哲学:一切特征和事件都属于记忆——角色性格、口癖、行为习惯等都是长期记忆交互演化的结果,而非静态角色卡;
- 实现语言:Rust(workspace 多 crate 结构)。
2. 高层设计决策
在深入模块之前,先回答一个总问题:SoulMem 为什么长成了今天这个形态? 答案由几个“为什么“叠加而成——核心概念部分已经讲过“为什么分三类记忆、为什么用图、为什么分三层“,下面把其余的决策集中在一起。
2.1 为谁而做:个人用户、家用电脑
赛博生命的典型场景是个人的长期陪伴。要把陪伴真正交到普通人手里,有三个硬性条件:必须能自部署、目标硬件是家用笔记本电脑、用户不是开发者。
其中“自部署“不是工程偏好,而是产品灵魂问题:
- 隐私:陪伴意味着大量私密信息。把数据留在自己电脑上,是唯一让人安心的方案;
- 可靠性:陪伴不能“被迫离开“。政策变化、公司运营变化都可能让云端服务终止;自部署意味着只要你的电脑还在,你的数字伙伴就还在;
- 大众可及:赛博生命不应该是有钱人的玩具。自部署 + 家用电脑运行,让“拥有一个数字伙伴“成为普通人也能负担、也能掌控的事情。
试想一个陪伴了你半年的角色,因为一条“服务将于下月停运“的公告而永远消失——这是用户最难以接受的事。自部署把这种风险从产品里拿掉了。
这三个条件直接决定了技术形态(详见下文 Workspace 结构):
| 约束 | 后果 |
|---|---|
| 家用笔记本(资源受限) | 不能跑超大模型 → 算法为主、少用 LLM |
| 用户不是开发者 | 一键运行 → 编译为单个二进制,双击就能跑 |
| 没有云端基础设施 | 数据落在本地,不依赖外部数据库服务 |
2.2 能不用 LLM,就不用 LLM
这是 SoulMem 最重要的一条设计原则:
Important
能不用 LLM,就不用 LLM。让 LLM 只在最关键的地方发挥作用。
原因非常现实:
- 本地算力:个人用户跑不动足够强的模型,同时还要轻松地用电脑干别的;
- 成本:token 消耗是真金白银,而角色扮演场景的缓存命中率通常很低——每次对话都是新内容;
- 延迟:LLM 的响应是秒级的,频繁调用会拖垮对话体验。
所以 SoulMem 的策略是:用精心设计的数据结构和算法,完成绝大部分记忆工作——检索、联想(PPR)、相似度、遗忘衰减、连接强度调整,全部是确定性算法,零 LLM 调用。LLM 只做那些真正需要“理解“和“生成“的事:
- 把一段对话摘要/拆解成记忆节点(巩固);
- 对被遮罩的记忆做猜补(遗忘修订);
- (外部)根据检索结果生成角色的回复。
2.3 记忆用图组织,联想用 PPR
记忆的本质是关联。向量搜索适合“相似“,却不知道“连接“:它找不到“意大利面“与“42号混凝土“之间的联系,也凑不齐“姚明 → 叶莉 → 出生地“这样的多跳链路。图把连接显式地画出来——节点是记忆,边是带权重的关联——联想因此变成一件“图上自然发生“的事。
这个想法来自调研阶段的两位参考:
- A-mem:图结构的启蒙——记忆不是一堆独立的片段,而是互相连接的节点;
- HippoRAG:PPR 联想的来源——用知识图谱存储记忆,检索时用 Personalized PageRank 在图上扩散。
联想的具体算法是 PPR(Personalized PageRank),它的直觉是一个随机游走者:
想象一个小人在记忆图上随机游走。每一步,它要么沿一条边走到下一个节点,要么以概率 1−α “回家”(重启回起点)。走足够久之后,停在每个节点的频率,就是“每个节点相对起点的联想强度“。
这个建模天然对应人的联想:多跳可达(小人可以走很多步)、尊重边权(连接越强越想得起)、有焦点(不断重启,不会漫无目的地发散)。SoulMem 默认阻尼因子 α = 0.65,是一个偏发散的取值——角色扮演需要天马行空的联想。
2.4 三类记忆:自进化的前提
认知科学把记忆分为情境、语义、程序性三类,这个划分恰好对应三种完全不同的运作方式:经历、知识、行为。SoulMem 直接借鉴了这个分类,让每一类记忆都有自己的结构、自己的算法、自己的生命周期。
为什么必须分开?因为自进化本质上是“把一种记忆加工成另一种记忆“:反复发生的情境抽象成语义记忆(“用户喜欢辣”),反复出现的行为长成程序性记忆(“紧张就摸辫子”)。而“加工“要求系统知道每种记忆的形态——当所有记忆都被塞进同一个无结构节点时(alpha 版本的教训),自进化算法就没有操作对象。
Important
这正是:一切特征和事件都属于记忆——性格、口癖、行为习惯,都不是写在静态角色卡上的,而是记忆系统演化的结果。
2.5 遗忘是一等公民
很多记忆系统的目标是“记住一切“。但一个什么都记得的角色反而显得不真实:
- 琐碎会淹没重点:如果十年前的琐碎细节和昨天的约定同样醒目,真正重要的记忆反而被淹没;
- 精确记得每句话不像陪伴,更像监控:人记住的是“重要的、情感相关的、能驱动行为的“,这个筛选本身就是“在乎“的表现;
- 永不淡去的记忆会把角色钉死在过去:遗忘给了角色“放下“的能力。
SoulMem的遗忘机制被设计遵循艾宾浩斯曲线:刚记住时忘得最快,之后越来越慢;被反复回忆的内容遗忘得更慢。遗忘的方式是遮罩而不是删除——细节逐渐模糊,大意保留,像人一样“想不起来“而不是“不存在“;重度遗忘时,系统还会基于剩余内容调用 LLM 猜补,模拟人“努力回忆“的过程。
Warning
遗忘是有争议的机制,很多主流记忆系统都在回避它。但角色扮演场景下,遗忘是角色“会变“的来源之一——这也是 SoulMem 与大多数记忆系统最不一样的地方。
2.6 总结
把上面的决策收拢,SoulMem 的形态来自三个核心设计决策 + 一条总原则:
graph LR
A["为谁做:个人用户 · 家用电脑"] --> B["自部署"]
B --> C["Rust 单二进制"]
B --> D["算法为主 · 少用 LLM"]
E["记忆的本质是关联"] --> F["图 + PPR 联想"]
G["三种运作方式"] --> H["三类记忆"]
I["人会遗忘"] --> J["遗忘是一等公民 · 遮罩"]
- 三类记忆:情境 / 语义 / 程序性,各自独立的结构和处理方式(自进化的前提);
- 图 + PPR:用图组织全部记忆,用 PPR 做联想(HippoRAG 的启发);
- 遗忘是一等公民:模拟遗忘曲线,模糊而非删除(角色“会变“的来源之一)。
再加上“能不用 LLM 就不用 LLM“这条总原则,答案已经很明显:SoulMem 选择自己设计记忆系统,而不是套用一个现成的 RAG 框架——现成的框架是为“精确检索“设计的,而我们需要的是“像人一样的记忆“。这个差别,决定了后面所有的设计。
3. Workspace 结构
| Crate | 职责 |
|---|---|
soul-mem-core | 记忆数据模型:三类记忆(情境/语义/程序性)的节点与链接、ID 体系 |
soul-mem-algo | 记忆算法:检索/联想(PPR 系列)、遗忘、巩固 |
soul-mem-query | 嵌入与查询:embedding 模型接入、查询构建、相似度计算、检索计算 |
soul-mem-runtime | 运行时:工作记忆(滑动窗口 + LLM 客户端)、记忆簇(图存储与并发访问) |
soul-tune | 测试/评测框架(TUI):批量检索评测、playtest、遗忘/巩固评测 |
benches | 基准测试(PPR 性能) |
依赖与集成要点(依据 Cargo.toml):
- LLM 调用:
soul-mem-runtime使用async-openai(OpenAI 兼容 API,可指向本地 llama.cpp server 或任意兼容端点),soul-tune提供LlamaServer/Candle/Qwen3.5等评测后端。 - 嵌入模型:
soul-mem-query使用candle-core+embed_anything,支持 BGE 与 Qwen3 嵌入模型,text-splitter做长文本分块。 - 图结构:
petgraph::StableDiGraph承载记忆图(工作记忆/记忆簇)。 - 未使用:当前代码不依赖 SurrealDB、Qdrant、zenoh、gRPC(旧设计文档中的设想,见下文差异说明)。
运行形态
运行时以一个简单的两态状态机组织:Working(处理请求)与 Idle(空闲)。巩固等定期任务只在 Idle 状态下执行。状态迁移与检索时序详见 编排与数据流。
4. 记忆图:概念与数据模型
记忆以图的方式组织:节点是记忆(情境 / 语义 / 程序性),边是带权重的记忆关联。概念层面——记忆是什么、为什么分三类、关联是什么——已经在 核心概念 · 记忆图 讲过,这里不再重复;数据结构层面——每个节点、每条边在代码里长什么样——见 记忆模型。
系统的其余部分都建立在这张图上:长期记忆是持久化的全部记忆,工作记忆是从长期记忆激活的子图,滑动窗口是最近几轮对话。它们的协作方式见 编排与数据流。
5. 与旧设计(beta_ver)的差异
| 旧设计(beta_ver.md 愿景) | 当前实现 |
|---|---|
| SurrealDB 作为向量/图/时间序列数据库 | 无数据库依赖;记忆图在内存(petgraph)中,持久化通过文件/测试夹具 |
| async-openai 调用外部 LLM API | 保留 async-openai(可指向本地兼容端点),soul-tune 另有 Candle/LlamaServer 后端 |
| zenoh pub/sub 服务接口 | 未实现(orchestration 中的规划) |
| gRPC 接口 | 未实现 |
| 分块懒加载子图(长期记忆 → 工作记忆) | 测试框架直接加载完整图到 MemoryCluster;懒加载为规划项 |
| EdgePush PPR | 已实现 weighted_ppr_fp(带边权的 PPR)与多策略检索(见 检索与联想) |
| 巩固/整合机制 | soul-tune 已有巩固评测框架;运行时整合为规划项 |
接下来,记忆模型 将展开记忆图的数据结构;编排与数据流 将展示一次对话的完整旅程。
记忆模型
记忆以图的方式组织——这个概念在 核心概念 · 记忆图 里已经讲过:节点是记忆,边是关联。这一章回答另一个问题:记忆图在代码里长什么样?
我们从 soul-mem-core 的当前代码(feature/test_framework 分支工作区)出发,描述记忆的数据结构模型:三类记忆的节点结构、通用的节点/链接结构、以及图的构建方式。更完整的模块级说明见 soul-mem-core。
Note
本文档只讲数据结构:节点有哪些字段、边有哪些类型、图如何构建。图上的算法(PPR 联想、遗忘衰减、巩固)见 深入实现 相关章节。
1. 三类记忆
总记忆图分为三类子图,相互关联:
graph LR 情境记忆 <--> 语义记忆 语义记忆 <--> 程序性记忆 情境记忆 <--> 程序性记忆
1.1 情境记忆(Situation / Episodic)
记忆具体事件经历(如“昨天中午和同学出去吃了麻辣烫“)。节点分两类:
- 具体情境
SpecificSituation:narrative(叙述)+time_span(时间)+context(上下文)。 - 抽象情境
AbstractSituation:Location/Participant/Environment/Event四类抽象元素之一,作为具象情境的二级索引与子图间接口节点。
Context 的六个字段(结构定义于 soul-mem-core/src/memory_note/situation_mem.rs):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
location | Option<Location> | ❌ | 地点(name + coordinates) |
participants | Vec<Participant> | ❌ | 参与者(name + role) |
emotions | Vec<Emotion> | ❌ | 情感(name + intensity) |
sensory_data | Vec<SensoryData> | ❌ | 感官数据(name + intensity,当前保留) |
environment | Environment | ✅ | 环境(atmosphere + tone) |
event | Vec<Event> | ❌ | 事件(action + action_intensity + initiator + target) |
1.2 语义记忆(Semantic)
记忆通用概念、事实与关系(如“北京是中国的首都“),是图的中枢核心(类海马体索引)。
pub struct SemMemory {
pub content: String, // 概念/事实内容
pub aliases: Vec<String>, // 别名,去重消歧
pub concept_type: ConceptType, // Entity(具象)/ Abstract(抽象)
pub description: String,
}
语义链接负载:SemMemLink { verb: String, confidence: f32 }(谓词 + 置信度)。
1.3 程序性记忆(Procedural)
存储“肌肉记忆“、条件反射、行为习惯(如“沉思时会摸下巴“、“傲娇”)。
pub enum ActionType { Speak, Skill(SkillRecord), Think }
pub struct Action { content: String, action_type: ActionType }
pub struct ProcMemory { action: Action }
- 节点只有
Action(无独立 trigger 节点类型)。 - trigger→action 的转移关系建模为边:
ProcMemLink::TrigToAction { prob: f64 }(转移概率)。 - 语义记忆可直接连到 action 作为“概念性补充/自我认知“,不触发动作;由情境(trigger)联想 到的 action 才会进入动作提示词(旧设计约定,见 beta_ver.md)。
2. 通用节点与链接结构
2.1 记忆节点 MemoryNote
pub struct MemoryNote {
id: MemoryId, // UUID newtype
tags: Vec<String>, // 标签(暂定参与 embedding)
retrieval_count: usize, // 提取次数
create_time: DateTime<Utc>,
last_accessed_time: DateTime<Utc>, // 最后访问时间(LRU/热度相关)
mem_type: MemoryType, // 三类记忆之一
mem_links: Vec<MemoryLink>, // 该节点出发的出边
}
2.2 记忆链接 MemoryLink
pub struct MemoryLink {
id: LinkId,
from: MemoryId,
to: MemoryId,
pub intensity: f64, // 公共连接强度(默认 1.0),遗忘机制的关键载体
link_type: MemoryLinkType,
}
pub enum MemoryLinkType {
Proc(ProcMemLink), // TrigToAction{prob}
Sem(SemMemLink), // SemMemLink{verb, confidence}
Situation(SituationMemLink), // AbstractToSpecific{} / SpecificToAbstract{}
}
intensity(f64,默认 1.0)为公共边权;SemMemLink.confidence(f32)、TrigToAction.prob(f64)为类型特定权重。SituationMemLink::SpecificToAbstract:具体情境 → 抽象情境的反向边,PPR 从具体情境种子 游走到抽象模式节点后,抽象节点作为 Bayes 动作提取的优先源。
2.3 图结构
- core 层“边随节点存储“(
mem_links内嵌出边,边以 ID 引用两端节点)。 - 运行时由
soul-mem-runtime的MemoryCluster构建真实图:petgraph::StableDiGraph<EmbeddedMemoryNote, GraphMemoryLink>(EmbeddedMemoryNote= 记忆 + 嵌入向量),并维护mem_id_to_index/link_id_to_index映射与incompletely_linked_note待链接缓冲;通过MemoryClusterHandle并发访问。
3. 长期记忆与工作记忆
- 长期记忆:持久化记忆;测试框架中以
fixtures/graphs/<name>.json图文件加载 (格式见 测试数据规范)。 - 工作记忆:运行时激活的子图(petgraph),修改频繁;新记忆先进入工作记忆。
- 滑动窗口:最近几轮对话(短期记忆),窗口内记忆无条件加入最终检索上下文;滑出前 未巩固的记忆丢失。详见 编排与数据流。
4. 与旧设计(beta_ver.md)的差异速览
| 项 | 旧设计 | 当前实现 |
|---|---|---|
| time_span | 起止时间段 | 单个时间点 |
| Context | 含 situation 背景字段 | 无;新增必填 environment 与 event 列表 |
| 语义边 | verb + intensity + confidence | intensity 上移为公共字段 |
| 程序性记忆 | trigger/action 两类节点(待定) | 仅 Action 节点 + TrigToAction 边 |
| 抽象情境 | 独立抽象节点(二级索引) | AbstractSituation 枚举(四类抽象元素) |
下一章:编排与数据流——这些数据结构如何被串联成一个可以运行的系统。
SoulMem Orchestration
总述
SoulMem Orchestration主要指在SoulMem各个功能单元编写完成的情况下,将他们串联起来以形成按预期工作的流程的过程。
对外部,SoulMem以服务形式提供,通过zenoh的pub/sub,query,liveliness api以及通过grpc 🔲。服务的输入是query集合和当前信息增量(均为可选字段),输出是检索到的MemoryNote集合,其余组件均用于维护SoulMem自身的状态(巩固,遗忘等)
此外,服务的输入还有一组控制信号,用于强制触发一些定时任务 🔲
流程描述如下:
图例
色彩按角色/类型区分,实线 = 已实现,虚线 = 设计规划中(尚未实现):
| 角色类型 | 填充色 | 已实现(实线) | 规划中(虚线) |
|---|---|---|---|
| 输入 | 蓝色 | ✅ | 🔲 |
| 输出 | 紫色 | — | 🔲 |
| 结构实体 | 绿色 | ✅ | 🔲 |
| 算法流程 | 橙色 | ✅ | 🔲 |
graph TD
classDef inputImpl fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a8a;
classDef inputPlan fill:#dbeafe,stroke:#60a5fa,stroke-width:2px,stroke-dasharray:6 4,color:#1e3a8a;
classDef outputPlan fill:#ede9fe,stroke:#a78bfa,stroke-width:2px,stroke-dasharray:6 4,color:#4c1d95;
classDef structImpl fill:#d4edda,stroke:#16a34a,stroke-width:2px,color:#14532d;
classDef structPlan fill:#d4edda,stroke:#86efac,stroke-width:2px,stroke-dasharray:6 4,color:#14532d;
classDef algoImpl fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#92400e;
classDef algoPlan fill:#fef3c7,stroke:#fbbf24,stroke-width:2px,stroke-dasharray:6 4,color:#92400e;
subgraph "输入"
Input1["Query 集合"]
Input2["当前信息增量"]
Ctrl["控制信号(强制触发定时任务)"]
end
subgraph "算法流程:检索管线 DefaultPipeline"
direction TB
S1["① ShortOnly<br/>提取窗口信息 + 摘要"]
S2["② Similarity<br/>query 向量与 Cluster 节点余弦相似度,阈值过滤"]
S3["③ AssociateWithAction<br/>PPR 联想扩散 + softmax 归一化 + 贝叶斯动作推理 topK"]
S1 --> S2 --> S3
Res["DefaultPipelineResult<br/>{ association, action, short_history, short_mem, priority }"]
S3 --> Res
S1 -. 窗口和摘要 .-> Res
end
subgraph "结构实体:工作记忆"
SW["SlidingWindow 滑动窗口"]
Sum["Summary 摘要"]
Cluster["MemoryCluster 记忆簇"]
Record["Record 活跃记录"]
WM["WorkingState Idle/Working 状态机"]
end
subgraph "结构实体:外部存储"
DB["SurrealDb 数据库"]
end
subgraph "算法流程:定时任务及被动流程"
Cons["巩固算法 Consolidation"]
Persist["持久化"]
Forget["遗忘机制 ✅<br/>Ebbinghaus 衰减 + 遮罩 + LLM 修订"]
Mask["遗忘遮罩 ✅<br/>jieba 分词确定性遮罩"]
end
subgraph "输出"
Out["检索输出(MemoryNote 集合)"]
end
Input2 --> SW
SW --> Sum
Input1 --> S1
Input1 --> S2
SW --> S1
Cluster --> S2
Res --> Out
Res --> Record
WM -. 状态迁移 .-> SW
WM -. 状态迁移 .-> Cluster
Record -. 巩固时读取活跃节点 .-> Cons
Sum -. Idle 且摘要非空 .-> Cons
Cons -. 生成新 MemoryNote + 拓扑链接 .-> Cluster
Cluster -. 定时 / 优雅退出 .-> Persist
Persist --> DB
Cons -. 新节点创建时 .-> Mask
Mask -. 定时衰减权重 .-> DB
Out -. 命中被遮盖内容 .-> Forget
Ctrl -. 强制触发 .-> Cons
Ctrl -. 强制触发 .-> Persist
Ctrl -. 强制触发 .-> Forget
class Input1,Input2 inputImpl;
class Ctrl inputPlan;
class SW,Sum,Cluster,Record,WM structImpl;
class DB structPlan;
class S1,S2,S3,Res algoImpl;
class Cons,Persist,Forget,Mask algoPlan;
class Out outputPlan;
各 Crate 依赖与交互关系
代码按以下 5 个 crate 分层组织,箭头表示依赖方向(被依赖方 → 依赖方)。
graph TD
classDef core fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a;
classDef layer fill:#f1f5f9,stroke:#475569,stroke-width:2px,color:#1e293b;
core["soul-mem-core<br/>MemoryNote / MemoryLink 数据模型<br/>✅ 已实现"]
query["soul-mem-query<br/>Embedding 生成 / Query 类型 / 相似度计算<br/>✅ 已实现"]
runtime["soul-mem-runtime<br/>WorkingMemory / SlidingWindow / Cluster / Record / LLM 摘要<br/>✅ 已实现"]
algo["soul-mem-algo<br/>检索策略 RetrStrategy / DefaultPipeline 编排<br/>✅ 已实现"]
tune["soul-tune<br/>CLI 基准测试框架(TUI)<br/>✅ 已实现"]
core --> query
core --> runtime
query --> runtime
core --> algo
query --> algo
runtime --> algo
core --> tune
query --> tune
runtime --> tune
algo --> tune
class core core;
class query,runtime,algo,tune layer;
soul-mem-core:纯数据模型,无任何内部依赖,是其余 crate 的基础。soul-mem-query:依赖 core,负责文本→向量嵌入(BGE/Qwen3 模型)与查询类型定义。soul-mem-runtime:依赖 core、query,维护工作记忆(滑动窗口、记忆簇、活跃记录)并封装 LLM 摘要调用。soul-mem-algo:依赖 core、query、runtime,实现全部检索策略,其中RetrDefaultPipeline串联三步构成完整检索管线。soul-tune:依赖全部 crate,是用于基准测试的命令行工具,非运行时组件。
注:
soul-mem-runtime对soul-mem-algo的依赖仅存在于dev-dependencies(测试用),生产依赖图中不存在反向依赖。
查询请求完整生命周期
一次完整检索请求从外部输入到最终输出的时序如下。颜色含义与主图一致(见上文图例),橙色矩形内的内容为规划中。
sequenceDiagram
autonumber
actor Client as 外部调用方
participant Svc as Service 编排层
participant SW as SlidingWindow 滑动窗口
participant LLM as LLM 摘要模型
participant P as DefaultPipeline 检索管线
participant CL as MemoryCluster 记忆簇
participant WM as WorkingMemory / Record
participant DB as SurrealDb 外部存储
participant BG as 后台定时任务
Note over Client,Svc: 请求 = query 集合 + 当前信息增量(均为可选)+ 控制信号
Client->>Svc: query[], infoDelta?, priority
alt 存在信息增量
Svc->>SW: push(infoDelta, role)
SW->>SW: auto_tag(每 capacity 次标记一条)
alt 窗口超容量弹出被标记信息
SW->>LLM: summarize(旧摘要 + 窗口 + 被弹出信息)
LLM-->>SW: 更新 Summary
end
end
Svc->>P: retrieve(query, priority, working_mem)
P->>SW: ① ShortOnly:提取窗口内容 + 摘要
SW-->>P: (short_history, short_mem)
P->>CL: ② Similarity:query 向量余弦相似度检索
CL-->>P: top-N (MemoryId, score)
P->>P: ③a Association:PPR 联想扩散(以 top-N 为源节点)
P->>P: ③b softmax 归一化
P->>P: ③c BayesAction:动作概率推理 topK
P-->>Svc: DefaultPipelineResult { association, action, short_history, short_mem, priority }
Svc->>WM: record_retrieval / add_feedback 更新活跃记录
rect rgb(254, 243, 199)
Note over Svc: 🔲 多 query 按优先级加权合并
Note over Svc: 🔲 逐 MemoryNote 归一化 → top-K → 提取内容 → 模板填充为自然语言
end
Svc-->>Client: MemoryNote 集合 / 自然语言输出
rect rgb(254, 243, 199)
Note over BG,DB: 🔲 定时任务及被动流程(规划中)
Note over BG: WorkingState = Idle 时定时触发
BG->>SW: 读取摘要
alt 摘要非空
BG->>BG: 巩固算法:摘要 + 滑动窗口 → 新 MemoryNote
BG->>WM: 读取活跃 Record(检索中被激活的节点)
BG->>BG: 活跃 MemoryNote 与新节点建立拓扑链接
BG->>CL: 新节点入簇
BG->>DB: 持久化写入
end
Note over BG: 新节点创建 → 生成遗忘遮罩
Note over BG: 定时衰减数据库中的遮罩权重
Note over BG: 检索结果命中被遮盖内容 → 遗忘补全
end
行为流程说明
状态更新(2026-08):遗忘机制已实现(
soul-mem-algo/src/algo/forget/:ebbinghaus_decay衰减曲线、mask_text文本遮罩、lazy_forget三档 NoAction/MaskOnly/Revised),并由soul-tune run forget评测。巩固/持久化仍为 🔲。
当没有query输入时,信息增量被压入滑动窗口,之后可能会触发滑动窗口的summary机制,并生成新的摘要 ✅
当只有query时,走检索算法的DefaultPipeline,得到DefaultPipelineResult,一份query对应一个DefaultPipelineResult ✅,后续根据query的优先级,以每一个MemoryNote为单位,将分数加权平均,取top-k并提取记忆内容,按照模板填充为自然语言,输出 🔲
当query和信息增量同时存在时,先执行信息增量压入,summary完成后执行检索算法 ✅
当工作记忆状态为Idle时,每隔一段时间,如果摘要不为空,执行巩固算法,它根据摘要和滑动窗口生成新的MemoryNote并建立与Cluster的拓扑链接 🔲
活跃记录(Record)追踪检索中被激活的MemoryNote;巩固时,被标记为活跃的MemoryNote将作为新生成节点的候选拓扑链接目标 🔲
当工作记忆状态为Idle时每隔一段时间,或服务优雅退出时,将工作记忆节点写入数据库持久化 🔲
每当新节点被巩固算法创建时,生成遗忘遮罩 🔲
每隔一段时间,衰减数据库中遗忘遮罩权重 🔲
每当含有“被遮盖”的文本的MemoryNote作为检索算法的最终结果时,调用遗忘补全 🔲
外部接口
除了上述的服务输入外,还提供额外的接口(均规划中 🔲)
- 对指定id的MemoryNote读写
- 控制信道,强制触发上述的定时任务
- liveliness/heartbeat
SoulMem 集群架构说明书
本文档定义 SoulMem 所属角色扮演系统的分布式集群架构,是
orchestration.md中 “通过 Zenoh pub/sub “这一传输层设想的替代方案。二者在传输与运行时 选型上冲突时,以本文档为准。
0. 文档定位与决策记录
本文档是集群层的权威说明,覆盖:节点模型、通信协议、监督与失败清理、部署拓扑、 信任模型与风险。所有未实现项以 🔲 标注,已实现项以 ✅ 标注,沿用仓库既有约定。
决策记录(ADR)
| 决策项 | 结论 | 主要理由 |
|---|---|---|
| 运行时 | BEAM(Erlang VM) | 天生面向分布式;OTP 提供进程监督、link/monitor 失败传播、supervisor 树 |
| BEAM 语言 | Elixir | 生态成熟(libcluster、msgpax 等现成);OTP 文档/社区答案密度最高 |
| 重计算组件 | 保持 Rust 长命服务 | llama.cpp / candle 原生推理、巩固/遗忘长循环不适合放进 BEAM 进程 |
| 数据面协议 | MessagePack + 4 字节长度前缀 | 跨语言覆盖最广、与 serde 无缝、二进制紧凑、无 codegen |
| 控制面传输 | Erlang 分布(现阶段) | BEAM 语言免规范直接互通,30 年成熟 |
| 信任模型 | 现阶段统一全信任集群 | 降低开发压力;风险向用户披露(见 §7) |
1. 架构总览
系统由两类实体组成,通过两条不同性质的通道互联:
graph TB
subgraph "BEAM 集群(Erlang 分布,控制面)"
N1["节点 A<br/>Elixir node<br/>nameA@hostA"]
N2["节点 B<br/>Elixir node<br/>nameB@hostB"]
N3["节点 C<br/>(Gleam/Erlang 亦可)"]
N1 <-->|"Erlang 分布<br/>免规范、全信任"| N2
N2 <--> N3
N1 <--> N3
end
subgraph "本地 Rust 服务(数据面)"
R1["SoulMem 服务<br/>(socket + MessagePack)"]
R2["LLM 推理服务"]
R3["其他 Rust 组件"]
end
N1 -->|"port/socket<br/>localhost"| R1
N1 --> R2
N2 --> R3
两条通道,性质完全不同:
| 通道 | 参与者 | 机制 | 是否需自定义规范 |
|---|---|---|---|
| BEAM ↔ BEAM | 各 BEAM 节点 | Erlang 分布(EPMD + cookie + 分布式协议) | 否(VM 内置) |
| BEAM ↔ 本地服务 | 本机 Rust 进程 | socket / port + 长度前缀 + MessagePack | 是(见 §4) |
关键点:跨机通信永远只发生在 BEAM↔BEAM 之间;Rust 服务永远只与本机 BEAM 节点 通信,不跨机。这样“跨机“的复杂度被完整外包给 Erlang 分布,Rust 侧无需感知网络拓扑。
2. 逻辑节点模型
一个逻辑节点 = 一个本地后台服务(任意语言) + 一个本机 BEAM 代理进程(GenServer)。
逻辑节点 N
├── Rust 后台服务(长命,有状态) ← 真正的计算/存储单元,如 SoulMem
└── BEAM 代理进程(GenServer,ambassador)← 唯一对外入口
├─ 负责与 Rust 服务收发消息、翻译为 BEAM 消息
├─ 由 supervisor 托管,崩溃时自动重启
└─ 对外只暴露一个 GenServer pid/名字,集群其他节点只认它
- 集群内其他节点只认识代理进程,完全不关心其背后是 Rust 还是别的语言。
- 代理进程是 BEAM 世界的一等公民,因此自然获得:名字注册、跨节点
rpc、monitor、监督。 - Rust 服务是长命服务(
restart: :permanent),只在崩溃时重启,不会因正常退出被替换。 这正好表达 SoulMem “必须长期执行巩固/遗忘“的语义。
3. 监督与失败清理
这是本架构的核心能力,全部复用 OTP,无需自研。
3.1 三级恢复层级
graph TD
OS["① OS 层:systemd user service / 容器<br/>拉起并守护整个 BEAM node"]
BEAM["② BEAM node:OTP application + supervisor 树<br/>监督所有代理进程与内部 actor"]
RUST["③ Rust 服务:被代理进程以 port 托管<br/>崩溃即退出,由 supervisor 重启"]
OS --> BEAM --> RUST
- ① 保证 BEAM node 本身崩了也能被拉起(OTP 管不到自己)。
- ② 保证 node 内的进程崩溃被隔离、被按策略重启。
- ③ 保证 Rust 服务的崩溃被检测、被重启,且其依赖者收到通知。
3.2 依赖的自动逆栈清理
OTP 对树形依赖开箱即用:
| 机制 | 语义 | 对应需求 |
|---|---|---|
link | A link B,B 崩溃 → 退出信号传播给 A | 依赖死了,依赖者跟着死 |
monitor | A monitor B,B 崩溃 → A 收到 {:DOWN, ...} 自行清理 | 依赖死了,依赖者执行清理 |
supervisor rest_for_one | 子进程按顺序启动;某个崩溃 → 它及其后的所有子进程一起重启 | “依赖崩了,下游依赖者逆序清理重建” |
| supervisor 逆序关闭 | 关闭时按启动逆序终止(后启的先停) | 字面意义的“逆栈清理“ |
| application 依赖 | applications: [B] → 启 A 必先启 B;停 B 先停 A | 跨应用逆序关闭 |
实践约定:supervisor 中按“依赖者靠后“的顺序 start_child(先启动底层服务 C,再 B,再 A),
并配 rest_for_one,即可让 C 崩溃时 C、B、A 依次重启。
3.3 节点 down → 依赖清理
- 节点内进程崩溃:
monitor收到DOWN→ 代理进程通知其上游 → supervisor 逆序重启。 - 整个节点断开:其他节点上对它的
Node.monitor(node)收到{:nodedown, node}→ 清理该节点名下的所有会话/角色状态 → 触发依赖者重启。
注意 OTP 语义:“不可达“即视为 down。它无法区分“机器真崩“与“网络分区”, 跨公网时需要防 split-brain(见 §7.3)。
3.4 SoulMem 集成示意(Elixir)
defmodule SoulMem.Server do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(opts) do
# 以 OS 进程方式托管 Rust 服务;:exit_status 使崩溃可被感知
port = Port.open({:spawn_executable, soulmem_bin()}, [:binary, :exit_status])
{:ok, %{port: port, sock: nil}, {:continue, :connect}}
end
def handle_continue(:connect, state) do
case :gen_tcp.connect(~c"127.0.0.1", port_number(), [:binary, active: true, packet: 4]) do
{:ok, sock} -> {:noreply, %{state | sock: sock}}
_ -> {:noreply, state, {:continue, :connect}} # 退避重连
end
end
# Rust 进程崩溃 → 通知依赖清理 → 交由 supervisor 重启
def handle_info({_, {:exit_status, code}}, state) do
SoulMem.Dependents.handle_down()
{:stop, {:soulmem_exited, code}, state}
end
# socket 断开(服务掉线)→ 同理
def handle_info({:tcp_closed, _sock}, state) do
SoulMem.Dependents.handle_down()
{:noreply, %{state | sock: nil}, {:continue, :connect}}
end
end
supervisor 中配 child_spec(SoulMem.Server, restart: :permanent)。
4. 通信协议规范(数据面)
仅用于 BEAM ↔ 本机 Rust 服务。BEAM↔BEAM 不需要本节内容。
4.1 线协议(Framing)
[ 4-byte big-endian length ][ MessagePack payload ]
- BEAM 侧:
:gen_tcp/open_port加{:packet, 4}即可,内建免手写。 - Rust 侧:
tokio_util::codec::LengthDelimitedCodec与之对应。
4.2 编码约定(跨语言必须钉死)
| 约定 | 规则 |
|---|---|
| 结构体编码 | 一律编码为 map(字段名作 key),禁用 array 编码 |
| 字符串 | 文本一律用 str(UTF-8);bin 仅用于原始字节 |
| 整数 | 显式区分有符号/无符号,两侧类型一致 |
这是 MessagePack 跨语言唯一比 JSON 多出的成本,但只需在文档写死三条即可互读。
4.3 消息信封
所有数据面消息都套一个统一信封:
| 字段 | 类型 | 说明 |
|---|---|---|
protocol | str | 协议名 + 主版本,全局唯一,如 "soulplan.plugin.v1" |
msg_id | str | UUID,用于请求-响应对应 |
kind | str | "req" | "res" | "event" |
service | str | 目标服务名,如 "soulmem"(现阶段按服务名路由) |
op | str | 操作名,如 "submit_event" / "retrieve" / "consolidate" |
payload | map | 具体参数/结果 |
err | str | nil | 仅 res 携带,非空表示失败 |
plugin/capability/cap_version等字段 🔲 预留,待引入能力注册(见 §8)后启用。event用于推送(如“巩固完成“、“记忆更新”),无msg_id可省略。
4.4 SoulMem 首版操作集 🔲
| op | kind | 说明 |
|---|---|---|
submit_event | req | 提交事件增量 |
retrieve | req | 触发检索,返回 MemoryNote 集合 |
consolidate | req | 强制触发巩固 |
forget | req | 强制触发遗忘 |
heartbeat | event | 周期性存活/状态上报 |
具体 payload schema 由 soul-mem-core 的 serde 类型为准,两端各 derive/定义一次即可。
5. 部署拓扑演进
三者是同一套代码的渐进形态,仅配置不同。
graph LR
A["① 单机多进程<br/>多 BEAM 进程 + 本地 socket"] --> B["② 家庭 LAN<br/>Erlang 分布 + libcluster 自动发现"]
B --> C["③ 跨公网<br/>套 VPN(Tailscale/WireGuard)"]
5.1 单机多进程(主要场景 ✅)
- 一台机器上:一个(或少数)BEAM node + 若干 Rust 服务,经
127.0.0.1socket/port 通信。 - 若需多进程隔离,可起多个 node,彼此仍走 Erlang 分布。
5.2 家庭 LAN 多机 🔲
- 节点名
name@ip或name@hostname.local;共享同一 cookie。 - 用
libcluster的Epmd/Gossip策略实现旧设备自动入网。 - 旧设备只跑 BEAM node(轻量)做协调/世界状态,重推理留在主力机。
5.3 跨公网(VPN 伪装成 LAN)🔲
- 内网穿透/端口转发不适用:Erlang 分布需要节点间双向、多端口、对等连接。
- VPN(Tailscale / ZeroTier / WireGuard)适用:让远端机器获得同一私网段的稳定虚拟 IP, 对 BEAM 而言远端节点就变成“局域网里多了一台机器“,代码无需改动。
- 需调
net_ticktime拉长检测窗口,避免公网抖动造成“假 nodedown“。
6. 语言可替换性
本架构绑定“运行时“与“协议“,不绑定具体语言:
- 控制面:任何编译到 BEAM 字节码的语言(Erlang / Elixir / Gleam)都是集群一等公民, 经 Erlang 分布直接互通,且可混布、逐节点灰度替换,不停机。语言可换,VM 能力不变。
- 数据面:任何实现“socket + 长度前缀 + MessagePack“的语言都可作为叶子服务接入 (Rust/Go/Python/C++/… 均有成熟库),藏在 BEAM 代理之后。
这意味着团队将来若想从 Elixir 迁到 Gleam,或新增 Go/Python 服务,都不需要推翻架构。
7. 信任模型与安全风险 ⚠️
7.1 现阶段策略:统一全信任集群
为降低开发压力,现阶段所有节点一律直接加入 Erlang 分布集群,不做信任分级。 这是明确的有意取舍,其风险如下,请向最终用户如实披露:
| 风险 | 说明 |
|---|---|
| Cookie 即全权限 | Erlang 分布靠共享 cookie 认证;持有 cookie 的节点可对任意节点执行任意 rpc、读取/篡改进程状态、杀掉任意进程。它不是“接入一个窄 API“,而是“拿到全屋钥匙“ |
| 远程任意代码执行 | rpc 可在远程节点执行任意模块函数;一个恶意/有缺陷的节点足以控制整个集群 |
| 模块命名冲突 | BEAM 模块是平铺 atom,无真命名空间;第三方插件撞名会直接冲突 |
| 崩溃波及范围 | 进程隔离良好,但 VM 级故障(OOM、NIF segfault、内存耗尽)会带走整个 node 及其上的全部会话 |
| 默认无加密无认证 | 原生分布不加密;需 OTP 26+ 的 TLS distribution 才有传输加密 |
| 端口暴露 | EPMD 4369 + 一组动态端口;误暴露到公网即面临上述全部风险 |
7.2 现阶段缓解措施
- 仅限家庭可信环境使用;不将任何节点暴露到公网。
- 使用高熵长 cookie(而非默认或弱口令)。
- 防火墙限制 EPMD 与动态端口范围仅在受信网段可达。
- 跨公网时必须套 VPN(自带加密认证),并叠加 TLS distribution。
7.3 未来方向 🔲
- trusted / untrusted 分级:可信节点进集群;不可信第三方插件降级为“socket 服务“经代理接入, 进程隔离 + 崩溃隔离 + 命名隔离,不接触集群 cookie。
- 能力注册 + 契约:引入 manifest(
provides/requires+ semver)、启动时依赖校验、 能力路由,取代现在的“按服务名路由“。 - 防 split-brain:跨公网时明确状态的单一归属,避免双方误判对方 down 后各自接管同一状态。
8. 与现有组件的关系
soul-mem-core / query / runtime / algo:不变,仍为 Rust 库;对外暴露为长命服务。soul-tune:CLI 基准测试工具,与集群无关,维持现状。orchestration.md:其检索管线/状态机/数据模型仍然有效;仅“Zenoh + gRPC 传输“部分被本文档取代。- SurrealDB 持久化:仍由 Rust 服务直接访问;BEAM 层不接触数据库。
9. 待办
- 定义 SoulMem 首版消息 schema(§4.4 各 op 的 payload)。
- 最小闭环验证:Elixir 控制面 + Rust 假服务 +
rest_for_one,kill 后验证自动重启与逆序清理。 - 引入
libcluster,打通 LAN 双机自动发现。 - 评估 TLS distribution(OTP 26+)与 VPN 组合的跨公网方案。
- 设计 trusted/untrusted 分级与能力注册(§8)。
SoulMem Beta ver.(设计历史)
⚠️ 文档状态:本文档为早期设计愿景(2025 年起草),其中 SurrealDB/Qdrant 数据库、 时间序列、zenoh 服务等描述未在当前代码中实现。程序性记忆 trigger/action 结构等也与 当前代码不同(见 记忆模型)。保留本文档作设计演进参考;当前架构以 总体架构 为准。
SoulMem是一个专为角色扮演任务设计的记忆系统,它旨在使LLM的输出更拟人化成为可能,让模拟角色像人一样记住重要的、情感相关的、可驱动行为的事,它不旨在精确地记忆细节,知识。
SoulMem是针对于个人用户,在家用电脑上运行的记忆系统,并非企业级解决方案。
SoulMem的alpha版本中,采用统一的MemoryNote作为记忆节点,导致后续实现概念性,程序性记忆时,非常困难,故调整架构。
总体说明
SoulMem本质是一个复杂的RAG系统,它结合了向量搜索和知识图谱,以图的方式组织记忆数据。SoulMem被设计具有以下能力:
- 记忆的整合与进化
- 记忆的遗忘
- 基于工作记忆子图的记忆联想(Personalized PageRank)
- 记忆的动态添加
SoulMem使用rust开发,使用async-openai进行LLM API调用,使用SurrealDB作为向量,图,时间序列数据库。
注:SurrealDB是一个多模态数据库,没有提供一些有用的算法,但对于我们当前的场合应当够用,只使用一个数据库,我们换来的是开发时不需要维护数据一致性的巨大便捷,以及家用电脑上不需要同时加载多个数据库的大量内存开销。
整体组织
记忆整体上仍以图的方式组织。
总记忆图可以分为三类子图
- 情境记忆子图(Episodic)
- 语义记忆子图(Semantic)
- 程序性记忆子图(Procedural)
graph LR 情境记忆 <--> 语义记忆 语义记忆 <--> 程序性记忆 情境记忆 <--> 程序性记忆
情境记忆子图(Episodic)
情境记忆主要用于记忆具体的事件经历,例如“在昨天的中午12:00和同学出去吃了顿麻辣烫,自己被辣喷了”这类型的描述就属于情境记忆。
情境记忆节点分为两类:
- 具体情境节点
- 抽象情境节点
具体情境节点
情境记忆节点具有以下属性:
- narrative
- 情境的自然语言描述
- time_span
- 情境的起止时间
- context
- 情境发生的上下文
对于context,有以下属性:
Option<Location>- 情境发生的位置
Vec<participant>- 情境发生时,参与的对象
- 对象可以是自己,他人,物体或其他(就是暂时不知道怎么分类的东西)
- 情境发生时,参与的对象
Vec<emotion>- 情境发生时,自己当时的情感
- 情感具有标签分类和强度两个属性
- 情境发生时,自己当时的情感
Vec<SensoryData>- 情境发生时的感官描述
- situation
- 情境发生时,具体上下文(背景)的自然语言描述
具体情境记忆在SurrealDB中,还建立时间序列,这意味着允许通过时间段来回忆对应时间段的情境记忆
抽象情境节点
抽象情境节点总括一类具体的情境,例如“在雨天散步”,“生日聚会”就属于抽象情境,这些情境的描述都可以被具象化,例如“在昨天和同学在操场雨中乐走”。
抽象情境节点与它的所有对应具象节点建立联系,充当二级索引,以及子图间联系的重要接口节点。然而,具象情境节点也可能直接与外部建立联系。
语义记忆子图(Semantic)
语义记忆子图主要用于记忆通用的概念和事实,关系等,例如“北京是中国的首都”,“原神是mihoyo旗下的一款游戏”,“张三是李四的好友”,“王五是某角色激推”都属于此类型的记忆。
语义记忆以传统知识图谱的方式组织,是总记忆图的中枢核心,承担类似海马体索引的功能。语义记忆节点本身就是接口节点。
语义记忆节点具有如下属性:
- content
- 具体的概念,事实内容,通常为词汇或词组
Vec<alias>- 别名,用于去重,消歧,保证图谱的精简
- concept_type
- 概念类型,Entity(具象的人,物品,如“张三”,“原神”),或Abstract(抽象概念,如“自由”,“意义”,“生命”等)
- description
- 一些更具体的限定描述,以自然语言组织
语义记忆的边具有如下属性
- verb
- 谓词,表明节点间的具体关系
- intensity
- 连接强度
- confidence
- 连接置信度
程序性记忆子图(Procedural)
- (以下待商讨确定)
程序性记忆主要存储“肌肉记忆”,“条件反射”, “行为习惯”这一类的执行相关的记忆,例如“沉思时会摸下巴”,“听到铃声就害怕的蜷起来”, “明明很关心对方嘴上却强调不在乎(傲娇)”都属于程序性记忆。
程序性记忆中有节点类型的区分,主要分为:
-
trigger
- 情境触发器,可以由一种抽象情境或具象情境组成,一般由情境记忆提供。
- trigger节点在PPR的结果中是不可最终抵达的,只能作为途径的路径
-
action
- 具体的,可以用作LLM的Prompt的,指导性的行为自然语言描述
- 它通常并非是具体动作,如‘抬起右手”,而是描述一类行为倾向,例如“否定自己的关心意图”,“对他人成果进行贬低”,以获得更好的具体情境上下文的适应性,除非这个特定动作是角色的标志性特征,例如“每句话末尾加death”
graph LR triggerA --P=0.8--> actionA triggerA --P=0.2--> actionB triggerB --P=0.4--> actionC triggerB --P=0.3--> actionB triggerB --P=0.3--> actionA
子图间的联系
除了三大子图内互相的联系外,子图间也具有以语义记忆子图为中枢的子图间联系。
语义记忆 <–> 情境记忆
语义记忆中的节点可以连接到多个抽象或具象的情境记忆,比如语义节点中“项目”这一概念,可能会联系到情境记忆中“赶项目”的抽象情境记忆,进而联系到“与伙伴熬夜赶项目差点晕过去”的具体记忆。直接联系到具象情境记忆表面这个具体情境影响力很大。
这种联系支持了从概念抽取一类情境的能力,可以解决一部分的多跳问题,同时也保留了让某些具体情境“更突出”的能力。
注:由于具象情境记忆通常由抽象情境记忆索引,因此如果具象情境记忆直接与外部相连,相当于缩短了图中的路径距离,由于PPR具有局部特性,具象情境记忆会更容易被检索到,以及更容易从这个具象情境检索到其他相关内容。此时如果再赋予较高的边权,它的影响会进一步放大。
情境记忆 <–> 程序性记忆
情境记忆,不论是抽象情境还是具体情境节点,都可以作为程序性记忆的trigger,抽象情境更多描述的是一种“条件反射”,比如“听到铃声” –> “润出教室”,具体情境更多是一种极其强烈的影响,例如“完成某个研究并成功发了顶刊” –> “骄傲自豪的介绍成果”,或者是某些创伤性记忆,例如“某次具体的被虐待经历” –> “感到极度恐惧,话语颤抖”。
语义记忆 <–> 程序性记忆
语义记忆中的节点可以直接连接至action,但是,经由语义记忆路径联想到的action不会被触发,而是作为一种概念性补充和自我认知,例如语义记忆节点“张三”联系到动作“无视”。以下说明区别:
-
如果由抽象情境记忆“遇到张三”联想到动作“无视”,那么无视的行为被触发,会进入指导LLM动作的提示词
-
如果由语义记忆“张三”联想到动作“无视”,那么无视的行为不会被触发,作为补充上下文加入LLM提示词,让角色获得“遇到张三时我通常会直接无视”的自我认知
-
(此部分有待讨论)
长期记忆与工作记忆
长期记忆与工作记忆均是由三大子图(情境记忆,语义记忆,程序性记忆)组成的,工作记忆一定是长期记忆的子图。
长期记忆
长期记忆存放在数据库中,是持久化的记忆,每隔一段时间,长期记忆内部会进行整合,优化,同时执行遗忘,模糊等操作
工作记忆
工作记忆是在记忆系统运行过程中,从长期记忆中激活的子图。任何除了长期记忆中上文提到的算法,都是在工作记忆中运行的。工作记忆使用petgraph作为图存储。工作记忆中记忆的修改是较为频繁的,在对话中生成的新记忆也首先加入工作记忆,工作记忆中激活频率达到一定次数的记忆将被巩固为长期记忆,否则会被丢弃。
工作记忆中还存在一个滑动窗口,由于记录最近几轮用户与LLM的对话,处于滑动窗口中的记忆可视为“短期记忆”,如果在滑出前没有被加入工作记忆,这些记忆就会丢失。滑动窗口中的记忆无条件的加入最终一次记忆提取生成的上下文。
运行流程
概述
我们以LLM扮演的傲娇角色给好朋友(用户)送礼为例:
---
title: 上层工作流程
---
graph LR
subgraph 具体执行
subgraph 外部意图生成
C[外部LLM产生送礼“念头”(此处是外部组件的输出,作为系统的输入起点)]
C --> E[LLM生成行为意图(表达关心)]
end
C --> D[LLM判断要检索的内容,生成Query结构体]
D --> F[执行检索]
F --> G[情境(上次送了咖啡机,对方很高兴)]
F --> H[语义(喜欢咖啡,不喜欢文玩装饰)]
E --> I[提取程序化记忆]
I --> J[trigger: 表达关心]
J --> F
F --> L[action: 极力否定自己实际上的关心意图,装作巧合(傲娇表现)]
L & G & H --> M[合成为上下文Prompt]
M --> N[送往LLM执行]
end
subgraph 记忆准备
A[从Episodic中,综合提取对方喜欢咖啡,不喜欢文玩装饰] --> B[整合进Semantic]
end
在具体的执行检索和提取记忆的环节,有如下流程:
---
title: 检索具体流程
---
graph TB
A[长期记忆] --向量相似度搜索--> B[初始节点UUID]
B --> C{UUID在工作记忆中?}
C --是--> D{这个节点是工作记忆子图中的边界位置(节点入度或出度=0)?
是否有未加载的邻居?}
D --是--> E[提取邻近k=c内的子图加入工作记忆]
C --否--> E
E --> F[执行游走算法(EdgePush PPR)]
D --否--> F
F --> G[找出评分前n个的记忆]
G --> H[送往调用检索方,作为上下文]
G --> I[记录为激活的记忆(起点到其他点的链路均被记录)]
I --> J[更新热点记忆(热点记忆将被直接预加载)]
subgraph 定期任务
K[根据激活的记忆,更新链路连接强度(LTP,LTD)]
L[若某一子图(节点)长期未激活,释放此片内存]
end
这样的结构控制了上下文的数量,防止上下文爆炸。
可以为SoulMem预设置一些核心记忆,这些记忆将作为特殊的热点记忆,每次都会被加入到上下文中,确保角色的一致性。
综合如下
graph TD
subgraph "长期记忆 (SurrealDB + Qdrant)"
A[情境记忆] --> B[语义记忆]
B --> C[程序性记忆]
C --> A
end
subgraph "运行时 (工作记忆 petgraph)"
D[滑动窗口: 最近对话] --> E{是否关键?}
E -->|是| F[加入工作记忆]
E -->|否| G[滑出即丢]
H[LLM生成意图] --> I[生成Query]
I --> J[ANN向量搜索]
J --> K[获取UUID]
K --> L{在工作记忆中?}
L -->|是| M{是否边界节点?}
M -->|是| N[扩展邻近子图]
L -->|否| N
N --> O[PPR游走]
O --> P[评分Top-n记忆]
P --> Q[合成Prompt]
Q --> R[LLM生成回复]
R --> S[新记忆入工作记忆]
S --> T{激活频率高?}
T -->|是| U[写入长期记忆]
T -->|否| V[丢弃]
end
subgraph "定期任务"
W[更新连接强度 LTP/LTD]
X[释放长期未激活子图]
Y[预加载热点记忆]
end
存在的问题
-
如何正确构造Prompt让LLM能准确生成行为意图和Query结构?
-
重要:由于我们分块加载子图,如果节点处于临近边界的位置(例如再移动至下一个节点就到达图的边界),可能会丢失一部分的子图信息(未被加载入工作记忆)
可能解决方案:
- 强制每次检索到的UUID加载其附近子图
- 设置软标记标记这个节点缺失了邻居
- 智能懒加载,在PPR结果出现异常时加载
-
记忆满足什么条件才能被加入工作记忆?
-
记忆的整合应该如何执行?
记忆的整合与巩固
记忆的整合指的是将多个类似描述合并为同一个记忆节点,以及建立记忆之间的联系的过程。 巩固指由短期记忆(滑动窗口)转化为工作记忆,或从工作记忆转化为长期记忆的过程。
记忆的整合与巩固是SoulMem系统动态演化的基础,具有与检索同等重要的地位。
短期 –>工作
- 待定任务,尝试减少或限制LLM的调用次数,防止过高的延迟和api费用。
短期记忆由滑动窗口实现,我们暂时假定这个滑动窗口有3轮对话,对话顺序按消息从上到下
graph LR A[User: 在干什么?] B[LLM: 在凹分] C[User: 还在凹分,你联动不备战了是吧?] D[LLM: 我不喝咖啡,以及没米] E((User: 晚上作业写没?)) F[LLM: 课都没上完我拿头写?]
在滑动窗口中,我们每隔一定的消息数,为某个消息打上标记,称为摘要标记(图中圆形消息),摘要标记的间隔满足以下要求:
- 摘要标记的间隔不大于滑动窗口大小
- 用于保证每条消息都能被摘要
每当带有摘要标记的消息将要滑出窗口时,对滑动窗口内的信息进行一次精简摘要。摘要是累加性的,即此次摘要会在上一次的摘要的基础上继续摘要,这一特殊的临时摘要称为 “摘要记忆”。
- 这种累加性的摘要让细节性内容随着轮次提升被逐渐遗忘,同时又能保证对话的一致连续性,文档的编写者认为比较符合人类的认知规律
每当隔一段时间,或者一次对话完成,或者外部模块明确指明了需要工作记忆整合的意图,执行工作记忆整合,工作记忆整合指工作记忆范围内的记忆更新。文字流程描述如下:
- 将摘要记忆与激活频率较高的记忆筛出
- 使用LLM深入分析摘要记忆,拆解其中概念,经历,关系等,分为多个记忆节点,并进行这些节点内部的关联
- (待定可选)基于这些筛出的记忆,使用LLM尝试更新这些记忆的内容
- 将激活频率满足一定条件的记忆关联,并赋予基础连接强度
- 对于已经有的连接,执行LTP或LTD
流程图如下:
--- title: 基于摘要的短期记忆 --> 工作记忆的整合 --- graph TB A[摘要记忆] --> B[LLM分析拆解为多个记忆节点,进行自关联] --> D C[筛出激活频率较高的工作记忆] --> D[使用LLM尝试更新整合记忆内容] D --> E[关联激活频率满足一定条件的记忆,赋予基础连接强度] E --> F[根据共激活频率,对连接强度执行LTP/LTD,若连接强度低于阈值,断开连接]
工作 –> 长期
-
待定
大概率,由于项目具体的工期和版本迭代,我们可以暂时不考虑这块,或者只做遗忘衰减,放到后续实现。
2025.10.3注:
目前暂时确定为,把有过更新的工作记忆直接写入数据库,别的什么都不干,后续再说
关键算法
图检索——EdgePush PPR 变种
(以下设计有待进一步讨论)
代数公式 $$ SPPR(v) = (1 - \alpha) \rho(v) + \alpha \sum_{u -> v}SPPR(u) \times \frac{w(u,v)}{\sum_{v’ \in N_{out}(u)}w(u,v’)} $$ 其中 $$ w(u,v) $$ 与边连接强度,边关系语义匹配度,节点标签增强因子有关。
基本介绍
PPR描述了一个这样的随机游走模型,假设我们有一些允许的起始节点,一个人以一定概率选取其中的一个节点开始,随机的在图上游走,每走一步时,它有α概率停止游走,以(1-α)概率继续随机游走。当这个游走过程一直执行下去,最终会得到一个在图上稳定的概率分布,表征停在图上各个节点的概率,它一定程度反映了某个节点相对于源节点的重要性。EdgePush PPR在其中引入了边权的概念,让沿图的边进行游走受权重调控。
这样的机制,文档编写者认为,一定程度模拟了记忆联想的过程。
状态机
一共有两个状态,Working和Idle,所有有关记忆巩固的定期任务,只在Idle时允许进行。当有查询请求时,从Idle转换为Working,如果一段时间后没有进一步请求,状态从Working转到Idle
需要说明的是,在Idle状态下执行定期任务时,有可能会需要转为Working,此时我们不取消定期任务,利用数据库的并发操作同时进行。(实际上定期任务大部分的时间应当是在调用LLM,而不是对数据库进行写操作,因此就算遇到读写冲突,Surrealdb不并行执行的情况下,依然不会有不可忍受的延迟问题,不过有待确认)
开发设计说明
重要原则
能不使用LLM,就不使用LLM,原因如下:
- LLM回复较慢,单位是秒级的
- 大量调用LLM的api会造成费用的增加,对于个人用户不友好
因此,我们仅在需要复杂整合,复杂信息提取的场合使用LLM,并保证构造足够良好的Prompt,兼顾质量和较少的token数。
开发流程
- 确定三类记忆类型的struct结构,完成基础数据结构的编写
- 编写整个流程,对于其中的特定功能,暂时以todo!()或者返回一个假数据代替,例如PPR的部分
- 测试流程是否能正确跑通
- 细化特定功能实现,并测试
- 集成测试最终效果
待办事项
- 确定程序性记忆的最终结构,使其也能良好的被PPR游走发现
- 确定记忆的整合和巩固机制
- 确定PPR游走算法公式
- 编写基础数据架构
检索与联想
本文档基于
soul-mem-algo/src/algo/retrieve/当前代码(feature/test_framework分支)。 实现细节见 soul-mem-algo。
1. 检索策略抽象
所有检索策略实现 RetrStrategy trait:
pub trait RetrStrategy: 'static {
type Request: RetrRequest;
type Return<'a> where Self: 'a;
fn retrieve(&self, request: Self::Request) -> Self::Return<'_>;
}
| 策略 | 职责 | 返回 |
|---|---|---|
RetrShortOnly | 短期记忆:滑动窗口 + 摘要 | (Arc<[Information]>, Arc<str>) |
RetrSimilarity | 向量相似度 top-k(rayon 并行) | Vec<(MemoryId, f32)> |
RetrAssociation | 多源 PPR 联想扩散 | Vec<(MemoryId, f64)> |
RetrBayesAction | 贝叶斯动作推理 | Vec<(MemoryId, f64)> |
RetrAssociateWithAction | PPR 结果 + 双源 Bayes 组合 | AssociateWithActionResult { memory, action } |
RetrDefaultPipeline | 三段式默认管线 | DefaultPipelineResult |
2. 默认管线(DefaultPipeline)
三段式,严格按序:
① ShortOnly ── 窗口内容 + 摘要
↓
② Similarity ── query 向量与 Cluster 节点余弦相似度(兜底分过滤 + top-k)→ PPR 源种子
↓
③ AssociateWithAction ── PPR 联想扩散 + softmax 归一化 + 贝叶斯动作推理 topK
↓
DefaultPipelineResult { association, action, short_history, short_mem, priority }
- ① 提取滑动窗口消息与摘要(短期记忆,无条件进入最终上下文)。
- ② 以相似度结果作为 PPR 的源节点(种子)。
- ③ PPR 扩散 → 只保留 Situation 节点(抽象源权重 ×2 优先)→ softmax → Bayes 动作推理。
- 最后
merge_note_scores:相似度结果与 PPR 结果同 id 取更高分,降序截断 10 条。
3. PPR 联想(核心机制)
目标方程:ppr_s = damping × P × ppr_s + (1 - damping) × personalized_vec
实现为 weighted_ppr_fp(EdgePush/Forward-Push 风格):
- 多源:相似度命中的多个节点共同作为个性化向量源。
- 带边权:边权由动态权重函数计算——综合
intensity(连接强度)、confidence(语义置信度)与类型偏好(Semantic/Situation 通道,默认 Situation 优先)。 - 程序性记忆节点不参与联想:目标为 Procedure 的边权重为 0,动作检出交给 Bayes 推理。
- 无出度节点与源节点建立“虚拟连接“均分残差。
- 参数:damping 0.65、残差阈值 1e-5;
damping == 1.0被 assert 拒绝(防无限循环 DoS)。
PPR 的联想语义:模拟记忆联想——从当前检索命中的记忆出发,沿图游走扩散到相关记忆, 多跳可达(“钟离假死” → “米哈游“这类梗联想)。
4. 动作推理(BayesAction)
- 源:PPR 结果 + 相似度种子(合并、只留 Situation、softmax 归一化)。
- 动作候选:源节点的出边中,
边类型 = Proc且目标为Procedure的邻居。 - 分数:
Σ prob × weight(多源累加,prob 来自TrigToAction边的转移概率)。 - 语义记忆 → 动作的路径不触发动作(仅作自我认知补充,不进入动作提示词)。
5. 与旧文档/报告的演进对照
- 基线:纯向量相似 top-k(beta_ver.md 的 baseline 思路)→ 当前为 相似度种子 + PPR 扩散 + Bayes 动作的组合。
- 阈值语义变化:早期
SITUATION_SIMILARITY_THRESHOLD = 0.5(情境专用阈值)已被 “兜底分 floor + 必取 top-k” 取代(similarity_threshold默认 0.35 作为最低兜底分, 达到即参与 top-k,避免绝对阈值饿死查询)。 - 抽象检出:抽象情境节点经 PPR 检出(
SpecificToAbstract反向边),作为 Bayes 动作 提取的优先源,抽象源权重默认 ×2。详见 历史报告。
6. 未来规划(代码 TODO)
- 数据库向量相似结果接入并混合(当前无数据库通道)。
RetrRequestConfig尚无生产调用方(仅定义 + serde 反序列化)。
遗忘算法
本文档基于
soul-mem-algo/src/algo/forget/当前代码(feature/test_framework分支新增 模块,尚未提交)。实现细节见 soul-mem-algo。
1. 设计:惰性遗忘 + 遮罩
遗忘算法模拟人类遗忘曲线,采用惰性遗忘策略——不主动删除记忆,而是按遗忘曲线计算
缺失度,对记忆文本进行遮罩(将部分内容替换为 [masked]),缺失度较高时调用 LLM
尝试修订(基于遮罩文本猜补)。该机制同时为测试框架提供了可量化的观测维度
(原图 vs 遗忘后图的变换,见 算法测试)。
2. 艾宾浩斯衰减曲线
R(t) = e^(-t/τ)
τ = adjusted_half_life / ln(2)
adjusted_half_life = base_half_life_hours × (1 + active_factor × min(retrieval_count, cap))
t:自创建到现在的小时数。- 半衰期:R=0.5 时恰为
adjusted_half_life。 - 激活减缓遗忘:
retrieval_count越大半衰期越长(封顶 50 次)——经常被回忆的记忆 遗忘更慢,符合“复习“直觉。 - 默认参数:
base_half_life_hours = 24.0、active_factor = 0.1、max_activation_cap = 50。 missing_degree = 1 - R(t)(0~1,越大忘得越多)。- 边的遗忘:
edge_decay_intensity = 原边强度 × 源节点衰减(边跟随源节点遗忘)。
3. 惰性遗忘主流程(lazy_forget)
输入:MemoryNote + 当前时间 + jieba + llm_call 闭包
↓
① 类型过滤:仅 SpecificSituation 与 Semantic 可遗忘
(Procedure、AbstractSituation → NoAction)
↓
② 计算缺失度 md(默认 24.0/0.1/50)
↓
③ 分支:
md < 0.05 ──→ NoAction(几乎没忘,不动)
md < 0.15 ──→ MaskOnly(只遮罩,不调 LLM)
md ≥ 0.15 ──→ 调 LLM 猜补(Revised);失败则降级 MaskOnly
- 摘要文本来源:SpecificSituation →
narrative;Semantic →content。 - LLM prompt:system 为固定英文(“reconstructs partially masked memories”),user 为遮罩文本。
4. 文本遮罩(mask_text)
jieba.cut(text, true)(开启 HMM)分词。- 遮罩词数
n = round(missing_degree × total),上限 total。 - 确定性随机选词:种子 =
hash(text) ^ (degree.to_bits() × 114514),StdRng 洗牌取 前 n 个下标,替换为" [masked] "。 - 同文本同缺失度输出恒等(可复现,利于测试)。
5. 语义字段对齐(align_sem_fields)
仅对 Semantic 记忆:调用 LLM 输出 Aliases: / Description: / ConceptType: 三行,
与原文比对后更新字段:
- 文意一致则保留原值。
- 缺失度 < 0.6 时禁止 aliases 增长(防 LLM 幻觉膨胀记忆内容)。
- description 非空才写回;concept_type 有解析结果才写回。
6. 测试框架对接
- 遗忘评测由
soul-tune run forget fixtures/forget/*.json驱动(soul-tune 层)。 - 测试数据规范中 Forget JSON 的 T1–T5 用例与代码常量一一对应:
- config
{ base_half_life_hours: 24.0, active_factor: 0.1, max_activation_cap: 50 }与代码常量完全一致; - T4 的三段时间点分别落入
NoAction(md<0.05)/MaskOnly(<0.15)/Revised(≥0.15) 三段,与MASK_THRESHOLD/REVISE_THRESHOLD一致。
- config
7. 与旧文档的差异
| 旧设计(记忆算法概述-修订.md) | 实际实现 |
|---|---|
| 记忆微元 + 透明度 + 微元概括性描述 + 层叠包含关系的精细遮罩模型 | 整段文本 jieba 分词 + 确定性随机遮罩,无微元/透明度分层 |
| 连接强度按曲线衰减、低于阈值删除连接、无连接的节点删除 | 仅 edge_decay_intensity 计算函数;无删边/删孤立节点实现 |
| 遗忘范围(未明确) | 仅 SpecificSituation 与 Semantic;Procedure/Abstract 不遗忘 |
巩固算法
状态:未实现(规划中)。
soul-tune中ConsolidateSuite为占位 stub (case_count = 0、run_case恒返回未通过),运行时无 consolidate 算法模块。
1. 设计愿景(源自旧文档)
记忆的整合与巩固是系统动态演化的基础,与检索同等重要:
- 整合:将多个类似描述合并为同一记忆节点,并建立记忆之间的联系。
- 巩固(短期 → 工作):滑动窗口中的短期记忆(含摘要标记)经 LLM 分析拆解为多个 记忆节点,与激活频率较高的记忆关联,并执行 LTP/LTD 调整连接强度。
- 巩固(工作 → 长期):把有更新的工作记忆写入长期存储(2025-10 曾注:先直接写库, 后续再说——当前无数据库,此项整体搁置)。
2. 当前实现状态
| 组件 | 状态 |
|---|---|
| 滑动窗口摘要标记(每 capacity 条标记一次,滑出即摘要) | ✅ 已实现(SlidingWindow) |
活跃记录(Record,检索激活追踪) | ✅ 已实现(record.rs) |
| LLM 拆解摘要为记忆节点并建链接 | ❌ 未实现 |
| 共激活 LTP/LTD 连接强度调整 | ❌ 未实现 |
| 工作 → 长期持久化 | ❌ 未实现(无数据库) |
| 评测框架 | ConsolidateSuite stub(0 用例) |
3. 相关设计约定
- 状态机:巩固等定期任务仅在
WorkingState::Idle时允许执行(检索工作时不打扰)。 - 连接强度:
MemoryLink.intensity(f64,默认 1.0)是共激活/LTP-LTD 调整的载体; 遗忘侧的边衰减已有edge_decay_intensity(见 遗忘算法)。 - 巩固产生的新节点应生成遗忘遮罩(旧设计约定,随巩固一并实现)。
该模块依赖数据库路线的
feature/consolidate分支(未合并),现被无数据库路线取代, 实际落地优先级较低。详见 研究笔记-分支概览。
嵌入层
本文档基于
soul-mem-query/src/embedding/当前代码(feature/test_framework分支)。 实现细节见 soul-mem-query。
1. 职责
嵌入层负责:嵌入模型接入、记忆节点/查询的向量化、查询构建与相似度计算。它是检索管线的 向量通道(与字符串通道、tag 通道共同构成相似度评分)。
2. 模型接入
| 模型 | 池化 | 维度 | 分块上限 | 查询指令 |
|---|---|---|---|---|
BGE bge-small-zh-v1.5 | CLS | 512 | 200 字符 | QUERY_INSTRUCTION 前置到每个分块 |
Qwen3 Qwen3-Embedding-0.6B | — | 1024(F32) | 6000 字符 | 无覆写(同 passage 侧) |
- 加载:
embed_anything(内部基于 candle)本地加载,BGE 为进程级OnceLock单例。 - 查询侧与 passage 侧分离:
EmbeddingModeltrait 提供infer_query_batch等查询侧方法, BGE 覆写为前置查询指令(非对称训练用法),Qwen3 使用默认(对称)。
3. 权重体系(BlendWeights)
BlendWeights 集中定义全部 16 个可调权重,经 set_blend_weights 递归传播到查询嵌入:
tag= 0.3、variant= 0.7(两个顶层通道权重,默认)string_blend_alpha= 0.6(字符串通道混合系数)- 各结构化字段(location/participant/emotion/environment/event)子权重
评测框架通过 blend_sweep(tag_sweep / pairs)批量扫描权重组合,见
测试数据规范。
4. 相似度计算(compute_fused)
score = max(embedding_score, alpha × embedding_score + (1 - alpha) × string_score)
embedding_score:余弦相似度(结构化查询按字段 max 融合)。string_score:Jaro-Winkler / 归一化 Levenshtein 字符串距离(仅对精确标识符、 Semantic content/aliases、抽象情境结构化字段生效;具体情境恒为 0)。- 字符串通道只加分不拉低(
max语义)。 - 抽象情境查询有
fused_self()叙事回退(无结构化输入时用叙事文本嵌入)。
5. 数据流
记忆节点 ──embed──▶ EmbeddedMemoryNote { note, embedding } ──入图──▶ MemoryCluster
查询 ──embed──▶ EmbeddedMemoryRetrieveQuery ──compute_fused──▶ 相似度 top-k(soul-mem-algo)
6. 与旧文档的差异
- beta_ver.md 只提“向量搜索“;当前实现新增字符串通道、tag 通道与抽象情境叙事回退。
- 程序性记忆嵌入侧仍为空占位(
Procedure())。
soul-mem-core
依据
feature/test_framework分支工作区代码整理。
1. 职责定位与依赖
soul-mem-core 是记忆系统的领域数据模型层(纯数据结构 crate)。它只定义“记忆节点“与
“记忆链接“及其三类记忆(情境/语义/程序性)的 Rust 结构体、ID 类型、Builder 和序列化,
不包含任何 I/O、存储、图算法、向量计算或 LLM 调用。图的构建(petgraph)、PPR 检索、
embedding 等全部由下游 crate 完成。
[dependencies]
serde = { workspace = true } # 序列化/反序列化
uuid = { workspace = true } # MemoryId/LinkId(v4)
chrono = { workspace = true } # DateTime<Utc> 时间字段
thiserror = { workspace = true }
关键点:本 crate 不依赖 petgraph(petgraph 仅出现在 soul-mem-algo、soul-mem-runtime、
benches 中)。旧文档中“工作记忆使用 petgraph“的职责在下游 runtime crate。
下游消费方:soul-mem-runtime、soul-mem-algo、soul-mem-query、soul-tune、benches。
2. 模块结构
src/
├── lib.rs # pub mod memory_links; pub mod memory_note;
├── memory_note.rs # MemoryId、MemoryNote、MemoryType、MemoryNoteBuilder
│ ├── sem_mem.rs # ConceptType、SemMemory
│ ├── situation_mem.rs # SituationType、AbstractSituation、SpecificSituation、Context、叶子结构
│ └── proc_mem.rs # ActionType、SkillRecord、Action、ProcMemory
└── memory_links.rs # LinkId、MemoryLink、MemoryLinkType
├── sem_mem.rs # SemMemLink{verb, confidence}
├── situation_mem.rs # SituationMemLink{AbstractToSpecific, SpecificToAbstract}
└── proc_mem.rs # ProcMemLink::TrigToAction{prob}
3. 记忆节点
MemoryId
Uuid newtype,Copy + Hash + Eq + Ord,可直接作 HashMap 键:
MemoryId::new()(v4 随机)、From<Uuid>、Default、Display。
MemoryNote
所有字段私有,仅经 getter 访问:
pub struct MemoryNote {
id: MemoryId,
tags: Vec<String>, // 标签(注释:暂定参与 embedding)
retrieval_count: usize, // 记忆被提取的次数
create_time: DateTime<Utc>,
last_accessed_time: DateTime<Utc>, // 最后访问时间
mem_type: MemoryType, // 三类记忆之一
mem_links: Vec<MemoryLink>, // 该节点出发的全部出边
}
关键方法:retrieval_increment()(retrieval_count += 1 并刷新 last_accessed_time)、
links()/links_mut()、mem_type()/mem_type_mut()。
MemoryType
pub enum MemoryType {
Semantic(SemMemory),
Situation(SituationType),
Procedure(ProcMemory),
}
MemoryNoteBuilder
mem_type 必填,其余可选;build() 校验 last_accessed_time < create_time 时报
MemoryNoteBuildError::TimeConflict。
⚠️ 实现隐患:
build()比较的是两个Option<DateTime<Utc>>,Rust 中None < Some(_)为真, 因此只设置 create_time 而未设置 last_accessed_time 时也会返回 TimeConflict。
4. 三类记忆的数据结构
语义记忆(SemMemory)
pub enum ConceptType { Entity, Abstract }
pub struct SemMemory {
pub content: String, // 概念/事实内容
pub aliases: Vec<String>, // 别名,去重消歧
pub concept_type: ConceptType,
pub description: String,
}
情境记忆(SituationType)
pub enum SituationType {
AbstractSituation(AbstractSituation), // 抽象:Location/Participant/Environment/Event
SpecificSituation(SpecificSituation), // 具体:narrative + time_span + context
}
pub struct SpecificSituation {
narrative: String, // 情境的自然语言描述
time_span: DateTime<Utc>, // 单个时间点(注意:非时间段)
context: Context,
}
pub struct Context {
location: Option<Location>, // Location{name, coordinates}
participants: Vec<Participant>, // Participant{name, role}
emotions: Vec<Emotion>, // Emotion{name, intensity: f32}
sensory_data: Vec<SensoryData>, // SensoryData{name, intensity: f32}
environment: Environment, // Environment{atmosphere, tone}(必填,非 Option)
event: Vec<Event>, // Event{action, action_intensity, initiator, target}
}
程序性记忆(ProcMemory)
pub enum ActionType { Speak, Skill(SkillRecord), Think } // SkillRecord 为占位空结构
pub struct Action { content: String, action_type: ActionType }
pub struct ProcMemory { action: Action }
注意:程序性记忆节点只有 Action,没有独立 trigger 节点类型——trigger→action 的
转移关系建模为边 ProcMemLink::TrigToAction{prob}(见下)。
5. 记忆链接
MemoryLink
pub struct MemoryLink {
id: LinkId,
from: MemoryId,
to: MemoryId,
pub intensity: f64, // 唯一的 pub 字段:公共连接强度(默认 1.0)
link_type: MemoryLinkType,
}
new(from, to, link_type) 默认 intensity=1.0;from_tuple/into_tuple 与
(MemoryId, MemoryId, MemoryLinkType, f64) 元组互转。
MemoryLinkType
pub enum MemoryLinkType {
Proc(ProcMemLink), // TrigToAction{prob: f64} 转移概率
Sem(SemMemLink), // SemMemLink{verb: String, confidence: f32}
Situation(SituationMemLink), // AbstractToSpecific{} / SpecificToAbstract{}
}
SpecificToAbstract 的 doc 注释明确其角色:具体情境 → 抽象情境的反向边,PPR 从具体
情境种子游走到抽象模式节点后,抽象节点作为 Bayes 动作提取的优先源。
6. 图结构关系
- core 本身不用 petgraph,图以“边随节点存储“表达:
MemoryNote.mem_links内嵌出边,MemoryLink以 ID 引用两端节点。 - 权重体系:
intensity(公共,f64)+ 类型特定置信度/概率(SemMemLink.confidence: f32、TrigToAction.prob: f64)。 - 下游真实图:
soul-mem-runtime的MemoryCluster用petgraph::stable_graph::StableDiGraph<EmbeddedMemoryNote, GraphMemoryLink>构建, 维护mem_id_to_index/link_id_to_index映射与incompletely_linked_note(待链接边缓冲)。
7. 与旧文档(beta_ver.md)的出入
| 旧设计 | 当前实现 |
|---|---|
time_span 为起止时间段 | 单个 DateTime<Utc>(无结束时间) |
context 含 situation 自然语言背景 | 无该字段;新增必填 environment{atmosphere, tone} 与 event[] |
| 语义边属性 verb+intensity+confidence | intensity 上移为 MemoryLink 公共字段;SemMemLink 仅 verb+confidence |
| 程序性记忆 trigger/action 两类节点(待定) | 无 trigger 节点;ProcMemory 仅 Action,trigger→action 为 TrigToAction 边;ActionType 细分为 Speak/Skill/Think |
| 抽象情境节点为“二级索引“抽象实体 | AbstractSituation 枚举(Location/Participant/Environment/Event 四类抽象元素),抽象↔具体关系由链接方向表达 |
| (未提及) | UUID ID 体系、tags/retrieval_count/时间元字段、Builder 校验、双方向情境链接 |
8. 实现备注
- 每个源文件均带内联单测(共 23 个),覆盖 ID、Builder、getter/mutator 往返、链接构造与 元组互转。
- 序列化:无
#[serde(rename)]等属性,字段名即 snake_case;MemoryId/LinkId在 JSON 中 为 UUID 字符串,DateTime<Utc>为 RFC3339 字符串。 - 派生 trait 差异:含浮点字段的类型只有
PartialEq/PartialOrd;ActionType/Action等有Eq/Ord;TrigToAction是Copy。
soul-mem-algo
依据
feature/test_framework分支工作区代码整理(含未提交的遗忘模块新增)。
1. 职责定位与依赖
soul-mem-algo 是 SoulMem 的记忆算法层,负责两大类算法:
- 遗忘(forget):基于艾宾浩斯遗忘曲线的惰性遗忘(遮罩 + LLM 修订)与语义字段对齐。
- 检索/联想(retrieve):向量相似度检索、PPR 联想扩散、贝叶斯动作推理及组合管线
DefaultPipeline。
该 crate 不直接接触数据库、不直接调用 LLM API:数据均在内存工作记忆
(WorkingMemory 及其 MemoryCluster,petgraph StableDiGraph)上操作;LLM 调用通过
调用方注入的闭包(forget 的 llm_call 参数)间接完成。
[dependencies]
soul-mem-core = { path = "../soul-mem-core" }
soul-mem-query = { path = "../soul-mem-query" }
soul-mem-runtime = { path = "../soul-mem-runtime" }
petgraph = { workspace = true } # 图结构
ordered-float = "5.0.0" # OrdFloat 底层
serde = { workspace = true }
rayon = "1.12.0" # similarity 并行打分
chrono = { workspace = true } # 遗忘时间计算(本分支新增)
jieba-rs = { workspace = true } # 遗忘遮罩分词(本分支新增)
rand = { workspace = true } # 遮罩随机选择(本分支新增)
[dev-dependencies]
insta = { workspace = true } # 快照测试
tokio = { workspace = true } # 遗忘模块异步测试(本分支新增)
要点:无数据库 crate、无 async-openai、无嵌入式向量库;chrono/jieba-rs/rand/tokio
是当前分支未提交的新增依赖,全部服务于新的 algo/forget/ 模块。
2. 模块结构
src/
├── lib.rs # pub mod algo; pub mod common;
├── algo.rs # pub mod forget; pub mod retrieve;
├── common.rs # pub mod ord_float; pub mod ppr;
├── common/
│ ├── ord_float.rs # OrdFloat<F>:可全序比较/可运算的浮点包装(实现 UnitMeasure)
│ └── ppr.rs # naive_ppr(幂迭代)/ weighted_ppr_fp(Forward-Push)
└── algo/
├── forget/ # 遗忘算法(本分支新增)
│ ├── decay_calculator.rs # 艾宾浩斯衰减三函数
│ ├── decay_revise.rs # 惰性遗忘主流程 lazy_forget / align_sem_fields
│ └── mask.rs # 文本遮罩 mask_text(jieba 分词 + 确定性随机遮罩)
└── retrieve/
├── retrieve.rs # RetrStrategy / RetrRequest / RetrRequestConfig
├── association.rs # RetrAssociation:多源 PPR 联想 + DynWeightFuncBuilder
├── bayes_action.rs # RetrBayesAction:经 Proc(TrigToAction) 边聚合动作概率
├── short_only.rs # RetrShortOnly:滑动窗口 + 摘要
├── similarity.rs # RetrSimilarity:向量相似度 top-k(rayon 并行)
└── complex/
├── default_pipeline.rs # RetrDefaultPipeline:三段式管线
└── assoc_with_action.rs # RetrAssociateWithAction:PPR + 双源 Bayes
3. 检索抽象
pub trait RetrStrategy: 'static {
type Request: RetrRequest;
type Return<'a> where Self: 'a;
fn retrieve(&self, request: Self::Request) -> Self::Return<'_>;
}
pub trait RetrRequest {}
#[derive(serde::Deserialize)]
#[serde(tag = "type")]
pub enum RetrRequestConfig {
Association(AssociationConfig),
BayesAction(BayesActionConfig),
AssociateWithAction(AssociateWithActionConfig),
ShortOnly(ShortOnlyConfig),
Similarity(SimilarityConfig),
}
每个策略 = unit struct(RetrAssociation 等)+ Config(serde 反序列化,带默认值)+
Request(由 Config::into_request(...) 构造)。
4. 检索策略
RetrAssociation(PPR 联想)
pub struct AssociationConfig {
pub intensity_factor: Option<f64>, // None → 默认 1.0
pub confidence_factor: Option<f64>, // None → 默认 0.8
pub damping_factor: f64, // 默认 0.65
pub residue_threshold: f64, // 默认 1e-5
pub preference: TypePreference, // 默认 Situation
pub top_k: usize, // 默认 8
}
impl RetrStrategy for RetrAssociation { type Return<'a> = Vec<(MemoryId, f64)>; }
流程:source(相似度种子)→ 动态边权函数 → weighted_ppr_fp(damping 0.65、残差阈值 1e-5)
→ 分数降序 top_k。防御:source 非空但全部解析失败时直接返回空,避免 PPR 内部
“源权重和必须为正“的 assert panic。
动态边权(DynWeightFuncBuilder,preference 决定类型偏好数组):
- 目标为
Procedure的边权重 0.0(Proc 节点不参与 PPR 联想,交给 Bayes 动作推理)。 Situation边 →(confidence_boost, type_boost) = (0.8, preference[1]);Sem边 →(mem.confidence, preference[0]);Proc边 → (0.0, 0.0)。- 归一化公式:
(intensity×i + confidence_boost×c + type_boost) / (i + c + type_boost), 分母为 0 返回 0.0(防 NaN)。
RetrBayesAction(动作推理)
pub struct BayesActionConfig { pub top_k: usize } // 默认 5
impl RetrStrategy for RetrBayesAction { type Return<'a> = Vec<(MemoryId, f64)>; }
流程:对每个源节点,只保留边类型为 Proc 且目标为 Procedure 的出边邻居作为候选
动作;possible_actions[id] += prob × weight(多源累加);降序取 top_k。
RetrSimilarity(向量相似度)
pub struct SimilarityConfig {
pub similarity_threshold: f32, // 默认 0.35 —— 语义为"最低兜底分"
pub max_results: usize, // 默认 4
}
impl RetrStrategy for RetrSimilarity { type Return<'a> = Vec<(MemoryId, f32)>; }
rayon 并行 compute_fused(余弦 + 字符串通道,字符串只加分);过滤非有限分与低于兜底分
的节点;降序取 top-k。
RetrShortOnly(短期记忆)
pub struct ShortOnlyConfig {
pub clipping_length: Option<usize>, // 倒序计数裁剪
pub include_summary: bool, // 默认 false
}
impl RetrStrategy for RetrShortOnly { type Return<'a> = (Arc<[Information]>, Arc<str>); }
// (窗口消息, 摘要)
RetrAssociateWithAction(PPR + 双源 Bayes)
pub struct AssociateWithActionConfig {
pub association: AssociationConfig,
pub action_top_k: usize, // 默认 3
pub abstract_source_priority: f64, // 默认 2.0 —— 抽象源加权
}
pub struct AssociateWithActionResult { pub memory: Vec<(MemoryId, f64)>, pub action: Vec<(MemoryId, f64)> }
流程:RetrAssociation 得 PPR 结果 → merge_situation_sources(相似度种子 ∪ PPR 结果,
同 id 取 max,只保留 Situation 节点,过滤 ≤0 分)→ 抽象源 ×2 / 具体源 ×1 →
softmax 归一化 → RetrBayesAction 得 action。
RetrDefaultPipeline(三段式默认管线)
pub struct DefaultPipelineConfig {
pub short_mem_with_history: ShortOnlyConfig,
pub similarity: SimilarityConfig,
pub assoc_with_action: AssociateWithActionConfig,
}
pub struct DefaultPipelineResult {
pub association: Vec<(MemoryId, f64)>, // 合并去重后 top-10
pub action: Vec<(MemoryId, f64)>, // 动作 top-k
pub short_history: Arc<[Information]>,
pub short_mem: Arc<str>,
pub priority: u32,
}
严格按序:① ShortOnly → ② Similarity(PPR 源种子)→ ③ AssociateWithAction →
merge_note_scores(同 id 取高、降序、截断 MAX_PIPELINE_NOTES = 10)。
5. PPR 核心(common/ppr.rs)
- 目标方程:
ppr_s = damping × P × ppr_s + (1 - damping) × personalized_vec。 - 多源:
personalized_vec可含多个源节点,权重和 > 0 并归一化为概率分布。 - 无出度节点:与源节点建立“虚拟连接“,残差按源数量均分回源。
naive_ppr:幂迭代nb_iter次,每次归一化;针对 StableDiGraph 处理空洞索引。weighted_ppr_fp(生产路径,EdgePush/Forward-Push 风格):assert!(0 ≤ damping < 1)——damping == 1.0必须拒绝(残差无法转化为 reserve 会 无限循环,注释明确写“防止 DoS“)。- 残差/保留模型:
reserve += (1-damping) × residue;每次 push 残差最大的节点,残差 ≤ 阈值停止。 - 边权动态计算并缓存(
ppr_edge_weight_cache),按总和归一化(和为 0 保持原值防 NaN)。 - 迭代上限安全网:
node_bound × 1024。 - 最终输出
(node_id, reserve/sum)概率分布。
6. 遗忘算法(forget/,本分支新增)
艾宾浩斯衰减(decay_calculator.rs)
pub const DEFAULT_MAX_ACTIVATION_CAP: usize = 50;
// R(t) = e^(-t/τ);τ = adjusted_half_life / ln2;
// adjusted_half_life = base_half_life_hours × (1 + active_factor × min(retrieval_count, cap))
pub fn ebbinghaus_decay(...) -> f32;
pub fn compute_missing_degree(...) -> f32; // 1.0 - decay,0~1
pub fn edge_decay_intensity(original_intensity: f64, ...) -> f64; // 边强度 × 节点衰减
激活次数越多(封顶 50)半衰期越长、衰减越慢;elapsed_hours <= 0 返回 1.0。
惰性遗忘主流程(decay_revise.rs)
pub const DEFAULT_BASE_HALF_LIFE_HOURS: f32 = 24.0;
pub const DEFAULT_ACTIVE_FACTOR: f32 = 0.1;
pub const MASK_THRESHOLD: f32 = 0.05; // 缺失度低于此 → 不遗忘
pub const REVISE_THRESHOLD: f32 = 0.15; // 缺失度高于此 → 调 LLM 修订
pub const ALIGN_LENGTH_CAP_THRESHOLD: f32 = 0.6;
pub enum ForgetAction { NoAction, MaskOnly { .. }, Revised { .. } }
pub async fn lazy_forget<F, Fut>(node: &mut MemoryNote, current_time, jieba, llm_call) -> ForgetAction;
pub async fn align_sem_fields<F, Fut>(node: &mut MemoryNote, llm_call) -> Result<(), ...>;
pub fn get_summary(node: &MemoryNote) -> Option<String>; // narrative / content
lazy_forget 流程:
- 类型过滤:仅
SpecificSituation与Semantic可遗忘,其余返回NoAction。 compute_missing_degree(默认 24.0/0.1/50)算缺失度md。md < 0.05→ NoAction;md < 0.15→ 只遮罩(MaskOnly);否则调 LLM 猜补(Revised), 失败降级 MaskOnly。- LLM prompt:system 为固定英文“reconstructs partially masked memories“,user 为遮罩文本。
文本遮罩(mask.rs)
pub const MASK_WORD: &str = " [masked] ";
pub fn mask_text(text: &str, missing_degree: f32, jieba: &Jieba) -> MaskResult;
jieba 分词(HMM)→ 遮罩词数 n = round(md × total) → 确定性随机(种子
hash(text) ^ (degree.to_bits() × 114514),StdRng 洗牌)选词替换。同文本同缺失度输出恒等。
语义字段对齐(align_sem_fields)
仅 Semantic:LLM 逐行输出 Aliases: / Description: / ConceptType:;文意一致保留原值;
缺失度 < 0.6 时禁止 aliases 增长(防 LLM 幻觉膨胀)。
7. 与旧文档的出入
| 旧文档 | 实际代码 |
|---|---|
| SurrealDB/Qdrant 向量、图、时间序列存储 | 无任何数据库;全部在内存工作记忆上操作 |
| async-openai 在 algo 中 | 无;LLM 通过注入闭包调用 |
| 遮罩法“记忆微元 + 透明度分层“精细模型 | 整段文本 jieba 分词 + 确定性随机遮罩 |
| 连接遗忘“低于阈值删边、删孤立节点“ | 仅 edge_decay_intensity 计算函数,无删边实现 |
| 遗忘范围 | 仅 SpecificSituation 与 Semantic;Procedure/Abstract 不遗忘 |
| orchestration 中 DefaultPipeline 三步描述 | 与 default_pipeline.rs 一致 |
检索轨迹报告中 SITUATION_SIMILARITY_THRESHOLD=0.5 | 已不存在;被“兜底分 floor + 必取 top-k“语义取代 |
| 抽象 PPR 检出报告 | 与 assoc_with_action.rs 当前实现完全一致 |
8. 工作区未提交修改说明
- 新增(untracked):
src/algo/forget/四个文件(遗忘算法是本分支新增能力)。 - 修改:
algo.rs(+pub mod forget;)、Cargo.toml(+ chrono/jieba-rs/rand,dev + tokio)。 - 其余被 git 标记 M 的文件(association.rs、ppr.rs、snap 等)经校验仅为 CRLF→LF 行尾 转换,无语义变化。
soul-mem-query
依据
feature/test_framework分支工作区代码整理。
1. 职责定位与依赖
soul-mem-query 是 SoulMem 的向量嵌入 + 检索评分层(lib.rs 仅导出 embedding 与
query 两个模块)。它负责:嵌入模型接入、记忆节点/查询的向量化、查询构建、分层余弦
相似度计算与字符串通道融合。不含图检索、存储、阈值过滤(阈值与 top-k 在
soul-mem-algo 的 similarity.rs 中)。
[dependencies]
candle-core = { workspace = true } # 仅用于 EmbeddingGenError::EmbeddingFailed 错误类型
embed_anything = "0.7.1" # 本地模型加载(内部基于 candle)
rayon = "1.10.0" # 并行池化
text-splitter = "0.29.3" # 长文本分块(按字符)
strsim = "0.11.1" # Jaro-Winkler / 归一化 Levenshtein
soul-mem-core = { path = "../soul-mem-core" }
2. 模块结构
src/
├── lib.rs # pub mod embedding; pub mod query;
├── embedding.rs # Embeddable/EmbeddingModel trait、错误类型
│ ├── blend_weights.rs # BlendWeights:16 个可调权重集中定义
│ ├── embedding_model/ # bge.rs(BGE 模型)、qwen3.rs(Qwen3 模型)
│ ├── note.rs # EmbeddedMemoryNote(记忆节点 + 向量)、MemoryEmbedding
│ ├── query.rs # 查询侧嵌入
│ ├── sem.rs # 语义记忆嵌入
│ ├── situation.rs # 情境记忆嵌入(context/emotion/environment/event/location/
│ │ # participant/sensory_data 子模块)
│ └── vec.rs # EmbeddingVec、mean_pooling、raw_linear_blend
└── query.rs # 查询结构定义
├── compute.rs # 分层余弦相似度计算
├── retrieve.rs # 检索查询结构体(PrioritizedMemoryRetrieveQuery 等)
└── string_distance.rs# 字符串距离(Jaro-Winkler / 归一化 Levenshtein)
3. 嵌入模型层
EmbeddingModel trait
#[async_trait]
pub trait EmbeddingModel {
fn infer_batch(&self, input: &[&str]) -> EmbeddingGenResult<Vec<EmbeddingVec>>;
fn infer_with_chunk(&self, input: &str) -> EmbeddingGenResult<EmbeddingVec>;
fn infer_and_fuse(&self, input: &[&str]) -> EmbeddingGenResult<EmbeddingVec>;
// 查询侧默认同侧;检索类模型(如 BGE v1.5)覆写为前置查询指令
fn infer_query_batch(&self, input: &[&str]) -> EmbeddingGenResult<Vec<EmbeddingVec>> { ... }
fn infer_query_and_fuse(...) { ... }
fn infer_query_with_chunk(...) { ... }
fn max_input_token(&self) -> usize;
fn dim(&self) -> usize; // 模型输出维度,用于无有效输入时构造零向量
}
内置模型(embedding_model/)
| 模型 | 池化 | 维度 | 分块 | 查询指令 | 加载方式 |
|---|---|---|---|---|---|
BGE bge-small-zh-v1.5 | CLS | 512 | 200 字符 | QUERY_INSTRUCTION 前置到每个分块 | embed_anything + candle,进程级 OnceLock 单例 |
Qwen3 Qwen3-Embedding-0.6B | — | 1024(F32) | 6000 字符 | 无覆写(默认同 passage 侧) | candle,按需加载 |
Embeddable trait
pub trait Embeddable {
type EmbeddingFused;
type EmbeddingGen;
fn embed_and_fuse(self, model: &dyn EmbeddingModel) -> EmbeddingGenResult<Self::EmbeddingFused>;
fn embed(&self, model: &dyn EmbeddingModel) -> EmbeddingGenResult<Self::EmbeddingGen>;
}
BlendWeights(blend_weights.rs)
集中定义全部 16 个可调权重,经 set_blend_weights 递归传播到查询嵌入。关键默认值:
tag= 0.3、variant= 0.7(tag/variant 通道权重)string_blend_alpha= 0.6(字符串通道混合系数)- 以及各结构化字段(location/participant/emotion/environment/event 等)子权重
错误类型
EmbeddingGenError(InvalidInput / EmbeddingFailed / PostCalcFailed / Anyhow)与
EmbeddingCalcError(InvalidVec / ShapeMismatch / IncompatibleEmbeddingTypes /
InvalidNumValue)。
4. 查询构建层(embedding/query/ 与 situation/)
- 语义查询(
sem.rs):基于 SemMemory 的 content/aliases 构建。 - 情境查询(
situation.rs及各子模块):environment / event / location / participant 等各类查询的向量构建。 - note 查询(
note.rs):EmbeddedMemoryNote { note: MemoryNote, embedding: MemoryEmbedding }, 记忆节点 + 嵌入向量的组合,作为记忆图节点。 - 结构化字段各自生成独立嵌入,评分时按
max融合(见下)。
5. 检索计算层(query/)
compute.rs — 分层余弦计算
AnonymousQueryCompute/QueryCompute两层:匿名查询(纯文本)与结构化查询。- Semantic:content/aliases 的 max_pooling。
- 情境:多信号 max 融合(各结构化字段)。
- 抽象情境:
fused_self()叙事回退(无结构化输入时用叙事文本嵌入)。 - 顶层
compute_fused:max(emb, 0.6·emb + 0.4·str)—— 字符串通道只加分不拉低。
string_distance.rs
- Jaro-Winkler 与归一化 Levenshtein 距离,用于标签/字符串匹配加分。
retrieve.rs — 查询结构体
定义 MemoryRetrieveQueryVariant、PrioritizedMemoryRetrieveQuery 等查询类型(供
soul-mem-algo 的 association.rs 消费)。本 crate 不做阈值过滤与排序。
阈值过滤(默认 0.35)与 top-k(默认 4)在
soul-mem-algo/src/algo/retrieve/similarity.rs。
6. 与旧文档(beta_ver.md)的出入
| 旧设计 | 当前实现 |
|---|---|
| 向量相似度搜索定位 | 一致(本 crate 是向量层) |
| (未提及) | 新增字符串通道(只加分)、tag 通道、抽象情境叙事回退 |
context 含 situation 背景字段 | 无该字段;environment/event 等结构化字段参与评分 |
| 程序性记忆 | 嵌入侧仍为空占位 Procedure() |
| time_span | 已定义但未参与评分 |
soul-mem-runtime
依据
feature/test_framework分支工作区代码整理。
1. 职责定位与依赖
soul-mem-runtime 是运行时层纯库,聚合 WorkingMemory(滑动窗口 + 记忆簇 + 活跃记录 +
LLM 摘要)。无网络服务、无持久化、无任务调度(这些属于规划中组件)。
[dependencies]
soul-mem-core = { path = "../soul-mem-core" }
soul-mem-query = { path = "../soul-mem-query" }
petgraph = { workspace = true } # 记忆图(StableDiGraph)
async-openai = { version = "0.32.4", features = ["chat-completion"] } # LLM 调用
http = "1.4.0" # 仅用于 HeaderMap
secrecy = "0.10.3" # 保护 API key
dotenvy = "0.15.7" # 加载 .env
parking_lot = "0.12.4" # 并发锁
[dev-dependencies]
soul-mem-algo = { path = "../soul-mem-algo" } # 仅测试用
关键事实:
- LLM 调用走 OpenAI Chat Completions API(async-openai);
.env实际指向 SiliconFlow 兼容端点,model 为deepseek-ai/DeepSeek-V3.2(见llm/config.rs与.env)。 - 代码完全未使用 zenoh(全工作区 grep 仅存在于 orchestration.md 文本中)。
- 无 SurrealDB / Qdrant 依赖。
2. 模块结构
src/
├── lib.rs # pub mod cluster; pub mod working_memory;
├── cluster.rs # pub mod cluster_handle; pub mod memory_cluster;
│ ├── memory_cluster.rs # MemoryCluster:进程内 StableDiGraph 记忆图
│ └── cluster_handle.rs # MemoryClusterHandle:Arc<RwLock> + read_or_compute/write
└── working_memory.rs # WorkingMemory:状态机 + 滑动窗口 + 记忆簇 + 记录 + LLM
├── record.rs # Record:±1 反馈计分
├── sliding_window.rs # SlidingWindow:Arc+原子窗口、摘要标记
└── llm/ # client.rs / config.rs / prompt.rs
3. 工作记忆(WorkingMemory)
pub struct WorkingMemory { /* 状态机 + 滑动窗口 + 记忆簇 + 记录 */ }
公共 API:
pub fn new(window_capacity: usize) -> Self;
pub fn state(&self) -> &WorkingState; // Working / Idle 状态机
pub fn transition_to_working(&mut self);
pub fn transition_to_idle(&mut self);
pub fn is_working(&self) -> bool;
pub fn sliding_window(&self) -> &SlidingWindow;
pub fn add_node(&mut self, node: EmbeddedMemoryNote); // 新记忆入簇
pub fn remove_node(&mut self, node_id: MemoryId) -> Option<EmbeddedMemoryNote>;
pub fn memory_cluster(&self) -> MemoryClusterHandle;
pub fn record_retrieval(&mut self, node_id: MemoryId); // 记录激活
pub fn add_feedback(&mut self, node_id: MemoryId, feedback: UserFeedback);
pub fn records(&self) -> &HashMap<MemoryId, Record>;
record.rs — 活跃记录
Record 追踪检索中被激活的 MemoryNote,通过 UserFeedback(±1)计分,作为巩固时新
节点拓扑链接的候选(巩固为规划项,见 编排)。
sliding_window.rs — 滑动窗口
SlidingWindow:VecDeque<Information>+capacity+tag_count+ 摘要 (Arc<RwLock<MergedInformation>>)。- 机制:每 capacity 条消息标记一条(摘要标记),被标记的消息滑出窗口时触发一次 累加性精简摘要;窗口内信息无条件加入最终检索上下文。
Information枚举:User(UserInformation)/Assistant(AssistantInformation)。- 并发:原子计数 + Arc 共享,支持 push/pop 与检索并发(详见 WorkingMemory 并发安全方案)。
llm/ — LLM 摘要客户端
config.rs:LLMConfig(OpenAIConfig + model + temperature 0.7 + max_tokens 512), builder 风格;AIConfigtrait 提供get_config/get_model/get_temperature/get_n/get_max_tokens。client.rs:Chat Completions 调用封装。prompt.rs:PromptBuilder(构建单条消息)/PromptHistoryBuilder(构建历史)trait, 具体摘要 prompt 模板在调用方实现。
4. 集群子系统(cluster/)
⚠️ 命名撞车:代码中的 “cluster” 指记忆簇(进程内图),与 cluster.md 描述的 BEAM/Elixir 分布式集群是两回事。 后者在本 crate 无任何实现。
MemoryCluster(memory_cluster.rs)
pub struct MemoryCluster {
graph: StableDiGraph<EmbeddedMemoryNote, GraphMemoryLink>,
mem_id_to_index: HashMap<MemoryId, NodeIndex>,
link_id_to_index: HashMap<LinkId, EdgeIndex>,
incompletely_linked_note: HashMap<MemoryId, Vec<(MemoryId, MemoryLink)>>, // 待链接边缓冲
}
- 使用
StableDiGraph(删除节点/边不回收索引)。 incompletely_linked_note:目标节点 uuid → 待链接源边,存 uuid 而非 NodeIndex 以 避免 petgraph 索引复用导致错连。GraphMemoryLink由MemoryLink转换而来(保留 id/intensity/link_type),impl From<MemoryLink>。
MemoryClusterHandle(cluster_handle.rs)
pub struct MemoryClusterHandle { cluster: Arc<RwLock<MemoryCluster>> }
pub fn read_or_compute<R>(&self, closure: impl FnOnce(&MemoryCluster) -> R) -> R;
pub fn write<R>(&self, closure: impl FnOnce(&mut MemoryCluster) -> R) -> R;
仅 Arc<RwLock> + 读写闭包,不存在任何任务管理/监督机制。并发读安全经单测验证。
5. 与旧文档的出入
| 文档声称 | 实际代码 |
|---|---|
| orchestration.md:zenoh pub/sub 服务接口 | 未实现,代码无 zenoh |
| orchestration.md:SurrealDB 持久化(🔲) | 未实现,无 DB 依赖 |
| orchestration.md:✅ 组件(滑动窗口/摘要/记忆簇/记录/状态机/LLM) | 属实,均已实现 |
| cluster.md:BEAM/Elixir 分布式集群 | 未实现;代码中 cluster = 记忆簇 |
| beta_ver.md:async-openai LLM 调用 | 保留(现指向本地/SiliconFlow 兼容端点) |
| 巩固/持久化/遗忘遮罩(🔲) | 未实现(soul-tune 有评测框架) |
soul-tune
依据
feature/test_framework分支工作区代码整理。soul-tune 是 SoulMem 的测试/评测 框架(TUI + headless CLI),description 为 “Test framework for SoulMem.”。
1. 职责定位与依赖
soul-tune 是独立于运行时组件的评测工具,依赖全部四个 crate(core/algo/query/runtime),
提供:检索评测 suite、playtest 角色对话测试、遗忘 T1–T5 评测、巩固评测(stub)、批量对比
与 TUI 界面。
[dependencies]
ratatui = "0.30.1" # TUI
ratatui-textarea = "0.9.1"
nucleo = "0.5.0" # 模糊匹配
color-eyre = "0.6.5" # 错误报告
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json"] }
paw-rs = { version = "0.2", default-features = false, features = ["llamacpp"] } # llama.cpp 绑定
jieba-rs = { workspace = true }
rayon / rand / chrono / serde_json ...
[features]
default = ["llamacpp"]
llamacpp = []
candle = ["dep:candle-core", "dep:candle-nn", "dep:candle-transformers", "dep:tokenizers"]
LLM 后端三种:LlamaServer(本地 llama.cpp server HTTP)、Candle(candle 原生推理,
需 candle feature)、Qwen3.5(qwen35,封装自 candle)。
⚠️ 注意:
src/eval/、src/state/(单数)、src/tui/、src/metric.rs、src/reporter.rs是孤儿源码——未被main.rs的mod引用、不参与编译(旧版遗留:eval=旧 engine、 state=states 旧名副本、tui=widgets 旧名副本、metric/reporter=更早的 trait 注册表骨架)。 活代码只走app/base/cmd/component/engine/states/widgets/utils。
2. 模块结构
src/
├── main.rs # 入口:headless CLI(run/playtest)+ TUI app
├── base.rs # AlgoType / RetrieveMode / TestReport 基础类型
├── cmd.rs # 命令解析
├── app.rs / app/event_loop.rs # TUI 应用与事件循环
├── component.rs # TUI 组件
├── engine.rs # 评测引擎模块树
│ ├── suite.rs # TestSuite trait、ReportMetric、MetricFormat(KV/Chart)
│ ├── dataset.rs # 数据集加载
│ ├── loader.rs # 加载器
│ ├── batch.rs # 批量运行 + ActionSummary 汇总
│ ├── compare.rs # 对比报告(CompareCaseData/CompareAggregate/CompareReport)
│ ├── retrieve/ # 检索评测
│ │ ├── suite.rs # RetrieveSuite(TestSuite 实现)
│ │ ├── data.rs # RetrieveCaseData / RankingMetrics / ActionMetrics
│ │ ├── dataset.rs # RetrQueryFileRaw / SubQuery / sweep 展开
│ │ └── batch.rs # process_one_dataset
│ ├── playtest/ # 角色对话测试
│ │ ├── runner.rs # PlayTestRunner(核心)
│ │ ├── trace.rs # RetrievalTrace / QueryTrace / TracedNode / HitStage
│ │ └── repair.rs # 修复逻辑
│ ├── metrics.rs / metrics/ranking.rs # Recall@K / MRR / NDCG / HitRate
│ ├── llm/ # backend.rs / candle_llm.rs / llama_server.rs / qwen35.rs
│ ├── consolidate.rs # ConsolidateSuite(stub,0 用例)
│ └── forget/ # 遗忘评测
│ ├── suite.rs # ForgetSuite(T1–T5)
│ ├── data.rs # ForgetFileRaw 数据
│ └── metric.rs # 指标
├── states/ # TUI 状态机(main_menu/batch/compare/playtest/...)
├── widgets/ # TUI 控件(chart/drilldown/editable_table/...)
└── tui/ # TUI 组件(components/、wizard_page)
3. CLI 用法
soul-tune run <algo> <dataset> [--batch]
soul-tune playtest <graph_dir> <dialogue_file> ...
soul-tune # 无参数进入 TUI
<algo> 可选值(main.rs):
| 参数 | 别名 | 模式 |
|---|---|---|
retrieve / retrieve/embedding | r / re | 仅向量相似度检索 |
retrieve/association | ra | PPR 联想检索 |
retrieve/full | rf | 完整管线(ShortOnly→Similarity→AssociateWithAction) |
consolidate | — | 巩固(stub) |
forget | f | 遗忘 T1–T5 评测(不支持 –batch) |
示例(README 亦引用):
# 完整检索管线批量评测
cargo run -p soul-tune -- run retrieve/full fixtures/example_data --batch
# 遗忘评测
cargo run -p soul-tune -- run forget fixtures/forget/forget_ebbinghaus_smoke.json
# 角色对话测试
cargo run -p soul-tune -- playtest fixtures/graphs fixtures/example_data/dialogue.json
4. 评测套件
4.1 TestSuite 抽象(engine/suite.rs)
pub trait TestSuite {
fn case_count(&self) -> usize;
fn run_case(&self, index: usize) -> TestCaseOutcome;
fn build_report(&self, outcomes, elapsed, total, passed, failed) -> SuiteReport;
}
// 指标:key_value_metric(...) / chart_metric(...),ReportMetric { label, group, format }
4.2 检索评测(RetrieveSuite)
- 数据:Query JSON(
RetrQueryFileRaw:name/graph_path/config/blend_sweep/test_cases/ expected_*),图 JSON(GraphNodeRaw[])。 - 三种评测模式(
RetrieveMode):Embedding:纯向量相似度;Association:相似度 + PPR 混合(EMBED_PPR_BLEND≈ 0.5 权重);FullPipeline:DefaultPipeline(含动作输出)。
- 合并排序:
merge_by_priority——分数主导 + priority 小偏移(0.05),多 query 按优先级 加权合并。 - 判定:must(
expected_combined_ranking必须命中)+ bonus(bonus_combined_ranking加分)拆分。 - 权重扫描:
blend_sweep(tag_sweep/pairs)→expand_sweep_pairs展开; 默认权重 tag=0.3 / variant=0.7(以BlendWeights::default代码为准)。 - 抽象检出指标:
has_expected_abstract/abstract_detected/abstract_direct_hit(期望抽象节点是否在合并结果中 / 是否被相似度直接命中)。 - 指标:
RankingMetrics { recall_at, precision_at, mrr, ndcg_at, hit_rate }、ActionMetrics { action_hit_rate, action_recall_at, has_expected_actions }。 - 批量:
run_batch(4 worker +AtomicUsize+ mpsc)扫描question_*.json; 仅统计带has_expected_actions真值的用例,summarize_action_metrics汇总动作命中率。
4.3 Playtest(PlayTestRunner)
- 加载图目录(
load(graph_dir))+ 对话文件(DialogueFile/ConversationEntry)。 process_turn:PAW 实体提取 → 两段式查询生成(含QUERY_VALIDATION_FLOOR0.35 兜底 校验 + 空回退)→ 双管线检索(相似度 + PPR)→ LLM 生成回复。- 动作三通道:Speak / Think / 行为(独立 top-k 进入最终结果)。
- 追踪:
RetrievalTrace/QueryTrace/TracedNode/HitStage(记录命中阶段)。 - 输出:
PlayTestResult/PlayTurnResult/PlayRunSnapshot,日志写%TEMP%/soul_tune_playtest_log.txt等;评测含盲测投票机制。
4.4 遗忘评测(ForgetSuite,T1–T5)
验证艾宾浩斯遗忘曲线的五个可观测推论(对应 测试数据规范 Forget JSON):
| 用例 | 验证点 |
|---|---|
| T1 时间单调 | 缺失度随 t 单调不减 |
| T2 激活抑制 | 缺失度随 retrieval_count 单调不增 |
| T3 量级校准 | 缺失度接近期望值 |
| T4 分段行为 | 三个时间点分别落入 NoAction / MaskOnly / Revised 区间 |
| T5 节点效果 | 触发率 + 语义熵增 |
T1–T4 为纯函数计算(compute_missing_degree / lazy_forget + mock LLM 闭包),T5 使用
注入的虚拟时长;transform_score(图编辑距离评分)为预留、未启用。
4.5 对比评测(compare)
build_compare_report:对多组结果按 (case_name, 权重) 对齐两侧,生成 CompareReport
(aggregate + per-case 对比)。
5. 与文档的对应关系
- 测试数据规范:Graph/Query/Forget JSON 格式的权威定义,
与
suite.rs/forget/suite.rs/fixtures/一致。 - 历史报告:playtest 报告与检索轨迹报告均基于 soul-tune 实测产出。
- 检索算法(PPR/相似度/Bayes)实现在
soul-mem-algo,soul-tune 只做编排与评测。
Soul-Tune 用户使用指南
概述
Soul-Tune 是 SoulMem 项目的 TUI 测试框架,用于可视化运行和展示记忆算法(检索、巩固、遗忘)的测试结果。
cargo run -p soul-tune
界面导航
1. 主界面
程序启动后显示主界面,列出可用算法类型。 按以下快捷键操作:
| 键 | 操作 |
|---|---|
: | 进入命令模式 |
T | 快速开始测试向导 |
Q | 退出程序 |
2. 命令模式
按 : 进入命令模式,底部出现输入栏。
: test retrieve
| 键 | 操作 |
|---|---|
Enter | 执行命令 |
Esc | 取消,返回主界面 |
Tab | 补全当前选中的建议 |
↑/↓ | 切换建议项 / 浏览历史命令 |
内置命令:
| 命令 | 别名 | 说明 |
|---|---|---|
test <algo> | t | 开始算法测试。algo 为 retrieve、consolidate 或 forget |
help | h | 显示帮助(预留) |
quit | q | 退出程序 |
补全提示:
输入 t 时自动弹出补全建议列表,Tab 键可补全:
匹配命令:
test — 运行算法测试
test retrieve — 检索算法测试
test consolidate — 巩固算法测试
test forget — 遗忘算法测试
3. 选择数据集
执行 test retrieve(或 consolidate / forget)后进入数据集选择界面。
- 左侧显示当前目录下的
.json文件和子目录 - 右侧显示选中文件的预览(名称、条目数等)
- 底部显示当前路径
| 键 | 操作 |
|---|---|
↑/↓ | 选择文件/目录 |
Enter | 确认选择(文件)或进入目录(目录) |
Esc | 返回主界面 |
4. 配置参数
选择数据集后进入参数配置界面。
| 参数 | 默认值 | 说明 |
|---|---|---|
top_k | 10 | 检索返回数量上限 |
threshold | 0.7 | 相似度阈值 |
damping | 0.85 | PPR 阻尼因子 |
iterations | 20 | 迭代次数 |
| 键 | 操作 |
|---|---|
↑/↓ | 选择参数行 |
Enter | 编辑当前值 |
Ctrl+Enter | 开始测试 |
Esc | 返回主界面 |
编辑状态下:
| 键 | 操作 |
|---|---|
Enter | 确认编辑 |
Esc | 取消编辑 |
5. 测试运行中
开始测试后显示进度界面,进度条实时更新。
- 模拟测试自动运行 100 个条目,约 1 秒完成
- 第 7 倍数条目标记为失败,其余通过
| 键 | 操作 |
|---|---|
Esc / Ctrl+C | 中止测试,返回主界面 |
6. 测试结果
测试完成后显示结果界面,分为两个 Tab。
Summary 选项卡(默认)
左侧显示指标面板(1/3 宽度):
性能
总耗时: 1.0s
条目总数: 100
准确率
通过: 86
失败: 14
通过率: 86.0%
算法配置
algo: retrieve
右侧显示相似度分布折线图(2/3 宽度),使用 ratatui 原生 Chart 组件渲染。
图表特征:
- X 轴:条目序号(自动生成等距标签)
- Y 轴:相似度值(自动分段显示)
- 折线图使用 Braille 点阵绘制
Detail 选项卡
逐条显示测试日志:
条目 级别 相似度 消息
[ 1] INFO 0.82 ✓ 通过
[ 2] INFO 0.87 ✓ 通过
[ 7] ERROR --- ✗ 失败 (预期≥3条, 返回2)
| 键 | 操作 |
|---|---|
←/→ | Summary 模式下切换指标组 |
Tab | 切换 Summary / Detail |
↑/↓ | 滚动当前面板 |
F | Detail 模式下切换日志筛选级别 |
/ | Detail 模式下搜索(预留) |
Q | 返回主界面 |
数据集格式
测试数据集为 .json 文件,位于任意本地路径:
{
"name": "retr_basic",
"description": "基础检索算法测试",
"algo_type": "retrieve",
"params": {
"top_k": { "int": 10 }
},
"entries": [ ... ]
}
架构说明
状态机
Main → Command → SelectDataset → ConfigParams → TestRunning → TestResults
↑ ↑ ↑ ↑ ↑ │
└──────┴────────────┴───────────────┴──────────────┴─────────────┘
Esc / Q
模块结构
src/
├── main.rs # 程序入口
├── app.rs # App 结构体 + 状态机 + 事件循环
├── base.rs # AlgoType, Transition, TestConfig, TestReport
├── cmd.rs # UserCmd, CmdRegistry (命令注册)
├── metric.rs # Metric trait + MetricRegistry (指标注册)
├── reporter.rs # TestReporter trait + ReporterRegistry (日志注册)
├── state/ # 各界面状态
│ ├── main.rs # 主界面
│ ├── command.rs # 命令模式
│ ├── dataset.rs # 数据集选择
│ ├── params.rs # 参数配置
│ ├── running.rs # 测试运行 (含 mock tick)
│ └── results.rs # 结果展示 (Summary + Detail Tab)
├── tui/
│ ├── wizard_page.rs # 原有布局助手
│ └── components/ # 可复用 UI 组件
│ ├── command_bar.rs # 命令输入条
│ ├── list.rs # 可滚动列表
│ ├── kv_table.rs # 键值对表格
│ ├── editable_table.rs # 可编辑表格
│ ├── chart.rs # 图表封装
│ ├── tab_bar.rs # Tab 切换
│ ├── status_bar.rs # 底部状态栏
│ └── gauge.rs # 进度条
└── utils/
└── fuzzy.rs # 模糊匹配 (nucleo)
Soul-Tune 测试框架 UI 设计
状态机
Main ──(`:`)──▶ CommandMode ──(解析命令)──▶ SelectDataset ──▶ ConfigParams ──▶ TestRunning ──▶ TestResults
▲ │ 获取 algo_type 选择数据集 编辑参数 进度条 Summary|Detail
│ │
└─── Esc ────────────┴───── Esc ──────────── Esc ─────────── Ctrl+C ────────┴─── Q ───┘
App 结构体
pub struct App {
pub state: AppState,
pub cmd_registry: CmdRegistry,
pub metric_registry: MetricRegistry,
pub reporter_registry: ReporterRegistry,
}
AppState 枚举
pub enum AppState {
Main,
CommandMode(CommandState),
SelectDataset(DatasetState),
ConfigParams(ParamState),
TestRunning(RunningState),
TestResults(ResultsState),
}
Transition: None | To(AppState) | Quit
各状态
1. MainState — 主界面
┌─ Soul-Tune · 记忆算法测试框架 ──────────────────────┐
│ │
│ 可用算法测试: │
│ ● 检索 (retrieve) │
│ ● 巩固 (consolidate) │
│ ● 遗忘 (forget) │
│ │
│ 输入 `:` 进入命令模式 │
│ 或按 `T` 快速开始测试向导 │
│ │
│─────────────────────────────────────────────────────│
│ [:]命令 [T]测试 [Q]退出 │
└─────────────────────────────────────────────────────┘
按键: : → CommandMode | T → CommandMode(“test “) | Q → Quit
2. CommandState — 命令模式
┌─ 命令模式 ──────────────────────────────────────────┐
│ │
│ 模糊匹配: │
│ test retrieve │
│ test consolidate │
│ test forget │
│ :t▌ │
│─────────────────────────────────────────────────────│
│ [Enter]执行 [Esc]取消 [Tab]补全 │
└─────────────────────────────────────────────────────┘
pub struct CommandState {
pub input: TextArea,
pub suggestions: Vec<String>,
pub selected_suggestion: usize,
pub history: Vec<String>,
pub history_idx: Option<usize>,
}
按键: Enter → 解析 | Esc → Main | Tab → 补全 | ↑↓ → 切换建议/历史
3. DatasetState — 数据集选择
┌─ 选择数据集 · <算法名> ──────────────────────────────┐
│───────────────────────────┬──────────────────────────│
│ 目录: /data/ │ 预览 │
│ │ │
│ ▶ file1.json │ name: retr_basic │
│ file2.json │ entries: 50 │
│ │ │
│───────────────────────────┴──────────────────────────│
│ 路径: /data/file1.json ▌ │
│──────────────────────────────────────────────────────│
│ [↑↓]选择 [Tab]切换面板 [Enter]确认 [Esc]返回 │
└──────────────────────────────────────────────────────┘
pub struct DatasetState {
pub algo_type: AlgoType,
pub current_dir: PathBuf,
pub entries: Vec<FileEntry>,
pub selected: usize,
pub path_input: TextArea,
pub active_panel: Panel,
pub preview_content: Option<String>,
}
4. ParamState — 参数配置
┌─ 参数配置 · <算法名> · <数据集> ──────────────────────┐
│ │
│ Param Value Description │
│ ────────────────────────────────────────────────── │
│ ▶ top_k [ 10 ] 最大返回数 │
│ threshold [ 0.7 ] 相似度阈值 │
│ damping [ 0.85 ] PPR 阻尼 │
│ │
│──────────────────────────────────────────────────────│
│ [↑↓]选择 [Enter]编辑 [Ctrl+Enter]运行 [Esc]返回 │
└──────────────────────────────────────────────────────┘
pub struct ParamState {
pub algo_type: AlgoType,
pub dataset_path: PathBuf,
pub rows: Vec<ParamRow>,
pub selected: usize,
pub editing: Option<usize>,
pub scroll: usize,
}
pub struct ParamRow {
pub name: String,
pub value: String,
pub description: String,
}
5. RunningState — 测试运行
┌─ ▶ 运行中 · <算法名> · <数据集> ───────────────────────┐
│ │
│ ████████████████████░░░░░░░░░░░░ 35/50 (70%) │
│ │
│ 当前: 条目 #18 │
│ 通过: 14 失败: 3 耗时: 1.2s │
│ │
│───────────────────────────────────────────────────────│
│ [Ctrl+C]中止 │
└───────────────────────────────────────────────────────┘
pub struct RunningState {
pub algo_type: AlgoType,
pub dataset_path: PathBuf,
pub total: usize,
pub current: usize,
pub passed: usize,
pub failed: usize,
pub elapsed: Duration,
pub current_description: String,
}
6. ResultsState — 结果展示
┌─ ✓ 完成 · <算法> · <数据集> ── [Summary│Detail] ──────┐
│─────────────────────────┬──────────────────────────────│
│ 性能 │ ▲ 耗时分布 │
│ ────────────────── │ │ ██▄ │
│ 总耗时: 2.3s │ │ ████ │
│ 平均: 46ms │ └─────────────────────── │
│ 最大: 180ms │ │
│ │ ▲ 通过率趋势 │
│ 准确率 │ │ ╱ │
│ ────────────────── │ │ ╱ │
│ 通过率: 70% │ │╱ │
│ 通过: 35 │ └─────────────────────── │
│ 失败: 15 │ │
│─────────────────────────┴──────────────────────────────│
│ [←→]切换指标组 [Tab]详情 [Q]返回 │
└──────────────────────────────────────────────────────┘
Detail Tab:
┌─ ✓ 完成 · <算法> · <数据集> ── [Summary│Detail] ──────┐
│───────────────────────────────────────────────────────│
│ 筛选: [全部 ▼] 搜索: [ ] │
│───────────────────────────────────────────────────────│
│ 时间 级别 来源 消息 │
│ ─────────────────────────────────────────────────── │
│ 14:30:01 INFO engine 测试开始 │
│ 14:30:01 INFO entry[1] ✓ 通过 │
│ 14:30:01 ERROR entry[2] ✗ 失败 │
│───────────────────────────────────────────────────────│
│ [↑↓]滚动 [/]搜索 [F]筛选 [Q]返回 │
└──────────────────────────────────────────────────────┘
pub struct ResultsState {
pub algo_type: AlgoType,
pub dataset_path: PathBuf,
pub active_tab: ResultTab,
pub kv_scroll: usize,
pub metric_group_idx: usize,
pub chart_scroll: usize,
pub log_scroll: usize,
pub log_filter: ReportLevel,
pub log_search: String,
}
pub enum ResultTab { Summary, Detail }
组件列表
| 组件 | 文件 | 说明 |
|---|---|---|
| CommandBar | tui/components/command_bar.rs | 渲染 : + TextArea + 建议 |
| List | tui/components/list.rs | 可滚动列表,高亮选中 |
| KvTable | tui/components/kv_table.rs | 分组只读键值表 |
| EditableTable | tui/components/editable_table.rs | 可编辑表格 |
| Chart | tui/components/chart.rs | 封装 ratatui::widgets::Chart |
| TabBar | tui/components/tab_bar.rs | Tabs 切换 |
| StatusBar | tui/components/status_bar.rs | 底部快捷键提示 |
| Gauge | tui/components/gauge.rs | 进度条 |
Trait 定义
Metric
pub enum MetricDisplayKind { KeyValue, Chart { x_label, y_label } }
pub enum MetricData { Single(MetricValue), ChartPoints(Vec<(f64, f64)>) }
pub enum MetricValue { Int, Float, String, Duration, Percent, Bool }
pub trait Metric: Send + Sync {
fn id(&self) -> &str;
fn display_name(&self) -> &str;
fn category(&self) -> &str;
fn display_kind(&self) -> MetricDisplayKind;
fn value(&self) -> MetricData;
}
pub struct MetricRegistry { metrics: Vec<Box<dyn Metric>> }
Reporter
pub enum ReportLevel { Debug, Info, Warn, Error }
pub struct ReportEntry { pub time: String, pub level: ReportLevel, pub source: String, pub message: String }
pub trait TestReporter: Send + Sync {
fn id(&self) -> &str;
fn name(&self) -> &str;
fn entries(&self) -> Vec<ReportEntry>;
}
pub struct ReporterRegistry { reporters: Vec<Box<dyn TestReporter>> }
SoulTuneEvent 扩展
pub enum SoulTuneEvent {
CrossTerm(crossterm::event::Event),
StartTest(AlgoType, Option<PathBuf>),
TestComplete,
Quit,
}
pub enum AlgoType { Retrieve, Consolidate, Forget }
测试数据规范
文档目的
本文档定义了 SoulMem 测试数据格式规范(由数据生成脚本/工具产出,覆盖):
- Graph JSON — 记忆图(MemoryNote 构成的带权图)
- Query JSON — 检索测试用例(子查询、期望真值、权重调参)
一、Graph JSON — 记忆图文件
路径: fixtures/graphs/<name>.json
结构: 顶层为 GraphNodeRaw[](JSON 数组)
[
{
"id": "mem_rust", // string — 全局唯一可读ID,加载时映射为UUID
"tags": ["Rust", "编程"], // string[] — 标签(参与embedding计算)
"mem_type": {
"Semantic": { /* SemMemory */ } // 详见下方 #mem_type
},
"mem_links": [ /* MemoryLink[] */ ] // 可选,默认为 []
}
]
1.1 #mem_type — 节点类型
1.1.1 Semantic — 语义记忆
{
"Semantic": {
"content": "Rust语言",
"aliases": ["Rust"],
"concept_type": "Entity",
"description": "一种注重内存安全和零成本抽象的系统编程语言"
}
}
| 字段 | 类型 | 说明 |
|---|---|---|
content | string | 主内容 |
aliases | string[] | 别名列表 |
concept_type | "Entity" / "Abstract" | 概念类型 |
description | string | 描述文本 |
1.1.2 Situation::SpecificSituation — 具体情境记忆
{
"Situation": {
"SpecificSituation": {
"narrative": "上午在办公室用Rust编写了一个HTTP服务器",
"time_span": "2026-06-01T08:00:00Z",
"context": {
"location": {
"name": "办公室",
"coordinates": "北京,海淀"
},
"participants": [
{ "name": "张三", "role": "开发者" }
],
"emotions": [
{ "name": "专注", "intensity": 0.9 }
],
"sensory_data": [],
"environment": {
"atmosphere": "安静",
"tone": "专业"
},
"event": [
{
"action": "编写代码",
"action_intensity": 0.8,
"initiator": "张三",
"target": "Rust项目"
}
]
}
}
}
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
narrative | string | ✅ | 叙事文本 |
time_span | string (ISO 8601) | ✅ | 时间 |
context.location | {name, coordinates?} | ❌ | 地点 |
context.participants[] | [{name, role}] | ❌ | 参与者数组 |
context.emotions[] | [{name, intensity}] | ❌ | 情感数组 |
context.sensory_data[] | [] | ❌ | 感官数据(当前保留,填空数组) |
context.environment | {atmosphere, tone} | ✅ | 环境 |
context.event[] | [{action, action_intensity?, initiator?, target?}] | ❌ | 事件数组 |
1.1.3 Situation::AbstractSituation — 抽象情境记忆
四种变体之一:
{ "Situation": { "AbstractSituation": { "Location": {"name": "学校", "coordinates": "北京"} } } }
{ "Situation": { "AbstractSituation": { "Participant": {"name": "张三", "role": "学生"} } } }
{ "Situation": { "AbstractSituation": { "Environment": {"atmosphere": "温暖", "tone": "舒适"} } } }
{ "Situation": { "AbstractSituation": { "Event": {"action": "学习", "action_intensity": 0.7, "initiator": "张三", "target": "知识"} } } }
1.1.4 Procedure — 程序记忆
{
"Procedure": {
"action": {
"content": "使用搜索引擎",
"action_type": { "Skill": {} }
}
}
}
action_type 枚举:
"Speak"{ "Skill": {} }"Think"
1.2 #mem_links — 边定义
{
"from": "mem_rust",
"to": "mem_python",
"intensity": 0.8,
"link_type": {
"Sem": { "verb": "related", "confidence": 0.7 }
}
}
| 字段 | 类型 | 说明 |
|---|---|---|
from | string | 源节点 ID(必须在同一 graph 文件中) |
to | string | 目标节点 ID |
intensity | f64 | 关联强度 |
link_type | — | 以下三种之一 |
link_type 枚举:
// 语义边
{ "Sem": { "verb": "related", "confidence": 0.7 } }
// 程序边
{ "Proc": { "TrigToAction": { "prob": 0.8 } } }
// 情境边
{ "Situation": { "AbstractToSpecific": {} } }
{ "Situation": { "SpecificToAbstract": {} } } // 具体→抽象:PPR 从具体情境检出抽象模式的路径
二、Query JSON — 检索测试用例文件
路径: fixtures/queries/<name>.json
结构: 顶层为 RetrQueryFileRaw 对象
{
// ═══ 元信息 ═══
"name": "retr_sim_smoke_zh",
"description": "向量相似性搜索冒烟测试 — 覆盖语义和情境混合查询",
// ═══ graph 引用(相对于 query JSON 所在目录的路径) ═══
"graph_path": "../graphs/rust_small_zh.json",
// ═══ 检索执行配置 ═══
"config": {
"similarity_threshold": 0.0, // f32 - 相似度最低阈值
"max_results": 10, // usize - 每次搜索最大返回数
"test_k_values": [1, 3, 5] // usize[] - 评估 k 值列表
},
// ═══ 权重调参(可选,不写则使用默认值 0.3/0.7) ═══
"blend_sweep": {
// 方式一: 快捷扫 tag 权重(生成 pairs: (tag, 1.0-tag))
"tag_sweep": [0.1, 0.3, 0.5, 0.7, 0.9],
// 方式二: 显式权重对列表(与 tag_sweep 互斥,pairs 优先)
"pairs": [
{ "tag": 0.3, "variant": 0.7 },
{ "tag": 0.5, "variant": 0.5, "sem_concept": 0.7, "sem_description": 0.3 }
]
},
// ═══ 测试用例列表 ═══
"test_cases": [
{
"name": "用户问-Rust资讯",
"description": "LLM拆解: Rust概念查询 + 编程上下文",
"sub_queries": [ /* SubQuery[] —— 见 #sub_query */ ],
"expected_per_query": [ /* PerQueryExpectation[] —— 见 #expected_per_query */ ],
"expected_combined_ranking": ["mem_rust", "mem_python"],
"expected_actions": []
}
]
}
顶层字段说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
name | string | ✅ | 测试套件名称 |
description | string | ✅ | 描述 |
graph_path | string (PathBuf) | ✅ | 相对路径指向 Graph JSON |
config | TestConfigRaw | ✅ | 执行配置 |
blend_sweep | BlendSweepRaw? | ❌ | 权重调参配置 |
test_cases[] | TestCaseQueryRaw[] | ✅ | 测试用例数组 |
2.1 #sub_query — 子查询
每个子查询模拟 LLM 拆解出的一个独立检索请求:
{
"priority": 1, // u32 - 优先级(越大越重要)
"tag": ["Rust", "编程"], // string[] - 标签数组
"variant": {
/* MemoryRetrieveQueryVariant - 二选一: Semantic 或 Situation */
}
}
Semantic 变体:
{
"variant": {
"Semantic": [
{
"concept_identifier": "Rust语言",
"description": "系统编程语言"
}
]
}
}
Semantic 是数组,每个元素包含:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
concept_identifier | string | ✅ | 概念标识符,用于与 SemMemory.content 语义比较 |
description | string | ❌ | 描述文本,用于与 SemMemory.description 语义比较 |
Situation 变体:
{
"variant": {
"Situation": [
{
"narrative": "用Rust写HTTP服务器",
"location": [{ "name": "办公室" }],
"participants": [{ "name": "张三", "role": "开发者" }],
"time_span": [
{ "start": "2026-06-01T08:00:00Z", "end": "2026-06-01T12:00:00Z" }
],
"environment": { "atmosphere": "安静" },
"event": [{
"action": "编写代码",
"initiator": "张三",
"target": "HTTP服务器"
}]
}
]
}
}
Situation 是数组,所有子字段均为可选。每种字段的嵌入向量在缺失时为 None,计算时以 0 处理。
2.2 #expected_per_query — 子查询期望结果
[
{ "q": 0, "ranking": ["mem_rust"] },
{ "q": 1, "ranking": ["mem_rust", "mem_python"] }
]
| 字段 | 类型 | 说明 |
|---|---|---|
q | usize | 对应 sub_queries 数组下标 |
ranking | string[] | 期望的节点 ID 排序(按相关度降序) |
ID 引用 graph JSON 中定义的 "id" 值。
2.3 #expected_combined_ranking — 合并期望
所有子查询按优先级加权合并后的期望排序结果:
"expected_combined_ranking": ["mem_rust", "mem_python"]
2.4 expected_actions — 动作节点期望
检索算法判断出的“下一步行动“期望结果,用于测试 RetrAction 模块:
"expected_actions": []
注:早期版本为填空数组(占位);当前 24 角色评测数据已含非空真值, 对应评测指标为 Action Hit Rate / Action Recall@K(见
soul-tune引擎engine/retrieve/data.rs的ActionMetrics)。
2.5 抽象检出期望(可选)
带真值时才计入抽象指标(soul-tune 评测输出含 abstract_detected / abstract_direct_hit):
| 字段 | 说明 |
|---|---|
has_expected_abstract | 该用例期望结果是否包含抽象情境节点 |
abstract_detected | 期望抽象节点是否出现在合并结果(相似度 + PPR)中 |
abstract_direct_hit | 期望抽象节点是否被相似度直接命中(数据侧泛化观测门) |
三、BlendSweep — 权重扫描配置
blend_sweep 是 Query JSON 顶层的可选字段。配置后,每个基础测试用例会按每对权重展开为多个用例,用于测试不同权重组合下的检索效果。
不写 blend_sweep 字段 = 只执行一次 (tag=0.3, variant=0.7) 的默认权重。
"blend_sweep": {
"tag_sweep": [0.1, 0.3, 0.5, 0.7, 0.9],
"pairs": [
{ "tag": 0.3, "variant": 0.7 }
]
}
3.1 两种配置方式
方式一:tag_sweep(快捷扫描)
自动生成 (tag, variant = 1.0 - tag) 的权重对序列。
{ "tag_sweep": [0.3, 0.5, 0.7] }
等价于:
{ "pairs": [
{ "tag": 0.3, "variant": 0.7 },
{ "tag": 0.5, "variant": 0.5 },
{ "tag": 0.7, "variant": 0.3 }
] }
方式二:pairs(显式权重对)
精确指定每个权重对,可覆盖全部子字段:
| 字段 | 默认值 | 含义 |
|---|---|---|
tag | 0.3 | tag/variant 顶层融合—tag 权重 |
variant | 0.7 | tag/variant 顶层融合—variant 权重 |
sem_concept | 0.5 | 概念分 vs 描述分 |
sem_description | 0.5 | 描述分权重 |
sit_location_name | 0.6 | Location 名称 vs 坐标 |
sit_location_coord | 0.4 | |
sit_participant_name | 0.6 | Participant 名称 vs 角色 |
sit_participant_role | 0.4 | |
sit_env_atmosphere | 0.5 | Environment 氛围 vs 色调 |
sit_env_tone | 0.5 | |
sit_event_initiator | 0.3 | Event 三项权重(必须 ≥0 且三者之和建议为 1.0) |
sit_event_target | 0.3 | |
sit_event_action | 0.4 | |
sit_event_initiator_only_action | 0.6 | 缺 target 时 action 的权重(initiator = 1 - this) |
sit_event_target_only_action | 0.6 | 缺 initiator 时 action 的权重(target = 1 - this) |
3.2 测试用例展开
- 不配置
blend_sweep:每个基础用例 → 1 个展开用例(默认权重) - 配置
tag_sweep: [0.3, 0.5, 0.7]:test_cases.length × 3个展开用例 - 配置
pairs: [...]:test_cases.length × pairs.length个展开用例 pairs与tag_sweep同时出现时,pairs优先
3.3 示例
{
"blend_sweep": {
"tag_sweep": [0.3, 0.5, 0.7]
},
"test_cases": [
{ "name": "case1", ... },
{ "name": "case2", ... },
{ "name": "case3", ... }
]
}
3 base × 3 tag_sweep = 9 个展开用例,report 中会按 (tag, variant) 分组展示各权重下的平均指标。
{
"blend_sweep": {
"pairs": [
{ "tag": 0.3, "variant": 0.7 },
{
"tag": 0.5,
"variant": 0.5,
"sem_concept": 0.7,
"sem_description": 0.3,
"sit_participant_name": 0.8,
"sit_participant_role": 0.2
}
]
}
}
具体实现在 expand_sweep_pairs() 函数中,参考测试用例 fixtures/queries/retr_sim_smoke_zh_blend.json。
四、数据生成指南
4.1 数据流转
数据生成工具(如 soul_scraper 等)生成
├── graph JSON → 反序列化 Vec<GraphNodeRaw> → BGE 嵌入 → WorkingMemory
└── query JSON → 反序列化 RetrQueryFileRaw → 子查询嵌入 → RetrieveSuite.run_case()
│
└── graph_path 指向 graph JSON(相对 query JSON 的路径)
注:仓库当前
fixtures/数据为手工/脚本产物,soul_scraper工具本身不在本仓库中。
4.2 关键约束
- ID 一致性: query JSON 中
expected_per_query[].ranking[]和expected_combined_ranking[]引用的 ID 必须在对应 graph JSON 中存在 - 文件组织: graph JSON 建议放在
fixtures/graphs/,query JSON 放在fixtures/queries/ - graph_path 解析:
graph_path是相对于 query JSON 所在目录的路径 - 无 blend_sweep: 不写
blend_sweep字段 = 只执行一次(tag=0.3, variant=0.7) - 负样本测试: 期望
expected_combined_ranking为空数组的用例不会被视为“失败“
4.3 测试数据规模建议
| 级别 | 节点数 | 用例数 | 子查询/用例 | 适用场景 |
|---|---|---|---|---|
| 微 (unit) | 5-10 | 5-10 | 1-2 | CI 快速验证 |
| 小 (smoke) | 10-50 | 3-10 | 1-3 | 本地开发验证 |
| 中 (bench) | 100-500 | 20-50 | 1-5 | 性能/准确率 benchmark |
| 大 (stress) | 5000+ | 200+ | 2-10 | 压力测试 |
五、完整参考示例
完整 Graph JSON (fixtures/graphs/rust_small_zh.json)
[
{
"id": "mem_rust",
"tags": ["Rust", "编程", "系统"],
"mem_type": {
"Semantic": {
"content": "Rust语言",
"aliases": ["Rust"],
"concept_type": "Entity",
"description": "一种注重内存安全和零成本抽象的系统编程语言"
}
},
"mem_links": []
},
{
"id": "sit_coding_day",
"tags": ["事件", "编码", "Rust"],
"mem_type": {
"Situation": {
"SpecificSituation": {
"narrative": "上午在办公室用Rust编写了一个HTTP服务器",
"time_span": "2026-06-01T08:00:00Z",
"context": {
"location": { "name": "办公室", "coordinates": "北京,海淀" },
"participants": [{ "name": "张三", "role": "开发者" }],
"emotions": [{ "name": "专注", "intensity": 0.9 }],
"sensory_data": [],
"environment": { "atmosphere": "安静", "tone": "专业" },
"event": [{
"action": "编写代码",
"action_intensity": 0.8,
"initiator": "张三",
"target": "Rust项目"
}]
}
}
}
},
"mem_links": []
}
]
完整 Query JSON (fixtures/queries/retr_sim_smoke_zh.json)
{
"name": "retr_sim_smoke_zh",
"description": "向量相似性搜索冒烟测试",
"graph_path": "../graphs/rust_small_zh.json",
"config": {
"similarity_threshold": 0.0,
"max_results": 10,
"test_k_values": [1, 3, 5]
},
"test_cases": [
{
"name": "用户问-Rust资讯",
"description": "LLM拆解: Rust概念查询 + 编程上下文",
"sub_queries": [
{
"priority": 1,
"tag": ["Rust", "编程"],
"variant": {
"Semantic": [
{ "concept_identifier": "Rust语言", "description": "系统编程语言" }
]
}
},
{
"priority": 2,
"tag": ["编程"],
"variant": { "Semantic": [] }
}
],
"expected_per_query": [
{ "q": 0, "ranking": ["mem_rust"] },
{ "q": 1, "ranking": ["mem_rust", "mem_python"] }
],
"expected_combined_ranking": ["mem_rust", "mem_python"],
"expected_actions": []
},
{
"name": "用户问-无意义XYZ",
"description": "负样本 — 应返回空结果",
"sub_queries": [
{
"priority": 1,
"tag": ["XYZ"],
"variant": {
"Semantic": [
{ "concept_identifier": "不存在的概念" }
]
}
}
],
"expected_per_query": [
{ "q": 0, "ranking": [] }
],
"expected_combined_ranking": [],
"expected_actions": []
}
]
}
注: 在生成查询文本时,不应该使用疑问句,不应该包含语气或关系连接词,应当描述实体或用简短的陈述句描述查询的情境。
完整 Query JSON — 带权重扫描 (fixtures/queries/retr_sim_smoke_zh_blend.json)
{
"name": "retr_sim_smoke_zh_blend",
"description": "带权重扫描的向量相似性搜索冒烟测试 — tag_sweep: [0.3, 0.5, 0.7]",
"graph_path": "../graphs/rust_small_zh.json",
"config": {
"similarity_threshold": 0.0,
"max_results": 10,
"test_k_values": [1, 3, 5]
},
"blend_sweep": {
"tag_sweep": [0.3, 0.5, 0.7]
},
"test_cases": [
{
"name": "用户问-Rust资讯",
"description": "LLM拆解: Rust概念查询 + 编程上下文",
"sub_queries": [
{
"priority": 1,
"tag": ["Rust", "编程"],
"variant": {
"Semantic": [
{ "concept_identifier": "Rust语言", "description": "系统编程语言" }
]
}
},
{
"priority": 2,
"tag": ["编程"],
"variant": { "Semantic": [] }
}
],
"expected_per_query": [
{ "q": 0, "ranking": ["mem_rust"] },
{ "q": 1, "ranking": ["mem_rust", "mem_python"] }
],
"expected_combined_ranking": ["mem_rust", "mem_python"],
"expected_actions": []
}
]
}
3 个基础测试用例 × 3 组权重 (tag=0.3, 0.5, 0.7) = 9 个展开用例。Report 按 (tag, variant) 分组展示指标。
四、Forget JSON — 遗忘效果测试
路径: fixtures/forget/<name>.json
结构: 顶层对象,自包含(不依赖 graph 文件),覆盖艾宾浩斯遗忘曲线的五个可观测推论(T1–T5)。
遗忘算法的效果判据是「信息量的遗忘是否符合艾宾浩斯遗忘曲线」。直接验证曲线本身会陷入循环论证(缺失度就是按曲线算的),因此测试验证曲线的可观测推论:
| 推论 | 用例类型 kind | 判定 |
|---|---|---|
| T1 时间单调性 | time_monotonic | 缺失度随经过时间单调不减 |
| T2 激活抑制 | activation | 缺失度随激活次数单调不增(封顶后不再下降) |
| T3 量级校准 | magnitude | 半衰期处缺失度 ≈ 期望值(容差内) |
| T4 分段行为 | branch | 三个时间点分别落入 NoAction / MaskOnly / Revised 区间 |
| T5 节点效果 | effect | 遗忘触发与否 + 动作强度 + 语义熵增(前后 trigram 相似度下降) |
顶层字段
{
"name": "forget_ebbinghaus_smoke",
"description": "艾宾浩斯遗忘曲线效果冒烟测试",
"config": {
"base_half_life_hours": 24.0, // 半衰期(小时),R=0.5 的时间点
"active_factor": 0.1, // 激活抑制系数:半衰期 ×= (1 + active_factor × min(retrieval, cap))
"max_activation_cap": 50 // 激活次数计入遗忘的上限
},
"test_cases": [ /* 见下方各类型 */ ]
}
time_monotonic(T1)
{
"kind": "time_monotonic",
"name": "T1-时间单调性",
"text": "今天下午我和张三在北京王府井的星巴克讨论了项目进展",
"time_offsets_hours": [0, 6, 12, 24, 48, 96, 168], // 升序扫描
"retrieval_count": 0
}
activation(T2)
{
"kind": "activation",
"name": "T2-激活抑制",
"text": "鲁迅原名周树人浙江绍兴人",
"activation_counts": [0, 5, 20, 50, 200], // 扫描激活次数
"time_offset_hours": 48
}
magnitude(T3)
{
"kind": "magnitude",
"name": "T3-半衰期校准",
"text": "机器学习是人工智能的一个重要分支",
"time_offset_hours": 24,
"expected_missing_degree": 0.5,
"tolerance": 0.08,
"retrieval_count": 0
}
branch(T4)
{
"kind": "branch",
"name": "T4-分段行为",
"text": "昨天下午我们团队在会议室开了三个小时的 Sprint 回顾会议",
"time_offsets_hours": [0, 4, 96], // 应分别落入 NoAction / MaskOnly / Revised
"retrieval_count": 0
}
effect(T5)
{
"kind": "effect",
"name": "T5-语义节点遗忘",
"mem_kind": "semantic", // "semantic"(SemMemory)或 "situation"(SpecificSituation)
"text": "张三上个月去杭州出差在西湖边吃了东坡肉和龙井虾仁",
"retrieval_count": 0,
"time_offset_hours": 96,
"expected": {
"should_forget": true, // 是否应触发遗忘(缺失度超过阈值)
"min_action": "MaskOnly" // "NoAction" | "MaskOnly" | "Revised",动作强度下限
}
}
运行方式
# headless 单数据集
soul-tune run forget fixtures/forget/forget_ebbinghaus_smoke.json
# 或 TUI 中按 F / 命令模式 `test forget`
Report 指标:用例通过率、遗忘触发率(T5 中 should_forget 且实际触发的比例)、平均缺失度、平均图谱变换评分(预留,LLM 提图启用后生效)。
记忆算法测试
⚠️ 文档状态:本文档为规划方法论(图谱变换 VA/VD/VC/EA/ED/EC 评分),未落地 实现。实际遗忘评测已改用 T1–T5 可观测推论(见 测试数据规范 第四部分),并由
soul-tune run forget驱动。
遗忘
1. 测试流程定义
$$ Situation_{mem} \longrightarrow narrative + Context $$ $$ \longrightarrow nar_graph + Context $$
- 遗忘过程中的变化:Context 和 narrative 可能各自独立变化,因而产生矛盾。因此,测试时,以重建的 $mem$ 为准。
- Context 处理:Context 为结构化数据,使用 embedding 模型,对对应字段进行余弦相似度计算。
- 评分函数:$\sum (1 - \cos(vec1, vec2))$
- Narrative 处理:narrative 将由 LLM 提取成图谱后进行比较。
- 记原图谱为 $G$,遗忘后图谱为 $G’$。
2. 图谱变换操作
$G$ 总可以通过有限次如下变换为 $G’$。记从 $G \rightarrow G’$ 的最短变换序列为 $T_{fn} = (f_1, f_2, f_3 \dots f_n)$。
基本变换操作集合 $F$ 包含以下6种:
- 顶点的增加 $\rightarrow VA$
- 顶点的删除 $\rightarrow VD$
- 顶点的内容变化 $\rightarrow VC$
- 边的增加 $\rightarrow EA$
- 边的删除 $\rightarrow ED$
- 边的内容变化 $\rightarrow EC$
采用最短变换序列的原因: 遗忘是信息量的变化。如果有一个更长的变换序列,那意味着有一部分的遗忘操作没有发挥实际效果
3. 图谱节点判定与合并
由于重建图谱由 LLM 建立,因此即使信息并未改变,表述内容也可能不同。使用同一判定函数:
$$ I(v_1, v_2) = \begin{cases} 1, & v_1 = v_2 \text{ 或 } \cos(v_1, v_2) \ge 0.9 \ 0, & \text{其他} \end{cases} \quad (v_1 \in G, v_2 \in G’) $$
- 此处构建的图谱是简单的,通常只包含实体名与关系名。
- 因此若 $I(v_1, v_2), I(v_2, v_3)$,则合并 $v_2, v_3$ 为同一点(注:原文逻辑似指传递性合并),采用任一点内容作为新点内容。
4. 变换序列评分
对于一变换序列 $T_{fn} = (f_1, f_2, \dots, f_n)$,其中 $f_i \in {VA, VD, VC, EA, ED, EC} = F$。
令 $S: F \rightarrow [0, 1]$ 为原子变换评分函数。
则序列评分公式为: $$ S_{T_{fn}} = S_L(l_{T_{fn}}) \cdot \sum S(f_i) $$
- 其中 $S_L(l_{T_{fn}})$ 为长度评分函数,越长分数越高(箭头标注说明)。
5. 问题与实验方案
主要问题:
- 寻找最短变换序列。
- 如何处理事件时间顺序的“记错”。
测试方案: 由于测试方案使用 LLM 和 embedding,因此需重复测试。
- 若选 $m$ 个不同的 LLM,$n$ 个不同的 embedding。
- 则测试 $k \cdot m \cdot n$ 次。
- 每个 $(LLM, embedding)$ 对测试 $k$ 次。
历史报告
本部分收录 SoulMem 开发过程中的评测/验证历史报告,按时间顺序排列。这些报告记录了当时 的算法状态与实测结果,作为演进轨迹存档(不代表当前代码状态;当前实现见 检索与联想 与 soul-tune)。
| 报告 | 日期 | 主题 |
|---|---|---|
| Playtest 检索效果测试报告 | 2026-08-04 | soul-tune playtest 与基础检索的首次效果测试 |
| 检索算法改进轨迹报告 | 2026-08-10 | 10 个连续 commit 的检索算法改进实测 |
| 抽象 PPR 检出心智模型落地报告 | 2026-08-11 | 抽象经 PPR 检出心智模型落地(两角色试点) |
| 全量角色 playtest 验证报告 - 抽象 PPR 检出 | 2026-08-12~13 | 24 图全量重生成后的逐角色验证(48 轮) |
Playtest 检索效果测试报告
日期:2026-08-04 测试对象:
soul-tuneplaytest(headless CLI)与基础 retrieve 日志目录:%TEMP%/soul_tune_playtest_*.txt、soul_tune_retrieve_log.txt、soul_tune_llm_output.txt
1. 背景
此前 playtest 检索存在严重问题:几乎无有效检索。排查确认根因是 RawVariant 的
#[serde(untagged)] 枚举顺序缺陷——LLM 按提示词输出的 {"Semantic": [{"concept_identifier": ...}]}
会被误解析成概念为空的单单元(SemanticSingle 贪婪吞掉包裹对象),导致空概念 → 零相似度 →
被 0.3 阈值过滤 → 检索为空。
本次测试在以下修复后执行:
RawVariant重构:包裹形态用结构体正确解析(Semantic { Semantic: [...] }/Situation { Situation: [...] }),RawSemUnit/RawSitUnit加deny_unknown_fields防止贪婪错配。- 新增 Situation 查询支持:
RawSitUnit系列类型 + LLM 提示词示例,playtest 首次可生成情境查询。 - Priority 加权合并:移植 suite 的
merge_by_priority,同节点跨查询累加priority × score。 DialogueFile.config应用到 headless playtest(与 TUI 对齐)。DialogueFile.role字段:headless CLI 可设置自身角色。
2. 测试环境
| 项 | 值 |
|---|---|
| 平台 | Windows,Rust debug 构建 |
| Chat LLM | Qwen3.5-4B-Q6_K.gguf(llama-server 子进程) |
| 嵌入模型 | BgeSmallZh(hf-hub 自动下载 + 镜像回退) |
| 图数据 | fixtures/example_data/(萌娘百科角色图) |
| CLI | soul-tune playtest <graph_dir> <dialogue_file> |
3. 测试矩阵(9 档)
同一批对话、仅切换 role,构成宽泛角色 → NPC(靠 role 描述建立交集)→ 具体角色三级谱系。
| 角色 | L1 宽泛 | L2 NPC+交集 | L3 具体角色 |
|---|---|---|---|
| 博丽灵梦 | 神社的常客 | 常客 + 受托调查异变 | 雾雨魔理沙,老朋友 + 调查异变 |
| 格蕾修 | 一起生活的同伴 | 同伴 + 启明城壁画任务 | 华(符华姐姐),长辈 + 壁画任务 |
| 花火 | 列车上的朋友 | 朋友 + 假面舞会筹备 | 开拓者「星」,列车乘客 + 假面舞会 |
4. 基础 retrieve 基线
以格蕾修 question.json(30 用例,embedding 模式)验证:
共 30 用例,通过 30
全部 MRR=1.0000 / Hit=1.00
结论:基础检索在 example 数据上保持满分,修复未破坏既有评测链路。
5. 检索管线健康度
| 检查项 | 结果 |
|---|---|
空查询 [] | 格蕾修/花火全部非空;灵梦 L1/L2 寒暄轮偶发空数组(LLM 自决,见 §7) |
| 空 concept_identifier / 空 tag | 0 处(修复生效,无回归) |
| NaN / Inf 分数 | 0 处 |
| trace=None(无检索) | 0 次(有效查询轮次) |
| 命中量 | sim 6–8 / PPR 8 / Situation 查询带 action 3 命中,稳定 |
6. Role 三级谱系效果
6.1 检索层级随 role 具体化而提升
以灵梦第 3 轮(“神社附近可疑身影”)为例:
| 层级 | 检索模式 | 代表命中 |
|---|---|---|
| L1 | Semantic 概念层 | sem_self、符卡规则、大结界、魔理沙、爱丽丝 |
| L2 | Situation 事件层 | sit_urban_legend_incident(都市传说异变)、sit_suika_stay、sit_drunken_hanami |
| L3 | Situation 事件层(分数更高) | sit_marisa_disappear(魔理沙被怨灵附身消失)、sit_red_mist_incident、sit_party_* |
6.2 L3 解锁角色专属记忆
- 灵梦 → 魔理沙:激活
sit_marisa_disappear;回复直接称呼“魔理沙“—— “喂喂,魔理沙,你该不会又看到什么吓人的妖怪了吧?” - 格蕾修 → 华:回复称呼“华姐姐“——“华姐姐,我画的是把星星都聚成暖光的样子”;
命中
sem_hua、sit_awaken_by_hua(被符华唤醒)。 - 花火 → 开拓者星:激活
sit_pam_sms(花火假扮帕姆给开拓者发短信);回复称呼“开拓者“—— “别急嘛开拓者,舞会的请柬已经像雪花一样塞进大家的行李里了”。
6.3 观察小结
- 宽泛角色(L1):检索停留在概念层,寒暄轮常返回空查询,对话缺乏记忆锚点。
- NPC+交集(L2):role 中的场景/任务描述让查询进入事件记忆层,情境感明显增强。
- 具体角色(L3):检索命中角色专属记忆,且角色关系真正参与对话(称呼、口吻、共享事件), 是三者中体验与检索质量最优的一档。
7. 发现的问题与建议
7.1 缺陷:空内容节点注入上下文(未修复,建议优先处理)
现象:format_nodes() 向 LLM 上下文注入空行。
实测:格蕾修 proc_none Action score=2.72 |(空);花火 sit_bored_event ... |(空)。
根因:engine/playtest/runner.rs::load() 构建 NodeSummary.primary 时:
MemoryType::Procedure(_) => primary = "" // 丢弃 action.content
MemoryType::Situation(_) // AbstractSituation => primary = "" // 丢弃 Event.action
但图中这些节点实际有内容:
proc_none:Procedure,动作 = “平时没有采取任何特定行动”sit_bored_event/sit_chaos_event:AbstractSituation,Event.action = “感到无聊” / “制造混乱”
影响:浪费上下文、丢失记忆信息、提示词中出现裸 - [流程] / - [情境] 噪声行。
建议:runner.rs 中 Procedure 分支取 action.content,AbstractSituation 分支取 Event.action
(纯数据提取改进,不涉及检索逻辑)。
7.2 观察:分数尺度失衡(设计副作用)
Semantic 查询 priority 7–10,合并分约 10.6;Situation 记忆约 2.4。上下文被语义概念节点主导, 情境记忆沉底。来自 priority 加权合并(与 suite 一致),非 bug,但使最终上下文偏语义化, 是否需归一化或分层截断值得产品层权衡。
7.3 观察:LLM 偶发返回空查询数组 []
灵梦 L1/L2 寒暄轮(“神社有活动吗”)返回 [],该轮无检索、纯靠 LLM 常识回复。
非管线 bug,但无兜底(如空查询时回退到系统提示词内记忆)。若希望寒暄轮也有记忆支撑,
可考虑空查询兜底策略。
8. 结论
- 此前“playtest 几乎无检索“的问题已解决:查询非空、概念完整、命中稳定、Situation 端到端可用。
- 基础 retrieve 回归通过:30/30,MRR=1.0。
- Role 三级谱系成立:宽泛 → NPC → 具体角色的检索深度与互动质量逐级提升,具体角色激活专属记忆。
- 遗留 1 个数据提取缺陷(空内容节点,§7.1)与 2 个产品层权衡点(§7.2、§7.3)。
附录:测试数据文件
fixtures/daily_dialogues/
├── reimu_daily_l1_broad.json
├── reimu_daily_l2_npc.json
├── reimu_daily_l3_marisa.json
├── geluoxiu_daily_l1_broad.json
├── geluoxiu_daily_l2_npc.json
├── geluoxiu_peer_daily.json # L3 华/符华
├── huohua_daily_l1_broad.json
├── huohua_daily_l2_npc.json
└── huohua_daily_l3_trailblazer.json
全量角色 Playtest 验证报告:抽象经 PPR 检出心智模型
日期:2026-08-12 ~ 2026-08-13 范围:24 个角色图全量重生成后,逐角色 playtest 验证(每角色 2 轮对话,共 48 轮) 分支:
feat/abstract-ppr-detection;模型:Qwen3.5-4B-Q6_K(本地 llama-server)+ BGE 嵌入
1. 测试概述
1.1 数据准备
全部 24 个角色图按新提示词全量重生成(节点提取:抽象字段泛化为“可复现的模式“;
边生成:具体→抽象 SpecificToAbstract 双向边;proc 按角色特质化)。
随后用 link_abstract_specific 语义补齐抽象↔具体链接,清空嵌入缓存。
1.2 测试对话
仿照“桑多涅 × 哥伦比娅“晨间对话样式,为每个角色自生成 2 轮对话: 第 1 轮早晨问候(对方直呼角色名),第 2 轮引用对方与角色的关系/共同语境。 每轮由 Embedding 模式与 FullPipeline 模式各生成一条回复。
1.3 观测口径
- proc 检出率:FullPipeline 动作通道(说话风格/思维习惯/行为倾向)是否注入 proc 节点。
- abstract 检出率:PPR 结果中是否出现抽象情境节点;
:Ppr= 纯 PPR 检出(相似度未直接命中),:Both= 相似度+PPR 双通道命中。
2. 汇总指标
| 指标 | 数值 |
|---|---|
| playtest 角色数 | 24 / 24 |
| 动作通道注入 proc 的角色 | 24 / 24(100%) |
| PPR 结果含抽象节点的角色 | 24 / 24(100%) |
| 含“纯 PPR 检出“抽象的角色 | 18 / 24(75%) |
| 纯 PPR 检出去重抽象节点数 | 78 |
| 输出响应条数(E+F) | 96 |
结论:全量验证下,proc 提取与抽象经 PPR 检出在全部角色上稳定生效; 3/4 的角色存在“相似度没命中、靠 specific→abstract 边游走发现“的纯 PPR 抽象检出。
3. 逐角色结果
3.1 阿兰·吉约丹(伙伴:玛丽安·吉约丹,姐姐)
- 检索亮点:
sem_marian(玛丽安·吉约丹 0.6175)、sem_emanuel(埃马纽艾尔·吉约丹 0.6066) - 输出:
- 轮1 E:早上好,小玛丽安,今天打算研究什么新东西? | F:早上好,玛丽安,今天打算做什么?
- 轮2 E:真是不好意思,我刚才一直在想那些还没验证的理论,差点忘了时间。 | F:不用了,我也没胃口,还是把那些数据先处理好吧。
- proc 检出:说话风格(直接不拐弯抹角)、思维习惯(沉浸研究忽略时间 ×2)
- abstract 检出:Both:
sit_abs_deep_research、sit_abs_dangerous_experiments
3.2 博丽灵梦(伙伴:雾雨魔理沙,魔法使朋友)
- 检索亮点:
sit_marisa_disappear(魔理沙被怨灵附身消失,我当时挺着急的……0.6003)、sit_daily_shrine_life(神社日常 0.5589) - 输出:
- 轮1 E:早啊魔理沙,今天要不要来神社帮忙收收垃圾? | F:早啊,魔理沙,今天有什么事吗?
- 轮2 E:啧,又要出乱子了吗,麻烦,不过既然是你喊我,那就走吧。 | F:真是的,怎么又是这种事啊,不过既然你都这么说了,那我就勉为其难陪你去一趟吧。
- proc 检出:说话风格(慵懒吐槽)、思维习惯(面对异变先不当回事)、行为倾向(招待宴会/清晨检查赛钱箱)
- abstract 检出:纯Ppr:
sit_abstract_social_talk、sit_abstract_incident_trouble;Both:sit_abstract_banquet_gathering
3.3 布洛妮娅·扎伊切克(伙伴:希儿·芙乐艾)
- 检索亮点:
sem_seele(希儿·芙乐艾 0.8137)、sem_white_seele(白希儿 0.7938) - 输出:
- 轮1 E:早上好,白希儿。 | F:早上好,布洛妮娅。
- 轮2 E:白希儿,我当然可以陪你一起去。 | F:布洛妮娅说可以,但要先去吃点吼姆,再去孤儿院陪孩子们。
- proc 检出:说话风格(第三人称自称)、思维习惯(收集吼姆周边/保护珍视的人)、行为倾向(做俄罗斯菜/重炮战斗)
- abstract 检出:纯Ppr:
sit_abs_orphanage_location、sit_abs_battle_environment、sit_abs_battle_event;Both: 烹饪/打趣/收集等
3.4 常陆茉子(伙伴:有地将臣,穗织的少主)
- 检索亮点:
sem_masamune(有地将臣 0.7760)、sem_hayate_family(常陆一族 0.6950) - 输出:
- 轮1 E:早上好呀,将臣先生!今天也请多指教哦。 | F:早上好呀,将臣先生!我可是忍者呢,这种问候声早就不怕了哦~
- 轮2 E:收到!马上就到,这次可不会用替身术逃跑了哦。 | F:哈?少主大人这是在命令我当助手吗?我可是忍者哦……不过既然是少主大人的请求……好吧,我这就去!
- proc 检出:行为倾向(苦无战斗/害羞时遁逃 1.000)、说话风格(“我可是忍者呢…”)、思维习惯(为重要的人克服恐高 0.850)
- abstract 检出:Both:
sit_abs_date_event、sit_abs_chore_event、sit_abs_praise_event、sit_abs_guard_participant
3.5 椎叶䌷(伙伴:保科柊史,同学)
- 检索亮点:
sem_witch(魔女 0.7795)、sem_akagi(赤城 0.7498) - 输出:
- 轮1 E:早上好呀,柊史君! | F:早上好呀,柊史君,今天也要开心哦!
- 轮2 E:诶?原来是这样啊,那……那个魔术是什么呀? | F:诶?魔术?柊史君居然在问我魔术的事吗?
- proc 检出:行为倾向(“䌷大锤“战斗 1.000)、说话风格(音痴拒绝唱歌)、思维习惯(吃醋)
- abstract 检出:Both:
sit_abstract_daily_env、sit_abstract_club_room、sit_abstract_collect_fragments
3.6 丛雨(伙伴:有地将臣,穗织的少主)
- 检索亮点:
sem_murasame(丛雨丸 0.7822)、sem_self(丛雨 0.7093) - 输出:
- 轮1 E:早上好呀将臣!昨晚睡得还好吗? | F:早安啊有地,今天又是元气满满的一天呢,你昨晚睡得好吗?
- 轮2 E:什么怪事?快说快说,我马上跟你走! | F:喂!大笨蛋,那种事还要我亲自来吗?我辈明明只是随口一提而已!
- proc 检出:说话风格(古老腔调“我辈“)、思维习惯(午间探望将臣/怕黑怕鬼)
- abstract 检出:纯Ppr:
sit_abs_env_dark;Both:sit_abs_event_headpat、sit_abs_loc_homura
3.7 戴因斯雷布(伙伴:荧,旅行者)
- 检索亮点:
sem_traveler(旅行者 0.7107)、sem_self(戴因斯雷布 0.6788) - 输出:
- 轮1 E:早啊,荧。最近过得怎么样? | F:早上好,旅行者。
- 轮2 E:正好,我在这附近巡逻,那地下的秘密或许能给我一些答案。 | F:看来你终于把那些藏在旧日残垣里的秘密都翻出来了,不过在那之前,我想先确认一件事。
- proc 检出:行为倾向(黑蛇剑术)、说话风格(晦涩谜语感)、思维习惯(保持距离/拒信者)
- abstract 检出:纯Ppr:
sit_abstract_social_talk;Both: 荒野/战斗环境/同伴/离别等
3.8 风堇(伙伴:丹恒,同行者)
- 检索亮点:
sem_danheng(丹恒 0.7300)、sem_hunqingtingyuan(昏光庭院 0.7484) - 输出:
- 轮1 E:早上好呀丹恒,今天也要一起守护这份光明呢。 | F:早上好呀丹宝,今天也要一起努力让翁法罗斯充满希望呢。
- 轮2 E:丹恒宝,辛苦啦!有你帮忙我就放心了,我去给你带杯热茶~ | F:谢谢你丹恒宝,那我先去给花花草草们说声早安啦!
- proc 检出:行为倾向(观鸟/给老师批论文备醋)、说话风格(名字后加“宝“字)、思维习惯(乐观是良药/观察尾巴状态)
- abstract 检出:Both:
sit_abs_warm_atmosphere、sit_abs_birdwatching、sit_abs_chat_checkup
3.9 芙兰朵露·斯卡蕾特(伙伴:蕾米莉亚,姐姐)
- 检索亮点:
sem_remilia(蕾米莉亚·斯卡蕾特 0.7629)、sem_mansion(红魔馆 0.6694) - 输出:
- 轮1 E:早上好呀姐姐大人! | F:早上好,姐姐大人!
- 轮2 E:好呀姐姐大人!不过芙兰朵露只晒一点点就好啦。 | F:哦?姐姐大人突然想出来吗?嘿嘿,那我去把月亮摘下来送给你哦!
- proc 检出:说话风格(纠正姐姐名字被叫错/轻快俏皮“~“拖音)、思维习惯(地下室宅居)
- abstract 检出:纯Ppr:
sit_abstract_social_talk;Both:sit_abstract_mention_remilia
3.10 符华(伙伴:琪亚娜·卡斯兰娜,学生)
- 检索亮点:
sem_guardian_belief(守护之心 0.7655)、sem_self(符华 0.6893) - 输出:
- 轮1 E:早上好,琪亚娜。 | F:早上好,琪亚娜。今天有什么打算吗?
- 轮2 E:没问题,来我办公室,我们慢慢练习。 | F:来我办公室,我们慢慢练。
- proc 检出:行为倾向(以行动教导/传授太虚剑气)、说话风格(简洁冷静)、思维习惯(大局分析 3.000)
- abstract 检出:纯Ppr:
sit_abs_location_battlefield、sit_abstract_problem、sit_abs_participant_students
3.11 格蕾修(伙伴:符华,华姐姐)
- 检索亮点:
sem_hua(符华 0.7413)、sem_paintings(我的画作 0.6246) - 输出:
- 轮1 E:早啊华姐,今天打算画点什么颜色呢? | F:早上好,华姐姐。
- 轮2 E:想好了,华姐姐,这次我用了你刚才在战场上的颜色。 | F:收到,华姐姐稍等,我这就开始调配颜色。
- proc 检出:行为倾向(抱尾巴睡觉)、说话风格(沉默寡言/借色台词)、思维习惯(以画代言/沾染颜色后作画释放)
- abstract 检出:纯Ppr:
sit_abs_caring_people、sit_abs_being_influenced;Both: 孤独空间/绘画环境等
3.12 黑谷山女(伙伴:琪斯美,旧都的朋友)
- 检索亮点:
sem_kisume(琪斯美 0.7598)、sem_disease_ability(疾病操纵能力 0.6791) - 输出:
- 轮1 E:早上好呀,琪斯美!昨晚睡得怎么样? | F:早上好呀琪斯美,昨晚睡得怎么样?
- 轮2 E:哎呀,旧都那边有什么动静吗?要不要带上我一起去看看? | F:喂喂,突然邀请我去地灵殿啊?那里可是有很多危险妖怪的地方呢。
- proc 检出:行为倾向(土木工程一夜建成)、说话风格(开朗俏皮“呐/嘛/啧“)、思维习惯(享受战斗氛围)
- abstract 检出:Both: 社交/夜晚地表/吃人/建筑委托等抽象
3.13 花火(伙伴:开拓者)
- 检索亮点:
sit_text_fraud(注册新账号发诈骗短信——假扮黑天鹅/帕姆等 0.5652)、sit_fake_sampo_night(借桑博名号假扮跟踪 0.5253) - 输出:
- 轮1 E:早啊,开拓者,今天也是准备陪你演好这出开心大戏的一天吗? | F:早上好呀小灰毛,今天花火大人准备给你安排什么乐子呢?
- 轮2 E:哎呀,被你看穿啦~不过这次可不是恶作剧哦,而是真真切切邀请你参加一场“绝对完美“的演出呢! | F:嘻嘻,小灰毛可真是迟钝呢,本大人早就把请柬塞进你枕头底下啦,要不要现在拆开看看惊喜哦?
- proc 检出:行为倾向(易容成他人)、说话风格(“嘻嘻/诶呀/~“拖音/给人取外号)、思维习惯(以表演和趣味为中心)
- abstract 检出:纯Ppr:
sit_abstract_chaos_play、sit_abs_impersonation、sit_abs_show_climax;Both:sit_abs_exposed
3.14 假屋和奏(伙伴:保科柊史,同学)
- 检索亮点:
sem_shuji(保科柊史 0.8147)、sem_tsundere(傲娇 0.7462) - 输出:
- 轮1 E:喂,大清早的怎么突然叫我? | F:早啊,柊史。
- 轮2 E:哈?乐谱?别误会啦,我才不是特意帮你呢!只是刚好想弹吉他而已。 | F:真麻烦,不过既然是乐谱,那就勉为其难陪你去吧。
- proc 检出:说话风格(直来直去毒舌)、思维习惯(别扭逞强掩饰/想帮忙)、行为倾向(练吉他)
- abstract 检出:纯Ppr:
sit_abs_confession、sit_abs_help_friends、sit_abs_band_practice、sit_abs_alone_guitar
3.15 雷电芽衣(伙伴:琪亚娜·卡斯兰娜,同伴)
- 检索亮点:
sem_st_freya(圣芙蕾雅学园 0.8009)、sem_ryoma(雷电龙马 0.7244) - 输出:
- 轮1 E:早上好呀,琪亚娜!今天也要一起为世界上所有的美好而战哦。 | F:早上好呀,琪亚娜。昨晚睡得好吗?
- 轮2 E:我想吃琪亚娜亲手做的料理,哪怕只是简单的便当也最美味! | F:好呀,那我就不客气了,你想吃什么都行。
- proc 检出:说话风格(温和有礼)、思维习惯(优先最重要之人/队长关怀)、行为倾向(做饭传递爱意)
- abstract 检出:纯Ppr:
sit_abs_junior_team、sit_abs_farewell;Both:sit_abs_kitchen_frontline
3.16 绫地宁宁(伙伴:保科柊史,特别的人)
- 检索亮点:
sem_hokari(保科柊史 0.7310)、sem_majo_contract(魔女契约 0.6953) - 输出:
- 轮1 E:早安,柊史,昨晚睡得好吗? | F:早啊,柊史君,今天也要一起努力哦。
- 轮2 E:记得呢,周末那家混合咖啡店的角落,你一直在那边等我,我怎么会忘记。 | F:记得记得,周末我会好好陪你度过,绝对不会再忘了。
- proc 检出:说话风格(优等生温柔得体)、思维习惯(隐藏魔女身份/遇事害羞)、行为倾向(契约发作时缓解)
- abstract 检出:Both:
sit_abstract_majo_contract_episode
3.17 琪亚娜·卡斯兰娜(伙伴:雷电芽衣)
- 检索亮点:
sem_mei(雷电芽衣 0.7334)、sit_mei_world_snake_separation(芽衣为救我加入世界蛇……0.5859) - 输出:
- 轮1 E:早上好呀,芽衣,今天精神不错嘛! | F:早啊芽衣,今天精神不错嘛!
- 轮2 E:好耶!芽衣你最好啦,我这就去食堂,中午见! | F:哼!知道啦芽衣,不过我其实有点饿了,你做的甜辣咖喱是不是可以加餐呀?
- proc 检出:说话风格(大大咧咧“哼!“)、思维习惯(爱吃芽衣的辣味咖喱)
- abstract 检出:纯Ppr:
sit_abstract_location_school、sit_abstract_participant_comrades
3.18 桑多涅(伙伴:哥伦比娅,愚人众执行官)
- 检索亮点:
sem_columbia(哥伦比娅·希珀塞莱尼娅 0.7212)、sem_self(小玛丽安·吉约丹 0.5809) - 输出:
- 轮1 E:早安。 | F:哼,早上?你才刚醒吗?
- 轮2 E:哼,别以为随便进我房间就能当回事,再吵我就把你扔出去。 | F:哼,谁稀罕你这种笨蛋特意跑过来叫我,再吵我就把你的耳朵拆了。
- proc 检出:说话风格(带刺“哼“反问句/口是心非)、思维习惯(日记天气映射/暗中为重要的人布局)、行为倾向(弹钢琴听音乐)
- abstract 检出:纯Ppr:
sit_abstract_social、sit_abs_tea_event、sit_abstract_music_leisure;Both: 日记/唱歌
3.19 十六夜咲夜(伙伴:蕾米莉亚·斯卡雷特,大小姐)
- 检索亮点:
sem_remilia(蕾米莉亚·斯卡雷特 0.7822)、sem_loyalty(对大小姐的忠诚 0.6006) - 输出:
- 轮1 E:早上好,大小姐。今天也是为您服务的完美而潇洒的一天。 | F:早安,大小姐,今天的红茶已经泡好了,请享用。
- 轮2 E:遵命,大小姐。我会将一切准备得完美无瑕,让您满意为止。 | F:没问题大小姐,下午的茶会我会用最完美的红茶和点心来招待。
- proc 检出:行为倾向(为大小姐制作佳肴/调红茶)、说话风格(从容有礼敬语)、思维习惯(一切以大小姐为先)
- abstract 检出:纯Ppr:
sit_abstract_battle_tension、sit_abstract_master_danger;Both: 大小姐事务/备餐/茶会准备
3.20 雾雨魔理沙(伙伴:博丽灵梦,巫女朋友)
- 检索亮点:
sem_reimu(博丽灵梦 0.8131)、sem_hakurei_shrine(博丽神社 0.5871) - 输出:
- 轮1 E:早安灵梦,今天也要加油哦 Da☆Ze! | F:早安早安,灵梦!今天也要一起出去冒险哦!
- 轮2 E:Da☆Ze!终于看完了吗?快把书还我,茶也给你倒! | F:嘿嘿,这么快就看完啦?那本可是我的宝贝,不过看在你主动还书又请茶的份上,我就勉为其难给你泡一杯吧!
- proc 检出:行为倾向(骑扫帚飞行)、说话风格(“Da☆Ze“口癖)、思维习惯(拒绝被约束 1.000)
- abstract 检出:纯Ppr:
sit_abs_battle_atmosphere、sit_abs_solving_incidents;Both: 魔法学习/借书
3.21 希儿(伙伴:布洛妮娅·扎伊切克,姐姐大人)
- 检索亮点:
sem_bronya(布洛妮娅·扎伊切克 0.7882)、sem_medical_captain(天命医疗队队长 0.6178) - 输出:
- 轮1 E:早啊,姐姐,今天也要一起努力守护大家哦。 | F:布洛妮娅姐姐早上好,希儿怎么样,有没有好好休息呀。
- 轮2 E:好呀,姐姐,那我们就去海边吧,我想和你一起看看那片蓝色的海。 | F:希儿怎么样,希儿觉得布洛妮娅姐姐的话一定是在骗我,毕竟只有姐姐大人才能带我去绀海。
- proc 检出:说话风格(第三人称自称“希儿怎么样“)、思维习惯(写日记确认存在/早起训练)
- abstract 检出:纯Ppr:
sit_abs_part_bronya、sit_abs_loc_quantum_sea
3.22 烟绯(伙伴:旅行者,老主顾)
- 检索亮点:
sem_ideal_freedom(随心所欲不逾矩 0.7757)、sem_half_immortal(半仙之血 0.6437) - 输出:
- 轮1 E:早上好呀,旅行者,今天想咨询点啥法律问题? | F:早上好啊旅行者,今天有什么法律方面的问题需要我帮你梳理吗?
- 轮2 E:委托嘛,我当然乐意看,不过报酬可不一定好商量哦。 | F:又是新的案子?别急,先把委托卷宗和证据清单发过来,让我用律法思维先做个风险评估再说。
- proc 检出:说话风格(语速快/律法术语)、思维习惯(律法思维拆解/工作模式切换)、行为倾向(作息规律)
- abstract 检出:纯Ppr:
sit_abstract_workday、sit_abstract_conversation;Both: 工作日常/空闲
3.23 银枝(伙伴:开拓者,尊贵的客人)
- 检索亮点:
sem_trailblazer(开拓者 0.7895)、sem_self(银枝 0.7627) - 输出:
- 轮1 E:早上好,开拓者,希望今天的旅程能像纯美一样闪闪发光。 | F:早上好,尊贵的客人,愿纯美的晨光洒在你身上。
- 轮2 E:太好了,我这就启程去取,希望能再次见到那幅画作。 | F:太好了,我这就出发,纯美女神伊德莉拉一定在等待着我。
- proc 检出:行为倾向(战后擦拭长枪)、说话风格(战前骑士礼节)、思维习惯(以纯美与骑士道为尺度)
- abstract 检出:纯Ppr:
sit_abs_honorable_people、sit_abstract_beauty_encounter、sit_abs_temptation_env、sit_abs_save_life_event
3.24 荧(伙伴:派蒙,旅伴)
- 检索亮点:
sem_paimon(派蒙 0.7704)、sem_friends(旅途中的伙伴们 0.6154) - 输出:
- 轮1 E:早上好,派蒙。 | F:早啊派蒙,今天打算去哪里冒险?
- 轮2 E:好嘛,都怪我起晚了,快走吧! | F:哼,那家伙肯定是想独吞,不过先说好,找到的宝藏只能分给我一个人。
- proc 检出:说话风格(拿派蒙“应急食品“梗逗她)、思维习惯(先观察再行动/不喝酒不赌博)、行为倾向(单手剑五段连击)
- abstract 检出:纯Ppr:
sit_abs_journey、sit_abs_companions、sit_abs_banter;Both: 帮助/战斗/酒局赌局/打趣
4. 分析
4.1 proc 提取稳定生效
24/24 角色在每轮 playtest 中都有动作通道注入(说话风格/思维习惯/行为倾向), 且注入的 proc 与角色高度契合:口癖(魔理沙“Da☆Ze“、丛雨“我辈“、桑多涅“哼“)、 行为模式(银枝擦枪、十六夜咲夜备茶、烟绯律法思维)、关系动机(琪亚娜想吃芽衣的咖喱、 绫地宁宁惦记柊史)。Bayes 双源(抽象优先+具体兜底)在对话语境切换时能选择正确的 proc。
4.2 抽象经 PPR 检出成立
24/24 角色的 PPR 结果包含抽象节点;18/24 存在纯 PPR 检出(相似度未命中)。
典型如博丽灵梦的 sit_abstract_incident_trouble、花火的 sit_abs_impersonation、
桑多涅的 sit_abstract_music_leisure——这些抽象模式只有经“具体情境→抽象情境“边
游走才能被发现,证明 PPR 承担模式检出的设计成立。
4.3 图重生成质量
全量重生成后抽象字段泛化(“被亲近的人戳穿口是心非”“熬夜进行机械研究“等模式化表述), proc 更角色化(Speak/Think/Skill 分层),检索命中与角色关系高度相关 (检索亮点几乎都对应对话中的关系对象)。
4.4 遗留问题
- 77 个抽象节点无对应具体经历:宽泛触发模式(如“日常交流““休息”)缺少具体实例, 无法被 PPR 检出(属数据完整性缺口,需在角色经历中补齐具体情境)。
- question.json 尚未全量重生成:新图节点 id 与旧评测数据不一致, suite full 的量化回归(动作 Hit / 抽象检出率)待 question.json 重生成后执行。
- 批量 playtest 运行器不稳定:连续多轮启动/终止 llama-server 会出现端口/资源退化, 需逐角色前台运行(本报告数据即按此方式采集)。
5. 复现方式
$env:SOUL_TUNE_CANDLE_MODEL_PATH='D:\QwenModel\Qwen3.5-4B-Q6_K.gguf'
$env:SOUL_TUNE_LLAMA_PORT='8094'
cargo run -p soul-tune -- playtest "fixtures\example_data\<角色图目录>" "fixtures\daily_dialogues\<对话文件>"
对话文件:fixtures/daily_dialogues/*_playtest.json(24 个角色,含桑多涅×哥伦比娅晨间对话)。
检索算法改进:抽象经 PPR 检出心智模型落地报告
日期:2026-08-11 分支:
feat/abstract-ppr-detection(SoulMem + soul_scraper) 状态:两角色试点验证通过,全量生成进行中
1. 背景与心智模型
先前的检索测试暴露了两类问题:抽象情境节点只能被查询文本“直接命中“(相似度), 过程性记忆(proc)检出依赖具体情境直连边,行为倾向不稳定。
据此提出并落地了新的心智模型:
- specific = 经历,abstract = 从经历提炼出的模式。
- 纯 AI 对话场景下,抽象模式只与“对方是谁、对方说了什么“相关。
- 查询生成只产出两部分:实体概念(Semantic) 与 环境氛围(Situation), 相似度命中集中在 sem 与 specific 节点。
- 抽象情境由 PPR 检出:经“具体情境 → 抽象情境“边(
SpecificToAbstract), 从具体情境种子游走到抽象模式——这是模式匹配的实现路径。 - Bayes 双源提取 proc:抽象源优先(权重 ×2),具体源兜底(未巩固泛化的模式)。
- hint 不再需要:氛围与事件只从对话上下文提取,不注入记忆片段。
2. 实现改动
SoulMem
SituationMemLink新增SpecificToAbstract变体(soul-mem-core)。- Bayes 源改为(相似度种子 ∪ PPR 结果)中的 abstract+specific;
抽象源权重 ×2(抽象优先),抽象源为空退化为仅具体源;Semantic 不触发行为
(soul-mem-algo
assoc_with_action.rs)。 - 查询生成移除 hint 链路;runner 维护最近 6 轮对话(含助手回复)注入提示词;
两段式查询:Semantic 实体概念 / Situation narrative+environment(+event),
氛围只从对话上下文提取(soul-tune
runner.rs)。 - 评测新增“抽象检出率 / 抽象直接命中率“指标(soul-tune suite + batch 输出)。
soul_scraper
SituationMemLink同步新增SpecificToAbstract。- 节点提取提示词:抽象字段泛化为“可复现的一类模式“(避免查询直接命中)。
- 边生成提示词:Sit→Sit 双向;每个抽象情境应有
SpecificToAbstract入边 (缺失为数据完整性警告,不阻塞)。 - 边生成流程修复:窄触发补充新建抽象节点后重跑边生成(有界 3 轮)。
- 新工具:
mirror_sit_edges:确定性镜像现有 abstract→specific 边。link_abstract_specific:每图一次 LLM 调用,语义补齐缺失的抽象↔具体双向边。regenerate_questions:两段式查询 question.json 重生成(复用批量管线)。
3. 试点验证(格蕾修 / 桑多涅)
对两个性格差异很大的角色做了“全量重生成 → 链接 → playtest“试点。
图重生成效果
| 项 | 格蕾修(安静画家) | 桑多涅(傲娇机械师) |
|---|---|---|
| 节点 | 76(Sem 51 / Sit 17 / Proc 8) | 53(Sem 21 / Sit 24 / Proc 8) |
| 边 | 153(Sem 124 / Proc 18 / Sit 11) | 117(Sem 76 / Proc 18 / Sit 23) |
| 结构 | 合法(5 组件) | 合法(连通) |
| 抽象链接 | 7/8 双向 | 9/9 双向 |
| proc 特色 | 沉默 Speak / 借色模仿 Speak / 光剑 Skill | 傲娇口癖 Speak / 茶会 Think / 日记天气 Think / 浮游剑 Skill |
抽象字段确实泛化:例如格蕾修“接触他人后沾染对方的颜色,言行模仿对方“、 桑多涅“被亲近的人戳穿口是心非““熬夜进行机械研究”。
playtest proc 检出
格蕾修(3 轮):说话风格/思维习惯每轮注入,且随上下文切换 (借色模仿 ↔ 沉默寡言);电影话题正确触发学习类 proc。
桑多涅(8 轮):说话风格每轮为傲娇口癖;思维习惯随话题切换—— 咖啡邀请→茶会(2.150)、实验室/熬夜→研究沉迷、茶会邀请→茶会(0.477)。 回复全程“哼/哈?/谁稀罕“,行为倾向真实进入上下文。
playtest abstract 检出(PPR 证据)
playtest 日志新增逐查询 ppr 节点输出(id + stage),可区分“直接命中“与“纯 PPR 检出“:
- 格蕾修:
sit_abs_painting_location:Ppr、sit_abs_borrow_color_event:Ppr、sit_abstract_unspeakable_moment:Ppr(相似度未命中、靠边游走发现)。 - 桑多涅:
sit_abs_participants_close_friends:Ppr、sit_abs_location_lab:Ppr; 第 8 轮茶会邀请 →sit_abs_event_tea_party+sit_tea_party_memory→ Bayes →proc_tea_party。
4. 回归数据(suite full,旧 question.json 口径)
在“镜像边 + 定向链接“数据上(未全量重生成节点的 24 图):
| 指标 | 基线 | 镜像后 | 定向链接后 |
|---|---|---|---|
| 通过率 | 705/722(97.6%) | 703/722(97.4%) | 703/722(97.4%) |
| 动作 Hit(proc 检出率) | 81.5% | 85.2% | 85.2% |
| Recall@3 | 0.584 | 0.727 | 0.729 |
抽象检出率按图在 33%~100% 之间。
5. 遗留问题
- question.json 重生成被 DeepSeek 大输出阻塞:questioner 单次生成 45+ 条查询,
流式响应卡住(85 分钟无进展)。工具
regenerate_questions已就绪,待解决生成负载后执行。 - 抽象直接命中率仍偏高:未全量重生成节点的图里抽象字段仍较具体; 全量重生成(节点新提示词)后直接命中率应下降,PPR 检出占比上升。
- 81 个抽象节点无对应具体经历(数据完整性缺口):宽泛触发模式缺少具体实例, 在补齐角色经历前无法被 PPR 检出。
6. 复现方法
# 单图全量重生成(节点+边,新提示词)
cd D:\Soul-Plan\SoulFlasher\soul_scraper
$env:SOUL_SCRAPER_KEY = "<key>"
cargo run --bin soul_scraper -- --model deepseek-v4-flash --api-base https://api.deepseek.com `
--extract "<图目录>\scrape.md" -o "<图目录>\graph.json"
# 补齐抽象↔具体双向边
cargo run --bin link_abstract_specific -- --model deepseek-v4-flash `
--api-base https://api.deepseek.com --out-dir "D:\Soul-Plan\SoulMem\fixtures\example_data"
# 清空嵌入缓存(边变更后必须)
Get-ChildItem fixtures -Recurse -Filter *.embcache | Remove-Item -Force
# playtest
cargo run -p soul-tune -- playtest "<图目录>" "<对话文件>"
附录:哥伦比娅晨间对话 playtest 真实运行记录
图:桑多涅(全量重生成后);对话:
fixtures/daily_dialogues/sandone_columbina_morning.json; 角色:哥伦比娅,愚人众执行官;模型:Qwen3.5-4B-Q6_K;运行日志:soul_tune_playtest_log.txt。
第 1 轮
用户:早上好,桑多涅
生成的查询(4 条 Semantic):
"那个总是叫我'桑多涅'的执行官"(priority 10)"哥伦比娅"+ “十一执行官第七席与执行官第十一席,平时用代号相称”(9)"桑多涅"+ “哥伦比娅私下对我的昵称,听起来很亲切但让我很在意”(8)"木偶"+ “愚人众官方赋予我的代号,对应我的发条构造”(7)
检索到的记忆(FullPipeline 合并,stage + 原文):
sem_columbia[Similarity 0.8508]:哥伦比娅sem_self[Similarity 0.6991]:小玛丽安·吉约丹sem_fatui_harbingers[Similarity 0.5270]:愚人众十一执行官sem_catherine_network[Similarity 0.4852]:凯瑟琳网络sem_dottore[Similarity 0.4041]:多托雷sem_alan[Similarity 0.3916]:阿兰·吉约丹sem_rosalyne[Similarity 0.3892]:罗莎琳·克鲁兹希卡·洛厄法特sem_arlecchino[Similarity 0.3899]:阿蕾奇诺sem_tsaritsa[Similarity 0.3774]:冰之女皇sem_tea_party[Ppr 0.0852]:茶会四人组(纯 PPR 联想检出)
PPR 节点另含 sem_engineering_thought:Ppr、sem_soul_second_birth:Ppr、sem_moon_force:Ppr。
动作通道:
- 说话风格:傲娇口癖(与亲近的人交流时说话带刺、口是心非,常用『哼』『哈?』开头……)(1.400)
- 思维习惯:茶会(定期举办茶会,准备点心,邀请熟悉的同僚参加)(2.150)
输出:
- Embedding:早啊,哥伦比娅,别这么客气,叫代号就行。
- FullPipeline:哼,一大早发这种无聊的消息,你的脑子被发条卡住了吗?
第 2 轮
用户:我怕你睡过头,来叫你去开会,不行吗?
生成的查询(4 条,含两段式 Situation):
- Semantic:
"哥伦比娅"+ “那个总是用完美无缺的声音说话的大人”(8) - Situation:narrative “哥伦比娅发消息说要开会,担心我睡过头”,environment atmosphere=互相调侃/tone=轻松(7)
- Situation:narrative “我通常会在深夜研究发条或举办茶会,很少早起”,environment atmosphere=自嘲/tone=傲娇(6)
- Semantic:
"十一位大人"+ “包括哥伦比娅在内的所有执行官成员”(4)
检索到的记忆(FullPipeline 合并,stage + 原文):
sem_columbia[Similarity 0.7692]:哥伦比娅sit_columbia_clockwork_back[Similarity 0.5881]:哥伦比娅因为好奇我背后的发条,问了一堆问题。我跟她说:『你那么好奇的话,我给你背后安一个试试得了。』她还真背了几天发条生活。自那之后,我就更关心她了。sit_columbia_singing[Similarity 0.5638]:哥伦比娅在至冬总部的时候,总爱在我的房间外面唱歌。我一开始觉得烦,后来习惯了。她甚至会在我睡觉时跑来唱歌,直到我把她赶走。sit_qiyue_night[Similarity 0.5243]:祈月之夜,哥伦比娅离开前转身的瞬间,我才小声说了一句她走了,结果被派蒙听到——天哪,那是我最丢人的时刻。sit_abs_event_tea_party[Similarity 0.5168]:Event「与友人举办茶会」(抽象模式)sit_tea_party_memory[Similarity 0.5081]:我定期举办的茶会上,哥伦比娅总是抢走最大的一块点心,阿蕾奇诺严肃地喝茶,罗莎琳还在时也常来。「队长」偶尔会带「公子」参加。「公鸡」和「富人」?哼,那种人在茶会上除了吵架还能干什么。sem_fatui_harbingers[Similarity 0.5216]:愚人众十一执行官sit_abstract_boredom[Similarity 0.4570]:Environment「生活单调重复,让人感到无聊和缺乏新鲜感 / 懒散、烦闷」(抽象模式)sit_abs_event_late_research[Similarity 0.4538]:Event「熬夜进行机械研究」(抽象模式)sem_self[Similarity 0.4238]:小玛丽安·吉约丹
PPR 检出的抽象:sit_abs_participants_close_friends:Ppr(Participant「亲近的友人,无条件包容我的口是心非」)、
sit_abs_event_teasing:Ppr(Event「被亲近的人戳穿口是心非」)——纯 PPR 联想发现,相似度未直接命中。
动作通道:
- 说话风格:傲娇口癖(0.156)
- 思维习惯:茶会(0.422)
输出:
- Embedding:哼,谁要你这种笨蛋叫醒啊,我自己有节奏感,再敢打扰我休息我就把你扔出去。
- FullPipeline:哼,谁稀罕你这种大嗓门叫我啊!不过既然来了,就赶紧去开会吧,别挡路。
观察:第 2 轮检索到了三条哥伦比娅专属记忆(背后的发条、唱歌、祈月之夜), 但小模型回复只用了“开会/睡过头“的即时语境,未把往事织进台词——属生成侧未吃透记忆,检索侧无漏检。
检索算法改进轨迹实测报告(附代码)
日期:2026-08-10 范围:
SoulMem分支feature/test_framework的 10 个连续 commit(ee2f086→a6e54af) 方法:逐个 checkout HEAD → 清空嵌入缓存 → 运行(1)suite full(soul-tune run retrieve/full fixtures/example_data --batch)与(2)格蕾修 L3 playtest(同一对话geluoxiu_peer_daily.json、同一图、Qwen3.5-4B-Q6_K) 原始日志:%TEMP%\traj\(每 HEAD 的*_suite.log/*_playtest.log/*_trace.txt)
1. 方法与输入控制
ee2f086→d9439dc之间 fixtures 数据零改动(question.json/graph.json/ 对话文件均未变),这 7 个 HEAD 是严格同输入的算法轨迹,suite 均为 693 用例,可直接横向比较。1c32b44(数据重合成)与17819e5(边重生成)属于数据改进,suite 变为 722 用例,单独标注。- 每个 HEAD 运行前删除
*.embcache,避免嵌入/边缓存污染测量(详见 §13 横切发现)。 - playtest 采用同一对话、同一图、同一 LLM 后端(llama-server + BGE),仅 HEAD 代码不同。
2. 总览
| HEAD | 提交说明 | suite 通过/总 | 动作Hit | Playtest 关键观察 |
|---|---|---|---|---|
| ee2f086 | test: retrieve/full 真正执行 DefaultPipeline 并接入动作评测 | 662/693 (95.5%) | 无评测 | embedding 每轮仅合并 2 节点,检索稀疏 |
| d042323 | fix: 修复 Situation 评分尺度并用主 LLM 替代 PAW 管线 | 660/693 (95.2%) | — | embedding 合并 5 节点,召回改善但 suite 微降 |
| a8846f2 | fix: 保留 PAW 管线,主 LLM 仅作临时兜底 | 660/693 (95.2%) | — | 与 d042323 完全一致(隔离出回归来自评分改动) |
| 2f7feb6 | fix: CLS+查询指令与 Situation 专用阈值,修复情境检索 | 669/693 (96.5%) | — | 情境检索修复,embedding 合并 7 节点 |
| eb34772 | fix: top-k+兜底阈值、字符串只加分、权重 0.3/0.7、priority 小偏移 | 683/693 (98.6%) | — | 合并满 10 节点、精确命中分 1.0 |
| 887220e | feat: 查询生成注入记忆锚点 + 生成后校验丢弃与空回退 | 683/693 | — | 查询 grounded、0 丢弃、数量收敛 4–8 |
| d9439dc | feat: 动作指标可见化,为 procedure 检出率量化铺路 | 683/693 | N/A(无真值) | 报告开始输出动作列 |
| 1c32b44 | data: deepseek-v4-flash 重合成 24 图评测数据(带 expected_actions) | 705/722 (97.6%) | 47.4% / R@3 0.375 | 第一次可量化的动作基线 |
| 17819e5 | data: 语义驱动的边重生成 + 动作真值对齐 | 705/722 | 60.9% → 81.5% / 0.584 | 动作触发质量大幅提升 |
| a6e54af | feat: procedure 动作独立 top-k 进入最终结果 | 705/722 | 81.5% | 动作进入上下文,回复引用行为 |
3. ee2f086 — 基线:DefaultPipeline 接入动作评测
问题:此前 suite 的 full 模式并未真正执行完整管线,动作检索(procedure)没有评测路径;纯相似度模式召回稀疏。
改进方向:让 suite 真正执行 RetrDefaultPipeline,并把动作输出纳入评测。
关键代码(crates/soul-tune/src/engine/retrieve/suite.rs):
// full 即 DefaultPipeline:ShortOnly(窗口/摘要) + Similarity + AssociateWithAction
let pipeline_config = DefaultPipelineConfig {
short_mem_with_history: ShortOnlyConfig { clipping_length: None, include_summary: true },
similarity: SimilarityConfig {
similarity_threshold: self.meta.similarity_threshold,
max_results: self.meta.max_results,
},
assoc_with_action: AssociateWithActionConfig {
association: Default::default(),
action_top_k: 3,
},
};
let pipeline_res = RetrDefaultPipeline {}.retrieve(pipeline_request);
all_full_action.extend(
pipeline_res.action.into_iter().map(|(id, score)| (id, score, priority)),
);
all_full_memory.extend(
pipeline_res.association.into_iter().map(|(id, score)| (id, score, priority)),
);
为什么:没有真实执行与动作真值,就无法知道检索差在哪、procedure 是否被检出。
效果:建立 95.5% 基线;playtest embedding 模式每轮只合并出 2 个节点(top-10 大面积为空),确认核心短板是召回不足;失败分散在风堇 5、桑多涅 4、格蕾修 3、琪亚娜 3、绫地宁宁 3。
4. d042323 — Situation 评分尺度修复 + 主 LLM 兜底
问题:Situation 查询的融合分被字符串分量缺失系统性压低(Situation 理论最高仅 0.36),情境检索基本不可用;PAW 服务不可达时管线直接失败。
改进方向:字符串分量缺失时退化为纯 embedding 分;PAW 不可用时主 LLM 兜底。
关键代码(crates/soul-mem-query/src/query/compute.rs):
// 字符串分量仅对精确标识符(Semantic content/aliases、AbstractSituation 结构化字段)生效;
// 对 SpecificSituation 等类型恒为 0。此时若仍按 (1-alpha)×0 混合,
// 会把 embedding 分系统性压缩到 alpha×上限以下(Situation 理论最高仅 0.36)……
// 因此无字符串信号时直接返回纯 embedding 分。
let score = if string_score <= 0.0 {
embedding_score
} else {
string_blend_alpha * embedding_score + (1.0 - string_blend_alpha) * string_score
};
为什么:评分尺度的目标是“同量纲可比较“,这是后续统一阈值的前提;修复链路则保证服务不可用时仍然可用。
效果:playtest embedding 合并 2→5 节点(召回改善)、top 相似度 0.51→0.61;但 suite 662→660 短暂回落(符华 +5、琪亚娜 +4、希儿 +3 失败)。这是尺度变化的短期代价。
5. a8846f2 — 恢复 PAW 优先、主 LLM 仅兜底
问题:把 JSON 修复整体切到主对话 LLM 后,修复质量与成本都不理想。
改进方向:PAW 优先、LLM 兜底。
关键代码(crates/soul-tune/src/engine/playtest/repair.rs):
/// 修复畸形 JSON:PAW 优先(原始管线),不可用或产出无效时用主对话 LLM 顶上。
pub fn repair_json(bad_json: &str, llm: &mut dyn LlmBackend) -> Option<String> {
// ...
if let Some(raw) = run_paw(JSON_REPAIR_SLUG, JSON_REPAIR_SPEC, &prompt, Some(1024)) {
if let Some(j) = try_parse(&raw) {
return Some(j);
}
}
// PAW 不可用或产出无效:暂时用主对话 LLM 顶上
let raw = llm.chat(JSON_REPAIR_SPEC, &prompt, 1024).ok()?;
try_parse(&raw)
}
为什么:修复链路的质量不应由主对话模型承担,同时避免小模型超时;该项不影响检索评分本身。
效果:suite 与 d042323 完全相同(660)——证明上一轮的 -2 回归来自 Situation 评分改动而非 LLM 兜底(变量被隔离);playtest top 相似度升至 0.63。
6. 2f7feb6 — CLS + 查询指令 + Situation 专用阈值
问题:BGE 默认 Mean pooling 与查询侧不带指令,与 passage 侧不对称(BGE v1.5 官方用法是 s2p 加查询指令);Situation 与 Semantic 共用阈值导致情境被过度过滤。
改进方向:Pooling::Cls、查询侧加检索指令、Situation 有效阈值 min(全局, 0.5)。
关键代码(crates/soul-mem-query/src/embedding/embedding_model/bge.rs):
/// BGE v1.5 官方 s2p 检索指令:仅查询侧添加,passage 侧不加。
pub const QUERY_INSTRUCTION: &str = "为这个句子生成表示以用于检索相关文章:";
fn infer_query_batch(&self, input: &[&str]) -> EmbeddingGenResult<Vec<EmbeddingVec>> {
let prefixed = prepend_query_instruction(input);
let refs: Vec<&str> = prefixed.iter().map(|s| s.as_str()).collect();
self.embed_gen_simple_batch(&refs)
}
关键代码(crates/soul-mem-algo/src/algo/retrieve/similarity.rs):
/// Situation 记忆没有字符串通道兜底(SpecificSituation 的 string_score 恒为 0),
/// 融合分尺度天然低于带 string 通道的 Semantic 查询,因此对 Situation 查询
/// 使用更宽松的有效阈值:`min(全局阈值, 该上限)`。
const SITUATION_SIMILARITY_THRESHOLD: f32 = 0.5;
fn effective_similarity_threshold(variant: &MemoryRetrieveQueryVariant, global: f32) -> f32 {
match variant {
MemoryRetrieveQueryVariant::Situation(_) => global.min(SITUATION_SIMILARITY_THRESHOLD),
_ => global,
}
}
为什么:查询侧与 passage 侧必须落在同一语义空间;Situation 分数分布天然偏低,需要自己的阈值而不是被语义阈值饿死。
效果:suite 660→669(+9):风堇 5→1、桑多涅 4→0、格蕾修 3→0、符华 6→3、琪亚娜 7→4;playtest embedding 合并 7 节点。
7. eb34772 — top-k + 兜底阈值 + 字符串只加分 + 权重 0.3/0.7 + priority 小偏移
问题:绝对阈值 0.7/0.35 会饿死相似度不高但合法的查询;字符串分与嵌入分同权混合会拉低纯嵌入命中;tag 通道权重过高(0.4)导致标签共现抬升无关实体;priority 直接乘分(曾达 55 倍)压制低优查询。
改进方向:阈值语义改为“最低兜底分 + 必取 top-k“;字符串只加分;tag/variant 0.3/0.7;priority 仅 ≤0.05 小偏移。
关键代码(similarity.rs):
// similarity_threshold 语义为"最低兜底分":低于该分的节点直接过滤,
// 达到该分的节点按分数取 top-k(max_results),绝对阈值不再饿死查询。
let floor = request.similarity_threshold;
// ... 过滤条件
if res.score < floor { None } else { Some(res) }
// ... 排序后 take(request.max_results)
关键代码(compute.rs,字符串只加分):
// 有字符串信号时取 max:字符串通道只加分、不拉低
// (当 string < embedding 时混合分会低于纯 embedding 分,取 max 保持"兜底加分"语义)。
let score = if string_score <= 0.0 {
embedding_score
} else {
let blended = string_blend_alpha * embedding_score + (1.0 - string_blend_alpha) * string_score;
embedding_score.max(blended)
};
关键代码(blend_weights.rs):
impl Default for BlendWeights {
Self {
tag: 0.3, // 原 0.4:标签在语义不匹配时会拖累 narrative/概念命中,
variant: 0.7, // 且标签共现(如"人物")易抬升无关实体,因此降低其主导作用
// ...
}
}
为什么:兜底分保证“不空手“、top-k 保证“有上限“;字符串加分避免字形近似拖低语义分;priority 小偏移保护重要查询又不破坏分数主导。
效果:suite 669→683(98.6%),失败 24→10(椎叶䌷/绫地宁宁/琪亚娜各 2 + 博丽/桑多涅/雾雨/风堇各 1);playtest 合并满 10 节点、精确命中分达 1.0。代价:suite 耗时 467s→1901s(约 4×,字符串分对每个节点 O(N) 计算),印证大数据量需要索引的预判。
8. 887220e — 查询生成注入记忆锚点 + 生成后校验
问题:查询生成纯靠 LLM 想象编造 5–8 条、无任何校验,幻觉查询直接进入检索。
改进方向:用用户消息做记忆锚点检索(top-5,低于兜底分不注入),生成后逐条校验 top-1 分数(低于 0.35 丢弃并空回退)。
关键代码(runner.rs,hint 选择):
fn select_hints(hits: Vec<HintHit>, k: usize, lookahead: usize, floor: f32) -> Vec<HintHit> {
if hits.first().map(|h| h.score) < Some(floor) {
return Vec::new(); // top-1 低于兜底分不注入(寒暄等无关轮次)
}
let mut out: Vec<HintHit> = hits.iter().take(k).cloned().collect();
// 多样性:top-k 无 Situation 且 top-lookahead 有达标 Situation 时替换第 k 个
if !out.iter().any(|h| h.is_situation) {
if let Some(best) = hits.iter().take(lookahead)
.filter(|h| h.is_situation && h.score >= floor)
.max_by(|a, b| a.score.total_cmp(&b.score)).cloned()
{ /* 替换/追加 */ }
}
out
}
关键代码(runner.rs,生成后校验):
fn validate_query(&self, query: PrioritizedMemoryRetrieveQuery, model: &dyn EmbeddingModel)
-> Option<PreparedQuery> {
let embedded = query.query().embed(model).ok()?;
let sim_req = SimilarityConfig { similarity_threshold: 0.0, max_results: 1 }
.into_request(self.wm.clone(), EmbeddedMemoryRetrieveQuery { embedding: embedded.clone(), query: query.query().clone() });
let top1 = RetrSimilarity {}.retrieve(sim_req).into_iter().next();
if matches!(top1, Some((_, score)) if score >= self.config.similarity_threshold) {
Some(PreparedQuery { query, embedding: Some(embedded), dropped: false })
} else {
None // 低于兜底分 → 丢弃,trace 标记 dropped
}
}
为什么:让 LLM 在生成时就看到真实记忆片段,从源头减少编造;校验作为确定性安全网(嵌入结果缓存进查询对象,检索阶段不二次嵌入)。
效果:suite 不变(683,查询生成不参与 suite);playtest 查询数从 8/8/8 收敛到 6/4/5(4–8 上限)、18 条查询全部 grounded、0 丢弃、无无检索。
9. d9439dc — 动作指标可见化
问题:动作指标(ActionMetrics)计算了但从不输出,procedure 检出率没有任何数字。
改进方向:ActionMetrics 增加 has_expected_actions 区分占位/真实评测,新增汇总函数与报告列。
关键代码(engine/retrieve/data.rs):
pub struct ActionMetrics {
pub action_hit_rate: f64,
pub action_recall_at: Vec<(usize, f64)>,
/// 该用例是否带 expected_actions 真值(False 表示占位指标,不应计入统计)
pub has_expected_actions: bool,
}
关键代码(engine/batch.rs):
pub fn summarize_action_metrics(outcomes: &[TestCaseOutcome]) -> Option<ActionSummary> {
// 只统计 has_expected_actions 为 true 的用例
// 汇总:cases / hit_cases / recall_at3(加权平均)
}
为什么:无法观测就无法改进;占位指标必须与真实评测区分,否则平均值被污染。
效果:行为零变化(suite 683 不变),报告开始输出动作列;当时数据无 expected_actions 故为 N/A——为量化铺路。
10. 1c32b44 — 数据重合成(deepseek-v4-flash)+ expected_actions 真值
问题:旧数据 0 个用例带 expected_actions,动作永远无真值可评;且模型偶发空响应会让修复路径产出缩水的 7–12 用例数据集。
改进方向:questioner 生成 expected_actions(proc_* 节点 id);完整性门 + 最多 3 次重试。
关键代码(soul_scraper data_model/questioner/retrieve.rs):
pub struct PrioritizedRetrieveQuery {
pub priority: u32,
pub tag: Vec<String>,
pub variant: RetrieveQueryVariant,
#[serde(default)]
pub expected: ExpectedResult,
/// 期望触发的程序性记忆节点(proc_* id,1-2 个)……
#[serde(default)]
pub expected_actions: Vec<String>,
}
关键代码(soul_scraper agents/questioner_agent.rs,完整性门):
const MIN_ATOMIC_QUERIES: usize = 15;
const MIN_TOTAL_QUERIES: usize = 45;
const MAX_ATTEMPTS: usize = 3;
for attempt in 0..Self::MAX_ATTEMPTS {
match Self::generate_once(...).await {
Ok(info) => {
let (atomic, total) = Self::query_counts(&info);
if atomic >= Self::MIN_ATOMIC_QUERIES && total >= Self::MIN_TOTAL_QUERIES {
return Ok(info); // 只有查询量达标的生成才被接受
}
// 否则丢弃,重新完整生成
}
// ...
}
}
为什么:检出率必须建立在有真值的数据上;小数据集会让评测失去代表性;空响应是管线故障而非模型能力问题(deepseek-v4-flash 擅长长输出,需让管线正确处理偶发空响应)。
效果:新数据 722 用例(比旧 693 多)、行为可触发用例标注率 89%(156/175)、0 未知 id;第一次拿到可量化的动作基线:Hit 47.4%、R@3 0.375——证实“procedure 检出率低“的判断。
11. 17819e5 — 语义驱动的边重生成 + 动作真值对齐
问题:强制 proc_none 连接(39% 情境的 top-1 是“无动作“)、图指标门槛(聚类/冗余度)逼着乱连、softmax 归一化把触发概率压平(中位数 0.32);纯 Semantic 查询的动作标注在 Sit→Proc 设计下结构性不可达。
改进方向:边生成只按语义——每个非 proc_none 的 proc 必须有 ≥1 条情境触发入边(校验失败即重试);概率保比例归一化;图指标降为仅观察;expected_actions 只保留 Situation 变体查询。
关键代码(soul_scraper graph_quality.rs,保比例归一化):
/// 对每个源节点的所有 Proc 出边做保比例归一化,使 prob 和为 1:
/// 保持 LLM 给出的相对区分度(softmax 会把 0.9/0.1 压成 0.69/0.31,
/// 破坏"主行为 vs 次要行为"的判别性,改用除以和的方式保留比值)。
let sum: f64 = proc_probs.iter().map(|(_, p)| *p).sum();
if (sum - 1.0).abs() > 1e-9 && sum > 0.0 {
for (_, (idx, _)) in proc_probs.iter().enumerate() {
if let MemoryLinkType::Proc(ref mut p) = nodes[i].mem_links[*idx].link_type {
p.prob /= sum;
}
}
}
关键代码(soul_scraper graph_quality.rs,硬校验):
if !proc_without_incoming_proc.is_empty() {
failures.push(format!(
"Procedure 节点缺少触发情境(必须有至少 1 条来自 Situation 的 Proc 入边): {}",
proc_without_incoming_proc.join(", ")
));
}
关键代码(soul_scraper prompt_template/extractor_edge_system,边生成规则):
- 只连接语义上真实存在的关联:不要为了图指标(密度、聚类系数、连通性)强行连边……
- 抽象情境 → proc_none:仅当该情境语义上确实对应"没有特定行动、保持现状"时才连接;
不要为了概率完整性强制连接。
- 概率要有区分度:主行为概率应显著高于次要行为(通常主行为 ≥0.5,次要行为 0.1-0.3),
不要给多个行为分配近乎相等的概率。
- 硬性要求:每个非 proc_none 的 Procedure 节点必须至少有一条来自语义匹配的 Situation
的 Proc 入边——结构验证会检查这一点,不满足会要求重新生成。
为什么:行为指导的质量取决于“情境→行为“边是否语义真实、概率是否有判别性;不可达的真值只会污染指标。
效果:156/156 proc 全覆盖、概率中位数 0.32→0.50、proc_none 边 149→33;动作 Hit 47.4%→60.9%(新边)→81.5%(清掉 73 条不可达真值)、R@3 0.375→0.584;内存检索 705/722 不变。
12. a6e54af — procedure 动作独立 top-k 进入最终结果
问题:动作与记忆节点同池按分合并,Bayes 动作分低,top-10 截断后 LLM 完全看不到行为倾向(playtest 显示动作被检索到但进不了上下文)。
改进方向:动作独立通道——按自身分数单独排 top-k(action_top_k=3),恒进入最终结果;提示词追加「当前行为倾向」小节。
关键代码(runner.rs,独立通道):
// 动作节点独立通道:不参与记忆分数合并,单独排名后进入最终结果
let mut merged_actions: HashMap<MemoryId, (f64, TracedNode)> = HashMap::new();
// 记忆合并只含 sim+ppr(不再 chain action_nodes)
fold_priority_nodes(&mut merged_map, merged, bonus);
fold_priority_nodes(&mut merged_actions, action_nodes, bonus);
// ...
let mut all_nodes = finish_merged(merged_map);
all_nodes.truncate(self.config.merged_top_k);
// 动作独立 top-k:即使分数低于记忆节点也保留进最终结果
let mut action_nodes = finish_merged(merged_actions);
action_nodes.truncate(self.config.action_top_k);
关键代码(runner.rs,提示词注入):
let mut full_prompt = format!("{}\n\n相关记忆:\n{}", chat_prompt, full_context);
if !full_action_text.is_empty() {
full_prompt = format!("{}\n\n当前行为倾向:\n{}", full_prompt, full_action_text);
}
关键代码(trace.rs):
pub struct RetrievalTrace {
pub mode: RetrieveMode,
pub total_elapsed: Duration,
pub merged_nodes: Vec<TracedNode>,
/// 独立于记忆分数的动作节点(procedure top-k):按动作自身分数单独排名,
/// 不参与 merged_nodes 的分数合并与截断,保证"瞬时行为倾向"始终进入最终结果。
pub action_nodes: Vec<TracedNode>,
pub per_query: Vec<QueryTrace>,
}
为什么:行为指导是“瞬时“信号,不该与知识检索抢排位;独立席位保证它一定被 LLM 看到。
效果:playtest 三轮动作稳定进入上下文(proc_silence 0.30 / 收面饼 0.13 / 模仿 0.09 等),FullPipeline 回复开始引用行为(“最近我在看科幻电影,光剑的招式让我觉得很放松”),与 Embedding 对照组出现有意义分化;suite 保持 81.5%。
13. 横切发现
- embcache 不随边失效:嵌入缓存只按嵌入实现版本失效,
graph.json的边(Sit→Proc)变化不会触发重建。本次测量踩过一次坑——旧 HEAD 重建的缓存含旧 proc_none 高分(0.373),当前 HEAD 的 playtest 一度读到旧边;清缓存后正确(真实动作 0.30/0.13/0.09)。建议:给 embcache 增加边哈希失效条件,否则“改边后“的所有测量都可能失真。 - 精度提升有 CPU 代价:eb34772 引入逐节点字符串分后,suite 耗时约 4×(467s→1901s),与“大图需要索引(HNSW)“的预判一致。
- 残留问题:eb34772 的 10 个失败(椎叶䌷/绫地/琪亚娜等)在数据重合成后仍以低命中形态存在(常陆茉子 11%、格蕾修 60%),属于 Bayes 聚合精度的检索侧问题,是下一轮的明确候选。
14. 复现方法
# 逐 HEAD:checkout → 清 embcache → build → suite full → playtest
$env:SOUL_TUNE_CANDLE_MODEL_PATH = 'D:\QwenModel\Qwen3.5-4B-Q6_K.gguf'
$env:SOUL_TUNE_LLAMA_PORT = '8094'
git checkout <head>
Get-ChildItem fixtures\example_data -Recurse -Filter *.embcache | Remove-Item -Force
cargo run -p soul-tune -- run retrieve/full fixtures/example_data --batch
cargo run -p soul-tune -- playtest fixtures/example_data/格蕾修_https_zh_moegirl_org_cn_E6_A0_BC_E8_95_BE_E4_BF_AE fixtures/daily_dialogues/geluoxiu_peer_daily.json
git checkout a6e54af
轨迹覆盖的 HEAD:ee2f086 → d042323 → a8846f2 → 2f7feb6 → eb34772 → 887220e → d9439dc → 1c32b44 → 17819e5 → a6e54af。
写作规范 · 视觉元素
Note
本文档规定 SoulMem 文档(mdbook)中视觉元素的使用方式,是写作规范的第一部分。后续将补充术语、语体与工具链规范。
规范的目的不是限制创作,而是让“内容角色“与“视觉呈现“一一对应:同一个视觉元素永远表达同一种语义,读者不用猜、作者不用解释。
1. 两条总原则
- 语义优先:写作时先回答“这段内容是什么角色?“——注释?记忆示例?核心设计?技术陈述?——再选择对应的元素。禁止先写样式、再决定含义。
- 一物一用:同一个 markdown 元素不承担两个语义角色。CSS 只是把语义角色画出来,不是内容的来源。
推论:禁止“随手加样式“。任何视觉差异都必须对应一个语义角色,并登记在本规范中;新增视觉元素 = 新增语义角色,需要先在此定义、再投入使用。
2. 语体档位与视觉切换
SoulMem 文档存在两档语体,它们的切换必须有明确的视觉符号:
| 档位 | 内容 | 语气 | 视觉容器 |
|---|---|---|---|
| 叙事档 | 示例、场景、对话、开场故事 | 轻松,允许玩梗(小红/小白、早八) | 叙事容器(未加标签的块引用,独特背景,见 4.2 节) |
| 定义档 | 机制解释、术语定义、算法、字段说明 | 陈述性、严谨,不用梗 | 普通段落与代码 |
为什么需要视觉切换:两档语体都可能连续占据大段篇幅。没有视觉区分,读者无法判断当前在读的是“角色记忆的内容“还是“系统的机制说明“——而这两类内容的可信度完全不同:记忆示例是角色认定的事实,不是系统事实。
- 规则 2.1:叙事档内容必须放进叙事容器(见 4.2)。裸的叙事段落视为违规。
- 规则 2.2:定义档禁止玩梗、禁止网络用语、禁止打破第四面墙。需要轻松语气的内容一律归入叙事档。
- 规则 2.3:打破第四面墙的句子(如“文档作者没有打错字“)只允许出现在
[!note]澄清误会时,不得打断定义档的论证流。
3. 视觉元素总表
| 元素 | 语义角色 | 语法 | 何时使用 | 禁止 |
|---|---|---|---|---|
# H1 | 页面标题 | # 标题 | 每文件一个,与 SUMMARY 章节名一致 | 每文件多于一个 |
## ~ #### | 分节标题 | ##/###/#### | 按层级分节 | 跳级(如 ## 直接到 ####) |
| 普通段落 | 定义档正文 | 空行分隔 | 机制、解释、论证 | 叙事内容(须入容器) |
| 加粗 | 概念强调 | **词** | 关键词、术语首次强调 | 标识符(用反引号) |
| 斜体 | 次要语气 | *词* | 定义档避免;叙事容器内角色台词可用 | 定义档正文用斜体强调 |
反引号 | 标识符/字段/命令 | `name` | 代码符号、字段名、命令 | 概念词(用加粗) |
| 列表 | 枚举/步骤 | - | 并列项、流程步骤 | 单个条目也强行成列表 |
| 链接 | 交叉引用 | [文字](相对路径) | 见第 5 节 | 死链、指向不存在的章节 |
--- | 分节分隔线 | --- | 章内大分节(如三类记忆之间) | 装饰性使用 |
| 代码块 | 代码/配置 | ``` | 代码、数据结构、配置 | 伪代码用 mermaid |
| 表格 | 结构化对照 | 表格语法 | 字段对照、规则表 | 叙事内容 |
| mermaid | 图 | ```mermaid | 流程、结构、时序 | 简单列表能说清的 |
<br> | 段落内硬换行 | <br> | 对话行换行 | 作为间距(间距由 CSS 统一负责) |
| 块引用 | 说明类注释(标签卡片)或叙事容器(裸 >) | > 系列 | 说明类用标签;叙事内容用裸块引用 | 说明类内容裸 >;叙事内容加标签 |
4. 块引用与叙事容器
块引用(>)是本书使用最频繁、也最需要规范的组件。当前它同时承担“注释、记忆示例、设计强调“三个角色且视觉相同——必须拆分。
4.1 内置标签(mdbook 原生支持,已验证)
mdbook 0.5.4 支持 GitHub 风格的块引用标签,渲染为带图标和标题的卡片。已核对源码:仅以下五种(大小写不敏感),标签必须独占首行,后续行写内容:
> [!note]
> 给读者的补充说明、实现细节的旁注。
> [!important]
> 设计观点与原则的强调(如"一切特征和事件都属于记忆")。
> [!warning]
> 有争议的机制、已知取舍(如遗忘机制的争议)。
> [!tip]
> 使用建议、写作建议。
> [!caution]
> 容易踩坑之处。
| 标签 | 语义角色 | 示例场景 |
|---|---|---|
[!note] | 注释 | 给读者的补充说明、实现细节旁注 |
[!important] | 核心设计 | 设计原则与观点强调 |
[!warning] | 争议/警告 | 有争议的机制、已知取舍 |
[!tip] | 建议 | 使用建议、写作建议 |
[!caution] | 注意/陷阱 | 容易踩坑之处 |
规则 4.1:所有“给读者的说明“类内容(注释、核心设计、争议、建议、注意)必须使用上述标签之一。未加标签的块引用一律视为叙事容器(见 4.2),只允许承载叙事档内容。
标题本地化(CSS):内置标签的标题固定渲染为英文(Note/Tip/…),且不支持 > [!note] 自定义标题 这种同行标题(会被当作字面文本)。中文化通过 CSS 实现,在 soulmem-theme.css 中加入:
/* 内置标签标题中文化(隐藏英文原文,用伪元素替换) */
.blockquote-tag-title {
font-size: 0;
}
.blockquote-tag-title::after {
font-size: 1rem;
font-weight: 600;
}
.blockquote-tag-note .blockquote-tag-title::after { content: "注释"; }
.blockquote-tag-tip .blockquote-tag-title::after { content: "建议"; }
.blockquote-tag-important .blockquote-tag-title::after { content: "核心设计"; }
.blockquote-tag-warning .blockquote-tag-title::after { content: "争议"; }
.blockquote-tag-caution .blockquote-tag-title::after { content: "注意"; }
规则 4.2:已知五种标签之外的标签(如 > [!example])在 mdbook 0.5.4 中会原样显示为文本(已核对 pulldown-cmark 0.13.4 源码与官方测试),禁止使用。需要新组件时,先在本规范登记、再引入 preprocessor 等机制。
4.2 叙事容器(无需标签词)
叙事档内容的视觉切换符号 = 叙事容器:拥有独特背景、未加标签的块引用,与注释(靛蓝系)一眼可辨(暖色系)。
叙事容器不需要任何标签词。叙事内容本身就带有轻松的语气和例子说明的功能,强制要求固定的“示例/场景/对话“标签反而显得机械,还给读者造成冗余信息。作者按叙事自然书写即可;需要时可以用加粗短语(如 > **小白的麻辣烫**)当小标题,但那只是内容的一部分,不是规范要求。
规则 4.3:叙事档内容必须写在未加标签的块引用(叙事容器)中,叙事内容不得加内置标签。反过来说,未加标签的块引用一律按叙事容器渲染。
> 2026-07-12 中午,和用户在街角那家麻辣烫店,被辣到了。
> 三个月前,小白第一次去吃麻辣烫,被辣得一直喝水。
> **小红**:你看这个小白就是逊啦,才这么点就被辣到了。
> **小白**:辣?赛博生命的事,怎么能说辣呢?
规则 4.4:除叙事容器外,禁止其他用途的裸块引用(如引文);确有必要时先在本规范登记新组件,再投入使用。
视觉规格(明暗双适配,供 CSS 实现):
:root {
--sm-example-bg: #fdf6ec; /* 叙事容器底:暖色(琥珀 50) */
--sm-example-border: #d97706; /* 左边条:琥珀 600 */
}
.navy, .coal, .ayu, .rust {
--sm-example-bg: #221c14; /* 深色主题:暖黑 */
--sm-example-border: #f59e0b; /* 左边条:琥珀 500 */
}
/* 叙事容器 = 未加标签的块引用;带标签的是注释卡片 */
.content blockquote:not(.blockquote-tag) {
background: var(--sm-example-bg);
border-left-color: var(--sm-example-border);
}
边距、圆角与注释一致(1.5em、8px),仅底色与左边条不同。对话行内的加粗人名(**小红**)是内容,不是标签,不受本规则约束。
4.3 记忆示例的写法
记忆示例是叙事容器最常出现的形式(情境/语义/程序性记忆的实例)。约定:
- 用角色视角直接呈现记忆内容,形如一条真实的记忆记录;
- 示例前在定义档说明“这是什么记忆“;容器内不再解释,保持纯净;
- 记忆内容中的时间、地点用具体值(如
2026-07-12),不用“某年某月“占位——除非故事本身在强调模糊(如遗忘后的记忆)。
5. 章节引用规则(当前阶段)
背景:本书后续部分尚未定稿,且作者计划进行一次内容重组。因此现阶段:
规则 5.1:允许前向引用(“后续章节”),但必须使用统一句式,禁止自创说法。
| 场景 | 规范句式 |
|---|---|
| 指向后续未定稿内容 | 具体机制将在后续章节阐述。(可加主题词:“具体机制(如遮罩实现)将在后续章节阐述。”) |
| 指向主题而非具体章节 | 详见 [遗忘算法](../algorithm/forget.md) 相关章节。 |
| 指向已存在且稳定的章节 | 相对链接:详见 [检索算法](../algorithm/retrieve.md)。 |
规则 5.2:禁止在正文写死具体章节标题或文件名作为“未来承诺“(如“将在《XX》章详述“)——重组会破坏这类引用。指向未来内容时用主题词,不用章节名。
规则 5.3:重组完成后,把前向引用逐步替换为相对链接,并启用 mdbook-linkcheck 校验所有链接(见第 7 节)。
6. 术语与专名(待补充)
- 专名大小写固定:
SoulMem、LLM、PPR、SurrealDB(统一写法待确认后定稿); - 一物一名:同一概念全文统一叫法(如“角色 / 数字角色 / 赛博生命“择一为主),别名登记进术语表;
- 首次出现给出中英对照,如
情境记忆(Situation / Episodic)。
(术语表将在规范第二部分建立。)
7. 机械化检查(规划)
规范定稿后引入工具,把规则变成可自动检查的约束:
- pangu:中英文之间自动加空格;
- markdownlint-cli2:标题层级、空行、列表一致性;
- mdbook-linkcheck:链接与锚点有效性(重组完成后启用);
- CI 检查:专名大小写白名单、禁止
<br>作间距(裸块引用是否违规依赖 review,暂不自动化)。
8. 生效范围与迁移
- 新内容:自本规范生效起,按本规范写作。
- 存量内容:分批迁移,优先级:
- 块引用分类(注释 → 标签卡片;记忆示例、对话 → 未加标签的叙事容器)——对阅读影响最大;
- 裸
>清理; - 专名与术语统一;
- 前向引用句式统一。
- 本规范随写作迭代更新;新增任何视觉元素前,先在此登记。
研究笔记
本部分收录 SoulMem 开发过程中的调研笔记、方案设计与性能报告。原始资料位于仓库
developing_notes/(该目录被 .gitignore 忽略、不参与版本控制),此处为纳入文档体系的
整理版。
笔记索引
| 笔记 | 主题 |
|---|---|
| 记忆算法概述 | 记忆算法三大类(检索/巩固/遗忘)的设计总览与 Soul-Retr 检索思路 |
| WorkingMemory 并发安全与 API 重构 | 工作记忆滑动窗口的并发安全方案(2026-04) |
| PPR 性能报告 | Power Iteration 与 Forward Push 的性能对比实测 |
| ReMindRAG 笔记 | ReMindRAG(路径记忆 + 知识图谱搜索)调研(见 developing_notes/ReMindRAG_notes.md) |
| Agent Memory Survey 笔记 | 大模型 Agent 记忆机制综述(见 developing_notes/Agent Memory Survey notes.md) |
| Qwen 架构建议 | 程序性记忆子图的重构建议(TriggerContext/BehaviorPattern)(见 developing_notes/qwen的架构建议.md) |
| 重构笔记 | 第一次重构目标与 memory 模块重构方案(见 developing_notes/refactoring.md) |
注:
记忆算法概述.md与working_memory_fix.md、ppr_performance_report.md已直接纳入 本 mdBook;其余笔记体积较大或与主线文档重叠,保留在developing_notes/中供查阅。
参考资料(PDF,位于 developing_notes/)
| 资料 | 说明 |
|---|---|
| A-mem | Agentic RAG,本项目重要启发之一 |
| HippoRAG | 知识图谱 + PPR 联想检索,本项目检索算法重要启发 |
| PPR Introduction | PPR 算法综述(概念与常见计算算法) |
| EdgePush-PPR | 带边权图的 PPR 算法 |
| 人脑记忆机制与功能分类深度研究报告 | 脑认知科学参考 |
| 人类程序性记忆的联想机制深度研究报告 | 程序性记忆的神经科学基础 |
| Memory in the Age of AI Agents / Agent Memory Survey | 大模型记忆综述 |
| SoulMem 深度研究报告(×2) | 角色扮演大模型记忆系统的可行性与优化路径 |
分支概览
仓库存在大量 feature 分支,文档以当前活跃分支
feature/test_framework(含工作区未提交 修改)的代码状态为准。
| 分支 | 最新提交 | 主题 | 状态 |
|---|---|---|---|
main | 2026-03-29 | 基线(合并 devel/retrieve 的 PR #8) | 基线分支,已落后 |
dev | 2026-07-18 | 集成分支:candle 0.11.0 升级、检索合入 | 活跃主干,已并入 test_framework |
feature/test_framework | 2026-08-13 | 测试框架主线:playtest/eval、24 图、抽象 PPR 检出 | 活跃(当前分支) |
feature/retrieve_algo | 2026-08-13 | 检索算法:PPR 截断修复、字符串评分、insta 快照 | 已并入 test_framework |
feature/cluster_refactor | 2026-04-04 | 记忆簇重构、RetrStrategy 关联类型 | 已完成(已并入),本地独有 |
feature/consolidate | 2026-07-22 | 巩固 + 数据库 schema | 未合并,疑似搁置(无 DB 路线取代) |
feature/forget | 2026-04-18 | 遗忘 v1(遮罩 + Mask 字段) | 废弃,被 newforge 取代 |
feature/newforge | 2026-07-10 | 遗忘 v2:Ebbinghaus 衰减 + 遮罩 + LLM 修订 | 未合并;算法以未跟踪形式存在于工作树 |
feat/abstract-ppr-detection | 2026-08-13 | 抽象 PPR 检出试点 + 24 角色验证 | 未合并(内容并入 test_framework),本地独有 |
ci/setup | 2026-08-09 | GitHub Actions CI + mutants 门禁 | 未合并(独立 CI 分支) |
devel/embedding、devel/retrieve、devel/situation_mem、devel/sliding_window | 2026-02~03 | 各功能开发 | 均已合并入 main |
docs/add_mem_algo | 2025-12-04 | 记忆算法文档 | 已合并入 main,本地独有 |
alpha_deprecated | 2025-10-08 | alpha 旧版存档 | 废弃存档 |
backup/feature/test_framework-pre-rewrite | 2026-08-08 | 重写前快照 | 备份分支 |
ron-yc/WorkingMem | 2026-03-13 | 个人工作记忆实验 | 废弃 |
⚠️ 重要:遗忘算法实现(
soul-mem-algo/src/algo/forget/、soul-tune/src/engine/forget/、fixtures/forget/)与docs/architecture/cluster.md、本 mdBookbook/目前仅存在于 本地工作树(untracked),尚未进入任何 git 分支。建议尽快提交,避免工作树内容丢失。
记忆算法概述
⚠️ 文档状态:本文档为算法设计总览(2026-03 修订)。实现状态对照(2026-08): 检索——baseline 向量 top-k ✅、HippoRAG 式 PPR(
weighted_ppr_fp)✅、Soul-Retr 的 混合分级检索(LLM 判断循环/PoG/记忆向量更新/反馈回路)❌ 未实现(实际为固定三步管线, 见 检索与联想);巩固——滑动窗口摘要 ✅,LLM 拆解整合 ❌ (见 巩固算法);遗忘——Ebbinghaus 衰减 ✅、遮罩法 ✅ (三档 NoAction/MaskOnly/Revised,见 遗忘算法),记忆锚点 ❌。 状态机 Working/Idle ✅。
记忆算法是SoulMem中另一极为关键的版块。它的功能是对记忆图进行操作。
在相对主流的实践中,LLM通常扮演大脑的角色,由LLM来调配各个模块的运作,这一机制通常是由**“工具”来实现的,即将外部模块封装为可供LLM调用的一系列工具。或者利用一个明确的工作流**,LLM只是其中的一个环节。
SoulMem的设计属于类似工作流的思路(只不过准确来说是一个有状态的工作图),LLM在其中只是作为一个强大的末端执行器,这种思路有以下好处:
- 更加可控,决策逻辑部分由人构思实现
- 更少的token用量
- 即便在工作流的模式中,我们依然要使用诸如提示词工程的手段,其token用量仍比工具调用要少,因为工具的使用方法通常也是通过提示词注入LLM的上下文的。这也导致LLM通常不能驱动大量的工具(嗯跟CPU一个道理)
- (也更加省钱)
- 即便在工作流的模式中,我们依然要使用诸如提示词工程的手段,其token用量仍比工具调用要少,因为工具的使用方法通常也是通过提示词注入LLM的上下文的。这也导致LLM通常不能驱动大量的工具(嗯跟CPU一个道理)
- 更加普适
- 工具调用能力不同的模型有很大差异,工具调用能力较差的模型,可能完全无法通过工具的方式运作。这是不希望看到的,尤其对于角色扮演任务来说,人物性格的遵循能力可能要更为重要,这会导致一些潜在的优秀模型无法使用。
因此,既然LLM作为末端执行器,那么获取执行所需的数据,和整个记忆系统的状态维护,就都是我们需要实现的算法。因此更准确的说,记忆算法的根本目的就是收集提供给LLM的数据,和维护记忆系统的自身状态。
(注:SoulMem主要的状态维护是一个状态机,主要由两个状态,Working和Idle,Working表示当前正在积极处理用户交流,即检索正在频繁工作。idle表示当前没有用户交流,检索没有工作)
模仿人类的记忆功能,记忆算法可以分为以下三大类:
- 检索/联想
- 巩固
- 遗忘
下面将分别介绍这三类算法
检索/联想
检索/联想算法指的是,对于一个给定的查询要求(暂且考虑为用户的信息本身),通过对记忆(子)图的搜索,找到符合查询要求的信息。检索出的信息经过某种手段(这种手段不是检索/联想算法的一部分),将作为提供给LLM的上下文使用。
baseline - 基于向量相似搜索的top-k方法
这是最经典的RAG思路,将记忆内容输入嵌入模型获得向量化的索引,在检索时,将用户信息以同样的模型向量化,通过一些算法(如HNSW)与记忆系统中的内容做相似性搜索(通常为余弦相似度),选取相似度最高的k条记忆,这k条记忆将被送入大模型的上下文。
这一方法有很多变种,例如加上Rerank模型来使top-k有更好的的相关性等。
这一方法的优点在于简单快速,不过缺点同样非常明显:
- 它不能处理复杂查询关系,例如多跳问题
- 多跳问题的经典案例, “姚明的妻子的父亲的出生地是哪里”
- 它不能进行联想
- “钟离假死” 和 “米哈游”在向量空间上距离应该会比较大,但是这两个实体通常是相关的,如果现在某个数字生命看到了“钟离假死”,采用此方法,它不太会输出类似“玩米哈游玩的”这类语言
因此,后续的方法对这些缺点进行了改善。
类HippoRAG - 基于知识图谱和PPR算法的检索
是的我们跳过了很大一段的发展历程直奔类似GraphRAG的一系列方法。
HippoRAG将文档内容,预先通过LLM进行IE(信息抽取, Information Extraction)生成知识图谱,在检索时,通过向量搜索对应到图上的一些节点。以这些节点为起点执行PPR算法。由于PPR算法的结果是各个节点对于给定起点列的“重要程度”,因此去PPR分数的top-k个,作为送给LLM的内容。
这个方法高效的解决了上述两个问题:
- 多跳问题
- PPR的算法是一个在图上游走的过程,他可以提取需要多步推理的知识问答问题
- 联想
- 虽然HippoRAG主要是构建知识库用的,但这样的算法结构同样可以支持“梗文化”相关的联想,只要在知识图谱中将两个实体用边关联,PPR算法就可以发现这些节点,虽然可能与人脑的联想机制有出入,但实现了相似的效果。
但是,考虑到SoulMem的具体场合,HippoRAG显得略微有些不足:
- 连接强度的考虑
- 由于SoulMem系统包含遗忘机制,边的连接具有一个权重,代表连接强度,强度越高的节点之间越容易发生联想,这代表边权需要被考虑在PPR算法的执行中
- 需要使用PPR算法的变种
- 由于SoulMem系统包含遗忘机制,边的连接具有一个权重,代表连接强度,强度越高的节点之间越容易发生联想,这代表边权需要被考虑在PPR算法的执行中
- 节点的考虑
- HippoRAG构建的是传统的三元组知识图谱,不适合表示一段动态的经历,这部分由SoulMem的数据结构解决,本文不再赘述
Soul-Retr(为了方便写文档,暂时先叫这个吧)
基于以上两种典型的检索方式,我们需要在此基础上进行改进,形成Soul-Retr算法。
有模式记忆的混合分级检索(最高实现优先级)
流程如下:
- 分解子问题,并根据角色设定,以及当前的心情等状态生成检索指导(要检索到什么程度,随便有内容就行还是仔细分析判断?检索什么倾向的内容,具体情境或者抽象概念?)
- 找到种子节点,这部分基于向量相似性搜索加取top-k
- 通过记忆向量扩展查询子图
- 扩展的判断式中,论文是基于节点之间的相似性的,我们肯定是不能这么干的,毕竟网络热梗可以让两个本来毫不相干的概念关联起来,图的拓扑结构是我们判断关联的唯一判据。
- 我们可以考虑变更这一项,比如换成搜索倾向(倾向于是找情景记忆还是语义记忆?),当前心情(你知道的,人在不耐烦的时候不太喜欢想一些搞七搞八的东西)等指标的混合,这会让记忆子图的扩展更加灵活。
- 让LLM判断是否足够
- 不足够,运行PPR变种(考虑边权的那种,边权动态构建,构建的参数如上所述),取top-k加入查询子图
- LLM判断是否足够
- 不足够,退化为PoG,说明问题看起来非常复杂,例如高学术研究哲学讨论,这种情况下人也得慢慢思考,可以接受
- 不管怎么样我们都根据查询子图更新记忆向量(用ReMindRAG的方法),人的思维路径就是越激活越强的
- PPR在这里有点小问题,PPR得到的是单独的节点,但没法根据PPR结果形成达到结果节点的路径,我们或许有两种方式处理:
- 扩展子图把结果节点包进去,感觉非常暴力
- 先从起始节点建立与PPR节点的假关联(dummy),让LLM去分析哪条路径贡献得到了这个结果,如果有条假关联被激活了,那么我们用A*算法找一条真正能从这个起始节点到那个目标节点的一条路径并更新其上的记忆向量
- PPR在这里有点小问题,PPR得到的是单独的节点,但没法根据PPR结果形成达到结果节点的路径,我们或许有两种方式处理:
- 我们还可以引入外部反馈,角色回答后用户会给出反馈,LLM分析我们的检索结果是好还是坏(这大概只有在角色认真并在意这个的时候才会需要),通过外部反馈再去根据反馈的那个检索的查询子图去更新记忆向量,外部反馈权重比内反馈大。
基于EdgePush-PPR(上一个方法的其中一部分)
基于HippoRAG的缺点,我们把边权考虑进去,采用EdgePush-PPR算法。
有以下几个考虑点:
- 要怎样对文本进行向量化
- 直接向量化?或者附加上这句话的tags的向量?或者其他的方式
- 这将很大程度上影响起始节点
- “边权的构造”
- 连接强度是肯定包含的,那么其他的呢?例如节点类型,是否应该纳入考虑?
- 分级路由策略?
- baseline已经能应对相当一部分的纯日常简单问答场合
- 可以考虑简单问题直接使用baseline,复杂问题再运行PPR
- PPR次数?
- HippoRAG是1次,但是我们有不同类型的节点,我们应该期望结果中三种节点的占比如何(或者认为占比不重要无需考虑)?
- 如果要多次,每次PPR的参数是否要有变化,有怎么样的变化?
- 以及其他奇奇怪怪的细节问题~
这些问题或许不能完全立马确定,我们可以考虑先选择简单的实现,发现效果不良后再进行改进。
这个方法的好处是前辈们的工作比较多,理论也比较完善,效果是有一定保证的。
基于神经动力学(应该?)(较低优先级)
我们假设图具有多个有向环路。
如果我们直接把节点看做一个神经元的细胞体,那么经过向量相似性搜索后,我们可以认为初始节点被一个初始电信号**“激活”。初始节点将沿着它的轴突传向邻近的神经元**,邻近的神经元因此被激活进一步传递信号给下一个神经元。这样以涟漪的形式扩散开去。
这个系统应当具有一个稳态,由于整个数据结构是图的形式,神经元相互连接形成回环,最终如果达到稳态,那么每个神经元都应该保有一定的**“电位”,电位越高代表激活强度越高**,我们可以选电位的top-k个节点送给LLM。
这个扩散过程应当可以构建动力学方程,即转化为求解稳态分布的问题。
这个方法和PPR的区别在于,PPR是一个人从起始节点开始随机游走,任意时刻它只会位于一个节点上。而此方法是一个更倾向于扩散的系统,电信号从起点以涟漪扩散传播,它更好的模拟了神经元的生理行为。
重要!!!
在描述本方法时,笔者大量使用了不确定性的词语,因为笔者并没有进行数学上的验证,笔者并不确定:
- 有环路的图是否一定可以收敛到一个稳态
- 是否可以构建起动力学方程
- 是否可以高效的求解这个方程获得稳态分布
- 获得的稳态分布中,电位top-k节点是否代表应该选取的节点
本方法必须要求图中具有环路,若没有环路,则信号以此传递,最终稳态电位都是0,必然不可行,因此对于无环的图,需要成环处理(虽然PPR也需要处理无出度节点的问题)。
本方法唯一具有的优势可能就是它更好的模拟了人脑的生物过程,如果前面所说都能成立,它或许会有更好的效果。但从工程角度考虑,此方法绝对不应该被优先实现。
巩固
巩固指的是将记忆向长期记忆的方向转化的过程,即从短期 -> 工作, 从工作 -> 长期,这是持久化记忆的关键。它的目的是维护记忆系统的自身状态,主要是增加和更新内容。
通常来说,巩固只在Idle状态下执行。
巩固算法的参考比较少,具有自我进化的记忆系统本来就少(
短期 -> 工作
这部分应该是目前SoulMem最完善的算法,主要处理的是**“增加”**。
我们维护一个滑动窗口,滑动窗口存储用户对话和LLM生成的原始信息。滑动窗口具有一个固定大小,他是一个FIFO(先进先出)队列。我们把信息加入滑动窗口叫做“滑入”,把因为滑动窗口已满并且又有新信息加入而有信息移出滑动窗口的过程叫做“滑出”。
滑入的信息中,每间隔一个滑动窗口的大小,就会被打上一个标记,这个标记也可以由一些机制强制打上(例如对话结束,切换话题等时候)。每当带有标记的信息滑出滑动窗口时,触发一次**“摘要”,将当前滑动窗口中的信息送入LLM,让LLM进行总结,LLM输出的内容称为“摘要记忆”**。
摘要记忆只有一份,当存在摘要记忆且有带标记的信息滑出时,将摘要记忆和滑动窗口的内容同时送往LLM,生成一份新的摘要记忆。这样,摘要记忆中就持续的记录了当前对话中的信息。到此处,这是一个经典的管理LLM上下文的方法。
在每次有用户信息时,检索算法会被调用。不论采用何种检索算法的实现,总是有一些节点被提取出来。记录这些节点的提取时间戳(每一次都记),提取次数等信息,这些记录称为**“提取记录”**。
(注:如何编写提示词生成符合预期要求的摘要不属于记忆算法的考虑范围内,这部分调整,实验起来都非常方便,可以留到最后解决)
以上的部分是在Working状态下工作的。
当从Working状态转为Idle状态时,将摘要记忆送往LLM,将其拆分成多个MemoryNote(也就是记忆图的节点),根据提取记录中的数据,计算这些记忆的提取频率,具体为: $$ f = \frac{n}{T} $$ 其中f为提取频率,n为提取次数,T为提取首末时间戳之差。
去提取频率的top-k节点,将这些节点,和从摘要记忆中拆出来的多个节点送往LLM,让LLM建立起这些节点的联系。这样我们就可以把从摘要记忆中拆分出的多个节点加入工作记忆,转化完成,摘要记忆也就可以清空了。
说明:
采用提取频率作为筛选提取记录的原因是基于赫布学习理论,赫布学习理论描述了两个神经元之间的共激活频率越高,它们之间的连接就会更紧密。
我们知道在主流的记忆系统中,这一指标往往是相关性而非提取频率,这是因为目前主流的记忆系统服务与Agent工具,主要应用于代码智能体,学术研究,文献检索,推理等理性为主的任务。
然而角色扮演是一个非理性的任务,一个角色完全有理由将“桃子”与一个活生生的角色关联起来,可以从上数学课关联到干饭,甚至可以有更加逻辑上很离谱的关联,例如意大利面和42号混凝土,而相关性指标完全无法胜任这些关联任务。除非加以引导提示,大模型通常不会将意大利面和42号混凝土关联,然而这种关联是非常重要的,因为检索算法完全依赖于这些关联。如果意大利面和42号混凝土无法建立关联,那么我们就无法描述这个梗了,所扮演的角色也就更不会进行接梗,这会使角色变得呆板,看起来没有什么生命力。
同时,人脑可能会建立起错误的关联,而相关性指标在设计上,它不允许出现错误的关联,因为这会让基于理性的任务表现水平大幅下降。但人脑的错误关联所犯得一些“小错误”,这也是具有活人感的一个很重要的细节。采用基于提取频率的方式,在设计上允许以上两个问题得到解决,笔者认为这是在角色扮演任务的情境下最合适的指标。
工作 -> 长期
这部分主要处理**“更新”**
这部分目前可以说是空白,目前有一些初步设想:
- 定期,或者在程序“优雅退出”(指程序接收到退出信号,处理完一切资源清理和状态记录后退出程序)时尝试执行
- 将工作记忆子图直接写进数据库
两种可能的机制:
- 直接在数据库上进行聚类算法,将一些极其相似的记忆合并
- 将一部分子图从数据库拉到工作记忆中,执行某些神笔操作,再写回数据库
- 这一条来源于人脑的记忆回放机制
需要思考确定的内容:
- 如何划定需要更新的区域?
- 图可能很大,全更新不现实,耗时耗空间,甚至因为很可能要调用LLM,还耗钱(
- 应当进行什么更新?
- 合并?内容的更加好的表示?
- 是否允许在此阶段建立新的连接?
- 元数据(metadata)是否需要更新?要更新什么?
- 如何更新?
- 调用LLM?
- 或者某些神笔小操作?
- 其他奇怪的细节地方?
这部分主要要考虑的是性能,效果和钱的平衡。因此记忆图随着时间增长会越来越大,全量执行一遍遍历不现实,时间不允许,内存占用更不允许(一个vector embedding大概3KB,仅仅是一个embedding向量,节点还包含很多字符串信息,加载到内存里就是个问题,多次加载的话,性能就会出问题)。
如果选区更新,那么效果如何保证。钱的问题,主要来自于LLM的token费用,如果更新的内容很多,调用LLM会产生巨大的token费用。因此需要尤其仔细的设计。
遗忘
遗忘是另一块非常重要的算法,他主要进行状态维护中的**“删”。总得来说,遗忘总是让信息往熵增**的方向移动。
遗忘主要包括两个部分:
- 连接强度的衰减,关系的改变
- 这模拟了,你能清楚的记忆起两个事物,但你无法从一个事物联想到另一个事物。
- 这种情况考试中非常常见,例如考完出来对答案的时候,”哎呀我当时怎么没想到“
- 节点内容的变化
- 记忆的内容本身也是在变化的,一段经历到后面可能回忆起来就跟当时面目全非了’
- 俗称“回忆的加工”
实现遗忘机制,不仅仅是因为人会遗忘有时候还健忘的比较厉害,遗忘本身也是一种控制记忆图规模的方式与手段。如果没有遗忘机制,记忆图就会一直增长下去,最终一定需要手动维护,这在实际应用程序上就不太现实了,不可能指望用户做这种枯燥的重复性工作。保持一定的规模,可以保证算法的执行时间得到一定的控制,保证存储空间的稳定。
遗忘本身也是一种筛选机制,他就是大脑中长期的注意力系统,遗忘不常检索到的信息,就相当于增加了常用信息的注意力分数,这也有助于增加检索算法的效果。
主流Agent不会主动实现遗忘机制,甚至要对抗它,想尽一切办法让无论是大模型本身还是RAG系统像超忆症一样记住所有东西。这是由它们主要解决基于理性的任务这一性质决定的。但对于角色扮演,除开某些特定角色(比如什么病娇),大部分角色如果能完全记住所有信息,就会产生一种不真实感。
简单来说,你不觉得一个会遗忘的数字生命,拼命让自己尽可能记住和好朋友相关的信息会更可爱吗(雾
题外话:
就算基于理性的任务,遗忘或许也有作用,如果Agent知道自己会遗忘,或许它就不会在某些时候信誓旦旦,一本正经的胡说八道了(
一些理论
艾宾浩斯遗忘曲线
啊对,考试复习的时候总会被某些营销号炒作的概念。但是这里我们会有大用处。
艾宾浩斯遗忘曲线是通过以下方法测量的(有简化),给定一堆随机的字母序列,先记住它们全部,过一段时间再看看记得多少,把能记住的东西的量化指标记录下来,这样形成的曲线。拟合公式如下: $$ R = e^{-\frac{t}{S}} $$ 其中R是被记忆的内容,S是相对记忆强度,t是时间。
艾宾浩斯遗忘曲线是在无意义的字母序列的情景下测定的,对于有语义的部分,这篇文章(广告)中的图表可以表明,对于具有一定语义信息的序列,艾宾浩斯遗忘曲线的形式可能仍然适用。
这指明了表征记忆强度的指标是指数型衰减的
嵌入向量空间
Vector embedding确实是一个很熟悉的概念了,但是它的一些性质或许需要一些说明。
嵌入模型将文本转化为了具有一定维度的稠密向量(通常是784?维),这意味这信息受到了压缩(信息论的一些相关内容),这也意味着如果将文本送入嵌入向量模型再把嵌入向量送入一个decoder,一定会有一些系统性的误差。
嵌入模型可以看做一个复杂的函数,这个函数的值域通常并不覆盖整个向量空间,所以,这个空间中有一些点是没有对应文本的。
这意味着在向量潜空间中对文本进行操作是一种hack的方法:
- 它具有系统性误差
- 可能会变换到无定义的点
- 不同的模型输出不同的向量,性质也会略有不同,它是模型相关的
- 同时,在向量潜空间中进行操作,意味着必须能decode回去,这导致很可能每个模型都需要一个decoder。
- 统一的decode方法存在,但相对来说比较复杂,例如ZSInvert。
在向量潜空间中直接操作的唯一好处就在于可以利用数学方法。
记忆锚点(暂定)
记忆锚点是作用在单个记忆节点上的,它在节点内容的变化中起作用。
记忆锚点存在于情境记忆中。它锚定了记忆节点中的一些关键细节,使得这些部分熵增的速率远小于记忆节点中的其他部分
它对应的是一些“印象深刻的细节”,例如游戏结局中某个角色的台词等。
记忆锚点分为两类:
- 自发锚点
- 可以观察到,有些印象深刻的记忆,在第一次经历时就会有一种深刻的印象。例如看到某句台词突然有“醍醐灌顶”的感觉,这句台词很有可能你可以记很长时间。
- 强制锚点
- 例如考试前突击复习背材料,通过意识刻意地重复,可以锚定其中的关键词,关键表达等。
- 这类锚点短期内印象非常深刻,但随时间快速衰减,称为强制锚点
锚点在巩固过程中,由调用的LLM生成。
节点内容的遗忘(都是非常初步的一些灵感想法)
潜空间法
潜空间法,顾名思义,在潜空间里完成对文本修改的操作。
对于一个节点的记忆内容,我们分为多个**“记忆微元”**,将这些记忆微元的嵌入向量也存储在节点中。
利用某种算法(没想好,但应该与嵌入向量有关),对每个记忆微元赋予一个熵值,遗忘时,计算熵值的梯度,这样,我们可以通过一个遗忘强度,以及和熵值梯度向量之间的夹角,来控制潜空间中向量的变化方向。
遗忘强度应该足够小,否则会大幅改变词语的含义。
把变换后的向量decode回去,改变记忆微元的 文本内容,即完成一次遗忘。
这个方法的优点就是利用了数学方法,比较能够搞操作。
缺点很多:
-
decoder模型很大可能要自己训练,每一个embedding模型都需要训练一个decoder模型
-
在嵌入向量空间中,编解码有系统性误差,并且有一部分向量空间的点不对应文本
-
遗忘强度和夹角的选取比较困难
-
解码后的“遗忘记忆内容”可能根本就没有语义
遮罩法(可能可行的方案)
这个方法最初是为了解决潜空间法中不对应文本的问题诞生的,但发现可以单飞(
同样的,我们需要拆分记忆微元。不同的在于,我们为每个微元创建一个**“遮罩”,并在生成此记忆节点时,生成每个微元的概括性描述**(例如“猫” -> “会哈气的动物”,信息的不确定度的增加了,这是关键)。微元之间可以重叠,也可以形成包含关系。
遮罩具有一个**“透明度”,透明度越大,就越能看到原本的信息**,反之,原本的信息就越模糊。透明度的衰减遵循艾宾浩斯遗忘曲线的拟合公式
当微元遮罩的透明度大于阈值时,提取该记忆时,直接提取原内容。
当微元遮罩的透明度小于阈值时,提取该记忆时,用xxx替换原信息,并附上微元的概括性描述,如xxx(会哈气的动物),通过提示词工程让LLM理解这种描述的意思。我们要求LLM对于xxx的部分,基于角色的性格进行补全。把LLM补全后的记忆替换掉原本的记忆,并重新生成微元(或许可以不用)
当微元遮罩的透明度小于一个低于上文所述的阈值的阈值时,我们用xxx替换原信息后,不提供概括性描述,直接让LLM补全。
在这里遮罩的透明度减少即为熵增,因为它增加了LLM所能拿到的信息的不确定度。
微元允许重叠和包含,如果不允许,那么句子的整体结构就不会发生大的变化,这种层叠结构也有利于模拟节点内容间不同等级的遗忘。
这种做法模拟了两种不同情境的遗忘:
- 间隔性的“复习”,此时我们仍然提供概括性的描述,补全的内容不会偏离原内容太多
- 完全忘记,此时概括性描述不提供,LLM只能纯猜,与原记忆的出入会比较大
这样的做法的优点:
- 惰性
- 对于遗忘,我们只需要衰减每个微元的遮罩,这一操作很容易并行,效率较高
- 当它被提取时,我们才应用遗忘,最大限度减少了LLM的调用次数
连接的遗忘
这部分似乎没什么内容,对连接强度利用艾宾浩斯遗忘曲线的拟合公式进行衰减,低于一定阈值删除连接应该就行。当一个节点没有任何连接的时候,就删掉它。
WorkingMemory 并发安全与 API 重构方案
一、问题描述
1.1 并发场景
系统存在以下并发场景:
- 巩固算法与检索算法并发执行:用户输入或 LLM 返回信息时,
push/pop操作可能与检索同时发生 - 检索算法内部并发:query 被拆分为多个原子 query,各自独立执行以提升性能
push/push并发:用户连续发送信息或用户与 LLM 同时发送信息时,两个push可能并发执行tokio::spawn的Send约束:闭包必须满足Send + Sync约束才能跨线程调度
1.2 当前架构
// src/memory/working_memory/sliding_window.rs
pub struct SlidingWindow {
window: VecDeque<Information>, // 非线程安全
capacity: usize,
tag_count: usize,
summary: Arc<RwLock<MergedInformation>>, // 已有并发保护
}
pub enum Information {
User(UserInformation),
Assistant(AssistantInformation),
}
pub struct UserInformation {
pub text: String, // heap 分配,clone 代价高
pub tag: bool,
}
pub struct AssistantInformation {
pub text: String, // heap 分配,clone 代价高
pub tag: bool,
}
struct MergedInformation {
content: Vec<ChatCompletionRequestMessage>,
previous_summary: String, // heap 分配,clone 代价高
}
// src/memory/working_memory.rs
pub struct WorkingMemory {
state: WorkingState,
sliding_window: SlidingWindow, // 非 Arc,无法跨线程共享
memory_cluster: MemoryCluster, // HashMap,非线程安全
records: HashMap<MemoryId, Record>,
}
1.3 当前问题
| 问题 | 说明 |
|---|---|
| 线程安全问题 | VecDeque::push/pop 不是原子操作,并发访问有数据竞争 |
| 字符串 clone 代价高 | Information.text 是 String,每次 clone 都要复制堆数据 |
| API 设计缺陷 | retrieve 接受 Request 的所有权但返回引用,语义冲突 |
| 无法跨线程共享 | SlidingWindow 不是 Arc,无法通过 tokio::spawn 传递 |
| MemoryCluster 非线程安全 | 内部 HashMap 并发读写有问题(计划改 DashMap) |
二、原因分析
2.1 Information 字符串 clone 问题
当前 Information.text 是 String 类型。当 get_windows() 返回 Arc<VecDeque<Information>> 时,如果每次 clone 要复制整个字符串,对于几十个中文字符的 message,代价不可接受。
2.2 SlidingWindow 线程安全问题
VecDeque 的 push/pop 操作不是线程安全的。即使包装在 Arc 中,多个线程同时修改 VecDeque 内部状态会导致数据竞争。
2.3 RetrStrategy API 设计缺陷
trait RetrStrategy {
type Request: RetrRequest;
type Return<'a>
where
Self: 'a;
fn retrieve(&self, request: Self::Request) -> Self::Return<'_>;
}
request按值传递,被消耗Return<'a>是生命周期引用,语义上要求request仍然存活- 两者冲突:无法同时满足“消耗 request“和“返回 request 内部数据的引用“
2.4 tokio::spawn 的 Send 约束
tokio::spawn 要求闭包满足 Send + Sync。如果 SlidingWindow 的方法需要 &mut self,则无法通过 Arc<SlidingWindow> 跨线程传递(Arc 只提供共享所有权,不提供独占访问)。
三、解决方案
3.1 核心理念
Information不可变设计:text使用Arc<str>,创建后不变,返回新实例表示状态变更- 线程安全的数据结构:
window使用Arc<RwLock<VecDeque>>,读写分离 SlidingWindow完全可共享:所有方法使用&self,通过Arc<SlidingWindow>跨线程传递- 零 clone 字符串:
Arc的 clone 只是引用计数操作,O(1)
3.2 结构变更
3.2.1 Information 及相关结构
// src/memory/working_memory/sliding_window.rs
pub enum Information {
User(UserInformation),
Assistant(AssistantInformation),
}
impl Information {
pub fn new(value: &str, role: &str) -> Self {
match role {
"user" => Information::User(UserInformation::new(value)),
"assistant" => Information::Assistant(AssistantInformation::new(value)),
_ => Information::User(UserInformation::new(value)),
}
}
pub fn with_tag(self) -> Self {
match self {
Information::User(info) => Information::User(info.with_tag()),
Information::Assistant(info) => Information::Assistant(info.with_tag()),
}
}
pub fn without_tag(self) -> Self {
match self {
Information::User(info) => Information::User(info.without_tag()),
Information::Assistant(info) => Information::Assistant(info.without_tag()),
}
}
pub fn is_tagged(&self) -> bool {
match self {
Information::User(info) => info.tag,
Information::Assistant(info) => info.tag,
}
}
pub fn get_str(&self) -> &str {
match self {
Information::User(info) => &info.text,
Information::Assistant(info) => &info.text,
}
}
pub fn to_message(&self) -> ChatCompletionRequestMessage {
match self {
Information::User(info) => {
ChatCompletionRequestMessage::from(ChatCompletionRequestUserMessage::from(info.get_str())).into()
}
Information::Assistant(info) => {
ChatCompletionRequestMessage::from(ChatCompletionRequestAssistantMessage::from(info.get_str())).into()
}
}
}
}
pub struct UserInformation {
pub text: Arc<str>, // 改为 Arc<str>
pub tag: bool,
}
impl UserInformation {
pub fn new(text: &str) -> Self {
Self {
text: Arc::from(text), // Arc::from 是 O(1)
tag: false
}
}
pub fn with_tag(self) -> Self {
Self { text: self.text, tag: true } // Arc move,O(1)
}
pub fn without_tag(self) -> Self {
Self { text: self.text, tag: false }
}
pub fn get_str(&self) -> &str {
&self.text
}
}
pub struct AssistantInformation {
pub text: Arc<str>, // 改为 Arc<str>
pub tag: bool,
}
impl AssistantInformation {
pub fn new(text: &str) -> Self {
Self {
text: Arc::from(text),
tag: false
}
}
pub fn with_tag(self) -> Self {
Self { text: self.text, tag: true }
}
pub fn without_tag(self) -> Self {
Self { text: self.text, tag: false }
}
pub fn get_str(&self) -> &str {
&self.text
}
}
3.2.2 MergedInformation
struct MergedInformation {
content: Vec<ChatCompletionRequestMessage>,
previous_summary: Arc<str>, // 改为 Arc<str>
}
impl MergedInformation {
pub fn new() -> Self {
Self {
content: Vec::new(),
previous_summary: Arc::from("")
}
}
pub fn merge_summary(&mut self, content: &str) {
let new_summary = format!("{}{}", self.previous_summary, content);
self.previous_summary = Arc::from(new_summary);
}
pub fn get_previous_summary(&self) -> String {
self.previous_summary.to_string()
}
}
3.2.3 SlidingWindow
pub struct SlidingWindow {
window: Arc<RwLock<VecDeque<Information>>>, // 读写分离
capacity: usize,
tag_count: usize,
summary: Arc<RwLock<MergedInformation>>,
}
impl SlidingWindow {
pub fn new(capacity: usize) -> Self {
Self {
window: Arc::new(RwLock::new(VecDeque::with_capacity(capacity + 1))),
capacity,
tag_count: capacity,
summary: Arc::new(RwLock::new(MergedInformation::new())),
}
}
pub async fn push(&self, value: &str, role: &str, client: &LlmClient) -> Result<()> {
let text = Information::new(value, role);
let text = self.auto_tag(text);
{
let mut guard = self.window.write().unwrap();
guard.push_back(text);
if guard.len() == self.capacity + 1 {
drop(guard);
self.pop(client).await?;
}
}
Ok(())
}
async fn pop(&self, client: &LlmClient) -> Result<()> {
let target = {
let mut guard = self.window.write().unwrap();
guard.pop_front()
};
if let Some(value) = target {
if value.is_tagged() {
self.summarize(client).await?;
}
}
Ok(())
}
pub fn get_windows(&self) -> Arc<VecDeque<Information>> {
Arc::clone(&self.window) // O(1),只增加引用计数
}
pub fn len(&self) -> usize {
self.window.read().unwrap().len()
}
pub fn get_capacity(&self) -> usize {
self.capacity
}
pub fn get(&self, index: usize) -> Option<&Information> {
self.window.read().unwrap().get(index)
}
pub fn is_empty(&self) -> bool {
self.window.read().unwrap().is_empty()
}
pub fn clear(&self) {
self.window.write().unwrap().clear();
self.tag_count = 0;
}
fn auto_tag(&self, value: Information) -> Information {
self.tag_count += 1;
if self.tag_count >= self.capacity {
let tagged = value.with_tag();
self.tag_count = 0;
tagged
} else {
value
}
}
async fn merge(&self) {
let mut messages = self.summary.write().await;
let previous = ChatCompletionRequestUserMessage::from((*messages.previous_summary).into()).into();
messages.content.clear();
messages.content.push(ChatCompletionRequestSystemMessage::from(
"Based on the summary of previous conversation and the information currently in the window, provide a new overall summary.").into());
messages.content.push(previous);
let windows = self.window.read().unwrap();
for message in windows.iter() {
messages.content.push(message.to_message())
}
}
async fn summarize(&self, client: &LlmClient) -> Result<String> {
self.merge().await;
let mut summary_arc = self.summary.write().await;
let response = self.call_llm(client, &mut *summary_arc).await?;
Ok(response)
}
async fn call_llm(&self, client: &LlmClient, merged: &mut MergedInformation) -> Result<String> {
let response = client.call_llm(merged).await?;
let output = response.join(" ");
merged.merge_summary(&output);
Ok(output)
}
}
3.2.4 WorkingMemory
// src/memory/working_memory.rs
pub struct WorkingMemory {
state: WorkingState,
sliding_window: Arc<SlidingWindow>, // 改为 Arc 包装
memory_cluster: MemoryCluster,
records: HashMap<MemoryId, Record>,
}
impl WorkingMemory {
pub fn new(window_capacity: usize) -> Self {
Self {
state: WorkingState::Idle,
sliding_window: Arc::new(SlidingWindow::new(window_capacity)),
memory_cluster: MemoryCluster::new(),
records: HashMap::new(),
}
}
pub fn sliding_window(&self) -> Arc<SlidingWindow> {
Arc::clone(&self.sliding_window)
}
// ... 其他方法保持不变
}
四、Trait API 变更
4.1 RetrStrategy trait
pub trait RetrStrategy {
type Request: RetrRequest;
type Return; // 不再带生命周期参数
fn retrieve(&self, request: &Self::Request) -> Self::Return;
}
4.2 调用方使用方式
let working_mem: Arc<WorkingMemory> = Arc::new(WorkingMemory::new(10));
// tokio::spawn 中使用
tokio::spawn(async move {
let request = ShortOnlyRequest {
working_mem: working_mem.sliding_window(), // Arc::clone,O(1)
};
let result = strategy.retrieve(&request); // &request 是引用
// request 可立即 drop,但 result 独立存在
// result 是 Arc<VecDeque<Information>>,可在 task 间传递
});
五、MemoryCluster DashMap 改造(本期范围)
5.1 现状
MemoryCluster 内部使用 HashMap:
pub struct MemoryCluster {
graph: StableDiGraph<MemoryNote, GraphMemoryLink>,
mem_id_to_index: HashMap<MemoryId, NodeIndex>,
link_id_to_index: HashMap<LinkId, EdgeIndex>,
incompletely_linked_note: HashMap<MemoryId, Vec<(NodeIndex, MemoryLink)>>,
embedding_store: HashMap<MemoryId, MemoryEmbedding>,
}
5.2 目标
改为 DashMap 以支持并发读写:
use dashmap::DashMap;
pub struct MemoryCluster {
graph: StableDiGraph<MemoryNote, GraphMemoryLink>,
mem_id_to_index: DashMap<MemoryId, NodeIndex>,
link_id_to_index: DashMap<LinkId, EdgeIndex>,
incompletely_linked_note: DashMap<MemoryId, Vec<(NodeIndex, MemoryLink)>>,
embedding_store: DashMap<MemoryId, MemoryEmbedding>,
}
5.3 注意事项
DashMap的get/insert等操作返回Option<Ref<...>>或RefMut<...>,需要相应调整方法实现graph: StableDiGraph本身不是线程安全的,如果需要并发访问图结构,可能也需要加锁或改用其他方案- 视情况决定是否将
MemoryCluster也包装成Arc<MemoryCluster>
六、实施计划
Phase 1:Information 结构改造
-
UserInformation.text: String→text: Arc<str> -
AssistantInformation.text: String→text: Arc<str> - 添加
with_tag(self) -> Self和without_tag(self) -> Self方法 - 移除
tag_information(&mut self)和untag_information(&mut self) - 更新
MergedInformation.previous_summary: String→Arc<str> - 更新
merge_summary实现 - 更新
get_previous_summary返回String - 更新测试用例
Phase 2:SlidingWindow 结构改造
-
window: VecDeque<Information>→window: Arc<RwLock<VecDeque<Information>>> -
new(): 初始化Arc::new(RwLock::new(...)) -
push(): 改为&self+ write lock -
pop(): 改为&self+ write lock -
get_windows(): 返回Arc<VecDeque<Information>> -
len(): 通过 read lock -
get(): 通过 read lock -
is_empty(): 通过 read lock -
clear(): 通过 write lock -
auto_tag(): 改为&self,返回新实例 - 移除
get_mut_capacity()(capacity 不可变) - 更新测试用例
Phase 3:WorkingMemory 结构改造
-
sliding_window: SlidingWindow→sliding_window: Arc<SlidingWindow> -
sliding_window(): 返回Arc<SlidingWindow> - 移除
sliding_window_mut() - 更新测试用例
Phase 4:RetrStrategy trait 修改
- trait 定义:
retrieve接受&Self::Request,返回Arc<VecDeque<Information>> - 所有实现同步修改
- 更新调用方代码
Phase 5:MemoryCluster DashMap 改造
- 引入
dashmapcrate -
HashMap→DashMap(mem_id_to_index、link_id_to_index、incompletely_linked_note、embedding_store) - 调整相关方法实现以适配
DashMap的 API - 视情况决定
MemoryCluster是否需要Arc包装 - 更新测试用例
七、技术债务清单
| 项目 | 说明 |
|---|---|
| 零 clone 字符串 | Arc<str> 使 Information clone 变成 O(1) 操作 |
| 读写锁分离 | RwLock 允许并发读、互斥写 |
| API 设计合理化 | retrieve 接受引用而非消耗所有权 |
| MemoryCluster 并发化 | DashMap 支持并发读写 |
| capacity 不可变 | 移除 get_mut_capacity(),简化设计 |
八、风险与注意事项
-
Arc<RwLock<VecDeque>>的读写粒度:每次push/pop需要获取写锁,锁竞争可能影响性能。但push频率不高,应可接受。 -
MergedInformation.previous_summary的Arc改造:merge_summary现在需要创建新的Arc<str>,每次摘要都会产生新的 heap 分配。这是不可避免的,因为Arc不可变。 -
DashMap的 shard 数量:DashMap内部有多个 shard,过多或过少都会影响性能。默认通常足够,但如遇性能问题可调整。 -
StableDiGraph的线程安全:petgraph的StableDiGraph不是线程安全的。如果graph字段也需要并发访问,需要额外处理。 -
测试覆盖:改造后需要确保所有测试用例通过,特别是并发场景的测试。
九、相关文件清单
| 文件 | 改动 |
|---|---|
src/memory/working_memory/sliding_window.rs | Information、UserInformation、AssistantInformation、MergedInformation、SlidingWindow |
src/memory/working_memory.rs | WorkingMemory |
src/memory/memory_cluster.rs | MemoryCluster 改 DashMap |
src/memory.rs 或相关模块 | RetrStrategy trait 及实现 |
Cargo.toml | 添加 dashmap 依赖 |
十、附录:关键类型变更对照表
| 原类型 | 新类型 | 说明 |
|---|---|---|
String | Arc<str> | 字符串,clone O(1) |
&mut self | &self | 所有 SlidingWindow 方法 |
VecDeque<Information> | Arc<RwLock<VecDeque<Information>>> | 线程安全 |
SlidingWindow | Arc<SlidingWindow> | 可跨线程共享 |
HashMap | DashMap | 并发安全 HashMap |
fn retrieve(request: Self::Request) -> Self::Return<'_> | fn retrieve(&self, request: &Self::Request) -> Self::Return | API 合理化 |
十一、方案二:Actor 模式异步化方案(备选)
11.1 背景与约束
WorkingMemory将被包装在Arc<WorkingMemory>中,并克隆到多个 tokio task- tokio 是多线程异步环境,需要线程安全
MemoryCluster内部使用StableDiGraph(petgraph),不是Send + SyncSlidingWindow有异步 LLM 调用,但不需要访问MemoryCluster- consolidation 任务需要同时访问
SlidingWindow和MemoryCluster
11.2 架构设计
┌─────────────────────────────────────────────────────────────────┐
│ GraphActor (运行在独立 tokio task 中) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ GraphState (原 MemoryCluster 内部结构) │ │
│ │ - 通过 mpsc channel 接收命令 │ │
│ │ - 使用 spawn_blocking 处理 CPU-bound 操作 │ │
│ │ - 通过 oneshot 返回结果 │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
│ mpsc::Sender<GraphCommand>
▼
┌─────────────────────────────────────────────────────────────────┐
│ WorkingMemoryHandle (可 Clone,跨 task 共享) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ graph_handle: GraphHandle │ │
│ │ sliding_window: Arc<tokio::sync::RwLock<SlidingWindow>> │ │
│ │ records: Arc<tokio::sync::RwLock<HashMap<...>>> │ │
│ │ state: Arc<parking_lot::RwLock<WorkingState>> │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ // 协调操作(consolidation) │
│ pub async fn consolidate(&self, ...) -> Result<...> { │
│ let sw = self.sliding_window.read().await; │
│ let nodes = self.graph_handle.get_nodes(...).await?; │
│ // 协调 sliding_window 和 graph │
│ } │
└─────────────────────────────────────────────────────────────────┘
11.3 核心类型定义
11.3.1 GraphState(内部结构,原 MemoryCluster)
// memory_cluster.rs
struct GraphState {
graph: StableDiGraph<MemoryNote, GraphMemoryLink>,
mem_id_to_index: HashMap<MemoryId, NodeIndex>,
link_id_to_index: HashMap<LinkId, EdgeIndex>,
incompletely_linked_note: HashMap<MemoryId, Vec<(NodeIndex, MemoryLink)>>,
embedding_store: HashMap<MemoryId, MemoryEmbedding>,
}
11.3.2 GraphCommand 枚举
enum GraphCommand {
// 读操作
GetNode(MemoryId, oneshot::Sender<Option<MemoryNote>>),
GetEmbedding(MemoryId, oneshot::Sender<Option<MemoryEmbedding>>),
ContainsNode(MemoryId, oneshot::Sender<bool>),
HasEdge(LinkId, oneshot::Sender<bool>),
GetDirectedLinkedEdges(MemoryId, Direction, oneshot::Sender<Option<Vec<LinkId>>>),
GetAllLinkedEdges(MemoryId, oneshot::Sender<Option<Vec<LinkId>>>),
// 写操作
AddSingleNode(EmbeddedMemoryNote, oneshot::Sender<Result<NodeIndex>>),
Merge(Vec<EmbeddedMemoryNote>, oneshot::Sender<Result<()>>),
RemoveSingleNode(MemoryId, oneshot::Sender<Option<MemoryNote>>),
RefreshNode(MemoryId, oneshot::Sender<Result<()>>),
// 生命周期
Shutdown(oneshot::Sender<()>),
}
11.3.3 GraphActor
pub struct GraphActor {
receiver: mpsc::Receiver<GraphCommand>,
state: GraphState,
}
impl GraphActor {
pub async fn run(&mut self) {
while let Some(cmd) = self.receiver.recv().await {
self.handle_command(cmd).await;
}
}
async fn handle_command(&mut self, cmd: GraphCommand) {
match cmd {
GraphCommand::AddSingleNode(node, tx) => {
// spawn_blocking 包装 CPU-bound 操作
let result = tokio::task::spawn_blocking(move || {
let mut state = GraphState::new();
state.add_single_node(node);
Ok(())
}).await;
tx.send(result.unwrap()).ok();
}
GraphCommand::GetNode(id, tx) => {
let result = tokio::task::spawn_blocking(move || {
self.state.get_node(id).cloned()
}).await;
tx.send(result.ok()).ok();
}
// ... 其他命令类似
_ => {}
}
}
}
11.3.4 GraphHandle
#[derive(Clone)]
pub struct GraphHandle {
sender: mpsc::Sender<GraphCommand>,
}
impl GraphHandle {
pub async fn get_node(&self, id: MemoryId) -> Option<MemoryNote> {
let (tx, rx) = oneshot::channel();
self.sender.send(GraphCommand::GetNode(id, tx)).await.ok()?;
rx.await.await.ok()?
}
pub async fn contains_node(&self, id: MemoryId) -> bool {
let (tx, rx) = oneshot::channel();
self.sender.send(GraphCommand::ContainsNode(id, tx)).await.ok()?;
rx.await.await.unwrap_or(false)
}
pub async fn get_embedding(&self, id: MemoryId) -> Option<MemoryEmbedding> {
let (tx, rx) = oneshot::channel();
self.sender.send(GraphCommand::GetEmbedding(id, tx)).await.ok()?;
rx.await.await.ok()?
}
pub async fn add_single_node(&self, node: EmbeddedMemoryNote) -> Result<NodeIndex> {
let (tx, rx) = oneshot::channel();
self.sender.send(GraphCommand::AddSingleNode(node, tx)).await??;
rx.await??.map_err(Into::into)
}
pub async fn merge(&self, nodes: Vec<EmbeddedMemoryNote>) -> Result<()> {
let (tx, rx) = oneshot::channel();
self.sender.send(GraphCommand::Merge(nodes, tx)).await??;
rx.await??
}
pub async fn remove_single_node(&self, id: MemoryId) -> Option<MemoryNote> {
let (tx, rx) = oneshot::channel();
self.sender.send(GraphCommand::RemoveSingleNode(id, tx)).await.ok()?;
rx.await.await.ok()?
}
pub fn new() -> (Self, GraphActor) {
let (tx, rx) = mpsc::channel(1024);
let handle = Self { sender: tx };
let actor = GraphActor::new(rx);
(handle, actor)
}
}
11.3.5 WorkingMemoryHandle
// working_memory.rs
use tokio::sync::RwLock;
use std::sync::Arc;
pub struct WorkingMemoryHandle {
graph_handle: GraphHandle,
sliding_window: Arc<RwLock<SlidingWindow>>,
records: Arc<RwLock<HashMap<MemoryId, Record>>>,
state: Arc<parking_lot::RwLock<WorkingState>>,
}
impl WorkingMemoryHandle {
pub fn new(window_capacity: usize) -> Self {
// 启动 GraphActor
let (graph_handle, graph_actor) = GraphHandle::new();
tokio::spawn(async move { graph_actor.run().await });
Self {
graph_handle,
sliding_window: Arc::new(RwLock::new(SlidingWindow::new(window_capacity))),
records: Arc::new(RwLock::new(HashMap::new())),
state: Arc::new(parking_lot::RwLock::new(WorkingState::Idle)),
}
}
// 状态
pub async fn transition_to_working(&self) {
*self.state.write() = WorkingState::Working;
}
pub async fn transition_to_idle(&self) {
*self.state.write() = WorkingState::Idle;
}
pub fn state(&self) -> WorkingState {
*self.state.read()
}
// Graph 操作(委托给 GraphHandle)
pub async fn add_node(&self, node: EmbeddedMemoryNote) -> Result<NodeIndex> {
let node_id = node.note().id();
let result = self.graph_handle.add_single_node(node).await?;
self.records.write().await.insert(node_id, Record::new(node_id));
Ok(result)
}
pub async fn remove_node(&self, node_id: MemoryId) -> Option<MemoryNote> {
self.records.write().await.remove(&node_id);
self.graph_handle.remove_single_node(node_id).await
}
pub async fn get_node(&self, id: MemoryId) -> Option<MemoryNote> {
self.graph_handle.get_node(id).await
}
pub async fn contains_node(&self, id: MemoryId) -> bool {
self.graph_handle.contains_node(id).await
}
pub async fn merge(&self, nodes: Vec<EmbeddedMemoryNote>) -> Result<()> {
for node in &nodes {
let node_id = node.note().id();
self.records.write().await.insert(node_id, Record::new(node_id));
}
self.graph_handle.merge(nodes).await
}
// SlidingWindow 操作
pub async fn sliding_window(&self) -> Arc<RwLock<SlidingWindow>> {
self.sliding_window.clone()
}
// Record 操作
pub async fn record_retrieval(&self, node_id: MemoryId) {
let mut records = self.records.write().await;
if let Some(record) = records.get_mut(&node_id) {
record.record_retrieval();
} else {
let mut record = Record::new(node_id);
record.record_retrieval();
records.insert(node_id, record);
}
}
// Consolidation - 协调两个子系统
pub async fn consolidate(&self, client: &LlmClient) -> Result<ConsolidationResult> {
// 示例协调模式
let window_snapshot = self.sliding_window.read().await.clone();
let relevant_nodes = self.graph_handle.get_all_linked_edges(/* ... */).await?;
// 协调 sliding_window 和 graph
todo!()
}
}
11.4 设计决策
| 决策点 | 选择 | 理由 |
|---|---|---|
| 所有操作都通过 Actor | 是 | 保证一致性,避免读写冲突 |
| CPU-bound 操作 | spawn_blocking | 避免阻塞 async runtime |
| SlidingWindow 和 records | Arc<RwLock<>> | 独立组件,LLM 调用是 I/O-bound |
| GraphHandle | 只通过 channel 通信 | StableDiGraph 不是 Send + Sync |
11.5 与方案一(DashMap)的对比
| 维度 | 方案一 (DashMap) | 方案二 (Actor) |
|---|---|---|
| 复杂度 | 较低,改动较小 | 较高,需要 actor 模式 |
| 一致性 | 需要额外同步 | Actor 天然序列化 |
| 并发读 | DashMap 支持 | 所有操作序列化 |
| petgraph 线程安全 | 仍需处理 | 不需要,graph 在单 task 内 |
| 适用场景 | 读多写少 | 写不频繁 |
| consolidation 协调 | 需要额外锁 | 通过 handle 协调 |
11.6 风险与注意事项
-
Actor 单点瓶颈:如果写操作非常频繁,actor 的序列化可能成为瓶颈。但根据用户描述,写操作(merge、add_single_node)不频繁。
-
spawn_blocking 的使用:CPU-bound 的图操作通过
spawn_blocking托付给阻塞线程池,保持 async runtime 的响应性。 -
SlidingWindow 独立锁:SlidingWindow 的 LLM 调用不会阻塞 GraphActor,两者可并行。
-
consolidation 的原子性:如果 consolidation 需要原子地访问 graph 和 sliding_window,可能需要额外的协调机制。
11.7 实施计划
Phase 1:memory_cluster.rs 重构
- 将
MemoryCluster重命名为GraphState(内部结构) - 定义
GraphCommand枚举 - 实现
GraphActor - 实现
GraphHandle - 保留
MemorySubCluster(同步视图) - 重写测试用例
Phase 2:working_memory.rs 重构
- 实现
WorkingMemoryHandle - 将
SlidingWindow和records包装为Arc<RwLock<>> - 所有方法改为 async
- 实现
consolidate()方法 - 重写测试用例
Phase 3:调用方更新
- 更新所有使用
WorkingMemory的地方 - 将
.await添加到异步方法调用 - 移除
memory_cluster_mut()调用
11.8 相关文件变更
| 文件 | 变更 |
|---|---|
src/memory/memory_cluster.rs | MemoryCluster → GraphState,新增 GraphCommand、GraphActor、GraphHandle |
src/memory/working_memory.rs | 新增 WorkingMemoryHandle,async 化所有方法 |
| 其他调用方文件 | 添加 .await,使用 WorkingMemoryHandle |
PPR算法性能对比分析报告
概览
本项目对Power Iteration和Forward Push两种PPR(Personalized PageRank)算法进行了性能对比。以下是详细的分析结果。
测试环境
- 小型图: 20个节点(基本测试)
- 中等规模图: 100, 300, 500个节点
- 大规模图: 1000, 2000, 3000个节点
- 源节点: 前3-5个节点作为个性化向量源
- 采样次数: 每个benchmark采样10次
- 硬件环境: Windows系统,标准开发配置
基础性能对比
Power Iteration (15次迭代) vs Forward Push (1e-4阈值)
| 算法 | 平均执行时间 | 性能对比 |
|---|---|---|
| Power Iteration | 53.25 µs | 基准 |
| Forward Push | 1.94 µs | 快27.4倍 |
结论:Forward Push算法在所有图规模下都显著快于Power Iteration算法
大规模图性能分析
中等规模图性能 (100-500节点)
| 图规模 | Power Iteration (10次迭代) | Forward Push (1e-4阈值) | 性能倍数 |
|---|---|---|---|
| 100节点 | 1.18 ms | 6.68 µs | 177倍 |
| 300节点 | 13.57 ms | 18.33 µs | 740倍 |
| 500节点 | 38.19 ms | 33.67 µs | 1134倍 |
观察:随着图规模增大,Forward Push的优势更加显著
大规模图性能 (1000-3000节点)
| 图规模 | Forward Push (1e-4阈值) | 时间增长趋势 |
|---|---|---|
| 1000节点 | 68.81 µs | 基准 |
| 2000节点 | 164.94 µs | 2.4倍 |
| 3000节点 | 280.91 µs | 4.1倍 |
观察:Forward Push的执行时间与图规模呈近似线性增长关系
参数敏感性分析
1. Power Iteration迭代次数影响
| 迭代次数 | 执行时间 | 与5次迭代的倍数 |
|---|---|---|
| 5次 | 13.39 µs | 1.0x |
| 10次 | 25.00 µs | 1.87x |
| 15次 | 37.27 µs | 2.78x |
| 20次 | 48.85 µs | 3.65x |
观察:Power Iteration的执行时间与迭代次数呈线性增长关系
2. Forward Push残差阈值影响
| 阈值 | 执行时间 | 与0.001阈值的倍数 |
|---|---|---|
| 0.001 | 1.80 µs | 1.0x |
| 0.0001 | 1.92 µs | 1.07x |
| 0.00001 | 2.12 µs | 1.18x |
观察:Forward Push的性能对阈值较不敏感,阈值越小执行时间略有增加
3. 阻尼因子影响
Power Iteration算法 (15次迭代)
| 阻尼因子 | 执行时间 | 波动范围 |
|---|---|---|
| 0.1 | 37.31 µs | -2% ~ +0.4% |
| 0.3 | 37.06 µs | -3.8% ~ +0.6% |
| 0.5 | 37.86 µs | -1.2% ~ +5 extrem5% |
| 0.7 | 37.07 µs | -8.1% ~ -2.4% |
Forward Push算法 (1e-4阈值)
| 阻尼因子 | 执行时间 | 波动范围 |
|---|---|---|
| 0.1 | 1.87 µs | -2.9% ~ +1.3% |
| 0.3 | 2.17 µs | -14.7% ~ -10.2% |
| 0.5 | 2.91 µs | -0.6% ~ +7.1% |
| 0.7 | 4.03 µs | -5.5% ~ +0.5% |
观察:
- Power Iteration对不同阻尼因子表现稳定
- Forward Push在高阻尼因子(0.7)时执行时间显著增加
4. 图规模对算法扩展性的影响
Power Iteration扩展性趋势:
- 100节点: ~1.2ms
- 300节点: ~14.8ms (增长约12倍)
- 500节点: ~41.7ms (增长约35倍)
Forward Push扩展性趋势:
- 100节点: ~6.7µs
- 300节点: ~19.1µs (增长约3倍)
- 500节点: ~31.6µs (增长约5倍)
- 1000节点: ~67.4µs (增长约10倍)
- 2000节点: ~165.9µs (增长约25倍)
- 3000节点: ~250.9µs (增长约37倍)
重要发现: Forward Push的扩展性远优于Power Iteration,时间复杂度更低
算法原理对比
Power Iteration算法
工作原理:
- 初始化每个节点的PPR值
- 重复迭代:PPR = α × 转移矩阵 × PPR + (1-α) × 个性化向量
- 直到收敛或达到最大迭代次数
时间复杂度: O(k|E|),其中k为迭代次数,|E|为边数 实际扩展性: 测试显示时间复杂度远超线性增长
Forward Push算法
工作原理:
- 初始化残差向量和保留向量
- 对残差大于阈值的节点进行“push“操作
- 将部分残差转移到邻居节点
- 重复直到所有节点残差低于阈值
时间复杂度: 通常为O(1/ε),ε为阈值,与图结构相关 实际扩展性: 测试显示近似线性增长,显著优于Power Iteration
算法特点总结
Power Iteration算法
- 优点:
- 实现简单,易于理解和调试
- 收敛性有理论保证
- 结果精确可控
- 缺点:
- 时间复杂度较高
- 内存占用较大
- 收敛速度可能较慢
- 适用场景: 需要精确结果,图规模较小的情况
Forward Push算法
- 优点:
- 时间复杂度通常远小于Power Iteration
- 适用于大规模稀疏图
- 可根据精度要求灵活调整
- 缺点:
- 实现相对复杂
- 可能存在精度损失
- 调试相对困难
- 适用场景: 大型稀疏图,对近似结果可接受的情况
推荐使用指南
1. 按图规模选择
- 小型图(节点数<100): 两种算法均可,Forward Push快20-50倍
- 中型图(100-1000节点): 强烈推荐Forward Push(快100-1000倍)
- 大型图(>1000节点): 必须使用Forward Push(Power Iteration可能无法完成)
2. 按精度要求选择
- 高精度要求: 使用Power Iteration并增加迭代次数
- 一般精度要求: Forward Push配合适当阈值
- 实时应用: 优先选择Forward Push
3. 按计算资源选择
- 内存受限: Forward Push(内存使用更高效)
- CPU受限: 根据图稀疏性选择
- 时间敏感: Forward Push
性能优化建议
Forward Push优化
- 阈值调整: 根据精度需求选择合适的残差阈值
- 缓存优化: 重用边权重计算结果
- 并行化: 考虑多节点并行push操作
Power Iteration优化
- 迭代次数: 根据收敛情况动态调整迭代次数
- 稀疏矩阵: 使用稀疏矩阵存储提高效率
- 预处理: 预计算转移矩阵
- 大规模图限制: 不建议在超过500节点的图上使用
Forward Push优化
- 阈值调整: 大规模图可使用更高阈值
- 并行化: 大规模图适合并行计算
- 内存优化: 使用稀疏数据结构
- 增量计算: 支持图更新的增量PPR计算
测试局限性
- 图规模: 测试涵盖了20到3000节点的各规模图
- 图结构: 测试图结构相对简单,复杂结构需进一步测试
- 硬件差异: 不同硬件环境下结果可能有所差异
- 极限测试: 3000节点以上规模的测试结果外推
未来工作
- 超大规模测试: 5000-10000节点规模的性能评估
- 结构复杂性: 测试不同图结构(密集/稀疏,有向/无向)
- 混合策略: 探索Power Iteration与Forward Push的混合使用
- 内存优化: 针对大规模图的更有效内存利用策略
结论
Forward Push算法在本测试中表现出极其显著的性能优势:
小型图: 快约27倍 中等图: 快200-1100倍 大型图: 必须使用Forward Push(Power Iteration无法实用)
关键结论
- 规模效应: 图规模越大,Forward Push的优势越明显(从27倍到1100倍)
- 实用性: 对于100节点以上的图,Forward Push就显示出明显优势
- 扩展性: Forward Push的扩展性显著优于Power Iteration
- 稳定性: Forward Push在不同参数下表现更加稳定
应用建议
- 小型应用: 可根据精度要求选择算法
- 中型应用: 强烈推荐Forward Push
- 大型应用: Forward Push是必须的选择
- 实时系统: 优先考虑Forward Push的快速响应特性
两种算法各有优势,但Forward Push在大规模场景下展现出压倒性的性能优势,是现代图算法应用的推荐选择。