【LangChain 1.x】10、短期记忆|checkpointer 持久化与上下文治理三件套

摘要 :前几篇的 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_agentstate_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)记住用户偏好、知识、画像,不再局限于单次会话。

相关推荐
zl_dfq4 小时前
LangChain 之 【内存\Redis\Pinecone向量存储原理与实战】
langchain
吃饱了得干活5 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
Oo9205 小时前
大模型是怎么随机说话的?—— Temperature、Top-k 与 LangChain 实战
langchain
一碗面4217 小时前
LangSmith:LLM 应用调试与评估平台
langchain
陳陈陳16 小时前
Workflow vs Agent:别再被“调包侠”忽悠了,一张图看懂AI工程的“骨架”与“大脑”
langchain·agent·workflow
一只小bit19 小时前
LangGraph 记忆、人机交互、时间旅行和核心能力
机器学习·langchain·人机交互·langgraph
早点睡啊Y1 天前
深入学 LangChain 官方文档(十六)Built-in 与 Custom Middleware
langchain
老刘说AI1 天前
AI服务核心: 高并发原理与性能监控调优
人工智能·神经网络·langchain·llama·持续部署
展示猪肝1 天前
LangChain学习笔记(一):基础入门与核心概念详解
langchain