对话记忆类型
💾 在 LangChain 中,Memory 的主要作用是什么?一般和哪些组件配合使用?¶
Memory 在 LangChain 中的角色就像是一个会话的记事本。它负责在多次独立的链调用(或 Agent 执行)之间,持久化地存储和传递关键信息,让无状态的 LLM 能够“记住”之前的对话内容、用户的偏好或是任务进展。
主要作用:
-
维护对话历史:记录用户和 AI 之间的所有交流,让模型能根据上下文来理解新问题(比如指代消解:“他后来怎么样了?”中的“他”是谁)。
-
存储上下文状态:在更复杂的链中,保存中间计算结果、提取的实体、用户的意图等。
-
实现长期交互:让对话不仅限于单轮,而是构建一个连续、连贯的多轮交互体验。
一般配合的组件:
-
LLMChain/ConversationChain:这是 Memory 最经典的搭档。Memory 负责存储对话历史,链在每次生成新回答时,会将历史记录注入 Prompt,从而使回答基于整个对话的上下文。 -
ConversationalRetrievalChain:Memory 在这里除了记录对话,还用于生成独立的检索查询。它会利用对话历史将“它有什么副作用?”改写为“布洛芬有什么副作用?”,从而提高检索精度。 -
Agent:Memory 让 Agent 能够记住多步推理的中间结果、调用了哪些工具、获得了什么信息,从而规划下一步行动。 -
自定义链:开发者可以在任何需要“状态保持”的链中集成 Memory。
工作流程示例:
-
用户提出问题“我想买一辆适合家用的SUV”。
-
链执行,生成回答。这次交互的
HumanMessage和AIMessage被追加到 Memory 中。 -
用户接着问“它的油耗高吗?”。
-
链将 Memory 中的历史消息与新问题一同加载到 Prompt,LLM 能理解“它”指的是前面提到的SUV,从而给出正确回答。
📄 ConversationBufferMemory 是如何存储对话历史的?内部数据结构是怎样的?¶
ConversationBufferMemory 是 LangChain 中最简单、最直接的 Memory 实现。它的工作方式就是将所有对话原封不动地存入一个缓冲区。
内部数据结构:
它内部主要通过一个 ChatMessageHistory 对象来存储消息列表。这个列表里的每个元素都是一个 LangChain 的 Message 对象,常见的有:
-
HumanMessage:用户说的话。 -
AIMessage:AI 的回复。 -
SystemMessage:系统提示词。
从源码角度看,它是一个包含多个 Message 的列表,大致结构如下:
[
HumanMessage(content="你好"),
AIMessage(content="你好!有什么我可以帮你的吗?"),
HumanMessage(content="介绍一下人工智能"),
AIMessage(content="人工智能是..."),
...
]
当你调用 memory.chat_memory.messages 就能看到这个列表。
它如何被用于 Prompt:
当你使用 ConversationChain 时,Memory 会将这个列表转换为一个适合 LLM 的文本字符串,通常是下面这种对话格式:
这个字符串会被插入到 Prompt 模板的 {history} 占位符中,LLM 就在此基础上进行续写。
配置示例:
from langchain.memory import ConversationBufferMemory
from langchain.chains import ConversationChain
from langchain.llms import OpenAI
memory = ConversationBufferMemory()
conversation = ConversationChain(
llm=OpenAI(),
memory=memory,
verbose=True
)
conversation.predict(input="你好")
conversation.predict(input="介绍一下人工智能")
# 此时 memory.buffer 或 memory.chat_memory.messages 已经包含了前两轮的对话
🧨 ConversationBufferMemory 在长对话中有什么问题?如何解决?¶
ConversationBufferMemory 最致命的缺陷是:它会无限地存储对话历史。
问题表现:
-
Token 爆炸,成本失控:每次请求都要把整个历史发送给 LLM。对话 100 轮后,Prompt 的 token 数可能达到数万,不仅 API 费用飙升,而且很容易超出模型的上下文窗口限制(如 4096),导致请求失败。
-
延迟剧增:更大的 Prompt 意味着更长的处理时间,用户体验变差。
-
模型迷失:过长的历史可能让模型注意力分散,难以聚焦于最近的对话,甚至“遗忘”早期的关键信息。
解决方案:
根据场景不同,有以下几种 Memory 替代方案:
-
基于窗口的 Memory (
ConversationBufferWindowMemory):只保留最近 K 轮对话,旧对话直接丢弃。适合实时对话,对近期上下文敏感的场景。 -
基于摘要的 Memory (
ConversationSummaryMemory):使用 LLM 对历史对话进行摘要,只保留摘要。适合需要长期记忆但不需要完整细节的场景。 -
混合策略 (
ConversationSummaryBufferMemory):结合窗口和摘要。保留最近几轮完整对话,同时将更早的对话压缩为摘要。这是最推荐的生产级方案,兼顾了近期细节和长期上下文。 -
基于 Token 的 Memory (
ConversationTokenBufferMemory):根据 token 数量限制,而不是轮次,更精确地控制上下文长度。
选择哪种方案取决于你对“细节”和“总长度”的权衡。
🪟 ConversationBufferWindowMemory 只保留最近 K 轮对话,这个 K 如何设置?过小会怎样?¶
ConversationBufferWindowMemory 通过一个滑动窗口来限制对话历史的长度。它只保留最近的 K 轮(每一轮包含一个 Human 输入和一个 AI 回复)完整对话记录。
如何设置 K?
K 的设定是一个平衡艺术,需要根据你的LLM 上下文窗口大小、每轮对话的平均长度以及任务对上下文的依赖程度来决定。
-
经验法则:通常取
K=3到K=10。如果你的应用是闲聊或简单问答,K=3-5 足够;如果是辅导、代码调试等需要长程理解的任务,则需要更大的 K。 -
精确控制:更科学的做法是基于 Token 来限制。先估算每轮对话消耗的平均 Token 数
T_avg,然后用(模型最大上下文 - 预期回复Token数) / T_avg来估算K。但这样比较麻烦,后来就直接有ConversationTokenBufferMemory了。 -
动态调整:你可以根据用户的行为或对话状态动态调整 K,比如在用户提出“回顾一下之前”时临时增大 K。
过小的 K 会怎样?
-
模型“失忆”:当 K=1 时,模型只有金鱼般的7秒记忆。如果你进行了如下对话: 用户:我想去北京旅游。 AI:北京的故宫和长城非常值得去。 用户:那里有什么特色小吃吗? 如果 K 太小,模型可能忘记了“北京”,就会回答“请问您指的是哪里?”,导致对话无法进行。
-
无法进行多轮指代:任何超过窗口的指代对象都会丢失,导致理解错误。
-
任务失败:需要多轮收集信息的任务(如保险理赔问答)会中断,因为之前的收集过程被遗忘。
配置示例:
from langchain.memory import ConversationBufferWindowMemory
memory = ConversationBufferWindowMemory(k=3)
# 这将只保留最近3轮对话。内存使用量恒定,但早期对话会被遗忘。
记忆的权衡是“信息的保留”与“资源的消耗”之间的取舍。K 就像是相机的取景框,框越小,能看到的画面就越少。
📝 写出 ConversationBufferWindowMemory 的配置示例,并说明它如何影响 prompt。¶
from langchain.memory import ConversationBufferWindowMemory
from langchain.chains import ConversationChain
from langchain.llms import OpenAI
# 创建只保留最近2轮对话的窗口记忆
memory = ConversationBufferWindowMemory(k=2)
# 创建对话链
conversation = ConversationChain(
llm=OpenAI(temperature=0),
memory=memory,
verbose=True
)
# 模拟多轮对话
conversation.predict(input="你好,我是小明") # 轮次1
conversation.predict(input="帮我查一下天气") # 轮次2
conversation.predict(input="刚才我说我叫什么名字?") # 轮次3
如何影响 Prompt:
在第三轮时,Memory 只会包含最近两轮(即第2和第3轮)的完整对话。Prompt 中 {history} 部分会被填充为:
由于第一轮对话(包含“我是小明”的信息)已被移出窗口,LLM 完全无法得知用户的名字,因此无法正确回答。其影响就是:早期上下文丢失,导致记忆缺失。
这就是窗口记忆的典型行为和局限性:严格控制资源消耗,但牺牲长期记忆。
⚠️ 窗口记忆的缺点是会丢失早期对话的上下文,这在什么场景下会出问题?¶
窗口记忆(ConversationBufferWindowMemory)通过丢弃旧对话来限制长度,这在许多需要长期记忆或全局理解的场景中会造成严重问题。
容易出问题的场景:
- 多轮信息收集与确认 例如,旅行社客服为用户预订机票。对话可能涉及:
-
目的地、日期、乘机人姓名、证件号、座位偏好等。 如果窗口大小不足以覆盖整个信息收集过程,AI 就会“忘记”之前确认好的关键信息(比如证件号),导致需要用户重复提供,体验极差,甚至出错。
-
用户指令的“锚定” 用户可能在对话一开始就设定了一个全局指令,例如:“用简单的中文跟我说话,避免专业术语”。如果这个指令在窗口之外,AI 就会恢复默认风格,开始大谈专业术语。
-
深度咨询与教育辅导 在一场数学辅导中,AI 需要记住学生暴露出哪些知识点薄弱(如分式运算),以便后续针对性出题。这些薄弱点可能在整个辅导过程中被多次提及,如果被窗口遗忘,AI 就失去了“因材施教”的能力。
-
角色扮演或故事创作 在合作编故事时,用户和 AI 共同构建了复杂的人物关系、世界背景、情节伏笔。窗口记忆一旦遗忘早期设定,后续的情节就会出现逻辑漏洞或人物崩坏。
-
复杂问题的多步推理 一些问题的解决可能需要跨越较长的对话轮次,比如分步调试代码。如果遗忘早期调试步骤的结果,AI 就无法准确判断错误根源。
在这些场景中,摘要记忆(ConversationSummaryMemory)或其与窗口记忆的结合体(ConversationSummaryBufferMemory)是更好的选择,因为它们能以压缩形式保留全局脉络。
🧠 ConversationSummaryMemory 是如何利用 LLM 来压缩历史的?它的 prompt 可以自定义吗?¶
ConversationSummaryMemory 是解决长对话记忆问题的一个优雅方案。它不会像窗口记忆那样粗暴地丢弃历史,而是请 LLM 出场,将冗长的对话历史提炼成一段简洁的摘要。每次新的对话产生,它都会更新这段摘要。
工作原理:
-
初始化:开始时摘要为空。
-
对话进行:每当有新的对话轮次产生,这些消息不会直接全部存入历史,而是被交给一个内部的“摘要链”。
-
LLM 摘要:摘要链使用一个预设的 Prompt,将已有的旧摘要 + 新的对话消息 一起发给 LLM,LLM 返回一段更新后的摘要文本。
-
存储摘要:Memory 存储的是这段不断更新的摘要文本,而不是完整的对话列表。当需要加载 Memory 时,它会直接将这段摘要作为上下文放入 Prompt。
优点:
-
上下文长度可控:无论对话多长,最终保存在 Prompt 中的只是那段摘要,其长度相对稳定。
-
保留长期脉络:摘要能够抓住对话的关键信息、意图和结论,让模型拥有全局视野。
默认 Prompt 与自定义:
是的,Prompt 完全支持自定义。它的默认 Prompt 类似:
Progressively summarize the lines of conversation provided, adding onto the previous summary returning a new summary.
EXAMPLE
Current summary:
The human asks what the AI thinks of artificial intelligence. The AI thinks artificial intelligence is a force for good.
New lines of conversation:
Human: Why do you think artificial intelligence is a force for good?
AI: Because artificial intelligence will help humans reach their full potential.
New summary:
The human asks what the AI thinks of artificial intelligence. The AI thinks artificial intelligence is a force for good because it will help humans reach their full potential.
END OF EXAMPLE
Current summary:
{summary}
New lines of conversation:
{new_lines}
New summary:
你可以通过 prompt 参数传入自定义的 PromptTemplate,例如,如果你想用中文摘要,或者要求摘要突出特定信息(如用户偏好)。
from langchain.memory import ConversationSummaryMemory
from langchain.llms import OpenAI
# 自定义摘要 Prompt
custom_prompt = PromptTemplate(
input_variables=["summary", "new_lines"],
template="""
请将以下新的对话内容,整合到已有的对话摘要中,生成一个全新的、更全面的中文摘要。
已有摘要:
{summary}
新对话内容:
{new_lines}
新摘要:
"""
)
memory = ConversationSummaryMemory(
llm=OpenAI(temperature=0),
prompt=custom_prompt
)
代价:
-
经济成本:每次对话更新摘要都要调用一次 LLM(额外的 API 费用)。
-
延迟增加:额外的 LLM 调用会增加处理时间。
-
信息损失:摘要过程是有损压缩,一些细节可能丢失。因此不适合需要精确复现历史原话的场景。
⚖️ 比较摘要记忆和窗口记忆:各自的适用场景和开销。¶
| 特性 | ConversationSummaryMemory | ConversationBufferWindowMemory |
|---|---|---|
| 记忆内容 | 对话的压缩摘要 | 最近K轮的完整对话原文 |
| 长期记忆 | 强,保留全局脉络 | 弱,超出窗口即遗忘 |
| 短期细节 | 弱,摘要会丢失细节 | 强,窗口内对话完整保留 |
| Prompt 体积 | 小,且相对恒定 | 与 K 成正比,可通过 K 控制 |
| 额外开销 | 高,每次更新都需调用LLM生成摘要 | 低,纯本地操作 |
| 响应延迟 | 较高,因为LLM摘要过程耗时 | 极低 |
| 信息精确度 | 可能有损,LLM可能出错 | 完全无损(窗口内) |
适用场景:
- 摘要记忆:
- 用户意图随时间变化的长期陪伴(如心理辅导、私人教练)。
- 全局指令需要被记住的场合。
-
预算充足,且对延迟容忍度较高的高级对话应用。
-
窗口记忆:
- 实时对话应用,对延迟极度敏感。
- 任务对最近上下文依赖极高,且对话轮次通常较短(如客服FAQ)。
- 成本敏感的项目。
最佳实践:很多时候,最佳的方案是两者的结合——ConversationSummaryBufferMemory,下面会详细解析。
🧩 ConversationSummaryBufferMemory 是如何结合窗口和摘要的?它内部的触发机制是什么?¶
ConversationSummaryBufferMemory 是目前 LangChain 中最强大、最适合生产环境的 Memory 之一。它吸取了两家之长:用窗口保留最近细节,用摘要保留远期脉络。它的工作方式是这样的:
-
内部维持一个完整的对话列表,但与
BufferMemory不同,它不会把所有内容都塞进 Prompt。 -
指定一个触发阈值,通常是
max_token_limit(最大 Token 数)。 -
加载历史时的处理逻辑:
- 它首先从对话列表中取出最近的几轮对话(即窗口部分)。
- 检查这些对话 + 剩余部分的总 Token 数是否超过
max_token_limit。 - 如果超过了,它就会将窗口中更靠前的旧对话不断移出窗口,并把这些被移出的对话送进 LLM 摘要器,逐步生成或更新一个摘要(
summary)。 - 最终,加载到 Prompt 里的历史内容由两部分拼接而成:一个全局摘要 + 最近几轮的完整对话记录。
触发机制:
这种机制的触发点不是“时间”或“轮次”,而是 max_token_limit 超标。每当 Memory 被加载时,它会自我检查,并“挤出”超出限制的早期完整对话,将它们转为摘要。这个过程是动态、自动的。你可以把它想象成一个内置压缩机的缓冲区:缓冲区快满时,不是扔掉旧数据,而是把它们压缩并存档。
配置示例:
from langchain.memory import ConversationSummaryBufferMemory
from langchain.llms import OpenAI
memory = ConversationSummaryBufferMemory(
llm=OpenAI(temperature=0),
max_token_limit=800, # 当加载的历史超过800 tokens时,触发摘要
)
# 它会自动维护一个摘要(存储在 memory.moving_summary_buffer 或类似属性中)
# 并确保最终放入 prompt 的历史不超过 max_token_limit
价值:它解决了纯粹窗口记忆“失忆”和纯粹摘要记忆“失细节”的矛盾,通过一个动态的平衡机制,在有限的上下文窗口内,最大化了信息密度。这正是构建“聪明”对话机器人的关键。
📏 在 SummaryBufferMemory 中,如何设定一个 token 限制,当历史超过该限制时自动触发摘要?¶
在 ConversationSummaryBufferMemory 中,这通过 max_token_limit 参数实现。
设定方法:
memory = ConversationSummaryBufferMemory(
llm=OpenAI(temperature=0),
max_token_limit=600, # 核心参数:历史token数上限
)
工作机制详解:
-
该参数并非绝对保证输入 Prompt 的 token 数恰好等于 600,它是一个触发摘要的阈值。
-
当 Memory 被加载并转换为 Prompt 字符串时,LangChain 会实时计算当前完整对话历史(包括摘要)的总 token 数。
-
如果总 token 数超过
max_token_limit,Memory 会采取行动: - 从对话历史的起始端开始,逐条移除完整对话消息。
- 将这些被移除的消息送入 LLM,与已有的摘要结合,生成一个新的、更长的摘要。
-
这个过程不断重复,直到(摘要 + 剩下的最近消息)的总 token 数低于
max_token_limit为止。 -
因此,你设置的
max_token_limit就像一个“水位线”,当记忆之水超过这条线,摘要的“水泵”就开始工作,将水(旧对话)抽走并压缩成摘要,维持水位。
选择限制值:
-
你需要根据模型的最大上下文长度和你期望的回复长度来决定。
-
例如,模型上下文窗口是 4096,你希望为生成回复预留 1000 token,其他 Prompt 固定内容占用 500 token,那么可以设定
max_token_limit = 4096 - 1000 - 500 = 2596。这样就能充分利用窗口,又不至于溢出。
重要提示:token 计数依赖于具体的 LLM tokenizer。LangChain 默认使用 gpt-2 的 tokenizer 进行近似计算,对于其他模型,你可以自定义 tokenizer 参数传入 Memory,以实现更精准的控制。
📊 ConversationTokenBufferMemory 直接根据 token 数量来限制,与 SummaryBuffer 有何不同?¶
两者的目标都是控制上下文长度,但控制方式和信息保留策略截然不同。
| 特性 | ConversationTokenBufferMemory | ConversationSummaryBufferMemory |
|---|---|---|
| 限制方式 | 严格根据 token 数量 裁剪历史,超出限制的旧消息直接丢弃。 | 以 token 数量为阈值,超出时触发 LLM 摘要,旧信息被压缩保留。 |
| 信息保留 | 无保留,旧消息永久丢失。 | 有保留,关键信息以摘要形式存活。 |
| 成本开销 | 低,无额外 LLM 调用。 | 高,每次触发摘要都需调用 LLM。 |
| 适用场景 | 对近期上下文极度敏感,且不关心早期对话的场合(如简单 FAQ)。 | 需要长期上下文,且预算允许的复杂对话应用。 |
| 行为比喻 | 像一个只有固定容量的短时记忆槽,旧的记忆被直接覆盖。 | 像一个配有智能笔记本的对话者,会把旧对话要点记下来。 |
具体区别示例:
假设你设定两者均为 max_token_limit=200。
-
ConversationTokenBufferMemory:当对话进行到 200 token 时,早期的消息(比如用户问候、介绍)会被直接删除,不留任何痕迹。如果后面用户问“我刚才说我叫什么名字?”,模型只能回答“不知道”。
-
ConversationSummaryBufferMemory:当达到阈值时,它会将早期对话总结为“用户叫小明,想了解天气预报”,并将其存入
summary。当用户问名字时,模型虽然不记得原话,但能从摘要中获取关键信息,正确回答。
选择建议:
如果你需要一个完全无状态,严格受控的短时记忆,用 TokenBuffer。如果希望拥有持久但有损的长时记忆,且不在乎额外的 LLM 开销,用 SummaryBuffer。对于大多数产品,SummaryBuffer 是更好的选择,因为它更接近人类的记忆模式——我们不会完全忘记早些时候的对话,只是无法逐字复述。
🧭 你如何选择记忆类型?有没有决策树或经验法则?¶
选择记忆类型的关键在于平衡长期上下文需求、细节保留程度、延迟与成本。以下是经验性的决策路径:
- 第一步:是否需要长期记忆?
- 不需要:仅在单轮交互中需要上下文(例如独立的问答)。可选择简单的
ConversationBufferMemory或完全不使用记忆。 -
需要:进入下一步。
-
第二步:对近期细节和远期脉络的要求如何?
- 只关心最近几轮对话,且轮次固定且较少(例如客服 FAQ 场景)→ 使用
ConversationBufferWindowMemory,通过设置较小的k来严格限制历史长度,零额外成本,响应快。 -
需要长期记忆但允许有损压缩,且能接受额外 LLM 开销 → 进入下一步。
-
第三步:是否需要精确的近期原话?
- 是:既需要精确的近期上下文(如信息收集、指令遵循),又需要保留全局脉络 → 使用
ConversationSummaryBufferMemory。通过max_token_limit控制总长度,自动将早期对话转为摘要,保留最近几条完整消息。 -
否:只关心对话的宏观脉络和要点,不要求原话 → 使用
ConversationSummaryMemory。直接将整个历史压缩为一段摘要,体积恒定,但细节丢失较多。 -
第四步:是否需要精确控制 token 数量,且不希望有任何额外 LLM 开销?
- 是:使用
ConversationTokenBufferMemory。严格按照 token 限制裁剪历史,超出部分直接丢弃。这适合完全不依赖长期上下文、但对 token 控制有硬性要求的场景(如预算极度受限)。 - 否:回到 SummaryBuffer。
经验法则:
-
极简场景:
ConversationBufferWindowMemory(k=3) -
生产级通用方案:
ConversationSummaryBufferMemory,配合合理的max_token_limit。 -
如果用户在对话中经常跳回早期话题:
ConversationSummaryMemory或 SummaryBuffer。 -
多用户、需要持久化:所有 Memory 都可搭配
RedisChatMessageHistory等后端,实现持久化和隔离。
图表决策树:
需要长期记忆?
├── 否 → ConversationBufferMemory 或不用记忆
└── 是
├── 只需最近几轮完整对话?
│ └── ConversationBufferWindowMemory
└── 需要全局脉络?
├── 需要精确近期原话?
│ └── ConversationSummaryBufferMemory
└── 不需要精确原话?
└── ConversationSummaryMemory
🧠 记忆模块在 Agent 中是如何使用的?Agent 的 scratchpad 和 memory 是什么关系?¶
在 LangChain 的 Agent 中,Memory 和 scratchpad 是两个不同但互补的概念,它们共同构成了 Agent 的上下文窗口。
-
Memory(记忆):跨多次Agent调用(即多次
agent_executor.run或整个会话)持久化的长期状态。它通常存储用户与Agent之间的对话历史、之前任务的结果等。Agent 的 Memory 与 Chain 的 Memory 功能类似,负责在会话级别维持状态。 -
Scratchpad(草稿本/中间记录):在一次Agent调用内部用于记录Agent思考和行动的临时日志。例如,Agent 在解决一个问题时,可能会先输出
Thought: ...,然后调用一个工具,再观察结果。这些中间步骤(Thought, Action, Observation)会被自动记录在AgentScratchpad中。它是瞬时的,只在当前这次run调用中有效,一旦任务完成(输出 Final Answer),scratchpad 就会被清空。它的作用是让 LLM 在同一个推理循环中能够查看之前的思考步骤,避免重复执行或遗忘已做的事情。
关系:
-
Memory 是会话级的,用于多轮对话;scratchpad 是任务级的,用于单次复杂推理。
-
在实现上,Agent 通常会将 Memory 的历史 + Scratchpad 的中间步骤 一同组装进 Prompt,送给 LLM。这样 LLM 既知道历史对话背景(Memory),也了解当前任务的进展(Scratchpad)。
示例:
用户:“帮我查一下今天天气,然后推荐合适的衣服。”
-
Agent 启动一次调用。此时 Memory 加载之前的对话历史(可能没有)。
-
Agent 开始推理,第一步 Thought:“我需要先查询天气。”,然后调用天气API,Observation 返回“25度,晴”。这些
Thought/Action/Observation会被追加到 Scratchpad 中。 -
第二步 Thought:“根据天气,推荐T恤和短裤。”,然后输出最终答案。此时 Scratchpad 中的所有中间步骤完成了使命,但不会进入 Memory。最终的用户请求和Agent的最终回答会被保存到 Memory,供下一轮对话使用。
如何配置 Memory:
from langchain.memory import ConversationBufferMemory
from langchain.agents import create_openai_functions_agent, AgentExecutor
memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)
agent = create_openai_functions_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, memory=memory)
🧰 当 Agent 需要记住多轮工具调用的中间结果时,应该用哪种记忆?¶
当 Agent 在一个复杂任务中需要多次调用工具,并且这些工具的中间结果需要在后续的工具调用或最终答案中被引用时,需要一种能够完整保留这些工具调用历史的记忆。
推荐方案:
-
ConversationBufferMemory或ConversationSummaryBufferMemory与 Agent 结合使用。 -
因为 Agent 的工具调用历史(包含工具名称、输入参数、输出结果)会被自动记录在 Agent 的
chat_history中(以FunctionMessage等形式),而 Memory 正是负责存储这个chat_history的。只要你使用了 Memory,这些中间结果就会被自动保留。
如果任务极长,工具调用链非常复杂,且你担心 token 超限,可以使用 ConversationSummaryBufferMemory。它会在早期工具调用历史过长时,将它们压缩为摘要,但保留关键信息(如“第一次调用天气API返回了上海25度”),并保留最近的几次完整工具调用。这样既保持了上下文,又控制了长度。
特别注意:ConversationBufferWindowMemory 用窗口裁剪历史时,如果窗口太小,可能裁剪掉早期的工具调用结果,导致后续推理断裂。因此对于需要记住多轮工具调用的 Agent,建议不设置过小的窗口,或直接使用 SummaryBuffer。
代码示例:
memory = ConversationSummaryBufferMemory(
llm=ChatOpenAI(temperature=0),
max_token_limit=2000,
memory_key="chat_history",
return_messages=True
)
agent_executor = AgentExecutor(agent=agent, tools=tools, memory=memory)
💾 在 LangChain 中,如何实现一个同时保留“短期记忆”和“长期记忆”的系统?¶
实现“短期记忆”(精确的近期上下文)和“长期记忆”(压缩的远期全局信息)正是 ConversationSummaryBufferMemory 的设计初衷,但在更复杂的应用中,你可能需要将结构化知识(如用户偏好、提取的实体)作为长期记忆存入向量数据库,而对话历史作为短期记忆。
综合方案:
-
短期记忆:使用
ConversationSummaryBufferMemory,维护最近几轮的完整对话。它已经内建了“近期完整 + 远期摘要”的机制,本身就是一种短期+长期记忆的融合。 -
长期记忆(外部知识库):使用 向量数据库(VectorStore) 存储从对话中提取的、可以长期复用的信息。例如,用户的姓名、偏好、历史订单等。在每次对话时,除了加载 Memory 中的历史,还可以根据当前查询从向量库中检索相关的长期记忆,并将其注入 Prompt。
实现步骤:
-
设置短期记忆:
ConversationSummaryBufferMemory。 -
创建长期记忆管理器:例如定义一个函数
get_long_term_memory(user_id, query),它从向量库中检索与该用户相关的长期记忆。 -
自定义链:在调用 LLM 之前,先调用检索函数,获取长期记忆文本;然后将短期记忆历史 + 长期记忆文本 + 用户新问题拼接成完整 Prompt。
-
更新长期记忆:在对话结束后,使用 LLM 从最新的对话中抽取值得长期保留的信息(如“用户是素食主义者”),并将其存入向量库(附带
user_id等元数据)。
LangChain 的集成:
-
有一个
VectorStoreRetrieverMemory类,可以将向量库直接作为 Memory 使用。它会在每次交互时,从向量库中检索与当前输入相关的记忆条目,并将它们注入 Prompt。 -
你可以将它与
ConversationBufferMemory结合使用,前者提供长期记忆,后者提供短期上下文。
示例:
from langchain.memory import VectorStoreRetrieverMemory, ConversationBufferWindowMemory
from langchain.vectorstores import Chroma
# 长期记忆(向量检索)
retriever = Chroma(...).as_retriever()
long_term_memory = VectorStoreRetrieverMemory(retriever=retriever, memory_key="long_term")
# 短期记忆(窗口)
short_term_memory = ConversationBufferWindowMemory(k=5, memory_key="chat_history")
# 在链中同时使用两种记忆,Prompt 模板需包含 {long_term} 和 {chat_history}
💡 长期记忆通常会持久化到向量数据库,LangChain 有没有现成的集成方案?¶
有。LangChain 专门提供了 VectorStoreRetrieverMemory,允许将向量数据库作为长期记忆的存储后端。
实现原理:
-
它内部持有一个
VectorStoreRetriever。 -
当需要加载记忆时,它使用当前用户输入作为查询,从向量库中检索最相关的记忆条目(以
Document形式),然后将这些文档的内容拼接成上下文,注入 Prompt 的指定位置。 -
当需要保存新的记忆时,它调用
memory.save_context(inputs, outputs),将新的交互转换为Document并存入向量库。
关键特性:
-
无需手动定义记忆条目,
save_context会自动将输入和输出拼接为一段文本,存入向量库。 -
检索是语义相关的,因此即使用户没有精确匹配关键词,也能获取相关的长期记忆。
-
支持任何 LangChain 兼容的 VectorStore(Chroma, FAISS, Pinecone 等)。
示例代码:
from langchain.memory import VectorStoreRetrieverMemory
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings
# 初始化向量库和检索器
vectorstore = Chroma(embedding_function=OpenAIEmbeddings())
retriever = vectorstore.as_retriever(search_kwargs=dict(k=5))
# 创建长期记忆
memory = VectorStoreRetrieverMemory(
retriever=retriever,
memory_key="long_term_memory", # Prompt 中的占位符名称
input_key="input", # 用于检索的键
)
# 在对话链中使用
conversation = ConversationChain(
llm=llm,
memory=memory,
prompt=PromptTemplate.from_template(
"以下是与你相关的长期记忆:\n{long_term_memory}\n\n当前对话:\n{input}"
)
)
第一次对话时,长期记忆为空。随着对话进行,memory.save_context 会自动将对话存入向量库,形成你的“长期记忆库”。之后每次对话,它都会检索出相关的历史信息。
适用场景:用户画像存储、偏好记忆、跨会话的个性化交互。
🛠️ 你可以自定义一个 Memory 类吗?需要继承什么,实现哪些方法?¶
完全可以。自定义 Memory 只需要继承 BaseMemory 或 BaseChatMemory,并实现几个关键方法。
需要实现的核心方法:
-
load_memory_variables(self, inputs: Dict) -> Dict:这是最重要的方法。根据传入的inputs(通常包含用户输入),返回一个包含记忆变量的字典。例如返回{"history": "对话历史文本"},这些变量会被注入到 Prompt 中。 -
save_context(self, inputs: Dict, outputs: Dict):在每次链调用后,保存新的上下文。inputs是链的输入,outputs是链的输出。你需要在此方法中更新你的记忆数据。 -
clear(self):清空记忆。
如果你需要与聊天消息格式更好集成,可以继承 BaseChatMemory,它内部已经帮你管理了 chat_memory 属性(一个 BaseChatMessageHistory 对象)。你只需要实现 buffer 的组装逻辑即可。
示例:一个只记住用户名字的记忆类
from langchain.memory import BaseMemory
from typing import Any, Dict
class RememberNameMemory(BaseMemory):
user_name: str = ""
@property
def memory_variables(self):
return ["user_name"]
def load_memory_variables(self, inputs: Dict[str, Any]) -> Dict[str, str]:
return {"user_name": self.user_name}
def save_context(self, inputs: Dict[str, Any], outputs: Dict[str, str]):
# 假设有一个函数可以提取名字
if "my name is" in inputs["input"]:
# 简单提取
name = inputs["input"].split("my name is")[-1].strip()
self.user_name = name
def clear(self):
self.user_name = ""
使用这个自定义记忆,每次链调用时,{user_name} 会自动填充进 Prompt。
👥 在多用户聊天应用中,你如何隔离不同用户的记忆?用什么键来区分?¶
隔离不同用户的记忆是多租户聊天应用的基础需求。LangChain 的 Memory 可以通过动态注入特定于用户的 session_id 或 user_id 来实现,这通常依赖于底层的 ChatMessageHistory。
方案:
-
使用支持
session_id的历史存储后端,如RedisChatMessageHistory、PostgresChatMessageHistory或DynamoDBChatMessageHistory。 -
在创建 Memory 时,为每个用户(或每个会话)生成一个唯一的
session_id,并传递给ChatMessageHistory。这样,每个用户的对话历史在数据库中是物理隔离的(通过不同的键)。 -
在 Web 应用(如 FastAPI)中,可以从请求的
user_id或session_token中获取标识符,然后动态构建 Memory。
代码示例(FastAPI + Redis):
from langchain.memory import ConversationBufferMemory
from langchain.memory.chat_message_histories import RedisChatMessageHistory
@app.post("/chat")
async def chat(user_id: str, message: str):
# 根据用户 ID 创建唯一的历史存储键
session_id = f"user:{user_id}"
history = RedisChatMessageHistory(session_id=session_id, url="redis://localhost:6379")
# 创建记忆,绑定到此用户的历史
memory = ConversationBufferMemory(
chat_memory=history,
memory_key="chat_history",
return_messages=True
)
# 构建链并运行...
chain = ConversationChain(llm=llm, memory=memory)
response = chain.predict(input=message)
return response
由于 session_id 是基于 user_id 的,不同用户的对话历史被严格隔离在 Redis 的不同键下。
隔离键的选择:
-
user_id:按用户隔离。同一用户的所有会话共享记忆。 -
session_id:按会话隔离。每次登录/浏览器标签页生成一个唯一 ID,会话结束记忆清除。 -
user_id + session_id组合:最灵活。可以按用户+会话维度存储。
⏳ 使用 RedisChatMessageHistory 持久化记忆时,你是如何设置 TTL 的?¶
RedisChatMessageHistory 内部使用 Redis 的 List 数据结构存储消息。Redis 本身不支持为 List 中的单个元素设置 TTL,但你可以为整个键设置 TTL。这意味着当键过期时,该用户/会话的整个对话历史都会被自动清除,非常适合需要定期清理的聊天应用。
设置方法:
在初始化 RedisChatMessageHistory 时,直接传入 ttl 参数(单位:秒)。
history = RedisChatMessageHistory(
session_id="user_123",
url="redis://localhost:6379",
ttl=3600 # 对话历史在1小时后自动删除
)
工作原理:
LangChain 在每次向 Redis 追加消息时,会检查 ttl 参数,并调用 Redis 的 EXPIRE 命令刷新该键的过期时间。这意味着每次有新的对话,过期时间会重置,类似于“空闲超时”。只要用户持续对话,历史就会一直保留;如果用户停止对话超过 TTL 时间,历史被清除。
注意事项:
-
如果你需要更细粒度的 TTL(例如每条消息独立的生命周期),原生
RedisChatMessageHistory不支持,需要自定义BaseChatMessageHistory子类,使用 Redis 的 Sorted Set 或其他结构。 -
TTL 功能在
langchain-community包的特定版本中才得到良好支持,确保使用较新版本。
🔒 如果记忆数据非常敏感(如医疗记录),你会在存储前做加密吗?LangChain 支持吗?¶
必须加密。医疗记录属于高度敏感的个人数据,在存储和传输过程中必须受到保护。
LangChain 原生并不提供内存加密功能,但你可以通过以下方式实现:
-
应用层加密 在数据进入 Memory 之前,在你的应用逻辑中对敏感字段进行加密。你可以使用 Python 的
cryptography库,对对话文本进行 AES 加密,然后将密文保存到数据库。在加载记忆时,再解密。这需要在自定义的ChatMessageHistory中实现。 -
加密整个数据库
更简单且更安全的做法是,确保你的存储后端(如 Redis、PostgreSQL)开启了透明数据加密(TDE) 或磁盘加密。这样即使数据被物理窃取,也无法读取。但这不防止应用层泄露。
- 使用支持字段加密的数据库
如果使用 MongoDB 或 PostgreSQL,可以利用它们内置的字段级加密(FLE),对特定列(如消息内容)进行自动加密。
- 结合 LangChain 自定义历史记录
你可以继承
BaseChatMessageHistory,在add_message和messages方法中加入加密/解密逻辑。例如:
from langchain.schema import BaseChatMessageHistory, BaseMessage
from cryptography.fernet import Fernet
import json
class EncryptedRedisHistory(BaseChatMessageHistory):
def __init__(self, session_id, redis_client, cipher):
self.session_id = session_id
self.redis = redis_client
self.cipher = cipher
def add_message(self, message: BaseMessage):
data = message.json()
encrypted = self.cipher.encrypt(data.encode())
self.redis.rpush(self.session_id, encrypted)
@property
def messages(self):
encrypted_list = self.redis.lrange(self.session_id, 0, -1)
return [BaseMessage.parse_raw(self.cipher.decrypt(item)) for item in encrypted_list]
结论:LangChain 信任开发者自行处理加密。鉴于医疗数据的敏感性,应在存储前进行字段级加密,并确保密钥管理安全。
📤 当你需要将记忆数据导出用于分析时,LangChain 的消息格式是什么样的?¶
LangChain 使用一套标准化的消息格式来表示对话历史,即 BaseMessage 的子类:
-
HumanMessage:用户消息。 -
AIMessage:AI 回复。 -
SystemMessage:系统指令。 -
FunctionMessage:工具/函数调用结果。 -
ToolMessage:工具执行结果。
这些消息对象都有 content 属性(文本内容)和 additional_kwargs(额外元数据)。当 Memory 的 return_messages=True 时,它返回的是一个消息列表。
导出为可分析格式:
最通用的方式是导出为 JSON。LangChain 的消息对象内置了 .json() 方法和 .dict() 方法,可以直接序列化。
示例导出代码:
import json
from langchain.memory import ConversationBufferMemory
# 假设这是你的 memory
memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)
# ... 进行一些对话 ...
# 导出所有消息为 JSON 字符串
messages = memory.chat_memory.messages
export_data = [msg.dict() for msg in messages]
with open("chat_history.json", "w") as f:
json.dump(export_data, f, indent=2, ensure_ascii=False)
导出的 JSON 结构示例:
[
{
"type": "human",
"content": "你好,我叫小明",
"additional_kwargs": {}
},
{
"type": "ai",
"content": "你好小明,有什么可以帮助你的?",
"additional_kwargs": {}
}
]
这种结构非常清晰,便于后续用 Pandas 或其他工具进行分析。
🌊 在流式输出场景下,记忆是什么时候更新的?是在整轮对话结束后吗?¶
在流式输出(Streaming)场景下,记忆的更新时机至关重要。标准做法是在整个回答生成完成后再更新记忆,而不是流式地边输出边更新。
原因:
-
完整性:流式输出时,AI 的回复是分块到达的,只有完整回复才构成有意义的对话历史。如果在流式过程中更新,可能会存入不完整的句子。
-
一致性:如果在流式过程中发生错误,可以丢弃整个不完整的回复,不污染记忆。
在 LangChain 中的实现:
-
当使用
chain.astream()或chain.stream()时,链的内部回调会在生成完全结束后,才调用memory.save_context。 -
即使使用
StreamingStdOutCallbackHandler,记忆的保存也是在 LLM 生成完全结束后才执行。
自定义流式处理的注意事项:
如果你在自建循环中手动处理流式生成,需要确保在流式循环结束后,手动调用 memory.save_context(inputs, {"output": full_response}),而不是在流式循环内调用。
示例:
full_response = ""
async for chunk in chain.astream({"input": "你好"}):
full_response += chunk
print(chunk, end="")
# 流式结束后,memory 会自动保存(如果链内部处理)
# 或者手动: memory.save_context({"input": "你好"}, {"output": full_response})
这种设计确保了记忆模块在流式输出下的健壮性和一致性。
❌ 如果 LLM 在生成回复时出错,记忆是否应该记录这次失败的交互?¶
一般不应该记录失败的交互,或者需要特殊标记后记录。
理由:
-
失败的消息(例如因为 API 超时、内容过滤等导致的中断)是不完整的,存入记忆会破坏对话的连贯性。
-
如果用户看到错误,他们通常会重试。如果失败的尝试也存入记忆,可能会造成重复或混乱的上下文。
最佳实践:
-
默认丢弃:在调用链的
predict或ainvoke抛出异常时,不在except块中调用save_context。 -
特殊记录(用于调试):如果需要记录错误以便分析,可以使用一个单独的“错误日志”记忆或直接写日志,而不是混入对话历史。或者,可以用一个特殊的
SystemMessage或标记了metadata的消息存入记忆,表明这是一条失败的消息,并在 Prompt 中指示 LLM 忽略它们。 -
LangChain 的回调:可以利用
on_llm_error回调来捕获错误并记录到日志。
实现示例:
try:
response = chain.predict(input=user_input)
except Exception as e:
# 记录错误日志,但不更新对话记忆
logger.error(f"LLM generation failed: {e}")
response = "抱歉,我暂时无法处理您的请求,请稍后再试。"
# 可选:将这句道歉存入记忆,让用户知道发生了什么
memory.save_context({"input": user_input}, {"output": response})
在生成真实回复失败时,通常返回一个兜底回复,并将这个兜底回复存入记忆,以保持对话的连续。
🧹 如何清空或重置一次对话的记忆?这在聊天机器人中很常见。¶
清空记忆非常简单,所有 Memory 类都提供了 clear() 方法。
-
对于单一 Memory:直接调用
memory.clear()。它会删除内部存储的所有历史消息。 -
对于持久化存储(如 Redis):
clear()会删除 Redis 中对应的键。
常见场景:
-
用户点击“新对话”按钮。
-
用户输入“重置对话”或“忘记之前”等指令。
实现示例:
from langchain.memory import ConversationBufferMemory
from langchain.chains import ConversationChain
memory = ConversationBufferMemory()
conversation = ConversationChain(llm=llm, memory=memory)
def start_new_chat():
memory.clear()
# 可选:重新设置系统提示或默认上下文
如果你的应用需要同时管理多个对话,可以为每个对话分配独立的 session_id,并创建对应的 Memory 实例。当用户需要清除某个对话时,只需用该 session_id 初始化 Memory 并调用 clear()。
对于 ConversationSummaryBufferMemory 等包含摘要的 Memory,clear() 也会同时清除摘要和消息历史,完全重置。
🔮 你认为 LangChain 的记忆模块设计有什么缺点?你会如何改进?¶
尽管 LangChain 的记忆模块功能强大,但在实际使用中暴露出一些不足:
现有缺点:
-
抽象层级过多,调试困难:Memory、ChatMessageHistory、Chain 之间的交互复杂,出问题时不易定位。内部变量命名混乱(如
buffer,chat_history,history),不同 Memory 对 Prompt 变量的占位符要求不一致。 -
缺少内建的用户/会话管理:实现多用户隔离需要手动管理
session_id并传递到ChatMessageHistory,没有开箱即用的“SessionManager”。 -
长记忆的智能化不足:摘要记忆完全依赖单一的 LLM 调用进行摘要,缺乏对重要信息的筛选和结构化提取能力。向量检索记忆不够“聪明”,检索粒度粗。
-
性能与成本考虑不足:没有为记忆提供内建缓存、压缩或离线摘要等优化选项。在高并发下,频繁的 Redis 读写和 LLM 摘要会成为瓶颈。
-
对复杂结构的支持弱:记忆默认为文本流,但实际应用中需要记住结构化数据(如 JSON 对象、表格),需要大量自定义工作。
-
流式处理和错误恢复不完善:流式输出时的记忆保存时机不够透明,异常情况下容易丢失或重复记录。
改进方向:
-
统一记忆接口,简化使用:设计更清晰的“会话”概念,将 Memory 内建为会话级组件,自动处理多用户隔离。提供
SessionManager,一行代码即可创建、切换、删除用户会话。 -
智能记忆管家:引入一个轻量级“记忆管理器”,它可以在后台异步工作,对对话历史进行提取实体、总结、遗忘等操作,将结构化信息存入向量库或知识图谱,而不仅仅是文本摘要。
-
多级记忆体系:提供开箱即用的“工作记忆(当前对话)+ 短期记忆(摘要/窗口)+ 长期记忆(向量库)”三层架构,开发者只需配置存储后端。
-
性能优化:支持记忆的本地缓存,批处理写入,以及对摘要的异步处理,减少对主链路的阻塞。
-
更强的结构化支持:允许记忆存储和检索结构化
Document,并支持按元数据过滤,使得记忆不仅仅是对话文本。 -
可观测性与调试工具:内置记忆操作的日志、历史回放、可视化功能,帮助开发者理解记忆如何影响模型决策。
总结:LangChain 的记忆模块已打下良好基础,但未来应向更自动化、更智能、更易用的“记忆系统”演进,而不仅是简单的历史记录器。开发者渴望一个能像人类记忆一样,自动筛选、巩固、遗忘的 AI 记忆组件。