给 Agent 装上有记忆的"脑子"(一):用 LangChain 打通内存记忆、文件持久化与上下文截断

一个每次对话都"失忆"的 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.5langchain ^1.5.10js-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 现在用的是哪种记忆方案?


完整项目代码

快速启动 (注意项目在仓库子目录,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_URLOPENAI_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"

相关推荐
光影少年1 小时前
从输入URL到页面渲染,React/RN 整体加载流程
前端·javascript·react native·react.js·前端框架
兔子零10241 小时前
我给 Pi Coding Agent 做了一个桌面控制台:Pi-Harness
前端·javascript·后端
橘子星1 小时前
给 Agent 装上有记忆的"脑子"(二):对话太长会撑爆上下文?用"自动总结"给记忆瘦身
javascript
runningshark2 小时前
Lecture: The ‘Why & How‘ Principle: Moving Beyond Simple Statements
开发语言·前端·javascript
雪芽蓝域zzs2 小时前
第三十一节:角色管理页面 + 权限分配树形弹窗
前端·javascript·vue.js
暖焰核心2 小时前
C++模板进阶——特化全解
javascript·c++·jquery
晓得迷路了2 小时前
栗子前端技术周刊第 145 期 - Remix 3 RC、htmx 4.0、Rslib 1.0...
前端·javascript·react.js
雪芽蓝域zzs2 小时前
第三十五节:Axios 统一错误拦截、401 Token 过期处理
前端·javascript·vue.js
范什么特西3 小时前
一些常用名词
开发语言·前端·javascript