Most of your agent should be a function
Most of what gets built as an agent graph should be a function with three calls in it.
I say this as someone who uses LangGraph and would pick it again for the thing I picked it for. But I've also added it to a pipeline, lived with it for a month, and taken it back out, and that removal taught me more about when it's worth having than the successful adoption did.
The one I removed
Interview Pro's assessment pipeline goes: transcript in, extract findings per rubric criterion, score, assemble a report. I built it as a graph because the work was LLM-shaped and that's what you reach for.
It was a straight line. Every run visited every node in the same order. There was no branching, no state that a later node needed from anywhere except the node before it, and no failure I wanted to recover from in the middle. If scoring fell over, the right outcome was for the whole assessment to fail and get retried by the queue.
What the graph gave me was a state object I had to thread through everything, a control flow I couldn't read top to bottom, and stack traces that went through the framework instead of my code. What it gave me in return was nothing at all, because I wasn't using a single one of its capabilities.
Replacing it with an async function that awaits three things took an afternoon and deleted about two hundred lines. The per-criterion extraction is a gather over a list. That's it. That's the whole orchestration layer.
The four things a graph is actually for
Having done it both ways, here's what I think you're buying.
Partial failure that doesn't kill the run. This is the big one and it's why the curriculum generator is a graph. A learning path is around forty lessons generated independently. One of them failing to find grounding is a lesson marked uncovered, not a failed request. A straight-line implementation makes that awkward, because now every step needs its own error handling and the "keep going" logic ends up smeared across the pipeline.
Genuine data-dependent branching. Not an if you could write in Python, but a route the model chooses, or a loop that runs an unknown number of times until a condition holds. If you find yourself writing a while loop around a set of LLM calls with a step counter and a bail-out, you've started implementing a graph by hand.
Interruption and resume. Anything with a human in the loop, or anything long enough that a process restart in the middle is unacceptable. Persisting a graph's state and picking it up later is a solved problem in the framework and a genuinely annoying one to build yourself.
Inspecting state per step. Being able to see exactly what each node received and returned is worth real money when you're debugging why a generated lesson is bad, because the failure is usually three steps upstream of where you noticed it. You can build this with logging. The framework gives it to you consistently, which matters more than it sounds when there are five people on the codebase.
Notice that only one of those four is about orchestration. It's mostly about failure and visibility.
The test
Before adding a graph, I try to draw the thing on paper.
If it's a straight line, write a straight line. A function that awaits four calls in order is readable by anyone, typed by your language, and debuggable with a breakpoint. Wrapping it in a state machine to look sophisticated is a cost you pay every time somebody new reads the file.
If the drawing has a branch the model chooses, a loop with no fixed count, or a fan-out where some branches are allowed to fail, use the graph. You'll be building those semantics yourself otherwise, and worse.
The one that's genuinely borderline is fan-out with uniform handling, where every branch does the same thing and any failure fails everything. That's a gather with a semaphore. I've seen it built as a graph several times, including by me.
The state object problem
The failure mode of graphs that nobody warns you about: the state object becomes a god object.
Every node needs something slightly different, and the path of least resistance is to add a field. Six months in, the state has thirty fields, most nodes read three of them, and no one can tell you which node populates which field without reading all of them. The type signature says every node takes and returns the whole state, so the compiler helps you with nothing.
We handle it by giving each node an explicit input model and an explicit output model, with the graph state as a container rather than the interface nodes are written against. It's more code. It's the difference between a pipeline you can modify in year two and one you rewrite.
None of this is an argument against the framework. It's an argument for reaching for it on the second version, once you know where the branching actually is, rather than on the first, when you're guessing. The straight-line version is cheap to write and cheap to throw away, and it tells you which of the four things you actually need.