老万故事会

老万】从0开始学chatGPT(十二):如何让机器人痛说革命家史

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

打 Meta 来了一个 LLaMA,
他爸爸是做 VR 的小扎。
打 Alpha 来了一个 LaMDA,
他大大是卖广告的孙达。
扛着小扎的 LLaMA 要拿小扎
去挤兑驮着孙达的 LaMDA 的孙达,
驮着孙达的 LaMDA 不愿意拿孙达
去 PK 扛着小扎的 LLaMA 的小扎。 
扛着小扎的 LLaMA 抡起小扎
就给了驮着孙达的 LaMDA 一小扎,
驮着孙达的 LaMDA 抽出孙达
就给了扛着小扎的 LLaMA 一孙达。
也不知是扛着小扎的 LLaMA
打塌了驮着孙达的 LaMDA,
还是驮着孙达的 LaMDA
咔嚓了扛着小扎的 LLaMA。 
LLaMA 耷拉着回 Menlo Park
怪小扎生迟了 LLaMA,
LaMDA 回谷歌那旮旯
怨孙达便宜了北边儿的萨提亚。
这边厢小扎拉着孙达要大战萨提亚,
那边厢老黄笑坏在 - 英 - 伟 - 达!

注:

  • LLaMA:Large Language Model Meta AI,Meta 公司开源的大语言模型

  • LaMDA:Language Model for Dialogue Applications,谷歌开发的大语言模型

  • 小扎:Mark Zuckerberg,Meta(前 Facebook)CEO

  • 孙达:Sundar Pichai,谷歌及其母公司 Alphabet CEO

  • 萨提亚:Satya Nadella,微软 CEO

~~~~

以上为定场诗。

上一期我们介绍了什么是向量数据库,如何用它来存储对话历史,以及如何让 GPT 判断一条记忆的重要程度。如果对这些问题还不太清楚,请先阅读《十一:动手实现长期记忆》再继续本文。

这一次我们一起学习如何根据需求查询对话历史并把重要的记忆整合到和 GPT 对话的提示当中,达到为 GPT 增加长期记忆的目的。现在就开始吧!

~~~~

书接上回。假定大家已经:

  • 克隆了老万开源的 https://github.com/zhanyong-wan/chatgpt-mem 代码仓库,

  • 创建了 Python 虚拟环境,

  • 开通了 OpenAI 和 Pinecone 的 API,

  • 安装了它们的 Python 客户端,

  • 在 Pinecone 创建了 chatgpt-mem 数据库索引,

并且编程实现了:

  • OpenAI  和 Pinecone 的初始化 (init_environment()),

  • 文字到嵌入向量的转换 (to_embedding()),

  • 记忆的插入和更新 (update_memory()),

  • 给记忆的重要性打分 (rate_importance()),

接下来我们就来实现记忆的查询。如果上面几步你还没有做,请按上期的介绍完成后再继续。

第一步我们先试着实现最简单的语义查询:输入一段话,找出和它意思最接近的那些记忆。比如,从

“那片笑声让我想起我的那些花儿,
在我生命每个角落静静为我开着”

我们可能联想到

“如今这里荒草丛生没有了鲜花,
好在曾经拥有你们的春秋和冬夏”,

而不大可能想到

“路见不平一声吼啊,
该出手时就出手啊,
风风火火闯九州啊
”

或者

“阿的弟,阿的刀,
阿的大的提的刀
”。

Image

我们先建立一个 Memory 类用来装查询结果。每条记录我们关心的属性包括记录的 ID、创建时间、重要程度和记忆内容,分别用 id,time,importance 和 text 字段表示(建议横屏阅读代码):

class Memory:    """A memory stored in the PineCone index."""
def __init__(self, id: str, time: datetime, importance: int, text: str) -> None: self.id = id self.time = time self.importance = importance self.text = text
def __repr__(self) -> str: return (f"Memory(id={self.id}, time={datetime_to_string(self.time)}, " f"importance={self.importance}, text={self.text})")

然后可以这样实现 query_memory() 函数:

def query_memory(    query: str,    top_k: int = 10) -> List[Tuple[float, Memory]]:    index = pinecone.Index(PINECONE_INDEX)    result = index.query(        namespace=PINECONE_INDEX_NAMESPACE,        vector=to_embedding(query),        top_k=top_k,        include_values=False,        include_metadata=True,    )    memories = []    for item in result["matches"]:        id = item["id"]        similarity = item["score"]        metadata = item["metadata"]        memory = Memory(            id=id,            time=string_to_datetime(id),            importance=metadata[PINECONE_INDEX_METADATA_KEY_MEMORY_IMPORTANCE],            text=metadata[PINECONE_INDEX_METADATA_KEY_MEMORY_TEXT],        )        memories.append((similarity, memory))    return memories

query_memory() 有两个参数:query 是查询输入文本,top_k 限定最多返回几条记录。它返回一个按相关性从高到低排序的记忆列表,其中每个元素是(相关度,记忆)。

我们看到:

  • 这个函数通过调用 Pinecone 的 Index.query API  实现查询。我们只需提供名字空间、查询嵌入向量(to_embeding(query))还有想要得到的记录数(top_k),数据库就会找出最接近查询嵌入向量的前 top_k 条记录。

  • 因为我们只对每条记录的元数据(metadata)感兴趣,并不需要记录的向量值本身,所以我们设定 include_metadata=True,include_values=False。

  • 从记录的元数据中,我们可以取得记录的重要性和原始文本。

在上一讲中,我们朝数据库中添加了三条记录。它们的文字内容分别是:

  • 今天天气不错,挺风和日丽的.

  • It's really sunny today.

  • 我在写一篇关于ChatGPT的文章.

现在我们就调用 query_memory() 看看结果如何:

% src/main.py query "天气"2023-05-29T01:18:16.210647 (0.869009852) 今天天气不错,挺风和日丽的.2023-05-29T00:25:47.858912 (0.782007575) It's really sunny today.2023-05-29T01:19:43.154695 (0.751624584) 我在写一篇关于ChatGPT的文章.

这条命令输出的每一行包括了记录的 ID(时间戳)、和输入文本的相似度、以及记录的原始文本。

我们看到,当查询输入是“天气”时,数据库准确地按顺序返回了三条记录:

  • “今天天气不错,挺风和日丽的.” 和 “天气” 吻合度最高(0.869009852)。

  • “It's really sunny today.” 也是谈天气的,虽然是英文。所以排在第二。

  • “我在写一篇关于ChatGPT的文章.” 和天气没什么关系,排在最后。

如果查询输入换成“GPT”,结果就倒过来了:

% src/main.py query "GPT" 2023-05-29T01:19:43.154695 (0.79688251) 我在写一篇关于ChatGPT的文章.2023-05-29T00:25:47.858912 (0.722446561) It's really sunny today.2023-05-29T01:18:16.210647 (0.703824341) 今天天气不错,挺风和日丽的.

~~~~

这一版 query_memory() 函数虽然也能工作,却有几点不足:

  1. 它没有对新旧记忆区别对待,不符合记忆的重要性通常会随时间衰减的惯例。

  2. 每次都在全部记忆中查找,记录数到了一定规模后代价太高。

  3. 记忆的重要性在排序时没有得到体现。

下面我们就来改进 query_memory(),弥补这几个缺陷。具体来说,我们会给它增加几个参数:

  • start_time:起始时间。在查询时只考虑这一时间以后发生的记忆。

  • end_time:结束时间。在查询时只考虑这一时间之前发生的记忆。

  • scorer:打分函数。它会综合记忆和输入向量的相似度和其它属性为每条记录打个分,得分高的记录排在前面。

我们实际采用的打分函数是这样的:

def comprehensive_score(        similarity: float, memory: Memory) -> float:    memory_age = datetime.now() - memory.time    recency_score = pow(0.99, memory_age.total_seconds() / 3600.0)    return similarity + recency_score + 0.3 * memory.importance / 10.0

最后得分由三部分相加组成:

  1. 和输入向量的相似度(similarity),也就是两个向量夹角的余弦值,由向量数据库计算。

  2. 时效分(recency_score),根据记忆的时间和当前时间计算。我们选了一个简单的指数衰减函数:假设每过一个小时时效分衰减 1%。

  3. 重要性(importance),由 GPT 模型计算,算法请见第十一篇《动手实现长期记忆》。

这三部分得分的权重和时效分的算法大家可以在实践中调整。

改进后的 query_memory() 函数长这样:

def query_memory(    query: str,    start_time: str = "",    end_time: str = "",    top_k: int = 10,    scorer: Callable[[float, Memory], float] = comprehensive_score,) -> List[Tuple[float, Memory]]:    index = pinecone.Index(PINECONE_INDEX)    filter: Optional[Dict[str, Any]] = None    if start_time and end_time:        filter = {            "$and": [                {                    "time": {"$gte": string_to_timestamp_in_microseconds(start_time)},                },                {                    "time": {"$lt": string_to_timestamp_in_microseconds(end_time)},                },            ]        }    elif start_time:        filter = {"time": {"$gte": string_to_timestamp_in_microseconds(start_time)}}    elif end_time:        filter = {"time": {"$lt": string_to_timestamp_in_microseconds(end_time)}}
result = index.query( namespace=PINECONE_INDEX_NAMESPACE, vector=to_embedding(query), filter=filter, top_k=top_k, include_values=False, include_metadata=True, ) memories = [] for item in result["matches"]: id = item["id"] similarity = item["score"] metadata = item["metadata"] memory = Memory( id=id, time=string_to_datetime(id),            importance=metadata[PINECONE_INDEX_METADATA_KEY_MEMORY_IMPORTANCE], text=metadata[PINECONE_INDEX_METADATA_KEY_MEMORY_TEXT], ) memories.append( ( scorer(similarity, memory), memory, ) )
    return sorted(memories, key=lambda x: x[0], reverse=True)

首先我们根据查询指定的时间区间按 https://docs.pinecone.io/docs/metadata-filtering 的格式创建一个时间过滤器(Pinecone 在查询时不支持字符串的大小比较,所以我们必须用数值形式表示时间戳。)。把这个过滤器传给 Index.query() 就可以限定记录的时间范围了。

拿到查询结果后,我们用 scorer 给每条记录打分,再从高到低排序就好了。

~~~~

有了更高级的查询函数,就可以着手实现带辅助记忆的对话机器人了。

Image

对话过程由 chat() 函数完成:

def chat() -> None:    print("Type 'quit' to exit.", file=sys.stderr)    conversation: List[str] = []    while True:        text = input(">>> ").strip()        if not text:            continue        if text == "quit":            break
end_time = datetime.now() - timedelta(hours=1) top_memories = query_memory( query=text,            end_time=datetime_to_string(end_time))[:3]
conversation.append( _make_prompt(text, top_memories)) add_memory(f"I said: ```{text}```")
answer = _get_gpt_answer( _make_messages(conversation)) print(f"{answer}", file=sys.stderr)
conversation.append(answer) add_memory(f"GPT said: ```{answer}```")

我们把对话历史用字符串列表的形式放在 conversation 变量里,第一个字符串是用户的第一次发言,第二个是机器人的回答,第三个是用户的第二次发言,第四个是机器人的再次回答,...... 以此类推。

比如我和 chatGPT 进行了以下两轮对话:

我:如果没有外界帮助,你能记住多少上下文?
ChatGPT:我可以记住大约几千个令牌的上下文,这通常相当于最近的几次互动。但是,请注意,我并不"记忆"内容,我不能跨越不同的对话或会话来记住以前的信息。每次对话开始时,我都不会保留以前的信息。这是为了保护用户的隐私。我不能长期记忆个人的特定信息,也不能识别个人。我的设计目的是尊重用户的隐私并且安全地提供信息和建议。
我:什么是向量数据库?
ChatGPT:向量数据库是一种专门用于处理向量数据的数据库。向量数据是指那些在高维空间中以向量形式存在的数据。这类数据在多种领域中都有广泛的应用,包括机器学习、人工智能、计算机视觉、生物信息学等。
向量数据库的主要优点在于它能够有效地处理和查询大量的高维向量数据。例如,通过使用特定的索引和查询策略(如K近邻搜索),向量数据库可以在大规模的数据集中快速地找到与查询向量最相似的向量。
在机器学习和人工智能的应用中,这种查询功能非常有用。例如,在推荐系统中,可能需要从数百万个商品中找出与特定商品最相似的商品。在人脸识别或图像搜索中,可能需要从大量的图像中找出与特定图像最相似的图像。这种情况下,向量数据库可以显著提高查询效率。
请注意,向量数据库通常需要专门的设计和实现来处理高维向量数据的特殊性质。这包括处理向量数据的大规模性(可能需要存储数十亿甚至更多的向量)、高维性(每个向量可能有数十到数千的维度)和稀疏性(大多数维度的值可能都是0或接近0)。

这些对话可以用以下列表表示:

[ "如果没有外界帮助,你能记住多少上下文?",  "我可以记住大约几千个令牌的上下文,...",  "什么是向量数据库?",  "向量数据库是一种专门用于处理向量数据的数据库。...",]

对话过程就是一个循环。每轮对话我们都会:

  • 取得用户输入的文字(text 变量),如果是“quit”就退出对话。

  • 否则从一小时前的记忆中根据和 text 的相关度、重要性和时效性提取出得分最高的三条记忆,和 text 一起整合成提示。

  • 把最近几轮对话历史和新提示一起发给 GPT。

  • 把提示和 GPT 的答复一起加入 conversation。

  • 把新添的对话历史加入记忆数据库。

之所以不考虑最近一小时的记忆,是因为它们和最近的对话历史重复度过高,而我们已经会把最近的对话历史发给 GPT 了,最新的记忆提供不了什么新信息。

这段程序用到几个辅助函数,我们一一介绍。

首先是 _make_prompt(),负责把用户输入的文字和得分最高的三条记忆整合成给 GPT 的提示:

def _make_prompt(text: str,                 top_memories: List[Tuple[float, Memory]]) -> str:    prompt = "Here are some memories (separated by !!!! and ended by $$$$) that might be relevant:\n"    for _, memory in top_memories:        prompt += f"""!!!!At {memory.time}, {memory.text}"""    prompt += f"""$$$$With that...{text}"""    return prompt

比如用户输入“今天天气如何?”,query_memory() 返回的前三条记忆是:

  • 今天天气不错,挺风和日丽的.

  • 我在写一篇关于ChatGPT的文章.

  • It's really sunny today.

那么 _make_prompt() 合成的提示就会是:

Here are some memories (separated by !!!! and ended by $$$$) that might be relevant:
!!!!
At 2023-05-29T05:56:55.866796, 今天天气不错,挺风和日丽的.
!!!
At 2023-05-29T05:57:13.968475, 我在写一篇关于ChatGPT的文章.
!!!!
At 2023-05-29T05:51:45.761970, It's really sunny today.
$$$$
With that...
今天天气如何?

我们用 _make_messages() 把最近的对话历史包装成发送给 GPT 的消息格式:

def _make_messages(        conversation: List[str]) -> List[Dict[str, str]]:    assert (        len(conversation) % 2 == 1    ), "The conversation history should have an odd length."
reverse_messages: List[Dict[str, str]] = [] role = "user" # user or assistant. total_len = 0 for text in reversed(conversation): # Limit the total length of text sent to the bot. total_len += len(text) if total_len > 2048: break
# In Python, it's more efficient to append to a list than to insert at the beginning. reverse_messages.append({"role": role, "content": text}) # Reverse the role. role = "assistant" if role == "user" else "user"
# Set the behavior of the bot. reverse_messages.append( {"role": "system", "content": "You are a helpful assistant."} ) return list(reversed(reverse_messages))

因为 GPT 能处理的上下文有限,我们在构建消息时从后往前依次添加对话历史,总长度超过 2048 个字符就停止。这是一种比较省事的做法,真正做产品的话可能把文字转化为记号(token)再按记号数截断历史效果更好,因为 GPT 是按记号而不是字符来处理文字的。

_get_gpt_answer() 比较简单:把构建好的消息通过 API 发送给 GPT,再从 GPT 的返回结果中抽取出答案。

def _get_gpt_answer(        messages: List[Dict[str, str]],        temperature: float = 0.7) -> str:    response = openai.ChatCompletion.create(        model="gpt-3.5-turbo",        messages=messages,        temperature=temperature,    )    return response["choices"][0]["message"]["content"].strip()

至此 chat() 函数就实现完了。为了验证它确实有记忆,我在和它对话一段时间后问道:

我今天感觉如何?

它沉思片刻,吐出答案:

根据你提供的记忆,您在2023年5月29日早上感觉很好,天气很好,您正在写一篇关于ChatGPT的文章。因此,可以推断您当天的感觉很不错。

~~~~

今天我们借助向量数据库和 OpenAI API 实现了有长期记忆的 chatGPT,但这只是一个帮助我们理解原理的原型,真正要实用还得做很多工作,细调参数,打磨细节。关于 chatGPT 还有很多知识可以学习,我们下次继续。I'll be back!

~~~~~~~~~~

猜你会喜欢:

~~~~~~~~~~

关注老万故事会公众号:

码字不易,呕心沥血只是希望更多人看到。如果喜欢这篇文章,请不吝订阅、转发、评论。谢谢!🙏