LangChain 第8章 记忆系统:短期/长期记忆与 PostgreSQL 持久化

目录

**摘要:**本文系统梳理 LangChain 第 8 章记忆系统:从短期记忆(InMemorySaver、PostgreSQL checkpointer)与长期记忆(向量库、知识图谱)的区分,到消息治理三策(裁剪、删除、摘要),再到生产级落地(PostgresSaver 持久化、断点续跑、记忆清理与合规),并附代码实战、常见坑位、面试考点与课后练习,帮助读者从原理到实战完整掌握 Agent 记忆设计。

LangChain 第8章 记忆系统:短期/长期记忆与 PostgreSQL 持久化

一句话总结:大模型本身无状态,短期记忆靠 checkpointer/saver 维持当前会话、长期记忆靠向量库等跨会话存储;记忆治理的关键不是全留,而是裁剪、删除与摘要三策并用。

📎 参考资料一:李沐深度学习191集课程全解析(模块拆解、学习路径)

李沐深度学习191集课程全解析:模块拆解、学习路径-CSDN博客

📎 参考资料二:吴恩达《面向开发者的提示词工程》

吴恩达《面向开发者的提示词工程》-CSDN博客

📎 参考资料三:吴恩达 MCP 教程(Model Context Protocol)

吴恩达 MCP 教程(Model Context Protocol)(一)-CSDN博客

📎 参考资料四:多模态大模型教程学习笔记(ViT · CLIP · SAM · GLIP · Stable Diffusion)

多模态大模型教程学习笔记 --- ViT · CLIP · SAM · GLIP · Stable Diffusion

说明:本章本地仅保留 91--95 集视频目录、无字幕文件,以下内容据课程集目录与 LangChain 1.x 官方体系整理,具体代码以课件/视频为准。

本章可视化图谱

📎 知识图谱在线链接(点击打开 / 一键复制 / 分享他人):

https://mcnj5s80gkfh.doubaoapps.com/app/app_17fqgznhq5u

一、本章定位

第 86--101 集为 Agent 补上记忆。大模型本身是无状态的,不会记住上一轮对话;记忆是智能体四要素之一,与工具并列为压缩后最核心的两件事。记忆分两类:短期记忆维持当前会话连续性,长期记忆实现跨会话、跨用户的知识沉淀。

二、短期记忆与长期记忆

类型 载体 特点 / 用途
短期记忆 InMemorySaver(内存) 重启即丢,适合本地演示与开发调试
短期记忆·持久化 PostgreSQL checkpointer(92) 消息落库、可恢复,适合生产与多用户
长期记忆 向量库 / 知识图谱 / 外部数据库 跨会话按语义召回历史事实与用户偏好

在 LangGraph 式 Agent 中,记忆通过 checkpointer(检查点保存器)接入 create_agent,并用 thread_id 区分不同会话;同一 thread_id 的多轮调用会自动携带此前的状态。

三、PostgreSQL 持久化落地(91--93)

  1. 第 91 集 远程服务器连接额外操作:云服务器的安全组、端口、数据库连接串等;没有云服务器时可先用本地或内存版跑通。

  2. 第 92 集 安装配置 PostgreSQL,把 checkpointer 的后端从内存换成 PostgreSQL,实现短期记忆持久化。

  3. 把 checkpointer 传给 create_agent,用 thread_id 做会话隔离,消息即可落库、重启后可恢复。

  4. 第 93 集 对比两种方式:内存版开发快但重启丢失,PostgreSQL 版生产稳、重启不丢、支持多用户。

四、消息治理三策(94--95)

策略 做法 目的
消息裁剪 Trim(94) 保留 system 与最近 N 轮,丢弃更早历史 控制上下文长度与成本
消息删除(95) 按规则清理,如删除冗长的工具返回消息 给上下文瘦身
消息摘要 Summarization(95) 把旧消息压缩成一段摘要保留要点 兼顾记忆连续性与 token

记忆与第 7 章中间件协同:每轮消息累积,超过 token 阈值时由 ContextEditing / Summarization 等中间件触发裁剪、删除或摘要;同一 thread_id 维持会话连续性。

五、学习与部署提示

  • 没有云服务器时,先用 InMemorySaver 把记忆流程跑通,再平滑替换为 PostgreSQL。

  • 记忆不是把所有历史都塞给模型------该留的留、该删的删、该总结的总结,盲目全量只会爆 token、稀释重点。

  • 长期记忆通常与第 9 章 RAG 共用向量数据库:把用户偏好、历史结论向量化后按语义召回。

六、知识点速查

主题 要点
短期·内存 InMemorySaver,重启丢失,用于调试
短期·持久化 PostgreSQL checkpointer(92),thread_id 隔离会话
长期记忆 向量库 / 知识图谱 / 外部 DB,跨会话语义召回
治理三策 裁剪(94)、删除、摘要(95)
部署 91 集云服务器连接;无服务器先跑内存版

深度拓展:本章完整知识体系与实战进阶

深度拓展一:核心原理与源码解读

大语言模型(LLM)本身是无状态(stateless) 的:每一次调用都是一次独立的前向计算,模型权重不会因为上一轮对话而改变。这意味着如果我们只把当前这一句用户输入塞给模型,它根本不记得你三句话之前说过"我叫小明"。要让 Agent 表现得像一个"记得住事"的对话伙伴,就必须在模型之外加一层记忆(Memory):把历史消息存下来,在每一轮调用前重新拼回 prompt,调用后再把新产生的消息追加进去。

LangChain 1.x 把"记忆"这件事完全收敛到了 **LangGraph 的 Checkpointer(检查点)**机制上。理解这一点非常重要:在旧版 LangChain 里你会看到 ConversationBufferMemory、ConversationSummaryMemory、ConversationBufferWindowMemory 一大堆 Memory 类,它们在 1.0 里基本都被废弃了,取而代之的是一个统一的状态图(StateGraph)+ 检查点持久化模型。所谓"短期记忆",本质上就是 state 里的 messages 列表;所谓"长期记忆",本质上就是把 messages 里提炼出的事实写进外部存储(数据库、向量库)。

下面这张表把本章涉及的几个核心概念串起来:

概念 本质 典型实现
短期记忆(Short-term Memory) 当前会话内的 messages 列表,活在上下文窗口里 state"messages"、trim_messages 裁剪
长期记忆(Long-term Memory) 跨会话保存的用户画像、事实、偏好,落到外部存储 PostgreSQL 表、向量库、用户画像库
Checkpointer 把整个 state(含 messages)按 thread_id 序列化保存 InMemorySaver、PostgresSaver
thread_id 会话隔离的主键,同一 thread_id 共享同一份 state config={"configurable": {"thread_id": "user-123"}}

从源码角度看,一次带记忆的 Agent 调用大致走这几步:

  1. 你调用 agent.invoke({"messages": [HumanMessage(content="你好")]}, config={"configurable": {"thread_id": "u1"}})。

  2. LangGraph 在图运行前,先用 checkpointer 按 thread_id 把上一次保存的 state 读出来;如果是新会话,就用初始 state。

  3. 把本次输入的新消息 append 到 state"messages" 后面。

  4. 进入节点:模型节点拿到 messages 拼进 prompt,调用 LLM,产出 AIMessage;如果模型要求调用工具,就进入工具节点执行,结果以 ToolMessage 回到 messages。

  5. 图跑完一轮后,checkpointer 把最新的 state 整体 dump 一份,按 thread_id 存回存储。下一轮再调时,这份 dump 就成为新的起点。

这就是"记忆"的全部秘密:没有任何魔法,就是把 messages 列表持久化,下次再读出来拼回去。理解了这一点,后面讲的窗口裁剪、摘要记忆、PostgreSQL 持久化都只是"怎么存"和"存多少"的变体。

下面这段伪代码帮你把整个流程在脑子里跑一遍:

复制代码

# 简化版:checkpointer 在每次节点执行前后做了什么(仅示意原理) def should_checkpoint_before_each_node(): # 图开始执行时: saved_state = checkpointer.get(config) # 按 thread_id 读上一次的快照 state = saved_state if saved_state else initial_state() # 执行用户这次的输入 state["messages"].append(HumanMessage("你好")) for node in graph_nodes: state = node(state) # 模型节点 / 工具节点依次跑 checkpointer.put(config, state) # 每个节点跑完都存一次(支持断点续跑) return state["messages"][-1]

注意最后一行:checkpointer 不只是在整个图跑完才存,而是每个节点执行完都存一次。这意味着如果 Agent 在调用某个工具时崩了,你下次 resume 时可以从崩溃点继续,而不是从头再来。这在生产环境(比如工具调用要花 30 秒、还可能超时重试)非常关键。

深度拓展二:代码实战------从最小可用到生产级

下面我们从最简的 InMemorySaver 多轮对话开始,一步步演进到 PostgresSaver 持久化版本。

示例 1:InMemorySaver 最小多轮对话

python 复制代码
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
@tool
def get_weather(city: str) -> str:
"""查询某城市天气"""
return f"{city} 今天晴,25 度"
1. 准备模型和工具
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [get_weather]
2. 关键:传入一个 InMemorySaver 作为 checkpointer
checkpointer = InMemorySaver()
3. create_agent 时把 checkpointer 塞进去
agent = create_agent(
model=model,
tools=tools,
checkpointer=checkpointer,
)
4. 用同一个 thread_id 跑两轮对话
config = {"configurable": {"thread_id": "user-001"}}
第一轮:用户说自己叫小明,在上海
r1 = agent.invoke(
{"messages": [HumanMessage(content="我叫小明,我在上海")]},
config=config,
)
print("第一轮 AI:", r1["messages"][-1].content)
第二轮:用户不提名字和城市,看 Agent 记不记得
r2 = agent.invoke(
{"messages": [HumanMessage(content="我这儿天气怎么样?")]},
config=config,
)
print("第二轮 AI:", r2["messages"][-1].content)
预期:模型会调用 get_weather("上海"),因为 messages 里留着"上海"
5. 验证:把 state 打印出来看 messages 到底有几条
snapshot = agent.get_state(config)
print("当前会话消息数:", len(snapshot.values["messages"]))
for m in snapshot.values["messages"]:
print(type(m).name, "->", m.content[:50])

运行上面这段代码你会发现:第二次 invoke 时,你只传了一句"我这儿天气怎么样?",但 Agent 自动调用了 get_weather("上海")------因为 checkpointer 把第一轮的 HumanMessage 也带进了 prompt。这就是记忆生效的证据。

示例 2:换 thread_id 验证会话隔离

python 复制代码
# 同一个 agent、同一个 checkpointer,但换一个 thread_id
config2 = {"configurable": {"thread_id": "user-002"}}
r3 = agent.invoke(
    {"messages": [HumanMessage(content="我这儿天气怎么样?")]},
    config=config2,
)
# 这次模型不知道你在哪个城市,会反问你"请问你在哪个城市?"
# ------因为 thread_id 不同,state 是全新的一份,user-001 的记忆不会串过来
print("新会话 AI:", r3["messages"][-1].content)
想验证一下:分别查两个 thread 的 state
s1 = agent.get_state({"configurable": {"thread_id": "user-001"}})
s2 = agent.get_state({"configurable": {"thread_id": "user-002"}})
print("user-001 消息数:", len(s1.values["messages"]))
print("user-002 消息数:", len(s2.values["messages"]))

thread_id 就是会话隔离的唯一钥匙。生产环境通常用"用户 ID + 会话 ID"拼出来,比如 f"{user_id}-{session_id}",这样同一个用户的不同聊天窗口互不干扰,不同用户之间也绝对串不了记忆。

示例 3:用 trim_messages 控制短期记忆窗口

当对话变长,messages 会越来越多,直接塞进 prompt 会爆 token 上限。最常用的做法是用 trim_messages 做滑动窗口裁剪:保留最近 N 轮,把更早的消息丢掉,或者按 token 预算裁。

python 复制代码
from langchain_core.messages import trim_messages
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4o-mini")
裁剪器:最多保留 60 条消息,按模型的 tokenizer 算预算
trimmer = trim_messages(
max_tokens=4000,          # 给历史消息留的 token 预算
strategy="last",          # 保留最近的
token_counter=model,      # 用模型自己的 tokenizer 数 token
include_system=True,      # 永远保留 system message
allow_partial=False,
start_on="human",         # 从一条 human message 开始算,避免切在 tool 调用中间
)
假设 messages 是 checkpointer 里读出来的完整历史
你在拼 prompt 前先 trim 一下:
trimmed = trimmer.invoke(messages)
print("原始消息数:", len(messages), " 裁剪后:", len(trimmed))

在 create_agent 里更优雅的做法是把 trim 包成一个预模型钩子(model wrapper / middleware) :在每次调用 LLM 之前,自动对 messages 做一次裁剪。LangChain 1.x 提供了 langchain.agents.middleware 机制,你可以写一个自定义 middleware,在模型节点执行前把 state"messages" 修剪到预算内。这样业务代码完全不用感知裁剪逻辑。

示例 4:摘要记忆(Summary Memory)

窗口裁剪的缺点是"老消息直接丢",丢了就真没了。摘要记忆的思路是:不是丢,而是用 LLM 把老历史压缩成一段摘要,把摘要留在 prompt 里。这样既省 token,又保留了大意。

python 复制代码
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, RemoveMessage
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4o-mini")
假设 state 里有一个 summary 字段,和 messages 并列
当 messages 超过阈值时,把前面一半摘要掉,然后从 messages 里删掉
def summarize_old_messages(state):
messages = state["messages"]
if len(messages) <= 10:
return {}  # 还不长,不摘要
# 把最老的一半消息拿来摘要
old = messages[: len(messages) // 2]
recent = messages[len(messages) // 2 :]
existing_summary = state.get("summary", "")
prompt = (
f"已有摘要:{existing_summary}\n\n"
f"请把以下新的对话内容合并进摘要,保持简洁:\n"
+ "\n".join(f"{type(m).name}: {m.content}" for m in old)
)
new_summary = model.invoke([HumanMessage(content=prompt)]).content
删除被摘要过的旧消息,把新摘要写回 state
delete_ops = [RemoveMessage(id=m.id) for m in old]
return {
"messages": delete_ops + [SystemMessage(content=f"对话摘要:{new_summary}")],
"summary": new_summary,
}</code></pre>
把这个函数作为一个普通节点挂进 StateGraph,或者用 middleware 在每次模型调用前判断一次,就实现了"滚动摘要"。生产里一般设定:当 messages 总 token 数超过 60% 上下文窗口时触发摘要。
示例 5:PostgresSaver 持久化(生产版)
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.postgres import PostgresSaver
连接串:postgres://用户名:密码@主机:端口/数据库
DB_URI = "postgresql://user:password@localhost:5432/langgraph"
PostgresSaver 是上下文管理器,用 with 包住
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
第一次跑需要建表(checkpoints、checkpoint_blobs、checkpoint_writes 等几张表)
checkpointer.setup()
agent = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[],
checkpointer=checkpointer,
)
config = {"configurable": {"thread_id": "prod-user-42"}}
agent.invoke(
{"messages": [HumanMessage(content="记住我的生日是 3 月 5 号")]},
config=config,
)
进程重启后......
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
agent = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[],
checkpointer=checkpointer,
)
用同一个 thread_id,历史消息从 PostgreSQL 里读回来了
r = agent.invoke(
{"messages": [HumanMessage(content="我生日几号?")]},
config=config,
)
print(r["messages"][-1].content)  # 能答出 3 月 5 号
PostgresSaver 内部会建三张关键表:checkpoints(每个 thread 的最新快照)、checkpoint_blobs(消息内容等大对象)、checkpoint_writes(节点级别的写入记录,用于断点续跑)。setup() 命令会自动建表,你不用手写 SQL。

深度拓展三:常见坑与调试技巧

下面是 10 个真实踩坑案例,每个按"现象→原因→解决"来讲。

**坑 1:跨轮对话模型"失忆"。**现象:第二次 invoke 时模型完全不记得第一次说过什么。原因:create_agent 时忘了传 checkpointer,或者两次 invoke 用了不同的 thread_id。解决:确认 checkpointer 传了,且两次 config 里的 thread_id 字符串完全一致。

**坑 2:InMemorySaver 重启后全没了。**现象:Python 进程一重启,所有对话历史消失。原因:InMemorySaver 存在内存字典里,进程一退就没。解决:生产环境必须换 PostgresSaver(或 RedisSaver),InMemorySaver 只适合本地调试和单元测试。

**坑 3:messages 越攒越长,最后 token 超限报错。**现象:跑了十几轮后突然报 "context length exceeded"。原因:没有任何裁剪机制,messages 列表无限增长。解决:加 trim_messages 做窗口裁剪,或者挂摘要节点。

**坑 4:thread_id 用了固定字符串 "1"。**现象:所有用户共享同一份记忆,A 用户说"我叫张三",B 用户进来 Agent 也喊他张三。原因:thread_id 写死了。解决:thread_id 必须按用户+会话动态生成,比如 f"u:{user_id}:s:{session_id}"。

坑 5:PostgresSaver 没调 setup()。 现象:第一次写入报 "relation checkpoints does not exist"。原因:数据库里还没建表。解决:部署时先跑一次 checkpointer.setup(),或者用迁移工具把 DDL 纳入版本管理。

**坑 6:连接串里密码带 @ 符号没转义。**现象:连 PostgreSQL 报解析错误。原因:URL 里 @ 是分隔符,密码里的 @ 会让连接串解析错乱。解决:对密码做 URL encode(@ → %40),或者用连接参数字典而不是 URI。

坑 7:裁剪裁掉了 ToolMessage,导致模型状态错乱。 现象:trim 完之后模型开始胡言乱语,或者报 "tool message without prior tool call"。原因:trim_messages 把一条 AIMessage(tool_calls=...) 留下了,但对应的 ToolMessage 被裁掉了。解决:trim_messages 加 start_on="human",保证裁剪不会切在 tool 调用链路中间。

**坑 8:想清空记忆但不知道怎么清。**现象:用户说"我们重新聊吧",Agent 还在翻旧账。原因:你没法直接删 checkpointer 里的 state。解决:给用户分配一个新的 thread_id,老的 thread_id 自然就不再用了;或者调用 checkpointer 的删除接口清理旧快照。

**坑 9:摘要把关键细节"摘要没了"。**现象:对话聊了半小时,最后问"我刚才说的订单号是多少",模型答不上来。原因:摘要把精确的数字、ID 给概括掉了。解决:对需要精确保留的信息(订单号、日期、人名),不要只靠摘要,应该在对话早期就把这些结构化事实写到长期记忆表(PostgreSQL 的 user_facts 表)里,每轮注入 prompt。

**坑 10:并发请求打同一个 thread_id 导致 state 竞争。**现象:多端同时发消息,偶尔出现消息顺序错乱。原因:checkpointer 是读-改-写模型,并发写同一 thread 会互相覆盖。解决:同一 thread_id 的请求要串行化(加分布式锁),或者用 LangGraph 的 task 机制把并发操作排队。

深度拓展四:最佳实践与性能优化

把记忆从 demo 推向生产,下面这些建议值得抄作业:

  • 分层记忆:短期记忆用 messages 窗口(最近 10~20 轮),长期记忆用外部数据库(用户画像、偏好、历史订单)。每轮 prompt 只注入"当前会话窗口 + 从长期库检索到的相关事实",而不是把所有历史都塞进去。

  • token 预算治理:给 messages 留 60% 上下文窗口,给 system prompt 留 10%,留给工具返回和模型输出 30%。超过预算就触发 trim 或摘要。

  • 工具返回也要裁剪:工具(比如查数据库、爬网页)返回的内容动辄几千 token,直接留在 messages 里非常浪费。工具节点返回后立刻做一次截断或摘要,再写回 state。

  • 检查点别存太大:PostgresSaver 默认会把整个 state 序列化进 blob 表,state 里如果存了大文件 base64,数据库会膨胀很快。大对象(文件、图片)应该存对象存储,state 里只留 URL。

  • 定期归档老会话:一个 thread_id 跑了几个月,checkpoints 表里堆了几十个版本。按时间归档或删除老 thread,只保留最近 N 天的活跃会话。

  • 可观测性:每次 invoke 打印 thread_id、messages 数量、本次消耗 token 数,接到监控系统上。用户反馈"Agent 变笨了"时,你能立刻查到是不是记忆裁剪太狠。

  • 不要滥用长期记忆:不是所有对话都值得存。用户随口抱怨一句"今天天气真差",没必要写进 user_facts。让 LLM 自己判断哪些事实值得长期保留(用一个结构化输出:IsImportant: bool, Fact: str)。

  • remember/forget 工具 :给 Agent 加两个工具 remember_fact(key, value) 和 forget_fact(key),让模型在对话中主动把关键事实写进长期库,比事后摘要更准。

深度拓展五:面试高频考点

Q1:为什么说 LLM 是无状态的?这对 Agent 设计意味着什么?

A:LLM 每次前向传播只看本次 prompt 里的 token,权重不会更新,所以它天然不记得上一次调用。这意味着所有"记忆"都必须在 prompt 层面显式构造------把历史消息拼进去,或者用外部检索把相关事实拉出来拼进去。Agent 框架(LangGraph)的核心工作之一就是帮你管理这份历史。

Q2:短期记忆和长期记忆的本质区别是什么?

A:短期记忆活在当前上下文窗口里,本质就是 messages 列表,受 token 上限约束;长期记忆活在外部存储(数据库、向量库)里,跨会话持久,用的时候再检索进 prompt。短期记忆是"最近聊了啥",长期记忆是"这个用户是谁、他喜欢什么"。

Q3:InMemorySaver 和 PostgresSaver 怎么选?

A:InMemorySaver 存在进程内存里,重启即丢,零依赖,适合本地开发和单元测试;PostgresSaver 把快照写进 PostgreSQL,进程重启、多实例部署都能恢复,是生产环境的标准选择。小规模也可以用 RedisSaver。

Q4:thread_id 是怎么实现会话隔离的?

A:checkpointer 把每个 thread_id 的 state 存成独立的 key。invoke 时 config 里带哪个 thread_id,就加载哪份 state,跑完再写回同一个 key。不同 thread_id 的 state 完全互不可见。

Q5:trim_messages 的 strategy="last" 和 strategy="first" 有什么区别?

A:last 是保留最近的 N 条(最常用,因为最新信息最重要),first 是保留最老的 N 条(比如保留开头介绍自己的几句)。实际工程里通常是"保留 system + 最早 1 轮自我介绍 + 最近 N 轮"的混合策略。

Q6:摘要记忆相比窗口裁剪的优劣?

A:优点是省 token 的同时保留了历史大意,不会"聊到后面忘了开头";缺点是摘要会丢失精确细节(数字、ID、日期),而且每轮摘要本身要多调一次 LLM,增加延迟和成本。生产里一般两者结合:精确事实走长期库,大意走摘要,最近细节走窗口。

Q7:checkpointer 为什么每个节点都存一次,而不是整个图跑完才存?

A:为了支持断点续跑。如果 Agent 在调用某个耗时工具时崩了,下次 resume 可以从崩溃的那个节点继续,而不是重新跑一遍前面已经成功的步骤。这对长任务(比如要调 5 个工具、跑几分钟)非常重要。

Q8:messages 里为什么会有 ToolMessage?它和 AIMessage 是什么关系?

A:模型决定调用工具时,会产出一个带 tool_calls 字段的 AIMessage;框架执行工具后,把结果包成 ToolMessage(带对应的 tool_call_id)追加到 messages。下一轮模型看 messages 时,必须看到 AIMessage(tool_calls) 和后面紧跟的 ToolMessage 配对,否则会报协议错误。

Q9:怎么让 Agent"忘记"某段记忆?

A:三种方式:(1) 给用户换新的 thread_id,老会话自然不再加载;(2) 用 RemoveMessage 从 state 里删掉指定 id 的消息;(3) 长期记忆表里按 key 删行。生产里最常见的是方案 (1),配合"开启新会话"按钮。

Q10:多实例部署时,InMemorySaver 为什么不能用?

A:InMemorySaver 存在单进程内存里。如果你的 Agent 服务部署了 3 个副本,用户第一次请求打到 A 实例,第二次打到 B 实例,B 实例的内存里没有这个 thread_id 的 state,就会当成新会话。必须用外部共享存储(PostgreSQL、Redis)做 checkpointer,所有实例读同一份数据。

Q11:记忆和 prompt 窗口是什么关系?

A:记忆不是无限塞进 prompt 的,它必须受模型上下文窗口约束。你在 prompt 里能放多少历史,取决于窗口还剩多少 token。所以记忆系统本质是一个"在有限窗口里,塞哪些历史最有价值"的问题------最近的全塞,老的摘要塞,相关的检索塞,不相关的丢。

深度拓展六:课后练习与思考

练习 1:写一个最小 Demo,用 InMemorySaver 跑 5 轮对话,每轮让用户输入一句话、Agent 回复一句。然后用 agent.get_state 打印出 messages 列表,观察 HumanMessage 和 AIMessage 是怎么交替排列的。思路提示:重点看每条消息的 type 和 content,理解 messages 是一个"全量历史"而不是"最近一条"。

练习 2:把练习 1 的 checkpointer 换成 PostgresSaver,跑完后 Ctrl+C 杀掉进程,重新启动脚本,用同一个 thread_id 再 invoke 一次,验证历史还在。思路提示:注意要先跑一次 setup(),并且连接串要改成你本地的 PostgreSQL。

练习 3:给 Agent 加一个 trim 逻辑:当 messages 总 token 数超过 2000 时,只保留最近 6 条 + system message。用 gpt-4o-mini 的 tokenizer 计数。思路提示:用 trim_messages,max_tokens=2000,include_system=True,start_on="human"。

练习 4:设计一个"长期记忆表":在 PostgreSQL 里建一张 user_facts(user_id, key, value, updated_at),写两个工具 remember_fact(key, value) 和 recall_fact(key),让 Agent 在对话中自动把"用户生日""订单号"这类事实写进去,下次提到相关问题时自动 recall。思路提示:工具函数里直接写 SQL,create_agent 把这两个工具和 checkpointer 一起传进去。

练习 5:思考一个问题------如果一个客服 Agent 跑了一年,同一个 thread_id 下积累了几千条消息,checkpoints 表会膨胀到多大?你会怎么设计归档策略?思路提示:从"活跃会话"和"历史会话"分离、冷热数据分层、老会话定期导出归档、thread_id 按月份分段等角度想。

练习 6:尝试实现一个"摘要触发"机制:当 messages 数超过 15 条时,把前 8 条摘要成一段 SystemMessage,然后从 messages 里删除那 8 条。跑一段长对话,观察 token 消耗是否显著下降。思路提示:参考正文示例 4 的 summarize_old_messages 函数,把它挂成图里的一个节点。

深度拓展补充:进阶案例与 FAQ

补充一:长对话中记忆衰减的工程实践

真实客服场景里,一个用户可能和 Agent 聊上几十轮,从"我的订单在哪"聊到"退款多久到账"再到"发票怎么开"。如果不对记忆做任何治理,messages 会无限膨胀,最终撞上下文窗口上限。这里展开讲三种生产级治理策略的取舍:

**策略一:硬窗口 + 摘要前置。**规则是:永远保留 system message、最近 N=10 条消息;更早的消息在超过阈值时自动摘要成一段 SystemMessage 塞在历史最前面。这样 prompt 结构固定为:system + 历史摘要 + 最近 10 条原始消息。优点是 token 消耗可预测,缺点是摘要本身会随对话越攒越长,需要定期把旧摘要和新对话再合并压缩。

**策略二:结构化事实抽取。**每轮对话后用一个轻量 LLM 调用,从这一轮里抽出"值得长期记住的事实"(比如用户的订单号、地址、偏好),写进 PostgreSQL 的 user_facts 表。下一轮拼 prompt 时,根据当前问题从 facts 表里检索相关条目注入。这种方式不依赖 messages 长度,事实越攒越多但每次只注入相关的几条,是生产环境最推荐的做法。

**策略三:分层记忆路由。**把记忆分成三层:L0 当前轮 messages(窗口 10 条)、L1 摘要(每 20 轮压一次)、L2 外部 facts 库。Agent 在回答时自己决定要不要去查 L1/L2。LangGraph 的 subgraph 机制很适合做这种分层:主图跑对话,需要回忆时调子图去查外部库。

补充二:Checkpointer 底层存储结构详解

PostgresSaver 在 PostgreSQL 里建的表不止一张,理解它们的分工对排错和性能调优很有帮助:

表名 存什么 你要关心的
checkpoints 每个 thread 每次保存时的 state 快照(含 thread_id、checkpoint_id、父 checkpoint_id) 一个 thread 会有很多行(每次节点执行存一行),定期清理老版本
checkpoint_blobs state 里各个 channel 的具体值(messages 列表、自定义字段),按 channel 名分片存 大 blob 会让表膨胀,注意不要在 state 里存大对象
checkpoint_writes 每个节点执行时的写入记录,用于断点续跑和事务一致性 崩溃恢复时靠这张表找到上次跑到哪

这三张表都是用 JSONB 存的,你可以直接用 SQL 查:比如 SELECT thread_id, COUNT(*) FROM checkpoints GROUP BY thread_id ORDER BY 2 DESC; 找出历史最长的几个会话,做归档。

补充三:断点续跑(Resume)实战

LangGraph 的 checkpointer 不只是"存历史对话",它还支持从中间某个节点恢复执行。这在需要人工审批、外部回调的场景特别有用:

bash 复制代码
from langgraph.types import interrupt, Command
假设你的图里有一个"人工审批"节点 def human_approval(state):
interrupt 会暂停图的执行,把状态存进 checkpointer,等待外部输入 decision = interrupt({"question": "是否批准退款?", "amount": state["amount"]})
下次 resume 时,decision 就是你传回来的值 if decision == "approve": return {"status": "approved"} return {"status": "rejected"}
第一次调用:图跑到 human_approval 节点就停了,state 已存盘 agent.invoke({"messages": [HumanMessage(content="我要退款 100 元")]}, config=config) # 几小时后,人工审批通过,用 Command(resume=...) 接着跑 agent.invoke(Command(resume="approve"), config=config) # 图会从 human_approval 节点继续往下跑,不用从头开始

这个机制依赖 checkpointer 把中间状态完整持久化。InMemorySaver 也支持,但进程一停就没了;PostgresSaver 才是真正能跨进程、跨天 resume 的方案。

补充四:记忆清理与合规

做 ToC 产品绕不开隐私合规。用户说"你把我们的聊天记录都忘了吧",技术上怎么实现?

  • 软删除:在 user_facts 表里加一个 deleted_at 字段,检索时过滤掉。优点是可恢复,缺点是数据还在库里。

  • 硬删除 :按 thread_id 从 checkpoints / checkpoint_blobs / checkpoint_writes 三张表里 DELETE 对应行。PostgresSaver 提供了 delete_thread(thread_id) 之类的封装,或者你自己写 SQL。

  • 自动过期:设定一个 TTL(比如用户 90 天不活跃就自动清理),在 checkpoints 表里加 last_access_at 字段,定时任务扫过期 thread。

  • 导出权:GDPR 类法规要求用户能导出自己的所有数据。用 agent.get_state(config) 读出 messages,整理成 JSON 给用户下载。

补充五:高频 FAQ

**FAQ 1:create_agent 不传 checkpointer 会怎么样?**会报错或者变成无状态模式------每次 invoke 都是全新会话,多轮对话不保留。开发调试时可以不传,一旦要做对话应用就必须传。

**FAQ 2:messages 里能塞图片、文件吗?**可以。LangChain 的 messages 支持多模态 content,图片可以作为 base64 或 URL 放进去。但要注意:把 base64 图片塞进 checkpointer 的 blob 表会让数据库暴涨,图片应该存对象存储,state 里只留 URL。

**FAQ 3:一个 thread 最多能聊多少轮?**理论上无上限,实际上受 context window 约束。一般做到 50~100 轮就必须上摘要或 facts 抽取,否则 token 成本和延迟都扛不住。

**FAQ 4:checkpointer 和 LangChain 旧版 Memory 类是什么关系?**旧版(0.1~0.2)的 ConversationBufferMemory 等类在 1.0 里已经废弃,它们的功能被 LangGraph 的 checkpointer 完全取代。新项目一律用 checkpointer + state"messages",不要再 import 任何 *Memory 类。

**FAQ 5:Redis 能当 checkpointer 吗?**能。langgraph-checkpoint-redis 包提供 RedisSaver,适合低延迟、可以容忍偶尔丢数据的场景。如果要求强一致和可审计,还是用 PostgreSQL。

**FAQ 6:多轮工具调用后,messages 里会堆多少条?**每轮工具调用至少 3 条消息:AIMessage(tool_calls) + ToolMessage + 下一个 AIMessage。一个复杂任务调 5 个工具就是 15 条,所以窗口裁剪时要意识到"一轮用户对话可能对应 3~5 条 messages",N=10 条原始消息其实只覆盖 2~3 轮对话。

深度拓展再补充:实战案例与细节答疑

案例:一个真实客服 Agent 的记忆设计

假设你要做一个电商客服 Agent,用户会咨询订单、退款、物流、发票四类问题。它的记忆系统该怎么设计?我们用本章学过的东西拼一遍:

**第一层:会话窗口(短期记忆)。**用 PostgresSaver 存 messages,thread_id 用 "customer:{user_id}:session:{session_id}"。窗口策略是保留最近 10 条 messages,更早的自动摘要。这一层负责"记得你刚才问过订单号 12345"这种上下文。

**第二层:用户画像(长期记忆)。**在 PostgreSQL 里建一张 user_profile 表:user_id、membership_level(普通/会员)、default_address、default_payment_method。每次对话开始时,把这个用户的画像查出来注入 system prompt。这样 Agent 一上来就知道"这是个钻石会员,优先处理"。

**第三层:订单事实(按需检索)。**用户提到某个订单号时,Agent 调用工具 get_order(order_id) 去业务数据库查最新状态。这些是动态数据,不适合塞进 messages,每次现查最准。

三层记忆各司其职:窗口管上下文,画像管静态偏好,工具查动态数据。这就是生产级 Agent 记忆的完整面貌。

细节答疑集锦

**Q:system_prompt 参数和 MessagesPlaceholder("system") 有什么区别?**create_agent 的 system_prompt 是一个便捷参数,相当于在 messages 最前面塞一条 SystemMessage。如果你需要更动态的 system prompt(比如每次根据用户画像拼不同内容),就别用 system_prompt 参数,自己在 state 里维护一个 system 字段,在图的第一个节点动态生成。

**Q:messages 里 system message 有几条?能有多个吗?**技术上可以有多条 SystemMessage,但大多数模型只认第一条。最佳实践是:一条 SystemMessage 写全局指令,其他"临时上下文"(比如对话摘要、用户画像)用 HumanMessage 或 AIMessage 包装,或者放在 system 字段里单独管理。

**Q:怎么在不重启服务的情况下清空某个用户的记忆?**给 Agent 加一个工具 clear_memory(),调用时用 checkpointer.delete_thread(config"configurable""thread_id"),然后提示用户"已开启新会话"。或者前端直接换一个新的 thread_id,老的自然失效。

**Q:checkpointer 会不会成为性能瓶颈?**每次 invoke 都要读写一次 PostgreSQL,延迟主要在数据库 IO。优化手段:(1) 用连接池(SQLAlchemy pool_size);(2) 热点用户用 Redis 做一层缓存;(3) checkpointer 本身支持异步(AsyncPostgresSaver),高并发场景用异步版。

**Q:多模态对话(用户发图片)时 messages 怎么存?**AIMessage 和 HumanMessage 的 content 可以是 list of dict,比如 {"type": "text", "text": "看图"}, {"type": "image_url", "image_url": {"url": "https://..."}}。checkpointer 会原样序列化进 blob 表。注意图片 URL 要长期有效,不要塞临时签名链接。

**Q:怎么调试某个 thread 的历史?**用 agent.get_state_history(config) 可以列出这个 thread 的所有 checkpoint 版本,每个版本看当时的 messages 和节点状态。出问题时按时间倒序翻,能定位是哪一步开始跑偏的。

**Q:PostgresSaver 的 setup() 在生产环境怎么跑?**不要在应用启动时每次都调 setup(),它是幂等的(IF NOT EXISTS)但没必要反复跑。在部署脚本里手动跑一次,或者用 Alembic 迁移管理 DDL。

**Q:会话很长时,checkpoints 表里同一个 thread 有几百行版本,查询会不会慢?**会。PostgresSaver 默认每次读最新版本,靠 thread_id + checkpoint_id 索引,单行查询不慢;但如果你要列历史、做审计,几百行也还好。真的到了几万行,就按时间归档老版本。

深度拓展收尾:本章知识图谱速记

最后用一段话把本章所有知识点串成一张思维导图,方便你复习时一眼回忆全貌:

Agent 记忆 = 短期窗口 (state"messages",用 trim_messages 按 token 预算裁剪)+ 摘要压缩 (老消息用 LLM 滚动总结成 SystemMessage)+ 长期 facts (PostgreSQL user_facts 表,按 user_id 检索注入)。所有持久化由 Checkpointer 统一管理:开发用 InMemorySaver(内存、重启即丢),生产用 PostgresSaver(PostgreSQL 三张表、跨进程恢复、支持断点续跑)。会话隔离靠 thread_id,同一个 thread_id 共享一份 state,不同 thread_id 完全隔离。create_agent(checkpointer=...) 之后,每次 invoke 自动加载历史、追加新消息、跑完再存盘,你不用手动管理 messages 列表。生产环境要注意:token 预算治理、工具返回裁剪、定期归档老会话、用户隐私删除、并发写同一 thread 要加锁。

记住一句话:记忆不是模型自带的,是你把历史消息拼回 prompt 的结果。理解了这一点,所有花里胡哨的 Memory 概念都只是"存多少、存哪、怎么裁"的工程取舍。

补充一句记忆口诀:短期窗口管"刚才聊了啥",长期 facts 管"这个用户是谁",checkpointer 管"历史存哪了",thread_id 管"这是哪场对话"。四件事分清了,记忆系统就再也不会乱。课后多动手跑几遍 InMemorySaver 和 PostgresSaver 的示例,用 agent.get_state 打印 messages 观察每一轮的变化,比看十遍文档都管用。

相关推荐
qq_452396231 小时前
第七篇:《向量数据库选型:从 Chroma 到 Milvus 的落地对比》
数据库·milvus
云絮.1 小时前
mybatis(二)
java·数据库·mybatis
yyuuuzz1 小时前
云服务器搭建个人博客记录:一次访问超时的复盘
运维·服务器·数据库
花间相见1 小时前
【计算基础|MySQL06】MySQL 架构与一条 SQL 的旅程
数据库·后端
oradh1 小时前
Oracle 分析函数使用总结
数据库·oracle
updayday8542 小时前
印染产线电脑进程异常占用资源却要逐台排查 - Ping64 远程进程管理
大数据·网络·数据库·安全·智能路由器
QYRdata2 小时前
年复合增长率8.2%!2026-2032年数据库开发服务赛道增长动能持续释放
数据库·服务发现·数据库开发·恒州博智
坊钰2 小时前
【LangGraph框架入门级】12. 人机交互:interrupt 中断与 Command 恢复
开发语言·数据库·python·人机交互·ai编程·langgraph
龙腾AI白云2 小时前
世界模型与智能体协同推理逻辑
大数据·数据库·知识图谱·pygame