Short-term memory
An agent with no checkpointer has no memory at all. Every invocation starts from whatever you handed it and ends with the result thrown away. Memory is a component you attach, not a property agents have.
Attaching one
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(model="...", tools=[...], checkpointer=InMemorySaver())from langgraph.checkpoint.postgres import PostgresSaver
DB_URI = "postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup()
agent = create_agent("gpt-5.5", tools=[...], checkpointer=checkpointer)Note checkpointer.setup(), which creates the tables. Skipping it is a first-run failure that looks like a connection problem and is not.
The thread id is the whole mechanism
thread_config = {"configurable": {"thread_id": "1"}}
response = agent.invoke({"messages": [{"role": "user", "content": "Hi!"}]}, thread_config)Two levels of nesting, and people flatten it from memory. configurable is the user-supplied compartment of the config; thread_id lives inside it.
What is in state
The default is AgentState, which carries a messages key. You extend it by subclassing:
class CustomAgentState(AgentState):
user_id: str
preferences: dict
agent = create_agent("gpt-5.5", tools=[...],
state_schema=CustomAgentState,
checkpointer=InMemorySaver())Tools read those fields through runtime.state[...] and write them by returning a Command, which is the previous lesson meeting this one.
Trimming, before the middleware exists
History grows, and eventually it stops fitting. Module 3 has a middleware for this. The manual mechanism underneath is worth seeing once, because it explains what the middleware is doing.
from langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
@before_model
def trim(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
messages = state["messages"]
if len(messages) <= 3:
return None
return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages]}Two things to take from that. REMOVE_ALL_MESSAGES followed by a spread is a replace, not an append, and the order inside the list is what makes it so. And returning None means “no change”, which is how a hook opts out of doing anything on this turn.
You can also delete individually, with RemoveMessage(id=m.id) per message, when you want to drop a specific exchange rather than compress the lot.
Try it yourself
Predict the second answer
The agent has a checkpointer attached. Two calls, and the config differs.
cfg_a = {"configurable": {"thread_id": "1"}}
cfg_b = {"configurable": {"thread_id": "2"}}
agent.invoke({"messages": [{"role": "user", "content": "My name is Rai."}]}, cfg_a)
agent.invoke({"messages": [{"role": "user", "content": "What is my name?"}]}, cfg_b)Show answer
Correct answer: D — It cannot answer, because thread 2 has no history containing that name
A checkpointer scopes memory per thread, and thread 2 is a different conversation with an empty history. The first option is the tempting one because "it has a checkpointer, therefore it remembers" is the intuition everybody arrives with; what a checkpointer actually gives you is persistence keyed by thread, and choosing the key is your job.
Where the thread id lives
An exact-shape question. The nesting is two levels deep and people flatten it from memory.
Write out the full config dictionary that scopes an invocation to a conversation thread.
Reveal answer
{"configurable": {"thread_id": "1"}}. It is passed as the second argument to invoke, alongside the messages payload. The common error is flattening it to {"thread_id": "1"}, because the configurable nesting looks like ceremony until you remember that config also carries other framework concerns and configurable is specifically the user-supplied compartment.
The agent with no checkpointer
An agent is created with no checkpointer at all, and invoked twice in a row with a thread id in the config. What happens on the second call?
Show answer
Correct answer: C — It starts fresh, because there is nothing persisting state between invocations
With no checkpointer there is nowhere for state to be written, so each invocation starts from whatever you passed in. The last option is the seductive one, because it describes InMemorySaver exactly, and people merge "in memory" with "no checkpointer" into one idea. They are different: InMemorySaver is a real checkpointer with a volatile backing store.
Clearing history inside a hook
A before_model hook trims history. What does this return value do?
return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages]}Show answer
Correct answer: A — Deletes every existing message, then writes new_messages as the history
REMOVE_ALL_MESSAGES clears the list, and the messages spread after it become the new contents, so the pair is a replace rather than an append. The second option is tempting because the spread syntax reads like an append and the RemoveMessage looks like a no-op sentinel; order inside that list is what makes it a replacement.
Prove thread isolation in a scratch file
Small, offline apart from one model call each, and it makes the abstraction concrete in a way reading cannot.
- Build an agent with InMemorySaver and no tools.
- Tell it a fact on thread_id "a", then ask for that fact back on thread_id "a".
- Ask the same question on thread_id "b" and watch it fail.
- Now restart the process and re-ask on thread_id "a". Note that the answer is gone, and write one sentence on which of the two saver types you would have needed.