Appearance
6.2 Shared State Architecture, Channel Reducers & Durable Checkpointers β
Canonical Curriculum Reference:
docs/plan/06_phase6_stateful_graphs_hitl.md
Textbook Reference: AI Agents and Applications (Manning Publications), Chapter 11, Section 11.2.4 ("Agent state", pp. 303β305) & Chapter 14, Section 14.1.3 ("Checkpoints in LangGraph", pp. 357β362).
π 1. The Shared State Object: The "Backpack" of Data β
In LangGraph, state is not passed implicitly or stored in global mutable variables. Instead, every node operates against a strongly-typed shared state object. This object acts as a common "backpack" carried through the graph:
- When a node executes, it receives a read-only snapshot of the current state.
- When a node completes, it returns a dictionary containing only the state keys it wishes to update.
- LangGraph merges these updates into the shared state before advancing to the next superstep.
Choosing Between TypedDict and Pydantic BaseModel β
TypedDict(Recommended for LangGraph): Standard Python dictionary semantics with static type validation. Serializes naturally to JSON without overhead; preferred by LangGraph's channel update engine.- Pydantic
BaseModel: Useful when runtime validation and schema sanitization of intermediate node updates are required.
π 2. State Channel Reducers β
By default, when a node returns an update for a state key, LangGraph performs a direct overwrite (the newest value replaces the old value). While suitable for scalar flags (e.g. is_verified: bool), direct overwrites break append-only collections such as dialogue histories, audit logs, or intermediate research findings.
To solve this, LangGraph provides Channel Reducers using Python's typing.Annotated:
python
from typing import Annotated
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
def custom_append_reducer(existing: list[str], update: list[str] | str) -> list[str]:
"""Custom reducer that appends new items without mutating existing state."""
new_items = [update] if isinstance(update, str) else update
return existing + new_itemsThe Canonical add_messages Reducer β
In conversational and tool-calling agents, dialogue history must be preserved. The prebuilt add_messages reducer handles:
- Append: Appending incoming
HumanMessage,AIMessage, orToolMessageto the list. - Deduplication: If an incoming message has the same
idas an existing message, it updates the message in place rather than duplicating it. - Deletions: If a
RemoveMessage(id=...)is emitted, it deletes the specified message from state.
πΎ 3. Durable Persistence via Checkpointers β
In production, agents do not run in a single uninterrupted in-memory process:
- A user might take 2 hours to approve a draft.
- A Kubernetes pod might be rescheduled or crash during a multi-step task.
- A database connection might temporarily timeout.
A Checkpointer saves a complete snapshot of the graph state at every superstep (discrete node boundary). If the application server crashes or restarts, the workflow can resume instantaneously from the exact last superstep without re-running expensive upstream LLM calls:
[ Superstep 1: Drafting Node ]
|
+---> [ Checkpointer: Commit Snapshot (thread_id='session-101', step=1) ]
|
[ Superstep 2: Testing Node ]
|
+---> [ Checkpointer: Commit Snapshot (thread_id='session-101', step=2) ]
|
*** SERVER CRASH ***
|
[ Server Reboots ] ---> Load Snapshot (thread_id='session-101') ---> Resume Superstep 3!Checkpointer Flavors β
MemorySaver: In-memory ephemeral persistence; ideal for unit tests and local development.SqliteSaver: Durable file-backed persistence; ideal for local desktop tools, staging, and lightweight services.PostgresSaver: Enterprise distributed persistence; connection pooling, transactional durability, and horizontal pod scaling.
π» 4. Production Implementation: StateGraph with SqliteSaver β
python
import sqlite3
from typing import Annotated, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
class TravelConsultantState(TypedDict):
"""Shared state with message reducer and custom facts list."""
messages: Annotated[list[BaseMessage], add_messages]
extracted_preferences: Annotated[list[str], lambda x, y: x + y]
consultation_complete: bool
def extraction_node(state: TravelConsultantState) -> dict:
"""Extracts preferences from latest user message."""
latest_msg = state["messages"][-1].content
new_facts = []
if "vegan" in str(latest_msg).lower():
new_facts.append("Dietary: Strictly Vegan")
if "budget" in str(latest_msg).lower():
new_facts.append("Pricing: Budget-Conscious")
return {
"extracted_preferences": new_facts,
"messages": [AIMessage(content=f"Noted your preferences: {new_facts}")]
}
# 1. Initialize StateGraph
builder = StateGraph(TravelConsultantState)
builder.add_node("extraction_node", extraction_node)
builder.add_edge(START, "extraction_node")
builder.add_edge("extraction_node", END)
# 2. Attach SQLite Checkpointer for Durable Persistence
conn = sqlite3.connect(":memory:", check_same_thread=False)
checkpointer = SqliteSaver(conn)
app = builder.compile(checkpointer=checkpointer)π§ͺ 5. Automated Verification: Multi-Turn Resumption via thread_id β
python
import pytest
def test_sqlite_persistence_and_state_accumulation():
config = {"configurable": {"thread_id": "huy-session-001"}}
# Turn 1: User specifies vegan diet
turn1_input = {
"messages": [HumanMessage(content="Hello, I am looking for a vegan culinary tour in Tokyo.")],
"extracted_preferences": [],
"consultation_complete": False
}
app.invoke(turn1_input, config=config)
# Inspect persisted state directly from checkpointer
state_after_turn1 = app.get_state(config)
assert len(state_after_turn1.values["messages"]) == 2
assert "Dietary: Strictly Vegan" in state_after_turn1.values["extracted_preferences"]
# Turn 2: User adds budget constraint in a NEW invocation
turn2_input = {
"messages": [HumanMessage(content="Also, I am on a strict budget under $100/day.")],
}
app.invoke(turn2_input, config=config)
# Verify both preferences are accumulated across turns via checkpointer
final_state = app.get_state(config)
assert len(final_state.values["messages"]) == 4
assert "Dietary: Strictly Vegan" in final_state.values["extracted_preferences"]
assert "Pricing: Budget-Conscious" in final_state.values["extracted_preferences"]βοΈ State Management Best Practices β
- Keep State JSON-Serializable: Never store active file handles, unpicklable database sockets, or non-serializable objects in state. Checkpointers must serialize state to JSON/binary blobs.
- Atomic Node Updates: A node should only return keys it explicitly intends to modify; returning unchanged keys risks stomping concurrent updates.
- Idempotent Reducers: Ensure custom reducers are side-effect free to support time-travel rewind and replay safely.