LangGraph from Scratch: Building a Text Quality Checker
I'd been hearing about LangGraph for a while, but every tutorial I found either jumped straight into complex agent architectures or glossed over the fundamentals. So I decided to learn it the way I learn best: by building something small enough to understand completely, but rich enough to touch all the core concepts.
The result: a text quality checker API. You send it a piece of text. It normalises it, counts the words, rates the clarity using an LLM, and if the score is too low, it rewrites it and tries again, up to three times before giving up. Simple on the surface. Surprisingly instructive underneath.
Tech stack: LangGraph, FastAPI, Groq (llama-3.1-8b-instant), Python, uv
The mental model shift
Before I show any code, here's the thing that took me the longest to internalise:
You are not writing sequential code. You are defining a flowchart that executes.
In normal Python, you call functions in order. In LangGraph, you declare nodes and edges, and the framework decides what runs, in what order, based on the graph structure you defined. This is a meaningful shift. Once it clicked, everything else made sense.
LangGraph's core model is:
- State: a TypedDict that flows through the entire graph. Every node reads from it and returns only the fields it changed.
- Nodes: plain Python functions. They receive state, do one thing, return a dict of changes.
- Edges: connections between nodes. Simple edges always go A → B. Conditional edges call a routing function that returns the name of the next node as a string.
- Parallel execution: happens automatically when multiple edges leave the same node. No special syntax needed.
- Loops: just edges that point backwards. The loop limit is enforced in your routing logic, not by LangGraph itself.
The graph I built
Here's the full flow:
START
↓
[normalize] — strips whitespace, lowercases text
↓ (fans out to two parallel branches)
[count_words] ——————————————→ END (informational, runs once)
[rate_clarity] — LLM rates clarity 1.0–10.0
↓ (conditional routing)
├─ clarity ≥ 7 → [finalize] → END (success)
├─ iteration ≥ 3 → [finalize] → END (max_retries_reached)
└─ otherwise → [rewrite] — LLM rewrites the text
↓
loops back to [rate_clarity]
Five nodes. One parallel branch. One conditional branch. One retry loop. That's all of LangGraph's core mechanics in a single project.
The state
Everything flows through a single TypedDict:
# state.py
from typing import TypedDict
class State(TypedDict):
input_text: str
normalized_text: str | None
word_count: int | None
clarity_rating: float | None
iteration: int | None
final_text: str | None
response: str | None
Every node reads from this dict and returns only the fields it changed. LangGraph merges the returned dict back into the state automatically. You never pass the whole state object back out of a node. Just the diff.
The LLM factory
I wanted to keep nodes decoupled from any specific LLM provider, so I wrote a simple factory function:
# llm.py
from langchain_groq import ChatGroq
def get_llm(provider="groq", model="llama-3.1-8b-instant"):
if provider == "groq":
return ChatGroq(
model=model,
temperature=0.3,
max_tokens=512,
)
raise ValueError(f"Provider: {provider} not supported")
Every node calls get_llm(). Swapping to a different provider later means changing one file, not touching any node.
The nodes
Here are all five nodes. Each one is a plain Python function.
# nodes.py
import re
from state import State
from llm import get_llm
def normalize(state: State) -> dict:
text = state.get("input_text", "")
normalized = re.sub(r"\s+", " ", text).strip().lower()
return {"normalized_text": normalized, "iteration": 0}
def count_words(state: State) -> dict:
text = state.get("normalized_text", "")
return {"word_count": len(text.split())}
def rate_clarity(state: State) -> dict:
text = state.get("normalized_text", "")
iteration = state.get("iteration", 0)
llm = get_llm()
prompt = f"""Rate the clarity of this text on a scale from 1.0 to 10.0.
Return only a number and nothing else.
Text: {text}"""
response = llm.invoke(prompt)
rating = float(response.content.strip())
return {"clarity_rating": rating, "iteration": iteration + 1}
def rewrite(state: State) -> dict:
text = state.get("normalized_text", "")
llm = get_llm()
prompt = f"""Rewrite the following text to be clearer and more readable.
Return only the rewritten text and nothing else.
Text: {text}"""
response = llm.invoke(prompt)
return {"normalized_text": response.content.strip()}
def finalize(state: State) -> dict:
clarity = state.get("clarity_rating", 0)
iteration = state.get("iteration", 0)
text = state.get("normalized_text", "")
if clarity >= 7:
response = f"Text is clear (score: {clarity}). Final text: {text}"
else:
response = f"Max retries reached after {iteration} attempts. Best score: {clarity}. Final text: {text}"
return {"final_text": text, "response": response}
The graph
The wiring all happens in one place:
# graph.py
from langgraph.graph import StateGraph, START, END
from state import State
from nodes import normalize, count_words, rate_clarity, rewrite, finalize
def route_after_rating(state: State) -> str:
if state.get("clarity_rating", 0) >= 7:
return "finalize"
if state.get("iteration", 0) >= 3:
return "finalize"
return "rewrite"
graph = StateGraph(State)
graph.add_node("normalize", normalize)
graph.add_node("count_words", count_words)
graph.add_node("rate_clarity", rate_clarity)
graph.add_node("rewrite", rewrite)
graph.add_node("finalize", finalize)
graph.add_edge(START, "normalize")
# fan-out from normalize to two parallel branches
graph.add_edge("normalize", "count_words")
graph.add_edge("normalize", "rate_clarity")
graph.add_edge("count_words", END)
graph.add_conditional_edges("rate_clarity", route_after_rating)
graph.add_edge("rewrite", "rate_clarity") # the retry loop
graph.add_edge("finalize", END)
agent = graph.compile()
That's the whole graph. The retry loop is just rewrite → rate_clarity. The limit is enforced in route_after_rating by checking iteration in state, not by any LangGraph-specific mechanism.
The API layer
FastAPI sits completely on top of the graph. The graph has no idea it's being called from an API.
# main.py
from fastapi import FastAPI
from state import TextRequest, TextResponse
from graph import agent
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/execute-agent", response_model=TextResponse)
def execute_agent(request: TextRequest) -> TextResponse:
output = agent.invoke({"input_text": request.input_text})
return TextResponse(final_text=output["final_text"],
response=output["response"])
agent.invoke() returns the final state dict. The Pydantic schemas TextRequest and TextResponse live in state.py alongside the graph's State TypedDict, which keeps all the data shapes in one place.
Mistakes I made along the way
Trying to call nodes from other nodes. My first instinct was to call finalize() from inside rate_clarity() when the score was high enough. That's not how LangGraph works. Nodes only transform state. Routing decisions belong in the conditional edge function, specifically route_after_rating. Once I moved the branching logic there, everything became clean.
Returning the whole state object. I was initially returning {**state, "clarity_rating": rating} from nodes. LangGraph doesn't want the full state back. It wants only the fields that changed. It merges them automatically. Returning the whole dict isn't technically wrong, but it's noisy and misses the point of how the merge model works.
What I actually observed when testing
I ran the API against a few different inputs to see how the LLM behaved:
- A reasonably clear sentence rated 2.9 on the first pass, then rewrote to 7.6. Success after just one retry.
- "thing happen then other thing" rated 1.0, rewrote twice before reaching 7.0. The retry loop kicked in exactly as expected.
- Dense academic jargon ("utilization of aforementioned methodological frameworks...") rated 8.9. The LLM considers grammatically correct text clear even when it's verbose. Worth keeping in mind as an edge case.
The LLM's definition of "clarity" doesn't always match yours. That's fine though. The graph structure is correct either way. If you want different behaviour, tune the prompt in rate_clarity, not the graph.
File structure
langgraph-demo-text-quality-checker/
├── state.py # State TypedDict + FastAPI Pydantic schemas
├── llm.py # Provider-agnostic LLM factory
├── nodes.py # All 5 node functions
├── graph.py # Graph wiring, routing function, compiled agent
└── main.py # FastAPI endpoints
What LangGraph actually gives you
After building this, here's my honest take: LangGraph doesn't do anything you couldn't do manually. You could write the same retry loop and branching logic in plain Python. What LangGraph gives you is a structured way to think about and express that logic as a graph, and an execution engine that handles state flow, parallelism, and routing for you.
The real value shows up as complexity grows. When you have ten nodes, some running in parallel, some looping, some conditional, having that structure explicit in the graph definition is far easier to reason about than equivalent imperative code buried in function calls.
For a project this small, the overhead might feel unnecessary. But the concepts it forces you to learn (stateless nodes, explicit routing, immutable state updates) are exactly the right habits for building larger agentic systems.
What's next
The patterns here scale directly to more complex use cases. You could swap rate_clarity and rewrite for any LLM-driven step. The retry loop pattern works for any task that needs validation and reattempt. The parallel branch works wherever you have independent operations that can run simultaneously.
If you're a Python developer who's been curious about LangGraph but hasn't tried it yet, this is a solid first project. Start here, get the mental model right, and the more complex agent patterns will follow naturally.
The full source code is on GitHub: langgraph-demo-text-quality-checker
Happy Coding 💻
