主题
Store
要点
- 前面几篇其实已经把「记住上下文」这件事做起来了
- 这里最容易混的一点,是把 Store 当成「另一个更大的 checkpointer」
checkpointer负责线程状态,Store负责跨线程长期资料- 官方 memory 设计里,Store 更适合保存跨 thread 的长期记忆
- 图里的节点通过运行时能力读写 Store
- 长期记忆需要控制写回时机,不能把每一句话都原样保存
- Search 让长期记忆从精确读取扩展到相似资料召回
内容
1. 线程记忆能解决很多事,但它不是全部
前面几篇其实已经把「记住上下文」这件事做起来了。
checkpointer 能把一条线程里的状态存下来,用户下一轮再回来时,图可以从原来的状态继续跑。
这已经很有用了。比如审批流暂停以后回来继续、对话多轮接着聊、某一步失败以后从中间恢复,靠的都是这套线程级记忆。
但业务再往前走一步,你会很快碰到另一类信息:
它不只属于某一条线程,而是用户跨很多次会话都应该被记住。
比如:
- 这个用户一直偏好简短语气
- 他常写给初中级开发者看
- 他之前已经明确说过文章优先发布到公众号
这些信息如果只挂在线程状态里,一换线程就没了。可从产品角度看,它们又明显应该继续存在。
到了这里,checkpointer 就不够了。
你需要的是另一层能力:把信息放到线程之外,在后面的任意会话里再取回来。
这就是 Store。
对照官方 memory 文档,可以把边界先放稳:Checkpointer 保存的是某条 thread 的运行状态,Store 保存的是跨 thread 可复用的长期资料。一个用户今天写 LangGraph 入门,明天写 ReAct Agent,两个创作线程可能不同,但「偏好简短语气」「目标读者是初中级开发者」这类信息应该能继续被读到。
可以用这张表区分:
| 能力 | 以什么为边界 | 保存什么 |
|---|---|---|
| Checkpointer | thread_id | 这条图执行到哪里、当前 state 是什么 |
| Store | namespace + key | 用户、团队或应用长期资料 |
| Runtime context | 单次调用 | 当前用户、团队、发布渠道等背景条件 |
Store 的价值主要在于让长期资料脱离某一条线程,而不是单纯「存更多东西」。这也是它和 Checkpointer 最容易混、也最需要分开的地方。
2. Store 和 checkpointer 不是一回事
这里最容易混的一点,是把 Store 当成「另一个更大的 checkpointer」。
它们看起来都在存东西,但职责其实不一样。
checkpointer 负责的是线程里的状态。重点是「这条图现在跑到哪了」,所以它天然围绕 thread_id 工作。
Store 负责的是跨线程长期保存的数据。重点不再是某一轮流程停在哪,而是「这个用户、这个团队、这个应用上下文,有没有什么应该长期记住的信息」。
所以它们经常会一起用:
checkpointer记线程里的即时状态store记跨线程的长期资料
这两层合在一起,系统才能同时处理「会话能续上」和「长期偏好不会丢」。
3. 先看 Store 里存的到底是什么
理解 Store 时,可以先看它的数据组织方式,再看 API。
在 LangGraph 里,一条长期记忆通常有三层:
namespacekeyvalue
namespace 可以先把它理解成一个目录。它常常会把用户、团队、业务上下文这些信息放进去。
key 更像这一条记忆自己的名字。
value 是实际存下来的内容,通常是一个 JSON 对象。
typescript
// store-basic.ts
import { InMemoryStore } from '@langchain/langgraph'
const store = new InMemoryStore()
const namespace = ['users', 'user_001', 'profile']
// put 的意思很直白:把一条 JSON 数据放进这个 namespace 下
await store.put(namespace, 'preferences', {
tone: 'short',
language: 'zh-CN',
audience: 'junior-developers',
publishChannel: 'wechat',
})
const item = await store.get(namespace, 'preferences')
console.log(item?.value)
// → {
// tone: 'short',
// language: 'zh-CN',
// audience: 'junior-developers',
// publishChannel: 'wechat',
// }这一段里需要看清的不是 put 和 get 本身,而是 Store 和线程状态的差别。
这里存下来的不是「某一次图执行的中间状态」,而是一份以后很多线程都能再拿出来用的资料。
4. 把 Store 接进图里的节点
单独调 store.put() 和 store.get() 不难。更需要关注的是:图里的节点怎么拿到这份长期记忆。
LangGraph 现在这套写法里,做法也很直接。你在 compile() 的时候把 store 传进去,节点运行时就能从 runtime.store 里访问它。
typescript
// compile-with-store.ts
import {
StateGraph,
StateSchema,
MessagesValue,
START,
END,
InMemoryStore,
} from '@langchain/langgraph'
import type { GraphNode } from '@langchain/langgraph'
import { z } from 'zod'
const State = new StateSchema({
messages: MessagesValue,
})
const ContextSchema = z.object({
userId: z.string(),
})
const store = new InMemoryStore()
const callModel: GraphNode<typeof State> = async (state, runtime) => {
// 当前是谁,不适合塞进长期记忆里,
// 更适合通过 runtime.context 在每次调用时带进来
const userId = runtime.context?.userId
const namespace = ['users', userId ?? 'anonymous', 'profile']
// 节点里可以直接访问 runtime.store
const memory = await runtime.store?.get(namespace, 'preferences')
const preference = memory?.value?.tone ?? 'normal'
return {
messages: [
{
role: 'assistant',
content: `已读取到用户偏好,当前语气要求:${preference}`,
},
],
}
}
const graph = new StateGraph(State)
.addNode('callModel', callModel)
.addEdge(START, 'callModel')
.addEdge('callModel', END)
.compile({
store,
contextSchema: ContextSchema,
})这里有两处要一起看:
一处是 compile({ store }),没有这一步,图里的节点就拿不到长期记忆。
另一处是 runtime.store,节点运行时会从这里读写 Store。
5. 跨会话长期记忆,最常见的用法就是按用户取资料
上面那段只是读一条已有记忆。放回真实业务里,更常见的是按用户去取这份资料。
这时候 runtime.context 就开始有用了。你通常会把当前用户是谁放在这里,再据此拼出对应的 namespace。
typescript
// read-user-memory.ts
import {
StateGraph,
StateSchema,
MessagesValue,
START,
END,
InMemoryStore,
} from '@langchain/langgraph'
import type { GraphNode } from '@langchain/langgraph'
import { z } from 'zod'
const State = new StateSchema({
messages: MessagesValue,
})
const ContextSchema = z.object({
userId: z.string(),
})
const store = new InMemoryStore()
await store.put(['users', 'user_001', 'profile'], 'preferences', {
tone: 'short',
language: 'zh-CN',
audience: 'junior-developers',
})
const respondWithMemory: GraphNode<typeof State> = async (state, runtime) => {
const userId = runtime.context?.userId
const namespace = ['users', userId ?? 'anonymous', 'profile']
// 这里读的是用户长期偏好,不是当前线程里的临时状态
const item = await runtime.store?.get(namespace, 'preferences')
const tone = item?.value?.tone ?? 'normal'
const language = item?.value?.language ?? 'zh-CN'
const question = state.messages.at(-1)?.text ?? ''
return {
messages: [
{
role: 'assistant',
content: `我会用 ${language} 回复,并保持 ${tone} 风格。你刚刚的写作需求是:${question}`,
},
],
}
}
const graph = new StateGraph(State)
.addNode('respondWithMemory', respondWithMemory)
.addEdge(START, 'respondWithMemory')
.addEdge('respondWithMemory', END)
.compile({
store,
contextSchema: ContextSchema,
})
const result = await graph.invoke(
{
messages: [{ role: 'user', content: '以后回复我尽量简短一点。' }],
},
{
context: { userId: 'user_001' },
},
)
console.log(result.messages.at(-1)?.text)这一段里,线程可以换,thread_id 也可以换,但只要 userId 没变,图还是能读到这位用户之前留下来的资料。
这就是 Store 的主要价值。它记住的不是这条线程,而是这个用户。
6. 只会读取还不够,长期记忆还得会写回去
有了读取以后,下一步自然就是写回。
这件事的落点通常不是每一轮都写,而是在图里找到一个合适的节点,把本轮值得长期保存的信息整理一下,再写进 Store。
比如用户明确说了:
「以后文章都写给初中级开发者看。」
这句话就很适合落进长期记忆。
typescript
// write-user-memory.ts
import type { GraphNode } from '@langchain/langgraph'
const savePreference: GraphNode<typeof State> = async (state, runtime) => {
const userId = runtime.context?.userId
const namespace = ['users', userId ?? 'anonymous', 'profile']
const lastMessage = state.messages.at(-1)?.text ?? ''
// 这里只演示最小写法:检测到明确写作偏好后,写回 Store
if (lastMessage.includes('初中级开发者')) {
// 这一类信息适合长期保留,因为下一个新线程里仍然会用到
await runtime.store?.put(namespace, 'preferences', {
language: 'zh-CN',
tone: 'short',
audience: 'junior-developers',
})
}
return {
messages: [
{
role: 'assistant',
content: '我已经记住这个长期偏好了,后面的新会话里也会继续使用。',
},
],
}
}这里最需要注意的不是 put 本身,而是写回的时机。
长期记忆不适合把每一句话都原样塞进去。更稳的做法,是在图里找一个专门节点,只在信息值得长期保留时再写回。这样 Store 里留下来的内容才会更干净。
7. Search 让 Store 支持相似记忆召回
如果 Store 只会 get(namespace, key),它更像一个按名字取数据的存储层。
让它开始接近「长期记忆」体验的,是搜索。
当某个用户积累下来的长期资料越来越多时,你往往不会每次都知道自己要拿哪一条 key。更常见的情况是:
我想找出跟当前问题最相关的那几条记忆。
typescript
// search-memory.ts
import { InMemoryStore } from '@langchain/langgraph'
const store = new InMemoryStore()
const namespace = ['users', 'user_001', 'memories']
await store.put(namespace, 'm1', {
text: '用户偏好把文章写给初中级开发者',
kind: 'audience-preference',
})
await store.put(namespace, 'm2', {
text: '用户希望技术文章语气尽量简短直接',
kind: 'writing-preference',
})
// search 更像是在一组长期资料里找与当前问题有关的内容
const items = await store.search(namespace, {
query: '这篇文章应该写给什么读者',
})
console.log(items.map((item) => item.value.text))这里有一点要讲清楚:search() 适合的是「资料多起来以后,再找相关内容」。如果你现在只是在读某一个固定 profile,那么 get() 会更直接。
也可以把这三个动作简单分一下:
get()是按名字拿一条固定资料put()是把一条资料写进去search()是资料多起来以后,再按当前问题去找相关内容
8. 用在真实业务里时,最容易写歪的地方
最常见的问题,是把短期状态和长期记忆混在一起。明明只是这一轮图里的临时结果,却也想顺手塞进 Store。这样时间一长,Store 里就会堆满根本不该长期保留的内容。
第二个问题,是 namespace 设计得太随意。今天写成 ['user_001'],明天又写成 ['users', 'user_001'],后面数据一多,就很难再整理。长期记忆一旦开始真的落库,namespace 最好一开始就想清楚。
还有一个问题,是把 Store 当成数据库替代品。Store 当然可以保存结构化 JSON,但它最适合的是「和图运行直接相关、需要在节点里顺手读写的长期资料」。如果你要做特别复杂的业务查询、跨很多表的关联,那还是业务数据库更合适。
9. 总结
走到这里,LangGraph 这一章的视角又往前拉了一步。
前面几篇一直在处理线程里的状态和多 Agent 之间的协作,这一篇开始把时间轴拉长,处理跨会话的长期记忆。checkpointer 让同一条线程能继续,Store 让很多条线程之间也能共享需要长期保存的资料。
下一篇讲 错误处理与容错。到那时,问题会从「系统能不能记住东西」切到「系统出错以后,怎么别一下子整条图都断掉」。