Construire un agent d’IA local : modèles, mémoire, stockage et interface
Les cinq couches d’une pile d’agents IA locaux, avec comparaison d’Ollama, OpenClaw, Mem0 et Qdrant ou Chroma, ainsi que des correctifs pour les problèmes de JSON en mode réflexion et les incohérences de dimensions silencieuses.
Sources: Building Local AI Agents: A Practical Guide to Models, Memory, and Orchestration
Un agent d’IA local conserve le modèle sur votre propre matériel, agit pour vous et conserve le contexte d’une session à l’autre, sans envoyer de données vers une API externe. Un chatbot basique répond puis oublie. Un agent, quant à lui, peut raisonner en plusieurs étapes, utiliser des outils et accumuler des connaissances au fil du temps. Le fait de le faire fonctionner localement vous offre un assistant sans les compromis en matière de confidentialité ni les frais par appel des services hébergés.
Cinq couches doivent fonctionner ensemble pour en créer un : une couche LLM qui effectue les inférences sur votre machine, un cadre d’agent qui dirige et exécute les actions, une couche de mémoire qui améliore progressivement les performances de l’agent, une couche de stockage qui conserve ce qu’il a collecté, et une couche d’interface adaptée à votre façon de travailler. Ci-dessous, chaque couche présente ses options, ses compromis et une recommandation, afin que vous puissiez assembler un ensemble adapté à votre situation.
Couche 1 : le modèle
C’est le moteur. Le modèle détecte l’intention, génère du code et extrait des données structurées sur lesquelles repose le reste de l’infrastructure, et tout le reste est conçu en fonction de ses capacités fiables. Trois outils dominent l’inférence locale.
Ollama est le moyen le plus simple d’y accéder. Une commande suffit pour l’installer, une autre pour télécharger un modèle, et vous disposez immédiatement d’une API compatible OpenAI sur votre ordinateur local. Il gère les modèles, choisit la quantification et définit les fenêtres de contexte sans nécessiter de configuration. Il n’a pas été conçu pour un trafic en production et convient mieux à un assistant personnel ou à un outil de développement.
ollama pull qwen3:8b
ollama pull nomic-embed-text
llama.cpp se situe à un niveau inférieur. Il s’agit d’un moteur en C++ qui exécute directement des modèles GGUF quantifiés, sans daemon ni couche HTTP. Sa faible consommation mémoire en fait un choix idéal pour les appareils edge tels que le Raspberry Pi ou Linux embarqué, où Ollama pèserait trop lourd. Le inconvénient réside dans l’absence de gestion intégrée des modèles et la nécessité d’une configuration manuelle plus importante.
vLLM est l’option destinée à la production. Il utilise PagedAttention et le lotage continu, des techniques propres aux inférences à grande échelle dans le cloud. Si vous servez de nombreux utilisateurs ou avez des besoins importants en performances, c’est la solution adaptée, bien qu’il requière une infrastructure GPU adéquate et offre une meilleure scalabilité que les deux autres.
Pour un assistant local destiné à un seul développeur, Ollama constitue le choix par défaut logique, car la simplicité prime sur la vitesse de traitement. Le modèle qwen3:8b mérite d’être mentionné : il fournit des données JSON fiables pour la détection des intentions, génère du code de manière propre et tient dans 8 Go de VRAM. Une particularité à connaître est qu’il enveloppe ses réponses dans des blocs <think>…</think> avant le contenu réel, ce qui empêche tout analyseur JSON ultérieur de fonctionner correctement sur la réponse brute. Un petit wrapper qui définit think: False partout résout ce problème :
_orig_ollama_chat = ollama.chat
def _no_think_chat(*args, **kwargs):
opts = kwargs.get("options") or {}
if isinstance(opts, dict):
opts.setdefault("think", False)
kwargs["options"] = opts
return _orig_ollama_chat(*args, **kwargs)
ollama.chat = _no_think_chat
Puisqu’il utilise setdefault, les appels individuels peuvent toujours réactiver le mode de réflexion lorsqu’ils le souhaitent. Pour l’extraction JSON et la génération de code au sein d’une chaîne d’outils, le mode de réflexion ajoute du retard sans améliorer la qualité.
Niveau 2 : le framework d’agents
Le modèle génère du texte. Le framework décide quoi faire de ce texte et quelle action entreprendre ensuite, en gérant la classification des intentions, le routage et l’exécution.
OpenClaw est axé sur les outils et fonctionne au niveau du système de fichiers. Chaque action correspond à une commande envoyée à une compétence déclarée, telle que créer un fichier, écrire du code, ouvrir un dossier ou exécuter une commande shell. Vous rédigez un SKILL.md qui indique le nom de la compétence ainsi que la manière dont elle est déclenchée, et c’est le framework qui s’occupe du routage :
- -
name: local-ai-assistant
description: Local AI coding assistant with persistent memory
command-dispatch: tool
command-tool: exec
command-arg-mode: raw
-
Lorsque la plupart des opérations consistent en des lectures/écritures de fichiers, comme c’est le cas dans un assistant de codage, ce modèle s’adapte naturellement. Les lignes command-dispatch: tool et command-tool: exec indiquent à OpenClaw d’exécuter des scripts Python lorsque l’on sollicite une compétence, de sorte que le travail sur les fichiers passe par ce framework. Son limite réside dans le fait qu’il n’est pas conçu pour des chaînes de raisonnement longues et multi-étapes ni pour coordonner plusieurs agents.
LangGraph adopte une approche inverse. Vous définissez un graphe d’état explicite dans lequel les nœuds représentent des appels de modèles ou des actions, les arêtes désignent les transitions et le framework gère l’état entre les différentes étapes. Le prix à payer est une conception préalable : vous devez définir le schéma des états et la structure du graphe avant toute logique métier. Pour une utilisation simple d’outils, c’est un surdimensionnement, mais pour des pipelines complexes multi-étapes, cela permet d’obtenir un enregistrement vérifiable de ce qui s’est passé à chaque étape.
CrewAI va encore un cran plus loin. Vous décrivez les agents avec des rôles et des tâches accompagnées de descriptions, et le framework détermine l’ordre d’exécution ainsi que la communication entre ces agents. C’est le moyen le plus rapide pour obtenir un prototype multi-agents fonctionnel, bien que l’abstraction rende le débogage des pannes plus difficile.
Quelle que soit votre choix, la classification de l’intention s’effectue en premier pour chaque message reçu. La combinaison d’une classification basée sur des modèles et d’un mécanisme de secours à base de regex permet de gérer les pannes mieux que n’importe quel autre approche utilisée seule :
def detect_intent(message: str) -> dict:
try:
resp = ollama.chat(
model=OLLAMA_CHAT_MODEL,
messages=[{"role": "user", "content": INTENT_PROMPT.format(message=message)}],
options={"temperature": 0, "num_predict": 1024},
)
return extract_json(resp["message"]["content"])
except Exception:
return keyword_intent_fallback(message)
Le mécanisme de secours permet de gérer les cas courants où le modèle renvoie des résultats mal formatés. Avec la réflexion désactivée et une température à 0, la plupart des requêtes sont traitées par le chemin du modèle, mais c’est le mécanisme de secours qui permet à l’agent de continuer à fonctionner.
Niveau 3 : mémoire
La mémoire distingue un agent qui s’adapte à vous d’un autre qui considère chaque conversation comme la première. Il apprend vos conventions, préférences et le contexte du projet, puis les applique sans avoir besoin d’instructions.
Les solutions habituelles se trouvent à l’intérieur de la fenêtre de contexte : un fichier PREFERENCES.md lu au démarrage, une longue demande système ou un fichier CONTEXT.md. Elles présentent toutes une même faiblesse : à l’intérieur de cette fenêtre, elles sont compressées, soumises à des limites de tokens et entraînent une réinitialisation de la session. Ce dont un agent a vraiment besoin, c’est d’une mémoire en dehors de cette fenêtre, stockée de manière durable, consultable selon son sens lorsque c’est pertinent et mise à jour au fur et à mesure que les faits changent. Trois approches courantes répondent à des besoins différents.
RAG de base est la plus répandue et la plus accessible pour les débutants. Il s’agit de diviser les documents ou l’historique des conversations en blocs, d’incorporer ces blocs et de récupérer les k blocs les plus similaires lorsqu’une requête arrive, sans nécessiter de configuration complexe. Sa faiblesse réside dans le fait que la mémoire se transforme en un amas de documents, sans distinction entre ceux qui méritent d’être conservés à long terme et ceux liés à des questions temporaires.
Mémoire liée à la session, comme par exemple ConversationBufferMemory et ConversationSummaryMemory de LangChain, assure la cohérence au sein d’une session. L’agent n’oubliera pas ce que vous avez dit il y a cinq messages, et les résumés empêchent la fenêtre de s’emplir. Cependant, cette mémoire n’est pas conservée après une redémarrage, ce qui constitue le principal défaut pour un assistant de programmation.
Mémoire persistante structurée, comme dans Mem0, traite la mémoire comme des données de première classe et non comme du texte brut. Au lieu de messages textuels tels quels, le modèle extrait des faits discrets qui sont intégrés et stockés dans une base de données vectorielle. Lors de la recherche, la requête est intégrée et des faits sémantiquement similaires sont retournés, même si le nouveau message n’utilise pas les mêmes mots. Les faits stockés perdurent entre les sessions, peuvent être fusionnés en cas de duplication et sont réécrits lorsque la réalité évolue. Mem0 prend en charge les modèles locaux, ce qui facilite la connexion à une stack basée sur Ollama.
Prenons un développeur qui mentionne qu’il aime pytest. Un système RAG classique conserve la phrase brute. Un système structuré extrait et stocke la préférence elle-même. La prochaine fois que vous demandez une fonction avec des tests, la mémoire renvoie "l’utilisateur préfère pytest", même si le message ne mentionne jamais de framework de test.
Pour intégrer Mem0 dans une installation locale d’Ollama, il suffit de configurer brièvement en indiquant le modèle local, le modèle d’embedding local et un stockage de vecteurs :
from mem0 import Memory
config = {
"llm": {
"provider": "ollama",
"config": {"model": "qwen3:8b", "ollama_base_url": "http://localhost:11434"},
},
"embedder": {
"provider": "ollama",
"config": {"model": "nomic-embed-text", "ollama_base_url": "http://localhost:11434"},
},
"vector_store": {
"provider": "qdrant",
"config": {"host": "localhost", "port": 6333, "embedding_model_dims": 768},
},
}
memory = Memory.from_config(config)
memory.add("I always use type hints and pytest", user_id="dev")
results = memory.search("write a utility function", user_id="dev")
Le bloc llm dirige Mem0 vers qwen3:8b pour l’extraction de faits, transformant un message brut en préférences stockées de manière discrète plutôt qu’en texte littéral. Le bloc embedder utilise nomic-embed-text pour vectoriser ces faits au moment de l’écriture et pour intégrer les requêtes lors de la récupération ; memory.search() récupère ensuite par sens et non par mot-clé.
Une amélioration souvent sous-estimée pour toute approche de mémoire est de filtrer ce qui est stocké avant de l’enregistrer. Un classificateur léger exécuté avant chaque écriture permet de maintenir la mémoire propre et de garantir une haute qualité de récupération :
def _is_worth_storing(self, user_message: str) -> bool:
response = ollama.chat(
model=OLLAMA_CHAT_MODEL,
messages=[{"role": "user", "content": SMART_MEMORY_PROMPT.format(
user_message=user_message
)}],
options={"temperature": 0, "num_predict": 512},
)
data = self._extract_json_robust(response["message"]["content"])
return bool(data.get("worth_storing", False))
Sa requête fournit au modèle des exemples clairs de cette distinction :
HIGH-VALUE (worth storing):
- "I prefer TypeScript over JavaScript"
- "I use pytest, never unittest"
- "Always use Google-style docstrings"
LOW-VALUE (discard):
- "What does enumerate() do?"
- "Write a retry decorator"
- "Thanks"
En sautant le filtre, la base de données vectorielle se remplit progressivement de requêtes à usage unique qui affaiblissent la qualité des recherches.
Niveau 4 : stockage
Le niveau mémoire décide de ce qui doit être mémorisé, et le stockage correspond à l’endroit où ces données sont conservées sur disque.
FAISS est une bibliothèque fonctionnant en interne : rapide, bien testée et ne dépendant d’aucune infrastructure externe. Elle peut être intégrée dans votre processus Python et exécutée en mémoire, ou bien serialisée pour être stockée sur disque. Ses limites sont d’ordre opérationnel : la persistance nécessite des appels explicites de serialisation, et il n’existe ni API HTTP ni mécanisme de réplication intégré. Cela convient aux pipelines de recherche et aux charges de travail par lots, mais est peu adapté à un assistant en exécution continue qui redémarre fréquemment.
Chroma représente la base de données vectorielle la plus simple que puisse choisir un développeur. Elle démarre en tant que processus Python local ou s’exécute intégrée dans son propre code, en enregistrant automatiquement les données dans un répertoire local. Installez-la avec :
pip install chromadb
Des milliers de faits mémorisés dans un assistant personnel relèvent pleinement de la zone de confort de Chroma. Cela élimine complètement la dépendance envers des infrastructures, au détriment d’un filtrage moins poussé que celui de Qdrant.
Qdrant fonctionne en tant que service complet, avec des API REST et gRPC intégrales, un filtrage des données et des vecteurs nommés. Il est conçu pour des déploiements impliquant des millions de vecteurs et des requêtes simultanées. Il enregistre automatiquement les données sur disque et survit aux redémarrages de Docker sans nécessiter de traitement spécial :
# Qdrant via Docker
docker run -d - name qdrant-local -p 6333:6333 \
-v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant
Un détail de configuration peut provoquer des problèmes en silence : la dimension d’incorporation dans la configuration du stockage de vecteurs doit correspondre exactement à la dimension de sortie de votre modèle d’incorporation. Le modèle nomic-embed-text génère, par exemple, des vecteurs de 768 dimensions. Si vous changez le modèle d’incorporation sans mettre à jour cette valeur, les insertions échoueront sans erreur visible.
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": "coding_assistant",
"host": "localhost",
"port": 6333,
"embedding_model_dims": 768, # must match your embedding model exactly
},
}
Chroma constitue un remplacement direct si vous souhaitez abandonner Docker : il suffit de modifier le fournisseur vector_store dans la configuration, le reste de la stack restant inchangé.
Couche 5 : l’interface
La couche la plus externe détermine la manière dont vous communiquez avec l’agent, et le choix approprié dépend de sa position dans votre flux de travail.
- UI web. Le choix le plus flexible. Il s’exécute dans un navigateur, affiche du markdown, du code mis en évidence ainsi que des sections rétractables, et fonctionne sur n’importe quel système d’exploitation sans installation. Un assistant de codage peut afficher en même temps le fichier actuel, le code généré et l’état de la mémoire de l’agent sur une seule interface.
Comment ces options s’articulent-elles ?
Chaque couche restreint ses voisines. Le modèle choisit la forme de l’API que le framework utilise, l’approche mémoire détermine quel type d’stockage doit être pris en charge, et l’interface décide de l’importance du formatage des résultats.
Pour un assistant de codage local destiné à un seul développeur, cette pile minimise les frictions tout en permettant de remplacer chaque couche :
Interface: Web UI on localhost
│
Framework: OpenClaw - tool-dispatch, SKILL.md routing
│
LLM: Ollama for qwen3:8b
(think mode disabled for JSON reliability)
│
Memory: Structured persistent memory with value filtering
Embeddings via nomic-embed-text
│
Storage: Chroma (zero infrastructure) or Qdrant (more robust)
Puisque chaque couche peut être remplacée indépendamment, commencez par l’option la plus simple à chaque niveau et mettez à jour progressivement en fonction des besoins. Passer de Chroma à Qdrant ou d’Ollama à vLLM ne nécessite pas de reconstruire quoi que ce soit au-dessus ou en dessous. Si une couche mérite un investissement précoce, c’est bien la mémoire. Un modèle local modeste doté d’une couche de mémoire persistante bien conçue surpasse de loin un modèle plus volumineux qui doit tout reconstituer à chaque session.