从 messages 数组到 LangChain Memory:理解大模型应用中的记忆系统

在使用 ChatGPT 这类应用时,我们习惯了连续对话:

我叫小明。

我喜欢吃西红柿。

那你推荐几个适合我的菜?

模型似乎能够记住之前的信息。

但实际上,大语言模型本身并没有真正的"记忆"。

每一次调用,本质都是:

markdown 复制代码
messages
    ↓
LLM
    ↓
response

模型只会根据当前传入的消息生成回复。

那么连续聊天是如何实现的?

答案是:

应用程序保存历史消息,并在下一次请求时重新发送给模型。

这就是 Memory 系统存在的原因。

本文通过一个简单 Demo,逐步实现:

复制代码
手动维护 messages

↓

InMemoryChatMessageHistory

↓

FileSystemChatMessageHistory

理解 LangChain 中 Memory 的设计思想。


一、为什么大模型需要 Memory?

先看一次普通调用:

arduino 复制代码
const response = await model.invoke(
  "你好"
);

模型收到:

复制代码
用户:
你好

返回:

复制代码
助手:
你好,有什么可以帮助你的?

调用结束后,这次信息不会自动保存。

如果下一次:

arduino 复制代码
const response = await model.invoke(
  "我叫什么?"
);

模型并不知道。

因为它没有看到:

复制代码
用户:
我叫小明

这条消息。


所以连续对话实际上是:

markdown 复制代码
应用保存历史消息

        ↓

下一次调用重新传入

        ↓

模型根据完整上下文生成回答

例如:

第一次:

makefile 复制代码
SystemMessage:
你是一个助手

HumanMessage:
我叫小明

模型返回:

makefile 复制代码
AIMessage:
你好,小明

应用保存:

makefile 复制代码
SystemMessage

HumanMessage:
我叫小明

AIMessage:
你好,小明

第二次用户输入:

复制代码
我叫什么?

实际发送给模型:

makefile 复制代码
SystemMessage

HumanMessage:
我叫小明

AIMessage:
你好,小明

HumanMessage:
我叫什么?

所以模型才能回答:

复制代码
你叫小明。

二、最原始的 Memory:messages 数组

理解了原理后,最简单的实现就是维护一个数组。

ini 复制代码
const messages = [];

每次用户输入:

arduino 复制代码
messages.push(
  new HumanMessage(
    "我叫小明"
  )
);

调用模型:

ini 复制代码
const response =
  await model.invoke(messages);

保存 AI 回复:

ini 复制代码
messages.push(response);

最终:

yaml 复制代码
[
  SystemMessage,

  HumanMessage:
  我叫小明,

  AIMessage:
  你好,小明
]

下一轮:

arduino 复制代码
messages.push(
  new HumanMessage(
    "我叫什么?"
  )
);

再次:

arduino 复制代码
model.invoke(messages)

模型就拥有了上下文。


对于简单 Demo,这没有问题。

但是进入真实应用:

css 复制代码
用户 A:
1000 轮聊天


用户 B:
500 轮聊天


用户 C:
200 轮聊天

问题开始出现:

  • 每个用户的历史如何隔离?
  • 消息如何保存?
  • 如何恢复历史?
  • 如何替换存储方式?

如果所有地方都直接操作:

scss 复制代码
messages.push()

代码会越来越难维护。

所以需要一个抽象。


三、InMemoryChatMessageHistory:抽象聊天历史管理

LangChain 提供:

ini 复制代码
const history =
  new InMemoryChatMessageHistory();

它负责管理聊天消息。

添加消息:

csharp 复制代码
await history.addMessage(
  new HumanMessage(
    "我叫小明"
  )
);

获取历史:

ini 复制代码
const messages =
  await history.getMessages();

然后:

arduino 复制代码
model.invoke(messages);

它和数组有什么区别?

底层其实类似:

markdown 复制代码
messages 数组

        ↓

ChatMessageHistory

但是区别在于:

数组只是数据结构。

而 History 是一个聊天历史管理抽象。

它定义了一套统一操作:

scss 复制代码
addMessage()

getMessages()

例如,一个真实聊天流程可以封装成:

javascript 复制代码
const history =
  new InMemoryChatMessageHistory();


const systemMessage =
  new SystemMessage(
    "你是一个友好的助手"
  );


async function chat(input) {

  // 用户消息
  const userMessage =
    new HumanMessage(input);


  // 保存用户消息
  await history.addMessage(
    userMessage
  );


  // 获取历史消息
  const messages =
    await history.getMessages();


  // 调用模型
  const response =
    await model.invoke([
      systemMessage,
      ...messages
    ]);


  // 保存 AI 回复
  await history.addMessage(
    response
  );


  // 返回结果
  return response.content;
}

调用:

javascript 复制代码
console.log(
  await chat("我喜欢吃西红柿")
);


console.log(
  await chat("我喜欢吃什么?")
);

执行流程:

第一次:

bash 复制代码
HumanMessage:
我喜欢吃西红柿

↓

history 保存

↓

发送给模型

↓

AIMessage 保存

第二次:

makefile 复制代码
HumanMessage:
我喜欢吃什么?

↓

history 中已有:

HumanMessage:
我喜欢吃西红柿

AIMessage:
记住了,你喜欢吃西红柿


↓

一起发送给模型

这里有一个重要设计:

SystemMessage 不属于聊天历史

SystemMessage:

复制代码
你是一个友好的助手

作用:

定义模型行为。

而 History:

保存:

复制代码
HumanMessage

AIMessage

也就是用户和模型之间真实发生的交流。

所以一次模型调用结构通常是:

css 复制代码
[  SystemMessage,  历史消息,  当前用户消息]

四、InMemory 的问题:程序关闭后怎么办?

现在:

scss 复制代码
new InMemoryChatMessageHistory()

已经可以完成聊天。

但是它有一个限制:

它存在于:

复制代码
Node.js 进程内存

例如:

程序运行:

复制代码
用户:
我喜欢吃西红柿

AI:
记住了

内存:

css 复制代码
[ HumanMessage, AIMessage]

关闭程序:

复制代码
进程结束

历史消失。

再次启动:

scss 复制代码
new InMemoryChatMessageHistory()

得到:

css 复制代码
[]

所以 Memory 还有一个问题:

历史消息应该保存多久?

这就是生命周期问题。


五、FileSystemChatMessageHistory:让 Memory 持久化

如果希望:

程序关闭后还能继续聊天。

消息就不能只存在内存。

需要持久化存储。

例如:

arduino 复制代码
const history =
  new FileSystemChatMessageHistory({
    filePath:
      "./chat_history.json",

    sessionId:
      "user_001"
  });

现在:

scss 复制代码
history.addMessage()

不再只是:

perl 复制代码
数组 push

而是:

复制代码
写入文件

生成:

复制代码
chat_history.json

保存:

json 复制代码
{
  "user_001": {
    "messages": [
      {
        "type": "human",
        "content": "我喜欢吃西红柿"
      },
      {
        "type": "ai",
        "content": "记住了,你喜欢吃西红柿"
      }
    ]
  }
}

注意:

业务代码没有变化。

仍然:

scss 复制代码
history.addMessage()

history.getMessages()

变化的是:

底层存储。


这就是抽象层的价值:

markdown 复制代码
业务代码

    ↓

ChatMessageHistory

    ↓

具体实现


InMemory

FileSystem

Redis

Database

六、sessionId:解决多用户隔离问题

持久化以后,又出现新的问题:

多个用户怎么办?

例如:

用户 A:

复制代码
我喜欢吃西红柿

用户 B:

复制代码
我喜欢吃牛肉

如果没有隔离:

所有消息会混在一起。


所以需要:

复制代码
sessionId

例如:

vbnet 复制代码
sessionId: "user_001"

表示:

复制代码
用户001 的聊天空间

另一个用户:

vbnet 复制代码
sessionId: "user_002"

拥有自己的历史。

最终:

json 复制代码
{
  "user_001": {
    "messages": []
  },

  "user_002": {
    "messages": []
  }
}

七、总结:Memory 的本质

通过这一条主线,我们实现:

复制代码
messages 数组

↓

InMemoryChatMessageHistory

↓

FileSystemChatMessageHistory

理解了 Memory 的核心设计。

Memory 并不是:

让模型真正拥有记忆。

而是:

应用程序负责保存、管理和恢复消息历史,让模型在每次调用时获得需要的上下文。

其中:

存储方式

决定:

消息保存在哪里。

例如:

arduino 复制代码
InMemory

FileSystem

Redis

Database

生命周期

决定:

消息存在多久。

例如:

复制代码
进程生命周期

文件生命周期

数据库生命周期

sessionId

决定:

不同用户、不同会话如何隔离。


到这里,我们解决的是:

Memory 如何保存消息。

但是新的问题出现:

如果用户聊天几百轮:

yaml 复制代码
1000 条消息

是不是每次都:

arduino 复制代码
model.invoke([
  ...messages
])

显然不是。

下一阶段需要解决:

Memory 中越来越多的历史消息,应该如何管理?

这会进入:

  • 消息截断(Truncation)
  • 历史总结(Summarization)
  • 语义检索(Retrieval Memory)
相关推荐
学习星球1 小时前
AI 原生游戏开发:Godot 4 + AI Agent 全栈指南(Ziva 3 / Godot MCP 深度实战)
人工智能·游戏引擎·godot
知几蜗牛1 小时前
GPT-6 Astra来了:AI真的开始像“数字员工”一样工作了吗?
人工智能
夏文强1 小时前
DeepSeek Harness 可观测性:用 OpenTelemetry 把会话遥测出去
人工智能·开源·大模型·agent·deepseek
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】Gemini模型接入知识体系
java·开发语言·网络·人工智能·网络协议·学习·http
AC赳赳老秦1 小时前
电力能源公开数据采集实操:用 OpenClaw 合规抓取电网电价与发电量数据,生成区域能源供需分析报告
大数据·数据库·人工智能·python·php·deepseek·openclaw
一切皆是因缘际会1 小时前
资源的适配
大数据·人工智能·算法
计算机编程-吉哥1 小时前
小麦叶病害识别系统:基于MobileNet的智能农业病害检测实战【计算机毕业设计选题推荐、深度学习】
人工智能·深度学习·毕业设计·课程设计·计算机毕业设计选题·机器学习毕业设计·大数据毕业设计选题推荐
新知图书1 小时前
第11章 智能体应用设计模式
人工智能·智能体
LlmCraft|大模型工程实践1 小时前
09 大语言模型(LLM)演进与 Prompt 入门
人工智能·语言模型·prompt