Deep Read

LangChain - Memory

一句话理解

大模型是无状态的——每次调用都是一次"失忆",它根本不记得你上一句说了什么。所谓"多轮对话"的连贯感,其实是每次都把之前的聊天记录一起重新喂给模型装出来的。

Memory(记忆)就是负责"存历史 + 喂回去"这件事的机制。

看个最直白的例子,不带记忆时:

你:  我叫云帆。
AI:  你好,云帆!
你:  我叫什么名字?
AI:  抱歉,我不知道你的名字。     ← 它真的忘了

带上记忆后,第二轮发给模型的其实是:

[历史]  Human: 我叫云帆。
        AI:   你好,云帆!
[当前]  Human: 我叫什么名字?

→ AI: 你叫云帆。               ← 因为历史被一起送了过去

模型本身没变,变的是我们每次都把对话历史拼进了输入。Memory 就是把这个"拼历史"的过程自动化、并管好"存哪、存多少、怎么取"。


经典记忆策略:存多少、怎么存

历史越攒越长,但模型的上下文窗口和 Token 成本有限,不可能无脑全塞。于是有了几种经典取舍策略:

策略做法适用场景
Buffer(缓冲)全量保存历史,超出长度就截断对话短、要完整上下文
Window(窗口)只保留最近 K 轮对话只关心近期、控制 Token
Summary(摘要)用 LLM 把旧历史压成一段摘要,只留摘要长对话、需要长期主线
向量记忆历史存入向量库,按当前问题检索式召回相关片段超长历史、按需取相关内容

这几种策略对应旧版 LangChain 里的 ConversationBufferMemoryConversationBufferWindowMemoryConversationSummaryMemory 等类。v1.0 已不再推荐这套 Memory(它们被移到了 langchain-classic),取而代之的是下面两种更现代的做法:① 用 RunnableWithMessageHistory 给链加历史;② 在 LangGraph 里用 Checkpointer。但"存多少、怎么压"的思路是相通的,理解策略比记类名更重要。


新版方案:ChatMessageHistory + RunnableWithMessageHistory

新版 LangChain 把记忆拆成了两个职责清晰的组件:

  • ChatMessageHistory:一个消息仓库,负责把 HumanMessage / AIMessage 一条条存起来、读出来。
  • RunnableWithMessageHistory:一个包装器,套在你已有的链外面,自动完成"调用前取历史、调用后存新消息"——你不用手写拼接代码。

三个关键拼图

要让一条普通的 prompt | llm | parser 链拥有记忆,需要三块拼图配合:

①  MessagesPlaceholder          在 Prompt 里"挖一个坑",留给历史消息
②  ChatMessageHistory           真正存历史的仓库(每个会话一个)
③  RunnableWithMessageHistory   把①和②接起来,自动取存

MessagesPlaceholder:Prompt 模板里的占位符,运行时这个位置会被填入整段历史消息列表:

prompt_template = ChatPromptTemplate.from_messages([
    ("system", "你是一个聊天助手,用 {language} 回答所有的问题"),
    MessagesPlaceholder(variable_name="history"),   # ← 历史消息从这里注入
    ("human", "{input}"),
])

完整示例

from langchain.messages import HumanMessage
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnableWithMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory
from models import get_lc_model_client

client = get_lc_model_client()

prompt_template = ChatPromptTemplate.from_messages([
    ("system", "你是一个聊天助手,用 {language} 回答所有的问题"),
    MessagesPlaceholder(variable_name="history"),
    ("human", "{input}"),
])
chain = prompt_template | client | StrOutputParser()

# ② 仓库:按 session_id 隔离每个用户/会话的历史
store = {}

def get_session(session_id: str) -> ChatMessageHistory:
    if session_id not in store:
        store[session_id] = ChatMessageHistory()
    return store[session_id]

# ③ 包装:把链和"取历史的函数"接起来
chatbot_with_his = RunnableWithMessageHistory(
    chain,
    get_session,
    input_messages_key="input",      # 输入里哪个键是"本轮用户消息"
    history_messages_key="history",  # 对应 MessagesPlaceholder 的变量名
)

config = {"configurable": {"session_id": "yunfang_chinese"}}

print(chatbot_with_his.invoke(
    {"input": [HumanMessage(content="你好,我是云帆。")], "language": "中文"},
    config=config,
))
print(chatbot_with_his.invoke(
    {"input": [HumanMessage(content="请问我的名字是什么?")], "language": "中文"},
    config=config,
))
# 第二轮能答出"云帆",因为历史被自动拼了进去

要点串一下:

  • 调用时通过 configsession_id,RunnableWithMessageHistory 据此调 get_session 拿到对应仓库;
  • 调用前,把仓库里的历史填进 history 占位符;调用后,自动把本轮的 input 和模型回复追加回仓库;
  • input_messages_keyhistory_messages_key 必须和 Prompt 里的变量名对上,否则取存找不到位置。

ChatMessageHistory 的常用接口

上面 RunnableWithMessageHistory 是"自动取存",但底层那个仓库 ChatMessageHistory(实现自 BaseChatMessageHistory 接口)其实暴露了一组手动读写历史的方法。无论是 RedisChatMessageHistorySQLChatMessageHistory 还是自定义实现,接口都是这一套——所以单独拎出来记一下,调试、迁移、自定义存储时都用得上。

接口作用
messages属性,读出当前仓库里的全部历史消息(list[BaseMessage])
add_user_message(msg)追加一条用户消息(传 strHumanMessage)
add_ai_message(msg)追加一条 AI 回复(传 strAIMessage)
add_message(msg)追加任意一条消息(更通用,要传 BaseMessage 对象)
add_messages(msgs)一次性追加多条消息
clear()清空该会话的全部历史
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain.messages import HumanMessage, AIMessage

history = ChatMessageHistory()

# 写入:两种风格等价
history.add_user_message("你好,我是云帆。")          # 便捷方法,直接传字符串
history.add_ai_message("你好,云帆!")
history.add_message(HumanMessage(content="我叫什么?"))  # 通用方法,传消息对象
history.add_messages([                                 # 批量追加
    AIMessage(content="你叫云帆。"),
])

# 读取
for m in history.messages:
    print(type(m).__name__, m.content)

# 清空
history.clear()

上表都是同步方法;每个方法还有对应的异步版本(aget_messages()aadd_messages()aclear() 等),用法一致,只是 await 调用。自定义存储时,最少实现 messagesadd_messagesclear 三样即可接入 RunnableWithMessageHistory


多会话隔离:session_id

store 是个普通字典,session_id 是它的键——不同的 session_id 拿到不同的 ChatMessageHistory,历史天然隔离,互不串台:

config_a = {"configurable": {"session_id": "user_A"}}
config_b = {"configurable": {"session_id": "user_B"}}
# user_A 和 user_B 的对话各存各的,A 记不住 B 说过的话

上面用内存字典 store 只是演示,进程一停历史就没了。生产环境应换成持久化实现,如 RedisChatMessageHistorySQLChatMessageHistory 等,把历史落到 Redis / 数据库,重启也不丢。


LangGraph 时代:Checkpointer

在 v1.0 主推的 LangGraph / create_agent 体系里,记忆又换了一套更强的机制——Checkpointer(检查点)

它不再只存"消息列表",而是在每一步自动保存 Agent 的整个状态(消息、工具调用、中间变量……),用 thread_id 标识一个会话线程;下次传同一个 thread_id,就能加载之前的完整状态接着跑。

from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(
    model=llm,
    tools=tools,
    system_prompt="你是人工智能助手。",
    checkpointer=InMemorySaver(),   # ← 短期记忆:线程级状态持久化
)

# 同一个 thread_id = 同一段连续对话
config = {"configurable": {"thread_id": "user_1"}}
agent.invoke({"messages": [{"role": "user", "content": "我叫云帆"}]}, config=config)
agent.invoke({"messages": [{"role": "user", "content": "我叫什么?"}]}, config=config)

相比 RunnableWithMessageHistory,Checkpointer 的优势在于:

  • 存的是整个状态而非仅消息,天然支持工具调用、分支、循环等复杂 Agent 流程;
  • 自带"时光倒流"(Time Travel)能力,可回到任意历史检查点重放、调试;
  • 换持久化后端(如 SqliteSaverPostgresSaver)即可落地存储。

一句话对应关系:旧版 Memory 类 → 新版 RunnableWithMessageHistory(给链加历史)→ LangGraph Checkpointer(给 Agent 加状态)。越往后越通用、越强。


短期记忆 vs 长期记忆

上面讲的(无论是消息历史还是 Checkpointer)本质都是短期记忆——只在一段会话/线程内有效,服务的是"这次聊天的上下文连贯"。

长期记忆指的是跨会话留存的知识:用户的长期偏好、画像、历史结论等。它通常不靠 Checkpointer,而是落到外部存储里,在需要时检索回来——实现上常和 [RAG](/ai/langchain/l-a-n-g-c-h-a-i-n-gai-shu#7.2 RAG(检索增强生成)) / 向量库重叠(把记忆当成可检索的文档)。

短期记忆长期记忆
范围单次会话 / 线程内跨会话、长期留存
存什么本轮对话的消息、状态用户偏好、画像、沉淀知识
典型实现RunnableWithMessageHistory / Checkpointer向量库 + 检索(RAG 思路)
解决什么"记得我们刚才聊了啥""记得我是谁、我喜欢什么"

小结

要点一句话
为什么要 Memory大模型无状态,多轮连贯靠"每次重新喂历史"实现
核心策略Buffer(全量)/ Window(近 K 轮)/ Summary(摘要)/ 向量(检索召回)
新版给链加记忆MessagesPlaceholder 挖坑 + ChatMessageHistory 存 + RunnableWithMessageHistory 自动取存
会话隔离session_id(或 LangGraph 的 thread_id)区分,互不串台
LangGraph 方案Checkpointer 存整个状态,更强、支持时光倒流
短期 vs 长期短期=会话内上下文;长期=跨会话知识,常用 RAG 实现

相关笔记:LangChain 概述LangChain 概述  1. 什么是 LangChain   LangChain 是一个用于开发由大型语言模型(LLM)驱动的应用程序的开源框架。它的定位是"LLM 应用的中间件 / 胶水层"——抽象了 LLM 调用、链式组合、工具集成、记忆管理、检索增强等通用能力,让开发者不必从零拼装,把 LLM、工具、数据和业务逻辑像搭积木一样组合成应用。 我们希望大模型应用不仅仅是聊天,更能从已有的数据库或文件中提取信息并执行具体操作。LangChain 正是为此而生:它允许开发者将通义千问、DeepSeek、OpenAI 等大语言模型与外部系统和数据源结合,完成更复杂的操作。   也可以借助 Dify、RAGFlow 之类的低代码平台,但自由度受限,对复杂业务适配欠缺;也可以完全自研裸调 SDK,但费时费力。LangChain 提供了现成组件,同时保留了足够的自定义自由度。 创始人**:Harrison Chase(2022 年开源) 语言**:Python / JavaScript(TypeScript) 官网**:https://python.langchain.c · LangChain - LCEL 高级特性与组件LangChain - LCEL 高级特性与组件  LCEL(LangChain Expression Language)用 | 把各个 Runnable 串成链。除了 prompt | llm | parser 这种基础用法,还有几个常用组件能把自定义函数、并行分支、数据透传等能力接进链里。 | 组件 | 作用 | | --- | --- | | RunnableLambda | 把普通 Python 函数包装成 Runnable,接进链里 | | RunnableParallel | 多个分支接收同一份输入,并行执行,结果汇总成 dict | | RunnablePassthrough | 把输入原样传递下去,或用 assign 增强后再传 | RunnableLambda —— 把自定义函数加入链   RunnableLambda 把任意普通 Python 函数(自定义函数)包装成一个 Runnable,从而能用 | 接进链里。 案例:在 LCEL 链中用 RunnableLambda 对 LLM 的输出统计字数。 from langchain_core.runnables import · Naive RAG 概述Naive RAG 讲解RAG,全称 Retrieval-Augmented Generation(检索增强生成),是过去两年大模型应用落地里最重要的工程方案之一。 如果把大模型比作一个"很聪明但记忆固定的学生",那么 RAG 做的事情就是:先去外部资料里找答案,再带着资料回答问题。 而 Naive RAG,可以理解为最基础、最原始、也是最容易落地的一代 RAG 方案。它的结构并不复杂,但已经足够解决很多实际问题,比如企业知识库问答、内部文档检索、客服机器人、私有数据接入大模型等。 一、为什么需要 RAG 大语言模型能力很强,但存在三类核心局限。 1. 时效性有限 模型知识来自训练数据,而训练数据有截止时间。最新政策、财报、内部 SOP、个人知识库文档——这些如果没被放进训练数据,模型就无法直接回答。 2. 知识覆盖不完整 即使训练了海量语料,也不可能覆盖所有垂直领域和每家企业的内部资料。医疗、法律、金融等高门槛领域,以及内部文档、合同、产品手册、私有接口说明——模型可能"懂一点",但不够深、不够准。 3. 幻觉问题 大模型在不知道答案时,仍可能生成一个看起来很像答案的内容——语气自信,内