- #ai
- #langgraph
- #langchain
- #agents
- #python
Agentic Workflows Without the Hype: What LangGraph Is Actually Good For
What LangGraph actually solves in production, where it falls flat, and when you should skip the framework entirely and just write a function.
Everyone’s building agents in 2025. My LinkedIn feed is a wall of “autonomous AI agent” demos showing chatbots that book flights, file taxes, and apparently solve world hunger. Most of these demos run on vibes, a single happy path, and the implicit assumption that LLMs never hallucinate, never time out, and never cost more than a penny per call.
I’ve shipped LangGraph workflows to production. At a previous role building AI infrastructure, my team used it to orchestrate multi-step LLM pipelines processing thousands of requests daily. Some of those workflows are still running. They handle real traffic, break in real ways, and get fixed by real engineers at 3 AM.
This post isn’t a tutorial. It’s a field report from someone who’s been on both sides: the excitement of wiring up your first agent graph, and the cold reality of debugging a stuck workflow in production while Slack pings pile up. I want to separate what LangGraph genuinely solves from the hype that surrounds anything with “agent” in the name.
What LangGraph Actually Is
Strip away the marketing and LangGraph is a state machine library with persistence. That’s it. You define nodes (functions that transform state), edges (transitions between nodes), and optionally conditional edges that branch based on current state values. The framework handles checkpointing so you can pause, resume, or replay a workflow from any point.
It sits on top of LangChain, which is both an advantage and a source of friction. You get LangChain’s model abstractions and its tooling ecosystem. You also get its occasionally bewildering API surface and version churn. If you’ve ever upgraded LangChain between minor versions and watched half your imports break, you know the feeling.
Here’s the mental model that actually helped me: think of LangGraph as a workflow engine that happens to speak LLM, not an AI framework that happens to have workflows. If you approach it with the same discipline you’d bring to Airflow or Temporal, you’ll have a much better time than if you approach it expecting magic.
Where It Earns Its Keep
After running LangGraph in production for the better part of a year, I can point to four specific capabilities that justified the added complexity. Everything else was noise.
Typed State Schemas
The single best feature of LangGraph is typed state. You define a TypedDict (or Pydantic model) that represents your workflow’s entire context. Every node receives this state and returns a partial update. The framework merges updates and validates the shape at each transition.
from langgraph.graph import StateGraph, END
from typing import TypedDict
class ContentPipelineState(TypedDict):
query: str
sources: list[str]
draft: str
review_notes: str
approved: bool
revision_count: int
def research_node(state: ContentPipelineState) -> dict:
# Fetch relevant documents from our knowledge base
results = search_knowledge_base(state["query"], top_k=5)
return {"sources": [r.text for r in results]}
def draft_node(state: ContentPipelineState) -> dict:
prompt = build_draft_prompt(state["query"], state["sources"])
response = llm.invoke(prompt)
return {
"draft": response.content,
"revision_count": state["revision_count"] + 1,
}
This seems obvious, but you’d be amazed how many “agent” codebases I’ve seen where state is a dictionary that grows keys unpredictably across function calls. One team I worked with had a workflow where the state dict accumulated 47 keys by the final node, and nobody could explain what half of them did. Typed state catches those problems at definition time, not at 2 AM when your pipeline silently drops a field.
Human-in-the-Loop Interrupts
LangGraph’s interrupt mechanism lets you pause a workflow mid-execution, surface it to a human for review, and resume exactly where you left off. The state gets serialized to a checkpoint store (Postgres, Redis, SQLite), so the process can die and restart between the pause and resume without losing anything.
from langgraph.checkpoint.postgres import PostgresSaver
def review_gate(state: ContentPipelineState) -> dict:
# This node surfaces the draft for human review.
# The graph pauses here until a human provides feedback.
return {"review_notes": state.get("review_notes", "")}
graph = StateGraph(ContentPipelineState)
graph.add_node("research", research_node)
graph.add_node("draft", draft_node)
graph.add_node("review", review_gate)
graph.add_node("publish", publish_node)
graph.add_edge("research", "draft")
graph.add_edge("draft", "review")
# Interrupt before review so a human can inspect the draft
checkpointer = PostgresSaver.from_conn_string(DATABASE_URL)
app = graph.compile(
checkpointer=checkpointer,
interrupt_before=["review"],
)
We used this pattern for a content generation pipeline where AI-drafted responses needed editorial sign-off before reaching customers. The workflow would generate a draft, pause, fire a webhook to our internal review tool, and resume when the editor clicked “approve” or “revise.” No polling loops, no fragile cron jobs. The state just sat in Postgres until someone was ready.
The beauty of this approach is that you can deploy new code between the pause and resume. The workflow picks up the new node implementations while preserving the checkpointed state. We used this to ship bug fixes to our review logic without interrupting in-flight workflows.
Checkpointed Retries and Replay
Because every state transition is checkpointed, you get retry and replay almost for free. If a node fails (say, the LLM provider returns a 503), you can retry from the last successful checkpoint without re-running the entire pipeline.
This matters more than you think. In production, LLM API calls fail. Not often, maybe 0.3-0.5% of requests on a good day, but when you’re running thousands of workflows, that 0.5% adds up fast. One of our pipelines had four LLM calls in sequence. Without checkpointing, a failure in the last call meant re-running all four. With LangGraph’s checkpointing, we’d retry just the failed node. Over a month, that saved us roughly 15% on our LLM API bill and kept our P95 latency within SLO.
The replay capability is equally valuable for debugging. When a workflow produces a bad output, you can replay it from the beginning with the exact same inputs, stepping through each node to see where things went wrong. It’s the closest thing to a debugger I’ve found for multi-step LLM pipelines.
Deterministic Routing Between LLM Calls
Conditional edges let you route workflows based on state values, not on what an LLM decides to do next. This distinction is crucial.
def route_after_review(state: ContentPipelineState) -> str:
# Deterministic routing: plain Python, fully testable
if state["approved"]:
return "publish"
if state["revision_count"] >= 3:
return "escalate" # Too many revisions, involve a human
return "draft" # Send it back for another pass
graph.add_conditional_edges("review", route_after_review, {
"publish": "publish",
"draft": "draft",
"escalate": "escalate",
})
graph.set_entry_point("research")
compiled = graph.compile(checkpointer=checkpointer)
The routing logic is a plain Python function. You can test it with pytest, reason about its termination conditions, and guarantee it doesn’t loop forever. Compare this to the “let the LLM decide what to do next” approach, where your workflow’s control flow depends on the stochastic output of a language model. I’ve debugged both. The deterministic version lets me sleep.
Where It’s the Wrong Tool
For all its strengths, LangGraph adds real complexity. Here are the cases where I’d tell you to skip it entirely.
Single-prompt tasks. If your entire workflow is “take input, call the LLM, return output,” you don’t need a graph. You need a function. Wrapping a single API call in a state graph is like deploying a static HTML page to Kubernetes.
Simple linear chains. If your pipeline is a sequence of steps with no branching, no human review, and no checkpoint-worthy failure points, a plain async function with a retry decorator is simpler, faster, and easier to debug. LangChain’s basic LCEL chains work fine here too.
Latency-critical hot paths. LangGraph adds overhead. State serialization, checkpoint writes to your persistence store, graph traversal logic. It’s not enormous, maybe 15-50ms per node transition depending on your checkpoint backend. But on a hot path where every millisecond matters, that overhead is real. We kept LangGraph off our real-time inference path entirely and used it only for async background pipelines.
Prototyping and exploration. When you’re still figuring out if an LLM can even solve your problem, don’t reach for a framework. Write throwaway scripts. Call the API directly. Figure out your prompts, your failure modes, your cost profile. Then, if the workflow genuinely needs branching and persistence, bring in LangGraph. Premature framework adoption is a real productivity killer.
I call this the “just use a function” rule. Before reaching for LangGraph, ask yourself: can I implement this as a regular async function with try/except and tenacity for retries? If the answer is yes, write the function. You can always add the framework later when complexity demands it.
Failure Modes I’ve Seen in Production
These are the sharp edges that don’t show up in tutorials or conference talks.
State Bloat
Every node transition writes the full state to your checkpoint store. If your state includes large text fields (full document contents, long conversation histories, raw API responses), your checkpoint table grows fast. We hit this when a research pipeline accumulated source documents in a list without pruning. Nobody noticed for a week. By then, our checkpoint table was 40GB and Postgres query performance on that table had cratered.
The fix: keep your state lean. Store references (document IDs, S3 keys) instead of full content. Prune conversation history to the last N turns. Set up a cleanup job that purges completed workflow checkpoints after a retention period. Treat your state schema like a database row, not a dumping ground.
Infinite Loops Without Recursion Limits
Conditional edges create cycles by design. That’s one of the framework’s strengths for iterative workflows like “draft, review, revise, repeat.” But if your exit condition depends on LLM output (“keep revising until the quality score is above 0.9”), you’re one bad model response away from burning through your API budget in a tight loop.
# This WILL ruin your day eventually
def should_continue(state: ContentPipelineState) -> str:
# What if the LLM never scores above 0.9?
if state["quality_score"] > 0.9:
return "done"
return "revise" # Round and round we go
Always set recursion_limit on your compiled graph. Always include a hard exit based on a counter or elapsed time, not just LLM-evaluated quality. We used recursion_limit=10 as a baseline and logged every time a workflow hit it. It happened more often than we expected, about 2% of runs in one pipeline. Without the limit, those runs would’ve looped until someone noticed the API bill.
Treating the LLM as the Router
I’ve seen teams use an LLM call to decide which node to execute next. “Given this state, which of these five actions should we take?” The model picks one, and the conditional edge routes accordingly.
This works beautifully in demos. In production, it means your workflow’s control flow is non-deterministic. The same input might take different paths on different runs. You can’t write reliable integration tests because the execution order isn’t stable. You can’t reason about worst-case latency. And you pay for an extra LLM call at every decision point.
Use code for routing. Use LLMs for generation and analysis within nodes. The moment your control flow depends on a model’s judgment call, you’ve traded debuggability for cleverness. That trade is almost never worth it.
Observability: Knowing What Your Graph Is Doing
One thing I didn’t appreciate early on: LangGraph workflows are hard to observe with standard application monitoring. A single workflow invocation might make five LLM calls, write ten checkpoints, and take anywhere from 2 seconds to 2 minutes depending on model latency. Your standard request-level metrics don’t capture this.
We added structured logging at every node boundary:
import structlog
from functools import wraps
logger = structlog.get_logger()
def traced_node(node_name: str):
"""Decorator that logs entry, exit, and duration for each graph node."""
def decorator(func):
@wraps(func)
def wrapper(state):
run_id = state.get("run_id", "unknown")
logger.info(
"node.enter",
node=node_name,
run_id=run_id,
revision_count=state.get("revision_count", 0),
)
start = time.monotonic()
try:
result = func(state)
elapsed = time.monotonic() - start
logger.info(
"node.exit",
node=node_name,
run_id=run_id,
elapsed_ms=round(elapsed * 1000),
)
return result
except Exception as exc:
elapsed = time.monotonic() - start
logger.error(
"node.error",
node=node_name,
run_id=run_id,
elapsed_ms=round(elapsed * 1000),
error=str(exc),
)
raise
return wrapper
return decorator
@traced_node("research")
def research_node(state: ContentPipelineState) -> dict:
results = search_knowledge_base(state["query"], top_k=5)
return {"sources": [r.text for r in results]}
We push these logs into Loki and built a Grafana dashboard showing per-node latency distributions, error rates, and the percentage of workflows hitting the recursion limit. That dashboard has been more useful for debugging than any amount of print-statement archaeology.
The other piece worth investing in: a run_id field in your state schema. Generate it at workflow creation time and include it in every log line. When something goes wrong, you can filter all logs for that run and reconstruct the exact path the workflow took, which nodes fired, what state looked like at each transition, and where it stalled.
Testing LangGraph Workflows
Testing graph workflows is awkward if you approach it like testing regular functions. The graph has state, transitions, and side effects (LLM calls, checkpoint writes). Testing the whole thing end-to-end is slow and flaky because LLM responses aren’t deterministic.
What worked for us: test the routing logic and node functions separately, then write a small number of integration tests for the assembled graph with mocked LLM calls.
# Unit test the routing function: pure logic, no LLM needed
def test_route_after_review_approved():
state = {"approved": True, "revision_count": 1}
assert route_after_review(state) == "publish"
def test_route_after_review_max_revisions():
state = {"approved": False, "revision_count": 3}
assert route_after_review(state) == "escalate"
def test_route_after_review_needs_revision():
state = {"approved": False, "revision_count": 1}
assert route_after_review(state) == "draft"
# Integration test with mocked LLM: verify the graph structure
from unittest.mock import patch
@patch("myapp.workflows.llm")
def test_content_pipeline_happy_path(mock_llm):
mock_llm.invoke.return_value.content = "Generated draft content"
# Use an in-memory checkpoint store for tests
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)
initial_state = {
"query": "test query",
"sources": [],
"draft": "",
"review_notes": "",
"approved": True, # Skip the review loop
"revision_count": 0,
}
result = app.invoke(initial_state)
assert result["draft"] != ""
assert result["revision_count"] == 1
The MemorySaver checkpointer is essential for tests. It stores checkpoints in memory instead of hitting Postgres, which keeps your test suite fast and removes the database dependency for unit-level graph tests.
The Honest Verdict
LangGraph is a solid piece of engineering that solves a real problem: orchestrating multi-step LLM workflows with persistence, branching, and human oversight. It’s not magic, and it’s not always the right choice.
Here’s the decision checklist I use:
- Do you have branching logic? If your workflow is purely linear, skip LangGraph. A function is fine.
- Do you need human-in-the-loop? This is LangGraph’s strongest use case. If you need to pause and resume workflows around human decisions, it’s worth the complexity.
- Do you need checkpointed retries? If your LLM calls fail often enough that “retry from the top” is expensive, checkpoint-and-resume pays for itself.
- Is your state complex enough to benefit from a schema? If your workflow touches three or more data fields across multiple steps, typed state prevents real bugs.
- Can you implement it as a plain function? If yes, do that first. You can always add LangGraph later. You almost never need to remove it later, because by then it’s load-bearing.
The best agent framework is the one you don’t adopt until you need it. When you do need it, LangGraph is genuinely one of the better options out there. Just go in with open eyes, set your recursion limits, keep your state lean, and route with code, not with vibes.
This post draws on my experience building LLM orchestration pipelines at a previous AI infrastructure role. The patterns described are representative of real production systems, with identifiers and details generalized.