如何设计Agent的记忆模块

22 阅读7分钟

大模型天生就有有记忆缺陷。 就同HTTP请求一般,每次对大模型的请求,都是无状态的。

所以为了让大模型拥有记忆,就需要在每次请求里面,携带上他之前说过的话。从而模仿他的记忆,让他知道,自己是谁,为谁服务,服务什么,服务对象又有什么性格等等...

但是这就会遇到一个很严肃的问题。大模型的推理文本长度是有限的。 也就是我们常说的上下文。这就牵扯出一个问题: 主流大模型的上下文长度也就1Mtoken左右。而国产大模型通常也就是256K左右。 这就意味着上下文长度是有限的,所以非常宝贵。 同时因为大模型的注意力机制的原因,上下文越长,推理的所需耗费的几何倍上升,并且精度也会越低。 所以,记忆模块的设计,就至关重要!

为好奇的小伙伴,补充一下什么是注意力机制 他的核心就是自注意力机制(Transformer),不再像RNN那样串行处理,而是一次性看待整段输入(可以并行处理)。 做法就是,对每个token生成QKV三个向量,计算出所有token的QK相似度得分,拿着这个相似度去和V做一个加权求和。从而能让token和语境关联。 就相当于把苹果手机中的,苹果从原本的向量区间,拉到手机品牌向量区间那样。

一、记忆模块设计

在开始搭建记忆模块之初,最先做的并不应该是RAG! 而是在domain协议层,定下契约。之后不论是写入、召回都会按照固定的格式进行。 通常记忆模块会分四层感知记忆(当前的输入)、短期记忆(上下文的压缩)、长期记忆(沉淀后的记忆)、结构化记忆(存储的用户的习惯、爱好、目标等)。

而我在设计中,

我对所有的 memory 记忆类型 分为三种:

  • 结构化type。
  • 长期记忆
  • 上下文压缩后的摘要,服务与上下文恢复。

同时,为了保护用户隐私,我又分了三层。

  • self:用户个人隐私,像习惯、爱好、自己的上下文等。
  • org:组织级别的,拥有对应权限的人可以共享。
  • platform:平台运维级别的。

虽然有些功能,暂时不需要实现,但是协议是非常有必要提前定义好的。

二、记忆治理

第一部分,已经定义好类型权限了。 但在入库前,更重要的就是定入库的规则。 我这里分为了两层:

  • 软规则:写进 prompt ,让 LLM 按照给定的规则去"理解和生成"。
  • 硬规则:负责最终"裁决和落库",像权限的过滤,字段的合法性校验等,尽可能的规避LLM生成的错误。

其中,软规则中的prompt更多是在告诉LLM。

  • 什么样的内容算高价值
  • 什么东西不能记
  • Fact、Document、摘要等等,给出来的东西应该是什么类型
  • 生成的是json格式
  • 等等...

硬规则,就是对生成的东西进行一次强校验。 就像权限范围TTL等,都是需要自软规则里面抽取出来,经过一遍硬规则的手。 确保一切没有问题。在入库。

我举个简单的例子:

比如用户说: “以后请你回答时更简洁一点。我最近 30 天目标是刷 200 道题。”

LLM 可能提两个候选:

[
  {
    "type": "fact",
    "namespace": "user_preference",
    "key": "answer_style",
    "value": {"style": "concise"},
    "source_kind": "explicit_user_statement",
    "scope_hint": "self",
    "confidence": 0.95
  },
  {
	 "type": "fact",
	 "namespace": "oj_goal",
	 "key": "current_goal",
	 "value": {"goal": "200 problems in 30 days"},
	 "source_kind": "explicit_user_statement",
	 "scope_hint": "self",
	 "confidence": 0.91,
	 "ttl_hint": {
	   "mode": "duration",
	   "days": 30,
	   "reason": "用户明确说最近 30 天目标"
	  }
]

  • 先判断一下致信度,太低的话直接skip掉。
  • 然后根据LLM生成的,开始配置一下权限,在过滤一遍TTL等,最后入库。

三、上下文压缩,以及上下文恢复

现在定义了如何记忆类型、以及允许什么样的内容入库。 但是另一个影响对话质量的就是上下文的质量。 也就是说:

下一轮对话到来时,如何在有限的 token 预算里,把真正有价值的上下文重新恢复给模型。

技术上,我了解最粗暴的方式,就是滑动窗口,自动将越界的删除掉。 其次就是摘要压缩,对越界的内容进行压缩后,在携带。但是这样会导致精度问题。 后来就有了层次压缩,如:最近10轮的压缩,10~50轮的轻度压缩,更后方的重度压缩。 但是这会导致一些重要信息,被裁剪忽略掉。 所以后来又出现了重要性过滤。不在一味的按先后顺序。等等...

所以我这里做的是分层恢复策略。 我会将上下文分为好几层进行控制:

第一层放:最新的决策和最近确认过的重要结论,优先级放到最高。 第二层放:当前还没有闭环的问题、待确定的问题。 第三层放:会话级别摘要,用来恢复长对话主线,而不是历史回放。 第四层放:已经沉淀出来的用户偏好、阶段目标、画像信息等。 第五层放:一下RAG召回的经验等。 第六层放:最近的几轮的对话内容。

其中,我把用户最近一次的问题,放到了最后。 因为我之前在读arXiv的一篇论文(读 “archive”),参考他上面的内容: 模型对长上下文的中间信息利用很不稳定,相关信息放到开头或结尾表现会更好。

有兴趣的可以瞅瞅: 在这里插入图片描述 在这里插入图片描述

四、RAG 切分入库

之前已经把协议、提示词、上下文的组成搞定。 但在,我规定的上下文组成的第五层中,是需要RAG召回的,这也就意味着,需要先切分入库

切分入库的质量越高,召回的质量就越高。 最简单的切分方式就是固定大小切分,但是这样很死板,上下文语义会很不完整。 所以就有了语义切分,按照标点符号进行切分。 但有时,先表格、函数代码这些特殊情况,是无法进行语义切分的,所以就需要对这些专项内容切分。 其实还有成本更高的父子切分,就是你检索到子片的时候,会把与他相连的上下文一起带回。

而我的本项目,需求不高,所以做的是语义切分

  • 首先按段落切分
  • 如果越界了在按照符号切分。 并且会保留相邻chunk的150个字符,用来维持语义。

最后在入qdrant与mysql的时。 我是以mysql作为真相数据源存document与元数据。 在 qdrant 里面存的是向量权限范围用于筛选、和对应mysql的数据索引,用于去数据库中检索。

我在我的代码中也考虑过专项内容切分,考虑到了表格、代码,但是暂时还没有引入语法解析工具这种更高精度的切分。

五、混合召回策略

在上下文压缩的时候,我已经把需要召回的内容,表述的很明白。 但是那个时候为了表述清楚,内部的组成结构,所以我说的还是比较直白死板

通常我会先判断一下两个数组: 最近决策:只有 key_points 不会空才加载。(这个是对话中的要点) 待完成内容:只有 open_loops 不为空才加载,他的存在可以让上下文的连续性更丝滑。 对话压缩后的摘要:长对话必备! Fact格式化内容:这个达到阈值才能被加载,比如用户性格爱好,最近有啥目标 等用户画像RAG检索:若检索出来了,需执行度高于阈值,且top前5。 最后就是一些tools信息,与用户最近一次的问题了。

六、我想说的话

本次整体是我自己手写搭建的。 目的就是为了深入理解记忆机制的底层原理。 但是如果要在生产级别项目中,为了追求快速开发。 可以复用像 mem0,这种记忆框架。