第2节 短期记忆管理及在 LangGraph 中的使用#
在经过上一篇文章「Agent 记忆机制入门详解:短期记忆、长期记忆与 LangGraph 记忆系统架构对比指南」的介绍我们已经理解了短期记忆的底层机制, 此时我们有一个工程问题不得不面对,那就是对话历史会无限增长但模型的上下文窗口是有限的,也是本节要解决的核心问题。 围绕它,LangGraph 提供了 3 种应对策略——删除、修剪、总结,下面我们逐个拆开讲。
2.1 为什么必须管短期记忆#
在一个对话型应用中,每一轮交互都会产生新的消息——用户说一句,模型回一句。你和模型一来一回,最终,在 LangGraph 中这些消息会被依次追加到全局状态的消息列表中。前几轮可能完全没事,但聊到几十、几百轮之后,消息列表就会越积越长,词元总量就便会持续膨胀,进而带来两类后果。
(1)超出上下文窗口直接报错
每个大模型都有一个上下文窗口(Context Window)的硬上限——8K、128K、200K 不等。当消息列表的总 token 数突破这个上限,模型会直接抛错,而且这种错误一般不可恢复,只能截断历史或从头开始对话。
(2)即使没超限,效果也会变差
即使模型的上下文窗口足够大,过长的上下文也未必带能带来好的效果。那些"过时但还在占位"的早期消息会分散模型的注意力,导致回答质量下滑、响应变慢——而且这种退化比"报错"更隐蔽,更容易被忽视。
所以一个核心结论是:更长的上下文 ≠ 更好的效果——超出合理长度之后,冗余的历史信息反而成了干扰。怎么办?LangGraph 给了 3 种策略来进行管理,删除、修剪、总结,分别是记忆修剪、记忆总结和记忆删除,下面依次进行介绍。
2.2 记忆删除:直接删掉旧消息#
先说最简单粗暴的一种:直接删。把不需要的历史消息按 ID 丢掉,只留你想保留的一定数量部分,思路如图2-1所示。
LangGraph 里专门提供了 RemoveMessage 类用来按 ID 删除单条或多条消息。下面是一个常见的删除函数写法:
1 def delete_messages(agent, config: RunnableConfig) -> None:
2 snapshot = agent.get_state(config)
3 messages = snapshot.values.get("messages", []) if snapshot.values else []
4 if len(messages) < 2:
5 print("当前记忆不足 2 条,跳过删除。")
6 return
7 updates = {"messages": [RemoveMessage(id=message.id) for message in messages[:2]]}
8 agent.update_state(config, updates, as_node="generate_answer")
9 print("已删除最前面的两条记忆。")这段代码做了 3 件事:第 3 行从快照状态 checkpoint 里捞出当前所有历史消息;第 7 行挑出最先发生的两条构造删除指令;第 8 行写回状态快照。这里 as_node="generate_answer" 是关键——它告诉 LangGraph"这次状态变更模拟成 generate_answer 节点产生的"。因为 update_state() 不是简单改一行 JSON,而是按 LangGraph 的图执行模型去改状态,所以需要声明"以谁的名义改"。
as_node 填哪个节点没有强制要求——不填的话默认会用图的最后一个节点(前提是没有歧义,否则报错)。但建议显式指定一个真实存在的节点。原则是:选一个本来就返回 {"messages": ...} 的节点,因为 update_state() 更新的是 messages 字段。本例中这次"修改"最接近"对消息状态做一次人工修正",所以 generate_answer 是最自然的选择;选 rewrite_question 也能跑通,但状态快照里 next() 的指向就不太合理了。
假设当前 checkpoint 快照里有 6 条历史消息:
StateSnapshot(values={'messages': [
HumanMessage(content='郭靖和杨康是什么关系?', , id='31e1f411-8015-4e27'),
AIMessage(content='', id='lc_run--019e2558-e520-7be1', tool_calls=[{'name': 'retrieve_context', 'args': {'query': '郭靖和杨康的关系'}, 'id': 'call_7cdad6b1a9394482846227', 'type': 'tool_call'}]),
ToolMessage(content='杨康边哭边说,涕泪滂沱,断断续续地道:......', name='retrieve_context', id='69d27ef5-2a11-4e2a', tool_call_id='call_7cdad6b1a9394482846227', artifact=),
AIMessage(content='郭靖和杨康是结义兄弟。两人在郭啸天灵前对拜八拜,结为兄弟,郭靖先出世一个月,为兄,杨康为弟。', id='lc_run--019e2558-f1ba-7c42', ),
HumanMessage(content='郭靖比杨康先出生几个月?', , id='e5336c6b-d60f-44bf'),
AIMessage(content='根据提供的文本,郭靖比杨康先出生**一个月**。', id='lc_run--019e2559-4511-7403', )]},
next=(), config={'configurable': {'thread_id': '5', 'checkpoint_ns': '', 'checkpoint_id': '1f14f650-3615-6440'}}, metadata={'k': 20, 'step': 6, 'top_n': 3, 'source': 'loop', 'parents': {}}, created_at='2026-05-17T07:17:49.219715+00:00', parent_config={'configurable': {'thread_id': '5', 'checkpoint_ns': '', 'checkpoint_id': '1f14f650-278b-6c8a'}}, tasks=(), interrupts=())执行 delete_messages 之后,消息列表会变成:
StateSnapshot(values={'messages': [
ToolMessage(content='杨康边哭边说,涕泪滂沱,断断续续地道:......', name='retrieve_context', id='69d27ef5-2a11-4e2a', tool_call_id='call_7cdad6b1a9394482846227', artifact=),
AIMessage(content='郭靖和杨康是结义兄弟。两人在郭啸天灵前对拜八拜,结为兄弟,郭靖先出世一个月,为兄,杨康为弟。', id='lc_run--019e2558-f1ba-7c42', ),
HumanMessage(content='郭靖比杨康先出生几个月?', , id='e5336c6b-d60f-44bf'),
AIMessage(content='根据提供的文本,郭靖比杨康先出生**一个月**。', id='lc_run--019e2559-4511-7403', )]}, next=(), config={'configurable': {'thread_id': '5', 'checkpoint_ns': '', 'checkpoint_id': '1f14f650-aa3c-6440'}}, metadata={'k': 20, 'step': 7, 'top_n': 3, 'source': 'update', 'parents': {}}, created_at='2026-05-17T07:18:01.399182+00:00', parent_config={'configurable': {'thread_id': '5', 'checkpoint_ns': '', 'checkpoint_id': '1f14f650-3615-6440'}}, tasks=(), interrupts=())可以看到,最先发生的两条已经被删除。此时如果用户接着追问"基于当前保留下来的对话记忆,继续回答:郭靖比杨康先出生几个月?" 模型会答出"郭靖比杨康先出生一个月。"
再来一轮新的对话之后,checkpoint 里的状态变成:
StateSnapshot(values={'messages': [ToolMessage(content='杨康边哭边说,涕泪滂沱,断断续续地道:......', name='retrieve_context', id='69d27ef5-2a11-4e2a', tool_call_id='call_7cdad6b1a9394482846227', artifact=), AIMessage(content='郭靖和杨康是结义兄弟。两人在郭啸天灵前对拜八拜,结为兄弟,郭靖先出世一个月,为兄,杨康为弟。', id='lc_run--019e2558-f1ba-7c42', ), HumanMessage(content='郭靖比杨康先出生几个月?', , id='e5336c6b-d60f-44bf'), AIMessage(content='根据提供的文本,郭靖比杨康先出生**一个月**。', id='lc_run--019e2559-4511-7403', ), HumanMessage(content='基于当前保留下来的对话记忆,继续回答:郭靖比杨康先出生几个月?', , id='c052ef69-9484-4bba'), AIMessage(content='郭靖比杨康先出生**一个月**。', id='lc_run--019e2559-7b45-7282', )]}, next=(), config={'configurable': {'thread_id': '5', 'checkpoint_ns': '', 'checkpoint_id': '1f14f650-bae5-6332'}}, metadata={'k': 20, 'step': 10, 'top_n': 3, 'source': 'loop', 'parents': {}}, created_at='2026-05-17T07:18:03.146080+00:00', parent_config={'configurable': {'thread_id': '5', 'checkpoint_ns': '', 'checkpoint_id': '1f14f650-ab4b-608e'}}, tasks=(), interrupts=())以上完整示例代码可参见 Code/Chapter05/C04_short_memory_delete.py 文件。
2.3 记忆修剪:保留最近 N 条#
讲完删除,再看修剪。修剪的思路非常直接,预先设定一个数量上限(消息数或 token 数),当消息列表接近这个上限时自动裁剪多余部分,只留下需要的部分。不过按 token 截断其实不太直接——因为喂给模型的 token 远不止消息本身,还包括工具调用的 Schema、Tool ID、Tool Call 等附加信息。所以工程上更建议按消息数截断,思路如图2-2所示。
在图2-2 中展示的是"只保留最近 4 条,且系统消息始终保留,以及必须以用户消息开头"的策略。可以看出,修剪和删除策略有相似之处,但修剪更灵活——可以精确控制保留什么、丢弃什么。
LangGraph 里提供了 trim_messages 来做这件事。只需要传两个关键参数——“保留多少"和"保留哪一段”——就能完成一次修剪。先看代码:
1 if __name__ == '__main__':
2 messages = [
3 SystemMessage(content="你是一个问答助手。"),
4 HumanMessage(content="郭靖是谁?"),
5 AIMessage(content="郭靖是《射雕英雄传》的主角,出生于临安府牛家村。"),
6 HumanMessage(content="他的武功是谁教的?"),
7 AIMessage(content="郭靖先后师从江南七怪和洪七公,后又得到马钰道长的内功传授。"),
8 HumanMessage(content="黄蓉是谁?")]
9 trimmed = trim_messages(messages, max_tokens=4, token_counter=len,
10 strategy="last", start_on="human", end_on=("human", "tool"),
11 include_system=True)
12 for msg in trimmed:
13 print(f"[{msg.type}]: {msg.content}")在上述代码中,第2~8行是模拟的一个交互对话消息列表。第9行trim_messages 便是用于对历史消息进行修剪,其中 max_tokens 用于指定最大 Token 数或历史消息数,取决于参数 token_counter 的设定。例如这里 token_counter=len 时,max_tokens表示最大历史消息数。第10行 strategy="last" 表示修剪时的丢弃策略,有 first 和 last 两种策略,即保留前面 max_tokens 部分还是后面 max_tokens 部分; start_on 和 end_on 分别表示必须以什么样的消息类型开始或结束,为 None 则无此限制。第11行 include_system 表示是否一直保留系统消息,即 SystemMessage。通常来说会将 include_system 设定为 True,因为系统消息中一般会包含固定的指令。
对于上述示例来说一共需要保留4条消息记录,且保留策略为 last,同时除了系统消息外第1条必须为用户提问,因此最终输出结果为:
[system]: 你是一个问答助手。
[human]: 他的武功是谁教的?
[ai]: 郭靖先后师从江南七怪和洪七公,后又得到马钰道长的内功传授。
[human]: 黄蓉是谁?如果把 start_on="human" 限制去掉,同时把 max_tokens 改成 2,结果就变成:
[system]: 你是一个问答助手。
[human]: 黄蓉是谁?以上完整示例代码可参见 Code/Chapter05/C05_shrot_memory_trim.py 文件。
2.4 记忆总结:滚动总结旧消息#
记忆删除和修剪虽然直接,但有一个代价——被裁掉的消息彻底没有了。如果用户早在几轮前提过的关键信息对当前问题依然重要,模型就再也看不到了。所以需要一种更友好的替代方案,这就是记忆总结(也叫消息摘要)。
记忆总结的思路不是丢弃旧消息,而是把它压缩成一段摘要保留下来——这样既释放了上下文空间,又没完全丢失历史信息。具体做法是,当消息列表积累到一定数量时触发一次摘要操作,让模型把当前的历史对话概括成一段文字存入全局状态,然后删掉大部分旧消息,只留最近若干条。流程如图2-3所示。
具体地,需要先在全局消息状态 MessagesState 中增加一个 summary 字段,用于保存历史消息的摘要总结,示例代码如下:
1 class State(MessagesState):
2 summary: str # 在 MessagesState 基础上新增摘要字段进一步,专门定义一个图节点来完成记忆总结这部分内容,代码如下:
1 def summarize_conversation(state: State):
2 summary = state.get("summary", "")
3 if summary:
4 summary_message = (
5 f"以下是目前为止对话的摘要:{summary}\n\n"
6 "请结合上方新的对话内容,对摘要进行补充和更新:")
7 else:
8 summary_message = "\n请对上方的对话内容生成一段摘要:"
9 messages = state["messages"][:-2] + [HumanMessage(content=summary_message)]
10 response = get_llm_model().invoke(messages)
11 delete_messages = [RemoveMessage(id=message.id) for message in state["messages"][:-2]]
12 return {"summary": response.content, "messages": delete_messages}在上述代码中,第2行用于取当前节点之前的历史总结内容。第3~8行是根据不同的情况构造提示词。第9~10行是只保留最后两条消息,然后对其余消息进行总结,可以发现每次触发总结时,都会把上一次的总结作为上下文,让模型在此基础上继续扩展,而不是从头重新总结,这种"滚动总结"的设计保证了历史信息不会彻底丢失,同时消息列表的长度始终保持在可控范围内。第11行是标记已经被总结的待删除记忆内容。
接着写一个最小可跑通的测试:
1 if __name__ == '__main__':
2 with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
3 ......
4 messages = [
5 SystemMessage(content="你是一个问答助手。"),
6 HumanMessage(content="郭靖是谁?"),
7 AIMessage(content="郭靖是《射雕英雄传》的主角,出生于临安府牛家村。"),
8 HumanMessage(content="他的武功是谁教的?"),
9 AIMessage(content="郭靖先后师从江南七怪和洪七公,后又得到马钰道长的内功传授。"),
10 HumanMessage(content="黄蓉是谁?")]
11 inputs = {"messages": messages}
12 for event in agent.stream(inputs, config=config):
13 for node, update in event.items():
14 print(f"#### 节点 {node} 处理完毕")
15 snapshot = agent.get_state(config)
16 print(f"========== 当前摘要 ==========\n{snapshot.values.get('summary', '')}")
17 print_memory(agent, config, "========== 总结并删除后的数据库记忆 ==========")根据 summarize_conversation 节点中的设定,上面前4条消息会被总结,然后保留最后两条消息在全局状态中。最终,上述代码运行后的出处结果类似如下:
#### 节点 summarize 处理完毕
========== 当前摘要 ==========
对话中,用户首先询问“郭靖是谁”,回答指出他是金庸武侠小说《射雕英雄传》的主角,出生于临安府牛家村;随后用户追问“他的武功是谁教的”,但该问题尚未得到回答。当前对话仅完成前两轮,信息停留在郭靖的身份介绍层面。
========== 总结并删除后的数据库记忆 ==========
snapshot: StateSnapshot(values={'messages': [AIMessage(content='郭靖先后师从江南七怪和洪七公,后又得到马钰道长的内功传授。', id='1e9a444a-ac01-4904'), HumanMessage(content='黄蓉是谁?', id='bfdaca1a-7a7a-44c6')], 'summary': '对话中,用户首先询问“郭靖是谁”...介绍层面。'})
读取到 thread_id=20260519 的短期记忆,共 2 条消息:
01. ai: 郭靖先后师从江南七怪和洪七公,后又得到马钰道长的内功传授。
02. human: 黄蓉是谁?从上述输出结果可以看出,summarize_conversation 节点确实只对前面4条消息进行了总结,同时在全局状态中完整的保留了最近的两条消息记录,以上完整示例代码可参见 Code/Chapter05/C06_shrot_memory_summary.py 文件。
到此,对于短期记忆管理到这里就讲完了。虽然短期记忆解决了"会话内记忆不丢失"的问题,但 想要 Agent 真正变得更"懂你",还得靠跨会话也能调用的记忆——也就是长期记忆。在下一节「Agent 长期记忆管理与持久化:PostgresStore 存储策略与语义、情景、程序记忆分类详解」中我们将继续介绍长期记忆怎么分类、怎么存以及如何实现跨会话复用。
引用#
[1] https://docs.langchain.com/oss/python/langgraph/add-memory