AI也有记忆?LangChain Memory 管理指南

你有没有遇到过这种情况:跟 AI 聊了几轮之后,它突然问你"你叫什么名字"------而这个问题你第一轮就告诉过它了。

这不是 AI 故意装傻,而是因为大模型本质上是一个"金鱼脑袋" 。每一次调用大模型,都是一次独立的、无状态的请求。模型本身不会记得你上一轮说了什么,也不会主动保存任何历史信息

那为什么我们平时用 ChatGPT 的时候,它好像记得住上下文呢?原因很简单:前端把所有的对话历史都打包塞进了每次请求里。每次你发一条新消息,前端都会把之前的"用户说 + 助手说"全部拼在一起,作为上下文一起发给模型。

这就是 Memory(记忆)要做的事情------在模型之外,帮它把对话历史存下来,下次再喂给它

一、Memory 是什么?为什么它如此重要?

在 LangChain 的体系里,Agent 的完整形态可以理解为:

Agent = LLM + 工具(Tool)+ 检索增强(RAG)+ 记忆(Memory)+ 其他

模型本身负责"思考"和"生成",但它需要外部组件来帮助它完成更复杂的任务

  • 工具(Tool) :让模型不只是回答问题,还能执行操作,比如查天气、发邮件、调接口。
  • RAG(检索增强生成) :根据用户的提问,从外部知识库(比如向量数据库)中检索相关信息,拼接到提示词里,让模型基于这些信息回答。
  • Memory:保存对话历史,让模型在多轮对话中"记得"之前说过什么。

这三者都依赖 Memory 来管理上下文

简单来说,Memory 解决的核心问题是:大模型记不住,我们帮它记

二、最基础的 Memory:用数组存对话

在开始用 LangChain 的封装之前,我们先理解最朴素的做法。

假设你要让模型记住两轮对话,最直接的方式就是维护一个数组:

javascript

php 复制代码
const messages = [];

// 第一轮
messages.push({ role: 'user', content: '我叫李四' });
// 调用模型,拿到回复后存起来
messages.push({ role: 'assistant', content: '你好李四!' });

// 第二轮
messages.push({ role: 'user', content: '我叫什么名字?' });
// 再次调用模型时,把整个 messages 数组传进去

每次请求都把整个数组发给模型,模型就能"看到"之前的对话了。

这个思路没问题,但存在几个明显的短板:

  1. 存在哪里? 数组存在内存里,程序一重启就丢了。
  2. 怎么管理? 对话越来越多,数组越来越大,每次请求都带全部历史,token 开销会爆炸。
  3. 怎么截断? 上下文窗口是有限的(比如 200k token),历史太长就得想办法丢掉一部分

LangChain 提供了一套完善的 Memory 管理方案,帮我们解决这些问题。

三、InMemoryChatMessageHistory:把对话存在内存里

LangChain 中有一个基础类叫 InMemoryChatMessageHistory,它的作用就是在内存中维护一个对话历史数组 ,并提供标准的增删改查接口

来看一个完整的例子:

javascript

php 复制代码
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  }
});

这段代码做了三件事:

  • 加载环境变量(dotenv/config),把 API Key 等敏感信息放在 .env 文件里。
  • 初始化一个 ChatOpenAI 模型实例,指定模型名称、API Key、温度和自定义 Base URL。
  • @langchain/core 中导入消息类和历史记录类。

关于 temperature 参数:它控制模型回答的随机程度。0 表示每次都给出最确定的答案,数值越高回答越多样、越"有创意"。

接下来我们创建历史记录实例并开始对话:

javascript

javascript 复制代码
async function inMemoryDemo() {
  // 创建一个内存历史记录实例
  const history = new InMemoryChatMessageHistory();
  
  // 系统提示词:设定 AI 的角色
  const systemMessage = new SystemMessage(
    "你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧"
  );
  
  // 第一轮:用户提问
  const userMessage1 = new HumanMessage("你今天吃的什么?");
  await history.addMessage(userMessage1);
  
  // 组装完整的消息列表:系统提示词 + 历史对话
  const messages1 = [systemMessage, ...(await history.getMessages())];
  const response1 = await model.invoke(messages1);
  console.log(`助手:${response1.content}`);
  
  // 把助手的回复也存入历史
  await history.addMessage(response1);
}

这里有几个关键点需要解释:

1. SystemMessage 和 HumanMessage 的区别

LangChain 中的消息分为几种类型:

  • SystemMessage :系统提示词,用来设定 AI 的角色、行为准则和回答风格。这个消息通常放在对话的最前面,且属于"对话历史"的一部分------它更像是一个"人设说明书"。
  • HumanMessage:用户发送的消息。
  • AIMessage :AI 返回的回复(在代码中,model.invoke() 返回的就是 AIMessage 实例)。

2. addMessage() 的作用

每一次调用 history.addMessage(),都是在往内部的 messages 数组里追加一条记录。这个数组就是 Memory 的核心载体。

3. getMessages() 的作用

获取当前存储的所有消息。注意,systemMessage 是单独定义的,没有通过 addMessage() 存入 history------因为系统提示词通常不需要被"记住",它每次都应该是固定的。

4. 为什么要把 response 也 add 进去?

如果不把 AI 的回复存入 history,那么下一轮对话时,模型只能看到用户的历史消息,却看不到自己之前说过什么------这就像一个人只记得别人说了什么,却不记得自己怎么回答的,对话依然是不完整的。

继续看第二轮对话:

javascript

typescript 复制代码
  // 第二轮:基于历史记录继续问
  const userMessage2 = new HumanMessage("好吃吗?");
  await history.addMessage(userMessage2);
  
  const messages2 = [systemMessage, ...(await history.getMessages())];
  const response2 = await model.invoke(messages2);
  await history.addMessage(response2);
  
  console.log(`助手:${response2.content}`);
  
  // 打印所有历史记录
  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)}...`);
  });
}

第二轮中,history.getMessages() 返回的数组已经包含了第一轮的"用户问题 + AI 回答",所以模型能够结合上下文来回答"好吃吗?"这个问题------它知道上一轮讨论的是"今天吃的什么",自然能接上话。

InMemory 的优缺点:

优点 缺点
实现简单,上手快 程序重启后数据全部丢失
读写速度快 无法在多进程/多服务间共享
适合开发测试和短期对话 内存占用会随对话增长

四、FileSystemChatMessageHistory:把对话存到文件里

InMemory 的方式虽然方便,但数据没法持久化。如果你的应用需要重启后还能恢复之前的对话,就需要把历史记录存到外部存储中。

LangChain 提供了 FileSystemChatMessageHistory(旧版本叫 FileChatMessageHistory),可以把对话历史保存到本地文件里

javascript

javascript 复制代码
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { FileSystemChatMessageHistory } from '@langchain/community/stores/message/file_system';
import path from 'node:path';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.API_BASE_URL,
  }
});

与 InMemory 版本的区别在于,FileSystemChatMessageHistory 的构造函数需要两个额外参数:

javascript

ini 复制代码
const filePath = path.join(process.cwd(), "chat_history.json");
const sessionId = "user_session_001";

const history = new FileSystemChatMessageHistory({
  filePath,    // 存储文件的位置
  sessionId,   // 会话标识
});

sessionId 的作用 :同一个文件里可以存储多个不同会话的历史记录,sessionId 用来区分不同的对话。比如用户 A 和用户 B 的对话可以存在同一个文件中,通过不同的 sessionId 来隔离。

使用方式与 InMemory 完全一致:

javascript

ini 复制代码
async function fileHistoryDemo() {
  const history = new FileSystemChatMessageHistory({ filePath, sessionId });
  const systemMessage = new SystemMessage('你是一个友好、幽默的做菜助手');
  
  // 第一轮
  const userMessage1 = new HumanMessage("红烧肉怎么做?");
  await history.addMessage(userMessage1);
  const messages1 = [systemMessage, ...(await history.getMessages())];
  const response1 = await model.invoke(messages1);
  await history.addMessage(response1);
  
  // 第二轮
  const userMessage2 = new HumanMessage("好吃吗?");
  await history.addMessage(userMessage2);
  const messages2 = [systemMessage, ...(await history.getMessages())];
  const response2 = await model.invoke(messages2);
  await history.addMessage(response2);
}

文件持久化的最大价值在于:恢复历史

假设程序重启了,或者用户第二天重新打开对话,我们可以直接从文件中读取之前的历史记录,而不需要用户重新说一遍:

javascript

javascript 复制代码
async function restoreHistory() {
  // 从文件系统恢复记忆
  const restoredHistory = new FileSystemChatMessageHistory({
    filePath,
    sessionId,
  });
  
  const restoredMessages = await restoredHistory.getMessages();
  console.log(`从文件中恢复了${restoredMessages.length}条历史信息`);
  
  // 第三轮对话:基于恢复的历史继续
  const userMessage3 = new HumanMessage("需要哪些食材?");
  await restoredHistory.addMessage(userMessage3);
  const messages3 = [systemMessage, ...(await restoredHistory.getMessages())];
  const response3 = await model.invoke(messages3);
  await restoredHistory.addMessage(response3);
}

这段代码演示了一个非常实用的场景:程序重启后,从文件加载历史记录,继续与用户对话。用户完全感觉不到 AI 曾经"失忆"过。

文件存储的局限性:

  • 适合单机应用,不适合分布式部署。
  • 文件会不断变大,需要配合截断策略。
  • 并发写入时可能存在文件锁问题。

五、上下文窗口管理:截断(Truncation)

不管是存在内存还是文件里,对话历史都会随着时间推移越来越长。而大模型的上下文窗口是有限的 ------比如某些模型支持 200k token,但超过这个限制就无法处理

更关键的是,token 是要花钱的。每次请求携带的历史越长,消耗的 token 越多,成本越高。

所以我们需要对 Memory 进行管理。LangChain 提供了三种主流策略:截断、总结、检索 。这里先讲最直接的截断(Truncation)

5.1 按消息数量截断

最简单的方式就是只保留最近 N 条消息,把更早的丢掉。

javascript

javascript 复制代码
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';

async function messageCountTruncation() {
  const history = new InMemoryChatMessageHistory();
  const maxMessages = 4;
  
  // 模拟 8 条对话历史
  const messages = [
    { type: 'human', content: '我叫李四' },
    { type: 'ai', content: '你好李四!' },
    { type: 'human', content: '我是一名设计师' },
    { type: 'ai', content: '设计师很有创造力!' },
    { type: 'human', content: '我喜欢艺术和音乐' },
    { type: 'ai', content: '艺术和音乐能激发灵感。' },
    { type: 'human', content: '我擅长 UI/UX 设计' },
    { type: 'ai', content: 'UI/UX 设计非常重要!' },
  ];
  
  for (const msg of messages) {
    if (msg.type === 'human') {
      await history.addMessage(new HumanMessage(msg.content));
    } else {
      await history.addMessage(new SystemMessage(msg.content));
    }
  }
  
  let allMessages = await history.getMessages();
  // 只保留最后 4 条
  const trimmedMessages = allMessages.slice(-maxMessages);
  
  console.log(`保留消息数量:${trimmedMessages.length}`);
  console.log('保留的消息:', trimmedMessages.map(
    m => `${m.constructor.name}: ${m.content}`).join('\n'));
}

slice(-maxMessages) 是 JavaScript 数组的原生方法,表示从数组末尾取 N 个元素。这里的逻辑是:丢掉最早的消息,保留最近的消息

这种方式的优点是实现简单,但缺点是没有考虑消息的长度。一条很长的消息和一条很短的消息占用同样"名额",显然不够精细。

5.2 按 Token 数量截断

更精确的做法是按 token 数量截断------保留尽可能多的最近消息,直到总 token 数不超过设定的阈值。

LangChain 提供了 trimMessages 函数来帮助我们完成这个任务

javascript

javascript 复制代码
import { trimMessages } from '@langchain/core/messages';
import { getEncoding } from 'js-tiktoken';

// 计算一组消息的总 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;
}

async function tokenCountTruncation() {
  const history = new InMemoryChatMessageHistory();
  const maxTokens = 100;
  
  // ... 添加同样的 8 条消息 ...
  
  let allMessages = await history.getMessages();
  const enc = getEncoding('cl100k_base'); // OpenAI 的编码器
  
  const trimmedMessages = await trimMessages(allMessages, {
    maxLength: maxTokens,
    tokenCounter: async (msgs) => countTokens(msgs, enc),
    strategy: 'latest',  // 保留最新的消息
  });
}

这里有几个新概念:

Token 是什么?

Token 是大模型处理文本的基本单位。它不是字符,也不是单词,而是一个介于两者之间的概念。比如"你好"可能是 2 个 token,"Hello"可能是 1 个 token。不同模型使用不同的 Tokenizer(分词器),所以计算方式也不一样。

getEncoding('cl100k_base') 是什么?

cl100k_base 是 OpenAI 目前使用的编码方案(用于 GPT-4、GPT-3.5-turbo 等模型)。getEncoding 函数返回一个编码器实例,调用 encode(text) 可以把文本拆分成 token 数组,.length 就是 token 数量。

trimMessages 的工作原理:

它使用二分查找 策略:从最新的消息开始,逐步向前尝试包含更多消息,直到总 token 数刚好不超过 maxLength 为止。因为消息数量与 token 总数是单调递增的(消息越多 token 越多),所以可以用二分查找来高效定位"能塞下多少条消息"。

strategy: 'latest' 的含义:

表示优先保留最新的消息。这是大多数场景下的合理选择------因为越早的对话对当前问题的影响越小。

六、三种 Memory 管理策略的对比

策略 适用场景 优点 缺点
截断(Truncation) 对话轮次有限,早期信息不重要 实现简单、计算开销小 可能丢失重要上下文
总结(Summarization) 长对话,需要保留关键信息 压缩信息密度高 总结本身消耗 token,可能丢失细节
检索(Retrieval) 超长对话,信息分散 按需召回相关历史 实现复杂,需要向量数据库

截断是最基础的管理手段,适合大多数场景。总结和检索会在后续文章中详细展开。

七、总结

这篇文章我们梳理了 LangChain 中 Memory 管理的核心概念和实践:

  1. 大模型是无状态的,Memory 的本质是在模型外部帮它"记住"对话历史。
  2. InMemoryChatMessageHistory 是最简单的实现,适合开发和测试,但数据无法持久化。
  3. FileSystemChatMessageHistory 把历史存到文件中,支持程序重启后恢复对话,通过 sessionId 区分不同会话。
  4. 上下文窗口管理 是 Memory 的必修课。按消息数量截断(slice)实现简单但不够精细;按 token 数量截断(trimMessages)更精确,通过二分查找在 token 预算内保留尽可能多的最近消息。
  5. Memory 管理的三种主流策略:截断、总结、检索,各有适用场景。

在实际开发中,Memory 的选择取决于你的应用场景:

  • 简单的聊天机器人:InMemory + 截断就够了。
  • 需要用户长期记忆的应用:文件持久化 + 定期截断。
  • 企业级多用户系统:可能需要数据库存储 + 向量检索。

下一篇我们会深入聊总结(Summarization) ------当对话太长无法直接截断时,如何让 AI 自己"总结"历史,把精华压缩成简短的上下文。

相关推荐
Daisygirl19 分钟前
审批系统接入 AI 润色后,如何拦截用户乱输入的文本?
langchain
大连好光景30 分钟前
【AI Agent案例开发项目01解读】
chrome·python·langchain
武子康31 分钟前
删掉邮箱后,Agent Trace 仍可能泄露什么:一条可重放脱敏流水线
人工智能·llm·agent
MicrosoftReactor41 分钟前
技术速递|如何在投入生产环境前评估 LLM
ai·llm·生产评估
未若君雅裁1 小时前
自定义中间件:Node-style 与 Wrap-style 钩子全解析
python·中间件·langchain
烬羽1 小时前
为 Agent 管好一张“上下文预算”:从数条数到数 token
架构·langchain·agent
烬羽1 小时前
把 Agent 的记忆写进文件:内存 vs 文件,两把钥匙搞定多会话
架构·langchain·agent
武子康1 小时前
RoboLab 解读:机器人策略评测为什么不能只看二元成功率
人工智能·llm·agent
小小龙学IT2 小时前
libuv 开源异步 I/O 事件循环库深度解析:Node.js 的心脏,C++ 高性能网络程序的引擎
c++·开源·node.js