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.

FrameworkCore abstractionStrongest atWatch out for
LangGraphState machine of nodes and edgesExplicit control flow, checkpointing, human interruptsGraph of a simple job is more code than a loop
OpenAI Agents SDKAgent plus handoffs, provider-hosted runnerShort, direct implementations with tracingFewest abstractions; depth is your responsibility
CrewAICrew of role-playing agents with tasksDemos and content-shaped pipelinesRole personas hide what actually happens in a call
AutoGenConversation between agentsResearch on multi-agent dialogue and critiqueConversation 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, replaceable
⚠️
The abstractions that are hardest to leave are not the API calls — they are the observability dashboard and the checkpoint format. If your traces live only inside a vendor's product, you have a data migration, not a refactor, on the day you switch.

FAQ

Do I need a framework at all?
Not for one agent with a handful of tools. A loop, a dispatcher and a schema validator are about 100 lines and you will understand every failure. Adopt a framework when you need durable interrupts, resumable state or tracing you would otherwise build yourself.
Which one should I pick?
Pick the one whose control flow matches your problem: a graph when you need explicit branching and resume, agent-plus-handoffs for simple delegation, and neither for a pipeline that is really a workflow with model calls inside it.

Model Context Protocol in practice Multi-agent patterns

Last refreshed 2026-09-18.