A friend asks whether they should "learn LangGraph" before building an order-lookup agent. I'd ask first whether they can write the loop themselves, because it's short, and knowing it makes every framework less mysterious. This post builds the plain version, runs it, builds the same agent as a LangGraph graph, runs that too, and ends with rules for choosing.
Both versions use a scripted stub in place of a model, so the output below is real and repeatable, but it says nothing about how a live model behaves. I ran everything on 2026-10-08 with Python 3.13 and langgraph 1.2.14.
What is an agent loop, in code?
Call the model. If it asks for a tool, run the tool, append the result to the conversation and call the model again. If it gives a final answer, stop. Add a cap so a confused model can't loop forever. That's the whole pattern behind the function calling lesson and the longer first agent tutorial.
Here is the plain Python version. The model is a parameter, which is what makes it testable. Everything above the __main__ guard is the reusable part: about 50 lines including the tools and the stub.
import json
ORDERS = {"A100": {"status": "shipped", "total": 1499}, "A200": {"status": "processing", "total": 799}}
def get_order(order_id: str) -> dict:
if order_id not in ORDERS:
raise KeyError(f"no such order: {order_id}")
return ORDERS[order_id]
def add(a: float, b: float) -> float:
return a + b
TOOLS = {"get_order": get_order, "add": add}
class StubModel:
"""Returns pre-scripted replies in the shape a real API wrapper would return."""
def __init__(self, script):
self.script = list(script)
self.calls = 0
def __call__(self, messages):
self.calls += 1
return self.script.pop(0)
def run_agent(model, user_msg, max_steps=5):
messages = [{"role": "user", "content": user_msg}]
for step in range(1, max_steps + 1):
reply = model(messages)
if reply["type"] == "final":
return reply["text"], messages
name, args = reply["name"], reply["args"]
try:
result = TOOLS[name](**args)
except Exception as exc: # feed errors back instead of crashing
result = {"error": f"{type(exc).__name__}: {exc}"}
print(f"step {step}: {name}({json.dumps(args)}) -> {json.dumps(result)}")
messages.append({"role": "assistant", "tool_call": reply})
messages.append({"role": "tool", "name": name, "content": json.dumps(result)})
return "stopped: max_steps reached", messages
Three design choices worth copying. The model is injected, so tests need no network. Tool exceptions become data the model can read, rather than a crash. And max_steps is a hard stop.
What does it do when it runs?
I drove it with three scripts: a normal run, a bad order ID, and a model stuck repeating itself (that last one called with max_steps=3 so the output stays short). Real output:
step 1: get_order({"order_id": "A100"}) -> {"status": "shipped", "total": 1499}
step 2: get_order({"order_id": "A200"}) -> {"status": "processing", "total": 799}
step 3: add({"a": 1499, "b": 799}) -> 2298
FINAL: Both orders total 2298 rupees. | model calls: 4 | messages: 7
--- recovery from a tool error
step 1: get_order({"order_id": "ZZZ"}) -> {"error": "KeyError: 'no such order: ZZZ'"}
FINAL: I could not find order ZZZ.
--- runaway model hits the step cap
step 1: add({"a": 1, "b": 1}) -> 2
step 2: add({"a": 1, "b": 1}) -> 2
step 3: add({"a": 1, "b": 1}) -> 2
FINAL: stopped: max_steps reached
The normal run took four model calls and left seven messages (one user message, then three tool-call and tool-result pairs). The error case shows the failure going back to the model as text. The runaway case stops at three steps with a clear message. Those three behaviours are what you test on every change, for free.
Note what the stub does not test: whether a real model picks get_order over add, passes valid arguments, or gives up gracefully. That needs agent evals against a live model.
What does the same agent look like in LangGraph?
I rebuilt it with LangGraph, reusing the same tools and the same stub. The model node calls the stub, a conditional edge routes to the tool node or to the end, and the tool node loops back to the model.
from typing import Annotated, TypedDict
import json
import operator
from langgraph.graph import StateGraph, END
class State(TypedDict):
messages: Annotated[list, operator.add]
reply: dict
def make_graph(model):
def call_model(state: State):
return {"reply": model(state["messages"]), "messages": []}
def run_tool(state: State):
r = state["reply"]
try:
result = TOOLS[r["name"]](**r["args"])
except Exception as exc:
result = {"error": f"{type(exc).__name__}: {exc}"}
print(f"tool: {r['name']}({json.dumps(r['args'])}) -> {json.dumps(result)}")
return {"messages": [{"role": "tool", "name": r["name"], "content": json.dumps(result)}]}
def route(state: State):
return END if state["reply"]["type"] == "final" else "tool"
g = StateGraph(State)
g.add_node("model", call_model)
g.add_node("tool", run_tool)
g.set_entry_point("model")
g.add_conditional_edges("model", route, {"tool": "tool", END: END})
g.add_edge("tool", "model")
return g.compile()
Real output for the normal script:
tool: get_order({"order_id": "A100"}) -> {"status": "shipped", "total": 1499}
tool: get_order({"order_id": "A200"}) -> {"status": "processing", "total": 799}
tool: add({"a": 1499, "b": 799}) -> 2298
FINAL: Both orders total 2298 rupees. | model calls: 4
Same behaviour, same four model calls. The graph file is 51 lines including a runner, and it imports the tools and stub from the plain file, so the graph wiring costs more lines than the plain run_agent function (about 15 lines). Don't expect a framework to shrink this. I did not wire in a step cap or a model-visible tool-call message here; those are your job in either version. (LangGraph has its own recursion limit, which I did not test.)
So what does the framework buy you?
Not shorter code. It buys things you'd otherwise build. From LangGraph's own overview page (read 2026-10-08), the named benefits are persistence ("persist through failures and can run for extended periods, resuming from where they left off"), human-in-the-loop ("inspecting and modifying agent state at any point"), short- and long-term memory, debugging with LangSmith, and production deployment. Streaming is not in that list; it is a feature I did not check. The same page calls LangGraph "very low-level, and focused entirely on agent orchestration," and points people new to agents toward LangChain's higher-level agents.
I did not exercise any of those capabilities in this post. The run above proves the two versions agree on a simple loop, nothing more. For a deeper comparison of the frameworks, see LangChain vs LangGraph and smolagents, CrewAI or LangGraph.
When should you stay in plain Python?
Pick plain Python when all of these hold:
- One loop, under about five tools.
- Runs finish in seconds and a retry from the start is fine.
- One developer owns it and needs to read a stack trace top to bottom.
- You want to unit test the control flow with a stub, as above.
- You're still learning what your agent needs.
When does a framework earn its keep?
Reach for one when you can name the specific feature you'd otherwise build:
| You need | Plain Python | Framework |
|---|---|---|
| Resume after a crash mid-run | Write a state store and a replay path | Built-in persistence (per the LangGraph overview) |
| Pause for a human approval, resume later | Write a queue and a resume endpoint | Built-in human-in-the-loop |
| Stream partial results to a UI | Write it | Check the framework's streaming docs (not verified here) |
| Trace every step across many runs | Add logging or OpenTelemetry | Tracing integrations |
| Several agents with shared state | Gets messy quickly | A graph models this directly |
A rule I'd defend: count the rows you need today, not the ones you might need. Zero or one, stay plain. Two or more, a framework is probably cheaper than your own version. When you do adopt one, keep your tools as ordinary functions and your model injectable, as above. Then switching is a rewrite of the wiring, not the logic.
What should you check before shipping either version?
- A hard step cap, and a visible message when it's hit.
- Tool errors returned to the model as text, never swallowed.
- A stub-model test for each control-flow branch.
- Tool inputs validated, since the model chooses the arguments. See tool design for agents.
- A cost cap per run once a real model is plugged in. I measured no token usage in this post.
What I did not test
- Any real model. Everything above uses a scripted stub.
- LangGraph's persistence, interrupts, streaming and recursion limit.
- Other frameworks. No claims here about CrewAI, smolagents or others beyond what the linked posts say.



