❗ 给金鱼装记忆(上):从白板到笔记本,AI 的短期记忆生存指南

写在前面:今天课程的主题是 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的回答也写到白板上

流程很清晰:

  1. 用户说话 → addMessage 写到白板
  2. 白板内容 + 系统提示 → 打包发给 LLM
  3. LLM 回答 → addMessage 写到白板
  4. 下一轮对话时,白板上已经有了之前的完整记录

第二轮:金鱼"记住了"

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));  // 放入总结
}

流程:

  1. 消息超过 6 条了
  2. 保留最近 2 条不动
  3. 前面 6 条交给 LLM 总结
  4. 清空 history
  5. 放回最近 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 = 200keepRecentTokens = 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 向量数据库。

相关推荐
酷酷的逗逗乐1 小时前
前端转 Agent 开发 · 第五节:Memory 会话记忆
前端·程序员
掘金挖土1 小时前
前端手摸手跑路之 AI 应用开发(二)
前端·后端
兜里只有三分钱~1 小时前
翻卡_3D 的仪式感,和三个让它穿帮的写法
javascript·css·3d
mayaairi2 小时前
Vue2 组件通讯(四):v-model、scoped样式、mixins与plugins
前端·javascript·vue.js
梦曦i2 小时前
@meng-xi/uni-router 未来展望:夯实基础、深化体验、探索前沿
前端·uni-app
前端炒粉2 小时前
DOM 常用 API 速查表
开发语言·javascript·ecmascript
Yeyu2 小时前
AAOS AppCard 实践:怎么把自己的 App 塞进别人的卡片里
前端
星云API技术支持2 小时前
企业微信二次开发:如何建立统一的错误处理、日志与请求追踪机制
javascript·企业微信·ruby
IT_陈寒3 小时前
Vite静态资源路径这个大坑害我调了一下午
前端·人工智能·后端