首页/LangGraph 框架指南/第 15 章 记忆管理
LangGraph 框架指南 · 第 15 章

第 15 章 记忆管理

本章目标:治理"记太多"与"记不住"两个相反的问题。短期记忆靠裁剪(把消息历史控制住),长期记忆靠 Store(跨线程的用户画像)。前者的核心武器是 RemoveMessage,后者的核心武器是带命名空间的 KV 存储

约 22 分钟8,699 字4 个代码示例

本章目标:治理"记太多"与"记不住"两个相反的问题。短期记忆靠裁剪(把消息历史控制住),长期记忆靠 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 消耗无上界。

裁剪的策略光谱,从粗到细:

  1. 滑窗截断:只保留最近 K 条。最粗暴,实现最简单。
  2. 按 token 预算截断:保留不超过 N token 的最近消息。比条数精确。
  3. 摘要压缩:把老消息压成一条摘要消息,保留近期原文。信息保留最好,代价是要调一次模型。
  4. 结构化剥离:工具调用的中间过程(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 存储,两个关键设计:

  1. 命名空间(namespace):多维元组,天然的隔离维度。比如 ("candidates", "李思远") ——按候选人隔离;("users", "hr-42", "preferences") ——按用户隔离。
  2. 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 编进命名空间第一维——记忆隔离是安全边界,串了画像比丢了画像严重得多。

常见坑与排错

  1. 滑窗裁剪切断消息对。删除时把 AIMessage 删了、配套的 ToolMessage 留着(或反之),模型看到残缺对话,下一轮请求可能直接被服务端拒绝(tool 消息必须紧跟对应 assistant 消息)。裁剪逻辑要成对处理。

  2. 摘要点缀式调用拖慢每轮。condense 节点每轮都跑、每次都调模型——多数轮次根本没触发裁剪阈值。先判长度(demo 里的 if len(...) <= KEEP: return {}),达标才压缩。

  3. 把 Store 当 checkpointer 用。往 Store 里存 messages 全量快照、按 thread_id 组织——重新发明一个劣质 checkpointer。分工记牢:过程归 checkpointer,事实归 Store。

  4. 命名空间设计随意。("memories", "default") 这种没有业务维度的命名空间,多用户一上线就是数据大锅粥。第一维永远是隔离边界(用户/租户/候选人),先想清楚"谁能看到这条记忆"再定 namespace。

  5. Store 里存大对象。简历全文、整个对话历史塞进 value——Store 是放"提炼后的事实"的(结论、偏好、标签),不是放原始资料的仓库。原始资料放对象存储,Store 存引用。

  6. 以为 get_store() 随处可用。与 get_stream_writer 同理,依赖执行上下文——在节点函数体内用。图没挂 store 就调用会拿到 None 或报错,编译时的 compile(store=...) 别漏。

  7. 记忆污染。一次误评估的结论写进 Store,此后每次评估都被带偏。长期记忆要有置信度/时间戳概念(覆盖更新时带上 updated),重要决策前允许模型质疑旧记忆(prompt 里写明"历史结论仅供参考")。

动手练习

  1. 扩展 15.3 的 trim 节点:删除老消息前先检查它们是否含未完成的工具调用对(AIMessage 有 tool_calls 而其后没有配对的 ToolMessage),成对删除,绝不留半对。

  2. 给 15.4 的摘要图加"摘要上限":summary 超过 200 字时,对摘要本身再压缩一次。思考:这算不算无限递归?什么条件下会稳定下来?

  3. 多用户画像隔离实验: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)
安全铁律 命名空间第一维 = 用户/租户/实体隔离边界