摘要 :前几篇的 Agent 每次 invoke 都是独立的、没有记忆。本篇介绍短期记忆:用
checkpointer持久化对话历史、用thread_id隔离会话、用state_schema让自定义状态也跨调用保留;对话变长后,用裁剪(trim)、删除(delete)、摘要(SummarizationMiddleware)三件套治理上下文、防止爆炸。配合 DeepSeek 实测,讲清 Agent 从"无记忆"到"会话内记牢"的完整链路。
前言
上一篇,我们介绍了 Agent 语境下工具的五项新能力,其中 Command(update={...}) 可以让工具修改 Agent 的状态。
传送门:【LangChain 1.x】09、工具进阶|ToolRuntime、Command 与动态工具选择
但这里有个问题:在第 9 篇中,工具修改的 language 字段只在当次 invoke 有效:下次再 invoke,状态又会回到初始值。也就是说,之前的 Agent 其实是"没有记忆"的:每次调用都是独立的,根本不记得上一轮用户说过什么。
本篇,就来解决这个问题,介绍 Agent 的短期记忆:
- 使用 checkpointer 让 Agent 跨 invoke 记住对话历史
- 使用 thread_id 隔离不同会话 / 用户
- 自定义状态 也能持久化(这里对应上一篇中的
Command) - 对话变长后,用 裁剪 / 删除 / 摘要 三件套治理上下文、防止爆炸
一、短期记忆与 checkpointer:让 Agent 跨调用记住对话
什么是短期记忆
短期记忆,指的是单次会话(thread)内的对话历史 :即 Agent 状态中的 messages 列表。用户说一句、Agent 回一句,messages 就跟着增长一条。
问题在于:默认情况下 Agent 没有持久化 ,每次 invoke 都是独立的,上一次的 messages 不会带到下一次。
没有 checkpointer:状态不保留
先看一个最直观的例子,同一个 Agent 连续问两轮:
python
agent = create_agent(model=deepseek_llm, tools=[get_weather])
# 第一轮:告诉它名字
agent.invoke({"messages": [{"role": "user", "content": "我叫小明,请记住我的名字。"}]})
# 第二轮:问名字
agent.invoke({"messages": [{"role": "user", "content": "我叫什么名字?"}]})
makefile
第一轮回答: 你好!小明,很高兴认识你!我已经记住你的名字了......
第二轮回答: 很抱歉,我目前还不知道您的名字......
第一轮明明说"记住了",第二轮却完全不记得:因为两次 invoke 之间,状态没有保留。
使用 checkpointer:状态持久化
解决方法是把状态存起来。create_agent 提供了 checkpointer 参数,传入一个持久化后端即可:
python
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
model=deepseek_llm,
tools=[get_weather],
checkpointer=InMemorySaver(), # 开启持久化
)
config = {"configurable": {"thread_id": "thread-1"}}
# 第一轮
agent.invoke(
{"messages": [{"role": "user", "content": "我叫小明,请记住我的名字。"}]},
config=config,
)
# 第二轮:只传新消息,历史由 checkpointer 自动恢复
agent.invoke(
{"messages": [{"role": "user", "content": "我叫什么名字?"}]},
config=config,
)
makefile
第一轮回答: 好的,小明,我记住你的名字啦!......
第二轮回答: 你叫小明呀,我记得呢!😊
这次第二轮就记得了。关键在两点:
checkpointer=InMemorySaver()开启持久化,Agent 每一步执行后,状态(messages等)都会自动存进 checkpointer。config={"configurable": {"thread_id": ...}}标识当前会话,下次用同一个thread_id调用,历史会自动恢复,所以在第二轮对话时只传新消息就可以了。
get_state:查看持久化的状态
想要查看在某个会话中,当前存了哪些内容,可以使用 get_state:
python
state = agent.get_state(config)
print(state.next) # 下一步要执行的节点
print(len(state.values["messages"])) # 当前消息数
print(state.values["messages"][-1].content) # 最后一条消息
get_state 返回一个 StateSnapshot,.values 就是持久化的状态字典。比如上面那个会话查出来是 4 条消息(user → ai 工具调用 → tool 结果 → ai 最终回答),能看到完整的工具调用过程;next 为空表示本轮已跑完。
checkpointer 的选型
InMemorySaver 把状态存在内存里,进程一重启就没了,适合开发和演示。生产环境要用落盘的后端:
| checkpointer | 适用场景 |
|---|---|
InMemorySaver |
开发 / 测试(内存,重启丢失) |
SqliteSaver |
单机轻量持久化(落盘到 sqlite 文件) |
PostgresSaver |
生产环境(多实例共享、高可用) |
生产接入 PostgresSaver 的细节(建表、连接池)属于部署话题,本篇先使用 InMemorySaver 来做示例,说明短期记忆相关的机制。
二、thread_id:会话隔离
有了 checkpointer,状态会按 thread_id 分别存储。不同 thread_id 就是不同的会话,彼此隔离、互不干扰。
这在多用户场景下很关键:用户 A 和用户 B 同时调用了同一个 Agent,各自的状态不能串:
python
agent = create_agent(
model=deepseek_llm,
tools=[get_weather],
checkpointer=InMemorySaver(),
)
config_a = {"configurable": {"thread_id": "user-A"}}
config_b = {"configurable": {"thread_id": "user-B"}}
# 用户 A、B 各自报家门
agent.invoke({"messages": [{"role": "user", "content": "记住我是用户A,我在北京。"}]}, config=config_a)
agent.invoke({"messages": [{"role": "user", "content": "记住我是用户B,我在上海。"}]}, config=config_b)
# 各自问"我在哪":只记得自己的
ra = agent.invoke({"messages": [{"role": "user", "content": "我在哪个城市?"}]}, config=config_a)
rb = agent.invoke({"messages": [{"role": "user", "content": "我在哪个城市?"}]}, config=config_b)
less
用户A 的回答: 您在北京。
用户B 的回答: 您之前提到过,您在上海。
用户 A 答「北京」、用户 B 答「上海」,互不串台。一个 thread_id 就是一个独立会话 :同一个 Agent 对象、同一套工具,靠 thread_id 区分出无数条并行会话。
实际开发中,通常会为每个用户(或每次会话)分配一个唯一的 thread_id(如 f"user-{user_id}"),这样能够实现会话隔离。
三、自定义状态:记忆不止 messages
在上一篇中,我们通过使用 Command(update={...}) 让工具修改了一个自定义的状态字段 language。但由于当时没有加 checkpointer,所以修改效果仅在当次 invoke 调用有效、下次就会丢失回到初始值。
使用 checkpointer 后,自定义状态字段也会一起被持久化,这样才算是真正"记住"了。
扩展 AgentState 加字段
自定义状态的做法是给 create_agent 传 state_schema,继承 AgentState 添加自定义字段:
python
from langchain.agents.middleware import AgentState
class ProfileState(AgentState): # 继承 AgentState
language: str = "" # 自定义字段:用户设定的回复语言
AgentState 自带 messages 字段,自定义添加了字段language。 注意:新字段要给默认值,避免状态初始化时的校验问题。
工具读写 + 持久化
工具通过 Command来修改状态、使用 ToolRuntime 读取状态信息(上一篇中已介绍),配合 state_schema + checkpointer,自定义字段就持久化了:
python
@tool
def set_language(language: str, runtime: ToolRuntime) -> Command:
"""设置回复语言,存入状态。"""
return Command(update={
"language": language,
"messages": [ToolMessage(
content=f"已把回复语言设为 {language}。",
tool_call_id=runtime.tool_call_id,
)],
})
@tool
def get_language(runtime: ToolRuntime) -> str:
"""查看当前设置的回复语言。"""
lang = runtime.state.get("language", "")
return f"当前回复语言:{lang}" if lang else "尚未设置回复语言。"
agent = create_agent(
model=deepseek_llm,
tools=[set_language, get_language],
state_schema=ProfileState,
checkpointer=InMemorySaver(),
)
config = {"configurable": {"thread_id": "profile-1"}}
# 第一轮:设语言
r1 = agent.invoke(
{"messages": [{"role": "user", "content": "请把回复语言设为「英文」。"}]},
config=config,
)
print(f"第一轮回答: {r1['messages'][-1].content}")
# 用 get_state 看 checkpointer 里的自定义字段(不只是 messages)
print(f" → state.language = {agent.get_state(config).values.get('language')!r}")
# 第二轮:读语言(language 由 checkpointer 自动恢复)
r2 = agent.invoke(
{"messages": [{"role": "user", "content": "现在回复语言设的是什么?"}]},
config=config,
)
print(f"第二轮回答: {r2['messages'][-1].content}")
第一轮使用Command设置模型回复语言、第二轮通过ToolRuntime读取语言设置,中间用 get_state 看一眼 checkpointer 里的字段:
sql
第一轮回答: The reply language has been set to English...
→ state.language = '英文'
第二轮回答: The current reply language is set to English.
→ 自定义字段 language 跨 invoke 仍在(第 9 篇没 checkpointer,这里就丢了)
agent.get_state(config).values["language"] 读到的就是持久化的 '英文':不只是 messages,自定义字段也跨 invoke 保留了 。这是和上一篇的关键区别:之前的 language 改完只在当次 resp 中,这里它真正"记住"了。
中间件读状态:为上下文治理铺垫
中间件同样能读到持久化的状态。 @before_model 在每次模型调用前被触发,可以直接读取状态 state:
python
from langchain.agents.middleware import before_model
from langgraph.runtime import Runtime
@before_model
def log_state(state, runtime: Runtime):
lang = state.get("language", "")
print(f" [before_model] 读到 state.language = {lang!r}")
return None
# 复用上面的 set_language / get_language / ProfileState
agent = create_agent(
model=deepseek_llm,
tools=[set_language, get_language],
state_schema=ProfileState,
middleware=[log_state], # 挂载中间件
checkpointer=InMemorySaver(),
)
config = {"configurable": {"thread_id": "profile-2"}}
# 第一轮:设语言(中间件会打印读到的 language)
agent.invoke(
{"messages": [{"role": "user", "content": "请把回复语言设为「英文」。"}]},
config=config,
)
# 第二轮:language 由 checkpointer 恢复
agent.invoke(
{"messages": [{"role": "user", "content": "你好。"}]},
config=config,
)
连续两轮 invoke(第一轮设语言、第二轮查状态),@before_model 读到的变化轨迹是:
ini
--- 第一轮:设语言 ---
[before_model] 读到 state.language = ''
[before_model] 读到 state.language = '英文'
--- 第二轮:language 由 checkpointer 恢复,中间件直接读到 ---
[before_model] 读到 state.language = '英文'
第一轮里 @before_model 触发了两次:模型第一次调用前(还没设)、工具执行后模型再次调用前(已设)。第二轮直接从 checkpointer 恢复到 '英文'。
这个"中间件读 state"的能力,是下面两节上下文治理的基础:要讲的裁剪、摘要,都是在中间件里读写 messages。
四、上下文治理(上):裁剪与删除
对话轮次越多,messages 列表就会越长。这会带来三个问题:
- 撑爆上下文窗口:超出模型的上下文限制,直接报错
- 模型分心:太多历史消息让模型抓不住重点、回复质量下降
- 变慢变贵:每次都把一大堆历史发给模型,token 消耗和延迟增加
所以,长对话场景需要主动治理上下文。先介绍两种手段:裁剪(trim)和删除(delete)。
一个关键机制:add_messages reducer
在编写治理逻辑之前,先要搞清楚一件事:messages 字段用的是 add_messages reducer,默认是"追加"语义。
也就是说,中间件返回 {"messages": [新消息]},效果是把新消息追加到现有列表,而非替换,这一点直接决定了我们的治理代码要怎么写。
裁剪 trim:保留首条 + 最近几条
裁剪的思路是:消息太多时,只保留"首条 + 最近几条",中间的旧消息丢掉。 使用 @before_model 在每次模型调用前执行:
python
from langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
@before_model
def trim_to_recent(state, runtime: Runtime):
messages = state["messages"]
if len(messages) <= 4:
return None # 消息不多,不裁剪
first_msg = messages[0]
# 奇偶判断:保证保留的最近消息以 human 开头(消息序列合法)
recent = messages[-3:] if len(messages) % 2 == 0 else messages[-4:]
new_messages = [first_msg] + recent
return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages]}
这里最关键的一行是返回值:先 RemoveMessage(id=REMOVE_ALL_MESSAGES) 清空全部,再 *new_messages 把要保留的加回去。
为什么要先清空? 因为前面说过 messages 是追加语义:如果直接返回 new_messages,它们会被追加到旧列表上(造成重复)。所以必须要先清空、再重新加进去,才能实现"替换"。
RemoveMessage(id=REMOVE_ALL_MESSAGES)是"清空全部"的特殊写法;RemoveMessage(id=具体消息id)则是删除单条。其中的REMOVE_ALL_MESSAGES常量来自langgraph.graph.message。
删除 delete:删掉最早的几条
删除的思路更直接:消息太多时,把最早的几条删掉。使用 @after_model 在模型响应后执行:
python
@after_model
def delete_oldest(state, runtime: Runtime):
messages = state["messages"]
if len(messages) <= 4:
return None
oldest = messages[:2]
return {"messages": [RemoveMessage(id=m.id) for m in oldest]}
这里用的是 RemoveMessage(id=m.id):删除指定的几条消息(最早的 2 条),而不是清空全部。
对比:同样问名字,结果不同
把两种手段分别接到两个 Agent 上,用完全相同的对话(先报"我叫小明"、再写几首诗、最后问名字)测试:
| 治理方式 | 中间件 | 最后问"我叫什么名字" |
|---|---|---|
| 裁剪 trim | @before_model 保留首条 + 最近几条 |
答出「小明」(首条被保留) |
| 删除 delete | @after_model 删最早 2 条 |
答不出(最早的"我叫小明"被删了) |
makefile
【trim】最后一轮: 你叫小明,我记得清清楚楚。
【delete】最后一轮: 根据我们的对话记录,您并没有告诉过我您的名字......
差别就在:trim 主动保留了首条(那段重要的自我介绍),而 delete 按位置砍、恰好把首条砍掉了。
这正好点出两种手段的特点:trim 能挑着留(保住重要信息)、delete 按位置砍(简单直接、可能误伤) 。但两者有一个共同的局限:都会丢信息。下一节的摘要,就是来解决这个问题的。
五、上下文治理(下):自动摘要
裁剪和删除都"会丢信息"。一种更聪明的做法是摘要:把旧消息压成一条 summary,既缩短了上下文,又把信息保留下来。
LangChain 提供了预置中间件 SummarizationMiddleware,零手写逻辑、一行接入:
python
from langchain.agents.middleware import SummarizationMiddleware
agent = create_agent(
model=deepseek_llm,
tools=[],
middleware=[
SummarizationMiddleware(
model=deepseek_llm, # 生成摘要用的模型
trigger=("tokens", 400), # token 超过 400 就触发
keep=("messages", 4), # 触发后保留最近 4 条消息
)
],
checkpointer=InMemorySaver(),
)
三个参数:
model:用来生成摘要的模型(可以和 Agent 主模型不同,比如用一个更便宜的)trigger=("tokens", N):当消息的 token 数超过 N,就触发摘要keep=("messages", N):触发后保留最近 N 条消息,更早的被压缩进 summary
效果:信息不丢
连续聊 5 轮信息丰富的对话(名字、年龄、工作、宠物、爱好、老家),观察每轮的消息列表:
less
--- 第 1 轮 ---
消息数: 2 [human] [ai]
--- 第 3 轮 ---(token 超阈值,触发摘要)
消息数: 6
[human] Here is a summary of the conversation to... ← 摘要消息出现
[ai] [human] [ai] [human] [ai] ← 保留的最近消息
从第 3 轮起,消息列表里多了一条 [human] Here is a summary...(过往对话的摘要),消息数稳定不再增长:旧消息被压缩进 summary 了。
最后连考 5 个早期信息:
makefile
提问: 我叫什么?多大?在哪工作?宠物叫什么?老家在哪?
回答:
名字:小明 年龄:28岁
工作地点:北京 宠物:一只3岁的橘猫,名叫橘子
老家:四川成都
5 个信息一个不差:摘要把早期信息压缩保留了,没有丢失。这就是它比 trim/delete 强的地方。
三件套选型
把三种治理手段放一起对比:
| 手段 | 做法 | 丢信息? | 额外开销 | 适用场景 |
|---|---|---|---|---|
| 裁剪 trim | 砍掉中间、留首条和最近 | 会丢 | 无(纯切片) | 对话不长、只需控长度 |
| 删除 delete | 删最早的几条 | 会丢 | 无(纯删除) | 按轮数硬性限制 |
| 摘要 summarize | 旧消息压成 summary | 不丢 | 有(多一次模型调用) | 长对话、需保留早期信息 |
简单说:短对话用 trim/delete 够了,长对话、怕丢信息的用 summarize。摘要的代价是多一次模型调用(生成摘要),换来的是信息不丢:值不值看场景。
六、总结
本篇,把 Agent 的短期记忆梳理了一遍:
- 短期记忆 =
messages列表:单次会话内的对话历史。默认不持久化,每次 invoke 独立。 - checkpointer :开启持久化,状态跨 invoke 保留。
create_agent(checkpointer=InMemorySaver()),开发用InMemorySaver、生产用PostgresSaver。 - thread_id :会话隔离。
config={"configurable": {"thread_id": ...}},一个thread_id一个独立会话,多用户互不干扰。 - 自定义状态 :
state_schema继承AgentState加字段,配合 checkpointer 后自定义字段也持久化(呼应第 9 篇的Command,现在language真正"记住"了)。 - 上下文治理三件套:trim(裁剪)、delete(删除)、summarize(摘要),都走中间件;trim / delete 会丢信息、summarize 不丢但多一次模型调用。
一句话概括:短期记忆靠 checkpointer 持久化、靠 thread_id 隔离;对话变长后,用 trim / delete / summarize 三件套把上下文管起来。
下一篇,将介绍长期记忆:让 Agent 跨会话(thread)记住用户偏好、知识、画像,不再局限于单次会话。