LangChain 短期记忆
代码仓库ChainReaction
找 LangChain 的记忆功能时,很多人会先去翻有没有一个叫 Memory 的类。v1 里没有这个东西。短期记忆就是 agent state 里的一份消息历史,加上你自己挂上去的字段,再交给 checkpointer 按 thread 存下来。把这三件事的分工弄清楚,后面大部分问题都能自己判断。
这篇先用最小例子跑通两轮对话,证明第二轮真的记得第一轮;然后解释 thread_id 和 checkpointer 的关系,以及换 thread 之后会发生什么;接着扩展自定义 state 字段,最后处理消息历史把上下文窗口撑爆的情况。文里的终端输出都是在 Windows 上连 DeepSeek 真跑出来的,脚本放在仓库的 ShortTermMemory/ 目录下。
顺带划一下短期和长期的分界。短期记忆活在一个 thread 里,thread 结束或者你换掉 thread_id,它就够不着了。跨会话记住用户偏好、历史结论,那是 long-term memory 的活,用的是另一套 Store 机制。这篇只谈 thread 内的部分。
短期记忆就是 state
create_agent 默认用 AgentState 管记忆,里面只有一个内置字段 messages,类型是 list[BaseMessage],装的是当前 thread 的完整对话历史。官方文档把它描述成 append-only:新消息往后面加,已有的不会被替换。
这就是为什么翻遍文档也找不到一个 Memory 类。真要做成独立模块,它得自己维护消息列表、自己判断什么时候读、什么时候写、还要自己处理并发。LangChain 把这部分交给了图里的 state 和 checkpointer,agent 本身保持无状态。好处是 state 里除了消息还能放别的东西,工具和中间件读写的是同一份数据,不用在几个系统之间同步。
想让它跨轮记住东西,就要在创建 agent 时给一个 checkpointer:
1 | import os |
真实输出(长回复做了截断):
1 | [第一轮] 助手: 你好,小林!很高兴认识你 |
第二轮没有重新告诉模型名字,它照样答出来了,因为 thread-1 的 state 里躺着第一轮的 human 和 ai 两条消息。调用 agent 时传入的消息是一个增量更新,LangGraph 把它和 checkpointer 里取出的历史合并,再交给模型。
注意这里没有”把历史塞进 prompt”的显式步骤。state 在每个步骤开始时被读取,在 agent 被调用或某个步骤(比如工具调用)完成时写回。你看到的 r2["messages"] 是合并后的完整列表,不是这一轮新产生的两条。
一次多轮对话里,state 和 checkpointer 的配合大致是这样:
sequenceDiagram
participant U as 用户
participant A as Agent
participant C as Checkpointer
U->>A: 第1轮 invoke(thread_id="thread-1")
A->>C: 按 thread_id 读取 state
C-->>A: 空,没有历史
A->>A: messages 追加 human1,模型回复 ai1
A->>C: 写入 checkpoint [human1, ai1]
U->>A: 第2轮 invoke(thread_id="thread-1")
A->>C: 按 thread_id 读取 state
C-->>A: [human1, ai1]
A->>A: messages 追加 human2,模型看到完整历史
A->>C: 写入 checkpoint [human1, ai1, human2, ai2]
checkpointer 存下来的是一串快照,每份对应图执行的一次 super-step,super-step 可以理解成一次 tick。一次两轮的对话会产生好几份快照,每份都带着当时的完整 state。这个特性后面讲状态膨胀时还要用到。
thread_id 和 checkpointer
checkpointer 用 thread_id 作为主键来存取状态。挂了 checkpointer 却不传 thread_id,LangGraph 直接抛异常,不会”默认开一个”。我实测了一下:
1 | [有 checkpointer,无 thread_id] ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_id |
这个报错信息挺直白,照着补 thread_id 就行。
反过来,不挂 checkpointer 的时候,传 thread_id 没有任何作用。同一个 thread_id 连续调两轮,第二轮的状态里只有自己这一轮的消息:
1 | [无 checkpointer] 第二轮 state 消息条数: 2 |
记忆不在 agent 对象里,而在 checkpointer 里。agent 每次都是无状态的,是 checkpointer 把上一轮的状态递了回来。
换一个 thread_id,就是换一个会话。下面这个脚本用同一个 agent,先在 thread-A 里告诉它喜欢的颜色,然后去 thread-B 问同一件事:
1 | [thread-A] 用户: 记住:我最喜欢的颜色是蓝色。 |
thread-B 完全不知道蓝色的事,而回到 thread-A 之后记忆还在。消息条数也印证了这一点:thread-B 是 2 条,thread-A 累积到 4 条。
flowchart LR
I[agent.invoke] --> C{config 里的 thread_id}
C -->|thread-A| S1["state A: messages + 自定义字段"]
C -->|thread-B| S2["state B: messages + 自定义字段"]
S1 --> CP[(Checkpointer)]
S2 --> CP
CP --> DB["InMemorySaver / SqliteSaver / PostgresSaver"]
实际项目里,thread_id 通常按会话生成,不按用户生成。一个用户可能同时开好几个对话,每个对话一个 thread_id,聊天记录才不会串在一起。用户级的东西放长期记忆,会话级的放 thread。
create_agent 返回的是编译好的图,agent.get_state(config) 能直接读当前状态,agent.get_state_history(config) 能拿到这个 thread 的全部快照,调试和回放时很省事。我后面数 checkpoint 用的就是它。
选哪种 saver,差别比想象中大:
| InMemorySaver | SqliteSaver / PostgresSaver | |
|---|---|---|
| 数据放哪 | 进程内存 | SQLite 文件 / Postgres 库 |
| 进程重启 | 状态全丢 | 保留 |
| 多进程或多副本 | 各存各的,互相看不见 | 共享同一份 |
| 依赖 | langgraph-checkpoint 自带 |
另装 langgraph-checkpoint-sqlite 或 langgraph-checkpoint-postgres |
| 适合 | 本地开发和测试 | 生产 |
官方把 InMemorySaver 定位成实验用,SQLite 适合本地工作流,Postgres 和 MongoDB 才是生产选项。Postgres 的用法长这样,setup() 会自动建表:
1 | from langgraph.checkpoint.postgres import PostgresSaver |
自定义 state 字段
AgentState 可以继承,加自己需要的字段,然后通过 state_schema 传给 create_agent:
1 | from typing import Any |
第二轮只传了 messages,不再传 user_id 和 preferences。真实输出:
1 | [第一轮] user_id='user_123' preferences={'theme': 'dark', 'lang': 'zh'} model_calls=1 |
自定义字段和 messages 一样被 checkpointer 保存,所以只需要在第一轮给一次。model_calls 每轮加一,靠的是 after_model 钩子返回的字典,它通过 state 的 reducer 合并回去。
字段声明用了 NotRequired,因为第一轮 invoke 时你未必会传。它属于类型标注,运行时不会因为少传就拦你:我试过把字段标成必填却不传,invoke 照样跑通,返回的 state 里连这个键都没有,state.get("user_id") 拿到的是 None。所以自定义字段要么第一轮就给全,要么在代码里自己兜住缺省值。合并规则按字段走:messages 配的是 add_messages,做追加;普通字段默认是覆盖。
还有个容易和 state 混在一起的概念是 context。context_schema 定义每次运行传入的只读上下文,比如 user_id、数据库连接、当前请求的元数据,工具里用 runtime.context 读。它不进 checkpoint,也不跨轮累积。要跨轮记住的放 state,只在单次运行里传递的放 context,分清楚能省不少事。
有个地方容易误会。第三轮我问模型”我的 user_id 是多少”,它答不上来。user_id 存在 state 里,但 state 字段不会自动进入 prompt,模型能看到只有 messages。想让模型知道,得靠中间件把字段拼进 system prompt,或者用工具读 runtime.state 再以工具结果的形式送回对话。
用中间件定义状态
上面把 state_schema=CustomState 同时给了 create_agent 和装饰器。如果这个字段只服务于某一个中间件,更干净的做法是只声明在中间件上:
1 | from langchain.agents import create_agent |
装饰器写法也支持 @before_model(state_schema=CustomState)。文档里 AgentMiddleware 的三个编译期属性,state_schema、tools、transformers,都是这个思路:能力跟着中间件走,工厂在编译时把它们合并进 agent 的 state。
我倾向中间件持有自己的字段,原因是可复用。计数器、调用次数、限流标志这类字段是中间件的实现细节,写进 create_agent(state_schema=...) 之后,换个 agent 就得再抄一遍,字段名还可能撞。反过来,user_id、preferences 这种业务层到处要读的字段,放 create_agent 的 state_schema 更合适,因为它属于 agent 本身,不归某个中间件管。
读和改消息历史
读有几种入口。调用结束后从返回值的 ["messages"] 里取,中间件里从 state["messages"] 取,工具里通过 ToolRuntime 取:
1 | from langchain.tools import tool, ToolRuntime |
runtime 参数对模型不可见,模型看不到它,但工具能拿到完整 state。
返回值里的 messages 包含 tool 消息,工具调用的中间结果全在里面。只想看最终回答就取最后一条,想检查这一轮到底发生了什么,就顺着 tool_calls 和对应的 ToolMessage 往下看。别把它当成只有人机对话,agent 循环里每次工具往返都会留下痕迹。
改历史要用 RemoveMessage,而且得让 messages 走 add_messages 这个 reducer。AgentState 默认就是,所以直接写就行。删指定几条:
1 | from langchain.messages import RemoveMessage |
清空全部用 REMOVE_ALL_MESSAGES:
1 | from langgraph.graph.message import REMOVE_ALL_MESSAGES |
工具里也能改 state,返回一个 Command 即可:
1 | from langgraph.types import Command |
删消息时注意结果历史是否合法。有些 provider 要求历史以 user 消息开头;带 tool call 的 assistant 消息后面必须跟着对应的 tool 结果消息。删中间那段很容易踩这个坑,报错通常来自 provider 而不是 LangChain。
state 在 agent 一次运行里的读写位置:
flowchart TD
S([开始]) --> P["before_model: 读 state,可返回 dict 更新"]
P --> M["model: 只能看到 messages"]
M --> A["after_model: 读 state,可返回 dict 更新"]
A -->|有 tool_call| T["tool: 通过 runtime.state 读写"]
T --> P
A -->|无 tool_call| E([结束])
E --> CK[("checkpointer 落 checkpoint")]
上下文窗口压力
聊得越久,messages 越长,迟早撞上模型的上下文窗口。就算窗口够大,长历史也会拖慢响应、抬高成本,模型还容易被无关的旧内容带偏。官方给了两类常见做法:修剪和摘要。
判断该不该动手可以先看用量。AIMessage 上带 usage_metadata,里面有 input_tokens 和 output_tokens,把最近几轮加起来就能估出增长趋势。不过这是事后观察,真正拦在调用前的是中间件。
修剪
修剪就是丢消息。写一个 before_model 中间件,在调用模型前把历史砍短:
1 | from typing import Any |
跑四轮,每轮打印 state 里的消息角色:
1 | 第 1 轮 state 消息角色: ['human', 'ai'] |
从第 3 轮开始条数一直卡在 4,因为中间那两条被丢掉了。第 4 轮还能答出名字,是因为我特意保留了第一条。如果不保留第一条,模型就会失忆。修剪策略的核心是决定留哪条,而不是砍掉多少条。
修剪全程不需要模型参与,执行快,结果也可预测。代价是丢掉的对话永久消失,哪怕后面用户又提起那个话题,模型也无从查证。要不要保留第一条,取决于你的 system prompt 里有没有把关键信息固化下来。如果关键事实都在 system prompt 里,第一条 human 消息其实也可以丢。
摘要
摘要用模型把旧消息压成一段文字,再用内置的 SummarizationMiddleware:
1 | from langchain.agents import create_agent |
trigger 是触发条件,keep 是摘要之后保留多少最近的消息。两个参数都支持 tokens、messages、fraction 三种单位。trigger 传一个列表就是 OR 逻辑,任意一条满足即触发;传字典是 AND 逻辑,里面所有阈值都要满足。fraction 依赖模型的 profile 数据,拿不到就换成 tokens 或 messages,或者自己给模型配 profile。跑四轮的真实输出:
1 | 第 3 轮 state 消息角色: ['human', 'ai', 'human', 'ai', 'human', 'ai'] |
第 4 轮进模型前消息到了 7 条,超过阈值,中间那段被替换成一条摘要消息,所以最后 state 只剩 4 条。名字和城市都还在,因为摘要把它们写进去了。
两种策略的取舍:
| 维度 | 修剪 | 摘要 |
|---|---|---|
| 做法 | 直接丢弃整条消息 | 用模型把旧消息压成一段摘要 |
| 代码 | 自己写 before_model 中间件 |
用 SummarizationMiddleware |
| 额外成本 | 没有 | 每次触发多一次模型调用 |
| 信息损失 | 丢掉的内容永久消失 | 语义保留,细节会被压缩 |
| 可控性 | 高,条数完全由你决定 | 偏低,摘要内容取决于模型 |
| 多模态 | 原样丢弃 | 旧图片和音频只剩文字摘要 |
| 适合 | 会话短,旧消息可以不要 | 长会话,早期信息后面还要用 |
我自己的默认选择是先修剪。它零成本、行为确定,调试起来省心。等发现”早期信息确实还有用”再换摘要。摘要也不是没有边界,官方特别说明它是纯文本压缩,不会压缩图片和音视频载荷,做图像密集的应用要把媒体放到文件系统或对象存储,历史里只传 URL 或文件引用。
生产环境的几个坑
内存 saver 只适合开发。InMemorySaver 的数据在进程内存里,重启就没了,多开一个 worker 也拿不到另一个 worker 的会话。上线就换 PostgresSaver 或 MongoDBSaver,别用内存 saver 硬扛。
并发 thread 之间是隔离的,不同 thread_id 互不影响,这也是它能直接支撑多用户会话的原因。但同一个 thread_id 上的并发请求要小心,checkpointer 以 thread 为主键,同一 thread 上的多次写入会按顺序落到同一条状态链上,交叉的两轮对话可能互相覆盖。一个会话同一时刻只处理一个请求,或者自己加锁。
状态膨胀值得单独盯。前面提过 checkpointer 在每个 super-step 存一份快照,两轮对话就会留下好几份。我数了一下:
1 | # 沿用前面带 checkpointer 的 agent,thread_id 换成 thread-cp |
1 | get_state 拿到的当前消息条数: 4 |
两轮对话,6 份快照,最后 4 条消息被重复存了多次。消息越长、轮次越多,重复得越厉害。上修剪或摘要,给老 thread 设清理周期,自定义字段里别塞大对象。
还有一点,换 saver 的时候 messages 里存的是完整的消息对象,包含工具调用和元数据。如果你打算跨版本升级 LangChain,先在测试环境验证旧 checkpoint 还能不能反序列化出来。
小结
- 短期记忆没有独立模块,它就是 thread 的 state:
messages加自定义字段,由 checkpointer 按thread_id存取。 - 不挂 checkpointer 就没有记忆,挂了不传
thread_id会直接抛 ValueError。 - 换
thread_id就是换会话,隔离是默认行为;InMemorySaver只用于开发,生产换数据库 saver。 - 中间件自己的字段就声明在中间件的
state_schema上,业务层共用的字段放create_agent的state_schema。 - 上下文压力优先用修剪,零成本且可控;需要保留早期语义时再上摘要,记得它不压缩多模态内容。

