一、先搞清楚:AI Agent 为什么需要"记忆"?
我们先从一个很日常的场景说起。假设你是一个"做菜助手",用户第一次问你"红烧肉怎么做"、第二次问"好吃吗"、第三次问"需要哪些食材"。
如果这个助手没有记忆,那么第三次它根本不知道用户前面说过什么------不知道用户在做红烧肉,不知道之前聊过"好吃吗"。这种情况下,每次对话都像和一个陌生人聊天,用户会很崩溃。
所以"记忆"的本质,就是让 AI 能带着过去的聊天记录一起思考。当我们把历史对话也交给模型时,模型才能理解"上下文",给出的回答才有连贯性。这就是大家常说的"多轮对话""上下文记忆"。
这里引入一个关键概念:SystemMessage(系统消息)、HumanMessage(用户消息)、AIMessage(助手消息)。
- 系统消息:给 AI 定"人设"和规则,比如"你是一个友好幽默的做菜助手"。
- 用户消息:用户说的话。
- 助手消息:AI 说的话。
一个完整的请求,通常就是"系统消息 + 一堆历史消息 + 当前用户消息"拼在一起发给模型。你可以把它想象成:每次让模型干活前,都要把它的"笔记本"(聊天记录)摊开给它看。
二、记忆放在哪里?三种存放方式
既然要记住历史,那这些聊天记录存在哪呢?演示项目里给了我们三种思路,从简单到强大,正好对应着从"临时"到"永久"、从"单个进程"到"海量检索"的演进。
1. 内存记忆(InMemoryChatMessageHistory)
src/history-test.mjs 里用到了它。所谓"内存记忆",就是用 JavaScript 的一个普通数组来存放聊天记录。
js
const history = new InMemoryChatMessageHistory();
await history.addMessage(userMessage1); // 往数组里塞一条消息
const messages = [systemMessage, ...(await history.getMessages())];
你可以把它理解成一个"只存在当前程序运行期间"的列表。它的优点非常粗暴------快、简单、零成本 ,不用连接任何数据库。但缺点是致命的:程序一重启,记录就全没了。
打个比方:内存记忆就像人的"短期记忆",关掉程序(人睡一觉)就忘光了。它适合做临时聊天、单个会话,但不适合需要长期保存的场景。
2. 文件记忆(FileSystemChatMessageHistory)
src/history-test2.mjs 和 src/history-test3.mjs 演示了升级版------把聊天记录写成文件 (比如 chat_history.json)。
js
const history = new FileSystemChatMessageHistory({
filePath, // 文件路径,比如 chat_history.json
sessionId, // 会话 ID,用来区分不同用户
});
关键点在于 sessionId。它解决了一个实际业务问题:多个用户同时使用你的应用,你不能把所有人的聊天记录混在一起。这个 ID 就像图书馆给每本书贴的标签,把属于同一个用户的对话归到同一堆。
文件记忆的好处是持久化 ------程序重启,数据还在文件里。history-test3.mjs 里专门演示了"重启后从文件恢复历史记录":
js
const restoredHistory = new FileSystemChatMessageHistory({
filePath,
sessionId,
});
const restoredMessages = await restoredHistory.getMessages();
你打开项目根目录下的 chat_history.json,就能看到保存下来的完整对话,甚至包含模型返回的 token 用量、finish_reason 等元信息。这就是文件记忆的直观体现。
但文件记忆也有局限:如果记录非常多(比如几万条),每次都要把整个文件读一遍,既慢又占内存。这时候就轮到第三种方案登场了。
3. 向量记忆(Vector Store / 数据库记忆)
在 src/history-test2.mjs 里,有一行没被真正使用、但很关键的导入:
js
import { Milvus } from "@langchain/community/vectorstores/milvus";
它暗示了第三种、也是目前大模型项目里最主流的方式------把记忆存进向量数据库(如 Milvus、Chroma、Pinecone)。
向量数据库的原理比较有趣:它不存"完整聊天记录",而是把每段文字转成一串数字向量(embedding,即"嵌入"),然后用向量相似度来检索。当用户问新问题时,系统会先从数据库里"找出和这个问题最相关的那几条历史记录",只把这几条塞给模型,而不是把全部历史都给它。
这解决了两个大问题:
- 效率:几万条历史不用全读,只取最相关的几条。
- 成本:给模型的内容越少,消耗的 token(见下文)就越少,花钱越少。
它相当于给 AI 装了一个"长期记忆+智能搜索"的大脑:不是什么都记,而是记住"关键的事",需要时精准调取。
三、核心难点:上下文窗口与 token
到这里,最核心的难点来了。你可能会想:既然记忆这么好,那我把所有历史都塞给模型不就行了吗?
**不行。**原因在"上下文窗口"和"token"这两个概念上。
1. 什么是 token?
模型的输入输出不是按"字"算的,而是按 token(词元) 算的。一个 token 大约等于:英文里的半个单词、中文里的一个字符或半个词。模型处理、收费,都按 token 计。
你可以把 token 想象成模型的"算力货币"。每次对话你发的 token 越多,越贵、越慢。 从 chat_history.json 里能清楚看到,模型返回里带了 tokenUsage:totalTokens、promptTokens、completionTokens,这就是每次对话烧掉的 token 数。
2. 什么是上下文窗口?
每个模型都有自己的上下文窗口上限,意思是"我一次最多只能看你这么多 token"。比如窗口是 8000 token,你硬塞 10000 token 的历史,模型要么报错,要么把最前面的内容"挤掉"、忽略掉。
所以现实是:历史记录会越攒越多,而模型能"记住"的长度有限。 这就像一个杯子的容量有限,水一直倒就会溢出来。这就是 Agent 记忆真正难的地方。
3. 怎么解决?------截断(Truncation)
src/memory/truncation-memory.mjs 这个文件,专门讲了上下文记忆管理的三个手段之一:截断。意思是"历史太多放不下时,只保留最近最有用的一部分"。
它演示了两种截断策略:
第一种:按消息条数截断
js
const trimmedMessages = allMessages.slice(-maxMessages); // 只留最近 4 条
思路很直白:如果记录太长了,干脆只保留最近 N 条,前面的往久远的全都丢掉。逻辑简单,但缺点也很明显------万一最关键的旧信息被丢了呢?
第二种:按 token 数量截断(生产环境更常用)
js
import { getEncoding } from "js-tiktoken";
const enc = getEncoding("cl100k_base");
const trimmedMessages = await trimMessages(allMessages, {
maxTokens: maxTokens, // 最多保留多少 token
tokenCounter: async (msgs) => countTokens(msgs, enc), // 精确计算 token
strategy: "latest", // 从最新的开始保留
});
这里用到了 trimMessages,它是 LangChain 内置的裁剪工具。它的厉害之处在于:它不是简单数条数,而是逐个计算每条消息占多少 token,然后把太老的、超预算的消息裁掉,只留下最新、最值钱的部分。 那句注释"不同 llm token 计算方式不一样",就是在提醒你:不同模型的 token 算法不同,所以要用 js-tiktoken 这种专业的库来精确计算,避免估错。
4. 更深一层:记忆管理的"三个手段"
文件注释里写了"上下文 memory 管理的三个手段,截断"。除了"截断",另外两个手段通常是总结(Summarization)和检索(Retrieval):
- 截断:装不下的,直接丢掉最新的之外的部分。最简单,但会流失信息。
- 总结:把很久以前的对话交给模型浓缩成一句摘要,用"一句话记住前情提要",既省空间又保留大意。
- 检索:用向量数据库按相关性调取最关键的历史(就是前文说的 Milvus 那套)。
这三个手段往往不是单选,而是组合使用。比如:一进来先看历史,命中相关的就调取(检索),太远了就总结,实在装不下再截断。