Agent frameworks compared
What LangGraph, the OpenAI Agents SDK, CrewAI and AutoGen each abstract, the mental model behind each, and how to use one without marrying it.
A framework is a loop you did not write
Every framework offers the same core: run a model, dispatch the tool calls it requests, append the results, decide when to stop. They differ in the shape of the control flow they make easy, and in how much of the run they can persist and inspect for you.
| Framework | Core abstraction | Strongest at | Watch out for |
|---|---|---|---|
| LangGraph | State machine of nodes and edges | Explicit control flow, checkpointing, human interrupts | Graph of a simple job is more code than a loop |
| OpenAI Agents SDK | Agent plus handoffs, provider-hosted runner | Short, direct implementations with tracing | Fewest abstractions; depth is your responsibility |
| CrewAI | Crew of role-playing agents with tasks | Demos and content-shaped pipelines | Role personas hide what actually happens in a call |
| AutoGen | Conversation between agents | Research on multi-agent dialogue and critique | Conversation is a poor structure for a job that ends |
The honest summary: graph for controlled state, agent-plus-handoffs for simple delegation, crew or conversation when the interaction itself is the product. If you cannot describe your control flow in one sentence, no framework will describe it for you.
The same agent in two shapes
# LangGraph: an explicit graph with a persisted state
from langgraph.graph import StateGraph, END
def think(state):
return {"messages": state["messages"] + [model(state["messages"], tools=TOOLS)]}
def should_continue(state):
return "tools" if state["messages"][-1].tool_calls else END
graph = StateGraph(dict)
graph.add_node("think", think)
graph.add_node("tools", run_tools)
graph.set_entry_point("think")
graph.add_conditional_edges("think", should_continue, {"tools": "tools", END: END})
graph.add_edge("tools", "think")
app = graph.compile(checkpointer=memory) # checkpointer enables resume
app.invoke({"messages": [{"role": "user", "content": goal}]},
config={"configurable": {"thread_id": run_id}})# A plain loop: the same behaviour, no dependency
def run(goal, tools, max_steps=8):
messages = [{"role": "user", "content": goal}]
for _ in range(max_steps):
reply = model(messages, tools=[t.schema for t in tools])
messages.append(reply)
if not reply.tool_calls:
return reply.content
for call in reply.tool_calls:
messages.append(execute(call, tools))
return None- The plain loop is not a lesser version. It is the reference implementation, and writing it once teaches you what every framework is doing.
- The graph earns its place when you need to pause mid-run, persist state and resume: that is genuinely hard to add later to a bare loop.
- Handoffs in the Agents SDK are just tool calls that return another agent. Useful, but not magic, and each handoff costs a full model call.
- Crew-style role agents produce good demos because each role gets a fresh context. The same effect is achievable by calling a model with a different system prompt.
Keeping the exit open
Frameworks change their abstractions faster than your application changes its requirements. Cost of adoption is low; cost of extraction is high. The trick is to keep everything valuable outside the framework.
# Business logic: framework-free, testable, no imports from the framework
def lookup_order(order_id: str) -> dict:
"""Read an order. Pure function of the database."""
return db.orders.find_one({"_id": order_id})
# Adapter: the only file that knows about the framework
def as_tool():
return Tool(name="lookup_order",
description="Find one order by id.",
parameters=OrderLookupSchema,
fn=lookup_order)
TOOLS = [as_tool(), ...] # swap this file to change framework- Keep tools as plain functions with typed arguments. They are your real asset; everything else is plumbing.
- Keep the loop replaceable. If your application code imports the framework in only one module, migrating is a day, not a quarter.
- Pin exact versions and read the changelog when you bump. Renamed node types are a silent behaviour change.
- Prefer open protocols for the tool boundary — an MCP server works with any client, a framework-specific tool class works with one.
- Your persisted state format and your trace data are the two things that really trap you. Decide deliberately whether to keep them in your own schema.
# Keep the run state in a shape you own, whatever the framework thinks
@dataclass
class RunState:
run_id: str
goal: str
steps: list[dict] # {tool, args, result, error, ms}
tokens: int
cost_usd: float
def to_framework(state: RunState) -> list:
return [dict(s) for s in state.steps] # thin, lossy, replaceableFAQ
Do I need a framework at all?
Which one should I pick?
Related
Model Context Protocol in practice Multi-agent patterns
Last refreshed 2026-09-18.