AI Graph实战(03)——State机制与AI对话记忆架构解析
🎁 读完这篇你会得到什么:彻底搞清楚 State 在图里是怎么流动的,Reducer 机制解决了什么问题,以及短期记忆、长期记忆、Checkpointing 三者各自在什么场景下用。
上一篇讲了 LangGraph 和 AutoGen 的选型。
很多人选完框架,开始搭图,跑起来之后发现一个奇怪的问题:并行的三个搜索节点,最后 State 里只剩一个节点的结果。或者图跑了 20 轮,State 里堆了一堆没用的历史数据,内存开始吃紧。再或者,用户关了浏览器回来,图从头开始跑,之前的进度全没了。
这些问题都指向同一个地方——State 的设计。
先说一个真实遇到的情况。
搭了一张并行搜索图:一个扇出节点同时触发三个搜索节点,分别搜索不同的信息源,最后汇聚到一个综合节点。逻辑很清晰,代码也没报错,但跑出来的结果每次都只有一个搜索节点的内容。
排查了半天,最后发现问题出在 State 定义上:
三个搜索节点都往search_results写,但默认行为是覆盖——后写的把前写的盖掉了。三个节点并行跑,谁最后写完,State 里就只剩谁的结果。
这就是 State 设计里最容易踩的坑:没有想清楚"写入方式"。
很多人把 State 理解成"把上一个节点的输出传给下一个节点",这个理解太浅了。
State 是整张图的全局共享内存。每个节点都从 State 里读数据,也往 State 里写数据。问题是,当多个节点同时往同一个字段写的时候,谁说了算?
这就是 LangGraph 的Reducer 机制要解决的问题。
Reducer 是一个函数,它告诉框架:当多个节点都往同一个字段写入时,怎么合并这些写入。
加了Annotated[list[str], operator.add]之后,三个搜索节点的结果会被追加到同一个列表里,而不是互相覆盖。
这个改动只有一行,但解决了并行节点的数据丢失问题。
Reducer 不只有operator.add,你可以自定义任何合并逻辑:
理解了 Reducer,你就理解了 State 设计的核心问题:每个字段,在多节点并发写入时,应该怎么合并?
搭一张图之前,State 的设计值得认真想清楚三件事。
第一件:谁能写哪些字段?
不是所有节点都应该能写所有字段。搜索节点只应该写search_results,不应该碰draft。评审节点只应该写review_score,不应该改analysis。
LangGraph 本身不强制这个约束,但你可以在节点函数里自己控制——节点函数只返回它负责的字段,其他字段不动:
这个习惯很重要。节点越专注,State 的流转越清晰,出了问题也更容易定位。
第二件:写入是覆盖还是追加?
上面已经讲了。顺序节点通常用覆盖(默认行为),并行节点通常需要追加(用 Reducer)。
有一个容易忽视的场景:循环节点。
如果一个写作节点会被多次执行(评审不通过就打回重写),draft字段应该是覆盖还是追加?
大多数情况下是覆盖——你只需要最新的草稿,不需要保留历史版本。但如果你想追踪每次修改的历史,就需要追加。这取决于你的业务需求,没有标准答案,但必须想清楚。
第三件:State 里放什么,不放什么?
State 是全局共享的,每个节点都能看到。这意味着你不应该把敏感信息、临时变量、或者只有某个节点需要的数据放进 State。
一个实用的原则:State 里只放"需要跨节点传递"的数据。某个节点内部的临时计算结果,不需要放进 State,直接在节点函数里用局部变量就行。
State 越精简,图越清晰,调试越容易。
理解了 Reducer,再来看 State 在整张图里是怎么流动的。
State 从图开始执行时创建,每个节点执行完之后,把自己的返回值合并进 State(按照 Reducer 规则),然后传给下一个节点。图执行完成时,返回最终的 State。
有一个细节值得注意:节点函数返回的是"增量更新",不是完整的 State。
这个设计很优雅——节点只关心自己的输出,框架负责合并。但也带来一个问题:如果你不小心在节点里返回了一个不存在于 State 定义里的字段,LangGraph 会静默忽略它,不会报错。这是一个容易踩的坑,建议在 State 定义里把所有字段都明确声明出来。
到目前为止讲的都是"图执行期间"的 State——它随着节点执行不断更新,图执行完就消失了。这就是短期记忆。
短期记忆够用吗?对于大多数任务,够。
但有两种情况不够:
情况一:图执行时间很长,中途可能中断。
用户跑一个需要 30 分钟的分析任务,跑到一半网络断了,或者用户关了浏览器。下次回来,图从头开始,之前的进度全没了。
情况二:需要跨多次执行记住信息。
用户今天问了一个问题,明天再来,希望 Agent 还记得上次的对话内容。但每次执行都是一个新的 State,上次的内容不会自动带过来。
这两种情况,需要的是持久化的 State——也就是 LangGraph 的 Checkpointing 机制。
Checkpointing 是 LangGraph 的持久化机制。简单说,就是在图执行的每个节点之后,把当前 State 存到数据库里。下次执行时,可以从任意一个检查点恢复。
thread_id是关键。它相当于一个"会话 ID"——相同thread_id的所有执行,共享同一条 State 历史。
如果图执行到一半中断了,下次用同一个thread_id调用,LangGraph 会自动从上次中断的地方继续:
Checkpointing 还有一个很有用的功能:Time Travel(时间旅行)。
你可以查看图在任意历史节点的 State,甚至可以回滚到某个历史状态重新执行:
Time Travel 在调试时特别有用——你不需要从头跑,可以直接跳到出问题的那个节点,修改 State 之后重新执行。
Checkpointing 解决了"单个任务的持久化"问题。但在多用户场景里,还有另一个问题:不同用户的 State 不能互相污染。
LangGraph 用thread_id来隔离不同用户的 State。每个用户(或每个任务)用一个独立的thread_id,它们的 State 完全隔离:
这是线程级记忆(Thread-level Memory)的基本用法。
不过有时候你想要的不只是隔离,而是跨线程共享某些信息。比如,同一个用户的多次对话,希望 Agent 能记住用户的偏好;或者多个任务共享同一份知识库。
这就需要在线程级记忆之上,再加一层跨线程记忆。
LangGraph 的做法是用namespace来区分不同层级的记忆:
这样,同一个用户的不同任务(不同thread_id)可以共享用户偏好,但不同用户之间完全隔离。
线程级记忆解决了"同一次执行内"和"同一用户多次执行间"的记忆问题。但还有一种更长期的需求:Agent 需要随着使用积累知识,越用越聪明。
这就是长期记忆(Long-term Memory)。
长期记忆和短期记忆的核心区别不在于存储时间,而在于检索方式。
短期记忆(State)是全量的——每个节点都能看到完整的 State。这在 State 很小的时候没问题,但如果 Agent 积累了大量历史知识,把所有知识都塞进 State 会让上下文窗口爆掉。
长期记忆需要按需检索——Agent 在需要的时候,根据当前任务的相关性,从知识库里取出最相关的部分,而不是全量加载。
实现长期记忆,通常用向量数据库:
这个模式在 Agent-Graph 这类项目里很常见——Agent 每次完成任务,都把结果存入长期记忆,下次遇到相似任务时,先检索历史经验,再做决策。
把上面讲的整理一下,Graph 工程里的记忆其实有四个层级,各自解决不同的问题:
不是每个场景都需要四层全上。判断标准很简单:
从简单开始,遇到问题再加,不要一上来就把四层全搭上。
说了这么多,来一个把上面内容串起来的完整例子。
场景:一个"每日市场简报"生成系统,需要:
这个例子把四个层级的记忆都用上了:
实际项目里不一定需要这么完整,但这个结构可以作为参考,按需裁剪。
最后总结几个在实际项目里反复验证过的原则。
State 要精简,不要贪多。每加一个字段,就多一个需要维护的地方。只放"需要跨节点传递"的数据,节点内部的临时变量用局部变量。
并行节点一定要想清楚 Reducer。只要有扇出结构,就要问自己:这些并行节点往同一个字段写的时候,应该覆盖还是追加?没想清楚就会出现数据丢失的 bug,而且不报错,很难发现。
Checkpointing 要早加,不要等出问题再加。加 Checkpointing 的成本很低,但不加的代价可能很高——任务跑了 20 分钟中断,从头再来。
长期记忆要控制检索数量。从向量数据库检索的结果会占用上下文窗口,n_results不要设太大。通常 3-5 条就够了,太多反而会稀释当前任务的注意力。
Time Travel 是调试利器,要学会用。出了问题不需要从头跑,直接跳到出问题的节点,修改 State 之后重新执行。这个功能能节省大量调试时间。
State 搞清楚了,图能跑起来了。
但生产环境里还有一个绕不开的问题:谁来监督 Agent?
Agent 自己跑到底,没有人工干预,在很多场景里是不可接受的——金融操作、内容发布、代码部署,这些关键节点必须有人确认。
下一篇,我们讲 Human-in-the-Loop(HITL)和断点调试——怎么在图里插入人工审批节点,怎么动态修改 State,以及 Time Travel 在实际调试中怎么用。
📚系列导航