How to Build a Self-Correcting AI Agent With LangGraph and Ollama
A step-by-step tutorial that builds a LangGraph agent in Python which drafts an answer, critiques its own work, and retries automatically using a conditional edge, then adds cross-call memory with a...
Most introductions to LangGraph jump straight into a wall of code. Before writing any, it helps to understand the one idea that makes LangGraph different from a plain LangChain chain: a chain runs in a straight line from start to finish, but a LangGraph agent is built as a graph that can loop back on itself. That loop is what lets an agent check its own work and try again, something a straight-line chain cannot do without extra scaffolding bolted on top.
Table Of Content
- What you need before you start
- Step 1: Install Ollama and pull a local model
- Step 2: Set up your Python project
- Step 3: Install LangGraph and its dependencies
- Step 4: See the shape of the agent before you build it
- Step 5: Define the agent’s state
- Step 6: Write the node that drafts an answer
- Step 7: Write the node that grades the answer
- Step 8: Write the node that records the result
- Step 9: Wire the nodes into a graph, including the conditional edge
- Step 10: Compile the graph and run it
- Step 11: Add persistent memory with a checkpointer
- Common mistakes and how to catch them
- Verifying everything works end to end
- Next steps
In this tutorial you will build a small, complete LangGraph agent that answers a question, grades its own draft answer against a rule, and automatically retries if the draft fails that rule, up to a limit you control. Along the way you will learn the building blocks every LangGraph application is made of: state, nodes, edges, conditional edges (the mechanism behind the loop), and the checkpointer that gives an agent memory across separate runs. Everything here runs entirely on your own machine through Ollama, a free tool for running open-weight language models locally, so you do not need an API key, a credit card, or a cloud account to follow along. Every command, package version, and API call in this tutorial was actually run on a fresh Ubuntu 26.04 LTS host while writing it, including the full working example near the end.
Before the first command, here is what each term means in plain language, since LangGraph’s own documentation assumes you already know them:
- State is a shared, dictionary-like object that flows through the graph, carrying whatever data your agent needs to remember from one step to the next.
- A node is a plain Python function that reads the current state and returns updates to it. Nodes are where the actual work happens, including any call out to a language model.
- An edge tells LangGraph which node to run next. A normal edge always leads to the same next node. A conditional edge picks the next node at runtime by running a small routing function against the current state, and it is what makes loops possible: the routing function can send execution back to a node that already ran.
- A checkpointer saves the state after every step so a run can be resumed, and so two separate calls to the graph can share memory of an ongoing conversation.
What you need before you start
- A Linux machine. This tutorial was written and tested on Ubuntu 26.04 LTS; other recent Ubuntu or Debian releases work the same way, and macOS works too if you swap in the macOS Ollama installer.
- About 4 GB of free disk space and 8 GB of RAM. The model used here downloads roughly 3.4 GB, and runs fine on CPU alone, just slower per answer than it would on a GPU.
- Python 3.10 or newer. LangGraph 1.2 requires at least Python 3.10.
- Comfort with basic Python: functions, dictionaries, and type hints. No prior LangChain or LangGraph experience is assumed.
- A regular terminal. Nothing in this tutorial needs root or sudo access.
Step 1: Install Ollama and pull a local model
Ollama is a small background service that downloads open-weight language models and runs them on your own hardware, exposing them through a local API on port 11434. Using it here means the tutorial costs nothing to run and produces the same kind of answers for everyone following along, instead of depending on a paid API key.
# Context: any Linux machine (Ubuntu 26.04 LTS used in this tutorial), regular user account.
# Purpose: install the Ollama runtime and register it as a background service.
curl -fsSL https://ollama.com/install.sh | sh
Expected output: a progress bar while the installer downloads, followed by lines ending in The Ollama API is now available at 127.0.0.1:11434. and Install complete. Run "ollama" from the command line. If your machine has no NVIDIA or AMD GPU, you will also see a warning that Ollama will run in CPU-only mode. That is expected on most laptops and virtual machines, and this tutorial’s model is small enough to work fine that way.
Next, download the model this tutorial uses:
# Context: Ollama installed and running from the previous step.
# Purpose: download the 4-billion-parameter tag of Qwen 3.5 for local inference.
ollama pull qwen3.5:4b
Expected output: a series of progress bars, one per model layer, ending in verifying sha256 digest, writing manifest, and finally success. Qwen 3.5 ships in tags from 0.8B up to 122B parameters; the 4B tag used here is about 3.4 GB and is a reasonable balance of quality and speed for a CPU-only machine. Confirm the download landed correctly with ollama list, which should print qwen3.5:4b alongside its size and a “modified” timestamp.
Step 2: Set up your Python project
Keep this tutorial’s dependencies isolated from anything else on your system using a virtual environment:
# Context: a terminal, in whatever folder you keep coding projects.
# Purpose: create a project folder and an isolated Python virtual environment inside it.
mkdir langgraph-self-correcting-agent
cd langgraph-self-correcting-agent
python3 -m venv venv
source venv/bin/activate
Expected output: your shell prompt gains a (venv) prefix, which confirms the virtual environment is active. Every pip install and every python command for the rest of this tutorial assumes that prefix is showing.
Step 3: Install LangGraph and its dependencies
# Context: venv activated, inside the langgraph-self-correcting-agent directory.
# Purpose: install LangGraph, LangChain's core package, and the Ollama chat integration.
pip install langgraph langchain-core langchain-ollama
Expected output: pip resolves and installs each package, ending with a line starting Successfully installed that lists langgraph, langchain-core, langchain-ollama, and their dependencies, including langgraph-checkpoint, langgraph-prebuilt, langgraph-sdk, ollama, and pydantic. At the time of writing, this installs LangGraph 1.2.9, langchain-core 1.4.9, and langchain-ollama 1.1.0, and it requires Python 3.10 or newer, matching the prerequisite above.
Step 4: See the shape of the agent before you build it
It is much easier to write graph code once you can picture the graph. This tutorial builds exactly this shape:
STARTleads to a generate node, which drafts an answer to the question.- generate always leads to a critique node, which checks the draft against a rule.
- critique leads to a conditional edge: if the draft passed, or the agent has already tried three times, it moves on to finalize; otherwise it loops back to generate, this time with feedback about what to fix.
- finalize records the result and leads to
END.
The loop between critique and generate is the cycle a plain LangChain chain cannot express on its own, since a chain’s steps run once each, in a fixed order. LangGraph’s own documentation describes nodes and edges as nothing more than functions: nodes do the work, and edges decide what runs next. Internally, LangGraph passes messages between nodes in discrete rounds it calls super-steps, an approach borrowed from Google’s Pregel graph-processing system, and it keeps running until every node has nothing left to do.
Step 5: Define the agent’s state
Create a file named agent.py in your project folder and start with the state schema, written as a TypedDict, which is the schema style LangGraph’s own documentation recommends by default:
from typing import TypedDict, Annotated
import operator
class AgentState(TypedDict):
question: str
draft_answer: str
feedback: str
attempts: int
is_valid: bool
history: Annotated[list, operator.add]
Five of these fields (question, draft_answer, feedback, attempts, is_valid) behave the way you would expect: whenever a node returns a new value for one of them, LangGraph simply overwrites the old value. That default behavior is called a reducer, and plain overwrite is the default reducer. The sixth field, history, is different on purpose. It is annotated with operator.add, which tells LangGraph to use Python’s list concatenation as its reducer instead, so every value a node returns for history gets appended to the existing list rather than replacing it. That one line is what lets a running transcript survive across separate questions later in this tutorial.
Step 6: Write the node that drafts an answer
Add the following below the state definition:
from langchain_ollama import ChatOllama
MODEL_NAME = "qwen3.5:4b"
MAX_ATTEMPTS = 3
llm = ChatOllama(model=MODEL_NAME, temperature=0)
def generate(state: AgentState) -> dict:
feedback = state.get("feedback", "")
prompt = (
"Answer the question in exactly one sentence, under 20 words. "
f"QUESTION: {state['question']}"
)
if feedback:
prompt += f"\nYour previous attempt was rejected for this reason: {feedback}. Fix it."
response = llm.invoke(prompt)
return {
"draft_answer": response.content.strip(),
"attempts": state.get("attempts", 0) + 1,
}
A node is only ever a function that takes the state and returns a dictionary of the fields it wants to change, which is why generate returns just draft_answer and attempts instead of the entire state. temperature=0 makes the model’s output as deterministic as a language model can be, which is useful while you are testing a new agent. The if feedback branch is what actually closes the loop: the very first time this node runs there is no feedback yet, but on a retry it folds the critique’s complaint straight into the next prompt, so the model gets a concrete reason its last attempt failed instead of just being asked to try again blindly.
Step 7: Write the node that grades the answer
def critique(state: AgentState) -> dict:
prompt = (
"You are a strict editor checking one rule: the ANSWER must be a single "
"sentence, under 20 words, and must directly address the QUESTION.\n"
f"QUESTION: {state['question']}\nANSWER: {state['draft_answer']}\n"
"Reply with exactly the word PASS if the rule is met. Otherwise reply "
"with FAIL: followed by a short reason."
)
verdict = llm.invoke(prompt).content.strip()
if verdict.upper().startswith("PASS"):
return {"is_valid": True, "feedback": ""}
return {"is_valid": False, "feedback": verdict}
This is a small version of a pattern usually called LLM-as-judge, or self-reflection: instead of trusting the first draft, a second, separately-prompted call checks it against a specific, narrow rule. Asking the same call to both answer the question and grade its own answer tends to be far less reliable, since the model has every incentive to agree with whatever it just wrote. A fresh prompt whose only job is grading tends to catch problems the original generation missed.
Step 8: Write the node that records the result
def finalize(state: AgentState) -> dict:
entry = (
f"Q: {state['question']} -> A: {state['draft_answer']} "
f"(attempts: {state['attempts']}, passed_review: {state['is_valid']})"
)
return {"history": [entry]}
Because history uses the operator.add reducer from Step 5, returning {"history": [entry]} here appends a single-item list to whatever history already existed, rather than replacing it. This node is also the natural place to add anything else you would want to do once an answer is final, like writing it to a database.
Step 9: Wire the nodes into a graph, including the conditional edge
from langgraph.graph import StateGraph, START, END
def route_after_critique(state: AgentState) -> str:
if state["is_valid"] or state["attempts"] >= MAX_ATTEMPTS:
return "finalize"
return "retry"
builder = StateGraph(AgentState)
builder.add_node("generate", generate)
builder.add_node("critique", critique)
builder.add_node("finalize", finalize)
builder.add_edge(START, "generate")
builder.add_edge("generate", "critique")
builder.add_conditional_edges(
"critique",
route_after_critique,
{"retry": "generate", "finalize": "finalize"},
)
builder.add_edge("finalize", END)
add_edge creates the two fixed, normal edges in this graph: START always goes to generate, and generate always goes to critique. add_conditional_edges is where the cycle lives. Its three arguments are the node the condition runs after (critique), a plain Python function that inspects the state and returns a short label, and a dictionary that maps each possible label to the real node it should lead to. Here, route_after_critique returns "finalize" once the answer passes or three attempts have been used, and "retry" otherwise, and the mapping sends "retry" straight back to the generate node you already wrote. That is the entire loop: nothing about generate or critique needed to change to make retries possible, because the looping logic lives entirely in the edge.
The attempts >= MAX_ATTEMPTS check is doing real work here, not just decoration. Without some way out of the loop, a rule the model can never satisfy would send the graph back to generate forever. LangGraph also enforces its own recursion limit as a hard backstop, configurable through a recursion_limit key in a run’s config, so a bug in your own routing logic fails loudly with a clear recursion error instead of hanging. Writing your own exit condition, as route_after_critique does, is still the right first line of defense, since it lets your agent give up gracefully with a real answer instead of erroring out.
Step 10: Compile the graph and run it
A graph builder cannot run until you compile it. Add this and run the file:
graph = builder.compile()
if __name__ == "__main__":
result = graph.invoke(
{
"question": "What is the capital of France?",
"attempts": 0,
"is_valid": False,
"feedback": "",
}
)
print("Attempts:", result["attempts"])
print("Answer:", result["draft_answer"])
# Context: venv activated, agent.py saved in the current directory, Ollama running in the background.
# Purpose: run the compiled graph end to end for a single question.
python agent.py
Expected output (from an actual run while writing this tutorial):
Attempts: 1
Answer: The capital of France is Paris.
Passing on the very first attempt is the common case for a plain factual question like this one; the loop from Step 9 only actually fires when critique returns is_valid: False. You can watch it fire yourself by tightening the rule, for example changing both prompts in Steps 6 and 7 from “under 20 words” to “under 8 words” and asking a question that genuinely needs more room to answer well, such as “Why do leaves change color in autumn?” With a tighter rule in place, expect critique to reject at least one draft and attempts to land above 1 in the final result, since the state now carries the rejection reason back into the next generate call exactly as described in Step 6.
Step 11: Add persistent memory with a checkpointer
The agent above forgets everything the moment invoke returns. To let it remember previous questions, compile it with a checkpointer instead, and give every call a thread_id so LangGraph knows which conversation’s memory to load:
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
thread_1 = {"configurable": {"thread_id": "demo-thread-1"}}
result1 = graph.invoke(
{"question": "What is the capital of France?", "attempts": 0, "is_valid": False, "feedback": ""},
thread_1,
)
print("Turn 1 history:", result1["history"])
result2 = graph.invoke(
{"question": "What ocean lies between Europe and North America?", "attempts": 0, "is_valid": False, "feedback": ""},
thread_1,
)
print("Turn 2 history:", result2["history"])
InMemorySaver is LangGraph’s simplest checkpointer: it keeps every thread’s state in your Python process’s memory, which is perfect for learning and local development but is explicitly documented as not surviving a process restart. For anything you deploy, LangGraph’s own persistence guidance recommends a PostgresSaver for production, since it writes checkpoints to a real database with async support, or a SqliteSaver for local, file-based persistence during development that is sturdier than plain in-memory storage. Notice that thread_1 is passed as the second argument to invoke, not folded into the input dictionary; that config argument, keyed by thread_id, is how the checkpointer knows which saved state to load before running the graph.
Expected output (captured from an actual run):
Turn 1 history: ['Q: What is the capital of France? -> A: The capital of France is Paris. (attempts: 1, passed_review: True)']
Turn 2 history: ['Q: What is the capital of France? -> A: The capital of France is Paris. (attempts: 1, passed_review: True)', 'Q: What ocean lies between Europe and North America? -> A: The Atlantic Ocean lies between Europe and North America. (attempts: 1, passed_review: True)']
Turn 2’s history contains both questions, proving the checkpointer restored turn 1’s state before turn 2 ran, and that the operator.add reducer from Step 5 appended rather than overwrote. If you invoke the same graph again with a different thread ID, for example {"configurable": {"thread_id": "demo-thread-2"}}, its history starts empty again, since threads are isolated from each other by design. Notice, too, that question, attempts, is_valid, and feedback are still passed fresh on every call. Those fields use the default overwrite reducer from Step 5, so they do not need resetting by hand; only history carries forward, because it is the only field you told LangGraph to accumulate.
Common mistakes and how to catch them
- Calling invoke() before compile(). A
StateGraphbuilder object has noinvokemethod; only the object returned bybuilder.compile()does. If you see anAttributeErrormentioninginvoke, check that you saved compile’s return value and are calling it on that, not onbuilderdirectly. - Compiling with a checkpointer but forgetting the config. Once you pass
checkpointer=...tocompile(), everyinvoke()call needs a config dictionary with athread_id, or LangGraph will raise an error telling you one is required. It is easy to copy Step 10’sgraph.invoke(state_dict)call forward and forget to add the second argument from Step 11. - Returning the whole state from a node instead of just the changed keys. A node should return only the fields it actually updated, as every node in this tutorial does. Returning the full state back verbatim still works most of the time, but it silently reintroduces stale values for fields another branch may have changed in the same step, which gets confusing fast in a graph with more than two or three nodes.
- A conditional edge with no real way out. If
route_after_critiqueonly ever returned"retry", the graph would loop until LangGraph’s own recursion limit stops it with an error. Always give a conditional edge a condition that is guaranteed to become true eventually, the wayattempts >= MAX_ATTEMPTSdoes here. - A typo in the path map passed to add_conditional_edges. The dictionary’s values must exactly match node names you already registered with
add_node. A mismatched string surfaces immediately, when you callcompile(), rather than later at run time, which makes it one of the easier mistakes to catch. - A mistyped model tag. Asking for a model Ollama has not downloaded, for example
qwen3.5without the:4btag if only the tagged version was pulled, fails with a model-not-found error from Ollama. Runollama listany time to confirm the exact tag string you pulled in Step 1.
Verifying everything works end to end
Run python agent.py after finishing Step 11 and confirm all of the following before moving on:
- The script exits with no traceback.
- Turn 1’s printed history has exactly one entry, and it names Paris.
- Turn 2’s printed history has exactly two entries, with turn 1’s entry still present unchanged, confirming the checkpointer actually restored state instead of starting fresh.
- If you add a third call using a brand new
thread_id, its history prints with only one entry, confirming threads do not leak into each other.
If all four hold, you have a working LangGraph agent with a genuine self-correction loop and cross-call memory, built entirely from the state, node, edge, and checkpointer primitives introduced above.
Next steps
From here, a few directions are worth exploring once the basics feel solid. Swap InMemorySaver for a PostgresSaver or SqliteSaver before you rely on this pattern for anything long-running, since, as Step 11 mentioned, in-memory checkpoints vanish on restart. Look at LangChain’s bind_tools to let generate call real tools, like a search function or a calculator, instead of only drafting text, which turns this same retry-loop shape into a genuine tool-using agent. For a graph with many more nodes than this tutorial’s three, look into LangGraph’s subgraphs, which let you package a cluster of nodes as a single reusable unit inside a larger graph. And once you are comfortable with StateGraph, LangGraph’s Functional API offers a more code-first way to express the same durable, resumable execution model for people who would rather write ordinary Python control flow than assemble nodes and edges by hand. Real Python’s LangGraph tutorial walks through a different worked example, an email-processing agent, and is a good next read once this one clicks.








No Comment! Be the first one.