从 messages 数组到 Memory:大模型到底是怎么“记住”上一轮对话的?

我们平时使用 ChatGPT 时,会很自然地进行这样的对话:

复制代码
用户:这个🍅是啥?
AI:这是番茄。

用户:好吃吗?
AI:......

第二句话里的"好吃吗"其实没有明确说:

番茄好吃吗?

但模型依然能够理解我们问的是番茄。

这很容易让人产生一个错觉:

大模型是不是自己记住了上一轮对话?

实际上并不是。

理解 Memory,最重要的一件事,就是先接受一个事实:

LLM 本身是无状态的(Stateless)。

所谓 Memory,本质上是应用程序在模型外部保存历史信息,然后在下一次调用模型时,把这些信息重新放进 Prompt。


一、LLM 本身没有"上一轮对话"

假设第一次调用模型:

arduino 复制代码
const response1 = await model.invoke([
  new HumanMessage("这个🍅是啥?"),
]);

模型可能回答:

复制代码
这是番茄。

然后我们进行第二次调用:

arduino 复制代码
const response2 = await model.invoke([
  new HumanMessage("好吃吗?"),
]);

对于第二次请求来说,模型实际上只能看到:

复制代码
好吃吗?

它并不知道前面讨论过番茄。

因为对于模型 API 来说,两次 invoke() 本质上是两次完全独立的请求:

复制代码
请求 1
↓
LLM
↓
响应 1


请求 2
↓
LLM
↓
响应 2

请求结束以后,模型不会在内部自动保存这次对话。

所以:

复制代码
LLM ≠ 自带聊天记忆

如果希望模型记住上下文,就必须由我们的程序负责保存。


二、最简单的 Memory:一个 messages 数组

其实我们之前已经实现过最原始版本的 Memory。

例如:

arduino 复制代码
const messages = [
  new SystemMessage("你是一个美食家"),
  new HumanMessage("这个🍅是啥?"),
  new AIMessage("这是番茄"),
  new HumanMessage("好吃吗?"),
];

然后把整个数组再次传给模型:

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

模型看到的实际上是:

sql 复制代码
System:你是一个美食家

Human:这个🍅是啥?

AI:这是番茄

Human:好吃吗?

所以模型自然就知道:

复制代码
"好吃吗?"

是在继续讨论番茄。

这就是所谓的:

Conversation Memory。

它并没有什么神秘的。

本质仍然只是:

markdown 复制代码
保存历史消息
        ↓
下一轮重新取出来
        ↓
和新消息一起发送给 LLM

因此最简单的 Memory 完全可以写成:

ini 复制代码
const messages = [];

每轮对话:

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

const response = await model.invoke(messages);

messages.push(response);

就已经拥有最基础的"记忆"能力了。


三、为什么还要 InMemoryChatMessageHistory

既然数组就能实现 Memory,那么 LangChain 为什么还要提供:

复制代码
InMemoryChatMessageHistory

原因并不是它拥有某种神奇的 AI 记忆能力。

而是因为:

它把"消息历史管理"从普通数组提升成了一个专门的抽象。

例如创建:

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

添加消息:

csharp 复制代码
await history.addMessage(userMessage);

获取历史:

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

过去我们可能自己维护:

scss 复制代码
messages.push(...)

现在则变成:

scss 复制代码
history.addMessage(...)

也就是说:

复制代码
普通数组
   ↓
ChatMessageHistory
   ↓
专门负责管理聊天历史

这一步非常重要。

因为后面我们可能不再把消息保存在 JavaScript 内存中,而是保存在:

复制代码
Redis
数据库
文件
云端存储

但是对于上层业务来说,我们希望调用方式尽量保持一致:

scss 复制代码
history.addMessage(...)
history.getMessages()

这就是抽象的价值。


四、手写一个 InMemory Memory Demo

来看一个完整例子。

javascript 复制代码
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: {
    basePath: process.env.OPENAI_API_BASE_URL,
  },
});

async function inMemoryDemo() {
  const history = new InMemoryChatMessageHistory();

  const systemMessage = new SystemMessage(
    "你是一个美食家"
  );

  console.log("[第一轮对话]");

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

  console.log("[第二轮对话 基于历史对话]");

  const userMessage2 =
    new HumanMessage("好吃吗😋?");

  await history.addMessage(userMessage2);

  const messages2 = [
    systemMessage,
    ...(await history.getMessages()),
  ];

  const response2 =
    await model.invoke(messages2);

  console.log(
    `[模型回复] ${response2.content}`
  );

  await history.addMessage(response2);

  const allMessages =
    await history.getMessages();

  console.log(
    `[所有对话历史]: 一共 ${allMessages.length} 条`
  );

  allMessages.forEach((message, index) => {
    console.log(
      `第 ${index + 1} 条: [${message.role}] ${message.content}`
    );
  });
}

inMemoryDemo()
  .catch(console.error)
  .finally(() => {
    console.log("Done");
  });

这段代码最值得理解的不是 API,而是数据到底是怎样流动的。


五、第一轮发生了什么?

首先创建 Memory:

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

此时可以想象成:

ini 复制代码
history = []

只是实际的数据由 LangChain 帮我们管理。


用户发送第一句话:

arduino 复制代码
const userMessage1 =
  new HumanMessage("这个🍅是啥?");

加入 Memory:

csharp 复制代码
await history.addMessage(userMessage1);

此时历史大致相当于:

复制代码
Human:这个🍅是啥?

接下来:

ini 复制代码
const messages1 = [
  systemMessage,
  ...(await history.getMessages()),
];

最终传给模型的是:

sql 复制代码
System:你是一个美食家

Human:这个🍅是啥?

模型生成:

复制代码
AI:这是番茄......

但是这里还有一个非常关键的步骤:

csharp 复制代码
await history.addMessage(response1);

必须把 AI 的回答也保存下来

因此此时 Memory 变成:

复制代码
Human:这个🍅是啥?

AI:这是番茄......

这就是一轮完整对话。


六、第二轮为什么知道"好吃吗"指的是番茄?

第二轮:

arduino 复制代码
const userMessage2 =
  new HumanMessage("好吃吗😋?");

加入历史以后:

复制代码
Human:这个🍅是啥?

AI:这是番茄......

Human:好吃吗😋?

然后:

ini 复制代码
const messages2 = [
  systemMessage,
  ...(await history.getMessages()),
];

真正发送给模型的是:

sql 复制代码
System:你是一个美食家

Human:这个🍅是啥?

AI:这是番茄......

Human:好吃吗😋?

注意:

模型不是"回忆起"了番茄。

而是我们的程序再次把:

复制代码
这个🍅是啥?
这是番茄......

发送给了它。

所以 Memory 的核心流程可以总结成:

复制代码
用户消息
   ↓
写入 Memory
   ↓
读取历史消息
   ↓
历史 + 当前问题
   ↓
发送给 LLM
   ↓
获得 AI 回复
   ↓
AI 回复写回 Memory

这才是大模型聊天应用所谓"有记忆"的真正原理。


七、这里还有一个小细节:SystemMessage 没有进入 Memory

Demo 中的:

arduino 复制代码
const systemMessage =
  new SystemMessage("你是一个美食家");

并没有执行:

ini 复制代码
history.addMessage(systemMessage);

而是每轮请求时重新拼进去:

csharp 复制代码
[
  systemMessage,
  ...(await history.getMessages())
]

因此最终:

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

里面保存的是:

markdown 复制代码
1. Human:这个🍅是啥?

2. AI:......

3. Human:好吃吗?

4. AI:......

一共四条。

SystemMessage 不在 history 中。

这种做法也很容易理解:

复制代码
SystemMessage
负责定义 AI 的角色和规则

History
负责保存用户与 AI 的聊天过程

于是每轮请求:

diff 复制代码
系统规则
+
聊天历史
+
当前问题

组成最终发送给 LLM 的上下文。


八、到这里似乎已经解决 Memory 了,但真正的问题才刚开始

如果聊天只有两三轮:

复制代码
Human
AI
Human
AI

把所有历史消息都发送给模型完全没问题。

但如果用户连续聊了几百轮呢?

Memory 可能逐渐变成:

erlang 复制代码
message 1
message 2
message 3
...
message 1000
...

而每次调用模型时,我们都执行:

ini 复制代码
await model.invoke([
  systemMessage,
  ...allHistory
]);

那么意味着:

每一次请求都要把越来越多的历史重新发送给模型。

于是马上出现两个问题。


1. 上下文窗口有限

模型一次能够处理的 Token 数量并不是无限的。

Prompt、历史消息、RAG 内容、Tool Result、模型输出等,都会占用上下文。

可以把一次 Agent 请求粗略理解成:

diff 复制代码
System Prompt
+
Conversation Memory
+
RAG Context
+
Tool Result
+
Current Question
+
Model Output

它们共同竞争同一个上下文空间。

所以 Conversation Memory 不可能无限增长。


2. Token 成本越来越高

假设第一轮只有:

复制代码
100 tokens

第二轮:

复制代码
200 tokens

几十轮以后可能已经变成:

复制代码
几千
甚至几万 tokens

而且每次请求都要重新输入这些历史。

于是:

复制代码
历史越长
↓
Prompt 越长
↓
Token 消耗越高
↓
调用成本越高
↓
响应也可能越来越慢

所以 Memory 管理真正困难的地方并不是:

怎么把消息存进数组。

而是:

哪些信息值得继续放进模型上下文?


九、Memory 的三个核心问题

到这里,我们就可以重新理解所谓的 Memory Management。

它主要解决三个问题:

复制代码
Memory
├── 上下文大小
├── Token 成本
└── 持久化

十、第一个问题:上下文不能无限增长

最简单的方法就是:

截断。

例如:

ini 复制代码
const recentMessages =
  messages.slice(-4);

只保留最近四条消息。

假设原本:

复制代码
message1
message2
message3
message4
message5
message6
message7
message8

经过:

scss 复制代码
slice(-4)

只剩:

复制代码
message5
message6
message7
message8

这叫做:

Sliding Window / 截断式 Memory。

优点非常明显:

复制代码
简单
速度快
成本可控

但缺点同样明显。

例如用户很早以前说:

复制代码
以后代码示例全部使用 JavaScript。

几十轮之后这条消息被截断了。

模型就"忘记"了。

所以截断适合保存:

最近上下文。

但不适合保存真正重要的长期信息。


十一、第二种方案:总结

另一种思路是:

不保存所有原始对话,而是把过去的内容总结。

例如原始对话可能有几十轮:

复制代码
用户学习 JavaScript
用户正在学习 React
用户不理解闭包
已经解释了作用域
已经解释了词法作用域
用户最终理解了闭包

Memory 可以将它压缩成:

复制代码
用户使用 JavaScript,
正在学习 React。
此前学习过闭包和词法作用域,
目前已经理解闭包的基本原理。

于是原本几千 Token 的聊天历史:

复制代码
大量 message

变成一小段:

css 复制代码
Summary

之后模型接收:

diff 复制代码
System Prompt
+
Conversation Summary
+
Recent Messages
+
Current Question

这样既能保留较早的信息,又不用把全部原始对话重新发送。

这就是:

Summary Memory。


十二、第三种方案:检索

还有一种更加适合长期 Memory 的方式:

不把所有记忆都塞进 Prompt,而是在需要的时候搜索。

例如长期记忆里保存:

复制代码
用户使用 JavaScript
用户喜欢先理解暴力解再学习最优解
用户正在学习 React
用户之前学习过 JWT
用户做过 Docker Demo
......

现在用户问:

复制代码
继续学 JWT。

Memory 系统可以根据当前 Query:

复制代码
JWT

检索相关记忆:

复制代码
用户之前学习过 JWT,
做过登录 Token Demo,
主要困惑是 Token 签发和验证流程。

然后只把这些相关内容放进 Prompt。

流程变成:

复制代码
当前 Query
   ↓
搜索 Memory
   ↓
找到相关历史
   ↓
Relevant Memory
   ↓
加入 Prompt
   ↓
LLM

这和 RAG 的思想其实非常接近。

只是:

复制代码
RAG
检索外部知识

Memory Retrieval
检索与用户、会话、Agent 有关的历史信息

十三、于是 Memory 有三种非常重要的管理思路

可以把目前的知识压缩成:

markdown 复制代码
Memory 管理

1. 截断
   slice(-N)

2. 总结
   历史对话 → Summary

3. 检索
   Query → 搜索相关历史 → Prompt

它们解决的是不同问题。

截断

保留:

复制代码
最近发生了什么

总结

保留:

复制代码
前面的对话整体讲了什么

检索

寻找:

复制代码
当前问题需要哪些过去的信息

实际系统通常不会只使用一种策略,而可能组合使用:

diff 复制代码
长期 Memory 检索
+
历史 Summary
+
最近几轮原始消息

最终形成模型真正需要的上下文。


十四、临时记忆和长期记忆

临时记忆

例如:

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

这些消息存在当前程序进程的内存中。

程序运行时:

复制代码

程序退出或者服务重新启动:

复制代码
没了

因此它更像:

Session Memory / 临时会话记忆。

适合:

复制代码
当前这次聊天
当前任务
当前 Agent 执行

长期记忆

如果希望用户:

复制代码
今天退出程序

明天重新打开以后,Agent 仍然知道:

复制代码
用户之前学过什么
用户有哪些偏好
任务做到哪一步

就不能只依赖:

复制代码
InMemoryChatMessageHistory

而需要把记忆存入真正的持久化系统,例如:

复制代码
数据库
Redis
文件
向量数据库
其他持久化存储

因此:

kotlin 复制代码
Memory
├── Short-term Memory
│     当前对话上下文
│
└── Long-term Memory
      跨 Session 保存的信息

十五、Memory 和 Agent 是什么关系?

理解完 Memory,再来看:

ini 复制代码
Agent = LLM + Harness

就容易很多。

一个比较完整的 Agent 系统可能包含:

sql 复制代码
Agent
│
├── LLM
│
├── System Prompt
│
├── Tools
│
├── RAG
│
├── Memory
│
└── Agent Loop

例如一个编程 Agent。

用户说:

go 复制代码
帮我修改 package.json。

LLM 本身只能生成文本。

但 Harness 可以给它提供:

复制代码
read_file
write_file
run_command
search_code

这样的工具。

于是模型不只是:

复制代码
回答问题

还可以:

复制代码
读取文件
修改代码
执行命令
检查结果
继续修改

这就是 Tool 带来的能力。


十六、RAG、Tool 和 Memory 不要混在一起

这三个概念非常容易混。

可以这样区分。

Tool

解决:

Agent 能做什么?

例如:

复制代码
搜索网页
运行终端
读取文件
发送邮件
查询数据库

RAG

解决:

Agent 从哪里获得相关知识?

例如用户问:

复制代码
公司的退款流程是什么?

Agent 可以:

复制代码
Query
↓
搜索知识库
↓
找到退款文档
↓
加入 Prompt
↓
回答

Memory

解决:

Agent 需要记住什么?

例如:

复制代码
之前聊了什么
任务执行到了哪里
用户有哪些长期偏好
Agent 已经尝试过哪些操作

所以三者分别可以理解成:

复制代码
Tool
→ 能力

RAG
→ 外部知识

Memory
→ 历史与状态

它们不是谁必须依赖谁才能存在。

但在复杂 Agent 中,它们通常会一起工作。


十七、为什么 Agent 尤其需要 Memory?

普通聊天可能只有:

sql 复制代码
User
↓
LLM
↓
Answer

但是 Agent 往往会执行多步任务。

例如:

复制代码
用户:
帮我修复这个项目的登录 Bug

Agent 可能执行:

复制代码
Thought
↓
读取 login.ts

Observation
↓
发现 token 解析代码

Thought
↓
继续读取 auth.ts

Observation
↓
发现验证逻辑

Thought
↓
修改代码

Action
↓
运行测试

Observation
↓
测试失败

Thought
↓
继续修改

这就是典型的 Agent Loop。

无论具体框架是否直接叫做 ReAct,它都需要不断维护:

复制代码
之前做了什么
工具返回了什么
当前任务进展到哪里
下一步应该做什么

这些信息最终都会以某种形式进入:

perl 复制代码
messages / state

所以对于 Agent 来说:

Memory 不只是"聊天记录"。

它还可能承担:

复制代码
任务状态
工具调用历史
中间结果
计划
用户偏好
过去经验

十八、重新理解 messages

学到这里以后,再看到:

ini 复制代码
const messages = [...]

它就不再只是一个简单数组了。

对于现代 LLM 应用来说,messages 实际上就是:

模型当前能够看到的世界。

模型不知道数据库里有什么。

不知道几十轮以前发生了什么。

不知道工具刚才执行过什么。

也不知道用户一个月以前说过什么。

除非应用程序把这些信息重新组织成:

复制代码
messages

发送给它。

因此一个 Agent 系统非常重要的一件事,就是不断决定:

下一次调用 LLM 时,到底应该让模型看到什么?

最终可能形成:

sql 复制代码
messages
│
├── System Prompt
│
├── Long-term Memory
│
├── Conversation Summary
│
├── Recent Messages
│
├── RAG Context
│
├── Tool Results
│
└── Current User Message

这其实就是很多 Agent 框架背后的核心问题。


十九、从一个数组,到真正的 Memory 系统

现在重新回头看整个学习过程。

最开始只是:

ini 复制代码
const messages = [];

然后发现:

复制代码
模型本身没有记忆

所以:

复制代码
messages 保存历史

接着使用:

复制代码
InMemoryChatMessageHistory

把消息管理抽象出来。

但随后又发现:

复制代码
聊天历史不能无限增长

于是需要:

复制代码
截断
总结
检索

然后继续发现:

复制代码
内存里的消息重启就消失

于是又出现:

复制代码
持久化
长期 Memory

最终进入 Agent 以后,Memory 管理的已经不仅仅是:

复制代码
HumanMessage
AIMessage

还可能包括:

复制代码
用户信息
任务状态
Agent 执行轨迹
工具执行结果
历史经验

于是整条知识线就连起来了:

markdown 复制代码
LLM 是 Stateless
        ↓
模型不会自动记住历史
        ↓
手动维护 messages
        ↓
InMemoryChatMessageHistory
        ↓
抽象聊天历史管理
        ↓
历史越来越长
        ↓
上下文限制 + Token 成本
        ↓
截断 / 总结 / 检索
        ↓
临时 Memory / 长期 Memory
        ↓
成为 Agent 状态管理的重要组成部分

二十、最后记住这几个结论

如果以后再看到 Memory,可以先忘掉各种框架 API,只记住下面几句话。

第一,大模型本身是无状态的。

所谓"记得上一轮",其实是应用程序把上一轮消息再次发送给了模型。

第二,Memory 的最原始形态就是 messages

css 复制代码
[  HumanMessage,  AIMessage,  HumanMessage,  AIMessage]

就是最基础的 Conversation Memory。

第三,InMemoryChatMessageHistory 只是把消息历史管理抽象了出来。

它解决的是:

复制代码
怎么管理 message

而不是自动解决所有 Memory 问题。

第四,真正的 Memory Management 关注的是:

复制代码
上下文长度
Token 成本
持久化
相关性

第五,Memory 常见的三个处理手段是:

复制代码
截断
总结
检索

第六,在 Agent 中要区分三个概念:

复制代码
Tool
→ 让 Agent 能做事情

RAG
→ 给 Agent 找外部知识

Memory
→ 让 Agent 保存历史和状态

而整个 Agent 系统真正不断在做的一件事情,就是:

在有限的上下文窗口里,把当前最有价值的信息组织好,再交给 LLM。

这才是理解 Memory 最核心的一条主线。

相关推荐
AEI31 分钟前
四年间大模型的演进历程
人工智能·面试·程序员
狂师1 小时前
阿里开源:skill-up,一款Agent Skill 评测工具!
人工智能·开源·agent
武子康1 小时前
同一套 Agent Runtime,为什么 Web、Headless 和 Python SDK 仍然不是同一个产品
人工智能·llm·agent
天天代码码天天1 小时前
一个纯 C 的 OCR Runtime,又向前走了一步:lw.PPOCR.C preview.5 发布
人工智能
程序员cxuan1 小时前
Claude 官方的学习教程,太强了。
人工智能·后端·程序员
小强闯江湖2 小时前
不只是让 AI 写代码:ViewCompose 把 Android UI 做成了可编译、可渲染的闭环
android·人工智能·kotlin
肆仲冬2 小时前
不用框架手写 AI Agent:ReAct 推理链和上下文管理的工程实践
人工智能
明月_清风2 小时前
发现一个超系统的 AI Agent 学习地图 —— Agent Atlas 推荐
人工智能·后端·agent
百度Geek说3 小时前
商业客户端Harness资产管理与应用实践
人工智能