Un framework agentique se choisit en une après-midi et se paie pendant deux ans. Les comparatifs disponibles alignent des colonnes de fonctionnalités recopiées des README, et périment au rythme des releases. Ils ne répondent jamais aux deux questions qui comptent le jour de la mise en production : qu'est-ce que le framework décide à ta place, et qu'est-ce qui te reste le jour où tu l'enlèves ?
Cet article part de la production plutôt que des README : 7 besoins relevés sur des systèmes en service, 12 frameworks installés pour les y confronter, et aucune case cochée sans que le code ait tourné.
Les 12 frameworks
Les 12 frameworks se rangent en trois familles.
- Les SDK d'hyperscalers :
strands-agentschez AWS,google-adk,openai-agentsetsemantic-kernelchez Microsoft, sortis en dix-huit mois par les quatre cloud providers. - Les indépendants :
langchain-coreetlanggraph,llama-index-core,crewai,agno,pydantic-ai, plus anciens pour la plupart. - Les outsiders :
haystack-ai, le seul européen, etsmolagentspour le très léger.
Un chiffre à garder en tête, relevé le 22 août 2026 : entre LangChain, premier au nombre d'étoiles avec 144 786, et Pydantic AI, onzième sur 12 avec 19 444, l'écart est d'un facteur sept. Ce classement mesure l'attention, pas l'aptitude à la production. Il est ici pour être contredit à la fin.
Pourquoi tout est mesuré avec Mistral
Il en fallait un seul, sinon on compare des routes réseau différentes en croyant comparer des frameworks. C'est Mistral parce que c'est le provider sur lequel je travaille le plus, pour des raisons de souveraineté. Le choix n'est pas neutre : tous les frameworks n'y accèdent pas de la même façon. Certains ont un extra dédié, d'autres un paquet d'intégration, d'autres encore n'ont aucune route native et doivent passer par la gateway LiteLLM.
Les 7 besoins
- Persistance des conversations : reprendre une conversation après un redémarrage, et choisir soi-même la base, le format et la rétention.
- OpenTelemetry : envoyer les traces dans le collecteur qu'on a déjà, sans client propriétaire imposé.
- Poids et surface d'audit : ce qui part dans l'image à chaque déploiement, et ce qu'il faut suivre en avis de sécurité.
- Retries : encaisser un 429 ou un 500 sans l'écrire soi-même.
- Tokens par exécution : savoir ce qu'a coûté chaque appel, au moment où il est dépensé.
- Asynchrone réel : ne pas bloquer l'event loop de l'application qui l'héberge.
- Sortie structurée typée : obtenir un objet validé, pas une chaîne à analyser.
Le protocole
Chaque framework part d'un environnement neuf sous Python 3.12, l'installation est jouée jusqu'au bout, et la capacité à appeler Mistral est vérifiée à l'import. Chaque framework est ensuite monté dans une application FastAPI minimale, parce qu'un framework agentique seul ne sert à rien, et que la vraie question est ce qu'il coûte en plus de l'application qui l'héberge.
Le modèle est remplacé par un serveur local qui imite l'API Mistral et répond en 300 ms fixes. Aucune clé d'API, aucun appel réseau, les mêmes chiffres sur n'importe quelle machine, et une latence constante sans laquelle rien de ce qui suit ne serait comparable.
Poids : ce que le framework ajoute à ton application
Un poids mesuré à part ne dit rien : un framework agentique n'est jamais déployé seul, il s'ajoute à une application qui a déjà ses dépendances. La mesure est donc une part : la fraction de l'image finale que le framework occupe une fois installé par-dessus un fastapi[standard] de 43 paquets et 36 Mo, sa route vers Mistral comprise.
Elle va de 36 % du disque pour Pydantic AI à 95 % pour CrewAI, dont les 672 Mo ajoutés réduisent l'application FastAPI à un détail dans sa propre image. Entre les deux, une progression continue : 45 % pour Agno, 49 et 51 % pour les deux LangChain, 62 % pour le SDK d'AWS, puis toute la seconde moitié au-dessus des trois quarts.
Le résultat que je n'attendais pas est ailleurs : la route vers le provider coûte souvent plus cher que le framework. Cinq des 12 n'ont aucune intégration Mistral et passent par la gateway LiteLLM : 150 Mo et 37 paquets, contre 11 Mo pour le SDK officiel, avec les clients d'OpenAI et d'AWS installés au passage pour appeler un modèle français. Pour ces cinq-là, le choix du framework n'a presque aucune influence sur le poids.
Observabilité : qui décide où vont tes traces
La question n'est pas « le framework trace-t-il », mais qui choisit la destination. Trois postures se lisent dans l'arbre de dépendances.
Quatre frameworks installent l'API OpenTelemetry et rien de plus : Pydantic AI, Agno, LlamaIndex, openai-agents. C'est la bonne réponse : une bibliothèque dépend de l'API, une application choisit son SDK, son exportateur et son échantillonnage. Quatre autres embarquent le SDK complet : Strands, l'ADK de Google, Semantic Kernel, CrewAI. Ça t'impose donc leur version.
Les quatre derniers n'installent aucun OpenTelemetry : langchain-core, langgraph, haystack-ai, crewai. Rien à câbler, mais rien non plus à observer sans y ajouter toi-même une bibliothèque de tracing.
Asynchrone : qui bloque la boucle de ton application
Ce critère ne se voit ni dans un README ni au prototype. Il apparaît le jour où l'API sert plusieurs utilisateurs à la fois, et il se paie en requêtes par seconde.
Le montage tient en trois pièces : une application FastAPI, un seul worker uvicorn, et un modèle qui répond en 300 ms fixes. Un seul worker, parce que plusieurs processus masqueraient un framework bloquant. Vingt requêtes sont ensuite envoyées en même temps, ce qui donne deux résultats possibles selon ce que fait le framework pendant l'attente :
- s'il rend la main pendant l'appel au modèle, les vingt attentes se superposent et l'ensemble prend 0,30 s, le temps d'un seul appel ;
- s'il bloque la boucle pendant l'appel, les vingt attentes se mettent en file et l'ensemble prend 6 s, 20 fois ce temps.
10 frameworks sur 12 tiennent la charge, groupés entre 0,35 et 0,64 s. L'écart entre eux ne départage personne. Strands ferme la marche à 0,64 s, seul du lot à ne parler que le streaming : son API de modèle est un générateur asynchrone, sans chemin non-streaming, et réassembler un stream coûte ce qu'il coûte.
Les deux restants, CrewAI et smolagents, sérialisent exactement : 20 fois le délai du modèle, à la milliseconde près.
Les deux « lents » ne le sont pas. La faute n'est pas au framework, elle est à la façon de le brancher sur FastAPI. Ni l'un ni l'autre n'expose d'API asynchrone ; les appeler depuis une route déclarée async def exécute donc leur code bloquant directement dans la boucle. Il suffit de déclarer la route def à la place : FastAPI exécute alors les routes synchrones dans un thread pool, hors de la boucle :
| Framework | Route async def |
Route def |
|---|---|---|
smolagents |
6,23 s | 0,34 s |
crewai |
6,21 s | 0,36 s |
Les mêmes frameworks, la même charge, 17 fois plus rapides pour un mot-clé retiré : ce n'est pas le framework qui décide de ton débit, c'est l'accord entre son API et la façon dont tu déclares ta route. Un framework synchrone dans une route synchrone se comporte très bien : smolagents devient même le plus rapide du tableau.
Le thread pool a toutefois un plafond que la boucle n'a pas : quarante threads par défaut, donc quarante requêtes traitées à la fois, les suivantes attendent qu'un thread se libère. À cent requêtes simultanées, smolagents passe de 0,34 à 1,07 s, deux vagues et demie sur ce plafond, quand Pydantic AI passe de 0,47 à 0,85 s. Le contournement fonctionne, il ne monte simplement pas aussi haut.
Un mot sur Agno, dont les premiers passages étaient illisibles : 1,07 s, puis 1,89 s, puis 5,92 s là où les autres reproduisent à quelques centièmes près. La cause n'était pas dans le code de l'agent mais dans un appel réseau que le framework passe à son propre serveur à chaque exécution. Coupé par telemetry=False, il devient l'un des plus réguliers du comparatif. L'histoire complète, et le balayage de télémétrie qu'elle a déclenché, sont dans l'article sur la souveraineté.
Retries : que se passe-t-il sur un 429
Un provider de modèles renvoie des 429 et des 5xx. Ce n'est pas un incident, c'est le fonctionnement normal : limites de débit, coupures de connexion, incidents passagers. La question est ce que le framework en fait quand ça arrive une fois.
Le serveur mock sait maintenant échouer sur commande : on arme un 429, on envoie une requête, on regarde ce qui revient. Trois comportements apparaissent, et ils sont très inégaux.
| Framework | Sur un 429 | Temps |
|---|---|---|
haystack-ai |
réessaie et réussit | 0,77 s |
smolagents |
réessaie et réussit | 183 s |
agno |
avale l'erreur, répond 200 | 0,01 s |
pydantic-ai |
propage | 0,02 s |
langchain-core |
propage | 0,01 s |
langgraph |
propage | 0,02 s |
llama-index-core |
propage | 0,01 s |
openai-agents |
propage | 0,03 s |
semantic-kernel |
propage | 0,02 s |
google-adk |
propage | 0,03 s |
strands-agents |
propage | 0,03 s |
crewai |
propage | 0,05 s |
Propager est la bonne réponse par défaut
Neuf frameworks laissent l'erreur remonter. C'est le comportement correct : la politique de retry dépend de ton contexte : un batch peut attendre une minute, une API qui répond à un humain ne le peut pas. Un framework qui déciderait à ta place choisirait mal la moitié du temps.
Encore faut-il le savoir, parce que rien ne le signale. Pydantic AI est le seul du lot à le documenter franchement, avec un extra retries à installer et sa propre phrase : « nothing retries at this layer unless you install a retrying transport ». Les huit autres te laissent le découvrir en production.
Réessayer, mais pendant trois minutes
Haystack réessaie proprement : 0,77 s au total, soit le 429, une temporisation courte, puis l'appel qui aboutit. C'est ce qu'on attend.
smolagents réessaie aussi, via LiteLLM. Mais avec le backoff exponentiel par défaut de LiteLLM, chaque nouvelle tentative attendant plus longtemps que la précédente, l'ensemble des essais programmés totalise 183 secondes pour absorber une seule erreur passagère. Aucun appelant HTTP n'attend trois minutes une réponse : le client aura déjà coupé la connexion et affiché une erreur à l'utilisateur bien avant que smolagents n'ait fini de réessayer dans le vide.
Ma première mesure avait d'ailleurs conclu à un échec : mon propre client abandonnait à 120 secondes, avant la fin des 183. Un retry plus long que le timeout de l'appelant ne sert à rien : c'est une fuite de ressources.
Le pire cas : répondre 200 avec une erreur dedans
Agno ne propage pas et ne réessaie pas. Il répond en dix millisecondes, avec le code HTTP 200, et le corps suivant :
{"out": "API error occurred: "}L'erreur du provider est devenue la réponse de l'agent. Du point de vue de l'appelant, l'appel a réussi. La supervision voit un 200, le taux d'erreur reste à zéro, les alertes ne partent pas, et l'utilisateur lit « API error occurred: », sans rapport avec sa question et sans même la fin de la phrase. Si cette réponse est stockée, elle entre dans l'historique de conversation comme un tour valide.
C'est le seul comportement dangereux du comparatif : les neuf qui propagent te donnent du travail, celui-ci te donne une fausse certitude.
Consommation de tokens : tous l'exposent, aucun de la même façon
Connaître le coût d'un appel au moment où il est dépensé, et non dans la facture du mois, est la condition pour arbitrer quoi que ce soit : raccourcir un contexte, changer de modèle, plafonner un usage.
Bonne nouvelle : les 12 exposent les tokens d'entrée et de sortie. Le serveur mock renvoie toujours les mêmes valeurs : 11 en entrée, 1 en sortie, 12 au total, et tous les rendent lisibles. Le critère ne départage donc pas sur la présence, mais sur l'endroit et sur la forme.
| Framework | Où | Total fourni |
|---|---|---|
pydantic-ai |
result.usage |
oui |
langchain-core, langgraph |
message.usage_metadata |
oui |
agno |
RunMetrics |
oui, plus un champ cost |
openai-agents |
result.context_wrapper.usage |
oui |
llama-index-core |
response.raw.usage |
oui |
haystack-ai |
reply.meta['usage'] |
oui |
smolagents |
TokenUsage sur la réponse |
oui |
strands-agents |
événement metadata du stream |
oui |
google-adk |
response.usage_metadata |
oui |
crewai |
llm.get_token_usage_summary() |
oui, mais cumulé, pas par appel |
semantic-kernel |
content.metadata['usage'] |
non : entrée et sortie seulement |
La granularité varie beaucoup. langchain-core rend trois entiers. Agno rend une structure complète : cache en lecture et en écriture, tokens de raisonnement, temps jusqu'au premier token, durée totale, détail par modèle, et un champ cost. Google et CrewAI sont dans la même veine. Pour un tableau de bord de coûts, cette différence pèse plus que la présence du chiffre.
Semantic Kernel est le seul à ne pas fournir le total. Il donne l'entrée et la sortie, l'addition est à ta charge : un détail qui suffit à faire diverger deux tableaux de bord censés mesurer la même chose.
CrewAI est le seul à ne pas attacher le compte à la réponse. LLM.call rend une chaîne de caractères, point. Le compte vit à part, cumulé sur l'objet LLM, et se lit par get_token_usage_summary(). C'est cohérent avec sa conception : le coût s'observe au niveau d'un Crew, pas d'un appel. Mais pour facturer un appel précis à un utilisateur précis, tu dois tenir ta propre comptabilité.
Persistance : qui possède la table pour l'historique de conversation
C'est la première chose qui casse en production et la dernière qu'on regarde en choisissant. Un chatbot doit reprendre une conversation après un redémarrage, donc quelqu'un doit écrire les tours quelque part. La question n'est pas « le framework sait-il persister », mais qui décide de la forme, de la base et de la rétention.
Le test est un aller-retour complet, en quatre étapes :
- exécuter une première fois ;
- récupérer l'historique de la conversation ;
- le sérialiser en JSON, puis le relire depuis ces octets ;
- relancer une seconde exécution avec l'historique relu.
Deux familles apparaissent et un absent, smolagents, dont l'historique vit dans la mémoire de l'agent et que je n'ai pas soumis à l'aller-retour.
Sept te rendent les messages et s'arrêtent là
| Framework | Aller-retour JSON | Taille pour deux tours |
|---|---|---|
llama-index-core |
oui | 234 o |
openai-agents |
oui | 301 o |
langchain-core |
oui | 652 o |
semantic-kernel |
oui | 756 o |
pydantic-ai |
oui | 1060 o |
haystack-ai |
oui : ChatMessage.to_dict() |
— |
strands-agents |
oui : messages: list[Message] |
— |
Aucun n'impose de couche de stockage : la table est la tienne, avec ton moteur, ta rétention et ton chiffrement. Le jour où quelqu'un demande l'effacement de ses données, la réponse est une clause WHERE, pas un ticket chez un éditeur pour savoir ce qu'il a écrit, où, et pour combien de temps.
Le même échange pèse de 234 à 1060 octets selon le framework, un facteur 4,5. Ce n'est pas du gaspillage. Pydantic AI, le plus volumineux, enregistre par message le modèle, le provider, l'identifiant de réponse, l'horodatage, la raison d'arrêt et la consommation de tokens : de quoi reconstituer un incident six semaines plus tard, plutôt que le supposer. LlamaIndex, à l'autre bout, ne stocke que le rôle et le texte : 4 fois plus léger, et muet sur la provenance.
Quatre te proposent leur propre store
LangGraph a ses checkpoints, Google son service de sessions, CrewAI son stockage de mémoire, et Agno une couche de base de données qui compte vingt implémentations : Postgres, MySQL, SQLite, Redis, Mongo, DynamoDB, Firestore, ClickHouse, SurrealDB, Valkey, JSON sur disque…
Ce n'est pas un défaut en soi : le premier jour, une abstraction de stockage fait gagner du temps, et vingt connecteurs veulent dire qu'on trouvera le sien. Mais le schéma appartient au framework et sa migration suit ses versions. Ce que la table contient exactement est une question à poser à sa documentation, pas à son propre code.
Le VertexAiSessionService de Google range les conversations dans un service géré de Google Cloud : c'est la seule option du comparatif où l'historique quitte ton infrastructure par conception. Et le stockage de CrewAI est bâti sur des bases vectorielles : LanceDB et Qdrant, ce qui explique une partie de son poids : les 123 Mo de pyarrow et les 96 de lancedb.
Sortie structurée : un objet validé, ou une chaîne à analyser
Un agent qui rend du texte libre oblige à écrire un parser, et ce parser cassera sur la réponse que personne n'avait prévue. Rendre un objet typé et validé n'est pas du confort : c'est la frontière entre une fonction non fiable et le reste du programme.
Le test demande la même chose : un modèle Pydantic à deux champs, ville: str et population: int, et regarde ce qui revient. Le serveur mock répond dans la forme demandée, tool call ou response_format selon ce que le framework envoie.
| Framework | Ce qui revient |
|---|---|
pydantic-ai |
objet Ville validé |
langchain-core, langgraph |
objet Ville validé |
openai-agents |
objet Ville validé |
agno |
objet Ville validé |
llama-index-core |
objet Ville validé |
haystack-ai |
chaîne JSON |
semantic-kernel |
chaîne JSON |
smolagents |
chaîne JSON |
google-adk |
chaîne JSON |
strands-agents, crewai |
non mesuré |
Six frameworks rendent l'objet construit et validé : le type déclaré est celui qu'on récupère, et population est un entier Python. Quatre rendent la chaîne {"ville": "Paris", "population": 2} et laissent l'analyse et la validation à l'appelant, ce qui marche très bien tant qu'on n'oublie pas que le modèle peut rendre autre chose.
La différence se voit le jour où le modèle se trompe. Avec un objet validé, l'erreur survient au moment de la validation, avec un message qui nomme le champ fautif. Avec une chaîne, elle survient plus loin, dans le code qui croyait manipuler un entier.
Une idée reçue au passage
Les comparatifs répètent que LangGraph ferait les graphes et les autres des agents linéaires. C'est faux : neuf frameworks sur 12 exposent une primitive pour décrire un enchaînement avec des branches et des boucles : StateGraph chez LangGraph, pydantic-graph chez Pydantic AI, un workflow événementiel chez LlamaIndex, GraphBuilder chez Strands, ProcessBuilder chez Semantic Kernel, et ainsi de suite.
La synthèse
Le tableau compte 7 cases, une par besoin. Une case est cochée quand le framework y répond sans qu'on ait à réparer quoi que ce soit.
| Framework | Poids | OTel ouvert | Async | Erreurs | Tokens | Historique | Typé | Total |
|---|---|---|---|---|---|---|---|---|
pydantic-ai |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 7 |
langchain-core |
✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | 6 |
openai-agents |
— | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 6 |
llama-index-core |
— | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 6 |
strands-agents |
— | ✓ | ✓ | ✓ | ✓ | ✓ | ? | 6 |
langgraph |
✓ | — | ✓ | ✓ | ✓ | — | ✓ | 5 |
semantic-kernel |
— | ✓ | ✓ | ✓ | — | ✓ | — | 4 |
agno |
✓ | — | ✓ | ✗ | ✓ | — | ✓ | 4 |
haystack-ai |
— | — | ✓ | ✓ | ✓ | ✓ | — | 4 |
google-adk |
— | ✓ | ✓ | ✓ | ✓ | — | — | 4 |
crewai |
— | ✓ | — | ✓ | — | — | ? | 2 |
smolagents |
— | — | — | ✗ | ✓ | ✓ | — | 2 |
Poids : le framework occupe moins de 60 % de l'application. OTel ouvert : OpenTelemetry sans client d'éditeur en dépendance obligatoire. Erreurs : ni avalées, ni réessayées pendant trois minutes. Tokens : entrée, sortie et total, attachés à l'appel, ce qui exclut Semantic Kernel, qui ne donne pas le total, et CrewAI, qui ne les rend que cumulés. Historique : sérialisable sans passer par un store imposé.
Pydantic AI coche les 7. Il est aussi onzième sur 12 au nombre d'étoiles : 19 444 contre 144 786 pour LangChain, mais la popularité ne mesure pas l'aptitude à la production.
Trois recommandations
Tu construis une API qui sert des utilisateurs, ton équipe a déjà son observabilité. Prends Pydantic AI : c'est le plus léger des 12, il installe l'API OpenTelemetry sans le SDK, il laisse l'historique dans ta base et il rend une sortie typée. Installe pydantic-ai-slim avec le seul provider voulu, pas le meta-package. Si l'écosystème LangChain t'est utile, langchain-core seul est un choix défendable, mais langsmith arrivera dans ton arbre de dépendances.
Une contrainte d'entreprise t'attache à un cloud provider. Strands, le SDK d'AWS, est le mieux noté des quatre SDK d'hyperscalers : OpenTelemetry complet, historique en messages nus, erreurs propagées. Il est aussi le plus lent des asynchrones (0,64 s) parce qu'il ne parle que le streaming. L'ADK de Google perd deux cases dont une lourde : son VertexAiSessionService range tes conversations dans un service géré.
Tu choisis sous contrainte de souveraineté. Le cas se pose sous RGPD strict, sous DORA, ou dès que des données ne doivent pas quitter ton infrastructure. Il ne se décide pas sur la nationalité de l'éditeur mais sur les flux sortants, et quatre choses le tranchent : une route native vers un provider européen, une observabilité qui reste chez toi, un historique dans ta base, aucune télémétrie active par défaut. Pydantic AI et LlamaIndex sont les seuls à tout tenir. À écarter dans ce contexte : google-adk et son service de sessions géré, crewai et haystack-ai qui collectent par défaut, langchain-core et langgraph dont langsmith est obligatoire, agno tant qu'on n'a pas écrit telemetry=False.
Ce que je n'ai pas testé
Le tableau porte sur 7 besoins. Il en reste beaucoup, et les ignorer serait malhonnête.
Je n'ai mesuré aucun tool call réel, aucune chorégraphie multi-agents, aucun RAG intégré : trois domaines où CrewAI, LangGraph et LlamaIndex ont manifestement plus à offrir que ce classement ne le montre. Je n'ai pas non plus testé la reprise après panne, la validation humaine dans la boucle, ni MCP au-delà de son poids.
Je ne dis rien non plus de ce qui ne se mesure pas par un benchmark : documentation, réactivité des mainteneurs, stabilité des API sur deux ans, et ce sont pourtant des critères de production.
Restent les limites du montage : un modèle simulé à latence fixe, qui ne montre pas la variabilité réelle d'un provider ; des mesures de charge arrêtées à cent requêtes sur un seul worker macOS ; Strands et CrewAI absents sur la sortie structurée. Et une leçon de prudence : deux résultats suspects sur trois venaient de mon protocole, pas des outils.