记忆系统:让AI拥有长期记忆

——CogitoAgent开发实战(第6篇)

📖 本文是专栏的第六篇。前五篇我们让AI学会了思考、动手、管理文件、联网、说话。但它仍然有一个致命的缺陷:它记不住。每次启动都是全新的开始,昨天聊过的内容、发现过的文件、做过的决定,全部归零。这一篇,我们给AI装上“大脑皮层”——记忆系统。

在这里插入图片描述


📌 从一个生活场景开始

想象你有一个助理。

第一天,他帮你整理文件,你告诉他“我的代码都在 D:\projects 里”。他很能干,帮你整理好了。

第二天,你问他“我那个项目放在哪了?”他说:“什么项目?我不知道啊。”

你会不会觉得这个人有问题?昨天刚说过的话,今天就忘了。

这就是没有记忆的AI。每次对话都是“第一次见面”,没有任何延续性。

CogitoAgent 的记忆系统,就是为了解决这个问题。


一、记忆系统要回答三个问题

在设计记忆系统之前,我们先想清楚要解决什么问题:

问题 通俗解释
怎么存? AI 发现的信息,以什么格式保存?
怎么找? 用户问起时,怎么快速找到相关记忆?
怎么用? 找到之后,怎么让 AI 理解并使用这些记忆?

这三个问题,对应着记忆系统的三个核心模块。


二、怎么存?——记忆的数据结构

2.1 一条记忆包含什么?

想象你要记住一件事,你会记下什么?

“2024年3月15日,用户说他的代码在 D:\projects 里。”

你会记下:

  • 内容:代码在 D:\projects
  • 时间:2024年3月15日
  • 来源:用户说的
  • 可能还想加个标签,方便以后找

CogitoAgent 的记忆结构也是这样设计的:

{
  id: 1710508800000,              // 唯一ID(时间戳)
  content: "代码在 D:\\projects 里", // 记忆内容
  tags: ["代码", "路径", "项目"],    // 标签,方便检索
  category: "general",            // 分类
  createdAt: "2024-03-15T...",    // 创建时间
  accessedAt: "2024-03-15T...",   // 最后访问时间
  accessCount: 0                  // 被访问次数
}

为什么用时间戳做ID?

简单。Date.now() 返回一个递增的数字,保证唯一,不需要额外生成UUID。

为什么需要 accessCountaccessedAt

因为记忆有“热度”。经常被访问的记忆更“重要”。统计时可以用来排序,显示“最常访问的记忆”。

2.2 标签的作用

标签是记忆系统的核心检索手段

用户说“帮我找一下关于代码的记忆”,程序搜索标签中包含“代码”的记忆。

用户说“还记得我那个项目路径吗?”,程序搜索标签中包含“路径”和“项目”的记忆。

标签的设计原则

  • 用户不需要手动打标签(AI自动生成,或用户自由输入)
  • 标签数量不限,但建议3-5个
  • 标签统一转为小写,方便匹配

2.3 分类的作用

分类是比标签更高层次的组织方式。

分类 用途 示例
general 通用记忆 “用户喜欢Python”
project 项目相关 “项目X在D:\projects\x”
personal 个人信息 “用户的名字是张三”
work 工作相关 “周报在每周五提交”

分类不是必须的,但可以帮助用户按主题浏览记忆。

2.4 存储:JSON文件

记忆数据存储在 data/memory.json 中:

[
  {
    "id": 1710508800000,
    "content": "代码在 D:\\projects 里",
    "tags": ["代码", "路径", "项目"],
    "category": "general",
    "createdAt": "2024-03-15T08:30:00.000Z",
    "accessedAt": "2024-03-15T08:30:00.000Z",
    "accessCount": 0
  }
]

为什么用JSON文件而不是数据库?

简单。对于个人使用场景,记忆数量通常不会超过几千条,JSON文件完全够用。不需要引入SQLite等重型方案。


三、怎么存?——保存逻辑的进化

3.1 第一版:简单读写

最直接的实现:

async function addMemory(content, tags = [], category = 'general') {
  const memory = {
    id: Date.now(),
    content,
    tags: tags.map(t => t.toLowerCase()),
    category: category.toLowerCase(),
    createdAt: new Date().toISOString(),
    accessedAt: new Date().toISOString(),
    accessCount: 0
  };
  
  memories.push(memory);
  await saveMemory();
  return memory;
}

问题:如果保存失败,用户不知道。

3.2 改进版:抛出错误

async function saveMemory() {
  try {
    await fs.writeFile(MEMORY_FILE, JSON.stringify(memories, null, 2), 'utf-8');
    return true;
  } catch (e) {
    const error = new Error(`[记忆] 保存失败: ${e.message}`);
    error.code = 'MEMORY_SAVE_FAILED';
    throw error;  // 抛出错误,让调用者处理
  }
}

改进点

  • 保存失败时抛出错误,而不是静默忽略
  • 错误携带 code 字段,便于调用方判断错误类型
  • 调用方(addMemory)可以 try-catch 并返回给用户

3.3 加载失败的处理

async function loadMemory() {
  try {
    if (await fs.access(MEMORY_FILE).then(() => true).catch(() => false)) {
      const data = await fs.readFile(MEMORY_FILE, 'utf-8');
      memories = JSON.parse(data);
    }
  } catch (e) {
    console.error(`[记忆] 加载失败: ${e.message}`);
    memories = [];  // 加载失败时重置为空数组
  }
}

设计原则:加载失败不导致程序崩溃,而是重置为初始状态(空记忆)。


四、怎么找?——搜索与匹配

4.1 问题:如何找到“相关”的记忆?

用户问:“我的代码项目放在哪了?”

记忆系统里可能存了100条记忆。怎么找出最相关的那条?

直觉方案:看记忆内容里有没有包含“代码”“项目”“路径”这些词。

但问题是:一条记忆可能包含这些词的多个,有些匹配度高,有些匹配度低。我们需要一个评分机制

4.2 评分机制的设计

async function searchMemory(query, limit = 10) {
  const queryLower = query.toLowerCase();
  
  const results = memories
    .map(memory => {
      let score = 0;
      
      // 内容匹配:最高分
      if (memory.content.toLowerCase().includes(queryLower)) {
        score += 10;
      }
      
      // 标签匹配:中等分
      memory.tags.forEach(tag => {
        if (tag.includes(queryLower)) {
          score += 5;
        }
      });
      
      // 分类匹配:低分
      if (memory.category.toLowerCase().includes(queryLower)) {
        score += 3;
      }
      
      return { ...memory, score };
    })
    .filter(m => m.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, limit);
  
  // 更新访问统计
  results.forEach(r => {
    const idx = memories.findIndex(m => m.id === r.id);
    if (idx !== -1) {
      memories[idx].accessCount++;
      memories[idx].accessedAt = new Date().toISOString();
    }
  });
  
  await saveMemory();
  return results;
}

评分权重

匹配类型 分值 为什么这么分配?
内容匹配 +10 内容是最直接的证据
标签匹配 +5 标签是人为提炼的关键词
分类匹配 +3 分类是宽泛的关联

4.3 走一遍搜索流程

场景:用户说“帮我找一下代码项目相关的记忆”

记忆库

  1. “代码在 D:\projects 里”(标签:代码、路径、项目)
  2. “今天天气不错”(标签:天气)
  3. “项目X使用React开发”(标签:项目、React)

搜索过程

query = "代码项目"

记忆1: 
  内容包含 "代码" → +10
  标签 "代码" 包含 "代码" → +5
  标签 "项目" 包含 "项目" → +5
  总分 = 20

记忆3:
  标签 "项目" 包含 "项目" → +5
  总分 = 5

记忆2:
  无匹配 → 0(被过滤掉)

结果排序:记忆1(20分)> 记忆3(5分)

4.4 改进:更精确的匹配

当前实现是简单的 includes 匹配。这意味着:

  • 搜索“代码”能匹配到“代码在D盘”
  • 但不能匹配到“coding”(中文分词问题)

如果要更精确,可以引入中文分词库(如 nodejieba):

import jieba from 'nodejieba';

function tokenize(text) {
  return jieba.cut(text);
}

// 搜索时,提取查询词和记忆内容的分词,计算重合度

但对于个人使用场景,includes 已经足够。过度工程化是危险的。


五、怎么用?——记忆的统计与管理

5.1 记忆统计

getMemoryStats 函数提供记忆的统计信息:

async function getMemoryStats() {
  const stats = {
    total: memories.length,
    byCategory: {},      // 各分类的数量
    topTags: {},         // 各标签的数量
    mostAccessed: []     // 最常访问的5条记忆
  };
  
  memories.forEach(memory => {
    stats.byCategory[memory.category] = (stats.byCategory[memory.category] || 0) + 1;
    memory.tags.forEach(tag => {
      stats.topTags[tag] = (stats.topTags[tag] || 0) + 1;
    });
  });
  
  stats.mostAccessed = memories
    .sort((a, b) => b.accessCount - a.accessCount)
    .slice(0, 5);
  
  return stats;
}

输出示例

{
  "total": 47,
  "byCategory": {
    "general": 30,
    "project": 10,
    "personal": 7
  },
  "topTags": {
    "代码": 15,
    "路径": 12,
    "项目": 8,
    "React": 5
  },
  "mostAccessed": [...]
}

5.2 相关记忆推荐

当用户查看一条记忆时,系统可以推荐“相关记忆”:

async function getRelatedMemories(id, limit = 5) {
  const memory = memories.find(m => m.id === parseInt(id));
  if (!memory) return { success: false, error: '记忆不存在' };
  
  const related = memories
    .filter(m => m.id !== parseInt(id))
    .map(m => {
      let score = 0;
      memory.tags.forEach(tag => {
        if (m.tags.includes(tag)) score++;
      });
      if (m.category === memory.category) score += 2;
      return { ...m, score };
    })
    .filter(m => m.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, limit);
  
  return { success: true, data: related };
}

工作原理

  • 计算标签重合度(相同标签越多,越相关)
  • 分类相同额外加分
  • 按分数排序,返回前N条

六、记忆的完整生命周期

一条记忆从创建到被遗忘,经历以下阶段:

创建

存储

被搜索

访问计数+1

被更新

被删除/归档

6.1 创建

用户或AI调用 addMemory

// AI 调用
[TOOL] addMemory("用户说他的代码在 D:\\projects", ["代码", "路径", "项目"]) [/TOOL]

6.2 存储

程序将记忆保存到 data/memory.json

6.3 搜索

用户或AI调用 searchMemory

// 用户问 "我的项目放在哪了?"
// AI 调用
[TOOL] searchMemory("项目 路径", 5) [/TOOL]

程序返回匹配的记忆,并按相关度排序。

6.4 访问计数更新

每次搜索命中,相应记忆的 accessCount++accessedAt 更新。

6.5 更新

用户可以更新记忆内容:

[TOOL] updateMemory(1710508800000, { content: "代码在 D:\\projects\\cogito-agent" }) [/TOOL]

6.6 删除

用户可以删除不再需要的记忆:

[TOOL] deleteMemory(1710508800000) [/TOOL]

七、AI如何使用记忆系统?

7.1 系统提示词中的说明

为了让AI知道记忆系统的存在,需要在系统提示词中说明:

## 记忆系统工具
- addMemory(content, tags, category) - 添加记忆
- searchMemory(query, limit) - 搜索记忆
- getAllMemories(category) - 获取所有记忆
- getMemory(id) - 获取记忆详情
- updateMemory(id, updates) - 更新记忆
- deleteMemory(id) - 删除记忆
- getMemoryStats() - 获取记忆统计
- getRelatedMemories(id, limit) - 获取相关记忆

7.2 AI的典型使用场景

场景1:用户主动告知信息

用户:我的代码都在 D:\projects 里。

AI:[TOOL] addMemory("用户的代码在 D:\\projects", ["代码", "路径"], "project") [/TOOL]
AI:好的,我记住了。

场景2:AI主动回忆

用户:我的项目放在哪了?

AI:[TOOL] searchMemory("项目 路径", 3) [/TOOL]
AI:根据记忆,您的代码在 D:\projects 里。

场景3:AI主动发现并记忆

AI:刚才我在 docs/ 目录里发现了一个项目说明文档,记录了项目X的架构。
AI:[TOOL] addMemory("项目X的架构说明在 docs/架构.md", ["项目X", "架构", "文档"], "project") [/TOOL]
AI:我把这个信息记下来了,以后您问起项目X时我可以帮您回忆。

八、设计决策回顾

决策 原因
用JSON存储 简单,够用,不需要额外依赖
用时间戳做ID 唯一且递增,无需额外生成
评分搜索 找到最相关的记忆,而不是所有匹配
访问计数 追踪记忆“热度”,发现重要记忆
标签+分类 两种粒度的检索手段
保存失败抛出错误 用户需要知道保存是否成功
加载失败重置为空 程序不应因数据损坏而崩溃

九、与人类记忆的类比

人类记忆特点 CogitoAgent 实现
短期记忆 对话历史(conversation.json
长期记忆 记忆系统(memory.json
遗忘曲线 无(不会主动遗忘,需要用户删除)
联想记忆 标签匹配 + 分类匹配
情境记忆 category 字段
程序记忆 工具调用的方式(AI知道怎么调用记忆工具)

一个有趣的差异:人类的记忆会随时间衰退,但 CogitoAgent 的记忆不会。除非用户主动删除,否则记忆永久保存。这既是优点(可靠),也是缺点(信息过载)。


十、小结

这一篇讲了记忆系统的实现:

功能 核心实现
存储 JSON文件 + 结构化数据
搜索 评分机制(内容+标签+分类)
统计 分类统计 + 标签统计 + 访问排行
推荐 标签重合度 + 分类匹配
AI集成 系统提示词说明 + 工具调用

核心设计原则

  1. 记忆是可搜索的(评分机制)
  2. 记忆是可统计的(了解记忆分布)
  3. 记忆是可管理的(增删改查)
  4. 保存失败必须告知用户

下一篇预告:代码执行与沙箱

我们将深入 code.jssandbox.js,看看:

  • JavaScript 如何用 vm 模块安全执行
  • Python 如何通过临时文件隔离执行
  • 沙箱如何防止原型链逃逸
  • 超时和输出限制如何防止资源耗尽

如果这篇文章对你有帮助,欢迎 ⭐Star 支持一下开源项目!

👉 https://gitee.com/cnt-code/cogito-agent 👈

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐