Files
LangBot/plugins/KnowledgeEngine/LongTermMemory/readme/README_zh_Hans.md

22 KiB
Raw Permalink Blame History

LongTermMemory

LangBot 长期记忆插件,采用双层记忆设计:

  • L1 核心画像:注入到 system prompt
  • L2 情景记忆:通过向量检索召回后注入

作用

  • 提供 remember 工具,用于写入情景记忆
  • 提供 recall_memory 工具,用于主动检索情景记忆并使用受控过滤条件
  • 提供 update_profile 工具,用于更新稳定画像信息
  • 提供 forget 工具,用于让 Agent 删除指定 ID 的情景记忆
  • 通过 EventListener 自动注入画像和当前说话人身份
  • 通过 EventListener 在模型调用前自动检索并注入相关情景记忆
  • 提供 !memory 命令用于查看和调试记忆状态
  • 提供网页版记忆控制台,用于可视化查看:画像、情景记忆(支持分页)、审计日志、导出、metadata 隔离健康检查,以及最近一次记忆注入快照
  • 提供 !memory list [page] 命令,用于分页浏览情景记忆
  • 提供 !memory forget <episode_id> 命令,用于删除单条情景记忆
  • 提供 !memory search <query> 命令,用于搜索情景记忆(结果包含 episode ID)
  • 提供 !memory export 命令,将当前会话范围内的 L1 画像导出为 JSON
  • 当存入带有 correction / fact-update / clarification 标签的新记忆时,会自动将相关旧记忆标记为 superseded

整体设计

这个插件的目标不是做一个“把所有历史对话都塞进上下文”的记忆系统,而是把长期记忆拆成两层,分别处理“稳定事实”和“具体经历”:

  • L1 核心画像:低频变化、相对稳定的信息,例如名字、偏好、身份、长期备注。
  • L2 情景记忆:带时间性和情境性的内容,例如最近做过什么、提过什么计划、发生过什么事件。

这样拆分的原因是:

  • 稳定画像适合直接注入 system prompt,成本低、命中稳定。
  • 情景记忆数量会不断增长,不适合每轮都全量注入,更适合按 query 做检索召回。
  • Agent 对“稳定画像”和“事件记忆”的写入方式也不同,前者更适合结构化更新,后者更适合追加式记录。

与 OpenClaw 这类个人助手记忆方案的区别

最近不少 Agent 系统会讨论类似 OpenClaw 这样的记忆实现:把长期记忆主要做成用户可直接阅读和编辑的文本文件,例如 MEMORY.md,再配合摘要、反思和少量检索逻辑来完成“个人助手式”的长期记忆。

这种方案有它明确的优点:

  • 记忆对用户完全透明,可直接检查和修改
  • 纯文本天然适合备份、同步、版本控制
  • 对单用户、单助手、强连续性的个人场景非常自然
  • 当记忆规模不大时,全文理解确实可能“足够好”

但 LangBot 的长期记忆插件面对的不是完全相同的问题。我们更常见的运行场景是:

  • 一个 bot 同时服务多个群聊和私聊
  • 同一个插件实例要处理多个会话、多个说话人
  • 记忆不仅是“我和助手”的私人连续叙事,还包括群共享背景、当前说话人的稳定画像、以及会话级事件记忆
  • 系统要明确控制不同会话、不同 bot、不同说话人之间的隔离边界

因此,我们没有直接采用“单一文本记忆文件作为长期记忆真相源”的方案,而是选择了更适合 LangBot 场景的分层设计。

OpenClaw 方案更像什么

如果把它抽象一下,OpenClaw 更像是:

  • 面向单用户个人助手
  • 以可读文本为长期记忆主形态
  • 强调透明、可编辑、叙事连续性
  • 默认假设记忆规模可控,且用户愿意直接参与维护

这非常适合“个人 AI 助手 / 日志伙伴 / 研究搭子”一类产品。

LangBot 插件为什么不直接这么做

LangBot 的长期记忆插件更关注的是“多会话、多说话人、可隔离、可检索、可控注入”的运行条件。

如果直接把长期记忆主形态做成类似 MEMORY.md 的单一文本文件,会很快遇到这些问题:

  • 隔离困难
    • 群 A、群 B、私聊 C 的记忆如何安全共存?
    • 某个说话人的稳定画像如何从共享叙事文本里精确拆出来?
  • 注入粒度不稳定
    • system prompt 里需要的是稳定画像,不是整个时间线叙事
    • 自动 recall 需要的是与当前 query 最相关的片段,而不是整本文本日记
  • 多用户边界不清晰
    • 在个人助手里,“用户”通常只有一个
    • 在 LangBot 里,“当前说话人”“当前会话”“当前 bot”是三个都需要认真处理的维度
  • 主动检索与自动注入是两类不同需求
    • 稳定画像适合固定注入
    • 情景记忆适合检索后注入
    • 如果都揉成同一种文本记忆形态,工程上会变得很别扭

我们当前设计的取舍

所以 LongTermMemory 的设计,本质上是在做下面这个取舍:

  • 借鉴 OpenClaw 的地方

    • 承认“长期记忆不该只是黑盒向量索引”
    • 重视可解释性、稳定画像、时间性记忆和长期行为调整
    • 接受“不是所有记忆都应该一股脑塞进上下文”
  • 与 OpenClaw 不同的地方

    • 我们没有把文本日记作为唯一记忆主形态
    • 我们把稳定画像和情景记忆显式拆层
    • 我们优先解决多会话、多说话人、多 bot 的隔离问题
    • 我们让 L2 记忆天然进入 LangBot 的 KB / 检索体系,而不是只靠全文理解

可以把两者理解成:

  • OpenClaw 主要回答的是“个人助手如何拥有可读、可控、可反思的长期记忆”
  • LongTermMemory 主要回答的是“群聊 / 私聊混合场景下,bot 如何在安全隔离前提下拥有稳定画像与可检索经验记忆”

这不是谁比谁“更先进”,而是产品目标不同导致的设计重心不同。

设计说明

本插件尽量复用 LangBot 现有的扩展点,而不是依赖额外的核心补丁:

  • L1 画像存储在插件存储中,格式为 JSON
  • L2 情景记忆存储在向量数据库中
  • 通过将本插件的 KnowledgeEngine 绑定到 pipeline,实现按 pipeline 显式启用
  • 当前实现假设每个插件实例只维护一个 memory KB,并通过 metadata 做隔离

当前实现是围绕现有 LangBot 和 SDK API 设计的。后续如果 LangBot 提供更明确的 memory API、session 身份 API 或 KB 注册 API,插件可以进一步简化,但现有架构本身不需要因此推翻。

向量数据库后端兼容性

L2 情景记忆依赖任意 metadata 字段(例如 user_key、episode_id、tags、importance)做隔离与过滤。不是所有 LangBot 向量数据库后端都支持任意 metadata:

后端 任意 metadata LongTermMemory 支持情况
Chroma(默认) 是 完整支持
Qdrant 是 完整支持
SeekDB 是 完整支持
Milvus 否(固定 schema:text、file_id、chunk_uuid) 不支持
pgvector 否(固定 schema:text、file_id、chunk_uuid) 不支持

Milvus 和 pgvector 会静默丢弃它们不认识的 metadata 字段。这意味着基于 metadata 的隔离(如 user_key)以及 !memory list、!memory forget、!memory search 这类命令在这些后端上都不能正确工作,过滤条件可能被忽略,结果也可能失去作用域约束。

如果要使用 LongTermMemory,请优先选择 Chroma、Qdrant 或 SeekDB 作为向量数据库后端。

它是怎么工作的

一次完整的长期记忆流程,大致分成四部分:

1. L1 画像写入

  • Agent 通过 update_profile 工具写入稳定信息。
  • 数据写入插件存储(plugin storage),按 session 或 speaker 维度保存。
  • 这些画像不是向量数据,不进入知识库,而是以结构化 JSON 存储。

2. L2 情景记忆写入

  • Agent 通过 remember 工具写入事件型记忆。
  • 记忆内容会带上时间、重要度、标签、scope 等 metadata。
  • 这些内容进入 LongTermMemory 的 KnowledgeEngine,经过 embedding 后写入向量库。

3. 对话前自动注入

  • 在 PromptPreProcessing 阶段,EventListener 会先解析当前会话身份。
  • L1 部分:
    • 读取 session profile
    • 读取当前说话人的 speaker profile
    • 把当前说话人身份信息一起注入到 default_prompt
  • L2 部分:
    • 使用当前用户消息做一次情景记忆检索
    • 命中的记忆以“事实数据”形式注入到 prompt

也就是说,L1 和 L2 都是在模型真正回答前进入上下文,但注入方式不同:L1 走 system prompt,L2 走检索结果块。

4. 主动检索与调试

  • 如果自动注入不足,Agent 还可以通过 recall_memory 工具主动检索记忆。
  • 开发或排障时,可以使用 !memory、!memory profile、!memory search、!memory list、!memory forget 查看当前状态。
  • !memory export 只导出当前 scope 下的 L1 画像,方便做备份或迁移。

与 AgenticRAG 的关系

如果同时启用了 AgenticRAG:

  • LongTermMemory 会先把自己的 memory KB 从 naive RAG 预处理列表里移除。
  • L2 自动记忆召回仍由 LongTermMemory 自己完成。
  • 同一个 memory KB 仍然可以被 AgenticRAG 的 query_knowledge 工具主动访问,用于更深一步的 agentic 检索。

这样设计的好处是:

  • 自动记忆召回不会和 naive RAG 重复
  • 长期记忆仍然保留“自动 recall + 主动 query”两条路径
  • 不依赖插件加载顺序来避免重复注入

为什么暂时不向 Agent 开放元数据过滤

虽然底层运行时支持 metadata filter,但本插件目前没有在 Agent 流中直接暴露任意原始元数据过滤能力。

原因包括:

  • 不同知识引擎和向量后端没有统一的 metadata schema
  • 过滤字段名、值格式、支持的操作符可能各不相同
  • Agent 当前没有稳定的 schema 来源,难以正确构造过滤条件

如果未来 LangBot 能为每个知识库提供统一的可过滤元数据字段描述,就可以再考虑开放更通用的 Agent 侧 metadata filter。

不过,由于长期记忆插件自己的 memory schema 是稳定的,本插件已经提供了受控的 recall_memory 工具入口,支持按说话人、时间范围等固定参数主动检索记忆,而不是让大模型直接拼接底层 raw filter。

隔离模型

支持两种隔离模式:

  • session:每个群聊或私聊各自独立
  • bot:同一个 bot 下的所有会话共享记忆

在当前部署模型下,这通常已经足够,因为插件实例一般会绑定在特定的 LangBot 运行环境或 bot 上。

隔离规则细节

这个插件里实际上有两套相关但不完全相同的“作用域”概念:

  • session_name:当前 query / retrieval 链路里传递的会话标识,格式是 {launcher_type}_{launcher_id}。
  • session_key:插件内部用于 L1 画像存储的 key;如果有 bot_uuid,实际会变成 {bot_uuid}:{launcher_type}_{launcher_id},否则退化为 {launcher_type}_{launcher_id}。
  • scope_key / user_key:真正用于画像存储或 L2 检索隔离的键。

L1 画像怎么隔离

L1 画像总是按当前会话范围保存:

  • session profile
    • 对应当前会话共享画像
    • 例如群聊共同背景、当前会话共享约定
  • speaker profile
    • 对应当前说话人的稳定画像
    • 例如某个成员的偏好、身份、长期备注

因此,!memory export 导出的也是当前 session_key 对应范围下的画像,而不是整个插件实例里的全部画像。

L2 情景记忆怎么隔离

L2 情景记忆在写入向量库时会带上隔离 metadata,检索时再按隔离规则过滤:

  • session
    • 群 A 的记忆不会被群 B 召回
    • 私聊 A 的记忆不会被私聊 B 召回
  • bot
    • 同一个 bot 下的多个会话共享一套 L2 记忆
    • 适合希望 bot 在不同会话之间共享长期经验的场景

如果还带有 sender_id,插件也可以优先检索“当前说话人相关”的记忆,再回退到更宽的 scope。

为什么 L1 和 L2 的隔离看起来不完全一样

这是有意为之:

  • L1 更像“当前会话和当前说话人的稳定画像”,天然适合做精细的 session / speaker 级存储。
  • L2 更像“可检索的经验库”,更适合通过 metadata 过滤控制召回范围。
  • 这样既保留了 L1 的精确注入能力,也保留了 L2 的可扩展检索能力。

使用方法

  1. 安装并启用本插件。
  2. 使用本插件的 KnowledgeEngine 创建一个 memory knowledge base。
  3. 配置以下参数:
    • embedding_model_uuid
    • isolation
    • 可选 recency_half_life_days
    • 可选 auto_recall_top_k
  4. 让 Agent 使用:
    • remember 记录事件、计划、情景事实
    • recall_memory 在自动召回不足时主动检索记忆
    • update_profile 记录稳定偏好和画像信息
    • forget 按 ID 删除单条情景记忆
  5. 使用 !memory、!memory profile、!memory search <query>、!memory list [page]、!memory forget <id>、!memory export 查看记忆状态。

给其他插件共享上下文

LongTermMemory 会在每次 PromptPreProcessing 事件期间,把结构化上下文摘要写入 query variable _ltm_context。其他插件可以读取这个变量,在完全不依赖 LongTermMemory 代码的前提下,基于用户记忆做程序化决策。

变量名

_ltm_context

数据结构

{
    "speaker": {
        "id": "user_123",           # sender_id,可能为空字符串
        "name": "Alice",            # sender_name,可能为空字符串
    },
    "session_profile": {            # 总是存在,但字段可能为空
        "name": "",
        "traits": ["creative", "analytical"],
        "preferences": ["prefers detailed explanations"],
        "notes": "",
        "updated_at": "2025-03-16T12:00:00Z",
    },
    "speaker_profile": {            # sender_id 不可用时为 null
        "name": "Alice",
        "traits": ["extroverted"],
        "preferences": ["likes humor"],
        "notes": "",
        "updated_at": "2025-03-16T12:00:00Z",
    },
    "episodes": [                   # 自动召回的 L2 情景记忆,可能为空
        {"content": "User mentioned a trip to Beijing last week"},
    ],
}

使用示例

from langbot_plugin.api.definition.components.common.event_listener import EventListener
from langbot_plugin.api.entities import events, context
from langbot_plugin.api.entities.builtin.provider.message import Message


class PersonalityCustomizer(EventListener):
    def __init__(self):
        super().__init__()

        @self.handler(events.PromptPreProcessing)
        async def on_prompt(event_ctx: context.EventContext):
            ltm = await event_ctx.get_query_var("_ltm_context")
            if not ltm:
                # LongTermMemory 未安装或未启用时,走默认行为
                return

            profile = ltm.get("speaker_profile") or ltm.get("session_profile") or {}
            traits = profile.get("traits", [])

            if "喜欢幽默" in traits:
                style = "Use a humorous and playful tone."
            elif "偏好简洁" in traits:
                style = "Be concise and direct."
            else:
                return

            event_ctx.event.default_prompt.append(
                Message(role="system", content=style)
            )

设计说明

  • 如果 LongTermMemory 未安装,_ltm_context 不存在。消费方插件应把 None 视为正常情况,并回退到默认行为。
  • 如果 LongTermMemory 已启用但还没有画像数据,变量会存在,但字段为空。这样消费方可以区分“没有记忆插件”和“记忆插件已启用但暂无数据”。
  • 双方只依赖变量名和 schema 约定,不依赖对方代码。如果未来用另一个记忆插件替换 LongTermMemory,只要它写同样的 key 和 schema,消费方插件就无需改动。
  • LongTermMemory 需要在事件分发顺序上先于消费方插件运行。实际效果通常取决于插件安装顺序。

导入与导出

  • 导出(L1 画像): 使用 !memory export 命令导出当前 scope_key 下的 session 画像和 speaker 画像,格式为 JSON,可复制保存用于备份或迁移。该命令不会跨 session / scope 导出其他会话的数据。
  • 导入(L2 情景记忆): 通过 LangBot 知识库前端界面上传 JSON 文件,批量导入情景记忆。
  • L2 情景记忆现在支持浏览:可通过 !memory list [page] 分页查看,也可以通过 !memory forget <id> 删除单条情景记忆。完整批量导出仍未实现。

关键技术问答

Q1. 为什么 L1 和 L2 要分层,而不是统一存在向量库里?

因为两类信息的访问模式完全不同:

  • L1 是稳定事实,适合每轮都稳定注入
  • L2 是事件型记忆,适合按 query 检索

如果把两者都放进向量库:

  • 稳定画像会变得不稳定,容易漏召回
  • 每轮都查会浪费上下文和检索预算
  • 写入和更新语义也会变混乱

Q2. L2 记忆为什么不全量注入,而要检索?

因为 L2 会不断增长。全量注入会带来几个直接问题:

  • prompt 很快膨胀
  • 噪声越来越大
  • 旧记忆会挤占真正相关的上下文

所以当前策略是:

  • 自动召回一小部分最相关记忆
  • 不够时再让 Agent 通过 recall_memory 主动查

Q3. L2 记忆有时间衰退吗?

有。

L2 检索不是只看向量相似度,还会结合时间衰退做重排。越新的记忆通常权重越高,越旧的记忆权重会逐步下降。

当前实现里使用的是“半衰期”思路:

  • 一条记忆在达到 half_life_days 时,时间权重衰减到约 50%
  • 越新的记忆越容易排在前面
  • 但旧记忆不会被直接删除,只是排序优势变弱

这样做的目的不是“自动遗忘所有旧事”,而是让检索结果更符合“近期上下文优先”的使用直觉。

Q4. 旧记忆会不会彻底失效?

不会自动彻底失效。

时间衰退影响的是排序,不是硬删除。只要内容仍然足够相关,旧记忆仍可能被召回。

真正删除通常发生在:

  • 你显式删除文档 / 知识库内容
  • 重建知识库
  • 替换插件实例或数据

Q5. session 和 bot 隔离模式怎么选?

经验上可以这样理解:

  • 选 session
    • 适合把每个群 / 私聊都当成独立人格上下文
    • 更安全,串话风险更低
  • 选 bot
    • 适合希望 bot 跨会话共享长期经验
    • 召回面更大,但也更容易把别的会话经验带进来

如果你不确定,默认优先 session。

Q6. 为什么 !memory export 只导出当前 scope?

这是刻意的安全边界,不是功能没做完。

如果默认能导出整个插件实例下的所有 L1 画像,就很容易发生跨会话、跨用户的数据泄漏。当前实现只允许导出当前 scope_key 下的数据,更符合“最小暴露面”的原则。

Q7. 如果运行时没有把 _knowledge_base_uuids 放进 query variables,会发生什么?

自动记忆注入本身仍然可以工作,但插件无法把自己的 memory KB 从 naive RAG 预处理中移除。

这会导致重复召回:

  • 一份由 LongTermMemory 自己注入
  • 另一份又被 runner 的通用知识库流程检索出来

所以这不是“完全失效”,而是会造成上下文冗余和噪声增加。

Q8. 为什么 L2 还不支持导出?

SDK 现在已经提供了 vector_list API,可以分页枚举向量库内容。LongTermMemory 因此支持了通过 !memory list [page] 浏览 L2 情景记忆,以及通过 !memory forget <episode_id> 或 forget 工具逐条删除。

完整的批量导出还没有实现,但底层构件已经具备。

Q9. 和 AgenticRAG 同时启用时会不会重复召回?

当前设计就是为了解决这个问题:

  • LongTermMemory 会主动移除自己的 naive RAG 预处理
  • L2 自动 recall 由 LongTermMemory 自己完成
  • 如果模型还需要更深入查询,同一个 memory KB 还可以通过 AgenticRAG 主动查询

所以它们是互补关系,不是简单叠加两次同样的召回。

记忆控制台

除了 !memory 命令外,插件还提供一个网页版记忆控制台(注册为插件的 Page 组件),用于可视化查看与维护。它通过受作用域约束的页面接口与插件通信,所有操作都不会越过当前选中的记忆空间。

控制台由「记忆空间侧栏」和若干标签页组成:

  • 记忆空间侧栏 —— 选择已有作用域,或填入 bot_uuid / session_name / isolation 并点击 推导,自动计算出对应的 scope_key 与 user_key;「高级 / 原始字段」区域向高级用户暴露底层 key。
  • 画像 —— 查看当前作用域的会话画像和当前说话人画像。
  • 情景记忆 —— 对当前 user_key 分页列出或语义搜索 L2 情景记忆,可按生命周期状态过滤,并就地归档、恢复或删除单条记忆。每个写操作都会写入作用域审计条目,与对应的 !memory 命令一致。
  • 注入快照 —— 加载该作用域最近一次记忆注入快照:本轮是否注入、注入了多少 block 和字符、当前说话人、所用的会话/说话人画像,以及自动召回的 L2 情景记忆。无需翻日志即可回答「机器人上一轮到底记住了什么」。
  • 审计 —— 分页浏览并导出作用域审计日志。
  • 导出 —— 将当前作用域的 L1 画像和 L2 情景记忆导出为 JSON。
  • 诊断 —— 点击 运行健康检查,可直接在界面中执行与 !memory health 相同的 metadata 隔离探针(知识库配置、embedding 模型,以及 search / list / delete 上的 user_key 过滤隔离),以红/黄/绿状态卡片呈现。在信任作用域召回之前建议先运行,尤其是上文列为「不支持」的后端。注意:控制台无法验证知识库是否已挂载到某条具体的流水线——这一项请在流水线中运行 !memory health 确认。

控制台支持 i18n(en_US、zh_Hans),并跟随宿主 LangBot 的明暗主题。

组件