记忆系统:让AI拥有长期记忆 ——CogitoAgent开发实战(六)
记忆系统:让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。
为什么需要 accessCount 和 accessedAt?
因为记忆有“热度”。经常被访问的记忆更“重要”。统计时可以用来排序,显示“最常访问的记忆”。
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 走一遍搜索流程
场景:用户说“帮我找一下代码项目相关的记忆”
记忆库:
- “代码在 D:\projects 里”(标签:代码、路径、项目)
- “今天天气不错”(标签:天气)
- “项目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条
六、记忆的完整生命周期
一条记忆从创建到被遗忘,经历以下阶段:
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集成 | 系统提示词说明 + 工具调用 |
核心设计原则:
- 记忆是可搜索的(评分机制)
- 记忆是可统计的(了解记忆分布)
- 记忆是可管理的(增删改查)
- 保存失败必须告知用户
下一篇预告:代码执行与沙箱
我们将深入 code.js 和 sandbox.js,看看:
- JavaScript 如何用
vm模块安全执行 - Python 如何通过临时文件隔离执行
- 沙箱如何防止原型链逃逸
- 超时和输出限制如何防止资源耗尽
如果这篇文章对你有帮助,欢迎 ⭐Star 支持一下开源项目!
更多推荐




所有评论(0)