大模型为什么总是“失忆”?—— 从零理解 Memory 模块的设计与实现

大模型为什么总是"失忆"?------ 从零理解 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 重排更复杂,因为记忆会随时间变化和产生冲突。

相关推荐
桃西西呀1 小时前
LangGraph4j之七:子图与组合
人工智能·llm·ai编程
待遇安然风1 小时前
DeepSeek 架构研读 (二):DeepSeek-V2 MoE 架构与 MLA 机制详解
人工智能
预知同行1 小时前
为什么大家都在劝退 Agent 开发?因为多数场景根本轮不到它出场
人工智能
千木SilvaMux1 小时前
prompt injection 攻防为什么总在边界失效
llm·api
星云低代码开发平台1 小时前
AI 销售周报的数据契约:CRM 商机、ERP 订单与异常核对
大数据·人工智能·企业ai
YH_Analytics1 小时前
2026-2032 蘑菇罐头市场规模与增长预测
人工智能
星云低代码开发平台1 小时前
企业 AI 连接架构:iPaaS、网关、RPA、MCP 与连接器边界
人工智能·架构·rpa·企业ai
0春华秋实1 小时前
内置不够用?手搓 RAGAS 自定义指标(含 SQL/MultiHop)(10/13)
人工智能·aigc·开源软件
老唐7771 小时前
(附代码)使用OpenCV和关键点检测计算瓶子角度
人工智能·深度学习·opencv·目标检测·计算机视觉·ai·transformer
用户930687792311 小时前
我的 Agent 自己学会了记住用户:一次计划外的涌现
agent