Skip to content

你的 AI 应用一发版,用户记忆全没了 ​

上篇《手写 Agent 循环 vs LangGraph:框架到底替我做了什么》结尾我留了个问题:LangGraph 的 Checkpointer"一行配置就有多轮记忆",但那行配置背后,状态到底被存哪了?那篇把"手写 while 循环"和"图 + ToolNode + checkpointer"逐行对账,结论是框架替你接管了循环——这篇就接着把那行 compile({ checkpointer }) 拆开,看它到底把状态塞进了哪里。 这一篇回答它。答案短得吓人:存在一个 JS 变量里。 而这个答案在生产上意味着什么——后端一发版,所有用户的对话记忆瞬间清空——正是这篇要展开的事故现场。

🎯 本文目标

读完你应该能:亲手复现"两次运行之间 AI 失忆"的实验,并说清失忆的不是 thread_id 而是"档案柜本身没了";分清 checkpointer 的"接口"和"实现",知道从开发切到生产只需要换一个实例、图代码一行不动;最重要的——往后看到任何"一行配置就搞定 X"的框架能力,都会条件反射地问一句:它把数据放哪了?


写在前面:这篇不教新功能,只拆一个隐患 ​

前面几篇文章,不管是手写 RAG 踩坑实录(自己搭检索、跑完脚本就退出)还是手写 Agent 循环(while 循环跑完就退出),都有个共同的舒适区:代码跑完就退出,天然不需要考虑"状态放哪"。 每次运行都是崭新的开始,也就无所谓"丢"。

但多轮记忆这个功能,第一次把"状态要活得比一次运行更久"这个需求摆上了台面。而 LangGraph 给的方案看起来美好得过分:

javascript
import { MemorySaver, StateGraph, MessagesAnnotation, START, END } from '@langchain/langgraph'
import { ToolNode } from '@langchain/langgraph/prebuilt'

// 先搭好"图":agent 节点 + tools 节点 + 条件边(和上一篇手搭的完全一样)
const builder = new StateGraph(MessagesAnnotation)
  .addNode("agent", callAgent)        // callAgent 里调模型,问 AI
  .addNode("tools", toolNode)         // 执行工具、回填结果
  .addEdge(START, "agent")
  .addConditionalEdges("agent", shouldContinue, { tools: "tools", [END]: END })
  .addEdge("tools", "agent")

const checkpointer = new MemorySaver()
const graph = builder.compile({ checkpointer })   // ← 就这一行,多轮记忆有了
javascript
// 同一个 thread_id = 同一段对话,历史自动接上
const cfg = { configurable: { thread_id: 'user-42' } }
await graph.invoke({ messages: [{ role: 'user', content: 'amount 含税吗?' }] }, cfg)
await graph.invoke({ messages: [{ role: 'user', content: '那不含税的字段是哪个?' }] }, cfg)
// 第二轮 AI 能接住"那"字的指代——它记得第一轮聊过 amount

第二轮我只传了新问题、没传历史,State 却把上一轮接了回来。用代码验证一下消息累积情况:

javascript
const r2 = await graph.invoke(
  { messages: [{ role: 'user', content: '那不含税的字段是哪个?' }] },
  cfg
)
console.log(r2.messages.map(m => [m.getType?.() ?? m.role, m.content]))

实际输出:

[ 'human', 'amount 含税吗?' ]            // 第一轮:你只传了这 1 条
[ 'ai', '...' ]                          // 第一轮模型回复
[ 'tool', '...字段元数据...' ]
[ 'ai', '第一轮最终回答...' ]
[ 'human', '那不含税的字段是哪个?' ]      // 第二轮:你又只传了这 1 条
[ 'ai', '...' ]
[ 'tool', '...' ]
[ 'ai', '能接住”那”字指代的最终回答...' ]

注意 SYSTEM 消息是调模型时通过 model.invoke([SYSTEM, ...state.messages]) 单独塞进去的,不进 state.messages——所以打印里看不到它,不是丢了。

🔍 callAgent 是怎么处理 SYSTEM 消息的?(不影响主线,可跳过)

callAgent 会在每次调用模型时临时拼入 SYSTEM,但只把模型回复写回 State:

javascript
// agent 节点:问模型(SYSTEM 临时拼入,不存进 state.messages)
const SYSTEM = new SystemMessage(`你是一个数据治理助手,可以调用工具完成任务…`)

async function callAgent(state) {
  const reply = await model.invoke([SYSTEM, ...state.messages])
  return { messages: [reply] }
}

也值得注意:不要只看模型”答没答对”来验证记忆。 第二轮问题本身信息量够时,模型可能靠常识猜中”那不含税的字段”——失忆和猜中,在回答层面长得一模一样,是假阳性。更可靠的判据是:result.messages 里第一轮的 human 消息还在不在。

爽是真爽——爽到我压根没问一个该问的问题:我没传历史,它从哪拿到的历史?


💥 第 1 章:事故复现——同样的代码,第二次运行就失忆 ​

🎯 本章目标

用两次脚本运行,亲眼看到"记忆"消失的瞬间。

实验:把两轮对话拆到两次运行里 ​

上面的代码里,两轮 invoke 写在同一个脚本里、一口气跑完。现在做一个小改动:把两轮对话拆到两次脚本运行里,thread_id 保持完全一致:

bash
# 第一次运行:只问第一轮的问题
node 你的脚本.js "amount 含税吗"
# 进程 A 启动 → new MemorySaver() → 存入对话 → 进程 A 退出 → Map 随进程蒸发

# 第二次运行:用同一个 thread_id 问第二轮
node 你的脚本.js "那不含税的字段是哪个?"
# 进程 B 启动 → 又 new 了一个全新的空 MemorySaver

怎么验证记忆有没有接上?在 invoke 之后加两行,检查 State 里的消息总数和第一轮的历史:

javascript
const result = await graph.invoke(
  { messages: [{ role: 'user', content: 'amount 含税吗?' }] },
  cfg
)
console.log('State 消息数:', result.messages.length)
console.log('包含第一轮历史:', result.messages.some(m =>
  typeof m.content === 'string' && m.content.includes('amount 含税吗')
))

实际跑出来的结果对比:

第一次运行(同进程两轮 invoke)              第二次运行(新进程,同 thread_id)
─────────────────────────────               ─────────────────────────────
State 消息数: 8                              State 消息数: 4
包含第一轮历史: ✅ 是                        包含第一轮历史: ❌ 否

第二次消息数只有 4(第一轮是 4,两轮累积应为 8)——说明进程 A 的存档在进程 B 里完全不可见。同一个 thread_id,换了次进程,存档就没了。 模型也完全接不住”那”字的指代,因为它的上下文里根本没有”amount 含税吗”这段历史。

时间线:存档随进程蒸发 ​

第一次运行:进程 A 启动 → new MemorySaver()(内存里建了个 Map)
           → 对话状态存进 Map → 脚本跑完,进程 A 退出
           → 💥 Map 随进程蒸发

第二次运行:进程 B 启动 → 又 new 了一个全新的、空的 MemorySaver
           → thread_id 传得一模一样也没用——上一次的存档根本不存在了

注意这里最容易归因错的地方:不是 thread_id 没配对,是存档本身已经随进程 A 死了。 thread_id 只是档案柜的抽屉编号,MemorySaver 才是档案柜本身——柜子都没了,编号对不对无所谓。

🧭 承上启下

失忆复现了。要理解它为什么必然发生,得回答开头那个被爽感淹没的问题:new MemorySaver() 到底把状态存哪了?


🔍 第 2 章:拆开看——MemorySaver 就是个 Map ​

🎯 本章目标

看清 MemorySaver 的本质,并用一个前端最熟的对照把问题钉死。

MemorySaver 听名字挺唬人,“Saver”嘛,总归是把东西“存”起来了。但从生命周期看,它本质是进程内存存储:

javascript
const checkpointer = new MemorySaver()
// checkpoint 只活在当前 JS 进程内;进程退出,实例和数据一起消失

把它类比成 Map 有助于理解生命周期,但不要把“内部源码就是一个 Map”当成严格结论。实现里还要维护 checkpoint、writes 等结构;本文关心的是更稳定的事实:它不落盘、不跨进程。 “Memory”在这里首先指内存(RAM),不是一份天然持久的用户记忆。

对前端来说,这个问题其实旧得不能再旧了:

前端世界LangGraph 世界
存 JS 变量let data = {...} → 刷新页面就没了MemorySaver → 进程退出就没了
持久化localStorage / 后端数据库 → 刷新还在持久化 checkpointer(下一章) → 重启还在

写页面时你绝不会把用户的登录态只存在一个 let 变量里指望它活过刷新——MemorySaver 干的就是这件事,只是"刷新页面"换成了"进程重启",所以不容易警觉。

💡 一句话抓住本质

MemorySaver = 把对话记忆存在 JS 变量里。它不是不能用,是只能活在"进程不死"的假设里——而生产环境,进程一定会死。

🧭 承上启下

"进程一定会死"不是吓唬人。看看生产环境里,进程都是怎么死的。


🚨 第 3 章:为什么这在生产上是事故,不是瑕疵 ​

🎯 本章目标

把"脚本跑两次失忆"翻译成生产场景,看清它的真实代价。

真实产品的对话服务长这样:

用户在网页上和 AI 聊天 → 后端服务重启 → 用户回来继续聊

而"服务重启"在生产上不是意外,是日常:

  • 发版:每次部署新代码,进程重启——如果用 MemorySaver,每发一次版,全体用户失忆一次;
  • 崩溃重启:进程 OOM 或异常退出,守护进程拉起来——记忆没了;
  • 扩容 / 负载均衡:起了第二个实例,用户这次请求落在实例 A、下次落在实例 B——B 的 Map 里根本没有他的存档,连重启都不需要,多实例本身就失忆。

用户视角是什么体验?前一分钟还在和 AI 深入聊需求,后一分钟 AI 变成完全的陌生人,"我们聊到哪了"变成"您好,请问有什么可以帮您"。这不是体验瑕疵,是数据丢失事故——对话上下文就是这个产品的用户数据。

而且它有个格外阴险的特点:开发阶段永远发现不了。 本地调试时进程一直活着,多轮对话一切正常;demo 演示也没问题;直到上线后第一次发版,事故才第一次发生——并且日志里没有任何报错,因为"Map 被回收"在程序看来天经地义。

🧭 承上启下

诊断清楚了:病根不在"记忆"这个功能,在"档案柜放错了地方"。解法自然是——换个柜子。


🔧 第 4 章:解法——换个"档案柜",图代码一行不动 ​

🎯 本章目标

看懂 checkpointer 的接口设计:开发和生产的差别,只是传给 compile 的实例不同。

LangGraph 这套设计里真正值得学的,是它把"存档"抽象成了接口:compile({ checkpointer }) 只认接口,不关心背后是内存还是数据库。所以从开发切到生产,是这样的:

javascript
// 开发/演示:存进程内存,进程死了就没
const checkpointer = new MemorySaver()

// 生产:存档落到数据库;首次使用需要建表迁移
// npm i @langchain/langgraph-checkpoint-postgres
import { PostgresSaver } from '@langchain/langgraph-checkpoint-postgres'
const checkpointer = PostgresSaver.fromConnString(process.env.DATABASE_URL)
await checkpointer.setup()

// ↓ 图的代码结构一行不用改,差异只剩“存哪”
const graph = builder.compile({ checkpointer })

换成数据库 checkpointer 后,第 1 章那条时间线会变成:

第一次运行:进程 A → 状态写进 Postgres → 进程 A 退出 → 数据还在库里 ✅
第二次运行:进程 B → 拿 thread_id 去库里查 → 查到了 → 记忆接上 ✅

不过要把话讲完整:存档层换了,只是把“进程重启/换机器导致丢失”这件事从应用层挪到了数据库层。 它不自动解决所有上线问题:多实例要都能连同一个库、迁移要纳入部署流程、thread_id 要有长度与冲突约束、长对话还要考虑 checkpoint 清理。前端最贴近的对照不是 localStorage 的“刷新还在”,而是把状态从“组件内存”挪到“后端存储”——读写代码大体不变,变的只是数据活在哪一层、由谁负责不丢。

🤔 几个可能被追问的点(点开看)
  • 那 MemorySaver 存在的意义是什么? 本地开发、单元测试、demo 演示——所有"进程活着就够了"的场景。它的问题不是设计缺陷,是被用错了地方。
  • 除了 Postgres 还有别的选择吗? 有,checkpointer 是个接口,官方和社区有 SQLite、Redis 等实现。选型逻辑和前端选 localStorage / IndexedDB / 后端存储一样:看数据要活多久、多大、几个实例要共享。
  • 多实例场景光换 Postgres 就够了吗? 存档层够了——所有实例读写同一个库,落在哪个实例都能查到。但会话粘性、并发写同一个 thread 这些问题是另一层的事,不是 checkpointer 一个人能扛的。

🎁 结语:值得带走的不是"换 Postgres",是那个反射弧 ​

这个坑填完,具体解法其实一句话就说完了:开发用 MemorySaver,生产换持久化 checkpointer。 但如果只带走这一句,这篇就白读了。

真正值钱的是那个被爽感淹没的问题。回头看,我在"一行配置就有多轮记忆"的那一刻就该问:状态存哪了?进程重启还在吗?多实例共享吗?——这三连问不只属于 LangGraph:

  • 某框架"内置缓存,零配置加速"——缓存在哪?重启还热吗?
  • 某库"自动记住用户偏好"——记在哪?换台机器还记得吗?
  • 某服务"开箱即用的会话管理"——会话落盘了吗?

💎 只记一句话也够

任何"一行配置就搞定"的状态类能力,都欠你一个交代:数据放哪了。它不主动说,你就主动问——在发版之前问,而不是在用户失忆之后。

上一篇说,调库和懂框架的分水岭是能说清"它替我做了什么";这一篇再补半句:还要能说清"它替我把数据放哪了"。 前者决定你会不会用,后者决定你敢不敢上线。

状态存对了,Agent 才算站得住;但用户真正天天感受到的,还有另一层——多步编排那 10 秒等待里,界面该给他看什么。这个产品视角的问题,我写在了同系列的第三篇《多步 Agent 转圈 10 秒,答案砸脸上:AI 应用的等待体验设计》。

原创声明

本文首发于我的个人博客。如需转载请注明作者及 原文链接。

👀 本文阅读 ··· 次

基于 VitePress 构建

本站总访问量 ··· 次·访客数 ··· 人