写在前面:今天课程的主题是 Memory 管理 ------给 AI Agent 装记忆。readme 第一句话就点明了核心:"Agent = LLM + Harness(tool + RAG + memory + ...)" 。LLM 本身是个语言模型,没有记忆、不会用工具、不会查知识库。所谓的 Agent,是给 LLM 套了一层"外骨骼"(Harness),让它有记忆、有工具、有知识。今天专攻其中的 Memory 模块 。readme 还说了一句大实话------"大模型是无状态的"。你跟它聊了三小时,关掉窗口再打开,它问你"你好,请问有什么可以帮您?"------跟没聊过一样。这就是金鱼。以下所有代码均来自课堂真实文件。
一、LLM 为什么是金鱼?
readme 原话:
"大模型是无状态的,基于上次的问答继续问,回答。之前已经通过 chatMessages 数组?做了简单的 Memory 管理。"
"无状态"(stateless)是什么意思?每次调用 API,LLM 都把你发过去的所有消息当作"全部历史"。它不会偷偷记住你上一次说了什么------如果你不把之前的对话塞进 messages 数组里,它就当你是个陌生人。
你跟 ChatGPT 聊天时感觉它"记得"之前说的内容,是因为客户端(App / 网页)帮你维护了一个 messages 数组,每次请求都把这个数组带上。
但这有个问题------readme 列了三个痛点:
markdown
1. 持久化 --- 关掉程序,内存里的消息全没了
2. 上下文窗口大小 200k --- 不是无限的,而且越大越贵
3. /compact 总结最近,/clear 清空 --- 需要主动管理
一句话总结:LLM 的记忆是借来的,不是自己的。 借的是 messages 数组,存在内存里。程序一关,记忆清零;聊太多,窗口爆满;不管理,费用起飞。
二、Memory 的两种类型
readme 把记忆分了两类:
markdown
临时记忆(短期记忆)
- 内存
- InMemoryChatMessageHistory
长期记忆
- 文件
- 向量数据库(Milvus)
| 类型 | 存储位置 | 特点 | 适用场景 |
|---|---|---|---|
| 短期记忆 | 内存 | 快,但程序关闭就没了 | 当前会话 |
| 长期记忆 | 文件 / 向量库 | 持久化,跨会话 | 历史对话、用户画像 |
readme 还给出了 Memory 的核心逻辑框架:
scss
存储逻辑:内存、文件、数据库
管理逻辑:截断(slice(-4))、总结、检索
存储解决"放哪",管理解决"留多少、怎么留"。 今天上篇讲存储(内存 + 文件)和管理(截断 + 总结),下篇讲向量数据库和检索。
三、第一层存储:内存------白板上的记忆
history-test.mjs 是最基础的短期记忆实现------用 InMemoryChatMessageHistory 把对话存在内存里。
白板的诞生
javascript
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
const history = new InMemoryChatMessageHistory();
InMemoryChatMessageHistory 就是块白板------白板立在教室里,写了什么都能看到,但放学后值日生一擦,干干净净。
readme 说的:
"用 InMemoryChatMessageHistory 来管理 message,放到内存里。用 addMessage 添加到 HumanMessage,AIMessage,ToolMessage。"
三种消息类型
LangChain 的消息有三种类型:
| 类型 | 含义 | 类比 |
|---|---|---|
HumanMessage |
用户说的话 | 学生提问 |
AIMessage |
AI 回的话 | 老师回答 |
ToolMessage |
工具返回的结果 | 助教查资料 |
白板上的对话流程
javascript
const systemMessage = new SystemMessage(
'你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧'
);
// 第一轮对话
const userMessage1 = new HumanMessage("你今天吃的什么?");
await history.addMessage(userMessage1); // 写到白板上
const messages1 = [systemMessage, ...(await history.getMessages())];
const response1 = await model.invoke(messages1); // 连白板一起发给LLM
await history.addMessage(response1); // 把AI的回答也写到白板上
流程很清晰:
- 用户说话 →
addMessage写到白板 - 白板内容 + 系统提示 → 打包发给 LLM
- LLM 回答 →
addMessage写到白板 - 下一轮对话时,白板上已经有了之前的完整记录
第二轮:金鱼"记住了"
javascript
// 第二轮对话 基于历史记录
const userMessage2 = new HumanMessage("好吃吗");
await history.addMessage(userMessage2);
const message2 = [systemMessage, ...(await history.getMessages())];
const response2 = await model.invoke(message2);
await history.addMessage(response2);
第二轮只说了"好吃吗"------没提之前聊的什么。但 LLM 能回答,因为 history.getMessages() 把第一轮的"你今天吃的什么"+ AI 的回答都带上了。
白板上写了"红烧肉",LLM 就知道"好吃吗"问的是红烧肉。 这就是短期记忆的效果------把历史消息塞进 context,让金鱼"假装"有记忆。
白板的查看
javascript
const allMessages = await history.getMessages();
console.log(`共保存了${allMessages.length}条对话`);
allMessages.forEach((msg, index) => {
const type = msg.type;
const prefix = type === 'human' ? '用户' : '助手';
console.log(`${index + 1}.[${prefix}]:${msg.content.substring(0, 50)}...`)
})
每条消息都有 type 属性------'human' 或 'ai'。通过它区分谁说的。
白板的致命缺陷
程序一关,内存清空,白板被擦干净。下次再启动,AI 又变回金鱼------"你好,请问有什么可以帮您?"
readme 给出了下一步的答案:
"InMemory 当前的 Agent。file 最近几次聊的。"
内存管当前会话,文件管跨会话。于是有了第二层存储。
四、第二层存储:文件------笔记本上的记忆
history-test2.mjs 把白板升级成了笔记本------FileSystemChatMessageHistory,对话写到 JSON 文件里。
从白板到笔记本
javascript
import { FileSystemChatMessageHistory } from '@langchain/community/stores/message/file_system';
import path from 'node:path';
const filePath = path.join(process.cwd(), 'chat_history.json');
const sessionId = "user_session_001"; // 多用户
const history = new FileSystemChatMessageHistory({
filePath,
sessionId,
});
两行代码,白板变笔记本。
关键参数:
| 参数 | 含义 | 类比 |
|---|---|---|
filePath |
文件路径 | 笔记本放在哪个抽屉 |
sessionId |
会话 ID | 笔记本上写谁的名字 |
sessionId:多用户的钥匙
sessionId = "user_session_001" 这行代码容易被忽略,但它是多用户系统的钥匙。
不同的用户用不同的 sessionId:
user_session_001→ 张三的笔记本user_session_002→ 李四的笔记本user_session_003→ 王五的笔记本
同一个文件,不同 sessionId,对话不会串。一个笔记本,多个标签页,互不干扰。
笔记本的用法跟白板一模一样
javascript
const userMessage1 = new HumanMessage("红烧肉怎么做");
await history.addMessage(userMessage1);
const messages1 = [systemMessage, ...(await history.getMessages())];
const response1 = await model.invoke(messages1);
await history.addMessage(response1);
API 完全一致------addMessage 写,getMessages 读。区别只在于数据存在哪:内存还是文件。
笔记本的真正威力:跨会话恢复
history-test3.mjs 展示了文件记忆的核心价值------程序重启后,从文件恢复历史对话。
javascript
// 从文件中恢复 history
const restoredHistory = new FileSystemChatMessageHistory({
filePath,
sessionId,
});
const restoredMessages = await restoredHistory.getMessages();
console.log(`从文件中共恢复了${restoredMessages.length}条历史信息`);
注释说得清清楚楚:
"从文件中恢复 history"
上次聊了什么,文件里都有。新建一个 FileSystemChatMessageHistory 实例,getMessages() 一调,历史对话全回来了。
javascript
// 第三轮对话
const userMessage3 = new HumanMessage("需要哪些食材");
await restoredHistory.addMessage(userMessage3);
const message3 = [systemMessage, ...(await restoredHistory.getMessages())];
const response3 = await model.invoke(message3);
第三轮问"需要哪些食材"------AI 能接上"红烧肉"的上下文,因为文件里存着第一轮和第二轮的对话。金鱼关掉水缸再打开,还能接着聊。
五、存储解决了,管理问题来了
记忆存下来了,但新的问题出现了------聊太多了怎么办?
readme 列了三个痛点:
markdown
1. 上下文窗口大小 200k --- 不是无限的
2. 开销 --- token 越多越贵
3. /compact 总结最近,/clear 清空
200k token 听起来很多,但一个长对话轻松破万 token。而且每次请求都要把全部历史消息发给 LLM------消息越多,费用越高。
readme 给出了三种管理策略:
scss
管理逻辑:截断(slice(-4))、总结、检索
| 策略 | 做什么 | 类比 | 丢失信息 |
|---|---|---|---|
| 截断 | 只留最近 N 条,老的直接扔 | 撕掉旧页 | 全丢 |
| 总结 | 老的消息压缩成摘要 | 写目录替代全文 | 部分丢 |
| 检索 | 按相关性从历史中搜索 | 查目录找相关页 | 不丢,按需取 |
今天上篇讲前两种,下篇讲检索。
六、截断记忆:撕掉旧页
truncation-memory.mjs 展示了两种截断方式------按消息数和按 token 数。
方式一:按消息数量截断
javascript
async function messageCountTruncation() {
const history = new InMemoryChatMessageHistory();
const maxMessages = 100; // 最多保留100条
// ... 添加8条消息 ...
let allMessages = await history.getMessages();
const trimmedMessages = allMessages.slice(-maxMessages); // 只留最近100条
}
核心就一行:allMessages.slice(-maxMessages)。
slice(-100) 的意思是------从数组末尾往前取 100 个。超过 100 条的,老的直接扔掉。就像一个只容纳 100 页的笔记本,写满了就把最前面几页撕掉。
readme 里也提到了这个:
"Memory 截断(slice(-4))。"
slice(-4) 就是只留最近 2 轮对话(每轮 1 条用户消息 + 1 条 AI 回复 = 2 条)。
简单粗暴,但有效。 最近的对话通常最相关,老的不留也无妨。
方式二:按 Token 数量截断
按消息数截断有个问题------一条消息可能 10 个 token,也可能 2000 个 token。100 条短消息和 100 条长消息的 token 开销天差地别。
所以生产环境更推荐按 token 截断。truncation-memory.mjs 用了 LangChain 自带的 trimMessages 工具:
javascript
import { trimMessages } from '@langchain/core/messages';
import { getEncoding } from 'js-tiktoken'; // 精准计算token开销
function countTokens(messages, encoding) {
let total = 0;
for (const msg of messages) {
const content = typeof msg.content === 'string' ? msg.content :
JSON.stringify(msg.content);
total += encoding.encode(content).length;
}
return total;
}
js-tiktoken 是 OpenAI 开源的 token 计算器------getEncoding('cl100k_base') 拿到编码器,encoder.encode(content).length 精确计算每条消息的 token 数。
readme 说的:
"trimMessages 帮我们实现了记忆 token 的截断。"
javascript
let trimmedMessages = await trimMessages(allMessages, {
maxTokens: maxTokens, // 最多保留100个token
tokenCounter: async (msgs) => countTokens(msgs, enc), // 自定义token计算
strategy: 'last', // 保留最近的
});
trimMessages 三个关键参数:
| 参数 | 含义 | 课堂取值 |
|---|---|---|
maxTokens |
最多保留多少 token | 100 |
tokenCounter |
怎么算 token | countTokens(用 tiktoken) |
strategy |
保留策略 | 'last'(留最近的) |
strategy: 'last' 会从最后一条消息往前数,累计 token 直到达到 maxTokens,超出的老消息全部丢弃。
readme 的注释也点明了两种方式的对比:
"messageCountTruncation:消息数量截断。tokenCountTruncation:token 数量截断,复杂,计算 token 开销,生产。"
消息数截断简单但不精确,token 截断精确但计算开销大。生产环境用 token 截断,开发环境用消息数截断。
七、总结记忆:写目录替代全文
截断直接扔老消息------简单但粗暴。如果老消息里有重要信息呢?比如用户在第 1 轮说了"我叫李四",到第 100 轮时被截断了,AI 就不认识李四了。
总结记忆解决了这个问题------老消息不扔,压缩成摘要。
summarization-memory.mjs 展示了按消息数量触发总结的方案:
核心思路
javascript
const maxMessages = 6; // 最多保留6条
const keepRecent = 2; // 保留最近2条
if (allMessages.length > maxMessages) {
const recentMessages = allMessages.slice(-keepRecent); // 最近的2条
const messagesToSummarize = allMessages.slice(0, -keepRecent); // 其余的总结
const summary = await summarizeHistory(messagesToSummarize);
await history.clear(); // 清空
for (const msg of recentMessages) {
await history.addMessage(msg); // 放回最近的
}
await history.addMessage(new AIMessage(summary)); // 放入总结
}
流程:
- 消息超过 6 条了
- 保留最近 2 条不动
- 前面 6 条交给 LLM 总结
- 清空 history
- 放回最近 2 条 + 1 条总结
8 条消息 → 3 条消息(2 条原文 + 1 条总结)。 压缩比接近 3:1。
怎么总结?
javascript
async function summarizeHistory(messages) {
const conversationText = getBufferString(messages, "用户", "助手");
const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:`;
const summaryResponse = await model.invoke(
[new SystemMessage(summaryPrompt)]
);
return summaryResponse.content;
}
getBufferString 把消息数组转成文本------"用户:我叫李四\n助手:你好李四..."。然后丢给 LLM 说"总结一下"。
readme 注释里还提到了 LangChain 的编排能力:
"langchain 编排线性工作流 workflow。langgraph 非线性工作流 graph。"
总结本身就是一次 LLM 调用------线性流程,LangChain 的 chain 就能搞定。如果是"根据总结结果决定保留几条"这种非线性流程,就得用 LangGraph 了。
按 Token 触发总结
summarization-memory2.mjs 升级了------从按消息数触发改为按 token 触发:
javascript
const enc = getEncoding('cl100k_base');
const maxTokens = 200; // 超过200 token触发总结
const keepRecentTokens = 80; // 保留最近80 token
const totalTokens = countTokens(allMessages, enc);
if (totalTokens >= maxTokens) {
// 从后往前累计,保留不超过80 token的消息
const recentMessages = [];
let recentTokens = 0;
for (let i = allMessages.length - 1; i >= 0; i--) {
const msg = allMessages[i];
const msgTokens = enc.encode(content).length;
if (recentTokens + msgTokens <= keepRecentTokens) {
recentMessages.unshift(msg);
recentTokens += msgTokens;
} else {
break;
}
}
// 其余的总结
const messagesToSummarize = allMessages.slice(0,
allMessages.length - recentMessages.length);
const summary = await summarizeHistory(messagesToSummarize);
await history.clear();
for (const msg of recentMessages) {
await history.addMessage(msg);
}
await history.addMessage(new AIMessage(summary));
}
跟截断的逻辑一样------按消息数简单,按 token 精确。这里用 maxTokens = 200 和 keepRecentTokens = 80 做课堂演示,实际生产中会设大得多。
readme 注释里提到了两个关键词:
"/clear /compact"
这正是 Claude Code / Codex 等 AI 编程工具的 /compact 和 /clear 命令------/compact 就是总结记忆(压缩历史),/clear 就是清空记忆。课堂讲的总结记忆,就是这些工具背后的底层逻辑。
八、三种策略对比
| 策略 | 做法 | 信息保留 | 计算开销 | 课堂文件 |
|---|---|---|---|---|
| 截断 | 扔掉老消息 | 0%(全丢) | 极低(slice) | truncation-memory.mjs |
| 总结 | 老消息→摘要 | ~60%(压缩) | 中等(1次LLM调用) | summarization-memory.mjs/2.mjs |
| 检索 | 按需搜索历史 | 100%(按需取) | 较高(向量搜索) | retrieval-memory.mjs(下篇) |
截断最省事,但像撕日记------撕了就没了。总结聪明些,像写目录------老内容压缩了但精华还在。检索最完整,像查图书馆------全保留,用到才取。
九、Memory 在 Agent 中的位置
回到 readme 开头那句话:
"Agent = LLM + Harness(tool + RAG + memory + ...)"
以及:
"Agent 执行流程 ReAct,message 数组 → Memory"
Agent 用 ReAct 模式(Reason + Act)执行任务时,每一步都会产生新的消息------思考(Thought)、行动(Action)、观察(Observation)。这些消息都要存到 Memory 里,下一步推理时带上。
csharp
用户提问
↓
[Memory 取出历史消息]
↓
LLM 推理(Thought)→ 选择工具(Action)→ 工具返回结果(Observation)
↓
[Memory 存入新的 Thought + Action + Observation]
↓
继续推理...直到给出最终答案
Memory 是 Agent 的"工作台"------所有中间状态都放在上面。没有 Memory,Agent 就是个一次性问答机器,每次都从零开始。
readme 最后还给出了一个完整目标:
"开发一个聊天应用 codex。每聊 20 句就发出一次总结,生成摘要,存入 milvus 向量数据库。从 milvus 取出历史对话,接着回答,Agent 更懂我们,Harness 的 Memory 模块。"
这就是上下两篇的完整路线图:
- 上篇(本篇):内存记忆 → 文件记忆 → 截断管理 → 总结管理
- 下篇:Milvus 向量数据库 → 插入对话 → 检索历史 → 完整的长期记忆闭环
PS:LLM 是条金鱼,7 秒记忆。但给它一块白板(内存)、一本笔记本(文件)、一把剪刀(截断)、一支荧光笔(总结),它就能假装自己是一头大象。下篇我们给金鱼建一座图书馆------Milvus 向量数据库。