From Schema definition to external persistence, Module 2 builds a complete
LangGraph state management and dialogue memory system.
State Schema is the "data contract" of the graph, which defines the data structure shared by all nodes. LangGraph supports three Python data types as Schema.
state["key"], the most lightweight, no runtime verification, official example default usagestate.key, native supportfield(default_factory=...)Default value@field_validatorCustom rules, preferred for production environmentreducer defines the update rules for fields:reducer(旧值, 新值) → 最终值. It is the core mechanism for resolving write conflicts on parallel nodes.
Annotated[list[int], add], list appending, resolving parallel conflictsInvalidUpdateError, the reducer must be specified with Annotated annotation.When all nodes share the same Schema, internal intermediate data can pollute the public interface. Multiple Schema mechanisms give you precise control over data boundaries.
The LLM context window is capped and the conversation history grows indefinitely. Each of the three options has its own trade-offs, controlling the scope of messages sent to the model.
Use LLM to automatically generate a "rolling summary" of the conversation, allowing the Chatbot to remember the entire conversation without consuming a large amount of Tokens.
MemorySaver stores the state in memory and disappears after the process is restarted. External database checkpoints make conversation memory truly persistent across sessions.
checkpointerParameters, figure code zero modification.The State Schema is the "data contract" of the graph through which all nodes communicate. Choosing the right Schema type (TypedDict / Dataclass / Pydantic) determines the balance between development efficiency and operational safety.
passAnnotated[T, fn]Specifying a reducer for a field is the only official way to resolve parallel node conflicts.add_messagesIt is the standard reducer of dialogue agent.
Private State isolates internal implementation details, and Input/Output Schema accurately defines the public interface of the graph, allowing complex graphs to maintain a clean boundary design.
The production Chatbot must control the volume of messages sent to the LLM.filter_messagesfilter by type,trim_messagesCropped by Token, neither modifies the State history.
Let LLM automatically compress the conversation history into a summary to achieve the effect of "remembering the entire conversation but only issuing a small number of Tokens". Summary injects SystemMessage as background context.
MemorySaver is only used for development and debugging. The production environment must use an external database (SQLite → PostgreSQL). To switch just replacecheckpointer, the rest of the code remains unchanged.
An intelligent assistant that supports multiple dialogue rounds, automatic history compression, and cross-session persistent memory. It organically integrates all six core knowledge points of Module 2 and is a LangGraph project skeleton that can be directly used in the production environment.
# ── 依赖导入 ──────────────────────────────────────────────
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()
InputStateThere is only one field, and the caller does not need to know the internal implementation of the graph;OutputStateOnly exposes two result fields, the ones inside the graphuser_messageThe intermediate fields are completely transparent to the caller. This is a direct manifestation of Lesson 3 "Separation of interface and implementation".
ResearchStateUse TypedDict (lightweight, no dependencies, suitable for the internal state of the graph);UserSessionUse Pydantic (strong validation of external input). The two types are used in combination, each performing its own duties, which is exactly the practice recommended in Lesson 1 selection.
# ════════════════════════════════════════════════════════
# 【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)
MemorySaverStored in the RAM of the Python process - the memory is reset when the Notebook is restarted or the service is deployed and updated.SqliteSaverWrite a snapshot of each execution step to a disk file. After the process restarts, just pass in the samethread_idYou can continue from the breakpoint. Here'scheck_same_thread=FalseAllows multiple threads to access the same database connection concurrently - required in asynchronous frameworks such as 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_messagesCount tokens forward from the end of the message list (the latest message). When the total exceedsmax_tokens=1500Stop and discard the remaining messages.Key: This only affects the content sent to LLM. The messages field in State still retains the complete history.This is essentially different from physical deletion of RemoveMessage from State.
After the summarize node compresses the history, old messages are deleted from the State but the summary is saved insummaryfield.call_modeldetectedsummaryIf it is not empty, just spell it into SystemMessage - in this way, even if the original message is no longer in State, LLM can still perceive the previous conversation content and implementsemantic continuity。
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)
}
Each time the digest is triggered, putExcerpt already + new conversationSend them together to LLM to generate an updated summary. This is the core of "rolling digest" - not rewrite every time, but on the old digestCumulative expansion. As the conversation continues, the summary will become more and more complete, while token consumption remains within control.
RemoveMessageNot the actual message content, but aDelete operation instructions. When the nodeRemoveMessage(id=x)into the return list,add_messagesAfter reducer receives it, it will be found in the message list of Stateid=xmessage and physically remove it. This is an advanced use of the reducer mechanism.
# ════════════════════════════════════════════════════════
# 【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)These three parameters define the three boundaries of the graph respectively:internal state(node reading and writing),Entrance contract(what the caller passes),export contract(what the caller gets). When the caller passes in additional fields (such as directly passingsummary), LangGraph will automatically ignore it to ensure the encapsulation of the graph.
# ── 【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 的对话历史完全独立,互不干扰
every callgraph.invoke(), LangGraph is used firstthread_idFind the latest Checkpoint (State snapshot) in SQLite and load it before executing the node. The next time it is called, the State executed by this node is written back to the database.Process restart does not affect the memory, because the memory is on the disk, not in the memory.
UserSession(session_id="ab")will be thrown immediatelyValidationError(less than 4 characters), was blocked before entering the graph. This guarantees illegalthread_idIt will not create garbage records in the SQLite database and is a typical application of Pydantic for "boundary verification".