第 15 章 记忆管理
本章目标:治理"记太多"与"记不住"两个相反的问题。短期记忆靠裁剪(把消息历史控制住),长期记忆靠 Store(跨线程的用户画像)。前者的核心武器是 RemoveMessage,后者的核心武器是带命名空间的 KV 存储
本章目标:治理"记太多"与"记不住"两个相反的问题。短期记忆靠裁剪(把消息历史控制住),长期记忆靠 Store(跨线程的用户画像)。前者的核心武器是
RemoveMessage,后者的核心武器是带命名空间的 KV 存储。
15.1 记忆问题的两副面孔
系统运行数月后,两份故障报告摆在桌上,方向截然相反:
报告 A:上下文爆炸。HR 与评估助手的对话进行到第 40 轮,每轮 invoke 都把完整 messages 喂给模型——token 成本线性上涨,终于一轮超过模型上下文窗口,请求 4xx。
报告 B:金鱼记忆。候选人李思远三个月前投递过、当时评估结论"算法偏弱",这次再投递,系统从零开始评估——同一候选人,两个线程之间毫无记忆。
这是记忆问题的两副面孔:
| 问题 | 症状 | 病根 | 解法 |
|---|---|---|---|
| 记太多 | token 爆炸、成本失控 | 线程内消息无限累积 | 短期记忆裁剪 |
| 记不住 | 跨会话失忆 | 线程之间互相隔离(12 章设计使然) | 长期记忆 Store |
先澄清概念地图,避免"记忆"一词的滥用:短期记忆 = 单线程内的 messages 历史(跟着检查点走);长期记忆 = 跨线程的业务事实(跟着 Store 走)。两者机制完全不同,别混用。
15.2 短期记忆:为什么必须裁剪
第 12 章建立了"线程即会话":同 thread_id 的多次 invoke,messages 不断追加。追加是 add_messages 的天性,但它没有终点——不裁剪的对话图,token 消耗无上界。
裁剪的策略光谱,从粗到细:
- 滑窗截断:只保留最近 K 条。最粗暴,实现最简单。
- 按 token 预算截断:保留不超过 N token 的最近消息。比条数精确。
- 摘要压缩:把老消息压成一条摘要消息,保留近期原文。信息保留最好,代价是要调一次模型。
- 结构化剥离:工具调用的中间过程(AIMessage + ToolMessage 对)在对话结束后就无信息量了,可以整段删除。
工程实践通常是组合拳:结构化剥离 + 摘要压缩(旧消息摘要 + 近期原文 + 删工具中间态)。本章demo演示滑窗与摘要两种。
15.3 RemoveMessage:删除消息的原语
裁剪的落地手段是 add_messages 的第三个语义(前两个:追加、替换——见第 3 章):收到 RemoveMessage(id=...) 时删除对应消息。
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage, RemoveMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import InMemorySaver
class ChatState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
def chat(state: ChatState) -> dict:
from langchain_core.messages import AIMessage
return {"messages": [AIMessage("好的,已记录您的偏好")]}
# 独立的裁剪节点:图结构的一部分,而非节点内的隐藏动作
def trim(state: ChatState) -> dict:
KEEP = 4 # 保留最近 4 条
if len(state["messages"]) <= KEEP:
return {} # 不够长,无需裁剪
to_remove = state["messages"][:-KEEP] # 更早的全部删掉
return {"messages": [RemoveMessage(id=m.id) for m in to_remove]}
builder = StateGraph(ChatState)
builder.add_node("chat", chat)
builder.add_node("trim", trim)
builder.add_edge(START, "chat")
builder.add_edge("chat", "trim")
builder.add_edge("trim", END)
# 跨轮累积消息必须有 checkpointer(线程记忆的前提,见第 12 章)
graph = builder.compile(checkpointer=InMemorySaver())
from langchain_core.messages import HumanMessage
config = {"configurable": {"thread_id": "mem-001"}}
msgs = [HumanMessage(f"第 {i} 轮问题", id=f"h{i}") for i in range(1, 7)]
for m in msgs:
graph.invoke({"messages": [m]}, config)
final = graph.get_state(config).values["messages"]
print(len(final), [m.content for m in final])
# 4 ['第 5 轮问题', '好的,已记录您的偏好', '第 6 轮问题', '好的,已记录您的偏好']
# 第 1~4 轮被陆续裁掉:每轮追加"1 问 + 1 答"共 2 条,超过 4 条即裁掉最旧 2 条(恰好一问一答)
值得停下来体会的设计点:裁剪被建模为图的一个节点,而不是藏在 chat 节点内部的私活。收益立现——裁剪时机在图上可见(可以放在任何位置、可以单独重跑)、裁剪动作本身有检查点记录(裁错了可以时间旅行回看裁剪前的状态)。"控制流进图"的心法(第 4 章)同样适用于数据治理动作。
顺带两个细节:RemoveMessage 不是真的消息,只是一个删除指令(构造它不需要 content);删除时保持消息对的完整性——AIMessage 与它配套的 ToolMessage 要么都在要么都不在,删一半会让模型看到"有问无答"的残缺对话。
15.4 摘要压缩:删旧留魂
滑窗会丢失早期信息。更聪明的做法:把要删的老消息压缩成一条摘要再删。
import os
from typing import Annotated, TypedDict
from dotenv import load_dotenv
from langchain_core.messages import AnyMessage, RemoveMessage, HumanMessage, AIMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import InMemorySaver
load_dotenv()
llm = ChatOpenAI(model=os.getenv("LLM_MODEL", "gpt-4o-mini"), temperature=0)
class ChatState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
summary: str # 滚动摘要:短期记忆的"魂"
def condense(state: ChatState) -> dict:
KEEP = 4
if len(state["messages"]) <= KEEP:
return {}
old, recent = state["messages"][:-KEEP], state["messages"][-KEEP:]
# 老消息并入滚动摘要(调一次模型做压缩)
transcript = state.get("summary", "") + "\n".join(
f"{'用户' if isinstance(m, HumanMessage) else '助手'}:{m.content}" for m in old
)
new_summary = llm.invoke([
SystemMessage("将以下对话历史压缩为不超过 100 字的要点摘要,保留关键事实与偏好。"),
HumanMessage(transcript),
]).content
# 删旧 + 存魂
return {
"messages": [RemoveMessage(id=m.id) for m in old],
"summary": new_summary, # 注意 summary 是覆盖语义字段
}
def respond(state: ChatState) -> dict:
# 回复时:摘要作为 system 上下文 + 近期原文
history = (
([SystemMessage(f"此前对话摘要:{state['summary']}")] if state.get("summary") else [])
+ state["messages"]
)
reply = llm.invoke(history)
return {"messages": [reply]}
builder = StateGraph(ChatState)
builder.add_node("condense", condense)
builder.add_node("respond", respond)
builder.add_edge(START, "condense")
builder.add_edge("condense", "respond")
builder.add_edge("respond", END)
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "mem-002"}}
for i in range(1, 8):
graph.invoke({"messages": [HumanMessage(f"第 {i} 轮:了解我的技术栈偏好", id=f"h{i}")]}, config)
final = graph.get_state(config).values
print("摘要:", final["summary"])
print("消息数:", len(final["messages"]))
消息条数被压在滑窗内,关键事实进滚动摘要随轮次滚动——这是"删旧留魂"。生产级实现还要考虑:摘要调用本身的成本与失败处理(第 16 章)、摘要的最终长度上限(摘要自身也可能无限增长,需要"摘要的摘要"或硬截断)。
15.5 长期记忆:Store
现在治"金鱼记忆"。病根在 12 章的设计:线程之间互相隔离——这本身是对的(两场独立会话不该串味),跨线程需要的是另一套按"人/物"组织的存储:Store。
Store 是 LangGraph 的跨线程 KV 存储,两个关键设计:
- 命名空间(namespace):多维元组,天然的隔离维度。比如
("candidates", "李思远")——按候选人隔离;("users", "hr-42", "preferences")——按用户隔离。 - put/get/search 接口:写入、按键读、按值搜(语义检索,取决于后端实现)。
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
# 写:命名空间 + 键 + 值
store.put(
("candidates", "李思远"), # 命名空间:候选人维度
"assessment", # 键:这类事实的名称
{"conclusion": "算法偏弱,工程能力扎实", # 值:任意可序列化 dict
"history": ["2026-06 投递后端岗", "2026-08 复投"],
"score": 72},
)
# 读:命名空间 + 键
item = store.get(("candidates", "李思远"), "assessment")
print(item.value["conclusion"]) # 算法偏弱,工程能力扎实
# 列:该命名空间下全部条目
for item in store.search(("candidates", "李思远")):
print(item.key, item.value)
与 checkpointer 的分工是理解 Store 的关键:
| 维度 | Checkpointer(12 章) | Store(本章) |
|---|---|---|
| 存什么 | 状态快照(怎么跑的) | 业务事实(知道了什么) |
| 组织维度 | 线程(thread_id) | 命名空间(业务实体) |
| 生命周期 | 会话级 | 跨会话、跨线程 |
| 典型内容 | messages、中间状态 | 用户画像、偏好、历史结论 |
| 挂载方式 | compile(checkpointer=...) |
compile(store=...) |
15.6 图里使用 Store:节点读画像、跑完写回
Store 挂到图上后,节点通过 get_store() 访问(与 11 章 get_stream_writer 同族的上下文访问器;演示用同步接口,异步版本对应 aget/aput):
from typing import TypedDict
from langgraph.config import get_store
from langgraph.graph import StateGraph, START, END
from langgraph.store.memory import InMemoryStore
store = InMemoryStore() # 全新的空 Store:演示"首次投递 → 建立画像 → 复投读取"
def load_history(state: dict) -> dict:
store = get_store()
item = store.get(("candidates", state["name"]), "assessment")
prior = item.value if item else None
# 旧结论进状态,供评估节点参考
return {"prior_assessment": prior["conclusion"] if prior else "首次投递"}
def save_assessment(state: dict) -> dict:
store = get_store()
store.put(
("candidates", state["name"]),
"assessment",
{"conclusion": state["conclusion"], "score": state["score"],
"updated": "2026-08-28"},
)
return {}
# 组装:评估前读画像,收尾时写回
class ScreenState(TypedDict):
name: str
resume_text: str
prior_assessment: str
conclusion: str
score: int
builder = StateGraph(ScreenState)
builder.add_node("load_history", load_history)
builder.add_node("evaluate", lambda s: {
"conclusion": f"综合评估(参考历史:{s['prior_assessment']})",
"score": 80,
})
builder.add_node("save_assessment", save_assessment)
builder.add_edge(START, "load_history")
builder.add_edge("load_history", "evaluate")
builder.add_edge("evaluate", "save_assessment")
builder.add_edge("save_assessment", END)
graph = builder.compile(store=store) # 挂载 Store
# 第一次投递:Store 为空,建立画像
r1 = graph.invoke({"name": "李思远", "resume_text": "..."})
print(r1["prior_assessment"]) # 首次投递
# 第二次投递(完全独立的一次运行!):读到了 r1 写回的画像
r2 = graph.invoke({"name": "李思远", "resume_text": "..."})
print(r2["prior_assessment"]) # 综合评估(参考历史:首次投递)—— r1 的 save_assessment 写入
金鱼记忆治愈:两次 invoke 连 checkpointer 都没挂(完全不同的两次运行),但第二次"记得"第一次——信息流经 Store,而非线程。
生产形态的最后一块拼图是记忆的自动沉淀:不靠显式的 load/save 节点,而是评估结束后让模型自己提炼"本次学到的候选人事实"写入 Store,下次评估注入 prompt。模式与本章 demo 相同,只是写入内容由模型从对话里抽取(LangGraph 的 InMemoryStore 支持语义检索接口,跨候选人相似搜索也能做)。此外,多用户系统务必把用户/租户 ID 编进命名空间第一维——记忆隔离是安全边界,串了画像比丢了画像严重得多。
常见坑与排错
-
滑窗裁剪切断消息对。删除时把 AIMessage 删了、配套的 ToolMessage 留着(或反之),模型看到残缺对话,下一轮请求可能直接被服务端拒绝(tool 消息必须紧跟对应 assistant 消息)。裁剪逻辑要成对处理。
-
摘要点缀式调用拖慢每轮。condense 节点每轮都跑、每次都调模型——多数轮次根本没触发裁剪阈值。先判长度(demo 里的
if len(...) <= KEEP: return {}),达标才压缩。 -
把 Store 当 checkpointer 用。往 Store 里存 messages 全量快照、按 thread_id 组织——重新发明一个劣质 checkpointer。分工记牢:过程归 checkpointer,事实归 Store。
-
命名空间设计随意。
("memories", "default")这种没有业务维度的命名空间,多用户一上线就是数据大锅粥。第一维永远是隔离边界(用户/租户/候选人),先想清楚"谁能看到这条记忆"再定 namespace。 -
Store 里存大对象。简历全文、整个对话历史塞进 value——Store 是放"提炼后的事实"的(结论、偏好、标签),不是放原始资料的仓库。原始资料放对象存储,Store 存引用。
-
以为 get_store() 随处可用。与 get_stream_writer 同理,依赖执行上下文——在节点函数体内用。图没挂 store 就调用会拿到 None 或报错,编译时的
compile(store=...)别漏。 -
记忆污染。一次误评估的结论写进 Store,此后每次评估都被带偏。长期记忆要有置信度/时间戳概念(覆盖更新时带上
updated),重要决策前允许模型质疑旧记忆(prompt 里写明"历史结论仅供参考")。
动手练习
-
扩展 15.3 的 trim 节点:删除老消息前先检查它们是否含未完成的工具调用对(AIMessage 有 tool_calls 而其后没有配对的 ToolMessage),成对删除,绝不留半对。
-
给 15.4 的摘要图加"摘要上限":summary 超过 200 字时,对摘要本身再压缩一次。思考:这算不算无限递归?什么条件下会稳定下来?
-
多用户画像隔离实验:Store 命名空间用
("candidates", name),分别对"李思远""王敏"各跑一次评估,验证画像互不串扰。再故意把第一维改成固定值("candidates", "all"),观察污染后果。
本章速查
| API / 概念 | 一句话 |
|---|---|
| 短期记忆 | 单线程内的 messages 历史,随检查点走 |
| 长期记忆 | 跨线程业务事实,随 Store 走 |
RemoveMessage(id=...) |
add_messages 的删除原语 |
| 裁剪节点化 | trim/condense 做成图节点:可见、可重跑、有检查点 |
| 滑窗 vs 摘要 | 滑窗删旧,摘要删旧留魂(滚动 summary 字段) |
store.put(namespace, key, value) |
写入(namespace 是元组,第一维是隔离边界) |
store.get / search |
按键读 / 按命名空间列(支持语义检索) |
compile(store=...) + get_store() |
挂载与节点内访问 |
| 与 checkpointer 分工 | 过程归线程(快照),事实归命名空间(KV) |
| 安全铁律 | 命名空间第一维 = 用户/租户/实体隔离边界 |