Choosing an Agent Framework by Workload Shape: Six Axes and a Decision Path
Pick an agent framework by workload shape: LangGraph, CrewAI, AutoGen, Temporal or none, judged on determinism, lifetime, idempotency, audit and cost.
This article is published in English.
Sources: Your AI Agent Framework Is Probably the Wrong One Here’s How to Actually Choose
Asking which agent framework to use is the wrong opening question. The better one is what shape the workload has, because the framework follows from that answer and should not be picked in a vacuum from benchmark posts. Teams that choose in week one on the strength of a social media thread often spend months three through six discovering that the framework's core assumptions do not match what they are building. The framework is rarely at fault. Nobody named the properties the workload required before picking the tool meant to provide them.
Instead of comparing features, this approach names six axes that determine fit, shows what each looks like in code and then builds a decision path. One running example carries through: an infrastructure alert-triage agent. It is dull, realistic and touches every axis. The agent has to reason about whether an alert is a real problem, act by paging someone or auto-remediating, sometimes wait hours on a human, and never page an on-call engineer twice at 4 a.m. because a retry ran twice.
Axis one: how deterministic must branching be?
This axis is skipped and paid for later. The honest test is to ask whether anyone gets hurt if the same input takes a different code path 2% of the time. Some branches tolerate variation, such as choosing a tone for a summary email.
# A branch where non-determinism is FINE — picking a tone for a summary email.
# If the agent occasionally phrases things slightly differently, nobody's paged.
def draft_summary_tone(context: dict) -> str:
return llm_call(
prompt=f"Summarize this incident in a {context['audience']}-appropriate tone.",
temperature=0.7, # variability here is a feature, not a bug
)
Others do not, such as deciding whether to wake a human at 4 a.m. or auto-remediate. That decision has to be code and not a prompt.
# A branch where non-determinism is NOT fine — deciding whether to page a human
# at 4am versus auto-remediating. This must be code, not a prompt.
def route_alert(alert: dict) -> str:
if alert["severity"] == "critical" and alert["service"] in PAGE_ALWAYS_SERVICES:
return "page_oncall"
if alert["auto_remediation_available"] and alert["confidence"] > 0.9:
return "auto_remediate"
if alert["severity"] == "critical":
return "page_oncall"
return "log_and_monitor"
When the important decisions in a workflow look like the second function, deterministic and auditable and something a compliance reviewer would want to read as plain code, the framework must keep that logic away from the LLM. LangGraph's conditional edges fit this well, because the routing function is ordinary Python and the model only touches the parts deliberately handed to it, such as classification or drafting.
CrewAI and AutoGen are built the other way round, letting the agent's own reasoning decide what happens next. That is a feature when creative flexibility is the goal and a liability when flexibility occasionally means skipping the compliance check. If most important branches must be deterministic, that answer alone removes a good part of the field.
Axis two: how long does a unit of work live?
A short-lived unit starts and finishes inside a single HTTP request. This is the zone where no framework is needed and one would be pure overhead.
# Short-lived: starts and finishes inside one HTTP request.
# This is the "no framework needed" zone — a framework here is pure overhead.
async def handle_summarize_request(request: SummarizeRequest) -> SummarizeResponse:
text = await fetch_document(request.doc_id)
summary = await llm_summarize(text, max_tokens=300)
return SummarizeResponse(summary=summary)
A long-lived unit is different. An alert might sit awaiting human acknowledgment for six hours while the on-call engineer sleeps, then resume in another process after a deploy replaced the pods underneath it.
# Long-lived: this alert might sit in "awaiting human ack" for six hours
# while the on-call engineer is asleep, then resume on a completely
# different process after a deploy rotated the pods underneath it.
class AlertTriageWorkflow:
async def run(self, alert: dict) -> dict:
decision = await self.classify_and_route(alert)
if decision == "page_oncall":
await self.page(alert)
await self.wait_for_ack(timeout_hours=1) # this line is the whole ballgame
...
If the work reliably finishes in seconds and lives wholly in one process's memory, the framework question is probably being overthought. A plain async function with a retry decorator beats any framework on maintainability because nothing needs orchestrating, and learning LangGraph's state model or running a Temporal cluster for a stateless summarization endpoint is cost with no payoff.
Once a unit of work can outlive a process, because it waits on a person, a batch job or another team's SLA, anything counted in hours, durable execution becomes necessary. None of the three popular LLM orchestration frameworks was designed to be the durability layer. This axis points toward something like Temporal, or at least a hand-built system of persisted state plus a job queue with resumable steps.
Axis three: what if a step runs twice?
Teams usually find this one in an incident review. The first version below looks fine in a demo and is an incident waiting to happen, since a retried activity restarts the service again.
# BEFORE — looks fine in a demo, is a live incident waiting to happen
async def auto_remediate(alert: dict):
await restart_service(alert["service"]) # what if this activity gets retried?
The second version is idempotent by construction.
# AFTER — idempotent by construction
async def auto_remediate(alert: dict, idempotency_key: str):
if await remediation_ledger.already_applied(idempotency_key):
logger.info("remediation already applied, skipping", key=idempotency_key)
return await remediation_ledger.get_result(idempotency_key)
result = await restart_service(alert["service"])
await remediation_ledger.record(idempotency_key, result)
return result
The idempotency key has to come from something stable in the business data, such as the alert ID, and never from a UUID generated on each attempt, or retries quietly stop being safe. This belongs to the tool function and not to any framework, yet it affects which frameworks are tolerable, because some make it easy to forget a retry happened. AutoGen's chat-based execution gives no structural signal that a tool call is a retry and not a fresh decision. From the transcript's point of view the agent simply decided to call the tool again, and whether that is a retry or a duplicate action goes untracked.
When side effects are expensive, dangerous or involve money or infrastructure changes, the setup needs a framework, or a homemade layer beneath one, that treats whether the side effect already happened as a first-class question and not something inferred from a chat log.
Axis four: who reads the decision later, and how?
A framework-agnostic audit record is what matters in a postmortem, whatever orchestrated the steps.
# A framework-agnostic audit record — this is what actually matters
# in a postmortem, regardless of what orchestrated the steps.
@dataclass
class DecisionRecord:
alert_id: str
timestamp: float
step: str
reasoning: str # what the LLM said, verbatim
decision: str # the structured outcome, not prose
confidence: float | None
human_override: bool
async def log_decision(record: DecisionRecord):
await audit_store.insert(record)
# Also emit as a structured log line — cheap insurance for when
# the audit store itself is the thing that's down during an incident.
logger.info("agent_decision", **asdict(record))
Every framework lets something be logged. The question is whether its structure naturally yields a clean, queryable decision trail or whether getting one means fighting the abstraction. A LangGraph state object passes through named nodes, so asking what the state was just before a routing decision is a direct query on a TypedDict that was never dissolved into free text. An AutoGen conversation has to be parsed to rebuild the same fact, since its native memory format is prose and not structured state. Anywhere with compliance duties, an audit team or a habit of thorough postmortems, this axis deserves more weight than marketing material implies.
Axis five: what limits the team's velocity?
This rarely appears on a technical comparison chart and often decides the matter. A four-person startup checking whether agents can do the job at all should not spend three weeks learning Temporal's determinism constraints before writing any business logic.
# Week-one prototype: prove the concept fast, accept the debt knowingly.
from crewai import Agent, Task, Crew
triage_agent = Agent(role="Alert Triage", goal="Decide how to handle infra alerts")
crew = Crew(agents=[triage_agent], tasks=[Task(description="Triage: {alert}", agent=triage_agent)])
crew.kickoff(inputs={"alert": alert_payload})
That is a perfectly legitimate week-one artifact. The error is not choosing CrewAI for the prototype. It is the team six months later that never revisited the choice once real pager duty and real infrastructure changes were at stake, because the prototype already worked. A framework choice carries an expiration date tied to how much rides on the system, and writing that date down when choosing keeps it from being set by default.
Axis six: what latency and cost budget per decision?
Every routing decision that is its own LLM call is multiplied across a multi-agent conversation with several turns, and at alert volumes of hundreds a day, with bursts into the thousands during an incident, the bill grows quickly.
# Expensive pattern: every routing decision is its own LLM call,
# multiplied across a multi-agent conversation with several turns.
# At alert volumes (hundreds/day, sometimes bursts of thousands during
# a real incident), this is a real line item, not a rounding error.
async def route_via_llm(alert: dict) -> str:
return await llm_call(f"How should we handle this alert? {alert}")
# Cheaper, faster, and more auditable: cheap deterministic pre-filtering
# in code, LLM reserved for genuinely ambiguous cases.
async def route_alert_efficiently(alert: dict) -> str:
if alert["service"] in KNOWN_NOISY_SERVICES and alert["severity"] == "low":
return "log_and_monitor" # zero LLM calls for the common case
if alert["signature"] in KNOWN_REMEDIATION_PLAYBOOK:
return "auto_remediate" # deterministic lookup, zero LLM calls
return await llm_call(f"Novel alert, needs judgment: {alert}") # LLM only when genuinely needed
This is less a framework axis than an architecture discipline that helps any of them. Frameworks that make it easy to interleave cheap deterministic code with expensive model calls save real money at volume. LangGraph does, and so does a workflow engine such as Temporal wrapping an LLM call as one activity. The alternative is a native pattern of routing everything through another model-mediated conversation turn, which is AutoGen's default posture with speaker_selection_method set to auto.
A decision path, not a decision tree
A flowchart that produces one answer would be dishonest, because the regions overlap. The path below is closer to how the choice actually gets made.
Is this unit of work stateless and finishes in seconds?
└─ YES → skip the framework entirely. Plain functions + retries. Ship it.
└─ NO, continue.
Does it need to survive process restarts / wait on humans for hours-to-days?
└─ YES → you need durable execution (Temporal or equivalent) as the backbone,
regardless of what else you pick for the reasoning layer.
└─ NO, continue.
Are the important branches safety- or compliance-critical
(money, infra changes, irreversible external actions)?
└─ YES → LangGraph-style explicit graphs, keep LLM scoped to narrow nodes.
└─ NO, mostly exploratory/creative → CrewAI or AutoGen are legitimate defaults.
Is this still a prototype whose findings might get thrown away?
└─ YES → optimize for speed of iteration over long-term correctness,
but write down when you'll revisit that tradeoff.
Applied to the alert-triage agent, the result is a layered design. It survives restarts and waits on humans, so a durable execution layer is needed. Its most important branch is safety-critical, paging versus auto-remediating, so LangGraph-style explicit routing is needed inside each step. And it is far past prototype stage once it holds a pager. Temporal becomes the backbone, with a scoped LLM orchestration step as one activity inside it.
@activity.defn
async def classify_alert_activity(alert: dict) -> dict:
# LangGraph-style graph runs here — bounded reasoning, deterministic routing —
# inside an activity Temporal will retry and time-box like any other side effect.
result = alert_triage_graph.invoke({"alert": alert, "audit_log": []})
return {"decision": result["decision"], "confidence": result["confidence"]}
@workflow.defn
class AlertTriageWorkflow:
def __init__(self):
self._acked = False
@workflow.signal
async def acknowledge(self):
self._acked = True
@workflow.run
async def run(self, alert: dict) -> dict:
classification = await workflow.execute_activity(
classify_alert_activity, alert,
start_to_close_timeout=timedelta(seconds=20),
retry_policy=workflow.RetryPolicy(maximum_attempts=3),
)
if classification["decision"] == "page_oncall":
await workflow.execute_activity(page_oncall, alert, start_to_close_timeout=timedelta(seconds=10))
await workflow.wait_condition(lambda: self._acked, timeout=timedelta(hours=1))
if not self._acked:
await workflow.execute_activity(escalate_to_secondary, alert, start_to_close_timeout=timedelta(seconds=10))
elif classification["decision"] == "auto_remediate":
await workflow.execute_activity(
auto_remediate, alert, f"remediate-{alert['id']}",
start_to_close_timeout=timedelta(minutes=2),
retry_policy=workflow.RetryPolicy(maximum_attempts=2),
)
return {"alert_id": alert["id"], "decision": classification["decision"]}
Real framework choice looks like this in practice, and it is not a satisfying one-line answer. It is a composition decision, and the composition is the skill.
Mistakes that keep recurring
- Choosing by GitHub stars. Popularity indicates community support and how many answers exist online. It says nothing about whether the core execution model matches the durability and determinism the workload needs.
- Letting the prototype framework become permanent. The prototype that just worked quietly turns into the production system, because rewriting orchestration feels like it adds no features. It adds reliability, which stays invisible until it is missing.
- Starting with a heavyweight workflow engine. The opposite failure is equally real: a team reads too many durable-execution posts and deploys Temporal behind a one-shot question-answering bot that was never going to outlive a process restart. The tool should match the workload in both directions.
- Treating an agent framework as the full answer. It covers the reasoning and tool-use layer and rarely the durability, idempotency and observability layers, which is why the axes above sit mostly outside any framework's marketing copy.
The real answer
There is no best agent framework, and acting as though there were is why so many of these systems get re-architected eighteen months in. There is a best framework for a workload with a given set of six properties. The discipline to build on a team is naming those properties before touching a framework at all, and once that is done the choice usually makes itself. When it is skipped, the orchestration layer gets rewritten within a year whichever framework was picked, because the mismatch was never about the framework. It came from not asking what was being built before deciding how to build it.