Back to deblo
deblo

La boucle agentique : 24 outils IA dans un seul chat

Jusqu'à 10 itérations d'appels LLM, 24 outils de la génération de fichiers à l'exécution de code, tâches de fond de 30 minutes. Le cœur agentique de Deblo.ai.

Thales & Claude | March 30, 2026 13 min deblo
EN/ FR/ ES
debloagentiqueoutilsllmfunction-callingtâches-de-fond

Un chatbot répond aux questions. Un agent agit. Deblo est un agent.

Quand un élève envoie « Aide-moi à préparer mon devoir de maths sur les fractions », l'IA ne se contente pas de générer du texte. Elle peut générer un quiz interactif pour tester la compréhension, attribuer des crédits bonus pour les bonnes réponses, suivre les résultats d'exercices pour le tableau de bord de progression de l'élève, et générer un résumé PDF de la leçon. Quand un expert-comptable dit « Génère-moi le bilan SYSCOHADA pour cette entreprise et envoie-le par e-mail », l'IA recherche sur le web les normes SYSCOHADA actuelles, génère un tableur Excel avec les écritures comptables correctes, le convertit en PDF, et envoie les deux fichiers par e-mail -- le tout en un seul tour de conversation.

C'est la boucle agentique. Le LLM réfléchit, décide quels outils appeler, les exécute, lit les résultats, réfléchit à nouveau, et recommence -- jusqu'à 10 itérations par message utilisateur. C'est le sous-système le plus complexe de Deblo, et celui qui rend la plateforme fondamentalement différente d'un wrapper de chat autour d'une API.

La boucle

La fonction de streaming principale dans llm.py implémente la boucle agentique sous la forme d'une simple boucle for avec un plafond strict :

pythonasync def stream_chat_response(
    messages: list[dict],
    model: str,
    tools: list[dict] | None = None,
    tool_executor: ToolExecutor | None = None,
    total_timeout: int | None = None,
    # ... autres paramètres
) -> AsyncGenerator[dict | str, None]:

    MAX_TOOL_ITERATIONS = 10
    TOOL_TIMEOUT_SECONDS = 60
    overall_start = time.monotonic()

    full_messages = list(messages)

    for iteration in range(MAX_TOOL_ITERATIONS):
        # Vérification du timeout global
        if time.monotonic() - overall_start > TOTAL_TIMEOUT_SECONDS:
            yield "\n\nTemps d'exécution maximal atteint.\n"
            break

        # Construction de la requête pour OpenRouter
        current_request = {
            "model": model,
            "messages": full_messages,
            "stream": True,
        }
        if tools and iteration < MAX_TOOL_ITERATIONS - 1:
            current_request["tools"] = tools
        elif iteration >= MAX_TOOL_ITERATIONS - 1:
            current_request["tool_choice"] = "none"  # Forcer le texte à la dernière itération

        # Streamer la réponse LLM, accumuler contenu et appels d'outils
        collected_content = ""
        tool_calls_acc: dict[int, dict] = {}

        async for data in _raw_stream(current_request):
            # ... yield des tokens de contenu, accumulation des fragments d'appels d'outils

        # Pas d'appels d'outils ? On a terminé.
        if not tool_calls_acc or not tool_executor:
            break

        # Exécuter chaque outil, ajouter les résultats, reboucler vers le LLM
        for tc in tool_calls_list:
            result = await asyncio.wait_for(
                tool_executor(func_name, func_args, tool_call_id),
                timeout=TOOL_TIMEOUT_SECONDS,
            )
            # Tronquer les résultats verbeux pour éviter le débordement de contexte
            result = _truncate_tool_result(func_name, result)
            # Ajouter le résultat de l'outil à l'historique des messages
            full_messages.append({
                "role": "tool",
                "tool_call_id": tool_call_id,
                "content": json.dumps(result),
            })

Les décisions de conception clés intégrées dans cette boucle :

  1. Plafond strict de 10 itérations. À la 10ème itération, nous définissons tool_choice: "none" pour forcer le LLM à produire une réponse textuelle au lieu d'appeler d'autres outils. Sans cela, un modèle confus pourrait boucler indéfiniment.
  1. Timeout de 60 secondes par outil. Chaque exécution d'outil est enveloppée dans asyncio.wait_for. Si une recherche web se bloque, nous ne bloquons pas le flux entier.
  1. Timeout global de 180 secondes pour le streaming direct (ou jusqu'à 1 800 secondes pour les tâches de fond). Le timeout global attrape les cas où le LLM produit de nombreuses itérations rapides qui passent individuellement la vérification par outil.
  1. Troncature des résultats. Après le retour de chaque outil, nous tronquons son résultat avant de l'ajouter à l'historique des messages. C'est critique. Une recherche web peut retourner 50 Ko de contenu de page. La lecture d'un fichier peut retourner un document entier. Sans troncature, la fenêtre de contexte se remplit après 2-3 itérations et le LLM commence à halluciner ou à produire du contenu incohérent.

Les 24 outils

Les outils sont organisés par catégorie. Chaque outil est défini comme un schéma JSON compatible OpenRouter -- un objet type: "function" avec un nom, une description et un schéma de paramètres. Le LLM voit ces schémas et décide quand appeler chaque outil.

Génération de fichiers (6 outils) : - generate_xlsx -- Tableurs Excel (écritures comptables, budgets, bilans) - generate_pdf -- Documents PDF (rapports, mémorandums, notes d'audit) - generate_pptx -- Présentations PowerPoint (supports de formation, pitches) - generate_docx -- Documents Word (contrats, courriers, correspondance formelle) - generate_html -- Documents HTML enrichis (newsletters, contenu formaté) - generate_md -- Documents Markdown (notes, checklists, texte structuré)

Communication (4 outils) : - send_email_to_user -- Envoyer un e-mail à l'utilisateur courant (HTML, avec pièces jointes) - draft_email -- Créer un brouillon d'e-mail modifiable que l'utilisateur peut réviser et envoyer à n'importe qui - send_sms_to_user -- SMS à l'utilisateur courant (rappels urgents) - send_whatsapp_to_user -- Message WhatsApp à l'utilisateur courant (documents, récapitulatifs)

Fichiers et mémoire (4 outils) : - list_user_files -- Parcourir les fichiers uploadés et générés de l'utilisateur - read_user_file -- Lire le contenu d'un fichier spécifique par ID - search_user_files -- Recherche sémantique dans la bibliothèque de fichiers de l'utilisateur - save_memory -- Persister un fait sur l'utilisateur pour les futures conversations

Exécution de code (1 outil) : - bash_execute -- Exécuter des commandes shell dans un sous-processus sandboxé (timeout 30 secondes, plafond de sortie 4 Ko)

Accès web (2 outils) : - web_search -- Rechercher sur le web via Tavily (limité à 5 résultats, 1,5 Ko chacun) - browse_url -- Récupérer et analyser une URL via Jina Reader (limité à 8 Ko)

Pédagogie (2 outils) : - interactive_quiz -- Générer un widget de quiz à choix multiples interactif - true_false_quiz -- Générer un énoncé de quiz vrai/faux

Récompenses (2 outils) : - award_bonus_credits -- Donner à l'élève 1 à 5 crédits bonus pour son effort - report_exercise_result -- Enregistrer silencieusement si l'élève a répondu correctement

Gestion des tâches (1 outil) : - create_task -- Créer une tâche avec titre, priorité, date d'échéance et tags

Facturation (1 outil) : - buy_credits -- Déclencher le flux d'achat de crédits depuis le chat

Signalement (1 outil) : - report_bug -- Signaler un bug (envoyé à l'équipe de développement par e-mail et WhatsApp)

Tous les outils ne sont pas disponibles dans tous les modes. Le mode enfant dispose de 22 outils (pas de bash_execute, pas de draft_email). Le mode pro dispose de 24 outils (tous). Les utilisateurs invités n'ont aucun outil -- juste le chat textuel.

L'outil de quiz interactif

Le système de quiz mérite sa propre explication car il montre comment un outil peut produire un élément d'interface riche et interactif, pas seulement du texte.

Quand le LLM décide de tester un élève, il appelle interactive_quiz avec des paramètres structurés :

pythonINTERACTIVE_QUIZ_TOOL = {
    "type": "function",
    "function": {
        "name": "interactive_quiz",
        "description": (
            "Generate an interactive multiple-choice quiz question for the student. "
            "Use after explaining a concept, to consolidate understanding, or to break "
            "monotony. 1-3 quizzes per conversation max. Never on the first message."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "question": {
                    "type": "string",
                    "description": "The quiz question text",
                },
                "options": {
                    "type": "array",
                    "items": {"type": "string"},
                    "minItems": 2,
                    "maxItems": 4,
                    "description": "Answer options (2-4 choices)",
                },
                "correct_index": {
                    "type": "integer",
                    "description": "Zero-based index of the correct answer",
                },
                "explanation": {
                    "type": "string",
                    "description": "Pedagogical explanation shown after the student answers",
                },
            },
            "required": ["question", "options", "correct_index", "explanation"],
        },
    },
}

L'exécuteur d'outils n'« exécute » pas cet outil au sens traditionnel. Il stocke l'état du quiz dans Redis avec un TTL et renvoie une version assainie (sans la bonne réponse) au frontend :

pythonif func_name in ("interactive_quiz", "true_false_quiz"):
    from app.services.quiz import store_quiz
    sanitized = await store_quiz(
        redis, conversation.id, tool_call_id, func_name, func_args,
    )
    return sanitized

Le frontend reçoit un événement SSE quiz et affiche un widget interactif avec des boutons de réponse cliquables. Quand l'élève appuie sur une réponse, le frontend envoie un appel API séparé pour vérifier la réponse par rapport à la bonne réponse stockée dans Redis. Cette conception en deux phases signifie que la bonne réponse ne quitte jamais le serveur tant que l'élève n'a pas validé sa réponse -- empêchant l'inspection via les outils de développement du navigateur.

Troncature des résultats d'outils

Le débordement de contexte est le tueur silencieux des systèmes agentiques. Chaque résultat d'outil est ajouté à l'historique des messages avant la prochaine itération du LLM. Sans troncature, un seul appel browse_url peut injecter 50 Ko de contenu HTML dans la fenêtre de contexte. Deux recherches web et une lecture de fichier plus tard, vous avez consommé 100K+ tokens de contexte en résultats d'outils seuls, ne laissant plus de place pour la conversation proprement dite.

La stratégie de troncature est spécifique à chaque outil :

pythondef _truncate_tool_result(name: str, result: dict) -> dict:
    _MAX_BROWSE = 8_000       # ~2 000 tokens
    _MAX_SEARCH_ITEM = 1_500  # ~375 tokens par résultat x 5 résultats max
    _MAX_RESULTS = 5          # plafond des résultats Tavily
    _MAX_BASH = 4_000         # ~1 000 tokens
    _MAX_FILE = 8_000         # ~2 000 tokens

    if name == "browse_url" and "content" in result:
        if len(result["content"]) > _MAX_BROWSE:
            result["content"] = result["content"][:_MAX_BROWSE] + "\n[...tronqué]"

    elif name == "web_search" and "results" in result:
        result["results"] = result["results"][:_MAX_RESULTS]
        for r in result["results"]:
            if "content" in r and len(r["content"]) > _MAX_SEARCH_ITEM:
                r["content"] = r["content"][:_MAX_SEARCH_ITEM] + "..."

    elif name == "bash_execute" and "stdout" in result:
        if len(result["stdout"]) > _MAX_BASH:
            result["stdout"] = result["stdout"][:_MAX_BASH] + "\n[...tronqué]"
    # ... similaire pour read_user_file

Ces limites ont été calibrées empiriquement. 8 Ko de contenu web est suffisant pour que le LLM comprenne la structure d'une page et extraie les informations pertinentes. 1,5 Ko par résultat de recherche suffit pour un résumé et les faits clés. 4 Ko de sortie bash suffit pour les résultats de commande sans déverser des fichiers de logs entiers dans le contexte.

Tâches de fond : quand 180 secondes ne suffisent pas

Certaines chaînes d'outils prennent plus de 180 secondes. Un professionnel demande à Deblo de rechercher un sujet SYSCOHADA, compiler les résultats dans un rapport PDF de 20 pages, générer des tableaux Excel de support, et envoyer le tout par e-mail. Cela peut impliquer 4-5 recherches web, 3 générations de fichiers et un envoi d'e-mail -- facilement 5 à 10 minutes de temps réel.

Le flux SSE direct a un timeout de 180 secondes. Au-delà, les navigateurs et les reverse proxies commencent à fermer les connexions. Deblo dispose donc d'un système de génération en arrière-plan.

Quand le frontend envoie background: true dans la requête de chat, le backend crée une ligne GenerationJob dans la base de données, lance une asyncio.Task détachée, et retourne immédiatement un ID de tâche. La tâche exécute la même boucle agentique mais écrit les événements de progression dans Redis au lieu d'un flux SSE :

python# Dans background_generation.py
async def run_background_generation(job_id: UUID, ...):
    redis = Redis(connection_pool=redis_pool)
    try:
        # Publier les événements de progression dans Redis
        async def publish_progress(event_type: str, data: dict):
            await redis.publish(
                f"job:{job_id}:progress",
                json.dumps({"type": event_type, **data})
            )

        # Exécuter la même boucle agentique avec des callbacks de progression
        async for event in stream_chat_response(...):
            if isinstance(event, dict):
                await publish_progress(event["type"], event.get("data", {}))
            # ... gérer le contenu texte, les événements d'outils

        # Marquer la tâche comme terminée
        job.status = "completed"
        job.result_messages = full_messages
        await db.commit()
    except Exception as e:
        job.status = "failed"
        job.error = str(e)
        await db.commit()

Le frontend interroge le statut de la tâche toutes les 2 secondes et affiche une timeline de progression montrant quels outils tournent, lesquels sont terminés et quelle est l'étape en cours. Les types d'événements SSE (tool_start, tool_progress, tool_end) sont réutilisés dans le canal Redis pubsub, de sorte que le frontend utilise la même logique de rendu pour la génération directe et en arrière-plan.

Les tâches de fond ont un timeout de 30 minutes -- 10 fois la limite du streaming direct. C'est suffisant pour les tâches professionnelles les plus complexes que nous avons vues en production.

L'exécuteur d'outils : une seule fonction de dispatch

Les 24 outils sont dispatchés via une seule fonction. Pas de pattern de registre d'outils. Pas d'architecture de plugins. Juste une fonction avec une longue chaîne if/elif :

pythonasync def execute_tool(
    func_name: str,
    func_args: dict,
    tool_call_id: str,
    *,
    db: AsyncSession,
    redis: Redis,
    user,
    conversation,
    effective_mode: str,
) -> dict:
    if func_name == "report_exercise_result" and user:
        er = ExerciseResult(
            user_id=user.id,
            conversation_id=conversation.id,
            subject=func_args.get("subject", ""),
            correct=bool(func_args.get("correct", False)),
            # ...
        )
        db.add(er)
        return {"success": True}

    if func_name == "award_bonus_credits" and user:
        credits = min(max(int(func_args.get("credits", 1)), 1), 5)
        user.credit_balance += credits
        await log_credit_event(user, "credit", credits, "ai_bonus", ...)
        return {"success": True, "credits_awarded": credits}

    if func_name in ("interactive_quiz", "true_false_quiz"):
        return await store_quiz(redis, conversation.id, ...)

    if func_name == "web_search":
        return await tavily_search(func_args.get("query", ""))

    if func_name == "bash_execute":
        return await sandbox_execute(func_args.get("command", ""))

    # ... 19 outils de plus

Ce n'est pas élégant. C'est lisible. Quand un outil échoue en production, nous ouvrons ce fichier, trouvons le bloc if, et lisons exactement ce qui se passe. Pas d'indirection. Pas d'injection de dépendances. Pas de hiérarchie de classes de base abstraites à parcourir.

Ce que nous avons appris

Construire un système agentique nous a enseigné trois choses qu'aucun tutoriel ne nous avait préparés à affronter :

Premièrement, la troncature est plus importante que la génération. La capacité du LLM à produire une sortie utile dépend entièrement de la qualité de son contexte. Déverser des résultats d'outils bruts dans le contexte empoisonne tout ce qui suit. Les limites de troncature dans _truncate_tool_result ont été calibrées au fil de dizaines de sessions, chaque fois en déboguant des cas où le LLM « oubliait » la question originale parce que le contexte était inondé de bruit de recherche web.

Deuxièmement, la dernière itération doit être forcée en texte. Sans tool_choice: "none" à la dernière itération, le LLM entre parfois dans une boucle d'appels d'outils où il appelle un outil, obtient un résultat, décide qu'il a besoin d'un autre outil, l'appelle, et ainsi de suite jusqu'à atteindre le plafond -- puis ne renvoie rien parce qu'il voulait appeler un autre outil mais n'y était pas autorisé. Forcer le texte à la dernière itération garantit que l'utilisateur reçoit toujours une réponse.

Troisièmement, 60 secondes par outil, c'est généreux. La plupart des outils se terminent en moins de 5 secondes. Le timeout de 60 secondes existe pour la navigation web (certaines pages sont lentes) et l'exécution bash (certaines commandes prennent du temps). Mais le timeout sert aussi de soupape de sécurité contre les appels d'outils bloqués. En production, nous avons vu exactement deux cas où un outil a atteint la limite de 60 secondes : une recherche Tavily pendant une panne de Tavily, et une génération de fichier qui a déclenché une erreur de mémoire dans la bibliothèque PDF. Les deux ont été capturés par le timeout au lieu de bloquer indéfiniment le flux entier.


Ceci est la partie 3 d'une série de 12 articles sur la construction de Deblo.ai.

  1. Tutorat IA pour 250 millions d'élèves africains
  2. 100 sessions plus tard : l'architecture d'une plateforme d'éducation IA
  3. La boucle agentique : 24 outils IA dans un seul chat (vous êtes ici)
  4. Des prompts système qui enseignent : anti-triche, méthode socratique et adaptation par niveau
  5. WhatsApp OTP et le problème de l'authentification en Afrique
  6. Crédits, FCFA et 6 passerelles de paiement africaines
  7. Streaming SSE : réponses IA en temps réel dans SvelteKit
  8. Appels vocaux avec l'IA : Ultravox, LiveKit et WebRTC
  9. Construire une application React Native K12 en 7 jours
  10. 101 conseillers IA : intelligence professionnelle pour l'Afrique
  11. Tâches de fond : quand l'IA met 30 minutes à réfléchir
  12. D'Abidjan à 250 millions : l'histoire de Deblo.ai
Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles