LangGraph clicked when I stopped thinking in chains
LangChain made sense to me on day one. Call a model, pass the output somewhere, call another thing. Chain.
LangGraph took longer. I stared at the docs, nodded along at the diagrams, and still thought: why do I need a whole graph library to call an LLM?
Then I tried to build something that had to (1) call a tool, (2) look at what came back, (3) decide if that was enough, and (4) sometimes go try again. My “chain” turned into a pile of ifs and a while I was embarrassed of. That’s when the graph stopped feeling like ceremony.
Chains are for the happy path
Most demos are happy paths. User asks → model answers. Or user asks → retrieve → answer. Fine. A chain is enough.
Real workflows get messy fast:
- the tool failed, try a different one
- the retrieved docs are garbage, rewrite the query
- the answer isn’t grounded, go fetch more
- a human has to approve before you send the email
That’s not a chain. That’s a flowchart someone would draw on a whiteboard. LangGraph is basically “make the whiteboard executable.”
LangChain vs LangGraph (how I keep them straight)
I got confused because the names sound like siblings competing for the same job. They’re not.
LangChain = adapters. Models, prompts, tools, output parsers. The boring glue so you’re not rewriting OpenAI/Anthropic/Ollama client code every week.
LangGraph = control flow. Who runs next. What state they share. When to loop. When to stop.
You still use LangChain stuff inside graph nodes. Graph decides the route; chain-y bits do the model work.
import { ChatOllama } from '@langchain/ollama';
const model = new ChatOllama({
baseUrl: 'http://localhost:11434',
model: 'llama3.1:8b'
});
const reply = await model.invoke('Explain reducers in two sentences.');
console.log(reply.content); Swap the provider later, keep invoke(). Not exciting. That’s the point.
Make the model return fields, not essays
I wasted a week parsing free-form text with regex. “Please reply with YES or NO” — the model replied with a paragraph that contained yes.
Structured output fixed more bugs than prompt tweaking:
import { z } from 'zod';
const Decision = z.object({
route: z.enum(['search', 'answer']),
reason: z.string()
});
const router = model.withStructuredOutput(Decision);
const decision = await router.invoke(
'Can this question be answered from the conversation alone?'
);
// decision.route is actually usable If it fails validation, fail at the boundary. Retry or surface an error. Don’t let half-parsed junk become graph state.
Tools don’t run themselves
This one bit me. You “give the model a tool” and somehow expect the tool to execute. No. The model returns a request: name + args. Your code still has to:
- validate the args (please validate the args)
- run the function
- stick the result back in the messages
- call the model again
That loop is the whole agent thing:
Agent loop
One decision can send the graph around again.
01Model
Reads state and proposes the next move.
Route the result
Tool call or final answer?
↻ Tools
Run the call, add its result to state, then return to the model.
Answer → END
Return the settled result to the user.
Stop when you get a normal answer. Also stop when you’ve looped too many times. A model that keeps calling the same broken tool will happily burn money forever if you let it.
The three words that matter
- State — shared bag of data for the run
- Nodes — functions that read state, do work, return updates
- Edges — who goes next
Tiny example:
import { Annotation, StateGraph, START, END } from '@langchain/langgraph';
const WorkflowState = Annotation.Root({
question: Annotation(),
category: Annotation()
});
const graph = new StateGraph(WorkflowState)
.addNode('clean', ({ question }) => ({
question: question.trim()
}))
.addNode('classify', async ({ question }) => ({
category: await classify(question)
}))
.addEdge(START, 'clean')
.addEdge('clean', 'classify')
.addEdge('classify', END)
.compile();
const result = await graph.invoke({ question: ' How do reducers work? ' }); Nodes return updates, they don’t mutate a shared object in place. That matters once two nodes run at once — there’s a defined merge step instead of a race.
.compile() is underrated. It catches “you forgot to connect this node” before production does.
Conditional edges = the whole reason I switched
Normal edge: always go to B.
Conditional edge: look at state, pick a destination.
const routeEvidence = ({ evidence }) =>
evidence.length >= 2 ? 'write' : 'searchAgain';
const graph = new StateGraph(WorkflowState)
.addNode('search', search)
.addNode('grade', gradeEvidence)
.addNode('rewriteQuery', rewriteQuery)
.addNode('write', writeAnswer)
.addEdge(START, 'search')
.addEdge('search', 'grade')
.addConditionalEdges('grade', routeEvidence, {
write: 'write',
searchAgain: 'rewriteQuery'
})
.addEdge('rewriteQuery', 'search') // <-- the loop
.addEdge('write', END)
.compile(); That edge back to search is what chains hate. Put an attempt counter in state. After 2–3 tries, bail to a “sorry, I don’t know” node. Loops without an exit condition aren’t agents. They’re bugs with branding.
Parallel branches need reducers
Say three nodes all write to sources at the same time. Without a reducer, last write wins and you silently lose work. Fun to debug at 1am.
const ResearchState = Annotation.Root({
query: Annotation(),
sources: Annotation({
reducer: (current, incoming) => [...current, ...incoming],
default: () => []
})
}); Message history uses the same idea — append, don’t replace the whole chat every time.
createReactAgent is still a graph
There’s a prebuilt ReAct agent. Use it when the only branch is “did the model ask for a tool?”
import { createReactAgent } from '@langchain/langgraph/prebuilt';
const agent = createReactAgent({
llm: model,
tools: [searchDocs, inspectRepository],
prompt: 'Use tools when you need evidence. Never invent a tool result.'
});
const result = await agent.invoke({
messages: [{ role: 'user', content: 'Why did the deployment fail?' }]
}); Under the hood: message state, model node, tool node, conditional edge, stop condition. Once you see that, you know when the helper is enough and when you need your own graph.
When I actually reach for it
Worth it when:
- something has to be checked before continuing
- tools might need multiple round trips
- branches can run in parallel
- work has to pause and resume later
- a human has to approve a risky step
Not worth it for: one prompt, one answer. That’s model.invoke(). Don’t graph-ify a hello world.
The shift for me wasn’t “LangGraph is cooler.” It was: uncertain model calls need explicit control flow, and graphs are a decent way to write that down without lying to yourself about how simple the system is.
got thoughts?
Let's talk