Memory 模块深度解析(面试向)
一、什么是 Memory?------ 一句话回答
Memory 是让无状态 LLM 拥有"上下文持续记忆"能力的机制。
LLM 本身是无状态的(stateless)------每次 API 调用都是独立的,不记得上一轮说了什么。Memory 通过在每次请求中注入历史信息,让 LLM "看起来"记住了对话。
二、为什么需要 Memory?------ 从 HTTP 类比讲起
2.1 LLM 的无状态本质
lua
LLM 就像纯函数:input → output,不保留任何状态。
这和 HTTP 协议一模一样------每次请求独立,服务器不记得你。
2.2 HTTP 怎么解决无状态?→ Cookie / Authorization Header
sql
HTTP 请求头里带上 Cookie 或 Authorization,服务器就能"认出"用户。
LLM 的 Memory 就是同一个思路------每次请求时,把历史对话塞进 messages 数组。
2.3 项目代码中的直接体现
看 memory-test/history-test.mjs 第 25-27 行:
javascript
// 核心:把历史消息拼进新一轮请求
const messages1 = [systemMessage, ...(await history.getMessages())];
const response1 = await model.invoke(messages1);
这就是 Memory 的最原始形态:把之前的对话记录塞进下一次请求的 messages 数组。
三、Memory 的三个层次(面试框架)
可以用一个清晰的递进结构来讲述:
yaml
Level 1: Messages 数组(最基础)
↓ 问题:context 越来越长,token 消耗爆炸
Level 2: 截断 / 滑动窗口 / 摘要(工程优化)
↓ 问题:丢失了长期记忆,无法跨会话
Level 3: 持久化 + 检索(RAG)+ 结构化记忆(生产级)
四、Level 1:Messages 数组 ------ 最基础的 Memory
4.1 原理
typescript
// 伪代码:每次请求时,消息数组越来越长
const messages = [
new SystemMessage("你是一个友好幽默的做菜助手..."),
new HumanMessage("你今天吃的什么?"), // 第 1 轮
new AIMessage("我今天吃了红烧肉..."), // 第 1 轮回复
new HumanMessage("教我做"), // 第 2 轮
new AIMessage("好的,首先准备五花肉..."), // 第 2 轮回复
new HumanMessage("有素食版本吗?"), // 第 3 轮 ← 当前问题
];
// 这一整坨全部发给 LLM
4.2 LangChain 中的实现
| 类 | 位置 | 作用 |
|---|---|---|
BaseChatMessageHistory |
@langchain/core/chat_history |
抽象基类,定义 addMessage() / getMessages() |
InMemoryChatMessageHistory |
同上 | 最简实现 :this.messages = [] 纯内存数组 |
memory-test/history-test.mjs 中用的就是 InMemoryChatMessageHistory:
javascript
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
const history = new InMemoryChatMessageHistory();
await history.addUserMessage(userMessage1); // 写入
const messages1 = [systemMessage, ...(await history.getMessages())]; // 读出
await history.addMessages([response1]); // 写入 AI 回复
4.3 Messages 数组的问题
- Token 消耗线性增长:每轮对话都追加,10 轮后 context 可能已经上万 token
- 超出上下文窗口:GPT-4 128K、Claude 200K 也有上限,超了就报错或截断
- 进程重启就丢失 :
InMemory存内存,服务重启记忆清空 - 没有优先级:远古对话和最近对话同等重要
五、Level 2:截断 / 滑动窗口 / 摘要
5.1 滑动窗口(Buffer Window)------ LRU 思想
python
只保留最近 K 轮对话,类似 LRU Cache。
messages.slice(-k * 2) ← 每轮对话占 2 条消息(Human + AI)
LangChain 实现:BufferWindowMemory(@langchain/classic/memory/buffer_window_memory.js)
javascript
// 核心逻辑就一行
loadMemoryVariables() {
return messages.slice(-this.k * 2);
}
类比:力扣 LRU Cache 题 ------ 淘汰最久未使用的,保留最近使用的。
5.2 摘要 Memory(Summary Memory)
arduino
当历史太长时,调用 LLM 把旧对话总结成一段话:
"之前用户问了红烧肉的做法,我给出了详细步骤。用户又问素食替代..."
然后将这段摘要代替原始消息塞入 context。
LangChain 实现:ConversationSummaryMemory(@langchain/classic/memory/summary.js)
javascript
// 核心:每次 save 后调用 LLM 重新生成摘要
async saveContext(inputValues, outputValues) {
await super.saveContext(inputValues, outputValues);
this.summary = await this.predictNewSummary(旧摘要, 新消息);
}
5.3 混合方案(Summary Buffer)------ 工业级常用
arduino
保留:最近 N 条原始消息 + 更早对话的 LLM 摘要
这样既有"细节"(最近的),又有"大局"(摘要的)
LangChain 实现:ConversationSummaryBufferMemory,通过 maxTokenLimit 控制何时触发摘要。
5.4 Cursor 等 IDE 的做法
从 readme.md 笔记可知:
- 持续追踪 token 开销
- 自动触发总结(/compact)
- 手动清空(/clear)
六、Level 3:持久化 + 检索 + 结构化记忆
6.1 向量检索 Memory(RAG 模式)
markdown
思路:把所有历史对话 embed 成向量 → 存入向量数据库
每次新问题时,检索最相关的历史片段 → 注入 context
不需要把全部历史都塞进去!
LangChain 实现:VectorStoreRetrieverMemory(@langchain/classic/memory/vector_store.js)
javascript
// 存:把对话 embed 后写入向量库
async saveContext(inputValues, outputValues) {
const doc = new Document({ pageContent: `${input} → ${output}` });
await this.vectorStore.addDocuments([doc]);
}
// 取:用当前问题做相似度检索
async loadMemoryVariables(inputValues) {
const docs = await this.vectorStore.similaritySearch(input, k);
return docs.map(d => d.pageContent).join('\n');
}
6.2 结构化实体 Memory(Entity Memory)
从对话中提取命名实体(人名、菜名、偏好...),
为每个实体维护独立的摘要。
LangChain 实现:EntityMemory,内部分为三层 LLM 调用:
- 抽取:从对话中提取实体
- 总结:为每个实体生成/更新摘要
- 注入:把相关实体摘要注入 system prompt
七、Reasonix Code 的 Memory 实现(文件系统 + MEMORY.md 索引)
7.1 架构总览
javascript
┌──────────────────────────────────────────────┐
│ 每次 /new 或启动时 │
│ │
│ 读取 MEMORY.md(索引文件) │
│ ↓ │
│ 根据 scope 筛选: │
│ • global → ~/.reasonix/memories/*.md │
│ • project → .reasonix/memories/*.md │
│ ↓ │
│ high priority 内容注入 HIGH PRIORITY 区块 │
│ 普通 priority 内容注入系统 prompt │
│ ↓ │
│ 拼入 System Prompt,模型获得记忆 │
└──────────────────────────────────────────────┘
7.2 数据模型
每条 Memory 是一个 Markdown 文件,包含元信息:
yaml
# 文件路径:.reasonix/memories/项目偏好.md
type: project # user | feedback | project | reference
scope: project # global | project
priority: high # low | medium | high
expires: project_end # 可选:项目结束时自动清除
description: ≤150字符的一句话摘要(显示在 MEMORY.md 索引中)
content: Markdown 正文(具体规则、偏好、上下文)
7.3 核心 API:三个 Tool
| Tool | 功能 | 类比 |
|---|---|---|
remember |
创建/更新一条记忆,写入 .md 文件 + 更新 MEMORY.md 索引 |
git add + git commit |
recall_memory |
读取某条记忆的完整正文 | cat 详细内容 |
forget |
删除记忆文件 + 从 MEMORY.md 移除条目 |
git rm |
7.4 为什么用文件系统而不是数据库?
面试可以这样回答:
- 简单可靠:Markdown 文件人类可读、Git 友好、不需要额外基础设施
- 与项目绑定 :
.reasonix/目录随项目走,clone 即用 - 系统 prompt 注入天然适配:Markdown 文本直接拼入 prompt,零转换成本
- 优先级分层加载:high priority 强制注入(用于关键约束),普通 priority 按需加载
- MEMORY.md 作为二级索引 :系统 prompt 中只放一行摘要,正文用
recall_memory按需读取 → 节省 context token
7.5 Memory 类型的设计哲学
| Type | 存什么 | 面试话术 |
|---|---|---|
user |
用户角色、技能偏好、习惯 | "个性化层,让 Agent 适配不同用户" |
feedback |
用户纠正过的错误做法 | "纠错层,避免重复犯同样的错" |
project |
项目架构决策、约定 | "项目上下文层,新人 clone 后立即获得项目知识" |
reference |
外部系统指针 | "集成层,记住 CI 地址、文档链接等外部资源" |
八、面试回答模板(3 分钟版本)
开场(30 秒)
"Memory 是让无状态大模型拥有上下文持续记忆能力的机制。核心思想很简单:LLM 每次调用是 stateless 的,我们通过在每次请求的 messages 数组中注入历史信息,让它'记住'之前的对话。这和 HTTP 用 Cookie 解决无状态问题是同一个思路。"
展开:三个层次(1 分 30 秒)
"我理解 Memory 有三个递进层次:
第一层是最基础的 Messages 数组 ------把历史对话直接拼进下一次请求。我们在
memory-test/history-test.mjs里用的InMemoryChatMessageHistory就是这个模式的最简实现。但它的问题是 token 线性增长,最终超出上下文窗口。第二层是工程优化 ------滑动窗口截断(类似 LRU Cache),或者用 LLM 把旧对话总结成摘要再注入。LangChain 提供了
BufferWindowMemory、ConversationSummaryMemory、甚至混合的SummaryBufferMemory。第三层是持久化 + 检索 ------把历史对话 embed 成向量存进向量数据库,每次用语义检索取回最相关的片段(RAG 模式),以及结构化实体记忆(
EntityMemory)。"
收官:Reasonix 的实践(1 分钟)
"在 Reasonix Code 中,Memory 用的是文件系统 + Markdown 的方案,非常务实:
- 记忆存为
.md文件,MEMORY.md作为索引文件- 启动时根据 scope 加载,注入系统 prompt
- 分层优先级(high/medium/low),关键约束强制注入
- 三个工具:
remember写入、recall_memory读取、forget删除这个设计的亮点是:文件系统零依赖、Git 友好、Markdown 可直接拼入 prompt 无需格式转换。"
九、可能被追问的深度问题
Q1: "滑动窗口的 K 怎么选?"
取决于模型上下文窗口和每轮对话的平均 token 数。先用 tokenizer 估算,留出 30% 给 system prompt 和输出。LangChain 的
ConversationTokenBufferMemory直接按 token 数而非消息数来截断,更精准。
Q2: "摘要 Memory 有什么坑?"
摘要会丢失细节。比如用户说"我讨厌香菜",摘要可能变成"用户有饮食偏好",具体偏好的关键信息丢了。解决方案是混合模式(SummaryBuffer)------最近的消息保留原文,旧的才摘要。
Q3: "向量检索 Memory 和 RAG 有什么区别?"
本质一样。向量检索 Memory 就是 RAG 在对话记忆场景的特化------把历史对话作为文档库,当前问题作为 query。区别在于 Memory 还需要写操作(
saveContext),RAG 通常只读。
Q4: "为什么不用数据库存 Memory?"
对于 Agent 场景,文件系统有几个优势:
- 零运维------不需要起数据库
- 随项目 Git 版本化------
git clone即获得项目记忆- 人类可直接编辑------紧急情况手动改
.md文件就行- 减少格式转换------Markdown 直接拼入 prompt
但大规模多用户场景下,数据库 + 向量检索是更好的选择。
十、代码走读速记(面试前快速过一遍)
| 文件 | 重点看什么 |
|---|---|
readme.md 第 1-17 行 |
Memory 的宏观定位:LLM + Tool + RAG + Memory 四件套 |
readme.md 第 19-28 行 |
无状态 → messages 数组的推导 |
readme.md 第 32-36 行 |
三种解决方案:截断、摘要、检索 |
memory-test/history-test.mjs 第 25-27 行 |
Memory 的最简实现:[systemMsg, ...history] |
memory-test/history-test.mjs 第 14 行 |
InMemoryChatMessageHistory 实例化 |
十一、一句话总结(电梯演讲)
Memory 就是把"对话历史"变成"系统 prompt 的一部分",让无状态的 LLM 看起来有记忆。从最简单的数组拼接,到滑动窗口截断,到向量语义检索,再到文件系统持久化------不同方案解决的核心矛盾始终是同一个:如何在有限的 token 预算内,给模型提供最有价值的上下文。