LangGraph 记忆系统设计实战

0 阅读9分钟

LangGraph 记忆系统设计实战

从"失忆"痛点说起

想象一个场景:用户和 Agent 聊了 50 轮之后突然问一句"刚才说的那个方案怎么实现?",结果对方答:“抱歉,我不知道之前说了什么。”

这时候别急着怪 AI 笨。真正的问题是——长对话系统天生就带着三大矛盾

| 矛盾 | 根源 | 实际代价 | | --- | --- | --- | | 需要记住太多 vs 窗口有限 | LLM 通常 128K tokens 上限 | 超出后只能硬截断,直接丢掉 | | 完整历史成本高 vs 预算有限 | 1000 轮对话就是数百万 tokens | 成本从几美元飙升到几十美元 | | 信息密度低 vs 关键需求高 | 用户偏好埋在闲聊里 | 模型根本抓不住重点 |

这些矛盾光靠"把窗口调大"解决不了。真正可行的办法是用分层的方式设计记忆系统


第一层:短期记忆——把一次会话聊完整

State 到底怎么设计

LangGraph 靠 State(TypedDict)做数据的中枢。不过很多人拿到手就把 messages 往里扔,然后就没下文了。这里头其实有讲究。

推荐写法:

class AgentState(TypedDict):
    # 必选项
    messages: list[BaseMessage]     # 对话历史
    # 控制项(用于压缩/召回决策)
    should_summarize: bool
    user_id: str
    # 扩展项(根据业务需求添加)
    current_task: str
    task_history: list[dict]

这么设计的考虑:

  • messages

     是主链路,几乎所有节点都要用它

  • should_summarize

     是个开关信号,用来触发压缩逻辑

  • user_id

     作为长期记忆的钥匙,可以跨节点传递

Checkpoint:给对话做个快照

Checkpoint 最实在的作用就一个:用户关掉页面几小时后再回来,Agent 还能接着之前的话题聊。

它的运行逻辑是这样的:

图片

这三个字段最实用:

| 字段 | 干啥用的 | 长什么样 | | --- | --- | --- | | thread_id | 区分不同会话,避免串线 | "user-123" | | checkpoint_id | 标记这是第几轮保存 | "1ef2a..." | | configurable | 扩展配置用 | {"thread_id": "..."} |

这些情况都能用上:

  • 用户刷新页面回来接着聊

  • 同一个用户多条对话线并行跑

  • 调试的时候想看某个时刻的状态

MessageGraph 为什么这么设计

为啥要用 messages 列表,而不是一整个字符串打包塞给 LLM?核心原因有两个:可控和灵活。

图片

实际用的时候记住三点:

  1. 顺序怎么排

    :system prompt 开头,中间插摘要,旧消息往后挪,最后留最近 10 条

  2. 数量怎么控

    :最多保留 50 条,再多了容易爆窗口

  3. 要不要工具结果

    :生产环境基本不用,调试的时候再看


第二层:长期记忆——隔几天还能认出来

短期记忆管"一次会话不卡壳",长期记忆管的是"过了几天再来,Agent 还能接着之前的话茬聊"。

到底啥值得存?

不是每条内容都配进长期记忆。看这张表就明白了:

| 适合长期存 | 放短期就行 | | --- | --- | | 名字、偏好、个人设置 | 这轮的具体答案 | | 聊过的项目背景 | 随手查的临时数据 | | 拍板的技术方案 | 中间的脑补过程 |

快速判断法:想象一下明天用户问"我之前提过啥",这事儿值得 Agent 记着吗?值得就值得存。

三种存储方案,怎么取舍

长期记忆一般就这三套打法:

图片

Redis 怎么设计 Key

一个实用格式:user:{user_id}:prefs:preference_name,value 直接存内容就行。

比如:

user:user_123:prefs:name = "小明"
user:user_123:prefs:tech_preference = "简洁方案优先"
向量库怎么存、怎么查

图片

什么时候该召回记忆

不用每条消息都去查长期记忆,那样 token 烧得飞快,响应也慢。常见有四招:

| 策略 | 啥时候用 | 好处 | 坑 | | --- | --- | --- | --- | | 抓关键词 | 出现"之前"“上次”"记得"这些词 | 直接命中需求 | 得靠用户会说话 | | 会话开局 | 前 5 条消息内 | 主动调画像出来 | 有时候没必要 | | 定时扫一眼 | 每隔 N 轮一次 | 性能和体验折中 | 有延迟 | | 抓实体 | 检测到人名、项目名 | 可能有旧账可翻 | 容易误报 |

旧记忆该怎么处理

存久了不处理,存储迟早爆。有个经验做法是按时间加权:

| 存放多久 | 权重给多少 | 怎么召回 | | --- | --- | --- | | 7 天内 | 1.0 | 随便用,频率拉满 | | 7-30 天 | 0.5-1.0 | 每周打个九折 | | 30 天以上 | 0.1-0.5 | 低调点,低频召回 | | 半年+ | 0.1 | 归档或者直接删 |


第三层:上下文压缩——窗口不够用时的补救措施

短期 + 长期记忆都上了,为啥还要压缩?原因很简单:就算只存 messages,一个会话聊到 100 轮也是常事,到了那会儿就得靠压缩腾地方。

啥能删,啥得留着

打死不能动:

  • System Prompt:这是 AI 的行为底线

  • 用户真实需求:现在到底想干啥

  • 硬约束条件:预算、时间、技术栈这些

  • 已经拍板的决定:“就用 React”

可以放心压缩的:

  • 中间的思考过程

  • 反复确认过的细枝末节

  • 被新信息覆盖掉的旧背景

  • 啰嗦重复的表达

压缩和 RAG,怎么选?

这俩不是非此即彼的关系。看你的使用场景来定:

| 场景 | 选哪个 | 为啥 | 常见例子 | | --- | --- | --- | --- | | 客服聊天 | 上下文压缩 | 得记住前后问题的关联 | 查订单、售后咨询 | | 知识问答 | RAG | 答案在外部文档里 | 产品手册、API 文档 | | 超长大对话 | 压缩 + RAG | 历史要记,外部知识也要 | 技术顾问、编程助手 | | 个性化助手 | 长期记忆 | 关键是记住用户偏好 | 私人助理、健身教练 |

压缩是怎么跑的

图片

压缩的关键就是让 LLM 自己概括,prompt 大致是这样:

把下面这段对话压缩成 bullet points,只要三样东西:
1. 用户真正想要啥
2. 已经拍板的决定
3. 重要的限制条件
[旧消息放这儿]

实战:搭一个完整的全能记忆 Agent

前面章节分别讲了短期、长期和压缩,现在把它们拼成一整个系统

整体长啥样

图片

每个组件干啥的

| 组件 | 负责啥 | 操作哪些 State 字段 | 依赖啥存储 | | --- | --- | --- | --- | | recall_node | 根据用户问题拉长期记忆 | user_id | Redis+ 向量库 | | chat_node | 调用 LLM 生成回复 | messages | - | | compress_node | 消息太多就压缩历史 | messagesshould_summarize | - | | save_node | 聊完把重要信息存下来 | user_idmessages | Redis+ 向量库 | | checkpointer | 每一步都自动存档 | 所有字段 | Postgres/Memory |

关键节点怎么写

1. 召回节点怎么干
def recall_memory(state: AgentState) -> dict:
    """
    新消息进来先拉长期记忆,把相关背景塞进去
    """
    user_id = state["user_id"]
    last_message = state["messages"][-1].content
    # 第一步:Redis 拿用户的固定偏好(准没错)
    preferences = redis_client.hgetall(f"prefs:{user_id}")
    # 第二步:向量库找语义相关的历史(带点模糊匹配)
    relevant_docs = vectorstore.similarity_search(last_message, k=3)
    # 第三步:把两者合一块
    memory_context = []
    if preferences:
        memory_context.append("【用户偏好】\n" + "\n".join(preferences.keys()))
    if relevant_docs:
        memory_context.append("【历史相关】\n" + "\n".join(d.page_content for d in relevant_docs))
    # 第四步:把拼好的东西塞进系统提示
    if memory_context:
        system_msg = SystemMessage(content="\n\n".join(memory_context))
        return {"messages": [system_msg] + state["messages"]}
    return {}

这么写的考虑:

  • Redis 先上:名字、设置这些固定信息,基本不会变

  • 向量库跟进:找历史上类似的情况,带点模糊匹配

  • 最后合流:把两块内容拼好,一起丢给 LLM

2. 压缩节点怎么干
def compress_node(state: AgentState) -> dict:
    """
    消息太多时,把旧的消息压成摘要
    """
    # 留最近 10 条原文,剩下的压缩
    old_messages = state["messages"][:-10]
    recent_messages = state["messages"][-10:]
    summary = llm.invoke(f"""
    把下面这段对话压成 bullet points,只要三样:
    1. 用户真正想要啥
    2. 已经拍板的决定
    3. 重要的限制条件
{format_messages(old_messages)}
    """)
    return {
        "messages": [
            SystemMessage(content=f"【历史摘要】\n{summary.content}")
        ] + recent_messages
    }

这么写的考虑:

  • 原文只留最近 10 条,其余全部压缩

  • 摘要标成 SystemMessage,后面好识别

  • 30 条这个阈值是经验值,按你的窗口大小调

3. 记忆怎么保存
def save_memory_node(state: AgentState) -> dict:
    """
    聊完了就把关键信息拎出来存长期记忆
    """
    user_id = state["user_id"]
    # 提偏好:发现新 info 就存 Redis
    extract_and_save_preferences(state)
    # 存摘要:丢进向量库,以后靠语义找
    conversation_summary = generate_conversation_summary(state["messages"])
    vectorstore.add_texts(
        [conversation_summary],
        metadatas=[{"user_id": user_id, "type""conversation""date": now()}]
    )
    return {}

这么写的考虑:

  • 偏好提取:盯着对话里有没有新的人物信息,有就更新 Redis

  • 摘要保存:每轮对话都存个短摘要,攒久了就是语义记忆库

数据咋流转的

图片

实际跑起来啥样

第一回聊 (2026-01-01):
用户:我叫小明,喜欢简洁的技术方案
AI: 很高兴认识小明!简洁确实是高效的选择。我会按这个偏好推荐方案。
→ Redis 存下:user:user_123:prefs:name = "小明"Redis 存下:user:user_123:prefs:tech_pref = "简洁方案优先"
→ 向量库记一笔:对话摘要 v1
--- 过了三天 ---
第二回聊 (2026-01-04):
用户:帮我选个框架
AI: 根据你的偏好(你喜欢简洁的方案),我建议 Next.js...
→ 先从 Redis 捞出用户名字和偏好
→ 再查向量库找到之前的讨论
→ 合起来给个个性化回复
第三回聊 (2026-01-10):
用户:我之前说过啥偏好?
AI: 你之前提过:"我喜欢简洁的技术方案"
→ 向量库翻出历史对话
→ 引用原文回复

实战经验总结

按需求挑方案

| 想干啥 | 第一层选啥 | 第二层补啥 | 复杂程度 | | --- | --- | --- | --- | | 基础对话恢复 | Checkpoint (MemorySaver) | - | ★☆☆ | | 跨会话简单场景 | Checkpoint (PostgresSaver) | Redis(偏好) | ★★☆ | | 完整用户画像 | 全套 | Redis+ 向量库 | ★★★ | | 企业级应用 | 全套+RAG | 接知识库 | ★★★★ |

五条实操建议

  1. 先拿 Checkpoint 上手:最简单的方案往往最实用,能搞定 80% 的场景。真遇到跨进程/多实例共享时再切 PostgresSaver 也不迟。

  2. 长期记忆要有写入策略:别一股脑全塞进去。每轮对话结束拎出 3-5 条核心的就够,定期把三个月前的旧账清一清。

  3. 压缩阈值设个预警线:30-50 条是个参考值,别等到爆满了才动手。根据实际窗口大小提前定好红线。

  4. 盯着 token 成本:长期记忆 + 压缩都会多烧 token。最好做个成本看板,心里有数。

  5. 分层做记忆系统:工作内存 (当前对话) + 情景记忆 (会话恢复) + 语义记忆 (用户画像),各司其职。

还能往哪深化

  • 记忆索引

    :按时间/主题/重要性建多级索引

  • 自我反思

    :让 Agent 定期整理记忆,去重合并

  • 选择性遗忘

    :像人一样,自动丢掉低权重记忆

  • 多模态记忆

    :支持图、音频多种类型


好用的对话系统不是"啥都记得",而是记得该记的

分层设计 + 按需召回,这才是正经的工程化思路。