深入学 LangChain 官方文档(九)Memory 记忆系统首讲

本篇对应的官方文档

  • Short-term memory:线程范围 State、checkpointer、thread_id 以及长消息的裁剪、删除和摘要。
  • Long-term memory:跨线程 Store、JSON 文档、namespace + keyruntime.store 读写路径。
  • Memory overview:长期记忆类型、profile / collection 组织方式,以及主链路和后台写入的取舍。

本篇讲解范围

本篇主要学习建立线程内短期记忆、跨线程长期记忆、两者与业务数据库的边界,以及最小代码闭环。记忆抽取模型、向量检索优化、复杂摘要策略和数据库部署细节留给后续专题。

我们来看这样一个场景,客服 Agent 刚刚帮用户查完退款进度,用户过了十分钟又问:“那还要多久?”它需要知道“那”指的是刚才的退款单商品。几天后,用户打开了一个新会话,希望系统仍然按自己之前习惯的方式回答,要求智能体像之前一样回复简洁、并且要先给结论,然后进行详细解释说明。

这两个需要“记住”的场景听起来相似,但工程上却不是一回事。前者依赖当前会话的连续状态,后者要跨越会话边界复用用户偏好。如果在设计中把订单金额、退款状态也混进同一套记忆,问题会更严重,会出现模型记住的旧值可能覆盖业务系统中的最新事实的严重后果。

所以,Memory 的起点不是“选哪个数据库”,也不是“把历史消息都塞回模型”。真正最需要考虑的的是:一条信息以后会在哪个范围被再次读取,它由谁维护,失效后又由谁负责删除或修正?

本篇文章将深入学习LangChain官方文档的Memory记忆系统,我们将沿着下面这条主线展开:

  1. 先判断记忆范围,再看 State + checkpointer + thread_id 如何延续当前线程;
  2. 随后进入 Store + namespace + key,解释跨线程信息怎样被组织;
  3. 最后把长对话治理、写入时机和生产边界合并成一套判断方法。

同一用户的当前会话与新会话
我们可以像图中一样把同一用户的两次会话拆成两条读取路径:当前退款任务沿 thread_id 恢复,新会话中的表达偏好从跨线程 Store 读取。两条路径可以同时服务一个 Agent,但它们的生命周期、隔离键和数据责任不同。

而要把这两条路径落到系统里,第一步不是继续拆存储接口,而是先给待处理信息确定去向。

一、Memory 不是一个盒子,而是一组“再次读取”规则

很多记忆方案一开始就列举各种技术名词:消息历史、缓存、向量数据库、用户画像、摘要。但名词越多,越容易忽略一个基本事实:信息被保存,不等于它应该在下一次推理中出现。

例如,用户本轮上传的一次性验证码只对当前请求有效;退款任务的中间状态需要在同一会话里延续;“偏好中文简洁回答”可能跨会话复用;真实退款状态则必须回到订单系统查询。它们都属于“Agent 可能需要的信息”,但不应该进入同一个容器。

我们可以先用四个问题判断去向:

  1. 这条信息只在本次调用有效,还是下次调用还要读取?
  2. 下次读取发生在同一线程,还是任意新线程?
  3. 它是辅助模型决策的记忆,还是业务系统的权威事实?
  4. 它是否允许被用户查看、修正、遗忘或删除?

这样的话,一次性验证码、线程任务、回答偏好和退款状态,会按照这四个问题分别落到 Runtime Context、State、Store 与业务数据库。

Agent 信息去向四分法
四个模块对应的是数据职责,不是四种可互换的存储产品:Runtime Context 提供本次运行的稳定依赖,State 承接线程内变化,Store 保存跨线程可复用记忆,业务数据库维护订单等权威记录。判断错位置,后续再精细的检索和提示词也只能放大错误。

官方文档中这套分法也连接了前两篇内容。第 07 篇讲清了 Runtime Context、State 与 Store 的容器边界,第 08 篇讨论哪些上下文应在什么步骤进入模型。Memory 在此基础上进一步一步:哪些信息离开当前步骤后仍值得保留,并且未来怎样安全地取回来。

二、短期记忆:让同一条线程能够继续

LangChain 官方文档把短期记忆定义在线程范围内。它通常是 Agent State 的一部分,其中最常见的字段是 messages,也可以扩展出当前任务编号、已经确认的参数或会话阶段。

只把字段写进 State 还不够,进程结束、请求返回或服务重启之后,内存里的 Python 对象不会自动存在。checkpointer 的职责就是在执行步骤之间保存状态快照,并在后续调用时恢复它。调用方再用 thread_id 指定“这次请求属于哪一条线程”。

三者可以这样记:

  • State 是内容:当前线程已经发生了什么。
  • checkpointer 是持久化机制:状态怎样保存、怎样恢复。
  • thread_id 是隔离与寻址键:本次调用应接到哪条线程上。

面对退款追问的问题,我们就可以把三者串成了具体执行顺序:先按 thread_id 找线程,再由 checkpointer 取回 State,最后把新消息接到旧任务之后。

checkpointer 恢复同一线程
恢复链从第一次调用结束时写入状态快照开始;第二次请求携带相同 thread_id,checkpointer 先取回旧 State,再把新消息接入 Agent loop。模型能够理解“那还要多久”,依赖的不是模糊的记忆能力,而是明确的线程寻址和状态恢复。

官方文档还强调,State 会在每个执行步骤开始时被读取,并在调用或步骤推进过程中更新。这意味着短期记忆并不是只在聊天结束后统一落盘。模型调用、工具执行、Command 写回 State,都可能形成新的状态版本。

如果消息之外还有必须精确延续的字段,可以扩展 AgentState。例如把 refund_idcurrent_stageconfirmed_email 分开保存,这要比每次让模型从自然语言历史中重新猜测更稳定。工具读取这些字段时会通过 ToolRuntime.state,需要更新时返回 Command(update=...)。这里要注意:只有后续步骤确实依赖、并且拥有清晰更新规则的数据才值得进入 State;把每个临时变量都持久化,只会让恢复语义越来越难解释。

这种步骤级持久化会带来一个很实用的能力:长任务中途失败后,可以从已有状态继续,而不必把整个 Agent loop 从头重跑。不过,可恢复不等于任意重放都安全。在客服场景中,如果某个步骤已经真实提交退款,恢复时再次执行同一工具,就可能发生重复写入。记忆层保存“发生过什么”,业务工具仍要负责幂等键、事务和重复提交检查。

三、thread_id 决定连续性,也决定隔离边界

在客服场景中,前端通常会为每个会话页生成稳定的线程标识,用户在同一会话继续追问时复用这个标识,切换到新会话则创建新的 thread_id。两次请求是否属于同一用户,并不能代替线程边界。

如果把 user_id 直接当成唯一 thread_id,用户的多个并行任务会被塞进同一条消息历史:上午咨询退款,下午修改地址,模型可能把两个任务的工具结果交叉引用。反过来,如果每一次 HTTP 请求都生成新标识,短期记忆根本无法恢复,“那笔退款”自然失去指代对象。

thread_id 的隔离边界
隔离图把“用户身份”和“会话线程”拆开:一个 user_id 可以拥有多条 thread_id,每条线程维护独立 State。复用同一标识得到连续性,错误复用会串线,频繁更换则让上下文断裂。

最小调用结构并不复杂。关键不是 config 这几行代码本身,而是应用层必须稳定维护线程生命周期。

from langgraph.checkpoint.memory import InMemorySaver
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI

model = ChatOpenAI(
    model="qwen3.7-plus",
    api_key="YOUR_API_KEY",
    base_url="YOUR_OPENAI_COMPATIBLE_ENDPOINT",
)

agent = create_agent(model=model, checkpointer=InMemorySaver())

thread_config = {"configurable": {"thread_id": "refund-thread-20260718"}}

agent.invoke(
    {"messages": [{"role": "user", "content": "查询退款单 RF-2048"}]},
    thread_config,
)
result = agent.invoke(
    {"messages": [{"role": "user", "content": "那还要多久?"}]},
    thread_config,
)

第二次 invoke 不需要手工把第一次的全部消息重新拼接进去;checkpointer 会依据 thread_id 恢复线程状态。演示使用 InMemorySaver,适合本地运行和测试。服务重启后仍要恢复的生产系统,应换成数据库支持的 checkpointer,并把连接、迁移、备份和保留周期纳入运维。

四、长期记忆:跨线程保存可复用信息

在之前的场景问题中,用户几天后新建会话时,新的 thread_id 不应该自动继承旧线程的全部 State。旧会话里可能有临时地址、过期工具结果,甚至属于另一个任务的敏感内容。此时如果确实需要复用“偏好中文、回答简洁”,就进入长期记忆的范围。

LangChain 的长期记忆建立在 LangGraph Store 上。Store 保存 JSON 文档,并用 namespacekey 组织它们:

  • namespace 像分层目录,用来表达用户、团队、应用或记忆类型等作用域。
  • key 标识该命名空间内的一条具体记录。
  • value 是 JSON 结构,可以保存偏好、事实、摘要或其他可序列化内容。

例如,("users", "u-1024", "preferences") 可以作为某位用户的偏好命名空间,response_style 是其中一条记忆的 key,值为 {"language": "zh-CN", "verbosity": "concise"}

namespace 与 key 组织长期记忆
层级结构先用 namespace 限定“谁的哪类记忆”,再用 key 定位具体文档。namespace 设计同时承担检索范围与权限边界;如果所有用户都写入同一平面空间,数据串读风险会比检索精度问题更早出现。

官方概念文档把长期记忆进一步分成三类:

  • Semantic memory 保存事实,例如用户偏好;
  • episodic memory 保存经历或示例,例如过去成功处理某类工单的过程;
  • procedural memory 保存行为规则,例如系统指令或流程约束。
    这个分类适合帮助建模,但首讲不必为每一类都搭一套数据库。先把作用域、权威来源和更新方式定义清楚,通常比先选向量索引更重要。

Semantic memory 还可以按 profile 或 collection 组织。profile 把一组字段维护在同一份 JSON 文档里,读取简单,适合结构稳定的用户画像;但每次更新都要正确合并整份结构,字段越来越多时更容易覆盖旧值。collection 把事实拆成多条独立记录,新增和搜索更灵活,也更容易保留每条记忆的来源,不过需要处理重复、冲突与过期项。客服偏好的语言和详细程度适合小型 profile,大量互不相关的历史兴趣则更接近 collection。选择哪一种,取决于更新方式,不取决于哪个名字更像“高级记忆”。

同时,需要注意的是长期记忆也不必在每次模型调用前全量加载,Store 支持按 namespace 读取具体 key,也可以搜索相关记忆。进入模型的仍应是本次任务所需的最小集合。否则“跨线程可用”很快会变成“每次都把用户全部历史塞进 prompt”,成本、隐私和冲突都会一起上升。

五、工具通过 ToolRuntime 访问 Store

在Store 被传给 Agent 后,工具可以从 ToolRuntimestore 属性访问它。ToolRuntime 参数对模型隐藏,不会出现在工具 schema 中;模型只需要决定是否调用“记录偏好”或“读取偏好”,不需要生成数据库对象、当前用户标识或连接信息。

这里仍然需要 Runtime Context 提供当前调用者身份。工具从 runtime.context.user_id 得到可信的用户 ID,再据此构造 namespace。如果把 user_id 暴露成模型可填写的普通工具参数,模型一次错误填参就可能读写到别人的记忆空间。

runtime.store 的读写路径
读写链把权限来源和模型参数分开:应用注入 user_id 与 Store,ToolRuntime 将它们交给工具,工具构造 namespace 后执行 putgetsearch。模型能选择记忆动作,却不能自行指定隐藏的身份边界。

下面两个工具只处理一类明确记忆:回答风格。其中工具名称和 docstring 让模型知道何时调用,真正的寻址规则留在应用代码里。

from dataclasses import dataclass

from langchain.tools import ToolRuntime, tool


@dataclass
class SupportContext:
    """保存当前客服调用中由应用可信注入的用户身份。"""

    user_id: str


@tool
def remember_response_style(
    language: str,
    verbosity: str,
    runtime: ToolRuntime[SupportContext],
) -> str:
    """记录用户明确要求长期沿用的回答语言和详细程度。"""

    namespace = ("users", runtime.context.user_id, "preferences")
    runtime.store.put(
        namespace,
        "response_style",
        {"language": language, "verbosity": verbosity},
    )
    return "回答偏好已记录"


@tool
def read_response_style(runtime: ToolRuntime[SupportContext]) -> dict:
    """读取当前用户已经保存的回答风格偏好。"""

    namespace = ("users", runtime.context.user_id, "preferences")
    item = runtime.store.get(namespace, "response_style")
    return item.value if item else {"language": "zh-CN", "verbosity": "normal"}

写入路径是“模型提出记忆动作 → 工具使用可信身份构造 namespace → Store 保存 JSON 文档”;读取路径则反向返回 value。工具返回的字符串或字典会成为本轮工具结果,但长期记忆本体保存在 Store 中,不会因为当前线程结束而消失。

六、Memory 不能替代业务数据库

到了这里,最容易出现的理解误区是:既然 Store 可以保存 JSON,那订单状态、账户余额和退款结果是不是也可以一起放进去。技术上当然能写,但工程上却不应该让它成为权威来源。

记忆服务的是 Agent 决策和交互连续性,允许经过抽取、摘要、搜索和自然语言更新。业务数据库则要维护事务、一致性、审计、权限和最新状态。用户说“我记得退款金额是 199 元”可以作为对话上下文,但最终金额必须查询订单系统;过去工具结果里写着“审核中”,也不能覆盖业务系统刚更新的“已到账”。

Memory 与业务数据库的权威边界
上面的边界图中,把偏好、对话摘要放在 Memory 一侧,把订单、余额、退款状态放在业务数据库一侧。Agent 可以用记忆决定怎样提问和解释,但涉及权威事实时必须重新调用业务工具,并以实时查询结果为准。

我们可以用一个实用思路来做判断:如果数据错误会直接改变资金、权限、库存、合规结论或不可逆操作,它就不应只存在于模型记忆中。即便为了提速建立缓存,也要保留明确的源系统、过期时间和重新校验路径。

同样,Runtime Context 也不等于长期记忆。当前请求的数据库连接、租户标识和权限对象可以通过 runtime 注入,但它们是运行依赖,不是让模型在未来会话中自由回忆的内容。

七、短期记忆会增长,长对话必须治理

同一 thread_id 持续使用,State 里的消息会越来越多。checkpointer 能保存它们,却不会替你判断每条消息是否仍值得进入模型。这里正好与 Context Engineering 相交:持久化解决“能不能找回”,上下文策略解决“本次应该让模型看到什么”。

官方短期记忆文档给出了三类常见动作:trim、delete 和 summarize。

  • 裁剪(trim):模型调用前临时选择一部分消息,底层线程记录可以保留,适合控制单次上下文窗口。
  • 删除(delete):从 State 中移除消息,后续步骤也不再读取,适合明确无效或按规则必须清除的内容。
  • 摘要(summarize):把较长历史压缩成短摘要,再保留最近的关键消息,适合需要延续任务语义的长线程。

三种动作最关键的差别是,它们分别改变本次可见集合、持久 State 和历史信息的表达形态。

裁剪删除与摘要的差异
三条路径改变的对象不同:裁剪主要控制本次模型可见集合,删除改变持久 State,摘要用压缩后的语义替换一段历史。选择时要同时检查任务连续性、隐私要求和消息合法性,不能只按字符数量截断。

其中的消息合法性尤其重要,包含工具调用的 AIMessage 与对应 ToolMessage 需要保持严格配对;如果裁剪后只剩工具结果,没有发起它的调用信息,模型收到的消息序列可能不再合法。System message 的位置、对话起止角色以及供应商对消息序列的约束,也要进入裁剪规则。

摘要同样不是无损压缩。金额、时间、否定词和未完成动作都可能在摘要中丢失。因此,摘要可以保存“用户正在处理退款、已提交材料”,却不应该成为退款金额和到账状态的最终依据。需要精确恢复的字段应结构化保存在 State 或业务系统里,而不是只留一段自然语言概述。

八、记忆什么时候写:主链路与后台任务

长期记忆并非只能在对话结束时批量生成。官方概念文档给出两种主要写入方式:在 hot path,也就是用户请求的主执行路径中写;或者在后台异步整理。

用户明确说“以后都用中文简短回答”,主链路写入更合适。意图清楚、结果立即生效,工具还能当场返回确认。代价是当前请求多了一次存储操作,记忆抽取如果依赖额外模型调用,还会增加延迟和费用。

如果要从大量历史对话中归纳稳定偏好、合并重复事实或构建经验库,后台处理更从容。它不阻塞当前回复,也能批量去重;但新记忆不会立即可用,任务调度失败会造成滞后,多条后台任务同时更新同一 profile 时还会出现覆盖冲突。

主链路写入与后台写入
两条写入路径交换的是及时性与解耦程度:主链路适合明确、需要立即生效的记忆,后台任务适合归纳、合并和低优先级整理。无论走哪条路径,都要定义触发条件、冲突策略、版本和删除入口。

同时,在写入之前还应增加一道价值判断,并不是每句用户输入都值得长期保存。一次性情绪、临时地址、验证码和未经确认的推断,通常不该进入用户长期画像。比较稳妥的规则是:只保存与未来任务稳定相关、获得合理授权、可以解释来源、能够修正或删除的信息。

另外,同一条记忆还可能被不同请求同时更新。比如主链路刚把 verbosity 改为 concise,后台任务却根据旧对话重新写成 normal,最终结果就会倒退。解决办法不能只靠“最后写入者获胜”,至少要保存更新时间或版本,合并时识别字段级变化;对于用户刚刚明确确认的偏好,还应给予比历史推断更高的来源优先级。

九、把短期与长期记忆接成一个最小闭环

现在把两个层次放进同一个客服 Agent。代码仍采用内存实现,便于看清对象关系;生产替换存储后,调用结构不需要因此改变。

from dataclasses import dataclass

from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore


@dataclass
class SupportContext:
    """保存由客服应用注入的当前用户身份。"""

    user_id: str


@tool
def remember_response_style(
    language: str,
    verbosity: str,
    runtime: ToolRuntime[SupportContext],
) -> str:
    """保存用户明确要求在后续会话继续使用的回答风格。"""

    namespace = ("users", runtime.context.user_id, "preferences")
    runtime.store.put(
        namespace,
        "response_style",
        {"language": language, "verbosity": verbosity},
    )
    return "偏好已保存"


@tool
def read_response_style(runtime: ToolRuntime[SupportContext]) -> dict:
    """读取当前用户跨会话保存的回答风格。"""

    namespace = ("users", runtime.context.user_id, "preferences")
    item = runtime.store.get(namespace, "response_style")
    return item.value if item else {"language": "zh-CN", "verbosity": "normal"}


model = ChatOpenAI(
    model="qwen3.7-plus",
    api_key="YOUR_API_KEY",
    base_url="YOUR_OPENAI_COMPATIBLE_ENDPOINT",
)
checkpointer = InMemorySaver()
store = InMemoryStore()

agent = create_agent(
    model=model,
    tools=[remember_response_style, read_response_style],
    checkpointer=checkpointer,
    store=store,
    context_schema=SupportContext,
    system_prompt=(
        "你是客服助手。需要延续当前任务时使用线程消息;"
        "涉及回答风格时读取长期偏好;订单与退款事实必须调用业务系统。"
    ),
)

user_context = SupportContext(user_id="u-1024")
refund_thread = {"configurable": {"thread_id": "refund-2048"}}

agent.invoke(
    {"messages": [{"role": "user", "content": "退款单 RF-2048 还在审核,以后请用中文简短回答"}]},
    refund_thread,
    context=user_context,
)

continued = agent.invoke(
    {"messages": [{"role": "user", "content": "那现在进展如何?"}]},
    refund_thread,
    context=user_context,
)

new_thread = {"configurable": {"thread_id": "shipping-7712"}}
reopened = agent.invoke(
    {"messages": [{"role": "user", "content": "先读取我的回答偏好,再帮我处理新的物流问题"}]},
    new_thread,
    context=user_context,
)

第一次调用同时产生两类可能的变化:退款对话进入 refund-2048 的 State;模型若调用偏好工具,则“中文、简短”进入 u-1024 的 Store namespace。第二次调用复用退款线程,因此能恢复“那”所指的任务。第三次调用换了新线程,旧退款消息不会自动混入,但工具仍能按同一用户身份读取长期偏好。

代码故意没有实现订单查询工具,它用缺口强调一个边界:即使短期 State 中留着“还在审核”,再次回答真实进展时也应调用业务系统获取最新状态。Memory 提供连续性,不能把历史工具结果升级成永久事实。

短期与长期记忆工程闭环
闭环从请求进入开始,先按 thread_id 恢复 State,再按用户 namespace 检索必要长期记忆;执行结束后分别写回线程状态和经过筛选的长期信息。外圈的权限、遗忘、审计与业务事实校验决定这套记忆能否进入生产,而不只是能否跑通示例。

十、进入生产前,至少补齐六条边界

代码跑通之后,Memory 才刚刚从概念进入工程。真正上线前,至少要补齐以下六点。

第一,持久化实现。 InMemorySaverInMemoryStore 适合本地开发与测试,不适合依赖进程重启后恢复的服务。生产环境要选择数据库支持的 checkpointer 和 store,并验证 schema migration、备份恢复、连接池与容量上限。

第二,身份与租户隔离。 thread_iduser_id 和 tenant 信息分别解决不同问题,不能互相替代。namespace 必须由可信应用代码构造;所有读取、搜索、更新和删除都要经过同一套鉴权。

第三,写入质量。 模型推断不能悄悄变成用户事实。长期记忆应记录来源、创建时间、最近更新时间和置信边界;重要偏好最好由用户明确表达或确认。profile 更新还要处理并发覆盖,collection 写入则要处理重复项和过期项。

第四,遗忘与删除。 能写入就必须能删除。线程保留多久、用户能否清除历史、长期偏好如何更正、删除请求是否覆盖索引和备份,都应形成可执行策略,而不是只在隐私声明里出现。

第五,可观测与审计。 一次回复使用了哪条线程、读取了哪些长期记忆、为什么写入新记忆、最终又调用了哪个权威系统,需要留下适度记录。否则出现串线或错误偏好时,只能看到模型答错,却找不到错误信息从哪里进入。

第六,失败与重试。 checkpointer 写入失败、Store 暂时不可用或后台归纳任务重复执行时,系统要有明确降级方式。记忆写入不应让高风险业务动作失去幂等性,后台任务也不能覆盖用户刚刚修正的新偏好。

验收时不要只测“第二轮能否回答”。至少要覆盖同线程恢复、不同线程隔离、同用户跨线程读取、不同用户无法串读、删除后不再召回、旧业务事实不会压过实时查询,以及服务重启后的恢复。这样测到的才是记忆合同,而不是一次偶然正确的模型输出。

这六条边界共同指向一个结论:Memory 不是给模型加一个“更聪明”的开关,而是给信息的生命周期建立规则。存储只是其中一环,身份、权限、更新、失效、删除和审计同样属于记忆系统。

十一、回到开头:先判断范围,再选择记忆机制

现在我们再回过头看最初的客服场景,经过本篇对于Memory记忆系统的学习,解决问题的处理顺序已经很清楚了:

  1. “那还要多久”要接续当前退款任务,使用相同 thread_id,由 checkpointer 恢复 State。
  2. “以后用中文简短回答”需要跨线程复用,经过明确触发后写入 Store,并用用户 namespace 隔离。
  3. 退款金额、审核状态和到账时间属于权威业务事实,每次需要时回到业务系统查询。
  4. 会话过长时,根据任务连续性选择裁剪、删除或摘要,同时维护工具消息配对和精确字段。
  5. 记忆写入选择主链路还是后台任务,取决于是否要立即生效,以及能否承担延迟、一致性和冲突处理。

总的来说,短期记忆解决的是“这条线程怎样继续”,长期记忆解决的是“换一条线程后哪些信息仍值得复用”。把这两句话与业务数据库边界一起记住,后续学习 Middleware、Retrieval 和更复杂的 Agent 系统时,就不会把所有上下文问题都误归到一个叫 Memory 的盒子里。

不过,把边界定义清楚,还不等于每次执行都会自动守住它。日志、敏感信息过滤、失败重试和高风险操作审批,往往同时横跨模型调用与工具调用。全写进提示词不够可靠,分散塞进各个工具又很难统一维护。下一篇我们就从这个缺口出发,一些看看官方文章中是怎样在执行链路的关键位置集中接住这些规则,让它们不再依赖模型“记得遵守”?

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐