ConversationBufferMemory 迁移到 v1:thread_id 与历史裁剪实测
升级到 LangChain v1 之后,ConversationBufferMemory、ConversationBufferWindowMemory 这些对话记忆类不再出现在 langchain 包里(导入兼容性见导入兼容矩阵)。官方给出的方向是:用 create_agent 加 checkpointer,把对话历史当作 Agent 状态的一部分保存。
这个替换看起来只是“换几行代码”,实际上是语义的变化:旧版是“一个对象一份历史”,新版是“一个存储加一个会话编号”。本页写给正在迁移聊天机器人的开发者:用四组脚本实测两边的行为差异,重点覆盖最容易出事的几个地方,包括静默失忆、会话串线、历史无限增长和重启丢失。
适用版本与验证信息
| 项目 | 内容 |
|---|---|
| 验证日期 | 2026-10-26 |
| Python | 3.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),也没有验证跨会话的长期记忆。
先说结论
- 旧记忆对象没有“会话”概念。 一个
ConversationBufferMemory实例就是一份历史,共用一个实例就是所有用户共用一份历史(实验 1)。v1 把这件事拆成两部分:checkpointer 负责保存,thread_id负责区分会话(实验 2)。 - 最危险的迁移错误不会报错,而是静默失忆。 去掉旧记忆对象却忘了给
create_agent传checkpointer,即使每次调用都传了thread_id,模型每一轮也只看到 1 条消息,没有任何提示;反过来,配了 checkpointer 却不传thread_id,会立刻报ValueError(实验 2)。 - “历史越来越长”两边都有。 旧缓冲记忆 100 轮后是 4683 个字符的一个字符串;v1 不加裁剪时,模型每一轮看到的消息线性增长(实验 1、3)。
- 裁剪有两种,后果不同。 官方示例的
@before_model会直接删除状态里的消息,被删的内容永久丢失;用@wrap_model_call配合trim_messages只裁剪模型看到的内容,状态里保留完整历史(实验 3)。 InMemorySaver重启即失忆,文件型SqliteSaver可以跨重启,并且能按线程删除。 官方建议生产环境使用数据库支持的 checkpointer(实验 4)。
实验 1:旧版记忆到底是什么
下面的脚本只用旧版环境,直接调用记忆对象的 save_context 和 load_memory_variables,不涉及模型,观察它的数据形态、窗口行为、共享实例的后果,以及增长速度:
"""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 服务几个会话,并记录模型每一轮实际收到几条消息,这是判断“有没有记忆”最直接的指标:
"""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}"))])"""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 只裁剪模型看到的内容。每种配置都打印模型每轮收到的消息数、最后一次调用里看到的用户消息,以及最终存储的消息数:
"""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,并在最后演示按线程删除:
"""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 2InMemorySaver:重启之后,模型收到的消息数又回到 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; - 历史数据:旧记忆对象里的历史存在进程内存中,没有统一的持久化格式。如果你自己把历史存进了数据库,需要自行把它们转换成消息,再写入新线程;本页没有提供也没有验证这种转换;
- 回滚:因为新旧写法的会话标识完全不同,回滚时新线程里积累的历史不会自动回到旧对象。上线前确认这一点是否可以接受;
- 局限:所有实验都使用脚本化模型,证明的是记忆机制的行为,不是任何真实模型对长上下文的表现。多进程、多实例的并发写入没有验证。
相关阅读
- 先把旧代码里的 import 路径量出来:LangChain 从 v0.x 升级到 v1;
- 重启之后的恢复、重放与幂等:LangGraph checkpoint 与故障恢复;
- 线上报错对号入座:LangChain v1 与 LangGraph 生产故障诊断中心;
- 给 Agent 加限流、脱敏和重试:Agent 中间件与安全护栏。
来源
- 短期记忆 (opens in a new tab):checkpointer、thread、生产环境使用数据库支持的 checkpointer,以及
@before_model的裁剪示例; - 上下文工程 (opens in a new tab):
request.override(messages=…)的用法; - LangGraph 记忆 (opens in a new tab):
trim_messages与删除线程; - Checkpointers (opens in a new tab):
delete_thread的说明。
以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/conversation-memory-migration/ 在上表版本下的实际运行。