LangChain 长期记忆
代码仓库ChainReaction
上一篇文章里我们给 agent 挂了 checkpointer,同一个 thread_id 下它能记住用户叫什么。把 thread_id 一换,它就失忆了。这个行为对单次会话来说是对的,做成产品就错了。用户在客服窗口里说过”我不用信用卡”,三天后他打开另一个窗口,这句话不该消失。
LangChain 里管这件事的组件叫 store。它和 checkpointer 是两套独立的东西,一个存 thread 内的消息,一个存跨 thread 的 JSON 文档。这篇把 store 这一层拆开:namespace 怎么设计、写入和召回有哪几条路、语义检索怎么接、什么时候该忘掉、噪声怎么挡在门外。
文里的代码都在 langchain 1.4.3 + langgraph 1.2.12 上跑过,输出是从终端粘出来的。对话模型统一用 DeepSeek,向量用本地跑的 BAAI/bge-small-zh-v1.5,原因在语义检索那一节。
短期记忆和长期记忆的边界
短期记忆就是 agent 的 state。默认的 AgentState 里有一个 messages 键,整段对话历史躺在里面。挂上 checkpointer 之后,这份 state 按 thread_id 落盘,恢复 thread 时读回来。它解决的问题是”这一通对话说到哪了”。
长期记忆是 store。每个条目是一份 JSON 文档,由两层坐标定位:一个 namespace 元组,相当于文件夹;一个 key 字符串,相当于文件名。这两层都不绑定 thread,所以 thread B 能读到 thread A 写的东西,只要 namespace 对得上。
官方文档里把长期记忆按内容分成三类,这个划分对设计 namespace 有实际帮助:
| 类型 | 存什么 | 例子 |
|---|---|---|
| 语义记忆 | 事实 | 用户写 Python、用户在上海 |
| 情节记忆 | 经历 | 上次帮用户调通了 asyncio 超时 |
| 程序记忆 | 规则 | agent 自己的系统提示词 |
三类混在同一个 namespace 里也能跑,但检索的时候会很别扭。后面讲 namespace 设计时会用 (user_id, "preferences") 和 (user_id, "episodes") 把语义记忆和情节记忆分开。
两者的分工用一张图看得更清楚:
flowchart LR
subgraph T1["thread A"]
M1[消息历史] --> CP[Checkpointer]
end
subgraph T2["thread B"]
M2[消息历史] --> CP
end
subgraph T3["thread C"]
M3[消息历史] --> CP
end
CP --> S1[(按 thread_id 存 state)]
T1 -.->|"runtime.store.put"| ST[(Store)]
T2 -.->|"runtime.store.search"| ST
T3 -.->|"runtime.store.search"| ST
ST --> S2[(按 namespace + key 存 JSON)]
图里实线走的是 checkpointer,虚线走的是 store。checkpointer 每个 thread 各存一份,store 是所有 thread 共用的同一份。
store 抽象:从 InMemoryStore 起步
InMemoryStore 是最简单的实现,数据存在一个 Python 字典里,进程一退就没了。开发和测试够用,生产要换成 PostgresStore、MongoDBStore 或 RedisStore,它们都继承同一个 BaseStore。
把 store 单独拿出来用,不接 agent 也能跑。下面这段脚本演示了 put / get / search / delete / list_namespaces 五个方法,以及 namespace 的匹配规则:
1 | from langgraph.store.memory import InMemoryStore |
真实输出(脚本在 LongTermMemory/StoreBasics.py):
1 | === get:按 namespace + key 精确取一条 === |
有几处行为值得单独拎出来。
search 的第一个参数是 namespace_prefix,不是 namespace 本身。传 ("alice",) 会把 ("alice", "preferences") 和 ("alice", "episodes") 一起捞回来,也就是前缀匹配。想只拿一级,就把完整 namespace 传进去,或者拿回来之后自己按 item.namespace 过滤。
limit 默认 10,超出的部分静默丢弃,没有任何提示。分页靠 offset 手工翻。这个默认值在记忆条目涨到几十条之后很容易踩到,我一般会在检索工具里显式写 limit=20。
返回顺序不能依赖。InMemoryStore 是插入顺序,最新的在最后;PostgresStore 是 updated_at 倒序,最新的在最前。如果代码里写了 items[-1] 来取”最近一条”,换后端的那天就会静默出错。
get 返回的是 Item,字段有 key、value、namespace、created_at、updated_at。带 query 检索时返回的是 SearchItem,多一个 score。
namespace 怎么设计
namespace 是任意长度的字符串元组,官方建议把 user id 或 org id 放进去。实践中我用两级:
1 | (user_id, "preferences") 稳定偏好,一个 slot 一个 key |
顺序不要反过来写 ("preferences", user_id)。前缀匹配是从左往右的,把 user_id 放在最外层,search((user_id,)) 一次就能把这个用户的所有记忆捞全,跨类别检索也不用改代码。
key 的选择决定了写入是新增还是覆盖。同一 namespace 下用同一个 key 再 put,就是覆盖,条目数不变。用随机 UUID 当 key,就是每次新增一条。偏好类记忆用稳定 key,情节类记忆用 UUID 加时间戳,这是我用下来最省心的分法。
覆盖的时候要留意一个坑。InMemoryStore 的覆盖会把整条记录换掉,created_at 也一起重置。我实测过:
1 | created_at 是否保留 -> False(InMemoryStore 覆盖时连 created_at 一起重置) |
如果业务上需要”首次记住的时间”,得自己往 value 里塞一个字段,别指望 created_at。
还有两件小事。store 的每个方法都有异步版本,aput、aget、asearch、adelete、alist_namespaces,在 FastAPI 这类异步框架里直接用异步版,别把同步调用塞进事件循环。另外 search 的第一个参数是前缀,传空元组 () 就能跨用户检索,做后台审计时用得上,线上接口里别这么干,很容易把别人的记忆喂给当前用户。
写入与检索的三条路径
长期记忆的读写发生在哪里,有三种做法。它们的差别不在 API,而在谁来拍板、什么时候拍板。
路径一:工具里读写,模型自己决定
这是最直接的一条路。把 store 传给 create_agent,工具签名里加一个 ToolRuntime 参数,就能通过 runtime.store 拿到同一个 store。这个参数对模型是不可见的,模型只看到业务参数。
下面这个脚本里,thread A 让模型自己决定写入,thread B 是全新会话,只带着同一个 user_id:
1 | import os |
真实输出(其中一行太长,做了折行):
1 | === thread A:模型自己决定写入 === |
thread B 的 state 里只有 4 条消息,看不到 thread A 说过什么。它答出”VS Code”,靠的是工具去 store 里查。这就是长期记忆该有的样子:消息不共享,知识共享。
模型把一句话拆成了三条记忆,这个行为有利有弊。拆开之后每条更短、更容易被语义检索单独命中,代价是条目数涨得快,也更容易写出互相矛盾的条目。
路径二:中间件或节点里按代码逻辑写
不想让模型拍板的时候,就在 @after_model 中间件或者自定义节点里读 state、按固定规则写 store。适合结构化抽取,比如每轮对话结束后把用户提到的地名抽出来。写入时机完全可控,代价是规则要自己维护,遇到没覆盖的表达就漏了。
路径三:后台任务写
主链路跑完,另起一个任务拿整段对话去提炼记忆。好处是用户感知不到额外延迟,模型也能一次看到完整上下文。官方文档提到的触发方式有定时调度、cron 和用户手动触发。代价是新记忆有延迟,用户这轮说完、下轮问起,可能还没写进去。
按需召回
召回也有两种粒度。上面 recall_preferences 是模型主动调工具,把整个 namespace 拉出来。另一种是代码在调用模型之前先检索,把结果塞进系统提示词。后者不用多花一次工具调用,但每次都要检索,即使这轮对话用不上。
选择依据很简单:记忆条数少、模型知道什么时候需要,就用工具召回;条数多、命中率靠 embedding 撑着,就代码预召回加工具兜底。
三条路径的取舍
| 路径 | 谁决定写 | 发生时机 | 对主链路的延迟 | 适合的场景 |
|---|---|---|---|---|
| 工具 | 模型 | 对话中 | 多一轮工具调用 | 用户明确说”记住”的偏好 |
| 中间件/节点 | 代码规则 | 对话中 | 同步执行,可控 | 字段固定的结构化抽取 |
| 后台任务 | 代码调度 | 对话结束后 | 无 | 需要通读全文才能提炼的画像 |
我默认选第一条。它的好处是用户能看见记忆被写下来了,出问题时也容易从消息里找到是哪一句触发的。
一次对话里,写入和读取分别走哪条路,用一张时序图收一下:
sequenceDiagram
participant U as 用户
participant A as Agent
participant M as DeepSeek
participant S as Store
participant C as Checkpointer
U->>A: thread A:记一下我的习惯
A->>C: 保存本轮 state
A->>M: 消息 + 工具清单
M-->>A: tool_calls: remember_preference
A->>S: put((user_id,"preferences"), key, value)
S-->>A: 写入成功
A->>C: 保存工具调用后的 state
Note over U,C: 换到 thread B,只带同一个 user_id
U->>A: thread B:我平时用什么编辑器
A->>C: 读 thread B 的 state,只有本轮消息
A->>M: 消息 + 工具清单
M-->>A: tool_calls: recall_preferences
A->>S: search((user_id,"preferences"))
S-->>A: 命中 3 条偏好
A->>M: 把工具结果拼回消息
M-->>U: 你用 VS Code,先给结论再给代码
语义检索:给 store 配 embedding
记忆涨到几十上百条之后,把整个 namespace 拉给模型就不合适了。search 支持 query 参数做相似度检索,前提是建 store 的时候配好 embedding。
IndexConfig 有三个字段。embed 是生成向量的东西,可以是 LangChain 的 Embeddings 实例、同步函数、异步函数,或者 "openai:text-embedding-3-small" 这样的 provider 字符串。dims 是向量维度,必须和模型对上。fields 决定把文档的哪些部分拿去算向量,默认 ["$"] 是整个 JSON 对象,也可以写成 ["text"] 只对某一个字段做 embedding。
1 | from langgraph.store.memory import InMemoryStore |
put 的时候还能逐条覆盖索引行为,index=["food_preference"] 指定字段,index=False 表示这条不进向量索引。
这里要如实说一件事:DeepSeek 没有 embedding 接口。我直接打了一下它的端点:
1 | === 0. 先确认 DeepSeek 有没有 embedding 端点 === |
所以文章里的模型能换成 DeepSeek,向量不行。真实项目里向量来源要么是 OpenAI 的 text-embedding-3-small,要么是本地跑的 sentence-transformers,要么是国内厂商的 embedding API。
我这条路走本地模型:BAAI/bge-small-zh-v1.5,中文检索用的小模型,512 维,权重一百来兆。装依赖两条命令:
1 | pip install langchain-huggingface sentence-transformers |
第一次跑会从 HuggingFace 把权重下下来,慢的话等几分钟,之后走本地缓存。HuggingFaceEmbeddings 是 LangChain 对 sentence-transformers 的包装,init_embeddings("huggingface:BAAI/bge-small-zh-v1.5") 是同一个东西的字符串写法,两者可以互换。我用前者,因为要显式传 normalize_embeddings=True:bge 按余弦相似度训练,向量归一化之后点积就是余弦,省得再操心量纲。
dims 我不硬写 512,直接问模型要:
1 | DIMS = len(embeddings.embed_query("维度探测")) |
换模型的时候这一行跟着变,比手写常量安全。
1 | from langchain_huggingface import HuggingFaceEmbeddings |
embed 传函数也完全可以,之前我用一个确定性函数占过位:字符 bigram 经 crc32 哈希到 128 维再归一化。它跑得飞快,也没有任何依赖,缺点是只对齐字面。我把它留成对照组,两套向量跑同一批 query,排序差别正好说明 store 这一层不产生语义。
1 | import zlib |
真实输出:
1 | === -1. 加载 embedding 模型 BAAI/bge-small-zh-v1.5(首次会从 HuggingFace 下载) === |
贴出来的输出做了删减:省掉第 5 节的 query instruction 对照和最后一节,第 4 个 query 也没列。下面正文引用到的数字都在脚本的完整输出里。脚本在 LongTermMemory/SemanticSearch.py,跑一次就能得到完整输出。
输出里有三点值得单独拎出来。
没配 index 的时候,query 不会报错,会被静默忽略。你以为在做语义检索,其实只是把 namespace 里的条目按默认顺序拿回来了。这类静默降级最难查,因为代码看起来完全正常。判断方法很简单,看返回的 SearchItem 有没有 score,没有就是没走向量。
query 和 filter 可以叠加,filter={"kind": "semantic"} 先把范围缩到某一类,再按相似度排序。
第三条是这个对比真正的价值所在。占位向量下,用户用什么写代码 的第一名是”喜欢吃披萨”,两句话共享的字符更多;”Python”和”写代码”没有任何共同字符,m-code 掉出前三。同一批数据、同一个 store,只把 embed 换成 bge,第一名就变成 m-code,分数 0.6543,和它本来的语义对上了。用户喜欢吃什么 和 回复风格有什么要求 这两个 query 的第一名两套向量都一样,所以这次改善挑不出”换什么都一样”的毛病,塌掉的就是占位向量唯一撑不住的那一条:跨字面理解。store 这一层只负责存和排序,语义全在 embedding 模型里。
真模型也不是万能的,有两个地方我自己跑出来觉得别扭,一并说清楚。
一个是分数没有绝对意义,别拿它当阈值。我问了一句 这条不建索引的记录,四条建了索引的记忆里没有一条在讲这件事,按道理应该返回”没有”。真 embedding 下它照样给了一个第一名:m-style,score=0.4910。bge 对任意两个中文句子都能给出 0.3 到 0.5 的底分,0.49 分完全可能是错的。要用分数做过滤就得自己定一条线,比如低于 0.55 当成没命中,或者在检索之后再加一层 rerank。指望”分数低自然就排到后面”不成立。
另一个是低分区的名次基本是噪声。还是 用户喜欢吃什么 这个 query,第二名 m-code 0.4374、第三名 m-style 0.4276,差 0.0098。这个差距换个 embedding 模型、甚至换个 encode_kwargs 就可能翻过来。真正稳的是第一名和后面拉开的那一截:0.6418 对 0.4374,差 0.2 以上。做 top-k 召回时 k 取 1 到 3 是有意义的,把 k 开到 10 再让模型自己挑,等于把噪声一起递过去了。
顺带一个 bge 特有的坑。官方建议检索时给 query 加一句前缀「为这个句子生成表示以用于检索相关文章:」,文档侧不加。我试了一下,加了前缀分数整体往下掉(m-code 从 0.6543 掉到 0.5838),名次没变。真正的问题是加不了:IndexConfig 只有一个 embed 函数,文档和 query 走同一条路,框架不区分调用方。要在 store 里用上这个前缀,得自己包一层函数,靠调用时的上下文去判断是不是 query,代价是这条路不再通用。我的选择是不加,省下的这点召回率不值得破坏 embed 的纯粹性。
还有一件容易漏的事。部署到 LangGraph Server 的时候,索引配置从 Python 代码里搬到了 langgraph.json 的 store.index 字段。本地跑得好好的语义检索,上线之后突然不生效,八成是漏了这一步。
记忆的治理
写进去容易,管起来难。记忆一旦有了噪声和矛盾,模型会一直照着错的做。
什么时候更新
偏好像是”用户喜欢中文回答”,应该覆盖而不是追加。我用的办法是给工具加一个受约束的 slot 参数,让 key 等于 slot 名,同一个 slot 再写就是覆盖。
1 | from typing import Literal |
Literal 会让模型看到的 JSON Schema 里带一个 enum,它能选的 slot 就那几个,不会随手编一个新 key 出来。
什么时候遗忘
delete(namespace, key) 是硬删除。用户注销、记忆被判定为错误、条目过期,都用它。
BaseStore.put 的签名里还有一个 ttl 参数,看起来很适合做自动过期。实测下来 InMemoryStore 没实现:
1 | === ttl:写入时给过期时间(治理用) === |
本地开发想验证 TTL 逻辑,得换成 PostgresStore。用 InMemoryStore 时 TTL 只能靠自己的定时任务扫 updated_at 来做。
怎么把噪声挡在门外
热路径写入最大的风险是模型什么都记。我把判断写进工具描述里,然后拿六条消息试了一遍,其中两条是一次性任务和临时提问:
1 | === 逐条消息,看模型调不调 remember === |
六条消息,模型只写了四次,任务和临时提问都放过去了。第 5 条把第 0 条的 language 覆盖掉了,条目数没涨。闸门是有效的。
但它也暴露了一个真问题。第 4 条和第 0 条是互相矛盾的,模型把第 4 条归到了 style,第 0 条留在 language。结果 store 里同时躺着”回答必须用中文”和”保留英文术语”。两条都不算错,凑在一起就自相矛盾。
靠固定 slot 只能收敛同一类里的更新,跨 slot 的矛盾它管不了。我的处理办法是写入之前先把同 namespace 下的现有记忆读出来,一起给模型看,让它决定是覆盖、新增还是删除。这一步可以在热路径的同一个工具里做,也可以丢给后台任务。官方文档里提到的 profile 与 collection 之争说的就是这个:单一 profile 文档好更新但容易丢信息,文档集合好追加但冲突要自己处理。
flowchart TD
IN[一轮对话结束] --> G{值得长期记住吗}
G -->|任务 / 临时提问| DROP[丢弃,不写 store]
G -->|稳定偏好或事实| RD[读出同 namespace 现有记忆]
RD --> C{和已有记忆冲突吗}
C -->|同 slot 更新| PUT[put 覆盖,key = slot]
C -->|新信息| ADD[put 新增,key 用 UUID]
C -->|推翻旧记忆| DEL[delete 旧 key 再 put]
C -->|无法判断| BG[丢给后台任务或人工确认]
PUT --> AUDIT[定期扫 namespace]
ADD --> AUDIT
DEL --> AUDIT
AUDIT -->|条目过期或从未被召回| DEL
图里最后一步是定期审计。我一般会看两个信号:一条记忆从写入到现在有没有被检索命中过,以及同 namespace 里有没有语义高度重复的条目。前者说明它是不是噪声,后者说明去重没做好。
和 checkpointer 的分工
这两个组件经常被搞混,因为它们都要在 create_agent 里传。分清楚之后就不会用错了。
| checkpointer | store | |
|---|---|---|
| 存什么 | agent state,主要是 messages | 任意 JSON 文档 |
| 怎么定位 | thread_id | namespace + key |
| 生命周期 | 一个 thread 结束可以留着,也可以清 | 跨 thread 长期存在 |
| 访问方式 | 自动读写,框架管 | 代码或工具里显式读写 |
| 换 thread 后 | 读不到 | 读得到 |
| 典型用途 | 多轮对话、中断恢复、时间旅行 | 用户画像、偏好、跨会话知识 |
两个可以同时挂上,互不干扰:
1 | agent = create_agent( |
我的判断标准是:这条信息在用户下次开新窗口时还有用吗?有用就进 store,没用就让它留在 thread 的 state 里。
小结
- 短期记忆是 thread 内的 state,长期记忆是跨 thread 的 store。判断标准是换一个 thread_id 之后这条信息还要不要。
- namespace 用 (user_id, 类别) 两级,search 的第一个参数是前缀,传 (user_id,) 就能跨类别捞。limit 默认 10 且静默截断,返回顺序不保证,这两点要在代码里显式处理。
- 写入有工具、中间件、后台任务三条路。默认选工具那条,模型拍板的记忆用户看得见,出问题也查得到。写入前把现有记忆读出来一起判断,能挡掉大部分矛盾。
- 语义检索靠 IndexConfig 的 embed / dims / fields。DeepSeek 没有 embedding 端点(实测 404),向量得另找来源,本地跑 BAAI/bge-small-zh-v1.5 就够用,换掉 embed 之后排序立刻从字面变成语义。没配 index 时 query 会被静默忽略,用返回项有没有 score 来判断是否真的走了向量。
- 相似度分数只有相对意义。第一名和后面拉开 0.2 才可信,0.4 上下的名次基本是噪声,完全不相关的 query 也能拿到 0.49 分。要做阈值过滤就自己定一条线,或者在检索后面再加一层 rerank。
- 治理三件事:同 slot 覆盖、用 delete 和 TTL 遗忘、按”以后还会不会用到”过滤写入。InMemoryStore 不支持 TTL,生产上换 PostgresStore 再说。

