深入浅出 LangGraph —— 第10章:记忆系统:短期与长期记忆

开发板推荐:天空星STM32F407VET6开发板

超高性价比 STM32主控 | 超高主频 | 一板兼容百芯 | 比赛神器 | 沉金彩色丝印

📖 本章学习目标

  • ✅ 理解短期记忆与长期记忆的本质区别与应用场景
  • ✅ 掌握基于 Checkpointer 的短期会话记忆管理
  • ✅ 学会使用 BaseStore 实现跨会话的长期记忆存储
  • ✅ 实现记忆的智能提取、摘要和注入流程
  • ✅ 掌握消息历史的压缩策略,解决 Token 超限问题
  • ✅ 避免常见的记忆管理陷阱和性能问题

一、两种记忆的本质区别

1、基本区别

  • 短期记忆(工作记忆):当前对话内容,关闭对话窗口后消失
  • 长期记忆:跨对话的用户偏好、历史习惯、重要事实

长期记忆 (Long-term)

短期记忆 (Short-term)

对话结束时
提取关键信息

摘要压缩

新会话开始时
注入上下文

会话1
消息历史

会话2
消息历史

用户偏好
姓名、习惯

历史摘要
上次讨论了什么

重要事件
里程碑记录

2、LangGraph 记忆架构对比

维度短期记忆长期记忆
实现方式Checkpointer + messagesBaseStore / 外部数据库
作用范围单个 thread_id 内跨 thread_id
生命周期会话期间持久化
容量限制Token 窗口限制理论无限
典型数据对话历史消息用户档案、偏好设置
访问速度快(内存/本地DB)慢(需查询)
更新频率每条消息按需提取

两种记忆的协作关系:

长期记忆 (Store) LLM 短期记忆 (Checkpointer) 用户 长期记忆 (Store) LLM 短期记忆 (Checkpointer) 用户 第一轮对话 第二轮对话(新会话) 发送消息 提供消息历史 回复 提取关键信息并保存 发送新消息 查询相关记忆 返回用户偏好 消息历史+长期记忆 个性化回复

二、短期记忆管理

1、自动短期记忆(Checkpointer)

第7章已介绍,Checkpointer 自动维护 thread_id 内的消息历史:

import * as dotenv from 'dotenv';
dotenv.config();

import { MemorySaver, MessagesAnnotation, StateGraph } from '@langchain/langgraph';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage, AIMessage, RemoveMessage } from '@langchain/core/messages';

const model = new ChatOpenAI({ model: 'gpt-4o-mini' });
const checkpointer = new MemorySaver();

const graph = new StateGraph(MessagesAnnotation)
  .addNode('chat', async (state) => {
    const response = await model.invoke(state.messages);
    return { messages: [response] };
  })
  .addEdge('__start__', 'chat')
  .addEdge('chat', '__end__')
  .compile({ checkpointer });

// 同一thread_id的多次调用共享消息历史
const config = { configurable: { thread_id: 'user-123-session-1' } };

await graph.invoke(
  { messages: [new HumanMessage('我叫Alice')] },
  config
);

const result = await graph.invoke(
  { messages: [new HumanMessage('我叫什么名字?')] },
  config
);
// AI会回答:"你叫Alice"

代码解读:

  • Checkpointer自动保存每次invoke后的messages状态
  • 相同thread_id的调用会自动加载历史消息
  • 这是短期记忆的基础机制

局限性:

  1. 消息数量增长会导致Token超限
  2. 不同thread_id之间无法共享记忆
  3. 需要手动清理过期消息

2、消息历史压缩:解决 Token 超限

随着对话进行,消息越来越多会超出模型的上下文窗口。三种策略:

策略1:截断——只保留最近 N 条
async function chatWithTrimming(state: typeof MessagesAnnotation.State) {
  // 只保留最近 10 条消息
  const trimmedMessages = state.messages.slice(-10);
  const response = await model.invoke(trimmedMessages);
  return { messages: [response] };
}

优势:

  1. 实现简单
  2. 性能最好
  3. 保证最新消息完整

劣势:
5. 可能丢失早期重要信息
6. 对话连贯性受影响

策略2:过滤——只保留特定类型的消息
import { trim_messages } from '@langchain/core/messages';

async function chatWithFiltering(state: typeof MessagesAnnotation.State) {
  // 使用 LangChain 内置的 trim_messages 工具
  const trimmed = await trim_messages(state.messages, {
    maxTokens: 2000,
    strategy: 'last',          // 从后往前保留
    tokenCounter: model,        // 用模型计算 token 数
    includeSystem: true,        // 保留 SystemMessage
    allowPartial: false,
    startOn: 'human',          // 第一条保留 HumanMessage
  });
  
  const response = await model.invoke(trimmed);
  return { messages: [response] };
}

优势:

  1. 智能控制Token数量
  2. 保留系统消息等重要内容
  3. 保证消息完整性(不会截断单条消息)

适用场景:

  • 长对话但Token有限的情况
  • 需要精确保留关键信息的场景
策略3:摘要——用 LLM 压缩历史
import { Annotation, messagesStateReducer } from '@langchain/langgraph';
import { BaseMessage, SystemMessage } from '@langchain/core/messages';

const SummaryState = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: messagesStateReducer,
    default: () => [],
  }),
  summary: Annotation<string>({ default: () => '' }),
});

async function summarizeIfNeeded(state: typeof SummaryState.State) {
  if (state.messages.length <= 6) return {}; // 消息少时无需摘要
  
  const summaryPrompt = state.summary
    ? `已有摘要:${state.summary}\n\n请结合以下新对话更新摘要:`
    : '请将以下对话总结为简洁的摘要:';
  
  const response = await model.invoke([
    new HumanMessage(summaryPrompt + 
      state.messages.map(m => `${m._getType()}: ${m.content}`).join('\n')
    ),
  ]);
  
  // 删除旧消息,只保留最近2条,并保存摘要
  const toDelete = state.messages.slice(0, -2)
    .map(m => new RemoveMessage({ id: m.id! }));
  
  return { summary: response.content as string, messages: toDelete };
}

工作流程:

  1. 检测消息数量是否超过阈值
  2. 调用LLM生成摘要
  3. 删除旧消息,保留最近几条
  4. 保存摘要到State
  5. 下次对话时将摘要作为SystemMessage传入

优势:

  1. 保留关键信息的同时大幅减少Token
  2. 对话连贯性好
  3. 适合超长对话场景

劣势:

  1. 需要额外调用LLM,增加成本
  2. 摘要可能丢失细节

三种策略对比:

策略实现难度Token节省信息保留适用场景
截断⭐ 简单⭐⭐ 中等⭐ 差短对话、独立问题
过滤⭐⭐ 中等⭐⭐⭐ 好⭐⭐⭐ 好通用场景
摘要⭐⭐⭐ 复杂⭐⭐⭐⭐⭐ 优秀⭐⭐⭐⭐ 很好长对话、重要会议

三、长期记忆:BaseStore

1、InMemoryStore 入门

步骤1:创建Store
import { InMemoryStore } from '@langchain/langgraph';

// 创建内存存储
const store = new InMemoryStore();

InMemoryStore是内存存储,重启后消失,适合开发测试。生产环境应使用PostgreSQL、Redis等持久化存储。

步骤2:存储记忆

Store是键值存储,支持命名空间组织:

// 存储记忆
await store.put(
  ['users', 'alice'],    // 命名空间:按层级组织数据
  'preferences',          // 记忆的键
  {                       // 记忆的值
    name: 'Alice',
    language: 'zh-CN',
    coding_style: 'functional',
    timezone: 'Asia/Shanghai',
  }
);

// 读取记忆
const memory = await store.get(['users', 'alice'], 'preferences');
console.log(memory?.value); // { name: 'Alice', ... }

put(namespace, key, value)用于存储记忆:

  • namespace:字符串数组,类似目录结构,用于组织数据
  • key:记忆条目的唯一标识
  • value:任意可序列化的对象

get(namespace, key)用于读取单条记忆,返回值为{ value: ..., metadata: ... } undefined

命名空间设计原则:

  • 按实体类型分层:[‘users’, userId]
  • 按功能分类:[‘preferences’, ‘facts’, ‘sessions’]
  • 避免过深嵌套:最多3层

2、在图中使用 Store

步骤1:编译时注入Store
import { LangGraphRunnableConfig } from '@langchain/langgraph';

// Store 需要在编译时注入,通过 config 在节点中访问
const graphWithStore = new StateGraph(MessagesAnnotation)
  .addNode('remember', async (state, config: LangGraphRunnableConfig) => {
    const userId = config.configurable?.user_id as string;
    const store = config.store!;
    
    // 读取用户的长期记忆
    const userMemory = await store.get(['users', userId], 'profile');
    
    const systemContext = userMemory
      ? `用户信息:${JSON.stringify(userMemory.value)}`
      : '这是新用户,还没有记忆档案';
    
    const response = await model.invoke([
      new SystemMessage(systemContext),
      ...state.messages,
    ]);
    
    return { messages: [response] };
  })
  .addEdge('__start__', 'remember')
  .addEdge('remember', '__end__')
  .compile({ checkpointer: new MemorySaver(), store });

使用compile({ store })将 Store 注入到图中,在节点内通过 config 访问 Store。从config.configurable?.user_id中获取用户 ID(user_id 是跨会话的,不同于 thread_id)。

步骤2:使用Store
const config = {
  configurable: {
    thread_id: 'session-001',
    user_id: 'alice',
  },
};

await graphWithStore.invoke(
  { messages: [new HumanMessage('你好')] },
  config
);
// AI会根据用户档案个性化回复

3、自动提取并保存记忆

import { z } from 'zod';

async function extractAndSaveMemory(
  state: typeof MessagesAnnotation.State,
  config: LangGraphRunnableConfig
) {
  const userId = config.configurable?.user_id as string;
  const store = config.store!;
  
  // 用 LLM 从对话中提取关键信息
  const extractionResult = await model.withStructuredOutput(
    z.object({
      hasNewInfo: z.boolean(),
      facts: z.array(z.string()).describe('从对话中提取的用户偏好和事实'),
    })
  ).invoke([
    new SystemMessage('从以下对话中提取用户的个人信息、偏好和重要事实'),
    ...state.messages.slice(-4), // 只分析最近4条
  ]);
  
  if (extractionResult.hasNewInfo && extractionResult.facts.length > 0) {
    // 保存提取的记忆
    const existing = await store.get(['users', userId], 'facts');
    const allFacts = [...(existing?.value?.facts ?? []), ...extractionResult.facts];
    
    await store.put(['users', userId], 'facts', { facts: allFacts });
    console.log(`💾 为用户 ${userId} 保存了 ${extractionResult.facts.length} 条新记忆`);
  }
  
  return {};
}

代码解读:

  • withStructuredOutput:强制 LLM 返回结构化 JSON
  • 只分析最近4条消息,避免重复提取历史消息
  • 合并新旧记忆,去重可以根据业务需要进一步优化
  • 这个节点通常在对话节点之后异步执行,不影响响应速度

提取时机:

  1. 每轮对话后自动提取
  2. 检测到关键词时触发(如"我喜欢"、“我讨厌”)
  3. 对话结束时批量提取

注意事项:

  1. 提取要异步执行,避免影响响应速度
  2. 定期清理过时或错误的记忆
  3. 给用户查看和删除记忆的权限

记忆提取流程图:

用户对话

对话节点

记忆提取节点

有新信息?

保存到Store

结束

合并去重

更新Store


四、记忆检索:语义搜索

1、基于向量的记忆检索

当记忆条目很多时,需要语义搜索找到最相关的记忆:

// 搜索相关记忆(InMemoryStore 支持简单的文本搜索)
async function searchMemories(store: InMemoryStore, userId: string, query: string) {
  const results = await store.search(
    ['users', userId],  // 在指定命名空间内搜索
    { query, limit: 3 } // 返回最相关的3条
  );
  
  return results.map(item => item.value);
}

// 在节点中注入相关记忆
async function chatWithRelevantMemories(
  state: typeof MessagesAnnotation.State,
  config: LangGraphRunnableConfig
) {
  const userId = config.configurable?.user_id as string;
  const store = config.store!;
  const lastMsg = state.messages[state.messages.length - 1].content as string;
  
  // 搜索与当前消息最相关的记忆
  const relevantMemories = await searchMemories(store as InMemoryStore, userId, lastMsg);
  
  const memoryContext = relevantMemories.length > 0
    ? `相关记忆:\n${relevantMemories.map(m => JSON.stringify(m)).join('\n')}`
    : '';
  
  const response = await model.invoke([
    new SystemMessage(`你是一个有记忆的助手。${memoryContext}`),
    ...state.messages,
  ]);
  
  return { messages: [response] };
}

使用store.search()在命名空间内搜索与query相关的记忆,返回指定数量的结果(建议3-5条),并将搜索结果注入到SystemMessage,让LLM感知。

更高级的方案是使用向量数据库(Pinecone、Weaviate)和 嵌入模型计算相似度,使用RAG模式检索最相关记忆。

实际应用场景:

  • 客服系统:搜索用户历史问题
  • 学习助手:回忆之前学过的知识点
  • 个人助理:记住用户的喜好和习惯

五、最佳实践和踩坑指南

💡 实践 1:记忆的分层设计

// 按重要性分层组织记忆命名空间
const namespaces = {
  core: ['users', userId, 'core'],      // 核心档案:姓名、语言偏好
  facts: ['users', userId, 'facts'],    // 事实记忆:知识点、习惯
  sessions: ['users', userId, 'sessions'], // 会话摘要
};

// 示例:存储不同类型的记忆
await store.put(namespaces.core, 'profile', {
  name: 'Alice',
  language: 'zh-CN',
});

await store.put(namespaces.facts, 'tech_stack', {
  languages: ['TypeScript', 'Python'],
  frameworks: ['React', 'LangGraph'],
});

await store.put(namespaces.sessions, 'last_topic', {
  topic: 'LangGraph记忆系统',
  date: '2026-04-19',
});

原因:分层设计便于管理和检索,不同类型记忆有不同更新策略。

💡 实践 2:记忆更新要幂等

// ✅ 使用 upsert 语义(put 天然幂等),避免重复
await store.put(['users', userId], 'preferences', newPrefs);

// ❌ 避免无限追加相同的事实
const existing = await store.get(['users', userId], 'facts');
// 追加前先去重
const uniqueFacts = [...new Set([...(existing?.value?.facts ?? []), ...newFacts])];
await store.put(['users', userId], 'facts', { facts: uniqueFacts });

原因:防止记忆膨胀,保持数据整洁。

💡 实践 3:记忆注入位置

// ✅ 正确:将记忆放在SystemMessage中
const response = await model.invoke([
  new SystemMessage(`用户偏好:${JSON.stringify(userPrefs)}`),
  ...state.messages,
]);

// ❌ 错误:将记忆放在HumanMessage中
const response = await model.invoke([
  ...state.messages,
  new HumanMessage(`用户偏好:${JSON.stringify(userPrefs)}`), // 混淆身份
]);

原因:SystemMessage是系统级别的上下文,不会影响对话角色的清晰度。

⚠️ 常见问题

问题现象解决方案
记忆注入位置错误LLM 忽视长期记忆将记忆放在 SystemMessage 中,而非 HumanMessage
注入过多记忆Token 超限使用语义搜索只注入最相关的3-5条记忆
记忆提取在关键路径响应变慢将记忆提取/保存改为异步后台任务
user_id thread_id 混淆跨会话记忆失效user_id 在 configurable 中单独传,与 thread_id 无关
记忆永不清理Store无限增长定期清理过期或无用记忆
提取过于频繁成本高、噪音多设置提取触发条件,如置信度阈值

📝 本章小结

核心知识点回顾

知识点关键要点应用场景
短期记忆Checkpointer 自动维护 messages单会话对话历史
消息压缩截断/过滤/摘要三种策略防止 Token 超限
BaseStoreput/get/search 跨会话存储用户档案、偏好记忆
记忆注入通过 SystemMessage 注入长期记忆个性化 Agent 响应
记忆提取LLM结构化输出提取关键信息自动构建用户档案
语义搜索store.search 检索相关记忆精准注入上下文

🎯 动手练习

练习 1:消息摘要压缩

  • 目标:超过8条消息后自动触发摘要,并清理旧消息
  • 要求:
    1. 实现summarizeIfNeeded节点
    2. 摘要要保留关键信息
    3. 下次对话能感知历史
  • 验收标准:
    • 20轮对话后 State 中的消息数量不超过4条
    • 摘要准确反映之前的对话内容
    • LLM能基于摘要继续对话

练习 2:用户偏好记忆

  • 目标:Agent 记住用户的语言偏好和回答风格要求
  • 要求:
    1. 第一次告诉 Agent “用简洁的语言回答”
    2. 第二个会话中无需重申
    3. 使用Store跨会话保存
  • 验收标准:
    • 新会话中 Agent 自动以简洁风格回答
    • 用户可以查询自己的偏好设置
    • 偏好可以修改和删除

练习 3:记忆搜索注入

  • 目标:对话时自动搜索并注入最相关的3条长期记忆
  • 要求:
    1. 使用 store.search 语义搜索
    2. 根据当前消息内容搜索
    3. 注入到SystemMessage
  • 验收标准:
    • 询问相关问题时,Agent 主动提及之前记录的信息
    • 搜索结果相关度高
    • 不影响响应速度

练习 4:记忆管理系统

  • 目标:实现完整的记忆CRUD功能
  • 要求:
    1. 查看:列出所有记忆
    2. 编辑:修改某条记忆
    3. 删除:移除错误记忆
    4. 导出:导出为JSON
  • 验收标准:
    • 所有CRUD操作正常工作
    • 用户界面友好
    • 数据一致性保证

📚 延伸阅读


下一章:第11章 —— 子图:构建模块化 Agent

开发板推荐:天空星STM32F407VET6开发板

超高性价比 STM32主控 | 超高主频 | 一板兼容百芯 | 比赛神器 | 沉金彩色丝印

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值