查看原文
其他

【老万】从0开始学chatGPT(十一):动手实现长期记忆

老万 老万故事会
2024-08-23

本文是《从0开始学chatGPT》系列第十一篇,欢迎按需阅读:


上一次我们用 100 倍的放大镜观察了 chatGPT 中 transformer 的实现细节,通过潘金莲在武大郎、武松、西门庆三人中择偶的案例对 transformer 用到的各种矩阵运算做了直观的解释。至此,我们对“chatGPT 是怎么读懂人类语言的”这个问题总算是有了入门级的了解。

不过,要真正理解一门电脑技术,最后一定要落实到代码上,否则就是隔靴搔痒,沦为光说不练的大忽悠。今天,我们就一起活动活动筋骨,编程把前面学习过的知识都串联起来。

做点什么好呢?

我们知道,chatGPT 这样的自回归语言模型有一个固有的缺陷:它们只能记得极其有限的上文。

这主要是因为 transformer 架构的自注意力机制需要 N 平方量级的计算(N 是模型能记得的历史窗口中的记号个数):随着 N 的增长,计算量会飞速上升,所以历史窗口不能太大。

平方级增长的计算量

好在,有办法借外力弥补 chatGPT 这一缺陷。好记性不如烂笔头,我们可以把对话历史另存在一个数据库中,对话时从数据库中取出相关的历史记忆,塞到给 GPT 的提示里,就能减轻它的健忘症。

这就像电影《记忆碎片》中的蓝诺,虽然头部受创不能形成长期记忆,但他借笔记、文身、拍照等外挂的帮助保存信息,从来不放弃追寻杀妻凶手的任务。

蓝诺通过狂做笔记扩展自己的记忆力

今天我们不妨就试着把长期记忆从头到尾实现一遍,攒出一个增强版的能记事的 chatGPT。

不要问 AI 能为你做什么。
问问你能为 AI 做什么!

~~ 向量数据库 ~~

我们需要把对话历史保存在一个数据库中,按需索取。

这个数据库需要能按语义接近的程度查询。

比如,我们在和 GPT 聊电视剧《漫长的季节》时,会希望它能优先调取跟东北相关的记忆,聊到点上:“某年某月某日,沈阳某厂下岗职工某彪卖茶叶蛋和城管发生冲突,碎了两个蛋”这条记忆对正在进行的聊天可能是有用的,而“某年某月某日,《老万故事会》首发《从 0 开始学chatGPT》系列喜大普奔”就不太相干。

如何实现按语义检索呢?

传统的关系型数据库(SQL 数据库)和 NoSQL 数据库都不太灵,因为它们没有“语义”的概念,只会做简单的匹配和比较。

向量数据库(vector database)在大模型时代应运而生,它可以比较好地支持语义检索。顾名思义,向量数据库保存的是文本的嵌入向量(embedding vector)

我们以前学过,嵌入向量是一个高维(一般有几百到几千维)实数向量,代表了文字的真实意思。它有一个神奇的特点:意思越接近的文字,它们的嵌入向量越接近,反之亦然。

普通的数据库也能存储向量,但是向量数据库有一个大杀器:它支持按向量近似度查询。比如数据库里存了 n 条向量 V1,V2,...,Vn,我们可以问它:跟向量 Q 最接近的前 k 名向量是哪些?

向量数据库可能回答:报告主人,最接近 Q 的 k 个向量依次是:第一名 V103(近似度 0.876),第二名 V21(近似度 0.832),...,第 k 名 V251(近似度 0.529)。

除了向量本身,我们还可以在每条记录中保存一些整数、浮点数、字符串等类型的数据,叫做元数据(metadata)

用向量数据库保存记忆的思路是:把记忆内容拆成碎片,再把每片的嵌入向量(embedding vector)作为一条记录存在数据库里,而记忆内容本身和其它相关信息可以作为这条记录的元数据(metadata)一并保存。比如:

在上图的记忆向量数据库例子里,每条记录有这么一些字段:

  1. id:记录独一无二的(unique)名字。一条记录只能有一个名字。反之亦然:一个名字只能有一条记录。所以,用 id 可以唯一确定一条记录。为了人类用户方便,这里我们用记忆生成的时间做 id,这样一看便知是什么时候的事。

  2. vector:浮点数向量。这是数据库存储的主要内容。同一张表里的所有向量必须有相同的维数。这个维数是在建表的时候就确定的。

  3. 元数据(metadata):根据应用需求保存的附加数据。我们除了可以读取这些数据,还可以用它们来过滤查询结果。比如,我们把记忆文本存在 memory 字段,这样在读取一条记录时可以得到对应的原始记忆,而 time 字段记录的是这条记忆的时间戳,和 id 里面表示时间的字符串等价,只不过是用一个整数来表示。之所以重复 id 中已有的信息,是因为 id 字段不能用来过滤查询结果。比如我们要查某年某月的某一天的记忆,就可以通过限定 time 字段的范围进行。


提取记忆时,我们先算出对话主题的嵌入向量,叫查询向量,然后让数据库快速找到和查询向量最接近的那些嵌入向量,也就是和主题匹配度最高的记忆。

~~ 准备工作 ~~

现在就开始编程吧!


这篇文章假定读者有一定的 Python 编程基础,而且已经设置好了 Python 开发环境。其它准备工作还有几步,不过没关系,老万会一步一步带你做下来。


开通 OpenAI API


要给 chatGPT 加外挂,必须要使用 OpenAI 的付费 API。所以请先到 https://openai.com/ 开通一个 OpenAI 账号,绑定自己的信用卡。然后到

https://platform.openai.com/account/api-keys 去生成一个 API Key(秘钥),自己记在一个可靠的地方,不要让别人看见 - 谁要是知道了这个秘钥,就可以刷你的卡用 OpenAI API。

OpenAI 秘钥管理网站界面

开通 Pinecone API

提供向量数据库服务的商家很多。老万选的是 Pinecone(松果),因为它对每个用户提供一个免费的数据库实例,做我们的实验够用了。

如果你还没有松果的免费账号,快去 https://www.pinecone.io/ 创建一个吧。如果你已经有谷歌账号,也可以用谷歌账号登录。


建好松果账号后,点左方的 API Keys,就可以看到系统自动为我们创建的叫 default 的 API 秘钥,我们用它就行了。点一下 Copy Key Value 的图标,把秘钥拷贝出来。


记住 Environment 值。这是部署数据库的生产环境,以后编程访问数据库的时候用得着。在这个例子里,环境是 northamerica-northeast1-gcp,但 Pinecone 分配给你的环境可能不同。

下载项目代码

老万已经把本文用到的全部代码开源上传到了 github,取名 chatgpt-mem,大家可以随便使用。

首先我们得在硬盘上找个地方克隆 chatgpt-mem 代码仓库。假设我们想把 它装在当前用户的 ~/GitHub 目录下,请在苹果电脑的 Terminal 窗口执行以下命令:


  • cd ~

  • mkdir GitHub

  • cd GitHub

  • git clone https://github.com/zhanyong-wan/chatgpt-mem

  • cd chatgpt-mem


创建虚拟环境


要编程控制 Pinecone 和 GPT,先得装上 Pinecone 和 OpenAI 的 Python 客户端。因为 AI 技术秒新分异,不建议大家把这些工具库装到全局环境中。最好建一个 Python 虚拟环境(virtual environment),这样把环境改坏了也没关系。


所谓 Python 虚拟环境,就是选一个子目录,在里面放上一份 Python 解释器和工具库。进入虚拟环境后,我们只会用到这个子目录下的 Python,不会影响全局环境里的 Python 系统,也不会被它影响。我们甚至可以在同一台机器上建立多个 Python 虚拟环境,互不干扰。


要为我们的项目创建一个虚拟环境,请执行:


  • cd ~/GitHub/chatgpt-mem

  • python3 -m venv env

    • -m venv 命令创建一个新的虚拟环境。

    • 最后一个参数( env )是用于存放虚拟环境文件的子目录名。所以,上面的命令会建立一个 ~/GitHub/chatgpt-mem/env 目录用来放虚拟环境的系统文件。记得不要自己改动这个目录里的任何文件。

    • 因为 ~/GitHub/chatgpt-mem/.gitignore 文件里已经有一行 env/ 了,Git 会自动忽略这个子目录,不会试图把里面的东西加进代码仓库。

  • 最后,激活虚拟环境:source env/bin/activate


现在你应该看到命令行提示符的前面多了 (env) 几个字符。这表示虚拟环境已经成功激活了。从现在起,你在这个环境下安装的任何 Python 工具库都会被放在 env/ 子目录下,不会影响全局的 Python 环境。

为保险起见,你可以查看一下当前的 Python 解释器是哪一个:


$ which python3

/Users/USERNAME/GitHub/chatgpt-mem/env/bin/python3


果不其然,用的是虚拟环境里的那个。


如果你新开一个 terminal 窗口,你可能会发现自己不在虚拟环境里了。没关系,只要运行 source env/bin/activate 就可以回到虚拟环境。


安装API客户端


现在可以在虚拟环境里装上 pinecone 和 OpenAI 的 API 了:


  • pip3 install pinecone-client

  • pip3 install openai

  • pip3 install tiktoken

  • pip install urllib3==1.26.6


创建向量数据库

接下来我们为 chatgpt-mem 项目创建一个向量数据库实例。在 Pinecone 的术语里,向量数据库的基本单位是一个索引(index),相当于 SQL 数据库的一堆表(tables)

我们可以手动或者用代码创建一个索引。因为是第一次操作,我们手动做一次,加深直观印象。


登录 https://www.pinecone.io/,点“Create your first Index”:


指定索引参数:

  • 名字写 chatgpt-mem

  • 维数(Dimensions)必须和 ChatGPT 嵌入向量的维数吻合,请选 1536

  • Metric 是计算向量近似度的算法,请选 cosine,也就是两个向量夹角的余弦值。


然后点击“Create Index”。


耐心等待几分钟,索引就建好了。

可以看到索引的全名是 chatgpt-mem-15815a6.svc.northamerica-northeast1-gcp.pinecone.io,生产环境还是以前看到的 northamerica-northeast1-gcp

因为我们是免费用户,只能建一个索引,而且七天不用就会被删除。切记过几天就用程序访问一下索引哦。

设置 API 秘钥

接下来我们要把自己的 API 秘钥和环境参数告诉 chatgpt-mem。

把 src/api_secrets.py.template 复制到 src/api_secrets.py,然后把其中的 ??? 换成你的 OpenAI API 秘钥,PineCone API 秘钥和环境名,保存文件就好了。

比如,文件内容可能是这样(建议横屏阅读):

OPENAI_API_KEY = "sk-..." # Replace the value with your OpenAI API key.PINECONE_API_KEY = "f3123e47-..." # Replace the value with your PineCone API Key.PINECONE_ENVIRONMENT = "northamerica-northeast1-gcp" # Replace the value with your PineCone deployment environment.

~~ 编程实现 ~~

现在步入正题:用 Python 编程为 chatGPT 加上长期记忆。

OpenAI 和 Pinecone 的 API 都很简单,官方文档也介绍得比较清楚,大家可以到 https://platform.openai.com/docs/introduction 和 https://docs.pinecone.io/docs/overview 去按需阅读。

第一步我们先确保向量数据库的设置是正常的。大家可以运行

$ src/main.py init

来初始化环境。这条命令会调用 src/utils.py 文件中的 init_environment() 函数。我们来打开这个文件看看它是怎么实现的。

它首先导入了几个工具库,还定义了一堆常数(建议横屏阅读代码):

import api_secretsimport openaiimport pineconeimport sys
# Number of dimensions in the GPT embedding space.OPENAI_GPT_EMBEDDING_DIMENSION = 1536# OpenAI model for computing text embeddings.OPENAI_TEXT_EMBEDDING_MODEL = "text-embedding-ada-002"
# Name of the PineCone index for storing memories.PINECONE_INDEX = "chatgpt-mem"# Dimension of the PineCone index for storing memories.PINECONE_INDEX_DIMENSION = OPENAI_GPT_EMBEDDING_DIMENSION# How to measure the distance between two vectors.PINECONE_INDEX_METRIC = "cosine"
  • OPENAI_GPT_EMBEDDING_DIMENSION 是 GPT 嵌入向量的维数。

  • OPENAI_TEXT_EMBEDDING_MODEL 是用来计算嵌入向量的模型名称。

  • PINECONE_INDEX 是数据库索引名。

  • PINECONE_INDEX_DIMENSION 是数据库中向量的维数。因为存储的是嵌入向量,它必须等于嵌入向量的维数。

  • PINECONE_INDEX_METRIC 是向量数据库计算向量相似度的算法。


然后是 init_environment() 的定义。简化后的代码如下:

def init_environment(): """Initializes the environment for working with the OpenAI API and PineCone API."""
# Specify the API keys. openai.api_key = api_secrets.OPENAI_API_KEY pinecone.init( api_key=api_secrets.PINECONE_API_KEY, environment=api_secrets.PINECONE_ENVIRONMENT, )
# Verify that the chatgpt-mem index exists in # PineCone, creating one if necessary. active_indexes = pinecone.list_indexes() if PINECONE_INDEX not in active_indexes: print( f"Didn't find the {PINECONE_INDEX} index. " "Creating it now (this may take several minutes).", file=sys.stderr, ) try: pinecone.create_index( name=PINECONE_INDEX, dimension=PINECONE_INDEX_DIMENSION, metric=PINECONE_INDEX_METRIC, ) except pinecone.ApiException as e: sys.exit(f"Failed to create the {PINECONE_INDEX} index: {e}") print(f"Created the {PINECONE_INDEX} index.", file=sys.stderr) active_indexes = pinecone.list_indexes() assert PINECONE_INDEX in active_indexes

可以看出,这个函数做的事情包括:

  1. 用秘钥和环境参数初始化 OpenAI 和 Pinecone  的客户端。

  2. 如果 chatgpt-mem 索引不存在,就用 pinecone.create_index() 函数创建一个新的索引。创建时需要指定索引名、向量维数和近似度算法。


如果 src/main.py init 运行正常,我们就可以进行第二步:计算文字的嵌入向量。比如:

$ src/main.py embed "Hello, world."

这条命令将计算“Hello, world.”这段文字的嵌入向量。它的输出可能是这样的:

Text: Hello, world.Embedding: [-0.002251409227028489, -0.0023393549490720034, -0.020620862022042274, -0.03064986690878868, -0.0005272743292152882, 0.0027726872358471155, -0.02722158469259739, -0.004765056539326906, -0.0066263070330023766, -0.01877880096435547, 0.021784942597150803, 0.006504782009869814, -0.02088949643075466, -0.011532076634466648, 0.010585461743175983, -0.0023073747288435698, ...]

embed 命令的实现是这样的:

def to_embedding(text: str) -> List[float]: """Converts the text to its embedding vector using OpenAI API."""
result = openai.Embedding.create( input=[text], model=OPENAI_TEXT_EMBEDDING_MODEL) data = result["data"] embedding = data[0]["embedding"] assert len(embedding) == OPENAI_GPT_EMBEDDING_DIMENSION    return embedding

它就是简单地调用 openai.Embedding.create() 函数用 text-embedding-ada-002 模型把文字转换成嵌入向量。

然后我们来实现朝数据库中添加一条记录。首先,定义一些新的常数:

# All memories will be stored in this namespace.PINECONE_INDEX_NAMESPACE = "memories"# Key for storing the memory time.PINECONE_INDEX_METADATA_KEY_MEMORY_TIME = "time"# Key for storing the memory text.PINECONE_INDEX_METADATA_KEY_MEMORY_TEXT = "memory"# Key for storing the memory importance score (1-10).PINECONE_INDEX_METADATA_KEY_MEMORY_IMPORTANCE = "importance"
  • 同一个索引内的记录可以分成多个名字空间(namespaces),每个名字空间可以看做一张独立的表。我们的项目只需要一张表,所以所有记录都放在 memories 名字空间里。

  • PINECONE_INDEX_METADATA_KEY_* 是元数据字段的名字。如前面的例子,我们用到了三个字段:time 是记忆的 UNIX 时间戳,用一个整数表示(单位是微秒);memory 是代表记忆内容的字符串;importance 是记忆的重要程度(1 到 10 之间的整数)。


update_memory(id, memory) 可以插入或更新一条记忆。它的实现如下:

def update_memory(id: str, memory: str) -> None: """Upserts a memory into the PineCone index.
Args: id: The memory ID. memory: The memory text. """
index = pinecone.Index(PINECONE_INDEX) embedding = to_embedding(memory) index.upsert( vectors=[ ( id, embedding, { PINECONE_INDEX_METADATA_KEY_MEMORY_TIME: string_to_timestamp_in_microseconds(id), PINECONE_INDEX_METADATA_KEY_MEMORY_IMPORTANCE: rate_importance(memory), PINECONE_INDEX_METADATA_KEY_MEMORY_TEXT: memory, }, ), ], namespace=PINECONE_INDEX_NAMESPACE, )

这个操作主要通过 upsert() 函数进行。我们需要提供的是名字空间、向量的 id,向量值,还有一个从字段名到元数据的映射。前面说过,我们用时间戳的字符串形式(比如“2023-05-30T02:01:24.659047”)做 id,所以 string_to_timestamp_in_microseconds(id) 得到的是以微秒为单位的 UNIX 时间戳。在元数据中用数值方式保存时间戳让我们可以对记录按时间检索。

值得一提的是 rate_importance(memory),它负责计算一条记忆的重要程度,可以帮助我们在查询记忆时优先考虑重要的记忆。

如何判断一条记忆有多重要呢?答案很简单:这正是大语言模型擅长的事,我们不用客气,直接问模型就好了。这里我们采用的是价廉物美的 gpt-3.5-turbo 模型。

使用 OpenAI API 向模型提问需要把一系列消息发送给它,而每条消息代表一个角色说的一段话。它的格式是:

{"role": 角色, "content": 内容}

这里角色只能是"system""user""assistant"三者之一。

要咨询”今天天气不错,挺风和日丽的。“这条记忆的重要程度,我们可以发过去这样两条消息:

{"role": "system", "content": "You are a helpful assistant."},{"role": "user", "content": """On the scale of 1 to 10, where 1 is purely unimportant (e.g., saying hello) and 10 is extremely important and useful (e.g., saving mankind), rate the likely importance of the following piece of memory (delimited by ```). Just give me the numeric rating and nothing else.Memory: ```今天天气不错,挺风和日丽的。```Rating: <number>"""}

消息千万条,系统第一条。格式不规范,码工两行泪。要有效使用 GPT 模型,第一条消息通常总是由 system 角色告诉模型它应该扮演一个什么样的角色。这里 system 给模型的提示是”你是一个乐于助人的助手“(You are a helpful assistant.)。千穿万穿马屁不穿,大模型也吃这一套。

第二条消息是由 user 角色说出的提示,翻译成中文:

请给下面这条用```界定的记忆按重要程度打分(1-10)。1 代表完全不重要(比如打招呼),10 代表极其重要(比如拯救人类)。请直接告诉我得分值。
记忆:```今天天气不错,挺风和日丽的。```
得分:<数值>

请看 rate_importance(memory) 的实现代码:

def _get_gpt_answer(messages: List[Dict[str, str]], temperature: float = 0.7) -> str: """Sends the list of messages to GPT and returns the answer.
Args: messages: The messages to send to GPT.
Returns: The GPT answer. """
response = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=messages, temperature=temperature, ) return response["choices"][0]["message"]["content"].strip() # type: ignore
def rate_importance(memory: str) -> int: """Rates the importance of a memory by asking the GPT model.
Args: memory: The memory text.
Returns: A integer in the range [1, 10] where 1 is least important and 10 is most. """
messages = _make_messages( [ f"""On the scale of 1 to 10, where 1 is purely unimportant (e.g., saying hello) and 10 is extremely important and useful (e.g., saving mankind), rate the likely importance of the following piece of memory (delimited by ```). Just give me the numeric rating and nothing else.Memory: ```{memory}```Rating: <number>""" ] ) answer = _get_gpt_answer(messages, temperature=0.0) return int(answer)

它主要做的事情就是用 _make_messages() 建立好消息列表,再把消息用 openai.ChatCompletion.create() API 发送给 gpt-3.5-turbo 模型,最后把模型返回的字符串(”1“”2“,...,”10“)转换成整数。_make_messages()的内容就不冗述了,大家可以直接到 src/utils.py 文件里去看。

现在我们试着添加几条记忆到数据库。比如,你可以运行:

$ src/main.py add "It's sunny today."

这条命令会输出:

Added memory 'It's sunny today.' with id 2023-05-29T00:25:47.858912.

表示记录添加成功了,id 是 2023-05-29T00:25:47.858912。

再加两条:

$ src/main.py add "今天天气不错,挺风和日丽的."$ src/main.py add "我在写一篇关于ChatGPT的文章."

恭喜!你现在已经学会了用向量数据库保存记忆的方法,这可是一大飞跃啊。

接下来我们还要教 GPT 在对话时按需要查询记忆数据库,显得过目不忘的样子。限于篇幅,今天就唠到这儿,下次讲完如何实现有长期记忆的 chatGPT。

I'll be back.

(本文插图由 Midjourney 绘制。)

~~~~~~~~~~

猜你会喜欢:


~~~~~~~~~~

关注老万故事会公众号:

本公众号不开赞赏不放广告。如果喜欢这篇文章,欢迎点赞、在看、转发。谢谢大家🙏

继续滑动看下一个
老万故事会
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存