Multi-Agent Knowledge Team with LangGraph
Build a multi-agent system where agents share memory across conversations.
Introduction
30 Second Summary
Think about how teams share knowledge at work. If you brief a colleague on a project today, they remember that context even when you catch up with them days later in a completely different conversation. AI agents need the same ability to retain and share knowledge across interactions.
In this project, you will build a multi-agent research team powered by LangGraph. You will create two specialized AI agents that collaborate through shared memory, so one agent's findings inform the other's output across multiple conversations.
What You'll Build
You ask a question, a researcher agent extracts key facts and stores them into shared memory, then a writer agent pulls those facts and produces a polished summary that gets richer with every session you run.
By the end of this project, you'll have:
- A chatbot that remembers your name and preferences across completely separate conversations, proving cross-thread memory works.
- A multi-agent research team where you can watch a researcher store findings and a writer produce summaries that incorporate everything from previous sessions.
- A clear mental model of LangGraph's three memory layers (state, checkpointers, and stores) that you can inspect directly to see exactly what each agent stored.
- Secret Mission: Add semantic search to the memory store so agents find relevant memories by meaning, not just by listing everything.
Are there any prerequisites?
You need Python 3.10 or higher installed on your machine, an OpenAI API key, and a code editor like VS Code or Cursor. No prior experience with LangGraph or LangChain is required.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters. This is your moment to commit to the goal so every step ahead has clear purpose.
Install Packages and Set Up Your Project
Before building agents that remember and share knowledge, you need the right Python packages installed. LangGraph handles graph-based agent orchestration, and langchain-openai connects your agents to OpenAI's language models.
In this step, you'll install both packages, create a project workspace on your Desktop, and configure your API key so everything is ready for the first chatbot.
In this step, get ready to:
- Install LangGraph and LangChain-OpenAI packages.
- Create a project directory with a chatbot.py file.
- Configure your OpenAI API key as an environment variable.
Install the required packages
- Open the integrated terminal in your code editor by pressing Cmd+` (macOS) or Ctrl+` (Windows).
- Install LangGraph and langchain-openai by running this command:
pip install -U langgraph langchain-openai
What does this command do?
The -U flag tells pip to upgrade to the latest version if the package is already installed.
langgraph is the framework for building stateful, multi-agent AI applications using graphs. langchain-openai provides the ChatOpenAI model wrapper that connects your graph nodes to OpenAI's API.
You should see pip downloading and installing packages, ending with a line like Successfully installed langgraph-1.2.9 langchain-openai-1.4.0 (your version numbers may vary slightly).
Seeing 'pip: command not found'?
- Try pip3 instead of pip. Some systems use pip3 for Python 3.
- Make sure Python 3.10+ is installed and added to your system PATH.
Help me troubleshoot my pip installation
Create your project directory and file
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and create the chatbot.py file by running these commands:
macOS
mkdir knowledge-team
cd knowledge-team
touch chatbot.py
Windows
mkdir knowledge-team
cd knowledge-team
New-Item chatbot.py -ItemType File
- Confirm the file exists by running:
ls
You should see chatbot.py listed in the output.
Don't see chatbot.py?
- Make sure you ran the cd knowledge-team command before creating the file. You need to be inside the folder.
- On Windows, use dir instead of ls if ls is not recognized.
Help me create my project files
Configure your OpenAI API key
Your agents need access to OpenAI's models. Setting the API key as an environment variable keeps it out of your code files.
- Set your OpenAI API key by running this command, replacing your-api-key-here with your actual key:
macOS
export OPENAI_API_KEY="your-api-key-here"
Windows
$env:OPENAI_API_KEY = "your-api-key-here"
Keep your key safe
This environment variable only lasts for your current terminal session. You'll need to set it again if you close and reopen your terminal. Never paste your API key directly into code files that you might share or commit to version control.
Before you run the next command, what do you expect to see if both packages installed correctly and your environment is configured?
- Verify that both packages import correctly by running:
python -c "import langgraph; from langchain_openai import ChatOpenAI; print('Ready!')"
You should see Ready! printed with no errors. This confirms that both langgraph and langchain-openai are installed and importable.
- Check the installed LangGraph version by running:
python -c "import langgraph; print(langgraph.__version__)"
You should see 1.2.9 or a similar version number printed.
Seeing an ImportError?
- If you see ModuleNotFoundError: No module named 'langgraph', the install may have targeted a different Python version. Try pip3 install -U langgraph langchain-openai instead.
- Make sure you're running the same python that pip installed to. Run which python (macOS) or where python (Windows) to check the path.
Help me fix my LangGraph import error
Your environment is ready. Next up, you'll build a basic chatbot graph and discover firsthand why it needs memory.
Build a Stateless Chatbot Graph
Your packages are installed and your API key is ready. Now it is time to write your first LangGraph chatbot.
Before adding any memory, you need to understand how a basic StateGraph works with nodes, edges, and state. You will also experience firsthand that without persistence, the bot has complete amnesia between invocations.
In this step, get ready to:
- Import LangGraph packages and define the chatbot node function.
- Build and compile the StateGraph.
- Run the chatbot and discover its memory limitation.
Import packages and define the model node
A LangGraph chatbot is built from three pieces: a state that carries the conversation, nodes that do work, and edges that connect them. You will start by setting up the model and defining your first node function.
- Open chatbot.py in your code editor and add the following code:
import os
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, START, END
# Set the API key from your environment variable
os.environ.setdefault("OPENAI_API_KEY", "your-api-key-here")
# Create the language model
model = ChatOpenAI(model="gpt-4o-mini")
# Define the node function that calls the model
def call_model(state: MessagesState):
response = model.invoke(state["messages"])
return {"messages": [response]}
What does this code do?
- MessagesState is a built-in LangGraph state type that holds a list of chat messages. It automatically appends new messages instead of overwriting old ones.
- call_model is a node function. It receives the current state, passes the messages to the LLM, and returns the response wrapped in the same state shape.
- START and END are special constants that mark where the graph begins and finishes.
- Save chatbot.py.
Build and compile the StateGraph
With your node defined, you can now wire it into a graph. The graph connects your node between START and END to create a complete conversation flow.
- Add the following code below your call_model function in chatbot.py:
# Build the graph
builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_edge(START, "call_model")
builder.add_edge("call_model", END)
graph = builder.compile()
What does this code do?
- StateGraph(MessagesState) creates a new graph that uses the messages state schema.
- add_node registers your function as a named node in the graph.
- add_edge connects two nodes. Here: START flows into your node, and your node flows into END.
- compile() validates the graph and produces a runnable object you can invoke.
- Save chatbot.py.
- Confirm the graph compiles cleanly by running:
python chatbot.py
You should see the terminal return to the prompt with no output and no errors. This confirms your graph compiled successfully.
Seeing an ImportError or ModuleNotFoundError?
- Make sure you installed both packages in the same environment where you are running the script. Run pip install -U langgraph langchain-openai again to confirm.
- Check that your import paths match exactly. langchain_openai uses an underscore, not a hyphen.
Help me debug my LangGraph import error
Run the chatbot and discover its amnesia
Your graph can now process a single message. Let's test it with a two-part conversation where you introduce yourself and then ask the bot to recall your name.
- Add the following invocations at the bottom of chatbot.py:
# Test: invoke the graph twice
response = graph.invoke({"messages": [{"role": "user", "content": "Hi! My name is Alice."}]})
print(f"Bot: {response['messages'][-1].content}\n")
response = graph.invoke({"messages": [{"role": "user", "content": "What is my name?"}]})
print(f"Bot: {response['messages'][-1].content}\n")
What does this code do?
Each graph.invoke() call runs the entire graph from START to END with a fresh input. The first call introduces yourself as Alice. The second call asks the bot if it knows your name.
- Save chatbot.py.
Before you run this, think about it: do you think the bot will remember Alice's name in the second invocation? Why or why not?
- Run the chatbot by executing:
python chatbot.py
The bot responds to the first message with a friendly greeting. But when asked "What is my name?" in the second invocation, it says it does not know your name.
Why does the bot forget?
Each call to graph.invoke() starts with a completely fresh state. The first invocation's messages are gone by the time the second invocation runs. The graph has no persistence layer to carry information between calls.
This amnesia is the core problem that LangGraph's memory systems solve. In the next step, you will add a checkpointer to fix it.
Seeing an authentication error?
- Make sure your OPENAI_API_KEY environment variable is set in the same terminal session where you run the script.
- If you set the key in a different terminal window, it will not carry over. Run export OPENAI_API_KEY="your-api-key-here" again in your current terminal.
Help me fix my OpenAI authentication error in LangGraph
✔️ Awesome, I've got everything!
Great. Double check you have saved chatbot.py and confirmed the amnesia behavior.
ⓧ I'd like to double check the full code
Here is the complete chatbot.py at this point:
import os
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, START, END
os.environ.setdefault("OPENAI_API_KEY", "your-api-key-here")
model = ChatOpenAI(model="gpt-4o-mini")
def call_model(state: MessagesState):
response = model.invoke(state["messages"])
return {"messages": [response]}
builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_edge(START, "call_model")
builder.add_edge("call_model", END)
graph = builder.compile()
response = graph.invoke({"messages": [{"role": "user", "content": "Hi! My name is Alice."}]})
print(f"Bot: {response['messages'][-1].content}\n")
response = graph.invoke({"messages": [{"role": "user", "content": "What is my name?"}]})
print(f"Bot: {response['messages'][-1].content}\n")
You have built a working LangGraph chatbot and proven that it forgets everything between calls. Next up, you will add a checkpointer to give the bot short-term memory within a conversation thread.
Add Short-Term Memory with a Checkpointer
In Step 2, you saw the bot's amnesia in action. It greeted Alice, then immediately forgot her name on the very next message. That gap exists because each graph.invoke() call starts from scratch with no history of what came before.
LangGraph solves this with a checkpointer. A checkpointer automatically saves and restores conversation state between invocations, scoped to a thread_id. In this step, you will add one and discover both its power and its limitation.
In this step, get ready to:
- Import and configure InMemorySaver as a checkpointer for your graph.
- Test within-thread memory by running two messages on the same thread.
- Discover the cross-thread limitation by starting a new thread.
Import InMemorySaver and wire up the checkpointer
The InMemorySaver class stores conversation checkpoints in Python's memory. You need to import it, create an instance, and pass it to the graph's compile() call.
- In chatbot.py, find the import section at the top of the file. Add this new import line below the existing imports:
from langgraph.checkpoint.memory import InMemorySaver
- Find this line below your imports:
model = ChatOpenAI(model="gpt-4o-mini")
- Add the checkpointer instance directly below it, so the two lines look like this:
model = ChatOpenAI(model="gpt-4o-mini")
checkpointer = InMemorySaver()
- Now find this line near the bottom of your graph setup:
graph = builder.compile()
- Replace it with this line to pass the checkpointer into the compiled graph:
graph = builder.compile(checkpointer=checkpointer)
What does this do?
InMemorySaver stores a snapshot of the conversation after every invocation. When you call graph.invoke() again with the same thread_id, the checkpointer restores the previous messages so the bot has full context.
Without a thread_id, the checkpointer does not know which conversation to load. You will add that in the next substep.
Update invocations to use thread configuration
Now that the graph has a checkpointer, every invocation must include a thread_id in its config. This tells the checkpointer which conversation thread to save and restore.
- In chatbot.py, find the existing invocation section at the bottom of the file:
response = graph.invoke({"messages": [{"role": "user", "content": "Hi! My name is Alice."}]})
print(f"Bot: {response['messages'][-1].content}\n")
response = graph.invoke({"messages": [{"role": "user", "content": "What is my name?"}]})
print(f"Bot: {response['messages'][-1].content}\n")
- Replace that entire section with the following code that passes a thread config to each invocation:
print("=== Thread 1: Short-term memory ===")
config = {"configurable": {"thread_id": "1"}}
response = graph.invoke({"messages": [{"role": "user", "content": "Hi! My name is Alice."}]}, config)
print(f"Bot: {response['messages'][-1].content}\n")
response = graph.invoke({"messages": [{"role": "user", "content": "What is my name?"}]}, config)
print(f"Bot: {response['messages'][-1].content}\n")
What does this code do?
- config = {"configurable": {"thread_id": "1"}} creates a configuration dict that labels this conversation as thread "1".
- Passing config as the second argument to graph.invoke() tells the checkpointer to save messages under that thread ID.
- The second invocation reuses the same config, so the checkpointer restores the first message before processing the second.
- Save chatbot.py.
- Run the script by running:
python chatbot.py
The bot should now correctly answer "What is my name?" with "Alice" because the checkpointer preserved the conversation history within thread 1.
Bot still doesn't remember your name?
- Make sure both invocations pass the same config variable. If you accidentally omit it from one call, that call has no thread context.
- Check that checkpointer=checkpointer appears inside builder.compile(). Without it, the graph ignores thread IDs.
- Confirm the import line reads from langgraph.checkpoint.memory import InMemorySaver exactly.
Why isn't my chatbot remembering my name within the same thread?
Discover the cross-thread limitation
The checkpointer solves amnesia within a single thread. But what happens when a user starts a brand new conversation? Let's find out.
- In chatbot.py, add the following code at the very bottom of the file, after the existing thread 1 invocations:
print("=== Thread 2: New thread ===")
config2 = {"configurable": {"thread_id": "2"}}
response = graph.invoke({"messages": [{"role": "user", "content": "What is my name?"}]}, config2)
print(f"Bot: {response['messages'][-1].content}\n")
What does this code do?
This creates a second thread with thread_id set to "2". The checkpointer treats this as an entirely separate conversation with no shared history from thread 1.
- Save chatbot.py.
Before you run this, what do you think the bot will answer when asked "What is my name?" on a brand new thread?
- Run the script again:
python chatbot.py
Thread 1 still works perfectly. But on thread 2, the bot says it does not know your name. That is the expected result.
Why can't the bot remember across threads?
Checkpointers are scoped to a single thread_id. Each thread is an isolated conversation with its own history. When a user returns in a new thread, the checkpointer has nothing to restore.
This is by design for conversation isolation. But it means preferences, names, and facts the user shared earlier are lost the moment they start fresh. That cross-thread gap is exactly what the Store solves in the next step.
Seeing an error instead of a response?
- If you see ValueError about a missing thread_id, make sure every graph.invoke() call passes a config dict as its second argument.
- Check that config2 uses a different thread_id value from config. If both use "1", the bot WILL remember (which defeats the test).
My chatbot is throwing an error about thread_id when I run it.
✔️ Awesome, I've got everything!
Great. Double check you have saved chatbot.py and that your terminal output shows the bot remembering on thread 1 and forgetting on thread 2.
ⓧ I'd like to double check the full code
Scroll through your chatbot.py and confirm these sections appear in order:
- Imports: os, ChatOpenAI, StateGraph, MessagesState, START, END, and InMemorySaver.
- Environment variable set with os.environ.setdefault.
- Model creation (ChatOpenAI) followed by checkpointer = InMemorySaver().
- The call_model function (unchanged from Step 2).
- Graph builder with builder.compile(checkpointer=checkpointer).
- Thread 1 section with config = {"configurable": {"thread_id": "1"}} and two invocations.
- Thread 2 section with config2 = {"configurable": {"thread_id": "2"}} and one invocation.
You now have a chatbot with short-term memory that works perfectly within a single conversation. Next up, you will add a Store that lets the bot remember facts across threads, so returning users never have to re-introduce themselves.
Add Cross-Thread Memory with a Store
In the last step, you added a checkpointer so the bot remembers within a single conversation thread. But you also saw its limitation: start a new thread and the bot forgets everything.
Thread-scoped memory is not enough when a user returns in a new conversation and expects the bot to remember their preferences. In this step, you will add an InMemoryStore that provides cross-thread persistence via namespaced key-value storage, completing your bot's memory model.
In this step, get ready to:
- Import and configure a cross-thread memory store with a context schema.
- Rewrite your chatbot's node function to read and write memories from the store.
- Prove that the bot remembers your name across completely separate conversation threads.
Import the store and define a context schema
The InMemoryStore is a key-value store that lives outside any single thread. To know which user's memories to fetch, the graph needs a context schema that carries the user's identity into every node.
- In chatbot.py, replace everything from the first import line down to (and including) the checkpointer = InMemorySaver() line with the following code:
import os
import uuid
from dataclasses import dataclass
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from langgraph.runtime import Runtime
os.environ.setdefault("OPENAI_API_KEY", "your-api-key-here")
model = ChatOpenAI(model="gpt-4o-mini")
# Cross-thread memory store
store = InMemoryStore()
checkpointer = InMemorySaver()
@dataclass
class Context:
user_id: str
What does this code do?
- InMemoryStore creates a key-value store that persists across all threads. Any node can read or write to it regardless of which thread the conversation is on.
- Runtime is the bridge that gives node functions access to the store and context at runtime.
- The Context dataclass tells LangGraph what metadata (in this case, a user_id) accompanies every invocation.
- Confirm the new imports resolve correctly by running this command:
python -c "from langgraph.store.memory import InMemoryStore; from langgraph.runtime import Runtime; print('Store imports ready')"
You should see Store imports ready printed with no errors.
Seeing a ModuleNotFoundError?
Make sure you installed LangGraph with pip install -U langgraph in the same Python environment you are using to run the script.
Help me fix this import error.
Now you need to tell the graph about the store and context schema. Find the builder and graph lines further down in the file.
- Find this line:
builder = StateGraph(MessagesState)
- Replace it with this, which registers your Context class as the schema:
builder = StateGraph(MessagesState, context_schema=Context)
- Next, find this line:
graph = builder.compile(checkpointer=checkpointer)
- Replace it with this, which hands the store to the compiled graph:
graph = builder.compile(checkpointer=checkpointer, store=store)
Why pass both checkpointer and store?
The checkpointer still handles within-thread conversation history. The store adds a second layer of persistence that lives outside any thread. Together they give your bot both short-term and long-term memory.
Rewrite call_model to read and write memories
Right now your call_model function only receives the message state. You need to give it access to the runtime so it can look up and save memories in the store.
- In chatbot.py, replace the entire call_model function with the following:
def call_model(state: MessagesState, runtime: Runtime[Context]):
user_id = runtime.context.user_id
namespace = (user_id, "memories")
# Retrieve stored memories for this user
memories = runtime.store.search(namespace)
info = "\n".join([d.value["data"] for d in memories])
system_msg = (
f"You are a helpful assistant. User info:\n{info}"
if info
else "You are a helpful assistant."
)
# Store new memories if the user asks to remember something
last_message = state["messages"][-1]
if "remember" in last_message.content.lower():
memory_id = str(uuid.uuid4())
runtime.store.put(namespace, memory_id, {"data": last_message.content})
response = model.invoke(
[{"role": "system", "content": system_msg}] + state["messages"]
)
return {"messages": [response]}
What does this code do?
- runtime: Runtime[Context] gives the function access to both the store and the user context you defined.
- runtime.store.search(namespace) fetches all memories stored under this user's namespace. The namespace (user_id, "memories") acts like a folder path, keeping each user's data separate.
- When the user's message contains the word "remember", the function stores that message in the shared memory with a unique ID.
- The retrieved memories are injected into a system message so the LLM has context about the user before responding.
- Save chatbot.py.
Test cross-thread memory
Time to prove the store works. You will talk to the bot on thread 1, then switch to a brand new thread 2 and see if it still knows your name.
- In chatbot.py, delete everything below the graph = builder.compile(...) line and replace it with this thread 1 test:
# --- Test: Thread-scoped memory (checkpointer) ---
print("=== Thread 1: Short-term memory ===")
config = {"configurable": {"thread_id": "1"}}
response = graph.invoke(
{"messages": [{"role": "user", "content": "Hi! Remember my name is Alice."}]},
config,
context=Context(user_id="user-1"),
)
print(f"Bot: {response['messages'][-1].content}\n")
response = graph.invoke(
{"messages": [{"role": "user", "content": "What is my name?"}]},
config,
context=Context(user_id="user-1"),
)
print(f"Bot: {response['messages'][-1].content}\n")
What changed from last step?
- The first message now says "Remember my name is Alice" (with the keyword "remember") so the store logic triggers and saves it.
- Each invocation passes context=Context(user_id="user-1") so the graph knows which user's memories to load.
- Now add the thread 2 test and a store inspection below the code you just pasted:
# --- Test: Cross-thread memory (store) ---
print("=== Thread 2: Cross-thread memory ===")
config = {"configurable": {"thread_id": "2"}}
response = graph.invoke(
{"messages": [{"role": "user", "content": "Do you know my name?"}]},
config,
context=Context(user_id="user-1"),
)
print(f"Bot: {response['messages'][-1].content}\n")
# --- Inspect the store ---
print("=== Stored Memories ===")
memories = store.search(("user-1", "memories"))
for mem in memories:
print(f" [{mem.key}] {mem.value['data']}")
What does this test prove?
Thread 2 is a completely fresh conversation. The checkpointer has no history for it. If the bot knows your name here, the only explanation is that it read it from the cross-thread store.
The final store.search() call lets you peek inside the store directly and see exactly what was saved.
- Save chatbot.py.
Before you run this, think about what you expect. On thread 2 (a brand new conversation), will the bot know your name or not?
- Run the updated script:
python chatbot.py
On thread 1, the bot greets you and confirms it will remember your name. When you ask "What is my name?" it answers "Alice" (the checkpointer helps here too). On thread 2, the bot correctly recalls your name even though it is a fresh thread. The store made the difference.
You should also see a "Stored Memories" section showing the exact memory entry with its UUID key and the content that was saved.
Bot says it does not know your name on thread 2?
- Make sure your first message includes the word "remember" (e.g. "Remember my name is Alice"). Without it, the store logic does not trigger.
- Confirm that both invocations pass the same context=Context(user_id="user-1"). Different user IDs mean different namespaces.
- Check that you passed store=store to builder.compile(). Without it, the runtime has no store to search.
Help me debug why my store is not persisting memories across threads.
✔️ Awesome, I've got everything!
Great. Double check you have saved chatbot.py and your bot recalls your name on thread 2.
ⓧ I'd like to double check the full code
Here is the complete chatbot.py at this point. Compare your file against it section by section:
- Imports section (8 import lines).
- Model, store, checkpointer setup.
- Context dataclass.
- call_model function with runtime, search, and put logic.
- Graph builder with context_schema and store.
- Thread 1 invocations with context.
- Thread 2 invocation and store inspection.
Scroll up through the chunks above and confirm each one is present in your file, in order.
Your chatbot now has the complete memory model: state for the current invocation, a checkpointer for within-thread history, and a store for cross-thread long-term memory. Next up, you will use this same store pattern to build a multi-agent research team where two specialized agents collaborate through shared memory.
Build the Multi-Agent Research Team
Your chatbot now has all three memory layers working together. You have seen how state handles a single invocation, checkpointers handle a full conversation thread, and the store handles cross-thread persistence.
Now you will put that knowledge to work on the real project: a multi-agent research team where two specialized agents collaborate through shared memory. A researcher agent extracts key facts and stores them, then a writer agent reads those facts and produces a polished summary.
In this step, get ready to:
- Create a new file with two specialized agent node functions (researcher and writer).
- Wire the agents into a sequential StateGraph that passes control from researcher to writer.
- Run multiple research sessions and see the writer's output get richer as shared memory accumulates.
Create the file and define the researcher agent
The researcher agent receives a question, asks the LLM for key facts, and stores each one individually in shared memory. This is the agent that populates the knowledge base.
- Create a new file called research_team.py in the same project directory as chatbot.py.
- Add the imports, model setup, and Context dataclass by pasting this code:
import os
import uuid
from dataclasses import dataclass
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from langgraph.runtime import Runtime
os.environ.setdefault("OPENAI_API_KEY", "your-api-key-here")
model = ChatOpenAI(model="gpt-4o-mini")
store = InMemoryStore()
checkpointer = InMemorySaver()
@dataclass
class Context:
user_id: str
What does this code do?
This setup block is nearly identical to chatbot.py. You get the same imports, the same store and checkpointer, and the same Context dataclass. The difference is what you build on top of it.
- Below the Context class, add the researcher agent function:
def researcher(state: MessagesState, runtime: Runtime[Context]):
"""Research agent: extracts key facts and stores them in shared memory."""
user_id = runtime.context.user_id
namespace = (user_id, "research_findings")
system_msg = (
"You are a research assistant. Analyze the user's question and provide "
"key facts and findings. Format each fact on a new line starting with '- '. "
"Provide 3-5 concise facts."
)
response = model.invoke(
[{"role": "system", "content": system_msg}] + state["messages"]
)
# Store each finding in shared memory
for line in response.content.split("\n"):
line = line.strip()
if line.startswith("- "):
memory_id = str(uuid.uuid4())
runtime.store.put(
namespace, memory_id, {"data": line[2:]}
)
return {"messages": [response]}
What does this code do?
The researcher asks the LLM to format its response as bullet points. Then it parses the response line by line. Every line starting with - gets stored as a separate memory item in the (user_id, "research_findings") namespace.
Each fact is stored individually so the writer agent (and future agents) can access specific findings rather than one big blob of text.
- Save research_team.py.
- Confirm there are no syntax errors by running this in your terminal:
python -c "from research_team import researcher; print('Researcher ready!')"
You should see Researcher ready! printed with no errors.
Seeing an ImportError or SyntaxError?
- Check that research_team.py is saved in the same directory where you are running the command.
- Verify parentheses and quotation marks are balanced in the system_msg string. Multi-line string concatenation requires each line wrapped in parentheses.
Help me debug my research_team.py import error.
Define the writer agent and build the graph
The writer agent is the consumer of shared memory. It reads all stored findings and synthesizes them into a polished summary. Because it pulls from the store (not from state), it automatically picks up findings from all previous research sessions.
- In research_team.py, add the writer function below the researcher function:
def writer(state: MessagesState, runtime: Runtime[Context]):
"""Writer agent: reads from shared memory to produce a polished summary."""
user_id = runtime.context.user_id
namespace = (user_id, "research_findings")
# Read all findings from shared memory
memories = runtime.store.search(namespace, limit=50)
findings = "\n".join([f"- {d.value['data']}" for d in memories])
system_msg = (
"You are a professional writer. Based on ALL the research findings below, "
"write a concise, well-structured summary for the user. Incorporate "
"findings from all previous research sessions if available.\n\n"
f"Research findings:\n{findings}"
)
response = model.invoke(
[{"role": "system", "content": system_msg}] + state["messages"]
)
return {"messages": [response]}
What does this code do?
The writer calls runtime.store.search(namespace, limit=50) to retrieve up to 50 stored findings. It formats them into a bullet list and injects them into the system prompt so the LLM can write a comprehensive summary.
The key insight: this agent reads from the same namespace the researcher writes to. The store is the bridge between them.
- Below the writer function, build and compile the multi-agent graph:
# Build the multi-agent graph
builder = StateGraph(MessagesState, context_schema=Context)
builder.add_node("researcher", researcher)
builder.add_node("writer", writer)
builder.add_edge(START, "researcher")
builder.add_edge("researcher", "writer")
builder.add_edge("writer", END)
graph = builder.compile(checkpointer=checkpointer, store=store)
What does this code do?
This wires the two agents into a sequential pipeline: START -> researcher -> writer -> END. When you invoke the graph, the researcher runs first, stores its findings, and then control passes to the writer which reads those findings and produces the final output.
- Save research_team.py.
- Confirm the graph compiles correctly by running:
python -c "from research_team import graph; print('Graph compiled with nodes:', list(graph.nodes))"
You should see the node names researcher and writer printed in the list.
Seeing a NameError for researcher or writer?
- Make sure the builder.add_node() calls come after both function definitions in the file.
- Check that your function names match exactly: researcher and writer (all lowercase).
Help me fix my multi-agent graph compilation.
Run research sessions and inspect shared memory
The graph is ready. Now you will run two research sessions on different topics using different thread IDs but the same user. The writer's second summary should incorporate findings from both sessions, proving that shared memory accumulates across threads.
- At the bottom of research_team.py, add the code that runs both research sessions and inspects the store:
# --- First research session ---
print("=== Research Session 1: Solar Energy ===")
config = {"configurable": {"thread_id": "1"}}
response = graph.invoke(
{"messages": [{"role": "user", "content": "What are the main benefits of solar energy?"}]},
config,
context=Context(user_id="user-1"),
)
print("Writer's Summary:")
print(response["messages"][-1].content)
# --- Second research session (new thread, same user) ---
print("\n=== Research Session 2: Wind Energy ===")
config = {"configurable": {"thread_id": "2"}}
response = graph.invoke(
{"messages": [{"role": "user", "content": "What are the main benefits of wind energy?"}]},
config,
context=Context(user_id="user-1"),
)
print("Writer's Summary (includes all accumulated findings):")
print(response["messages"][-1].content)
What does this code do?
Session 1 asks about solar energy on thread_id "1". The researcher stores solar facts. The writer summarizes them.
Session 2 asks about wind energy on thread_id "2". The researcher stores wind facts. The writer now reads ALL findings (solar and wind) from memory. Its second summary should reference both topics.
- Below the research sessions, add the memory inspection code:
# --- Inspect shared memory ---
print("\n=== All Stored Research Findings ===")
memories = store.search(("user-1", "research_findings"), limit=50)
for mem in memories:
print(f" - {mem.value['data']}")
What does this code do?
This directly queries the store outside of any agent. It shows you every fact stored by the researcher across both sessions. This is the raw shared memory both agents have access to.
- Save research_team.py.
Before you run this, think about what the writer's second summary should look like. Will it only mention wind energy, or will it also reference the solar facts from session 1?
- Run the research team by executing:
python research_team.py
You should see three sections of output. The first writer summary covers solar energy only. The second writer summary references findings from both solar and wind energy. The final section lists every individual fact stored by the researcher across both sessions.
Why does the second summary include solar facts?
The writer reads from the InMemoryStore, not from the thread's state. By the time session 2 runs, the store already contains session 1's findings. The store is the shared knowledge base that persists independently of any conversation thread.
Writer summary only mentions one topic?
- Check that both invocations use the same user_id ("user-1"). Different user IDs mean different namespaces, so findings stay isolated.
- Verify that the researcher's runtime.store.put() call uses the namespace (user_id, "research_findings") and the writer's runtime.store.search() searches the same namespace.
- The LLM may sometimes focus on the current question. Run it again. If the stored findings list at the bottom shows facts from both topics, shared memory is working correctly.
Why is my writer agent only summarizing one research session?
✔️ Awesome, I've got everything!
Great. Double check you have saved research_team.py and that your output shows accumulated findings from both sessions.
ⓧ I'd like to double check the full code
Here is the complete research_team.py file. Scroll through and confirm each section is present in order: imports, setup, Context, researcher, writer, graph build, invocations, and memory inspection.
The file has five sections you built above:
- Imports + model + store + checkpointer + Context dataclass (top of file).
- The researcher function that extracts facts and stores them.
- The writer function that reads from shared memory and summarizes.
- Graph build: StateGraph with both nodes wired START -> researcher -> writer -> END, compiled with checkpointer and store.
- Two research invocations (solar on thread 1, wind on thread 2) plus the memory inspection loop.
Scroll up and confirm each chunk is present in your file, in this order.
You now have a working multi-agent research team. Two agents share a knowledge base through the InMemoryStore. The more research sessions you run, the richer the writer's output becomes. This is the pattern that makes shared memory so powerful for multi-agent systems.
Secret mission
Add Semantic Search to the Memory Store
Your research team currently dumps every stored memory into the writer's context. That works fine with a handful of findings, but imagine hundreds of stored memories. Can you add semantic search so the writer only retrieves findings that are actually relevant to the current question?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. All data stored in InMemorySaver and InMemoryStore lives in Python process memory and is lost when the script exits.
Decide whether to keep your project files for future experimentation, or delete them entirely.
Resources you used:
- Project directory containing chatbot.py and research_team.py.
- Installed Python packages: langgraph and langchain-openai.
- OpenAI API usage (already consumed, no ongoing charges).
Keep everything running
No action needed. Choose this if you want to keep experimenting with LangGraph's memory layers or extend the project further.
- Your project files stay on your Desktop ready to run again.
- Nothing persists between script runs, so there is nothing to shut down.
- To take the project further, try swapping InMemoryStore for a PostgresStore to make memories survive restarts.
Pause - I'll come back to this later
Since all memory lives in the Python process, there is nothing to pause or shut down. Your files remain on disk until you delete them.
- Your project directory stays on your Desktop. You can re-run the scripts any time.
- If you exported your OPENAI_API_KEY in a terminal session, it disappears when you close that terminal.
Delete - I don't want to use this again
Remove all project files and uninstall the packages you installed.
- Delete your project directory by running the following command (macOS/Linux):
rm -rf ~/Desktop/langgraph-memory
On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\langgraph-memory
- Uninstall the Python packages by running:
pip uninstall langgraph langchain-openai -y
- Your OpenAI API key remains in your OpenAI account. If you want to revoke it, visit the API keys page in your OpenAI dashboard and delete the key you created for this project.
Nice Work!
Nice Work!
Nice work! You just built a multi-agent research team powered by LangGraph where specialized agents collaborate through shared memory to produce richer results over time.
You've learned how to:
- Build a LangGraph chatbot from scratch, experience the amnesia problem firsthand, and solve it with InMemorySaver (thread-scoped checkpointer) and InMemoryStore (cross-thread store) memory layers.
- Design namespace-based memory organization so agents store and retrieve knowledge by user and topic, persisting across separate conversation threads.
- Build a multi-agent research team where a researcher agent stores findings into shared memory and a writer agent reads from it to produce summaries that get richer as memory accumulates across sessions.
- Secret Mission: Added semantic search with OpenAI embeddings so agents find relevant memories by meaning instead of listing everything.
Ready to quiz yourself?