☰
LangChain 1.0 记忆管理实战:短期与长期记忆的实现与避坑指南
2026/10/20 7:56:35 网站建设 项目流程

做对话式 AI 项目,只要超过三四轮,就会撞上同一个问题:模型不记得刚才聊过什么。我在 LangChain 1.0 环境下重做了几个客服助手和文档问答类的应用,最纠缠人的不是工具调用,也不是提示词,而是记忆管理。短期记忆怎么传、长期记忆怎么存、什么时候该截断、什么时候该去向量库里捞,这些不梳理清楚,上生产之后全是事故现场。这篇就用 LangChain 1.0 的实践口径,把短期记忆和长期记忆的实现路径、关键参数、常见坑一次性讲透。适合刚跑通 demo 想往生产走的开发者,也适合还在旧版 Memory 模块里打转的迁移选手。

1. LangChain 1.0 里“记忆管理”到底指什么

1.1 为什么单独聊记忆

大模型接口本身是无状态的,它不会像人一样天然记得上句话。所谓记忆,本质上是每次请求前,由我们替模型把“它应该知道的过去信息”重新组装进上下文。组装得好不好,直接决定回答质量。

这里要分清两个层次:

  • 短期记忆:当前会话内的对话历史,比如用户在一个会话里先问 A,再问 B,B 里可能引用 A 的内容。这类信息只需要在会话生命周期内有效。
  • 长期记忆:跨会话保留的用户事实和业务偏好,比如“用户喜欢表格形式回复”“用户所在城市是杭州”“上个月定的报价规则不能改”。这类信息需要在用户下次回来时仍然可用。

很多项目起步时只做了短期记忆,聊天时看着正常,用户第二天回来问一句“我上次那个需求改了吗”,模型就完全懵掉。原因就是长期记忆缺位。反过来,如果一上来就把所有历史对话都塞进提示词,短期记忆也会变成 token 黑洞,成本先不说,模型对关键信息的注意力会被稀释。

1.2 1.0 之后,记忆模块的变化

LangChain 1.0 之前,大家习惯去找ConversationBufferMemory、ConversationSummaryMemory这类记忆对象,然后塞进 Chain。1.0 之后,官方路线明显变了:记忆不再是一个单独的“内存组件”,而是被拆成了消息协议、持久化检查点和检索存储三件事。

用大白话说就是:

  • 消息列表负责“对话长什么样”;
  • 检查点负责“对话状态能不能恢复”;
  • 向量库或键值存储负责“跨会话知识放哪里”。

这个变化看着小,实际上影响很大。旧版 Memory 是“帮你把历史存进一个变量”,新版更像是“给你一套组装上下文的积木,让你自己决定从哪些地方取、怎么拼”。刚开始会觉得麻烦,但调参和排障时就会发现,这套方式透明很多,至少你不会对着一个黑盒子查不出问题。

2. 短期记忆:让模型记住当前会话在聊什么

2.1 最朴素的实现:消息列表直接拼

短期记忆的底层数据结构,就是消息列表。LangChain 1.0 里最常用的三条消息类型是HumanMessage、AIMessage和SystemMessage。对话的每一轮,都对应一条用户消息和一条模型回复。

from langchain_core.messages import HumanMessage, AIMessage, SystemMessage messages = [ SystemMessage(content="你是订单助手,回答要简洁准确。"), ] user_input = "帮我查一下昨天下午的订单" messages.append(HumanMessage(content=user_input)) response = llm.invoke(messages) messages.append(AIMessage(content=response.content))

这里最容易犯的错误是:调用模型之后忘了把AIMessage追加回去。如果只追加用户消息,模型永远只能看到最新的用户问题,看不到自己刚才说了什么,多轮对话里就会出现“你刚才不是已经回答过了吗”式的失忆。

从 langchain_core 导入消息类型有一个额外好处:无论底层接的是哪家模型服务,消息结构都统一。你不需要在项目里兼容每家厂商不同的历史格式,LangChain 的抽象层把这些差异吃掉了。

2.2 上下文太长怎么办:滑动窗口加自动摘要

消息列表是会无限增长的。生产环境下,一个活跃会话可能跑几十轮甚至上百轮,如果全量传给模型,要么触发 token 上限,要么把单次请求的延迟和成本拉到不可接受。

我的做法是两层配合:滑动窗口保最近,摘要压缩保过去。

先看滑动窗口,简单粗暴但有效:

MAX_RECENT = 12 def build_recent_context(all_messages): if len(all_messages) > MAX_RECENT: return all_messages[-MAX_RECENT:] return all_messages

只保留最近 12 条消息是我在多数客服场景里的默认值。太小,用户上上文提到的信息会丢失;太大,token 压力又回来了。具体数字要根据业务复杂度调整,如果对话动不动就要引用很多轮之前的细节,可以放大到 20,同时给更大的上下文预算。

滑动窗口会把更早的历史丢掉,所以还需要第二步:把被丢掉的旧消息压缩成摘要,放进系统提示词。

from langchain_core.messages import SystemMessage summarize_prompt = """ 请把下面的对话压缩成 200 字以内的摘要。 必须保留:订单号、约定时间、用户偏好、未完成事项。 对话内容: {conversation} """ def summarize_messages(old_messages): text = "\n".join(msg.content for msg in old_messages) summary = llm.invoke(summarize_prompt.format(conversation=text)) return summary.content

然后把摘要放在SystemMessage里,再接最近的消息:

final_messages = [] if summary: final_messages.append(SystemMessage(content=f"以下是本会话早期对话的摘要:\n{summary}")) final_messages.extend(recent_messages)

这里有一个很关键的细节:摘要的指令里必须写明要保留哪些字段。如果不写,大模型会按照它自己的理解来概括,很容易把订单号、金额这类关键事实丢掉。你以为是在省 token,实际上是在给后续回答埋雷。

2.3 在 LangGraph 里用检查点做会话续传

如果不想自己管理消息列表的持久化,LangGraph 是 LangChain 1.0 生态里更推荐的方案。它把消息列表当作 state 的一部分,通过 checkpointer 自动保存每个会话节点的状态。

from typing import Annotated, TypedDict from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages from langgraph.checkpoint.memory import MemorySaver class AgentState(TypedDict): messages: Annotated[list, add_messages] def call_assistant(state: AgentState): response = llm.invoke(state["messages"]) return {"messages": [response]} builder = StateGraph(AgentState) builder.add_node("assistant", call_assistant) builder.add_edge(START, "assistant") builder.add_edge("assistant", END) memory = MemorySaver() agent = builder.compile(checkpointer=memory) config = {"configurable": {"thread_id": "session-001"}}

add_messages是 LangGraph 提供的一个合并器,会把新产生的消息自动追加到已有消息列表,不会覆盖历史。thread_id是会话隔离的关键,不同用户或不同会话使用不同thread_id,就能把对话状态彻底分开。

需要注意,上面的MemorySaver只是把状态保存在内存里,进程重启后数据就没了。生产环境一定要换成数据库支持的 checkpointer,比如 Postgres 或 Redis 实现。短期记忆不等于不持久化,用户刷新页面之后,短期内重新恢复上下文,靠的也是这套检查点机制。

3. 长期记忆:跨会话记住用户

3.1 先想清楚记什么,再想怎么存

长期记忆最容易做成一锅粥。有人把全部历史聊天记录倒进向量库,美其名曰“长期记忆”,结果检索时召回一堆废话,模型被噪声带偏。

我的经验是:长期记忆只存那些在后续会话中可能再次需要的稳定事实。几类典型的可存内容:

  • 用户偏好:喜欢简答、喜欢用表格、沟通要客气;
  • 实体信息:用户名、城市、部门、项目代号;
  • 业务约束:某个订单不能超过 X 元、某个接口只能走白名单;
  • 决策记录:上次确认过的方案、时间节点、责任人。

至于“用户今天中午吃了什么”“这句话说得情绪不好”这类临时性内容,除非业务明确要求,否则不建议进长期记忆。记忆不是越全越好,而是越准越好。

3.2 抽取结构化记忆

长期记忆不能靠人工一条条写,对话系统需要在合适的时机自动抽取。最常用的办法是让大模型根据对话内容生成结构化片段。

memory_prompt = """ 根据下面这段对话,提取需要长期记住的信息。 只提取稳定的、跨会话有用的事实,不要提取情绪和临时状态。 返回 JSON 数组,每个元素格式如下: {"field": "字段名", "value": "字段值", "source": "原始说法"} 对话: {conversation} """

抽取时机很关键。我的习惯不是每轮对话都抽,那样又慢又贵,还会在中间轮次产生大量重复或半成品记忆。更合理的做法是:在当前会话结束,或者累计对话达到 10 轮时,批量抽一次。生产环境里,可以在用户离开会话后触发一次异步任务,把整个会话浓缩成 2 至 5 条长期记忆。

拿到大模型返回的 JSON 后,不要直接信任格式。务必用输出解析器或者二次校验,常见的坑是模型偶尔会多输出解释文字,或者字段名前后不一致。解析失败时,宁可丢弃这条记忆,也不要把它带着脏格式写进存储。

3.3 写入向量库并支持按用户召回

抽取出来的记忆片段,推荐放两个地方:一个是用字段名做键的键值存储,方便精确更新;另一个是向量库,方便按语义召回。

写入向量库的时候,每条记忆都要带足元数据:

vector_store.add_texts( texts=[snippet["value"]], metadatas=[{ "user_id": user_id, "field": snippet["field"], "updated_at": timestamp, }] )

检索时,查的还是用户当前问题,但结果要按user_id做过滤:

docs = vector_store.similarity_search_with_score( query=user_input, k=5, filter={"user_id": user_id} ) recalled = [] for doc, score in docs: if score > 0.7: recalled.append(doc.page_content)

这里有两个关键参数,一个是k,一个是相似度阈值。k=5表示最多召回 5 条候选;阈值 0.7 是我在多个项目中的起点值,具体要根据向量模型调整。阈值太低会召回不相关的内容,说得难听一点,那是在给大模型喂噪声;阈值太高则可能什么也召不回,长期记忆形同虚设。

召回结果不要直接作为完整句子塞进提示词,最好把field和value重新拼成一段“已知事实”:

用户偏好:回复时使用表格 业务约束:报价单金额不得超过 5000 元

这种格式比把原始句子原封不动放进去更利于模型理解。

3.4 长期记忆的更新与淘汰

长期记忆最大的隐藏问题不是写不进去,而是写进去之后不会更新。用户上个月说“预算控制在 1 万”,这个月改成了“预算放宽到 3 万”,向量库里如果两句话都在,模型可能随机挑一个来用。

我采用的更新策略很简单:

  • 每条记忆带上field,同一用户同一field只保留最新版本;
  • 写入新记忆前,先检索该用户该field的旧片段,删除后再写入;
  • 每隔一段时间做一次全量扫描,清掉超过 N 个月未访问的记忆。

这样看起来多了一步删除操作,但能避免大量记忆冲突。记忆管理不能只做加法,不做减法,否则系统会越来越“精分”。

4. 记忆管理的关键参数与取舍

4.1 常用参数参考表

记忆管理里没有万能参数,但有一套安全起点。下面这个表是我在客服助手、文档问答、知识库机器人三类场景里不断调出来的经验值:

参数参考值影响
短期消息窗口12 至 20 条消息太小丢上下文,太大烧 token
摘要压缩目标200 至 500 token太小丢事实,太大失去压缩意义
摘要触发轮数10 轮左右太频繁成本高,太稀疏摘要区域过大
长期记忆召回条数 k3 至 5 条太少不够用,太多噪声明显
相似度阈值0.6 至 0.8太低误召回,太高漏召回
thread_id按用户或会话唯一防止多用户串话
长期记忆写入频率每会话 1 至 5 条写太多会稀释重点,写太少覆盖不足

这套参数不是拍脑袋定的。短期窗口和模型上下文长度有关,如果模型上下文有 128k,你可以更激进,多留一些历史;如果只有 8k,20 条消息加上每个消息都可能很长,就明显超预算,必须把窗口调小并依赖摘要。

4.2 混合记忆的组装顺序

真实项目里往往不是只用短期或只用长期,而是混合使用。关键是拼接顺序。

system_parts = [base_prompt] if long_term_recalled: system_parts.append("长期记忆:\n" + "\n".join(long_term_recalled)) if session_summary: system_parts.append("本会话早期摘要:\n" + session_summary) messages = [SystemMessage(content="\n\n".join(system_parts))] messages.extend(recent_messages)

我的经验是:长期记忆放最前面,接着是会话早期摘要,最后接最近的消息。原因在于,长期记忆是最高层的稳定事实,应该让模型先建立对用户的整体认知;摘要次之,提供会话内的历史背景;最近消息是当前最新的追问,必须放在最靠近模型输入的位置。

这里有一个很多人踩过的坑:把长期记忆塞进最近的消息列表里。一旦短期窗口截断,长期记忆也跟着被切断,等于没做长期记忆。所以长期记忆与短期历史必须分层存放,不要混在一起。

5. 常见问题与排查技巧实录

5.1 问题速查表

生产环境里,记忆相关的问题通常不是“代码跑不起来”,而是“行为不对”。我把实际排查过的现象整理成了一张速查表:

症状可能原因排查与解法
模型说“不记得刚才的内容”消息列表没把历史传进去,或摘要没生效打印最终发给模型的messages,确认历史消息存在且顺序正确
用户 A 看到用户 B 的聊天记录thread_id相同,或向量库检索没按user_id过滤给每个用户分配独立thread_id,检索时强制filter
对话超过几十轮后 token 超限没有做窗口截断或摘要压缩引入MAX_RECENT,并定期生成摘要
长期记忆召回结果明显无关相似度阈值太低,或写入时没做字段约束调高阈值到 0.75 以上,检查向量库元数据
摘要丢掉了关键订单号摘要指令没要求保留具体字段在摘要 prompt 里用“必须保留:”句式明确字段
模型复用旧规则,不用新规则向量库里旧片段没删除,新旧冲突按field更新,写入新记忆前先删除旧记忆

排查记忆问题,最有效的招数是先看“最终发给模型的 messages”。大多数记忆问题本质上是组装问题,而不是模型问题。只要把最终上下文打印出来,一眼就能发现该在的不在,不该在的反而在。

5.2 真实项目里踩过的三个坑

第一个坑:每轮都做长期记忆抽取。早期项目里我图省事,在每轮对话后都调一次大模型抽取记忆,结果成本翻倍,而且经常把“用户随口一说”的内容当成偏好存进去。后来改成会话结束后异步抽取,效果一样,费省了很多,记忆质量也更高。

第二个坑:把长期记忆和短期历史放在同一个列表里。当时为了省事,直接在messages列表头部插入一堆长期记忆,结果滚动截断的时候,长期记忆先被切掉了。一个看起来像是“模型失忆”的问题,排查到最后居然是列表拼接顺序错了。从那以后我坚持分层:长期记忆放 system,短期摘要放 system,最近对话放 messages 末尾。

第三个坑:写入新记忆前不处理旧记忆。用户改过一次偏好之后,系统里新老两条信息并存。模型有时会用旧信息回答,用户回来质问“我明明改过了,怎么还按旧的来”。后来我加了“先删后写”的逻辑,按用户和字段维度去重,这个问题才彻底消失。

按我个人的习惯,最后还有一个小技巧:在向量库的元数据里给每条记忆加一个last_accessed_at,每次命中时顺手更新。这样你不仅能知道哪些记忆被反复使用,还能定期清理那些写入后一次都没被用过的冷数据。长期记忆不是越积越多越好,而是留下来的每条都要能派上用场。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询