DEV Community

Ramesh S
Ramesh S

Posted on

LangChain for Absolute Beginners - Part 5: Memory & Middleware

Every agent we've built so far forgets everything the moment invoke() returns. Ask it a follow-up question and it has no idea what you were just talking about. In this article we fix that with memory, and then add middleware - LangChain's mechanism for enforcing rules around what an agent is allowed to do.

Recap: The Series So Far

  1. Part 1 - What LangChain is & your first script
  2. Part 2 - Prompts, models, and LCEL chains
  3. Part 3 - Tools and your first agent
  4. Part 4 - RAG: reading your own documents
  5. Part 5 (this article) - Memory and middleware
  6. Part 6 - Debugging and observing agents with LangSmith

Why Agents Forget by Default

Each call to agent.invoke(...) runs the agent loop from a blank slate - there's no built-in place for previous messages to live between calls. To fix that, LangChain agents support a checkpointer: a component that saves the conversation's state after every step, keyed by a thread_id you choose.

Step 1: Add a Checkpointer

from dotenv import load_dotenv
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver

load_dotenv()

checkpointer = InMemorySaver()

agent = create_agent(
    "gpt-5",
    checkpointer=checkpointer,
    system_prompt="You are a helpful, concise assistant.",
)

config = {"configurable": {"thread_id": "conversation-1"}}

agent.invoke(
    {"messages": [{"role": "user", "content": "Hi! My name is Ramesh."}]},
    config=config,
)

result = agent.invoke(
    {"messages": [{"role": "user", "content": "What's my name?"}]},
    config=config,
)

print(result["messages"][-1].content)
Enter fullscreen mode Exit fullscreen mode

The second call correctly answers "Ramesh" - even though it's a separate invoke() call - because both calls share the same thread_id. Change the thread_id and you get a completely fresh conversation, which is exactly how you'd isolate memory per user or per chat session in a real app.

InMemorySaver keeps everything in your process's memory, so it disappears when your script exits. For anything you want to survive a restart, swap it for PostgresSaver or another persistent checkpointer - same pattern we've used throughout this series: change one line, keep the rest of your code.

Step 2: Keep Conversations From Growing Forever

Long conversations eventually blow past a model's context window, and cost money on every turn besides. LangChain's @before_model hook lets you trim the message list right before it's sent to the model:

from typing import Any
from langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langchain.agents import AgentState
from langchain.agents.middleware import before_model
from langgraph.runtime import Runtime

@before_model
def trim_messages(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    """Keep only the most recent messages to stay within context limits."""
    messages = state["messages"]
    if len(messages) <= 6:
        return None  # nothing to trim yet

    first_msg = messages[0]
    recent_messages = messages[-5:]
    return {
        "messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), first_msg, *recent_messages]
    }

agent = create_agent(
    "gpt-5",
    checkpointer=checkpointer,
    middleware=[trim_messages],
)
Enter fullscreen mode Exit fullscreen mode

This is middleware: a function that intercepts the agent loop at a defined point - here, right before every model call - and gets a chance to modify what happens next.

Step 3: Require Human Approval for Sensitive Tools

The most important middleware pattern for production agents is human-in-the-loop. If a tool can send an email, charge a card, or delete data, you generally don't want the model taking that action unsupervised.

from langchain.agents.middleware import HumanInTheLoopMiddleware

agent = create_agent(
    "gpt-5",
    tools=[send_email, get_weather],
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "send_email": {"allowed_decisions": ["approve", "reject"]},
            }
        )
    ],
    checkpointer=checkpointer,
)
Enter fullscreen mode Exit fullscreen mode

With this in place, whenever the agent decides to call send_email, execution pauses and hands control back to your application instead of firing the tool immediately. Your app can then show the proposed email to a human, and only actually send it once approved - the agent literally cannot bypass this gate, because it's enforced outside the model's control, not requested of it.

Notice get_weather isn't listed in interrupt_on - only tools you name there get the approval gate, so low-risk tools keep running automatically.

Putting It Together

A production-grade support agent might combine everything from this series so far:

agent = create_agent(
    "gpt-5",
    tools=[search_company_docs, send_email],          # Part 3 & 4
    system_prompt="You are a support assistant...",    # Part 2
    checkpointer=checkpointer,                          # memory, this part
    middleware=[trim_messages, HumanInTheLoopMiddleware(
        interrupt_on={"send_email": {"allowed_decisions": ["approve", "reject"]}}
    )],                                                  # guardrails, this part
)
Enter fullscreen mode Exit fullscreen mode

That's a genuinely production-shaped agent in about ten lines - remembers the conversation, grounds answers in your documents, and can't send an email without a human saying yes.

What's Next

In the final article of this series, Part 6, we have covered how to actually see what your agent is doing - tracing every model call and tool call with LangSmith, and using that visibility to debug the inevitable cases where an agent picks the wrong tool or gets stuck in a loop.


This is Part 5 of a 6-part LangChain beginner series.

Top comments (0)