解决方案
对话记忆迁移

ConversationBufferMemory 迁移到 v1:thread_id 与历史裁剪实测

升级到 LangChain v1 之后,ConversationBufferMemory、ConversationBufferWindowMemory 这些对话记忆类不再出现在 langchain 包里(导入兼容性见导入兼容矩阵)。官方给出的方向是:用 create_agent 加 checkpointer,把对话历史当作 Agent 状态的一部分保存。

这个替换看起来只是“换几行代码”,实际上是语义的变化:旧版是“一个对象一份历史”,新版是“一个存储加一个会话编号”。本页写给正在迁移聊天机器人的开发者:用四组脚本实测两边的行为差异,重点覆盖最容易出事的几个地方,包括静默失忆、会话串线、历史无限增长和重启丢失。

适用版本与验证信息

项目内容
验证日期2026-10-26
Python3.12.12
旧版环境langchain==0.3.27、langchain-core==0.3.86
v1 环境langchain==1.4.7、langchain-core==1.7.0、langgraph==1.2.17、langgraph-checkpoint-sqlite==3.1.2
模型记录型脚本化模型:每次返回固定计数文本,并记下自己收到了哪些消息,不联网、不需要 API Key
官方来源短期记忆 (opens in a new tab)、上下文工程 (opens in a new tab)、LangGraph 记忆 (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16)
实验代码站点仓库 labs/conversation-memory-migration/,依赖锁定在 labs/requirements/legacy.txt 与 labs/requirements/v1.txt

本页没有验证 ConversationSummaryMemory 的替代方案(官方的 SummarizationMiddleware),也没有验证跨会话的长期记忆。

先说结论

  1. 旧记忆对象没有“会话”概念。 一个 ConversationBufferMemory 实例就是一份历史,共用一个实例就是所有用户共用一份历史(实验 1)。v1 把这件事拆成两部分:checkpointer 负责保存,thread_id 负责区分会话(实验 2)。
  2. 最危险的迁移错误不会报错,而是静默失忆。 去掉旧记忆对象却忘了给 create_agent 传 checkpointer,即使每次调用都传了 thread_id,模型每一轮也只看到 1 条消息,没有任何提示;反过来,配了 checkpointer 却不传 thread_id,会立刻报 ValueError(实验 2)。
  3. “历史越来越长”两边都有。 旧缓冲记忆 100 轮后是 4683 个字符的一个字符串;v1 不加裁剪时,模型每一轮看到的消息线性增长(实验 1、3)。
  4. 裁剪有两种,后果不同。 官方示例的 @before_model 会直接删除状态里的消息,被删的内容永久丢失;用 @wrap_model_call 配合 trim_messages 只裁剪模型看到的内容,状态里保留完整历史(实验 3)。
  5. InMemorySaver 重启即失忆,文件型 SqliteSaver 可以跨重启,并且能按线程删除。 官方建议生产环境使用数据库支持的 checkpointer(实验 4)。

实验 1:旧版记忆到底是什么

下面的脚本只用旧版环境,直接调用记忆对象的 save_context 和 load_memory_variables,不涉及模型,观察它的数据形态、窗口行为、共享实例的后果,以及增长速度:

legacy_memory.py
"""What the old memory classes do (langchain 0.3.x): one object, one string, no session key."""
import warnings
 
from langchain.memory import ConversationBufferMemory, ConversationBufferWindowMemory
 
warnings.filterwarnings("ignore")  # the deprecation notice is printed once per class otherwise
 
 
def exchange(memory, question, answer):
    memory.save_context({"input": question}, {"output": answer})
 
 
print("A. buffer memory returns one string")
buffer = ConversationBufferMemory()
exchange(buffer, "hi, my name is bob", "hello bob")
exchange(buffer, "what is 2+2", "4")
print(buffer.load_memory_variables({}))
 
print()
print("B. return_messages=True returns message objects")
as_messages = ConversationBufferMemory(return_messages=True)
exchange(as_messages, "hi", "hello")
print([(m.type, m.content) for m in as_messages.load_memory_variables({})["history"]])
 
print()
print("C. window memory keeps the last k exchanges")
window = ConversationBufferWindowMemory(k=1)
for i in range(3):
    exchange(window, f"q{i}", f"a{i}")
print(window.load_memory_variables({}))
 
print()
print("D. one shared instance has no notion of a user")
shared = ConversationBufferMemory()
exchange(shared, "I am alice, my card ends in 4242", "noted")
print("bob asks for his history:", shared.load_memory_variables({}))
 
print()
print("E. the buffer grows without bound")
growing = ConversationBufferMemory()
for turn in range(1, 101):
    exchange(growing, f"question number {turn}", f"answer number {turn}")
    if turn in (10, 50, 100):
        print(f"after {turn} exchanges: {len(growing.load_memory_variables({})['history'])} characters")
 
print()
print("F. clear() empties the one shared history")
shared.clear()
print(shared.load_memory_variables({}))
A. buffer memory returns one string
{'history': 'Human: hi, my name is bob\nAI: hello bob\nHuman: what is 2+2\nAI: 4'}

B. return_messages=True returns message objects
[('human', 'hi'), ('ai', 'hello')]

C. window memory keeps the last k exchanges
{'history': 'Human: q2\nAI: a2'}

D. one shared instance has no notion of a user
bob asks for his history: {'history': 'Human: I am alice, my card ends in 4242\nAI: noted'}

E. the buffer grows without bound
after 10 exchanges: 451 characters
after 50 exchanges: 2331 characters
after 100 exchanges: 4683 characters

F. clear() empties the one shared history
{'history': ''}
  • A:默认返回的是一个字符串,Human: …\nAI: … 拼接而成,放在 history 键下;
  • B:return_messages=True 才返回消息对象列表,这是和 v1 状态里 messages 形态最接近的一种;
  • C:窗口记忆 k=1 只保留最近一轮;
  • D:这是最重要的一条。一个共享实例没有“用户”这个概念,用户 bob 取记忆时直接拿到了 alice 的内容,包括银行卡尾号。很多旧代码为了“省事”,把记忆对象做成模块级全局变量,迁移时要先确认你的代码是不是这样;
  • E:缓冲记忆没有上限,100 轮后历史字符串有 4683 个字符,而且和轮数成正比;
  • F:clear() 清空的是这一个共享实例里的全部历史。

实验 2:checkpointer 与 thread_id

v1 里没有记忆对象。下面的脚本让同一个 Agent 服务几个会话,并记录模型每一轮实际收到几条消息,这是判断“有没有记忆”最直接的指标:

recording_model.py
"""A chat model that answers with a counter and remembers what it was shown."""
from typing import Any, List, Optional, Sequence
 
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, BaseMessage
from langchain_core.outputs import ChatGeneration, ChatResult
 
 
class RecordingModel(BaseChatModel):
    calls: int = 0
    seen: List[List[BaseMessage]] = []
 
    @property
    def _llm_type(self) -> str:
        return "recording"
 
    def bind_tools(self, tools: Sequence[Any], **kwargs: Any):
        return self
 
    def _generate(
        self,
        messages: List[BaseMessage],
        stop: Optional[List[str]] = None,
        run_manager: Any = None,
        **kwargs: Any,
    ) -> ChatResult:
        self.calls += 1
        self.seen.append(list(messages))
        return ChatResult(generations=[ChatGeneration(message=AIMessage(content=f"answer {self.calls}"))])
v1_thread_memory.py
"""The v1 replacement: a checkpointer plus a thread_id decides who remembers what."""
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from recording_model import RecordingModel
 
 
def turns(agent, model, config, count, label):
    seen_sizes = []
    for turn in range(count):
        before = len(model.seen)
        agent.invoke({"messages": [HumanMessage(f"turn {turn + 1}")]}, config)
        seen_sizes.append(len(model.seen[before]))
    print(f"{label:<44} messages the model saw per turn: {seen_sizes}")
 
 
model = RecordingModel()
agent = create_agent(model, tools=[], checkpointer=InMemorySaver())
turns(agent, model, {"configurable": {"thread_id": "alice"}}, 3, "checkpointer, thread alice")
turns(agent, model, {"configurable": {"thread_id": "bob"}}, 2, "same agent, thread bob")
turns(agent, model, {"configurable": {"thread_id": "alice"}}, 1, "thread alice again")
stored = agent.get_state({"configurable": {"thread_id": "alice"}}).values["messages"]
print(f"stored messages for alice: {len(stored)} ({[m.type for m in stored][:4]} ...)")
 
print()
model = RecordingModel()
agent = create_agent(model, tools=[])
turns(agent, model, {"configurable": {"thread_id": "alice"}}, 3, "no checkpointer, thread_id passed anyway")
 
print()
model = RecordingModel()
agent = create_agent(model, tools=[], checkpointer=InMemorySaver())
try:
    agent.invoke({"messages": [HumanMessage("hello")]})
except Exception as exc:
    print(f"checkpointer but no thread_id: {type(exc).__name__}: {str(exc).splitlines()[0]}")
checkpointer, thread alice                   messages the model saw per turn: [1, 3, 5]
same agent, thread bob                       messages the model saw per turn: [1, 3]
thread alice again                           messages the model saw per turn: [7]
stored messages for alice: 8 (['human', 'ai', 'human', 'ai'] ...)

no checkpointer, thread_id passed anyway     messages the model saw per turn: [1, 1, 1]

checkpointer but no thread_id: ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_id
  • 线程 alice 三轮:模型收到的消息数是 1、3、5,每多一轮多 2 条(一条用户消息加一条回答),说明历史在累积;
  • 同一个 Agent 上的线程 bob 从 1 开始,两个会话互不影响;回到 alice,继续从 7 条开始,状态里存的是 8 条消息;
  • 没有 checkpointer,但传了 thread_id:三轮都是 1。没有报错,没有警告,thread_id 被静默忽略,每次调用都是全新的对话;
  • 配了 checkpointer,不传 thread_id:直接抛 ValueError: Checkpointer requires one or more of the following 'configurable' keys…。

所以迁移之后的第一道验收,就是用这个脚本的方法:同一个会话连续调用两次,看第二次模型收到的消息数是不是大于 1。

实验 3:历史越来越长,两种裁剪

下面的脚本让同一个线程连续进行 8 轮,对比三种配置:不裁剪;官方示例里的 @before_model 裁剪,保留第一条加最近几条;用 @wrap_model_call 只裁剪模型看到的内容。每种配置都打印模型每轮收到的消息数、最后一次调用里看到的用户消息,以及最终存储的消息数:

v1_trim_history.py
"""Bounding the history the model sees: delete from state, or trim only the model's view."""
from typing import Any
 
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import before_model, wrap_model_call
from langchain.messages import RemoveMessage
from langchain_core.messages import HumanMessage, trim_messages
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.runtime import Runtime
from recording_model import RecordingModel
 
TURNS = 8
 
 
@before_model
def trim_state(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    """Keep the first message and the last few, as in the official short-term memory page."""
    messages = state["messages"]
    if len(messages) <= 3:
        return None
    first = messages[0]
    recent = messages[-3:] if len(messages) % 2 == 0 else messages[-4:]
    return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), first, *recent]}
 
 
@wrap_model_call
def trim_view(request, handler):
    """Trim only what this model call sees; the stored history stays complete."""
    kept = trim_messages(request.messages, strategy="last", token_counter=len, max_tokens=4, start_on="human")
    return handler(request.override(messages=kept))
 
 
def run(label, middleware):
    model = RecordingModel()
    agent = create_agent(model, tools=[], middleware=middleware, checkpointer=InMemorySaver())
    config = {"configurable": {"thread_id": "t1"}}
    for turn in range(1, TURNS + 1):
        agent.invoke({"messages": [HumanMessage(f"turn {turn}")]}, config)
    sizes = [len(call) for call in model.seen]
    last_view = [m.content for m in model.seen[-1] if m.type == "human"]
    stored = agent.get_state(config).values["messages"]
    print(f"{label}")
    print(f"  messages seen per turn : {sizes}")
    print(f"  human turns in the last model call: {last_view}")
    print(f"  messages stored after {TURNS} turns: {len(stored)}")
    print()
 
 
run("no middleware", [])
run("@before_model trim_state (deletes from state)", [trim_state])
run("@wrap_model_call trim_view (trims the view only)", [trim_view])
no middleware
  messages seen per turn : [1, 3, 5, 7, 9, 11, 13, 15]
  human turns in the last model call: ['turn 1', 'turn 2', 'turn 3', 'turn 4', 'turn 5', 'turn 6', 'turn 7', 'turn 8']
  messages stored after 8 turns: 16

@before_model trim_state (deletes from state)
  messages seen per turn : [1, 3, 5, 5, 5, 5, 5, 5]
  human turns in the last model call: ['turn 1', 'turn 7', 'turn 8']
  messages stored after 8 turns: 6

@wrap_model_call trim_view (trims the view only)
  messages seen per turn : [1, 3, 3, 3, 3, 3, 3, 3]
  human turns in the last model call: ['turn 7', 'turn 8']
  messages stored after 8 turns: 16
  • 不裁剪:模型每轮看到的消息数是 1、3、5……15,线性增长,最后一次调用能看到全部 8 条用户消息;
  • @before_model 裁剪(删除状态):消息数在 5 处封顶;最后一次调用里模型只看到 turn 1、turn 7、turn 8,第 2 到第 6 轮已经从状态里永久删除,存储里最终只有 6 条。这是官方示例的行为:保留第一条消息和最近几条,一旦删掉就无法找回;
  • @wrap_model_call 裁剪(只改视图):模型每轮最多看到 3 条,最后一次调用只看到 turn 7、turn 8,存储里仍有完整的 16 条,需要时可以改用别的策略重新处理。官方的上下文工程页面同样使用 request.override(messages=…) 来改变单次模型调用看到的消息。

怎么选:对话内容有合规留存或回放需求,或者以后可能改用摘要策略,用只裁剪视图的方式;确实希望状态保持精简、并且接受丢失,才用删除状态的方式。两种方式的“窗口”都不能感知语义,被裁掉的内容就是模型看不到的内容,需要保留关键事实时,请考虑官方的摘要中间件(本页未验证)。

实验 4:重启之后还记得吗

最后一组实验模拟进程重启:新建一个 Agent,接上新的 saver 对象。对比内存型 saver 和文件型 SQLite saver,并在最后演示按线程删除:

restart_persistence.py
"""Does the conversation survive a process restart? InMemorySaver versus SqliteSaver."""
import sqlite3
import tempfile
from pathlib import Path
 
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver
from recording_model import RecordingModel
 
CONFIG = {"configurable": {"thread_id": "alice"}}
 
 
def ask(saver, text):
    """Build a fresh agent around the saver, like a newly started process, and ask once."""
    model = RecordingModel()
    agent = create_agent(model, tools=[], checkpointer=saver)
    agent.invoke({"messages": [HumanMessage(text)]}, CONFIG)
    return len(model.seen[0]), len(agent.get_state(CONFIG).values["messages"])
 
 
print("InMemorySaver")
first = ask(InMemorySaver(), "turn 1")
restarted = ask(InMemorySaver(), "turn 2")  # a new saver object is a new process
print(f"  before restart: model saw {first[0]}, stored {first[1]}")
print(f"  after restart : model saw {restarted[0]}, stored {restarted[1]}")
 
print()
print("SqliteSaver on a file")
with tempfile.TemporaryDirectory() as tmp:
    db = str(Path(tmp) / "memory.sqlite")
 
    def open_saver():
        return SqliteSaver(sqlite3.connect(db, check_same_thread=False))
 
    saver = open_saver()
    first = ask(saver, "turn 1")
    saver.conn.close()
 
    saver = open_saver()
    restarted = ask(saver, "turn 2")
    print(f"  before restart: model saw {first[0]}, stored {first[1]}")
    print(f"  after restart : model saw {restarted[0]}, stored {restarted[1]}")
 
    saver.delete_thread("alice")
    deleted = ask(saver, "turn 3")
    print(f"  after delete_thread: model saw {deleted[0]}, stored {deleted[1]}")
    saver.conn.close()
InMemorySaver
  before restart: model saw 1, stored 2
  after restart : model saw 1, stored 2

SqliteSaver on a file
  before restart: model saw 1, stored 2
  after restart : model saw 3, stored 4
  after delete_thread: model saw 1, stored 2
  • InMemorySaver:重启之后,模型收到的消息数又回到 1,历史没了;
  • SqliteSaver 文件:重启之后模型收到 3 条消息(上一轮的一问一答加新的一问),历史还在;
  • delete_thread:删除线程 alice 之后,再提问时模型又只收到 1 条消息。官方在 Checkpointers 文档中说明,delete_thread 删除该线程的全部检查点和写入记录。

这个实验用单进程、单连接的 SQLite 演示了持久化的有无,不代表多进程部署下的行为。官方建议生产环境使用数据库支持的 checkpointer,例如官方示例里的 PostgreSQL 版本,本页没有验证。

旧写法与 v1 写法对照

旧写法v1 中对应的做法关键差异依据
ConversationBufferMemory()create_agent(..., checkpointer=...)记忆从对象变成了存储,必须配合 thread_id实验 2
return_messages=True状态里的 messages本来就是消息列表,不再是拼接字符串实验 1、2
ConversationBufferWindowMemory(k=n)裁剪中间件,二选一:删除状态,或只裁剪视图不再有现成的窗口类,需要自己选策略实验 3
ConversationSummaryMemory官方的摘要中间件本页未验证内置 Middleware (opens in a new tab)
每个用户一个记忆实例,或一个字典存多个实例每个会话一个 thread_id隔离靠编号,不再靠对象实验 2
memory.clear()saver.delete_thread(thread_id)按线程删除,对象本身不再持有历史实验 1、4
进程内全局记忆对象持久化的 checkpointer重启是否保留取决于 saver 的类型实验 4

排障对照表

现象最可能的原因怎么验证处理
迁移后 Agent 不记得上一句话,模型每轮只看到 1 条消息create_agent 没有传 checkpointer,thread_id 被静默忽略同一会话连续调用两次,打印模型收到的消息数传入 checkpointer(实验 2)
调用时报 ValueError: Checkpointer requires one or more of the following 'configurable' keys配了 checkpointer,但每次调用没有带 thread_id看调用处的 config每次 invoke 都传 {"configurable": {"thread_id": …}}(实验 2)
用户 A 看到了用户 B 的历史旧代码:共享了记忆实例;新代码:thread_id 被复用或写死检查 thread_id 的来源,旧代码检查记忆对象的作用域每个会话生成独立的 thread_id(实验 1、2)
服务重启后所有人的历史都没了使用的是 InMemorySaver看 checkpointer 的类型换成持久化的 saver(实验 4)
对话越长,响应越慢、成本越高没有裁剪,模型收到的消息线性增长打印每轮模型收到的消息数加裁剪中间件(实验 3)
裁剪之后模型“忘了”前面的设定删除状态方式只保留第一条和最近几条,中间的永久丢失看存储里剩下的消息改用只裁剪视图的方式,或使用摘要中间件(实验 3)
用户要求删除对话,但数据还在没有调用 delete_thread;另外,日志、追踪等位置的副本不在 checkpointer 里删除后重新提问并检查存储按线程删除,同时检查你自己的日志与追踪系统(实验 4)

失败与回滚

  • 双轨过渡:兼容层 langchain-classic 下仍然可以导入旧的记忆类(见导入兼容矩阵)。迁移期间可以先保持旧记忆,等新实现通过验收再切换,整体步骤见迁移 Runbook;
  • 历史数据:旧记忆对象里的历史存在进程内存中,没有统一的持久化格式。如果你自己把历史存进了数据库,需要自行把它们转换成消息,再写入新线程;本页没有提供也没有验证这种转换;
  • 回滚:因为新旧写法的会话标识完全不同,回滚时新线程里积累的历史不会自动回到旧对象。上线前确认这一点是否可以接受;
  • 局限:所有实验都使用脚本化模型,证明的是记忆机制的行为,不是任何真实模型对长上下文的表现。多进程、多实例的并发写入没有验证。

相关阅读

来源

以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/conversation-memory-migration/ 在上表版本下的实际运行。

维护信息

  • 编辑:LangChain 中文网编辑部(内容标准见编辑与纠错说明);
  • 最近验证:2026-10-26;
  • 复现:先执行 labs/setup-envs.sh legacy 和 labs/setup-envs.sh v1 重建环境,再执行 python3 labs/run.py --lab conversation-memory-migration;
  • 发现错误请按联系我们反馈。