时歌
返回首页

一念不落,万象可循:YOLO 的记忆系统实现

4606 字 23 分钟
目录

在之前的博客《浅谈ChatGPT的记忆实现机制 兼论工程端记忆设计》里,我们拆解了目前 C 端记忆系统做到天花板的 ChatGPT 是怎么设计其记忆能力的。那篇文章从 LLM 本身的无状态性聊起,逆向分析了 ChatGPT 背后那套六模块用户画像系统,包括它如何在你毫无感知的情况下提取你的对话偏好、行为模式和交互元数据,再通过语义匹配动态注入上下文,构造出”它记得你”的幻觉。最后我们把工程端的记忆实现分成了三档:最基本的上下文维护、中间形态的模糊语义记忆、以及 ChatGPT 所代表的高精度结构化动态注入方案。

那篇文章结尾我还放了自己写的一套”猴版”长期记忆,靠 Gemini Flash 每隔十五轮抽取关键信息、分配优先级权重、存进 JSON 文件,效果凑合但确实能跑。当时的结论是”大道至简,绝大多数 chatbot 场景用这套就够了”。

但 YOLO 显然不属于”绝大多数场景”。

作为一个嵌入 Obsidian 的 AI 助手,YOLO 面对的情况要特殊得多:用户的整个 Vault 本身就是一个巨大的外部记忆体,笔记、日记、项目文档天然构成了一套可检索的知识库。在这个前提下,AI 自己维护的”记忆”和用户笔记库里已经存在的信息之间,边界在哪?它应该记住什么、忽略什么、又以什么粒度去组织这些记忆?这些问题在通用 chatbot 场景下可以含糊过去,但在一个以本地知识管理为核心的工具里,必须给出明确的设计回答。

这篇文章会展开聊聊 YOLO 的记忆系统是如何设计与实现的。

一、YOLO 的记忆到底应该记什么?#

普通 chatbot 的记忆系统很大程度上要解决的是「我对你一无所知」的问题,所以我们可以看到 ChatGPT 的六模块画像系统什么都记:你的职业、项目、爱好、你上次聊了什么;但 YOLO 的用户坐在自己的 Obsidian vault 里面,而 vault 本身就是一个巨大的知识库,你的日记、项目文档、学习笔记全在那里,我们的 Agent 随时可以用 fs_readfs_search 去查。

所以 YOLO 的记忆不应该去复制 vault 里已有的信息,它应该记的是那些不会自然存在于任何一篇笔记里的东西

该记不该记
用户的个人信息(姓名、年龄、身份)某篇笔记的具体内容
用户和 AI 的交互偏好(语气、格式、禁忌)项目文档里的技术细节
AI 被纠正过的行为(“不要用破折号”)用户日记里写过的事
跨会话的任务连续性(“我们上次在做 X”)vault 里可以搜到的事实性知识
用户反复提及但没落在笔记里的隐性偏好已经被 Skills 覆盖的程序性知识

RAG 搜索处理的是世界知识,Skills 保存的是程序知识,而 Memory 管理的则是「我们之间的默契」。

二、存储方案:markdown 文档#

确定了「该记什么」之后,下一个问题就是「怎么存」。

在正式聊储存方案之前,我们必须要先明确一个前提:YOLO 的记忆内容应当会在每次对话开始时全量注入 system prompt。不做语义检索,不做相关性筛选,整个文件的内容一股脑塞进上下文里。这个决定直接决定了存储方案的设计方向:文件不能太大,格式不能太复杂,内容必须是人和模型都能一眼看懂的。

为什么选全量注入?因为个人助手场景下,记忆条目的量级就不大。50 条记忆大概 1500~2000 tokens,对现在动辄 200K 甚至 1M 的上下文窗口来说完全不值一提。如果为了这点数据量去搞语义匹配和动态召回,那是用高射炮打蚊子,工程复杂度上去了,效果反而可能更差(因为你引入了一个新的失败点:万一该召回的记忆没被召回呢?)。全量注入最大的好处是确定性:AI 每次对话都能看到所有记忆,不会遗漏,不会选择性失忆。

那什么时候需要升级呢?

如果用户的记忆条目超过 100 条(大概 4000+ tokens),可以考虑引入语义匹配。但如果你的个人助手记忆已经膨胀到 100 条以上,更应该做的是让 AI 主动合并和清理冗余条目,而不是给检索层加工作量。

好,回到存储格式本身。

1.储存格式:markdown+列表式#

我最早考虑过的方案是 YAML Frontmatter + 三段式结构:属性区存键值对、用 checkbox 当事实库、末尾追加日志。这种方案对通用 chatbot,或者更复杂的商业化 Agent 项目来说还行,但对 YOLO 来讲有点过度设计了。三套东西各自需要不同的解析逻辑,维护起来很烦,而且对用户来说也不直观。

因此,最终我选的方案是纯列表式:一条记忆一行,- 开头,存在 YOLO/memory/global.md 文件里,和 YOLO/skills/ 同级。理由有三个:

  1. 注入成本最低。 读记忆文件内容,塞进 <memory> 区块完事,不需要任何解析逻辑。
  2. 工具封装最自然。 列表结构天然对应增删改三种操作,插件层可以轻松封装出专门的记忆工具,模型只管传参,格式维护、编号分配这些脏活全由底层处理。
  3. 用户一目了然。 打开这个文件就能看到 AI 记住了什么,想删就直接删一行,零学习成本。

2.文件结构:分区 + 编号#

纯列表解决了格式问题,但如果 50 条记忆全摊在一起,用户看着会有点乱,AI 在管理时也缺乏抓手。所以在纯列表的基础上,我加了两层组织:分区编号

实际的记忆文档长这样:

# User Profile
> Long-term characteristics about the user. Update when user info changes.
- Profile_1: 用户叫xxx,今年 xx 岁,毕业于 xxx
- Profile_2: 用户正在开发 YOLO 插件,这是一个 Obsidian 的 AI 助手
# Preferences
> User's interaction preferences and behavioral patterns. Add when patterns emerge.
- Preference_1: 不喜欢对话结尾追问或反问
- Preference_2: 不要出现"不是……,而是"的句式
- Preference_3: 不要使用破折号
# Other Memory
> Contextual facts and temporary notes. Default category.
- Memory_1: 2025-03-15 开始设计 YOLO 的记忆系统
- Memory_2: 用户习惯深夜工作

三个分区各有定位:

  • User Profile 放用户的长期特征,身份、背景、正在做的事。这类信息相对稳定,变更频率低。
  • Preferences 放交互偏好和行为规则。用户纠正 AI 的行为(「不要用破折号」)、表达的格式偏好、沟通风格要求,都归这里。
  • Other Memory 是默认分区,放不好归类的上下文性信息、临时性事实、带时间标记的事件。AI 添加记忆时如果不确定该放哪个分区,就往这里丢。

每条记忆有一个编号(Profile_1Preference_3Memory_2),编号在分区内独立递增,不重用。删掉 Profile_2 之后下一条新增的是 Profile_3,不会回填。这个设计的核心好处是:AI 在更新和删除记忆时只需要指定编号,不需要输出旧的全文内容做匹配。从 memory_update(id="Preference_2", new_content="...") 到定位具体是哪一行,这件事变得确定且廉价。

3.双重记忆:全局 + Per-Assistant#

上面展示的文档解决了一个助手记什么、怎么记的问题,但 YOLO 支持多助手配置,用户可能同时拥有一个日常陪伴型助手、一个整理文档的秘书型助手、一个写报告的分析助手。这些助手的角色定位完全不同,它们需要记住的东西也不一样:陪伴助手需要记住你们之间的互动默契,工程助手需要记住你的偏好和项目上下文,分析助手需要记住你的写作风格和报告规范。如果所有记忆都塞在同一个文件里,不同角色的信息会彼此污染,还浪费 token。

所以 YOLO 的记忆系统分两层:全局记忆(Global Memory)和助手记忆(Assistant Memory)。所有记忆文件统一放在 YOLO/memory/ 目录下,全局记忆是 global.md,每个助手的记忆以助手名命名,比如 helper.mddev-helper.md

文件结构如下:

YOLO/
├── memory/
│ ├── global.md ← 全局记忆,所有助手共享
│ ├── helper.md ← Helper 的专属记忆
│ ├── dev-helper.md ← 工程助手的专属记忆
│ └── ...

全局 global.md 只放跨助手通用的信息:用户身份、年龄、职业、全局格式偏好(不要破折号、不要追问)这些不管哪个助手都需要知道的东西。分区结构和前面介绍的一样,三个区不变。

Per-Assistant 记忆文件 则放只跟当前助手角色相关的记忆,分区可以由助手自行组织。比如一个工程助手 dev-helper.md 可能长这样:

# Project Context
- Proj_1: YOLO 插件用 TypeScript 开发,基于 Obsidian API
- Proj_2: 记忆系统设计已完成,正在实现工具层
# Code Preferences
- Code_1: 偏好函数式风格
- Code_2: 不要过度抽象,能跑就行

对话开始时,插件把两层记忆合并注入 system prompt:

<memory>
<global>
{global.md 的内容}
</global>
<assistant>
{当前助手记忆文件的内容}
</assistant>
</memory>

<global><assistant> 标签分开,模型能清楚地区分哪些是所有助手都要知道的通用信息,哪些是只属于当前助手的上下文。

有一个边界处理:如果用户只用默认助手(没有自定义 assistant_instructions),就不需要创建 per-assistant 记忆文件,直接退化到单层全局记忆。插件层做个判断就行,对轻度用户零负担。

三、Memory 工具设计#

储存方案确定了,那我们的 AI Agent 应该如何操作这些记忆呢?

答案是 Tool Calling。让 AI 通过调用专门的记忆工具来增删改记忆条目,而不是直接用 fs_edit 去改文件。这样做有两个好处:一是插件层可以封装格式维护、编号分配这些脏活,AI 只管传参;二是可以加校验逻辑,防止 AI 把记忆文件写乱。

工具一共三个,先看签名:

memory_add(content: string, category?: string, scope?: 'global' | 'assistant')
→ 在指定分区末尾追加一条记忆,自动分配编号
→ category 默认为 'other'
→ scope 默认为 'assistant'(写到当前助手的记忆文件)
→ scope 为 'global' 时写到 global.md
→ 返回新记忆的 ID(如 "Profile_3"
memory_update(id: string, new_content: string, scope?: 'global' | 'assistant')
→ 根据 ID 定位并更新记忆内容
→ 保持编号不变,只改文本
→ scope 默认为 'assistant'
memory_delete(id: string, scope?: 'global' | 'assistant')
→ 根据 ID 删除记忆
→ 编号不回收
→ scope 默认为 'assistant'

三个工具都加了 scope 参数来对应双层记忆架构。默认值设成 assistant 是刻意的:日常对话中产生的记忆绝大多数都跟当前助手的角色相关,只有用户基本信息变更(换工作了、改名了、新增了一条全局格式偏好)才需要显式指定 scope: 'global'

签名本身很简单,没什么好说的。但光有工具不够,更重要的问题是:AI 什么时候该调用它们?怎么调用才不会乱?

触发策略:谁来决定「该记了」?#

最偷懒的做法是完全靠模型自觉,在 system prompt 里写一句「当你发现重要信息时请保存记忆」,然后祈祷它的判断力在线。这在目前旗舰 chat 模型上其实也能用,但不够稳定,你可能会碰到两种很典型的翻车:一种是该记的没记,用户说了三遍自己的名字 AI 还是没调用 memory_add;另一种是不该记的疯狂记,每句话都触发一次存储,记忆文件很快变成垃圾场。

当然有的模型实在是比较粪,我作为开发者其实也真的没有办法适配每一个模型的 taste,遑论大家的渠道五花八门的,有的可能是纯净官方 api,有的可能是 Claude code 乃至 kiro 逆向的,还有的可能是直接从 ChatGPT 官网逆向下来的 api,实在是众口难调。

YOLO 的做法是在 system prompt 的角色设定(assistant_instructions)里显式写明记忆行为的规范。具体来说是两条:

  1. 「你会主动使用记忆工具来新建/更新你的记忆。」 这是正向引导,告诉模型记忆操作是你的本职工作,不是可选项。
  2. 「你会定期删除已经冗余的无用记忆。」 这是反向约束,防止只增不删导致膨胀。

注意,这里没有规定具体的触发条件(比如「每 10 轮检查一次」或「当用户提到个人信息时」),因为这种硬编码规则反而会让模型的行为变得僵硬。实际测试下来,只要在角色设定里把「该记」和「该清理」这两个意识植入了,模型在大多数场景下的判断力是够用的。它会在用户自我介绍时自动存 profile,会在被纠正行为时自动存 preference,也会在发现两条记忆说的是同一件事时主动合并。

去重与冲突:AI 自己判断,还是插件层兜底?#

一个很自然的问题:如果 AI 想新增一条记忆,但记忆文件里其实已经有一条语义相近的了,怎么办?

比如记忆里已经有 Profile_1: 用户叫无敌暴龙兽,今年 21 岁,然后用户在新的对话里又提了一嘴自己的年龄,AI 是应该 memory_add 一条新的,还是 memory_update 已有的那条?

YOLO 的答案是:完全交给 AI 判断。 因为前面说过,记忆是全量注入的,AI 在每轮对话里都能看到所有已有记忆。它有足够的信息来决定「这条信息已经存在了,我应该更新而不是新增」。在 system prompt 里补一句「当一条记忆已经过长时,请不要再往里加新的内容了,请新建一条记忆」,就能覆盖绝大多数边界情况。

插件层不做语义去重。原因很简单:要做就得引入 embedding 和相似度计算,工程复杂度直接翻倍,而收益几乎为零。50 条以内的记忆,模型看一眼就知道有没有重复,比任何 cosine similarity 阈值都靠谱。

把上面这些串起来,一次典型的记忆操作流程是这样的:

  1. 对话开始,插件读取 memory.md 全文,注入 system prompt 的 <memory> 区块
  2. 用户在对话中提到:「对了我最近换工作了,现在在做独立开发」
  3. AI 看到 <memory> 里已有 Profile_2: 用户正在做 XX 项目,判断这条需要更新
  4. AI 调用 memory_update(id="Profile_2", new_content="用户目前是独立开发者")
  5. 插件定位到 Profile_2 所在行,替换内容,保存文件
  6. 工具返回成功,AI 继续对话,不需要特别告知用户(除非用户主动问「你记住了吗」)

整个过程对用户来说是无感的。如果用户好奇 AI 记了什么,打开 YOLO/memory.md 就能看到,想手动改也随时可以改。

四、记忆在YOLO上下文中的位置#

最后,把记忆系统放回 YOLO 的整体上下文构成里看一眼,明确一下它和其他模块的关系:

YOLO 上下文构成:
├── System Prompt(角色设定、行为规范)
├── <memory>(记忆系统 ← 本文主角)
│ ├── <global>(全局记忆,global.md)
│ └── <assistant>(当前助手专属记忆,助手名.md)
├── <available_tools>(工具索引)
├── <available_skills>(技能索引)
├── <custom_instructions>(用户自定义指令)
├── 对话历史
└── 工具调用结果(文件读写、搜索等)

Custom Instructions 是用户手写的静态规则,Skills 是按需加载的程序化能力,Memory 是 AI 在交互中自主积累的动态关系知识。三者各管一摊:Custom Instructions 管「用户要求我怎么做」,Skills 管「我知道怎么做」,Memory 管「我知道你是谁、我们之间发生过什么」。

RAG 搜索处理世界知识,Skills 保存程序知识,Memory 管理的是「我们之间的默契」。

记忆系统是 YOLO 在「理解用户」这件事上迈出的第一步,但远不是终点。YOLO 下一个大版本的重心是 RAG 系统重构,目前的文件搜索能力本质上还是关键词匹配,够用但谈不上聪明,我计划统一 embedding 语义检索与关键词检索的能力,让 AI 在翻 vault 的时候不再只是找到你说的那个词,它会真正理解你在找什么,做出更接近直觉的搜索体验。

再往后则会探索更激进的 Agent 能力,包括 Sub-Agent 架构Cron Agent(定时触发的自动化任务)。这两个方向的想象空间在于,它们可以和现有的记忆系统、Skills、RAG 模块深度联动:Sub-Agent 可以在后台持续观察你的 vault 变化,主动更新记忆条目或补全相关笔记;Cron Agent 则能定期执行你设定的任务,比如每周自动整理项目进度、同步日记里的待办事项、甚至根据你的行为模式提前准备好下周的工作计划。这些能力指向的是同一件事:YOLO 不只是你打开 Obsidian 之后主动提问才会回复的对话框,它应该是一个持续运行的、主动的、真正懂你的数字助理。

我们下篇更新日志再见!