【老万】从0开始学chatGPT(十一):动手实现长期记忆
本文是《从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)一并保存。比如:
在上图的记忆向量数据库例子里,每条记录有这么一些字段:
id:记录独一无二的(unique)名字。一条记录只能有一个名字。反之亦然:一个名字只能有一条记录。所以,用 id 可以唯一确定一条记录。为了人类用户方便,这里我们用记忆生成的时间做 id,这样一看便知是什么时候的事。
vector:浮点数向量。这是数据库存储的主要内容。同一张表里的所有向量必须有相同的维数。这个维数是在建表的时候就确定的。
元数据(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_secrets
import openai
import pinecone
import 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
可以看出,这个函数做的事情包括:
用秘钥和环境参数初始化 OpenAI 和 Pinecone 的客户端。
如果 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 角色说出的提示,翻译成中文:
记忆:```今天天气不错,挺风和日丽的。```
得分:<数值>
请看 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 绘制。)
~~~~~~~~~~
猜你会喜欢:
谷歌对微软:代码管理工具哪家强?- 要集中还是要分布
谷歌新语言 Carbon 能干翻 C++ 吗?- 深入浅出分析 Carbon
后 C++ 演义(第一回、第二回) - 起底 C++ 发明人比雅尼
后 C++ 演义(第三回) - C++ 的最新发展
程序员护发秘籍 - 掌握这些工作技巧,包你不脱发
程序员的核心技能 - 以脱口秀的方式讲解程序员最重要的技能
如何做出保鲜十年的软件 - 老码农冒死披露行业内幕系列
dongbei 语言满月记事 - 一种基于东北方言的娱乐式程序设计语言
~~~~~~~~~~
关注老万故事会公众号:
本公众号不开赞赏不放广告。如果喜欢这篇文章,欢迎点赞、在看、转发。谢谢大家🙏