一个每次对话都"失忆"的 Agent 是没有灵魂的。这篇手把手带你把 Agent 的对话历史管起来:内存记忆 → 文件持久化 → 上下文截断,一步一个坑地升级。文末附完整可运行项目代码,也欢迎追这个"记忆专题"的连载 🚀
前言
你有没有遇到过这种情况:刚跟 AI 助手聊完"今天吃了什么",下一句问"好吃吗",它却一脸茫然?
原因很简单------大模型本身是无状态的。它收到什么上下文,就只基于什么回答。你如果不把之前的对话"喂"给它,它当然不记得。
那该怎么办?答案就是给 Agent 装一个 记忆模块(Memory)。这篇文章会基于一个真实的 LangChain 教学项目,带你把记忆模块的"第一层地基"搭起来:
- 内存记忆 :会话期间记住对话(
InMemoryChatMessageHistory) - 文件持久化 :程序重启后记忆不丢(
FileSystemChatMessageHistory) - 上下文截断:对话太长时如何优雅地"瘦身"(按条数 / 按 token)
适合谁看 :会用 JS/TS,对 LangChain 有基础了解,想知道 Agent 记忆到底怎么做的同学。 读完能收获 :理解对话历史对象(ChatMessageHistory)的设计思路,并能写出一个不会"聊完就忘"的 Agent。 能追的连载:这篇是记忆模块"三步走"的第一篇,文末会预告续集:老消息的自动总结、向量库长期记忆。
📦 完整可运行的示例代码在文末,仓库链接 + 启动命令都给你准备好了。
项目概览
项目里做了一个 "做菜助手" 的对话应用,用一个故事线演示记忆升级的三个阶段:
text
阶段一:内存记忆(会话内记得住)
└─ 进程结束 → 记忆全丢 😱
阶段二:文件持久化(重启后还记得)
└─ 消息越来越多 → 上下文膨胀 💥
阶段三:上下文截断(只留最相关的)
└─ 老消息被丢弃 → 需要总结压缩(下一篇预告)
文件结构(本文涉及的部分):
text
memory/demo
├── .env # API Key 等环境配置
├── package.json
├── chat_history.json # 文件记忆生成的 JSON(运行后自动生成)
└── src
├── history-test.mjs # ① 内存记忆 InMemoryChatMessageHistory
├── history-test2.mjs # ② 文件记忆:写入 chat_history.json
├── history-test3.mjs # ③ 文件记忆:重启后从文件恢复
└── memory
└── truncation-memory.mjs # ④ 上下文截断(条数 / token 两种)
核心技术点:
| 技术 | 用途 |
|---|---|
@langchain/core/chat_history |
对话历史容器(内存版、文件版) |
@langchain/core/messages |
消息对象:HumanMessage / AIMessage / SystemMessage |
@langchain/community |
FileSystemChatMessageHistory 文件版历史 |
js-tiktoken |
精确计算消息的 token 数量 |
依赖版本:@langchain/core ^1.2.3、@langchain/openai ^1.5.5、langchain ^1.5.10、js-tiktoken ^1.0.21(详见文末)。
记忆的载体:一张会"增长"的消息列表
无论哪种记忆,本质都殊途同归:把对话一条条按顺序存起来。LangChain 里用「历史对象 + 消息对象」来建模:
HumanMessage(用户说的)、AIMessage(助手答的)、SystemMessage(系统人设)------ 三种消息类型。ChatMessageHistory系列 ------ 专门管理这条"消息流水账"的容器,核心就是addMessage()追加、getMessages()取回。
记住这个模型,后面三个阶段的代码就都好懂了。下面我们沿着故事线一步步升级 👇
阶段一:内存记忆 ------ 让 Agent 记住当前会话
先看最简单的做法(history-test.mjs):
js
// ① 建一个"内存账本"
const history = new InMemoryChatMessageHistory();
// 第一轮:用户提问
const userMessage1 = new HumanMessage('你今天吃的什么');
await history.addMessage(userMessage1); // 记一笔
// 把 system 人设 + 账本里的历史 一起发给模型
const message1 = [systemMessage, ...(await history.getMessages())];
const response1 = await model.invoke(message1);
// 🔑 关键:把模型的回答也记回去!
await history.addMessage(response1);
// 第二轮:这时 history 里已经有第一轮的完整对话了
const userMessage2 = new HumanMessage('好吃吗');
await history.addMessage(userMessage2);
const message2 = [systemMessage, ...(await history.getMessages())];
const response2 = await model.invoke(message2); // ✅ 它"记得"前面聊过菜了
await history.addMessage(response2);
逐行拆解:
| 代码 | 作用 |
|---|---|
new InMemoryChatMessageHistory() |
在内存里建一个对话"账本",内部就是一条消息数组 |
history.addMessage(userMessage1) |
用户提问先入账 |
[systemMessage, ...history] |
把「人设 + 历史」拼成一个完整的消息数组发给模型 |
await history.addMessage(response1) |
模型的回答也入账,账本才完整 |
第二轮复用同一个 history |
模型就能"看到"第一轮聊过什么 |
底层逻辑 :model.invoke() 是"一次性"的------你这次传了什么,它只根据什么回答。所谓"记忆",就是每次提问前,把之前的对话手动塞进 prompt 。而 InMemoryChatMessageHistory 的账本,本质上就是一个保存在内存里的数组,你手动 getMessages() 再展开(...)进去即可。
💡 一句话总结:记忆 = 每次请求时把历史拼进上下文。
但坑马上来了:内存是"断电即失"的。程序一重启,账本清空,Agent 又变回金鱼记忆 🐟。
阶段二:文件记忆 ------ 程序重启也不丢
把账本从"内存"搬到"磁盘文件",就是升级(对比 history-test2.mjs 的注释------它原本引用的 InMemoryChatMessageHistory 被注释掉,换成了文件版,这正是在展示"升级路线"):
js
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 });
const userMessage1 = new HumanMessage('红烧肉怎么做');
await history.addMessage(userMessage1);
const response1 = await model.invoke([systemMessage, ...(await history.getMessages())]);
await history.addMessage(response1);
const userMessage2 = new HumanMessage('好吃吗?');
await history.addMessage(userMessage2);
const response2 = await model.invoke([systemMessage, ...(await history.getMessages())]);
await history.addMessage(response2);
逐行拆解:
| 代码 | 作用 |
|---|---|
filePath |
记忆存到 chat_history.json,跑完可以打开看看真实数据 |
sessionId: 'user_session_001' |
同一文件里按用户/session 隔离,支持多用户 |
FileSystemChatMessageHistory |
接口和内存版一模一样 (都是 addMessage/getMessages),只是落盘了 |
记忆真的被"记住"了吗 ?关键在第三个文件 history-test3.mjs------模拟"重启进程"后恢复记忆:
js
// 重启后,重新 new 一个对象,从同一个文件读回来
const restoreHistory = new FileSystemChatMessageHistory({ filePath, sessionId });
const restoreMessages = await restoreHistory.getMessages(); // 🔑 恢复出历史!
console.log(`共恢复了: ${restoreMessages.length}条历史信息`);
// 直接接着第三轮聊,模型记得前两轮聊过"红烧肉"
const userMessage3 = new HumanMessage('需要哪些食材');
await restoreHistory.addMessage(userMessage3);
const message3 = [systemMessage, ...(await restoreHistory.getMessages())];
const response3 = await model.invoke(message3);
底层逻辑 :前一个进程把对话写进了 chat_history.json;新进程用相同的文件 + 相同的 sessionId 重新实例化,就能把历史原样读回来,无缝续聊。这就是"持久化记忆"------记忆从进程内,升级到了磁盘上。
💡 小知识点:
addMessage()是异步的(返回 Promise),文件版还要等待磁盘写入。history-test2.mjs里注释掉的Promise.reject/resolve正是作者随手复习 Promise 三种状态(pending → resolved / rejected)的草稿,不影响主逻辑。
但第二个坑来了 :对话会一直增长。聊一百轮,历史就有几百条;全塞进上下文,轻则 token 费用飙升,重则直接超出模型的上下文窗口报错。于是进入阶段三 👇
阶段三:上下文截断 ------ 给记忆"瘦身"
截断是 LangChain 上下文管理的"第一招"(truncation-memory.mjs)。作者在文件头的注释里点明了上下文管理的"三个手段",也为后续埋了伏笔:
js
// 上下文 memory 管理的三个手段:
// ① 被裁剪掉的老消息 → (总结)
// ② 截断(只留最近)
// ③ history.clear() 后放新的 messages
方案 A:按消息条数截断(简单粗暴)
js
const maxMessages = 4;
const allMessages = await history.getMessages();
// 直接留"最后 4 条",最老的丢掉
const trimmedMessages = allMessages.slice(-maxMessages);
一条 slice(-maxMessages) 就搞定:留最近的 maxMessages 条,扔掉更老的。
缺陷也很明显 :消息有长有短------"嗯"和一篇 800 字的回答占的 token 天差地别。"条数"不等于"token 量",可能在窗口边缘反复横跳。生产环境更可靠的是按 token 截断 👇
方案 B:按 token 数量截断(精确控费)
js
import { trimMessages } from '@langchain/core/messages';
import { getEncoding } from 'js-tiktoken';
// 一个准确的 token 计数器(把每条消息编码成 token 后数长度)
function countTokens(messages, encoder) {
let total = 0;
for (const msg of messages) {
const content = typeof msg.content === 'string'
? msg.content : JSON.stringify(msg.content);
total += encoder.encode(content).length;
}
return total;
}
const enc = getEncoding('cl100k_base'); // OpenAI 的 token 编码器
const trimmedMessages = await trimMessages(allMessages, {
maxTokens: 100, // 预算:总 token 不超过 100
tokenCounter: async (msgs) => countTokens(msgs, enc),
strategy: 'last', // 策略:保最新,丢最老
});
逐行拆解:
| 代码 | 作用 |
|---|---|
getEncoding('cl100k_base') |
拿到 OpenAI 官方的 token 编码器,能精确算出字符串占多少 token |
countTokens(...) |
遍历每条消息,对内容编码后累加长度,得到真实 token 数 |
tokenCounter |
告诉 trimMessages 怎么"称重"每条消息 |
strategy: 'last' |
优先保留最近的消息,超预算就从最老的开始砍 |
maxTokens: 100 |
裁剪后总 token 的硬上限 |
底层逻辑 :trimMessages 会用你给的 tokenCounter 反复"称量"整段历史,在不超过 maxTokens 预算 的前提下,尽量多留靠近当前的最新消息 ------因为最新的对话对当前回答最相关。相比 slice 数条数,这是真正站在"成本/窗口"角度做的裁剪,更贴近生产。
三条路走完,记忆模块的地基就打好了
回顾这条升级路线,你会发现每一步都是在解决上一步的痛点:
| 痛点 | 解法 | 代价 |
|---|---|---|
| 模型无状态,聊完就忘 | 内存记忆(阶段一) | 进程重启即失忆 |
| 重启丢记忆 | 文件持久化(阶段二) | 历史无限增长 |
| 上下文膨胀、超窗口 | 上下文截断(阶段三) | 老消息被白白丢弃 |
第三步虽然控住了成本,但也暴露了新的遗憾:被丢掉的"老消息",可能是用户两周前说的关键信息。丢掉太浪费了,怎么办?
答案就在代码注释那句"被裁剪掉的老消息 → (总结)"------以及 demo 里 src/memory/ 下已经写好的其他文件。
🎬 续集预告
这个项目会继续演进,下一篇将回答"丢掉太可惜"的问题:
- 续集二:自动总结(Summarization) ------超长对话触发时,把最老的一批消息交给模型压缩成一段摘要 ,再和历史新消息重新组队。老信息没丢,只是"瘦身"了(对应
src/memory/summarization-memory*.mjs)。 - 续集三:向量长期记忆 ------把对话"向量化"存入 Milvus 这类向量数据库,下次提问时先语义检索 出相关的历史片段再回答。这才接近人脑的"长期记忆"(对应
src/memory/retrieval-memory.mjs等)。
最终形态会是一套"短期记忆 + 长期记忆"的分层记忆系统,敬请期待 👀 觉得有用的话,点个收藏,追更不迷路~
也欢迎在评论区聊聊:你做的 Agent 现在用的是哪种记忆方案?
完整项目代码
- 仓库地址 :gitee.com/dcx2758/ai_...
- 本文项目所在目录 :
ai/agent/memory/demo
快速启动 (注意项目在仓库子目录,clone 后要 cd 进去):
bash
# 1. 克隆仓库
git clone git@gitee.com:dcx2758/ai_doubao_dcx.git
# (没有配 SSH 的话用:git clone https://gitee.com/dcx2758/ai_doubao_dcx.git)
# 2. 进入子目录并安装依赖
cd ai/agent/memory/demo
npm install
# 3. 配置 .env(在 demo 目录下新建 .env)
.env 需要以下配置项(以真实可用的 Key 替换):
env
OPENAI_API_KEY=你的_API_Key
OPENAI_API_BASE_URL=https://你的代理地址/v1
OPENAI_MODEL_NAME=你的模型名
⚠️ 若用的是兼容 OpenAI 的国内服务,
OPENAI_API_BASE_URL和OPENAI_MODEL_NAME填对应的网关地址与模型名即可。后续迭代文件里也可能出现MODEL_NAME/OPENAI_BASE_URL这类别名,注意统一。
运行四个 demo (都从 demo 目录执行):
bash
# ① 内存记忆
node src/history-test.mjs
# ② 文件记忆(写入)→ ③ 文件记忆(恢复),两者共用 chat_history.json,
# 必须先跑 ② 再跑 ③,才能演示"重启后恢复"
node src/history-test2.mjs
node src/history-test3.mjs
# ④ 上下文截断(纯本地 token 计算,可不依赖网络)
node src/memory/truncation-memory.mjs
运行 ①~③ 需要网络调用大模型;④ 只做 token 裁剪演示。
项目文件树(完整版):
text
memory/demo
├── .env
├── package.json
├── chat_history.json # 运行②③后自动生成
└── src
├── history-test.mjs # 内存记忆
├── history-test2.mjs # 文件记忆:写入
├── history-test3.mjs # 文件记忆:恢复
└── memory
├── truncation-memory.mjs # 本文:截断
├── summarization-memory.mjs # 续集:自动总结(先行预览)
├── summarization-memory2.mjs
├── insert-conversations.mjs # 续集:Milvus 入库
└── retrieval-memory.mjs # 续集:向量检索
依赖版本:
| 依赖 | 版本 |
|---|---|
| @langchain/core | ^1.2.3 |
| @langchain/openai | ^1.5.5 |
| langchain | ^1.5.10 |
| js-tiktoken | ^1.0.21 |
| dotenv | ^17.4.2 |
(Node 需 20.11+,项目用 ESM,package.json 中 "type": "module")