Bordered avatar

Street Learner

Author
16 min read

Last Updated: a year ago

LangGraph Part-2: Complete Message Management System With Reducers, Annotated Framework & Dynamic Memory

LangGraph Part-2: Complete Message Management System With Reducers, Annotated Framework & Dynamic Memory

In Part-1 of this LangGraph Blog Series, we understood the foundation of LangGraph — Graph structure, Nodes, Edges, Conditional Routing, State system, and Graph Execution.

Now in Part-2, we upgrade our knowledge and turn LangGraph into a real conversation system that:

  • Stores message history
  • Appends interactions
  • Repairs memory structure
  • Removes old messages
  • Trims state to prevent token explosion
  • Summarises past conversations
  • Automatically manages conversation loops

This is the stage where LangGraph starts resembling real-world conversational intelligence:

Chatbots, customer support workflows, AI interview engines, tutoring systems, and research assistance.

🔷 Why Message Management Matters in LangGraph?

Large Language Models don’t just need input → response. They need evolving memory.

Without memory control:

  • Conversations become expensive
  • Responses get irrelevant
  • Token limit failures occur
  • Data leaks
  • Performance degrades
  • Model becomes inconsistent

LangGraph solves this using:

  • Reducers
  • Annotated constructors
  • MessageState template
  • Dynamic delete/update operations

SECTION 1 — Annotated Construct & Reducer Functions

This section introduces:

✔ Annotated typing
✔ add_messages() reducer
✔ how messages are appended, not overwritten
✔ true State evolution

Why Annotated Matters?

In Part-1, your State looked like this:

class State(TypedDict):
    messages: Sequence[BaseMessage]

But this method overwrites messages.

To build real chat memory, we need append logic.

LangGraph solves this using:

messages: Annotated[Sequence[BaseMessage], add_messages]

Meaning:

  • Sequence[BaseMessage] defines data type
  • add_messages instructs StateGraph to append new messages

Without this, chatbot memory breaks.

Reducer Overview

Reducer is a rule applied automatically every time a node returns messages.

add_messages reducer does:

1️⃣ Take old messages 2️⃣ Take returned messages 3️⃣ Merge both 4️⃣ Store result into next State

Example Test:

my_list = add_messages(
    [HumanMessage("Hi! I'm Oscar."),
     AIMessage("Hey, Oscar. How can I assist you?")],
    [HumanMessage("Could you summarize today's news?")]
)
print(my_list)

Output meaning:

  • New HumanMessage is appended at the end
  • Original messages preserved

Full Example Code (Provided block)

Your code defines:

  • 3 nodes → ask_question, chatbot, ask_another_question
  • routing function deciding loop
  • Stateful memory based on Annotated reducer

SECTION 2 — Reducer Functions In Action

This section teaches deeper reducer behavior:

  • Multiple message storage
  • AI + Human messages in same node
  • Enhanced conversational history

Why this matters

Most developers think append logic is only one message per turn.

Incorrect.

Real workflows append:

  • AI Questions
  • Human answers
  • Model responses
  • System instructions

Example difference:

Standard human question append:

return State(messages=[HumanMessage(user_input)])

Updated 04-02 version adds structure:

return State(messages=[AIMessage(question), HumanMessage(user_input)])

Why?

Because reducers need more accurate context. More context = smarter chain routing and model reasoning.

Routing Change Explained

Part-1 used:

state["messages"][0]

Part-2 upgrades to:

state["messages"][-1]

Why?

Last message is always the most relevant.

SECTION 3 — The MessageState Class

LangGraph provides a pre-built State definition:

from langgraph.graph import MessagesState

Meaning:

  • messages field exists
  • reducer is pre-attached
  • no manual TypedDict work

Why is this powerful?

You don’t need to write:

messages: Annotated[Sequence[BaseMessage], add_messages]

LangGraph already created the State for you.

Example Behaviour From Your Code

def ask_question(state: MessagesState) -> MessagesState:
    question = "What is your question?"
    user_input = "Tell me a joke."

    return MessagesState(messages=[
        AIMessage(question),
        HumanMessage(user_input)
    ])

Notice: No typing overhead. Just return messages and reducer manages the merge.

Key advantage

Building large conversational systems becomes cleaner and safer.

SECTION 4 — RemoveMessages Class

This section addresses the most dangerous problem:

conversation token explosion

As chat grows:

  • model slows down
  • memory becomes expensive
  • output quality drops
  • cost skyrockets

Solution:

LangGraph provides this class:

RemoveMessage(id=...)

Example Use Case From Provided Code

Conversation history:

[AIMessage(...),
 HumanMessage(...),
 HumanMessage(...),
 ...
]

Goal:

Delete first N messages using:

remove_messages = [RemoveMessage(id=i.id) for i in my_list[:-5]]

Why is deleting messages important?

Because memory grows endlessly.

If your graph loops 300 times:

  • messages list = 300 records
  • LLM fails due to tokens
  • cost increases
  • response quality collapses

Result:

Remove old messages → keep last 5

SECTION 5 — Trimming Messages in LangGraph

Now we combine:

✔ reducers ✔ removers ✔ routers ✔ dynamic trimming

Why trimming?

Real-world graph loops like:

  • Q → A → Q → A → Q → A

Meaning memory must shrink automatically.

New Node Introduced:

def trim_messages(state: MessagesState) -> MessagesState:

Logic:

remove_messages = [RemoveMessage(id=i.id) for i in state["messages"][:-5]]

So the graph keeps:

  • last 5 messages
  • all earlier messages are deleted

Execution Flow

1️⃣ ask_question → chatbot → ask_another_question 2️⃣ if user says "yes":

  • route to trim_messages

3️⃣ trim done 4️⃣ node returns to ask_question

SECTION 6 — Summarizing Messages (Advanced Memory)

The final and hardest concept.

Goal:

Keep historical knowledge AND reduce tokens

Solution:

Store summaries in new State field:

class State(MessagesState):
    summary: str

Now the graph supports long-term memory.

Summarization Node Function

def summarize_and_delete_messages(state: State):

Process:

1️⃣ generate summary prompt 2️⃣ ask ChatOpenAI to produce new summary 3️⃣ store summary 4️⃣ delete all messages 5️⃣ restart loop clean

Why this solves memory problem?

Because LLM doesn’t need raw old messages.

It only needs a compressed form.

Final routing upgrades:

if user says “yes”:

→ summarize → delete → ask again

if user says “no”:

→ exit graph

***

Now Building a Fully Integrated LangGraph Conversational Memory System (All Features Combined)

So far, we explored every core component individually:

  • Reducers
  • Annotated memory storage
  • MessageState
  • Message deletion
  • Trimming logic
  • Summarization
  • Routing between nodes

But real-life engineering requires:

ONE system where everything works together

This section builds exactly that.

We will create a conversational loop that:

1️⃣ asks a question

2️⃣ stores messages using reducer

3️⃣ passes to the chatbot

4️⃣ asks user if they want to continue

5️⃣ trims old messages to avoid token overload

6️⃣ summarises conversation when memory gets too big

7️⃣ exits gracefully when user finishes

This is enterprise-ready LangGraph design.

Step-1: Install Packages & Load Models

!pip install langchain langgraph langchain-openai openai tiktoken

This ensures all LangGraph and model dependencies are installed.

Step-2: Import All Required Modules

from langgraph.graph import MessagesState, StateGraph, START, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
from langgraph.graph.message import RemoveMessage
import random

Step-3: Build Shared LLM Model

We want low randomness for stable testing.

llm = ChatOpenAI(
    model="gpt-4",
    temperature=0.2,
    max_tokens=150
)

Step-4: Define Application State With Summary Memory

class State(MessagesState):
    summary: str

This is crucial:

  • messages = live conversation
  • summary = compressed long-term memory

Step-5: Node-1 → Ask User a Question

def ask_question(state: State) -> State:
    
    question = "What topic are you curious about?"
    
    # simulate user answer (in real system use input())
    user_choices = ["Space", "AI", "Sports", "History", "Movies"]
    user_input = random.choice(user_choices)

    return State(
        messages=[
            AIMessage(question),
            HumanMessage(user_input)
        ]
    )

Here memory reducer auto-stores both messages.

Step-6: Node-2 → Chatbot Generates Information

def chatbot(state: State) -> State:
    
    last_msg = state["messages"][-1].content
    
    answer = llm.invoke(
        [HumanMessage(f"Explain something interesting about {last_msg}")]
    )
    
    return State(
        messages=[AIMessage(answer.content)]
    )

This node turns user interest into educational output.

Step-7: Node-3 → Ask User If They Want to Continue

def ask_continue(state: State) -> State:

    question = "Do you want to continue exploring topics?"
    
    random_reply = random.choice(["yes", "no"])
    
    return State(
        messages=[
            AIMessage(question),
            HumanMessage(random_reply)
        ]
    )

This creates a branching loop.

Step-8: Node-4 → Trim Old Messages for Token Safety

def trim_messages(state: State) -> State:

    # keep last 6 messages
    if len(state["messages"]) > 6:

        remove = [RemoveMessage(id=m.id) for m in state["messages"][:-6]]
        
        return {"messages": remove}

    return state

Why keep 6 messages?

  • 2 cycles of conversation
  • enough context
  • no memory overload

Step-9: Node-5 → Summarize When Loop Too Big

def summarize(state: State) -> State:
    
    message_texts = "\n".join([m.content for m in state["messages"]])
    
    summary = llm.invoke(
        [HumanMessage(f"Summarize the following conversation:\n{message_texts}")]
    )
    
    remove_all = [
        RemoveMessage(id=m.id) for m in state["messages"]
    ]
    
    return State(
        summary=summary.content,
        messages=remove_all
    )

Flow:

1️⃣ all messages compressed 2️⃣ summary saved 3️⃣ old messages deleted

Step-10: Conditional Routing Logic

def router(state: State):

    last = state["messages"][-1].content.lower()

    if "no" in last:
        return "end"
    
    if len(state["messages"]) > 12:
        return "summarize"

    return "ask_question"

Breakdown:

  • “no” → end
  • more than 12 messages → summarise
  • otherwise → loop

Step-11: Construct Graph Blueprint

builder = StateGraph(State)

builder.add_node("ask_question", ask_question)
builder.add_node("chatbot", chatbot)
builder.add_node("ask_continue", ask_continue)
builder.add_node("trim", trim_messages)
builder.add_node("summarize", summarize)

Step-12: Connect Node Flow

builder.add_edge(START, "ask_question")
builder.add_edge("ask_question", "chatbot")
builder.add_edge("chatbot", "ask_continue")
builder.add_edge("ask_continue", "trim")
builder.add_edge("trim", "router")
builder.add_conditional_edges("router", router, {
    "ask_question": "ask_question",
    "summarize": "summarize",
    "end": END
})

This creates a loop like real applications.

Step-13: Compile Graph

graph = builder.compile()

Step-14: Execute Full System

response = graph.invoke({"messages": [], "summary": ""})

print(response)

Now LangGraph will:

  • run multiple conversation cycles
  • detect memory size
  • trim messages
  • summarise
  • exit
***

Related Stories