SoulMem是一个专为角色扮演任务设计的记忆系统,它旨在使LLM的输出更拟人化成为可能,让模拟角色像人一样记住重要的、情感相关的、可驱动行为的事,它不旨在精确地记忆细节,知识。
SoulMem是针对于个人用户,在家用电脑上运行的记忆系统,并非企业级解决方案。
SoulMem的alpha版本中,采用统一的MemoryNote作为记忆节点,导致后续实现概念性,程序性记忆时,非常困难,故调整架构。
SoulMem本质是一个复杂的RAG系统,它结合了向量搜索和知识图谱,以图的方式组织记忆数据。SoulMem被设计具有以下能力:
- 记忆的整合与进化
- 记忆的遗忘
- 基于工作记忆子图的记忆联想(Personalized PageRank)
- 记忆的动态添加
SoulMem使用rust开发,使用async-openai进行LLM API调用,使用SurrealDB作为向量,图,时间序列数据库。
注:SurrealDB是一个多模态数据库,没有提供一些有用的算法,但对于我们当前的场合应当够用,只使用一个数据库,我们换来的是开发时不需要维护数据一致性的巨大便捷,以及家用电脑上不需要同时加载多个数据库的大量内存开销。
记忆整体上仍以图的方式组织。
总记忆图可以分为三类子图
- 情境记忆子图(Episodic)
- 语义记忆子图(Semantic)
- 程序性记忆子图(Procedural)
graph LR
情境记忆 <--> 语义记忆
语义记忆 <--> 程序性记忆
情境记忆 <--> 程序性记忆
情境记忆主要用于记忆具体的事件经历,例如“在昨天的中午12:00和同学出去吃了顿麻辣烫,自己被辣喷了”这类型的描述就属于情境记忆。
情境记忆节点分为两类:
- 具体情境节点
- 抽象情境节点
情境记忆节点具有以下属性:
- narrative
- 情境的自然语言描述
- time_span
- 情境的起止时间
- context
- 情境发生的上下文
对于context,有以下属性:
- Option
- 情境发生的位置
- Vec
- 情境发生时,参与的对象
- 对象可以是自己,他人,物体或其他(就是暂时不知道怎么分类的东西)
- 情境发生时,参与的对象
- Vec
- 情境发生时,自己当时的情感
- 情感具有标签分类和强度两个属性
- 情境发生时,自己当时的情感
- Vec
- 情境发生时的感官描述
- situation
- 情境发生时,具体上下文(背景)的自然语言描述
具体情境记忆在SurrealDB中,还建立时间序列,这意味着允许通过时间段来回忆对应时间段的情境记忆
抽象情境节点总括一类具体的情境,例如“在雨天散步”,“生日聚会”就属于抽象情境,这些情境的描述都可以被具象化,例如“在昨天和同学在操场雨中乐走”。
抽象情境节点与它的所有对应具象节点建立联系,充当二级索引,以及子图间联系的重要接口节点。然而,具象情境节点也可能直接与外部建立联系。
语义记忆子图主要用于记忆通用的概念和事实,关系等,例如“北京是中国的首都”,“原神是mihoyo旗下的一款游戏”,“张三是李四的好友”,“王五是某角色激推”都属于此类型的记忆。
语义记忆以传统知识图谱的方式组织,是总记忆图的中枢核心,承担类似海马体索引的功能。语义记忆节点本身就是接口节点。
语义记忆节点具有如下属性:
- content
- 具体的概念,事实内容,通常为词汇或词组
- Vec
- 别名,用于去重,消歧,保证图谱的精简
- concept_type
- 概念类型,Entity(具象的人,物品,如“张三”,“原神”),或Abstract(抽象概念,如“自由”,“意义”,“生命”等)
- description
- 一些更具体的限定描述,以自然语言组织
语义记忆的边具有如下属性
- verb
- 谓词,表明节点间的具体关系
- intensity
- 连接强度
- confidence
- 连接置信度
- (以下待商讨确定)
程序性记忆主要存储“肌肉记忆”,“条件反射”, “行为习惯”这一类的执行相关的记忆,例如“沉思时会摸下巴”,“听到铃声就害怕的蜷起来”, “明明很关心对方嘴上却强调不在乎(傲娇)”都属于程序性记忆。
程序性记忆中有节点类型的区分,主要分为:
-
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注:
目前暂时确定为,把有过更新的工作记忆直接写入数据库,别的什么都不干,后续再说
(以下设计有待进一步讨论)
代数公式 $$ 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游走算法公式
- 编写基础数据架构