Module 8: Memory and Persistence

Short-term Memory: Conversation History

Capsule overview

Short-term memory is the conversation history — the messages exchanged in the current session. Without it, every invoke() is an island: the agent doesn't know what happened 30 seconds ago. With it, the agent holds context between turns and can refer back to what was said.

Sounds simple. It isn't. The history grows with every turn, and models have finite context windows. GPT-4.1 has 128K tokens — that sounds enormous, but a 30-turn research session with tool calls and long results can fill it. If you don't manage that growth, your agent will fail at the worst possible moment: right when the conversation gets interesting.

This capsule gives you the full toolkit: MessagesState to manage history automatically, thread_id to isolate conversations per user, message trimming to control growth, and summarization to preserve context without burning tokens.


The problem: invoke() with no context

from langgraph.graph import StateGraph, MessagesState, START, END
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv

load_dotenv()

model = ChatOpenAI(model="gpt-4o-mini")

def chatbot(state: MessagesState) -> dict:
    response = model.invoke(state["messages"])
    return {"messages": [response]}

builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)
graph = builder.compile()

result1 = graph.invoke({"messages": [{"role": "user", "content": "My name is Carlos and I work in AI"}]})
print(f"Turn 1: {result1['messages'][-1].content}")

result2 = graph.invoke({"messages": [{"role": "user", "content": "What's my name?"}]})
print(f"Turn 2: {result2['messages'][-1].content}")
# Expected output:
# Turn 1: Hi Carlos! Interesting that you work in AI...
# Turn 2: I don't have any information about your name...

The agent doesn't know the user's name. Every invoke() receives only the messages you explicitly pass in. There's no accumulated history. It's like a conversation where the other person forgets everything each time they blink.


MessagesState: state that manages messages

MessagesState is a pre-built LangGraph state that handles message history automatically:

from langgraph.graph import MessagesState

# MessagesState is equivalent to:
# class MessagesState(TypedDict):
#     messages: Annotated[list[AnyMessage], add_messages]

The key detail is the add_messages reducer. When your node returns {"messages": [new_message]}, the reducer doesn't replace the list — it appends the new message to the existing ones. Every turn accumulates messages.

How add_messages works

from langchain_core.messages import HumanMessage, AIMessage
from langgraph.graph import add_messages

existing = [
    HumanMessage(content="What is RAG?"),
    AIMessage(content="RAG is Retrieval-Augmented Generation...")
]
new = [HumanMessage(content="Give me an example")]

result = add_messages(existing, new)
print(f"Messages after the reducer: {len(result)}")
for msg in result:
    print(f"  {msg.type}: {msg.content[:50]}...")
# Expected output:
# Messages after the reducer: 3
#   human: What is RAG?...
#   ai: RAG is Retrieval-Augmented Generation......
#   human: Give me an example...

add_messages also handles deduplication by ID: if you append a message with the same id as an existing one, it updates it instead of duplicating it.


Multi-turn conversation with thread_id

The thread_id is what connects separate invocations. Each thread is an independent conversation with its own history.

from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv

load_dotenv()

model = ChatOpenAI(model="gpt-4o-mini")

def chatbot(state: MessagesState) -> dict:
    response = model.invoke(state["messages"])
    return {"messages": [response]}

builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)

checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "conversation_001"}}

result1 = graph.invoke(
    {"messages": [{"role": "user", "content": "My name is Carlos and I work in AI"}]}, config
)
print(f"Turn 1: {result1['messages'][-1].content}")

result2 = graph.invoke(
    {"messages": [{"role": "user", "content": "What's my name?"}]}, config
)
print(f"Turn 2: {result2['messages'][-1].content}")
print(f"Total messages: {len(result2['messages'])}")
# Expected output:
# Turn 1: Hi Carlos! Interesting that you work in AI...
# Turn 2: Your name is Carlos, and you work in AI.
# Total messages: 4

Two changes from the version without memory: MemorySaver() as the checkpointer and a config with a thread_id. The agent now accumulates messages across invocations.

Threads as isolation: multi-user

Each thread_id is an isolated conversation. Using the same compiled graph from the previous example:

config_alice = {"configurable": {"thread_id": "alice_thread"}}
config_bob = {"configurable": {"thread_id": "bob_thread"}}

graph.invoke({"messages": [{"role": "user", "content": "I'm Alice, I'm interested in NLP"}]}, config_alice)
graph.invoke({"messages": [{"role": "user", "content": "I'm Bob, I'm interested in computer vision"}]}, config_bob)

result_alice = graph.invoke({"messages": [{"role": "user", "content": "What am I interested in?"}]}, config_alice)
result_bob = graph.invoke({"messages": [{"role": "user", "content": "What am I interested in?"}]}, config_bob)

print(f"Alice: {result_alice['messages'][-1].content}")
print(f"Bob: {result_bob['messages'][-1].content}")
# Expected output:
# Alice: You're interested in NLP...
# Bob: You're interested in computer vision...

Alice and Bob use the same graph. Their conversations are completely independent. Multi-tenancy with one line of config.


The infinite-history problem

A model's context window is finite. GPT-4.1 has 128K tokens. Sounds like a lot. It isn't.

A typical research turn:
  - User message: ~50 tokens
  - Agent tool call: ~100 tokens
  - Tool result (web search): ~500-2000 tokens
  - Agent response: ~300 tokens
  Total per turn: ~1,000-2,500 tokens

With a 128K-token context window:
  128,000 / 2,500 = ~50 turns, in theory
  Practical limit (quality + generation): ~30-40 turns

30 turns sounds like plenty for a casual chat. For a research agent with several tools per turn, you can hit the ceiling in 10-15 interactions. When you do, the model fails with "context length exceeded" — or worse, silently ignores older messages.


Message trimming: keep only what's relevant

Trimming is the most direct strategy: keep the last N messages and drop the rest.

from langchain_core.messages import trim_messages, SystemMessage, HumanMessage, AIMessage

messages = [
    SystemMessage(content="You are a research assistant specialized in AI."),
    HumanMessage(content="What is RAG?"),
    AIMessage(content="RAG is Retrieval-Augmented Generation, a pattern that combines..."),
    HumanMessage(content="How is it implemented?"),
    AIMessage(content="You implement it with a retriever that finds relevant documents..."),
    HumanMessage(content="Which vector databases do you recommend?"),
    AIMessage(content="The main options are Pinecone, Weaviate, Chroma..."),
    HumanMessage(content="Which one is easiest to start with?"),
    AIMessage(content="Chroma is the easiest to start with because..."),
    HumanMessage(content="Give me a code example with Chroma"),
]

trimmed = trim_messages(
    messages,
    max_tokens=200,
    token_counter=len,
    strategy="last",
    include_system=True,
    start_on="human",
)

print(f"Original: {len(messages)} → Trimmed: {len(trimmed)}")
for msg in trimmed:
    print(f"  {msg.type}: {msg.content[:60]}...")
# Expected output:
# Original: 10 → Trimmed: 3
#   system: You are a research assistant specialized in AI....
#   human: Give me a code example with Chroma...

Key trim_messages parameters

ParameterWhat it doesRecommended value
max_tokensMax token budget after the trim80-90% of the context window
token_counterFunction that counts tokensmodel.get_num_tokens_from_messages
strategy"last" (most recent) or "first" (oldest)"last"
include_systemAlways keep the system messageTrue
start_onFirst message type to include"human" (avoids orphaned responses)

Wiring it into the graph

The right place to trim is inside the node, right before calling the model:

from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import trim_messages, SystemMessage
from dotenv import load_dotenv

load_dotenv()

model = ChatOpenAI(model="gpt-4o-mini")
SYSTEM_PROMPT = SystemMessage(content="You are a research assistant specialized in AI.")

def chatbot(state: MessagesState) -> dict:
    trimmed = trim_messages(
        state["messages"],
        max_tokens=4000,
        token_counter=model.get_num_tokens_from_messages,
        strategy="last",
        include_system=True,
        start_on="human",
    )
    if not any(isinstance(m, SystemMessage) for m in trimmed):
        trimmed = [SYSTEM_PROMPT] + trimmed

    response = model.invoke(trimmed)
    return {"messages": [response]}

builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)

graph = builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "trimming_demo"}}

for i in range(1, 6):
    result = graph.invoke(
        {"messages": [{"role": "user", "content": f"Turn {i}: tell me about AI topic {i}"}]}, config
    )
    print(f"Turn {i}: {len(result['messages'])} messages in history")
# Expected output:
# Turn 1: 2 messages in history
# Turn 2: 4 messages in history
# Turn 3: 6 messages in history
# Turn 4: 8 messages in history
# Turn 5: 10 messages in history

The full history stays in the state (thanks to the checkpointer). But the model only sees the messages that fit inside the token budget. Best of both worlds: complete history for auditing, a bounded window for the model.


Summarization: compress instead of discard

Trimming is lossy: the messages you drop are gone. If the user mentioned something important on turn 3 of 50, the agent loses it when you trim. Summarization solves that: it compresses old messages into a summary.

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, RemoveMessage
from typing import Annotated, TypedDict
from langgraph.graph import add_messages
from dotenv import load_dotenv

load_dotenv()

model = ChatOpenAI(model="gpt-4o-mini")


class ConversationState(TypedDict):
    messages: Annotated[list, add_messages]
    summary: str


def chatbot(state: ConversationState) -> dict:
    msgs = state["messages"]
    if state.get("summary"):
        msgs = [SystemMessage(content=f"Summary of the previous conversation: {state['summary']}")] + msgs
    return {"messages": [model.invoke(msgs)]}


def should_summarize(state: ConversationState) -> str:
    return "summarize" if len(state["messages"]) > 10 else "end"


def summarize_conversation(state: ConversationState) -> dict:
    to_summarize = state["messages"][:-2]
    prompt = f"Summarize the conversation in 2-3 sentences, capturing key facts about the user.\n"
    prompt += f"Previous summary: {state.get('summary', 'None')}\n\nConversation:\n"
    for msg in to_summarize:
        role = "User" if isinstance(msg, HumanMessage) else "Assistant"
        prompt += f"{role}: {msg.content}\n"

    response = model.invoke([HumanMessage(content=prompt)])
    delete_messages = [RemoveMessage(id=m.id) for m in to_summarize]
    return {"summary": response.content, "messages": delete_messages}


builder = StateGraph(ConversationState)
builder.add_node("chatbot", chatbot)
builder.add_node("summarize", summarize_conversation)
builder.add_edge(START, "chatbot")
builder.add_conditional_edges("chatbot", should_summarize, {"summarize": "summarize", "end": END})
builder.add_edge("summarize", END)

graph = builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "summary_demo"}}

conversations = [
    "My name is Carlos and I'm an AI researcher",
    "My area is NLP, especially transformers",
    "I'm working on a paper about attention mechanisms",
    "The paper compares multi-head attention with linear attention",
    "The results favor linear attention for long sequences",
    "I need more benchmark data for the conclusions",
]

for i, msg in enumerate(conversations):
    result = graph.invoke({"messages": [{"role": "user", "content": msg}]}, config)
    summary = result.get("summary", "")
    preview = summary[:80] + "..." if summary else "No summary"
    print(f"Turn {i+1}: {len(result['messages'])} msgs | Summary: {preview}")
# Expected output:
# Turn 1: 2 msgs | Summary: No summary
# ...
# Turn 6: 4 msgs | Summary: Carlos is an AI researcher specialized in NLP...

The summarization flow

Turn N (messages <= 10):
  user message → chatbot → should_summarize → "end" → END

Turn N (messages > 10):
  user message → chatbot → should_summarize → "summarize"
                                                  ↓
                                          Summarize old msgs into 2-3 sentences
                                          Drop the summarized msgs with RemoveMessage
                                          Store the summary in state["summary"]
                                                  ↓
                                                 END

Recent messages stay untouched (the last 2). Older ones get compressed into a summary that's injected as a SystemMessage. The agent has context from the whole conversation, but only pays tokens for the summary + recent messages.


Trimming vs summarization: when to use which

AspectTrimmingSummarization
ComplexityLow (one function)Medium (extra LLM call)
Information lossTotal: whatever gets trimmed is gonePartial: the summary keeps the essentials
CostZero: local operationOne LLM call every N turns
LatencyZero+1-3s per summarization
Best forShort Q&A, casual conversationsLong research, cumulative context

Decision rule: Start with trimming — it covers 80% of cases. If users complain that the agent "forgot" something important from earlier turns, move to summarization.


Token counting: knowing when you're near the limit

Don't wait for the model to blow up with "context length exceeded." Monitor proactively with model.get_num_tokens_from_messages(messages) and compare it against the context window. Wire the monitoring into the chatbot node so it trims automatically past a threshold (say, 75% of the context window):

token_count = model.get_num_tokens_from_messages(state["messages"])
if token_count > CONTEXT_WINDOW * 0.75:
    messages = trim_messages(...)  # Automatic trim

This prevents errors and keeps costs under control.


Troubleshooting

Problem 1: "The agent doesn't remember the previous turn"

Symptom: Every invoke() seems to start from zero. Cause: Missing checkpointer or missing thread_id. Fix: Check that you compiled with checkpointer=MemorySaver() and that every invoke includes config = {"configurable": {"thread_id": "..."}}.

Problem 2: "context length exceeded" after many turns

Symptom: The model errors out once you pass the context window. Cause: No trimming — the history grows without bound. Fix: Call trim_messages inside the node, before invoking the model. Set max_tokens to 80% of the context window.

Problem 3: "The system prompt disappears after the trim"

Symptom: The agent loses its persona or its instructions. Cause: trim_messages dropped the SystemMessage. Fix: Use include_system=True. If it still gets lost, re-inject it:

if not any(isinstance(m, SystemMessage) for m in trimmed):
    trimmed = [SYSTEM_PROMPT] + trimmed

Problem 4: "Threads are bleeding into each other"

Symptom: One user sees another user's messages. Cause/Fix: Same thread_id for different users. Generate unique IDs: f"user_{user_id}_conv_{conversation_id}".


Exercises

Exercise 1: Basic multi-turn chatbot (Easy)

Build a chatbot with MessagesState and MemorySaver that holds a conversation. Send 3 messages: your name, your job, and "What do you know about me?". Check that it remembers both facts.

See solution
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv

load_dotenv()

model = ChatOpenAI(model="gpt-4o-mini")

def chatbot(state: MessagesState) -> dict:
    return {"messages": [model.invoke(state["messages"])]}

builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)

graph = builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "exercise_1"}}

r1 = graph.invoke({"messages": [{"role": "user", "content": "My name is Ana"}]}, config)
print(f"T1: {r1['messages'][-1].content[:80]}")

r2 = graph.invoke({"messages": [{"role": "user", "content": "I'm a data engineer"}]}, config)
print(f"T2: {r2['messages'][-1].content[:80]}")

r3 = graph.invoke({"messages": [{"role": "user", "content": "What do you know about me?"}]}, config)
print(f"T3: {r3['messages'][-1].content}")
print(f"Total messages: {len(r3['messages'])}")
# Expected output:
# T1: Hi Ana! Nice to meet you...
# T2: Interesting! Data engineering is...
# T3: I know your name is Ana and you're a data engineer.
# Total messages: 6

assert len(r3["messages"]) == 6
print("✅ The agent keeps the history correctly")

Explanation: MemorySaver plus a thread_id accumulates messages across invocations. On the third turn, the model receives all 6 messages and answers with full context.

Exercise 2: Isolated threads for two users (Easy)

Create two threads with different IDs. Each "user" states their name and topic of interest. Ask each one "What am I interested in?" and check that the answers don't bleed into each other.

See solution
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv

load_dotenv()

model = ChatOpenAI(model="gpt-4o-mini")

def chatbot(state: MessagesState) -> dict:
    return {"messages": [model.invoke(state["messages"])]}

builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)
graph = builder.compile(checkpointer=MemorySaver())

config_a = {"configurable": {"thread_id": "user_maria"}}
config_b = {"configurable": {"thread_id": "user_pedro"}}

graph.invoke({"messages": [{"role": "user", "content": "I'm María, I'm interested in MLOps"}]}, config_a)
graph.invoke({"messages": [{"role": "user", "content": "I'm Pedro, I'm interested in cybersecurity"}]}, config_b)

result_a = graph.invoke({"messages": [{"role": "user", "content": "What am I interested in?"}]}, config_a)
result_b = graph.invoke({"messages": [{"role": "user", "content": "What am I interested in?"}]}, config_b)

print(f"María: {result_a['messages'][-1].content}")
print(f"Pedro: {result_b['messages'][-1].content}")
# Expected output:
# María: You're interested in MLOps...
# Pedro: You're interested in cybersecurity...

print("✅ The threads are properly isolated")

Explanation: Same graph, different thread_id. Each thread stores its own history. Multi-tenancy with one line of config.

Exercise 3: Trimming with an automatic alert (Medium)

Build a chatbot that counts tokens on every turn and applies trim_messages automatically once it passes 500 tokens. Log how many messages got dropped on each trim.

See solution
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import trim_messages, SystemMessage
from dotenv import load_dotenv

load_dotenv()

model = ChatOpenAI(model="gpt-4o-mini")
TOKEN_LIMIT = 500

def chatbot_with_trim(state: MessagesState) -> dict:
    messages = state["messages"]
    token_count = model.get_num_tokens_from_messages(messages)
    print(f"  [MONITOR] {token_count} tokens, {len(messages)} messages")

    if token_count > TOKEN_LIMIT:
        original_count = len(messages)
        messages = trim_messages(
            messages,
            max_tokens=TOKEN_LIMIT // 2,
            token_counter=model.get_num_tokens_from_messages,
            strategy="last",
            include_system=True,
            start_on="human",
        )
        print(f"  [TRIM] ⚠️ {original_count}{len(messages)} messages, {token_count}{model.get_num_tokens_from_messages(messages)} tokens")

    return {"messages": [model.invoke(messages)]}

builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot_with_trim)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)
graph = builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "trim_alert"}}

for i, topic in enumerate(["Explain ML in detail", "What about deep learning?", "What's the difference?",
                           "Give me practical examples", "Which frameworks do you recommend?",
                           "Compare PyTorch vs TensorFlow", "Which one for beginners?"]):
    print(f"Turn {i+1}: '{topic}'")
    graph.invoke({"messages": [{"role": "user", "content": topic}]}, config)
print("\n✅ Automatic trim working")

Explanation: The node checks tokens before every invocation. Past 500, it trims to 50% and logs the operation. In production, these logs go to your observability system.

Exercise 4: Summarization with verification (Medium)

Build a graph with summarization that kicks in past 6 messages. Send specific facts in the first turns (name, company, city). After summarization, check that the summary preserved those facts.

See solution
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, RemoveMessage
from typing import Annotated, TypedDict
from langgraph.graph import add_messages
from dotenv import load_dotenv

load_dotenv()

model = ChatOpenAI(model="gpt-4o-mini")


class SumState(TypedDict):
    messages: Annotated[list, add_messages]
    summary: str


def chatbot(state: SumState) -> dict:
    msgs = state["messages"]
    if state.get("summary"):
        msgs = [SystemMessage(content=f"Previous summary: {state['summary']}")] + msgs
    return {"messages": [model.invoke(msgs)]}


def should_summarize(state: SumState) -> str:
    return "summarize" if len(state["messages"]) > 6 else "end"


def summarize(state: SumState) -> dict:
    old = state["messages"][:-2]
    prompt = "Summarize in 2-3 sentences. Include ALL specific facts (names, companies, cities):\n"
    for m in old:
        role = "User" if isinstance(m, HumanMessage) else "Assistant"
        prompt += f"{role}: {m.content}\n"
    resp = model.invoke([HumanMessage(content=prompt)])
    return {"summary": resp.content, "messages": [RemoveMessage(id=m.id) for m in old]}


builder = StateGraph(SumState)
builder.add_node("chatbot", chatbot)
builder.add_node("summarize", summarize)
builder.add_edge(START, "chatbot")
builder.add_conditional_edges("chatbot", should_summarize, {"summarize": "summarize", "end": END})
builder.add_edge("summarize", END)
graph = builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "sum_verify"}}

graph.invoke({"messages": [{"role": "user", "content": "My name is Diana and I live in Bogotá"}]}, config)
graph.invoke({"messages": [{"role": "user", "content": "I work in fintech, my company is PayLatam"}]}, config)
graph.invoke({"messages": [{"role": "user", "content": "We use Python and FastAPI"}]}, config)
graph.invoke({"messages": [{"role": "user", "content": "Our main product is mobile payments"}]}, config)

result = graph.invoke(
    {"messages": [{"role": "user", "content": "Where do I live and what's my company called?"}]}, config
)
print(f"Answer: {result['messages'][-1].content}")
print(f"Summary: {result.get('summary', 'None')[:120]}...")
# Expected output:
# Answer: You live in Bogotá and your company is called PayLatam...
# Summary: Diana lives in Bogotá, works in fintech at PayLatam using Python and FastAPI...

print("✅ Summarization preserves facts from earlier turns")

Explanation: The first turns establish concrete facts. Once the history passes 6 messages, they get summarized. The final question checks that the summary captured the key facts — name, city, company.

Exercise 5: Compare trimming vs summarization (Medium-Advanced)

Build two graphs — one with aggressive trimming and one with summarization. Send a specific fact in message 1 ("my code is ALPHA-7742"), followed by 7 messages on unrelated topics. In message 9, ask for the code. Compare which of the two remembers it.

See solution
from langgraph.graph import StateGraph, START, END, MessagesState, add_messages
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import trim_messages, SystemMessage, HumanMessage, RemoveMessage
from typing import Annotated, TypedDict
from dotenv import load_dotenv

load_dotenv()

model = ChatOpenAI(model="gpt-4o-mini")

# Graph 1: Aggressive trimming (300 tokens max)
def chatbot_trim(state: MessagesState) -> dict:
    messages = trim_messages(
        state["messages"], max_tokens=300,
        token_counter=model.get_num_tokens_from_messages,
        strategy="last", include_system=True, start_on="human",
    )
    return {"messages": [model.invoke(messages)]}

b1 = StateGraph(MessagesState)
b1.add_node("chatbot", chatbot_trim)
b1.add_edge(START, "chatbot")
b1.add_edge("chatbot", END)
graph_trim = b1.compile(checkpointer=MemorySaver())

# Graph 2: Summarization (threshold 6 msgs)
class SumState(TypedDict):
    messages: Annotated[list, add_messages]
    summary: str

def chatbot_sum(state: SumState) -> dict:
    msgs = state["messages"]
    if state.get("summary"):
        msgs = [SystemMessage(content=f"Context: {state['summary']}")] + msgs
    return {"messages": [model.invoke(msgs)]}

def should_sum(state: SumState) -> str:
    return "summarize" if len(state["messages"]) > 6 else "end"

def do_sum(state: SumState) -> dict:
    old = state["messages"][:-2]
    prompt = "Summarize, capturing ALL specific facts (codes, numbers, names):\n"
    for m in old:
        role = "User" if isinstance(m, HumanMessage) else "Assistant"
        prompt += f"{role}: {m.content}\n"
    resp = model.invoke([HumanMessage(content=prompt)])
    return {"summary": resp.content, "messages": [RemoveMessage(id=m.id) for m in old]}

b2 = StateGraph(SumState)
b2.add_node("chatbot", chatbot_sum)
b2.add_node("summarize", do_sum)
b2.add_edge(START, "chatbot")
b2.add_conditional_edges("chatbot", should_sum, {"summarize": "summarize", "end": END})
b2.add_edge("summarize", END)
graph_sum = b2.compile(checkpointer=MemorySaver())

# Test
msgs = [
    "My secret code is ALPHA-7742. Remember it.",
    "Let's talk about Python", "FastAPI or Django?", "And Flask?",
    "Let's talk about databases", "PostgreSQL or MySQL?",
    "And MongoDB?", "What's my secret code?",
]

ct = {"configurable": {"thread_id": "cmp_trim"}}
cs = {"configurable": {"thread_id": "cmp_sum"}}

for msg in msgs[:-1]:
    graph_trim.invoke({"messages": [{"role": "user", "content": msg}]}, ct)
    graph_sum.invoke({"messages": [{"role": "user", "content": msg}]}, cs)

rt = graph_trim.invoke({"messages": [{"role": "user", "content": msgs[-1]}]}, ct)
rs = graph_sum.invoke({"messages": [{"role": "user", "content": msgs[-1]}]}, cs)

print(f"TRIMMING: {rt['messages'][-1].content}")
print(f"SUMMARIZATION: {rs['messages'][-1].content}")
print(f"\nTrimming remembers: {'✅' if '7742' in rt['messages'][-1].content else '❌'}")
print(f"Summarization remembers: {'✅' if '7742' in rs['messages'][-1].content else '❌'}")
# Expected output:
# Trimming remembers: ❌ (message 1 was dropped)
# Summarization remembers: ✅ (the summary captured the fact)

Explanation: The code appears only in message 1. Trimming drops it once the window fills up. Summarization captures it in the summary. This is the concrete trade-off between the two strategies.


Summary

In this capsule you learned:

  • Without short-term memory, every invoke() is an island — the agent has no idea what happened on the previous turn. With MessagesState and a checkpointer, the history accumulates automatically
  • MessagesState is a pre-built state with messages: Annotated[list[AnyMessage], add_messages]. The add_messages reducer appends new messages and handles deduplication by ID
  • thread_id isolates conversations: same graph, different threads, different histories. It's the foundation of multi-user
  • The history grows without bound and context windows are finite. Without management, your agent fails once the conversation gets long
  • Message trimming drops old messages. Simple, free, but lossy
  • Summarization compresses messages into a summary. It preserves context but costs an extra LLM call
  • Proactive token monitoring avoids "context length exceeded" — monitor before invoking the model, not after it blows up

Next capsule: Checkpointing: MemorySaver — how LangGraph saves the agent's state automatically, how to inspect checkpoints, and how thread management organizes executions.


Further reading

  1. LangGraph — How to manage conversation history — Official guide to managing history with trimming and summarization
  2. LangGraph — MessagesState — Docs for the pre-built message state
  3. LangChain — trim_messages — Full reference for the trim_messages function
  4. LangGraph — How to add summary of conversation history — Step-by-step guide to summarization
  5. OpenAI — Managing tokens — How to count and manage tokens
  6. LangGraph — add_messages reducer — Docs for the message reducer

Module 8 — LangChain & LangGraph: From Chains to Agents

Next capsule: Checkpointing: MemorySaver — you'll learn how LangGraph automatically saves the agent's state after every node, how to inspect checkpoints to see the exact state at any moment, and how thread_id organizes execution history.