Accueil / Articles

Choisir un cadre d’agent en fonction de la forme de la charge de travail : six axes et une voie de décision

Choisissez un cadre d’agent en fonction de la forme de la charge de travail : LangGraph, CrewAI, AutoGen, Temporal ou aucun, évalués en fonction du déterminisme, de la durée de vie, de l’idempotence, de l’audit et du coût.

Éditeur : REACTAPP.TOP11 min de lecture2439 mots

Sources: Your AI Agent Framework Is Probably the Wrong One Here’s How to Actually Choose

Poser la question de savoir quel framework d’agent utiliser est la mauvaise question de départ. Il vaut mieux se demander quelle est la forme de la charge de travail, car le framework en découle et ne doit pas être choisi de manière isolée à partir de posts de benchmark. Les équipes qui prennent leur décision dès la première semaine en se basant sur un fil de discussion sur les réseaux sociaux passent souvent les mois trois à six à découvrir que les hypothèses de base du framework ne correspondent pas à ce qu’elles construisent. Le framework est rarement en cause. Personne n’a défini les propriétés requises par la charge de travail avant de choisir l’outil censé les fournir.

Plutôt que de comparer les fonctionnalités, cette approche définit six axes qui déterminent l’adéquation, montre à quoi ressemble chacun d’eux dans le code, puis construit un chemin de décision. Un exemple concret est présenté : un agent de tri des alertes d’infrastructure. Il est réaliste, simple et prend en compte tous les axes. Cet agent doit déterminer si une alerte représente un véritable problème, agir en contactant quelqu’un ou en corrigeant automatiquement la situation, parfois attendre des heures une réponse humaine, et ne jamais contacter deux fois un ingénieur de garde à 4 heures du matin simplement parce qu’une tentative de contact a déjà eu lieu.

Premier axe : dans quelle mesure le déroulement doit-il être déterministe ?

Cet axe est omis pour l’instant et sera abordé plus tard. Le test pertinent consiste à se demander si quelqu’un est affecté lorsque la même entrée emprunte un chemin de code différent 2 % du temps. Certaines branches tolèrent des variations, comme le choix du ton d’un e-mail de résumé.

# 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
    )

D’autres non, comme la décision de réveiller une personne à 4 heures du matin ou d’appliquer une correction automatique. Cette décision doit être prise par du code et non par une demande utilisateur.

# 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"

Lorsque les décisions importantes au sein d’un flux de travail ressemblent à une fonction déterministe et auditable, que l’on pourrait présenter sous forme de code simple à un auditeur de conformité, le framework doit isoler cette logique du LLM. Les arêtes conditionnelles de LangGraph conviennent parfaitement à cela, car la fonction de routage est écrite en Python classique et le modèle ne traite que les parties qui lui sont délibérément transmises, comme la classification ou la rédaction.

CrewAI et AutoGen fonctionnent à l’inverse, en laissant le raisonnement propre de l’agent décider de ce qui se passe ensuite. C’est un avantage lorsque la flexibilité créative est l’objectif, mais un inconvénient lorsque cette flexibilité signifie parfois ignorer les contrôles de conformité. Si la plupart des branches importantes doivent être déterministes, cette seule contrainte élimine déjà une grande partie des solutions possibles dans ce domaine.

Deuxième axe : combien de temps une unité de travail existe-t-elle ?

Une unité à durée de vie courte débute et se termine au sein d’une seule requête HTTP. C’est la zone où aucun framework n’est nécessaire, car son utilisation ne ferait qu’ajouter une charge inutile.

# 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)

Une unité à longue durée de vie est différente. Une alerte peut rester en attente d’une confirmation humaine pendant six heures tandis que l’ingénieur de garde dort, puis reprend son traitement après qu’un déploiement ait remplacé les pods qui la soutenaient.

# 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
        ...

Si le traitement s’achève de manière fiable en quelques secondes et reste entièrement dans la mémoire d’un seul processus, la question relative au framework est probablement surestimée. Une simple fonction asynchrone dotée d’un décorateur de tentative de réexécution est plus facile à maintenir que n’importe quel framework, car rien n’a besoin d’être orchestré ; en revanche, apprendre le modèle d’état de LangGraph ou mettre en œuvre un cluster Temporal pour un point de terminaison de synthèse sans état représente un coût sans bénéfice réel.

Lorsqu’une unité de travail peut survivre à un processus, car elle attend une intervention humaine, un job par lot ou les SLA d’une autre équipe, une exécution durable, mesurée en heures, devient nécessaire. Aucun des trois frameworks populaires d’orchestration de LLM n’a été conçu pour servir de couche de durabilité. Cet axe oriente donc vers des solutions comme Temporal, ou au moins vers un système personnalisé reposant sur un état persisté ainsi que une file d’attente de tâches permettant la reprise des étapes interrompues.

Troisième axe : que se passe-t-il si une étape est exécutée deux fois ?

Les équipes trouvent généralement ce problème lors de l’analyse d’un incident. La première version présentée ci-dessous semble fonctionner correctement en démonstration, mais elle constitue un incident imminent, car une tentative répétée de l’activité redémarre à nouveau le service.

# 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?

La deuxième version est idempotente par 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

La clé d’idempotence doit provenir d’un élément stable dans les données métier, comme l’ID de l’alerte, et jamais d’un UUID généré à chaque tentative, sinon les tentatives de réessai cessent d’être fiables. Cela relève de la fonctionnalité de l’outil et non de tout framework, mais cela influence les frameworks qui sont acceptables, car certains facilitent l’oubli du fait qu’un réessai a eu lieu. L’exécution basée sur des conversations d’AutoGen ne fournit aucun signal structurel indiquant qu’une appel à l’outil correspond à un réessai et non à une décision nouvelle. Du point de vue de la transcription, l’agent a simplement décidé d’appeler à nouveau l’outil, et il n’y a aucun suivi pour déterminer s’il s’agit d’un réessai ou d’une action redondante.

Lorsque les effets secondaires sont coûteux, dangereux, ou impliquent des changements financiers ou infrastructurels, la configuration nécessite un framework, ou une couche personnalisée en dessous, qui traite le fait que l’effet secondaire a déjà eu lieu comme une question prioritaire et non comme quelque chose déduit d’un journal de conversation.

Quatrième axe : qui lit la décision ultérieurement, et comment ?

Un rapport d’audit indépendant du cadre utilisé est ce qui importe lors d’une analyse post-mortem, quel que soit le mécanisme ayant orchestré les étapes.

# 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))

Chaque framework permet de journaliser des informations. La question est de savoir si sa structure génère naturellement un historique de décisions clair et consultable, ou s’il faut lutter contre les abstractions pour y parvenir. Un objet d’état LangGraph passe par des nœuds nommés, ce qui permet de demander quel était l’état juste avant une décision de routage en effectuant une requête directe sur un TypedDict qui n’a jamais été transformé en texte libre. Une conversation AutoGen doit être analysée pour reconstituer les mêmes faits, car son format de mémoire natif est en prose et non en état structuré. Là où des obligations de conformité existent, ou où une équipe d’audit ou une pratique de bilans approfondis sont en place, cet axe mérite plus d’attention que ce que suggèrent les supports marketing.

Quatrième axe : qu’est-ce qui limite la vitesse de l’équipe ?

Ce critère apparaît rarement dans les tableaux de comparaison techniques, mais il décide souvent du résultat. Une startup de quatre personnes qui cherche à savoir si des agents peuvent effectuer le travail ne devrait pas passer trois semaines à étudier les contraintes de déterminisme de Temporal avant d’écrire la moindre logique métier.

# 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})

C’est un phénomène tout à fait normal dès la première semaine. L’erreur réside non pas dans le choix de CrewAI pour le prototype, mais dans le fait que l’équipe, six mois plus tard, n’a jamais réexaminé cette décision lorsque des responsabilités réelles et des changements d’infrastructure importants étaient en jeu, car le prototype fonctionnait déjà. Le choix d’un framework a une date d’expiration liée au nombre d’utilisations du système ; en notant cette date au moment du choix, on évite qu’elle ne soit définie par défaut.

Sixième axe : quelle latence et quel budget de coûts par décision ?

Chaque décision de routage qui implique une appel à un LLM est multipliée par le nombre de tours dans une conversation multi-agents. Avec des volumes d’alertes de plusieurs centaines par jour, voire des milliers en cas d’incident, la facture augmente rapidement.

# 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

Ce n’est pas tant un axe de framework qu’une discipline architecturale qui aide chacun d’eux. Les frameworks qui permettent d’entrelacer facilement du code déterministe peu coûteux avec des appels de modèles coûteux permettent d’économiser réellement de l’argent à grande échelle. LangGraph le fait, tout comme un moteur de workflow tel que Temporal qui encapsule un appel à un LLM en tant qu’activité unique. L’alternative consiste à utiliser un schéma natif où tout est acheminé via une nouvelle étape de conversation médiée par un autre modèle, ce qui correspond à la configuration par défaut d’AutoGen lorsque speaker_selection_method est réglé sur auto.

Un chemin de décision, pas un arbre de décision

Un organigramme qui produirait une seule réponse serait trompeur, car les régions se chevauchent. Le chemin présenté ci-dessous correspond davantage à la manière dont le choix est réellement effectué.

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.

Lorsqu’il est appliqué à l’agent de tri des alertes, on obtient un design en couches. Ce système résiste aux redémarrages et attend l’intervention humaine, ce qui exige une couche d’exécution fiable. Sa branche la plus importante est critique pour la sécurité : il s’agit du choix entre affichage d’alertes et auto-réparation, ce qui nécessite un routage explicite de type LangGraph à chaque étape. Il est bien au-delà de la phase de prototype dès qu’il intègre un système d’affichage d’alertes. Le temps devient alors l’élément central, avec une étape d’orchestration de LLM ciblée en tant qu’activité au sein de ce cadre.

@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"]}

Le choix d’un framework réel se présente ainsi en pratique, et ce n’est pas une réponse satisfaisante en une seule ligne. Il s’agit d’une décision de composition, et c’est justement cette capacité de composition qui constitue l’expertise requise.

Erreurs qui persistent

  • Choisir en fonction du nombre d’étoiles sur GitHub. La popularité indique le soutien de la communauté et le nombre de solutions disponibles en ligne. Elle ne dit rien sur le fait que le modèle d’exécution principal corresponde ou non à la fiabilité et au déterminisme nécessaires au travail à effectuer.
  • Permettre au framework de prototype de devenir permanent. Le prototype qui fonctionnait bien devient alors le système de production, car réécrire l’orchestration semble ne pas ajouter de fonctionnalités. En réalité, cela améliore la fiabilité, ce qui reste invisible tant qu’elle est présente.
  • Commencer avec un moteur de workflow de haute performance. L’échec inverse est tout aussi réel : une équipe lit trop d’articles sur l’exécution fiable et déploie Temporal derrière un bot de réponse à des questions ponctuelles qui n’était pas conçu pour survivre à une réinitialisation du processus. L’outil doit correspondre à la charge de travail dans les deux sens.
  • Considérer un framework d’agents comme la solution ultime. Il couvre la couche de raisonnement et d’utilisation des outils, mais rarement les couches de fiabilité, d’idempotence et d’observabilité, c’est pourquoi les critères mentionnés ci-dessus ne figurent presque jamais dans les textes de promotion des frameworks.
  • La véritable solution

    Il n’existe pas de framework d’agent optimal, et c’est en agissant comme s’il en existait un que tant de systèmes doivent être réarchitecturés après dix-huit mois. Il existe bien un framework idéal pour une charge de travail présentant un ensemble donné de six propriétés. La discipline à adopter au sein d’une équipe consiste à définir ces propriétés avant même de toucher à un framework, et une fois cela fait, le choix se fait généralement de lui-même. Lorsque cette étape est omise, la couche d’orchestration est réécrite au cours d’un an, quel que soit le framework choisi, car le problème ne venait jamais du framework lui-même. Il provenait du fait de ne pas avoir défini ce qui devait être construit avant de décider comment le construire.

    Lectures complémentaires

  • L’affinement est devenu abordable, mais les agents ont toujours besoin d’un mécanisme pour les poids — Comment transformer les leçons enregistrées d’un agent en poids grâce à la auto-distillation en temps réel, à quatre critères de promotion, et pourquoi répéter le cycle trois fois le rend inefficace.