大模型为什么总是"失忆"?------ 从零理解 Memory 模块的设计与实现
摘要:大模型本身没有记忆,每次 API 调用都是失忆的。所有"记忆"都是应用层在每次请求时把历史信息重新塞进 prompt 实现的。本文从记忆分层出发,拆解存储、截断、总结、检索四大机制,帮你构建一个真正"记得住"的 AI 应用。
📑 目录
- 大模型的"失忆症":每次对话都从零开始
- 记忆的两个层次:短期与长期
- 存储层:内存、文件、数据库的物理差异
- 会话隔离:多个用户如何互不干扰
- 截断:满了就丢,但别丢错
- 总结:用 LLM 压缩旧消息
- 检索:从向量库中按需召回历史
- 综合方案:截断为主,总结为辅,检索兜底
- 互动讨论
大模型的"失忆症":每次对话都从零开始
大模型本身没有记忆。每次调用 API,你发送一组 messages,模型基于这组 messages 生成回复,然后立刻忘掉一切。下一次调用时,它完全不记得上一轮聊了什么。
类比:大模型就像一个失忆症患者,每次见面前你得把之前发生的事简要告诉他。
所以,所谓的"记忆"并不是模型自己记住了什么,而是应用层在每次请求时把历史信息重新塞进 prompt 。你之前写的每一行 chatMessages 数组维护代码,本质上就是在做 Memory 管理。
但简单的数组维护会遇到三个痛点:
| 痛点 | 表现 |
|---|---|
| 上下文窗口限制 | 对话越长,messages 数组越大,最终超出模型窗口 |
| token 开销 | 每次请求都要发送完整历史,token 成本随对话轮次线性增长 |
| 持久化 | 服务重启后内存中的历史丢失,用户需要重新自我介绍 |
Memory 模块要解决的,就是这三个问题。
记忆的两个层次:短期与长期
| 层次 | 范围 | 解决什么问题 | 典型实现 |
|---|---|---|---|
| 短期记忆 | 单次会话内 | "刚才聊了什么" | InMemoryChatMessageHistory |
| 长期记忆 | 跨会话持久化 | "你是谁、你的偏好是什么" | 文件存储、数据库、向量库 |
存储层的基础接口
LangChain 把消息存储抽象成了 BaseChatMessageHistory 基类:
typescript
csharp
abstract class BaseChatMessageHistory {
abstract get messages(): BaseMessage[];
abstract addMessage(message: BaseMessage): Promise<void>;
abstract addUserMessage(message: string): Promise<void>;
abstract addAIChatMessage(message: string): Promise<void>;
abstract clear(): Promise<void>;
}
关键认知 :存储建立在 BaseChatMessageHistory 上,存储的内容是 BaseMessage 的实例。两者是不同的东西------BaseMessage 是消息本身(HumanMessage、AIMessage),BaseChatMessageHistory 是历史存储的抽象基类。
内存记忆的实战
javascript
ini
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
async function inMemoryDemo() {
const history = new InMemoryChatMessageHistory();
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 message2 = [systemMessage, ...(await history.getMessages())];
const response2 = await model.invoke(message2);
await history.addMessage(response2);
}
关键设计 :systemMessage 独立开来,不需要加入内存存储,因为每次会手动带上。history 只存储对话消息(HumanMessage + AIMessage),保持数据的纯粹性。
存储层:内存、文件、数据库的物理差异
三种存储对外暴露的接口都是 BaseChatMessageHistory,但物理形态完全不同。
| 存储类型 | 数据形态 | 读取方式 | 生命周期 |
|---|---|---|---|
| 内存 | BaseMessage 对象引用直接存在 JS 对象里 |
取出来就是原对象 | 进程结束即丢失 |
| 文件 | 序列化成 JSON 写文件 | 反序列化组装成 BaseMessage |
持久化到磁盘 |
| 数据库 | 拆成字段存表 | 按字段拼装成 BaseMessage |
持久化到数据库 |
关键认知 :内存存储没有序列化,取出来就是原对象,改它会影响原对象。文件/数据库存储经过序列化,取出来是新组装的对象。
文件存储的会话隔离
FileSystemChatMessageHistory 需要 sessionId 参数,因为它是持久化定位符,要落地成磁盘文件:
javascript
ini
const filePath = path.join(process.cwd(), "chat_history.json");
const sessionId = "user_session_001";
const history = new FileSystemChatMessageHistory({
filePath,
sessionId
});
从文件中恢复历史
javascript
ini
const restoredHistory = new FileSystemChatMessageHistory({
filePath,
sessionId
});
const restoredMessages = await restoredHistory.getMessages();
console.log(`从文件中恢复了${restoredMessages.length}条历史信息`);
// 继续第三轮对话
const userMessage3 = new HumanMessage("需要哪些食材?");
await restoredHistory.addMessage(userMessage3);
const message3 = [systemMessage, ...(await restoredHistory.getMessages())];
const response3 = await model.invoke(message3);
服务重启后,从文件中恢复历史,用户不需要重新自我介绍。
会话隔离:多个用户如何互不干扰
InMemoryChatMessageHistory 不接受 sessionId ,它就是一个无名的历史容器。区分多个实例靠外层的 Map:
javascript
csharp
const store = new Map<string, InMemoryChatMessageHistory>();
function getHistory(sessionId: string) {
if (!store.has(sessionId)) {
store.set(sessionId, new InMemoryChatMessageHistory());
}
return store.get(sessionId)!;
}
await getHistory("user-123").addUserMessage("我叫张三");
await getHistory("user-456").addUserMessage("我叫李四");
FileSystemChatMessageHistory 需要 sessionId,因为它是持久化定位符。
LangGraph 的会话隔离
LangGraph 用 Checkpointer + thread_id,不需要手动管理 Map:
javascript
css
await graph.invoke(input, { configurable: { thread_id: "user-123" } });
关键认知 :会话隔离靠 thread_id,Checkpointer 用它做 key 查找,本质就是"用编号做键的 Map"。条件边是图内部的流程控制,决定"下一步去哪个节点",与会话隔离无关。
Milvus 的定位
Milvus 不是"消息历史的存储",而是"消息片段的索引"。它存的是向量 + 文本片段,不保证顺序,也不按 sessionId 组织。
| 存储类型 | 数据形态 | 读取方式 | 用途 |
|---|---|---|---|
| 内存/文件/数据库 | 完整消息列表,有序 | 按 sessionId 全量或窗口读取 | 短期对话历史 |
| Milvus/向量库 | 向量 + 文本片段,无序 | 按 query 语义相似度检索 | 长期记忆的按需召回 |
典型长期记忆方案:消息存数据库,同时向量化存 Milvus,检索时走向量库,取回后再去数据库拿完整内容。
截断:满了就丢,但别丢错
截断的逻辑很简单:满了就把之前的丢了。两种思路------按消息数量、按 token 数量。
按消息数量截断
javascript
ini
const maxMessages = 4;
let allMessages = await history.getMessages();
const trimmedMessages = allMessages.slice(-maxMessages);
但有个坑:截断后第一条如果是 AIMessage,上下文断裂 。需要确保开头是 HumanMessage:
javascript
ini
let trimmed = messages.slice(-n);
while (trimmed.length && trimmed[0]._getType() !== "human") {
trimmed = trimmed.slice(1);
}
按 token 截断
javascript
javascript
import { trimMessages } from "@langchain/core/messages";
const trimmed = await trimMessages(messages, {
maxTokens: 2000,
tokenCounter: (msgs) => countTokens(msgs),
strategy: "last",
includeSystem: true,
startOn: "human",
allowPartial: false,
});
| 参数 | 作用 |
|---|---|
includeSystem: true |
system prompt 不参与截断,永远保留 |
startOn: "human" |
截断后第一条必须是人类消息 |
strategy: "last" |
保留最新的;"first" 则保留最早的 |
allowPartial: false |
一条消息要么完整保留,要么完整丢弃 |
Token 计算的性能问题
trimMessages 是同步阻塞的,每次调用遍历所有消息算 token。对话长、调用频繁时,token 计算本身会成为性能瓶颈。生产环境通常缓存每条消息的 token 数。
用 js-tiktoken 精确计算
javascript
ini
import { getEncoding } from 'js-tiktoken';
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;
}
const enc = getEncoding("cl100k_base");
注意 :多模态调用时,msg.content 可能不是字符串而是 JSON 对象,需要先 JSON.stringify 再计算 token。
总结:用 LLM 压缩旧消息
总结的核心机制:用 LLM 把旧消息压缩成一段摘要,替代原文。
按条数总结
javascript
ini
const maxMessages = 6;
const keepRecent = 2;
if (allMessages.length > maxMessages) {
const recentMessages = allMessages.slice(-keepRecent);
const messagesToSummarize = allMessages.slice(0, -keepRecent);
const summary = await summarizeHistory(messagesToSummarize);
await history.clear();
for (const msg of recentMessages) {
await history.addMessage(msg);
}
await history.addMessage(new AIMessage(summary));
}
总结函数的实现
javascript
ini
const summarizeHistory = async (messages) => {
if (messages.length === 0) return "";
const conversationText = getBufferString(messages, "用户", "助手");
const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:
`;
const summaryResponse = await model.invoke([new SystemMessage(summaryPrompt)]);
return summaryResponse.content;
}
getBufferString 把消息数组 [HumanMessage, AIMessage...] 转为单段拼接文字字符串,每条消息带上角色前缀。
按 token 总结
javascript
ini
const maxTokens = 200;
const keepRecentTokens = 80;
const totalTokens = countTokens(allMessages, encoder);
if (totalTokens >= maxTokens) {
const recentMessages = [];
let recentTokens = 0;
for (let i = allMessages.length - 1; i >= 0; i--) {
const msg = allMessages[i];
const msgTokens = enc.encode(content).length;
if (recentTokens + msgTokens <= keepRecentTokens) {
recentMessages.unshift(msg);
recentTokens += msgTokens;
} else {
break;
}
}
const messagesToSummarize = allMessages.slice(0, allMessages.length - recentMessages.length);
const summary = await summarizeHistory(messagesToSummarize);
// 清空后重建
}
总结的工程坑
| 坑 | 表现 | 解法 |
|---|---|---|
| 摘要丢细节 | LLM 压缩时可能丢掉关键数字、专有名词、否定词 | 在总结 prompt 中强调"保留重要信息" |
| 总结消耗 token | 频繁触发可能比不总结还贵 | 设置合理的触发阈值 |
| 递归总结衰减 | 每次把"上次摘要 + 新消息"再总结,信息一轮轮丢失 | 保留原始消息的备份 |
| 同步阻塞 | 总结本身要调 LLM,增加用户等待时间 | 后台异步总结,用户请求走截断 |
截断 vs 总结
| 维度 | 截断 | 总结 |
|---|---|---|
| 信息保留 | 直接丢失 | 压缩保留 |
| token 成本 | 零 | 要调 LLM |
| 延迟 | 无 | 有 |
| 实现复杂度 | 低 | 高 |
| 信息衰减 | 无(丢就丢了) | 有(递归总结会衰减) |
实践组合:截断为主 + 总结为辅。截断负责日常控制窗口,总结只用于必须记住的长期上下文。
检索:从向量库中按需召回历史
检索的核心逻辑:
text
erlang
用户新 query → 向量化 → 向量库检索相关历史片段 → 拿到片段 ID
→ 回数据库/文件取完整消息 → 注入当前 prompt
与截断、总结的本质区别
| 维度 | 截断 | 总结 | 检索 |
|---|---|---|---|
| 处理对象 | 当前窗口内的消息 | 当前窗口内的旧消息 | 窗口外的历史 |
| 是否调 LLM | 否 | 是 | 否(只调 embedding) |
| 是否调向量库 | 否 | 否 | 是 |
| 信息保留 | 丢旧的 | 压缩旧的 | 按相关性召回 |
| 适用场景 | 日常对话 | 长对话压缩 | 跨会话长期记忆 |
检索记忆的实现
javascript
php
async function retrievedRelevantConversations(query, k=2) {
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'round', 'timestamp']
});
return searchResult.results;
}
完整流程
javascript
javascript
// 检索相关历史
const retrievedConversations = await retrievedRelevantConversations(input, 2);
let relevantHistory = '';
if (retrievedConversations.length > 0) {
relevantHistory = retrievedConversations.map((conv, idx) => {
return `[历史对话 ${idx+1}]
轮次: ${conv.round}
${conv.content}
`;
}).join('\n\n----------\n\n');
}
// 构建带上下文的 prompt
const contextMessages = relevantHistory
? [new HumanMessage(`相关历史对话:\n ${relevantHistory}\n\n用户问题:${input}`)]
: [userMessage];
const response = await model.invoke(contextMessages);
// 将本轮对话存入向量库
const conversationText = `用户:${input} \n 助手:${response.content}`;
const convId = `conv_${Date.now()}_${i + 1}`;
const convVector = await getEmbedding(conversationText);
await client.insert({
collection_name: COLLECTION_NAME,
data: [{
id: convId,
content: conversationText,
vector: convVector,
round: i + 1,
timestamp: new Date().toISOString()
}]
});
检索的工程坑
| 坑 | 表现 | 解法 |
|---|---|---|
| 检索噪音 | 向量相似度高不代表真的相关 | 加时间衰减因子 + 冲突检测 |
| 时间衰减 | 三个月前的偏好可能过时 | 时间加权:得分 = 相关性 × exp(-λ × 距今天数) |
| 会话隔离 | 检索时必须限定 sessionId |
metadata 过滤 |
| 写入污染 | 什么都往向量库写,检索质量越来越差 | 过滤或提炼后再写入 |
记忆重排 vs RAG 重排
| 维度 | RAG 重排 | 记忆重排 |
|---|---|---|
| 考虑因素 | 语义相关性 | 语义相关性 + 时间时效性 + 冲突检测 |
| 冲突问题 | 文档不会互相矛盾 | 记忆会矛盾("喜欢红色" vs "现在不喜欢红色") |
| 处理方式 | Cross-Encoder 精排 | Cross-Encoder 精排 → 时间加权 → 冲突检测 → 取 top 3~5 |
易错:以为重排就是简单按分数排序。重排是根据具体业务决定计算方法。记忆重排比 RAG 重排更复杂,因为多一个时间维度和冲突检测。
综合方案:截断为主,总结为辅,检索兜底
将三种手段组合使用:
| 场景 | 采用手段 | 说明 |
|---|---|---|
| 日常对话 | 截断 | 保留最近 N 条消息,控制窗口 |
| 长对话压缩 | 总结 | 旧消息丢给 LLM 总结,保留最近几轮原文 |
| 跨会话长期记忆 | 检索 | 向量库按 query 召回相关历史片段 |
典型实现方案
text
markdown
每聊 20 条 → 触发一次总结 → 生成摘要 → 存入 Milvus
↓
用户提问 → 向量化 → Milvus 检索相关历史
↓
注入当前 prompt → 调用 LLM → 返回回答
Agent 执行流程 ReAct:维护 messages 数组 → Memory 模块负责截断、总结、持久化 → 结合临时记忆和长期记忆,解决上下文限制、开销、持久化三大痛点。
互动讨论
💬 大模型真的有记忆吗?
没有。大模型是无状态的,每次 API 调用都是独立的。所有"记忆"都是应用层在每次请求时把历史信息重新塞进 prompt 实现的。
💬 内存存储和文件存储的物理差异是什么?
内存存储直接存 BaseMessage 对象引用,没有序列化,取出来就是原对象。文件存储序列化成 JSON 写文件,读出来反序列化组装成 BaseMessage。两者对外接口相同,但物理形态不同。
💬 截断后第一条消息为什么必须是 HumanMessage?
如果截断后第一条是 AIMessage,上下文就断裂了------模型看到的第一条是自己说的话,不知道在回复什么。所以需要循环移除开头的 AIMessage,直到第一条是 HumanMessage。
💬 总结为什么会导致信息衰减?
每次总结都是"上次摘要 + 新消息"再总结,信息一轮轮被压缩。关键数字、专有名词、否定词可能在压缩过程中丢失。解法是保留原始消息的备份,或限制递归总结的次数。
💬 检索记忆和 RAG 有什么区别?
检索记忆是"从历史对话中召回相关片段",RAG 是"从知识库中召回相关文档"。两者的底层机制(向量化 + 相似度检索)相同,但处理对象不同。记忆重排比 RAG 重排更复杂,因为记忆会随时间变化和产生冲突。