欢迎光临
我们一直在努力

记忆系统:让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条

    六、记忆的完整生命周期

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

    #mermaid-svg-HbppjNij601657SN{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HbppjNij601657SN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HbppjNij601657SN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HbppjNij601657SN .error-icon{fill:#552222;}#mermaid-svg-HbppjNij601657SN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HbppjNij601657SN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HbppjNij601657SN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HbppjNij601657SN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HbppjNij601657SN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HbppjNij601657SN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HbppjNij601657SN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HbppjNij601657SN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HbppjNij601657SN .marker.cross{stroke:#333333;}#mermaid-svg-HbppjNij601657SN svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HbppjNij601657SN p{margin:0;}#mermaid-svg-HbppjNij601657SN .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-HbppjNij601657SN .cluster-label text{fill:#333;}#mermaid-svg-HbppjNij601657SN .cluster-label span{color:#333;}#mermaid-svg-HbppjNij601657SN .cluster-label span p{background-color:transparent;}#mermaid-svg-HbppjNij601657SN .label text,#mermaid-svg-HbppjNij601657SN span{fill:#333;color:#333;}#mermaid-svg-HbppjNij601657SN .node rect,#mermaid-svg-HbppjNij601657SN .node circle,#mermaid-svg-HbppjNij601657SN .node ellipse,#mermaid-svg-HbppjNij601657SN .node polygon,#mermaid-svg-HbppjNij601657SN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HbppjNij601657SN .rough-node .label text,#mermaid-svg-HbppjNij601657SN .node .label text,#mermaid-svg-HbppjNij601657SN .image-shape .label,#mermaid-svg-HbppjNij601657SN .icon-shape .label{text-anchor:middle;}#mermaid-svg-HbppjNij601657SN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HbppjNij601657SN .rough-node .label,#mermaid-svg-HbppjNij601657SN .node .label,#mermaid-svg-HbppjNij601657SN .image-shape .label,#mermaid-svg-HbppjNij601657SN .icon-shape .label{text-align:center;}#mermaid-svg-HbppjNij601657SN .node.clickable{cursor:pointer;}#mermaid-svg-HbppjNij601657SN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HbppjNij601657SN .arrowheadPath{fill:#333333;}#mermaid-svg-HbppjNij601657SN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HbppjNij601657SN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HbppjNij601657SN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HbppjNij601657SN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HbppjNij601657SN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HbppjNij601657SN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HbppjNij601657SN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HbppjNij601657SN .cluster text{fill:#333;}#mermaid-svg-HbppjNij601657SN .cluster span{color:#333;}#mermaid-svg-HbppjNij601657SN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HbppjNij601657SN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HbppjNij601657SN rect.text{fill:none;stroke-width:0;}#mermaid-svg-HbppjNij601657SN .icon-shape,#mermaid-svg-HbppjNij601657SN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HbppjNij601657SN .icon-shape p,#mermaid-svg-HbppjNij601657SN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HbppjNij601657SN .icon-shape .label rect,#mermaid-svg-HbppjNij601657SN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HbppjNij601657SN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HbppjNij601657SN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HbppjNij601657SN :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    创建

    存储

    被搜索

    访问计数+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集成 系统提示词说明 + 工具调用

    核心设计原则:

  • 记忆是可搜索的(评分机制)
  • 记忆是可统计的(了解记忆分布)
  • 记忆是可管理的(增删改查)
  • 保存失败必须告知用户
  • 下一篇预告:代码执行与沙箱

    我们将深入 code.js 和 sandbox.js,看看:

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

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

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

    赞(0)
    未经允许不得转载:171主机测评 » 记忆系统:让AI拥有长期记忆 ——CogitoAgent开发实战(六)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址