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

10 阅读5分钟

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

我叫小明。

我喜欢吃西红柿。

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

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

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

每一次调用,本质都是:

messages
    ↓
LLM
    ↓
response

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

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

答案是:

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

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

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

手动维护 messages

↓

InMemoryChatMessageHistory

↓

FileSystemChatMessageHistory

理解 LangChain 中 Memory 的设计思想。


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

先看一次普通调用:

const response = await model.invoke(
  "你好"
);

模型收到:

用户:
你好

返回:

助手:
你好,有什么可以帮助你的?

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

如果下一次:

const response = await model.invoke(
  "我叫什么?"
);

模型并不知道。

因为它没有看到:

用户:
我叫小明

这条消息。


所以连续对话实际上是:

应用保存历史消息


下一次调用重新传入


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

例如:

第一次:

SystemMessage:
你是一个助手

HumanMessage:
我叫小明

模型返回:

AIMessage:
你好,小明

应用保存:

SystemMessage

HumanMessage:
我叫小明

AIMessage:
你好,小明

第二次用户输入:

我叫什么?

实际发送给模型:

SystemMessage

HumanMessage:
我叫小明

AIMessage:
你好,小明

HumanMessage:
我叫什么?

所以模型才能回答:

你叫小明。

二、最原始的 Memory:messages 数组

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

const messages = [];

每次用户输入:

messages.push(
  new HumanMessage(
    "我叫小明"
  )
);

调用模型:

const response =
  await model.invoke(messages);

保存 AI 回复:

messages.push(response);

最终:

[
  SystemMessage,

  HumanMessage:
  我叫小明,

  AIMessage:
  你好,小明
]

下一轮:

messages.push(
  new HumanMessage(
    "我叫什么?"
  )
);

再次:

model.invoke(messages)

模型就拥有了上下文。


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

但是进入真实应用:

用户 A1000 轮聊天


用户 B500 轮聊天


用户 C:
200 轮聊天

问题开始出现:

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

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

messages.push()

代码会越来越难维护。

所以需要一个抽象。


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

LangChain 提供:

const history =
  new InMemoryChatMessageHistory();

它负责管理聊天消息。

添加消息:

await history.addMessage(
  new HumanMessage(
    "我叫小明"
  )
);

获取历史:

const messages =
  await history.getMessages();

然后:

model.invoke(messages);

它和数组有什么区别?

底层其实类似:

messages 数组


ChatMessageHistory

但是区别在于:

数组只是数据结构。

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

它定义了一套统一操作:

addMessage()

getMessages()

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

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;
}

调用:

console.log(
  await chat("我喜欢吃西红柿")
);


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

执行流程:

第一次:

HumanMessage:
我喜欢吃西红柿

↓

history 保存

↓

发送给模型

↓

AIMessage 保存

第二次:

HumanMessage:
我喜欢吃什么?

↓

history 中已有:

HumanMessage:
我喜欢吃西红柿

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


↓

一起发送给模型

这里有一个重要设计:

SystemMessage 不属于聊天历史

SystemMessage:

你是一个友好的助手

作用:

定义模型行为。

而 History:

保存:

HumanMessage

AIMessage

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

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

[  SystemMessage,  历史消息,  当前用户消息]

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

现在:

new InMemoryChatMessageHistory()

已经可以完成聊天。

但是它有一个限制:

它存在于:

Node.js 进程内存

例如:

程序运行:

用户:
我喜欢吃西红柿

AI:
记住了

内存:

[ HumanMessage, AIMessage]

关闭程序:

进程结束

历史消失。

再次启动:

new InMemoryChatMessageHistory()

得到:

[]

所以 Memory 还有一个问题:

历史消息应该保存多久?

这就是生命周期问题。


五、FileSystemChatMessageHistory:让 Memory 持久化

如果希望:

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

消息就不能只存在内存。

需要持久化存储。

例如:

const history =
  new FileSystemChatMessageHistory({
    filePath:
      "./chat_history.json",

    sessionId:
      "user_001"
  });

现在:

history.addMessage()

不再只是:

数组 push

而是:

写入文件

生成:

chat_history.json

保存:

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

注意:

业务代码没有变化。

仍然:

history.addMessage()

history.getMessages()

变化的是:

底层存储。


这就是抽象层的价值:

业务代码


ChatMessageHistory


具体实现


InMemory

FileSystem

Redis

Database

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

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

多个用户怎么办?

例如:

用户 A:

我喜欢吃西红柿

用户 B:

我喜欢吃牛肉

如果没有隔离:

所有消息会混在一起。


所以需要:

sessionId

例如:

sessionId: "user_001"

表示:

用户001 的聊天空间

另一个用户:

sessionId: "user_002"

拥有自己的历史。

最终:

{
  "user_001": {
    "messages": []
  },

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

七、总结:Memory 的本质

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

messages 数组

↓

InMemoryChatMessageHistory

↓

FileSystemChatMessageHistory

理解了 Memory 的核心设计。

Memory 并不是:

让模型真正拥有记忆。

而是:

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

其中:

存储方式

决定:

消息保存在哪里。

例如:

InMemory

FileSystem

Redis

Database

生命周期

决定:

消息存在多久。

例如:

进程生命周期

文件生命周期

数据库生命周期

sessionId

决定:

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


到这里,我们解决的是:

Memory 如何保存消息。

但是新的问题出现:

如果用户聊天几百轮:

1000 条消息

是不是每次都:

model.invoke([
  ...messages
])

显然不是。

下一阶段需要解决:

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

这会进入:

  • 消息截断(Truncation)
  • 历史总结(Summarization)
  • 语义检索(Retrieval Memory)