从 Schema 定义到外部持久化,Module 2 构建了一套完整的
LangGraph 状态管理与对话记忆体系。
State Schema 是图的"数据契约",定义了所有节点共享的数据结构。LangGraph 支持三种 Python 数据类型作为 Schema。
state["key"],最轻量,无运行时校验,官方示例默认用法state.key,原生支持 field(default_factory=...) 默认值@field_validator 自定义规则,生产环境首选Reducer 定义了字段的更新规则:reducer(旧值, 新值) → 最终值。它是解决并行节点写入冲突的核心机制。
Annotated[list[int], add],列表追加,解决并行冲突InvalidUpdateError,必须用 Annotated 注解指定 Reducer。
当所有节点共用同一 Schema 时,内部中间数据会污染公开接口。多重 Schema 机制让你精确控制数据边界。
LLM 上下文窗口有上限,而对话历史会无限增长。三种方案各有取舍,控制发给模型的消息范围。
用 LLM 自动生成对话"滚动摘要",让 Chatbot 既记得全程对话,又不消耗大量 Token。
MemorySaver 将状态存在内存中,进程重启后全部消失。外部数据库检查点让对话记忆真正跨会话持久化。
checkpointer 参数,图代码零修改。
State Schema 是图的"数据契约",所有节点通过它通信。选对 Schema 类型(TypedDict / Dataclass / Pydantic)决定了开发效率与运行安全性的平衡点。
通过 Annotated[T, fn] 为字段指定 Reducer,是解决并行节点冲突的唯一官方方式。add_messages 是对话 Agent 的标配 Reducer。
Private State 隔离内部实现细节,Input/Output Schema 精确定义图的公开接口,让复杂图保持整洁的边界设计。
生产 Chatbot 必须控制发给 LLM 的消息量。filter_messages 按类型过滤,trim_messages 按 Token 裁剪,两者都不修改 State 历史。
让 LLM 自动压缩对话历史为摘要,实现"记住全程对话但只发少量 Token"的效果。摘要注入 SystemMessage 作为背景上下文。
MemorySaver 仅用于开发调试,生产环境必须换用外部数据库(SQLite → PostgreSQL)。切换只需替换 checkpointer,其余代码不变。
一个支持多轮对话、自动压缩历史、跨会话持久记忆的智能助手。它将 Module 2 的全部 6 个核心知识点有机整合,是一个可直接用于生产环境的 LangGraph 项目骨架。
# ── 依赖导入 ──────────────────────────────────────────────
from typing import Annotated, Optional, Literal
from typing_extensions import TypedDict
from pydantic import BaseModel, field_validator
from langchain_core.messages import AnyMessage, HumanMessage, AIMessage, SystemMessage, RemoveMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import trim_messages
from langgraph.checkpoint.sqlite import SqliteSaver
import sqlite3
# ════════════════════════════════════════════════════════
# 【L3】输入 Schema ── 图的公开入口,只暴露给调用者的字段
# ════════════════════════════════════════════════════════
class InputState(TypedDict):
user_message: str # 调用者只需传这一个字段
# ════════════════════════════════════════════════════════
# 【L3】输出 Schema ── 图的公开出口,过滤掉内部中间字段
# ════════════════════════════════════════════════════════
class OutputState(TypedDict):
messages: list[AnyMessage] # 调用者可见的对话历史
summary: str # 调用者可见的当前摘要
# ════════════════════════════════════════════════════════
# 【L1 + L2】OverallState ── 图内部完整状态,包含所有字段
# · TypedDict(L1):零安装成本,字典风格访问
# · Annotated + add_messages(L2):消息追加 Reducer
# ════════════════════════════════════════════════════════
class ResearchState(TypedDict):
# 【L2】add_messages Reducer:每轮对话追加,不覆盖
messages: Annotated[list[AnyMessage], add_messages]
# 【L5】摘要字段:超过阈值时由 summarize 节点写入
summary: str
# 普通字段:默认覆盖策略(L2:无 Reducer 即覆盖)
user_message: str
# ════════════════════════════════════════════════════════
# 【L1】Pydantic Schema ── 运行时强校验用户会话配置
# · @field_validator 确保 session_id 合法
# · 非法输入在进入图之前就被拦截
# ════════════════════════════════════════════════════════
class UserSession(BaseModel):
session_id: str
max_messages: int = 6 # 触发摘要的消息阈值
@field_validator("session_id")
@classmethod
def validate_session_id(cls, v):
if not v or len(v) < 4:
raise ValueError("session_id 至少需要 4 个字符")
return v.strip()
InputState 只有一个字段,调用者无需了解图的内部实现;OutputState 只暴露两个结果字段,图内部的 user_message 等中间字段对调用者完全透明。这是 Lesson 3 "接口与实现分离"的直接体现。
ResearchState 用 TypedDict(轻量、无依赖,适合图内部状态);UserSession 用 Pydantic(对外部输入强校验)。两种类型组合使用,各司其职,这正是 Lesson 1 选型建议的实践。
# ════════════════════════════════════════════════════════
# 【L6】外部持久化配置
# · SqliteSaver 将图的检查点(Checkpoint)写入 SQLite 文件
# · 进程重启后,同一 thread_id 的对话历史可完整恢复
# · 对比 MemorySaver:进程退出即全部丢失
# ════════════════════════════════════════════════════════
conn = sqlite3.connect("research_assistant.db", check_same_thread=False)
memory = SqliteSaver(conn) # 替换为 PostgresSaver 即可升级到生产级数据库
# LLM 初始化
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
MemorySaver 存在 Python 进程的 RAM 里——Notebook 重启或服务部署更新,记忆归零。SqliteSaver 将每个执行步骤的快照写入磁盘文件,进程重启后只需传入相同的 thread_id 即可从断点继续。此处的 check_same_thread=False 允许多线程并发访问同一数据库连接——在 FastAPI / Starlette 等异步框架中必需。
def call_model(state: ResearchState) -> dict:
"""主对话节点:整合摘要上下文 + Token 控制 + LLM 调用"""
summary = state.get("summary", "")
messages = state["messages"]
# ── 【L4】trim_messages:按 Token 裁剪,只保留最近的消息 ──
# strategy="last" 从最新消息开始保留,不满足 token 上限就截断
# include_system=True 保留 SystemMessage(优先级最高)
# 这里裁剪的是发给 LLM 的消息,State 中的完整历史不受影响
trimmed = trim_messages(
messages,
max_tokens=1500,
strategy="last",
token_counter=llm,
include_system=True,
allow_partial=False,
)
# ── 【L5】将摘要注入 SystemMessage 作为背景上下文 ──
# 当摘要存在时,LLM 能"记住"之前压缩掉的所有对话
# 这解决了:消息被 RemoveMessage 删掉后如何保留语义信息
system_content = "你是一个专业的智能研究助手,帮助用户深入探讨各类研究问题。"
if summary:
system_content += f"\n\n【对话摘要】以下是之前对话的压缩记录:\n{summary}"
final_messages = [SystemMessage(content=system_content)] + trimmed
response = llm.invoke(final_messages)
# ── 【L2】add_messages Reducer 自动追加 response ──
# 节点只需返回包含新消息的列表,Reducer 负责拼接到历史末尾
return {"messages": [response]}
trim_messages 从消息列表末尾(最新消息)向前计数 Token,当累计超过 max_tokens=1500 时停止,把剩余消息丢弃。关键:这只影响发给 LLM 的内容,State 中 messages 字段依然保留完整历史。 这与 RemoveMessage 从 State 物理删除是本质区别。
在 summarize 节点压缩历史后,旧消息从 State 删除,但摘要保存在 summary 字段。call_model 检测到 summary 非空,就把它拼入 SystemMessage——这样即使原始消息已经不在 State 里,LLM 仍能感知之前的对话内容,实现语义连续性。
def should_summarize(state: ResearchState) -> Literal["summarize", "end"]:
"""条件边:判断消息数量是否触发摘要阈值"""
# 超过 6 条消息就触发摘要节点,否则直接结束
if len(state["messages"]) > 6:
return "summarize"
return "end"
def summarize_conversation(state: ResearchState) -> dict:
"""摘要节点:压缩对话历史,物理删除旧消息"""
existing_summary = state.get("summary", "")
messages = state["messages"]
# ── 【L5】构建摘要 Prompt ──
# 若已有摘要则在其基础上扩展(滚动摘要模式)
# 若没有则从头生成(首次压缩)
if existing_summary:
prompt = (
f"当前已有摘要:\n{existing_summary}\n\n"
"请在此基础上,将以下新对话内容也纳入摘要,保持信息完整简洁:"
)
else:
prompt = "请简洁总结以下对话的关键信息,保留重要的问答内容:"
summary_messages = messages + [HumanMessage(content=prompt)]
response = llm.invoke(summary_messages)
# ── 【L2】RemoveMessage:物理删除旧消息,只保留最近 2 条 ──
# messages[:-2] 取"最后 2 条之前"的所有消息作为删除目标
# add_messages Reducer 识别到 RemoveMessage 后,按 id 从列表中移除
# 这与 trim_messages(仅过滤发给 LLM 的内容)不同:这里是真正修改 State
delete_messages = [RemoveMessage(id=m.id) for m in messages[:-2]]
return {
"summary": response.content, # 覆盖旧摘要(默认覆盖策略,L2)
"messages": delete_messages, # add_messages Reducer 执行删除(L2)
}
每次触发摘要时,把已有摘要 + 新对话一起发给 LLM,生成更新后的摘要。这是"滚动摘要"的核心——不是每次重写,而是在旧摘要上累积扩展。随着对话持续,摘要会越来越完整,而 Token 消耗始终维持在可控范围内。
RemoveMessage 不是真正的消息内容,而是一个删除操作指令。当节点把 RemoveMessage(id=x) 放入返回列表,add_messages Reducer 收到后会在 State 的消息列表中找到 id=x 的消息并物理移除它。这是 Reducer 机制的高级用法。
# ════════════════════════════════════════════════════════
# 【L3】多重 Schema 装配
# · 第一参数 ResearchState:图内部的完整 Schema
# · input=InputState:调用者只能传 user_message
# · output=OutputState:图只返回 messages 和 summary
# ════════════════════════════════════════════════════════
builder = StateGraph(
ResearchState,
input=InputState,
output=OutputState,
)
# 注册节点
builder.add_node("call_model", call_model)
builder.add_node("summarize_conversation", summarize_conversation)
# 连接边
builder.add_edge(START, "call_model")
# 条件边:call_model 执行后,根据消息数决定走向
builder.add_conditional_edges(
"call_model",
should_summarize,
{"summarize": "summarize_conversation", "end": END}
)
builder.add_edge("summarize_conversation", END)
# ════════════════════════════════════════════════════════
# 【L6】挂载外部检查点,compile 时传入 SqliteSaver
# · 每个节点执行完后,LangGraph 自动将 State 快照存入数据库
# · 只需替换 checkpointer=memory 即可切换存储后端
# ════════════════════════════════════════════════════════
graph = builder.compile(checkpointer=memory)
StateGraph(ResearchState, input=InputState, output=OutputState) 这三个参数分别定义了图的三个边界:内部状态(节点读写)、入口契约(调用者传什么)、出口契约(调用者拿到什么)。当调用者传入额外字段(比如直接传 summary)时,LangGraph 会自动忽略它,保证图的封装性。
# ── 【L1】Pydantic 校验:确保 session_id 合法再启动 ──
session = UserSession(session_id="researcher_001", max_messages=6)
config = {"configurable": {"thread_id": session.session_id}}
# ── 第 1 轮对话 ────────────────────────────────────────
result = graph.invoke(
{"user_message": "深海生物是如何在极端高压环境中生存的?"},
config=config,
)
# result 只包含 OutputState 的字段:messages 和 summary
print(result["messages"][-1].content)
# ── 第 2 轮:LangGraph 自动从 SQLite 恢复第 1 轮的状态 ──
result = graph.invoke(
{"user_message": "这与极地生物的耐寒机制有何异同?"},
config=config,
)
# ── 模拟进程重启后的跨会话恢复 ────────────────────────
# 重新建立数据库连接(模拟重启)
new_conn = sqlite3.connect("research_assistant.db", check_same_thread=False)
new_memory = SqliteSaver(new_conn)
new_graph = builder.compile(checkpointer=new_memory)
# 同一个 thread_id → 历史自动恢复,就像从未重启过
result = new_graph.invoke(
{"user_message": "给我推荐几篇这个领域的经典论文"},
config=config, # thread_id 不变,记忆完整继承
)
# ── 多用户隔离示例 ──────────────────────────────────────
user_a_config = {"configurable": {"thread_id": "user_alice"}}
user_b_config = {"configurable": {"thread_id": "user_bob"}}
# alice 和 bob 的对话历史完全独立,互不干扰
每次调用 graph.invoke(),LangGraph 先用 thread_id 在 SQLite 中查找最新的 Checkpoint(State 快照),加载后再执行节点。下次调用时,这个节点执行完的 State 又被写回数据库。进程重启不影响记忆,因为记忆在磁盘里,不在内存里。
UserSession(session_id="ab") 会立即抛出 ValidationError(不足 4 字符),在进入图之前就被拦截。这保证了非法的 thread_id 不会在 SQLite 数据库中创建垃圾记录,是 Pydantic 做"边界校验"的典型应用。