Accueil / Articles

Un agent de support local avec SmolLM3 : mode réflexion, outils et contraintes

Exécuter un modèle de 3 milliards de paramètres sur un ordinateur portable en tant qu’agent d’assistance : quand utiliser think ou no_think, comment fonctionnent les appels à des outils, et les mécanismes de protection qui empêchent les appels à des outils de remplacement.

Éditeur : REACTAPP.TOP9 min de lecture1979 mots

Sources: **A Local Support Agent with SmolLM3: Think Mode, Tools, and When Not to Reason**

Un modèle de 3 milliards de paramètres peut-il gérer un ticket d’assistance sans l’aide d’un modèle de 70 milliards de paramètres ? Pour les 90 % des tickets simples, oui, mais seulement si l’on cesse de faire confiance aux appels d’outils effectués par le modèle lui-même. Ici, SmolLM3 fonctionne localement en tant qu’agent d’assistance de petite taille et montre où il se comporte correctement et où le code qui l’entoure doit être plus strict que celui du modèle.

Le service client n’est pas l’endroit idéal pour déployer un chatbot généraliste. Les tickets typiques nécessitent une recherche, une étiquette et une réponse brève basée sur des règles prédéfinies, tandis que une minorité sont liés à des facturations doubles, des commandes perdues ou des clients mécontents. Pour bien gérer les deux types de tickets avec un seul modèle, il faut le faire fonctionner de deux manières différentes.

SmolLM3-3B convient à cette tâche pour trois raisons que le nombre de paramètres cache :

  • Il intègre deux modes de raisonnement. L’ajout de /no_think le pousse à répondre immédiatement, tandis que /think le fait écrire au préalable un trace de raisonnement caché.
  • L’appel d’outils est intégré : les outils décrits sous forme de schémas JSON sont inclus dans le modèle de chat, et le modèle répond par un bloc <tool_call> lorsqu’il souhaite les exécuter.
  • Ce système est suffisamment compact pour un ordinateur portable. Tout fonctionne sur un MacBook doté de 24 GB de mémoire unifiée : le modèle en précision complète via Transformers sur MPS, ainsi qu’une version 8 bits MLX pour le cycle de l’agent.
  • Aucun entraînement n’a lieu, et les poids du modèle ne sont jamais modifiés. Le travail consiste à séparer la phase de réflexion de celle d’action et à rendre le code environnant plus strict que le modèle lui-même.

    La configuration

    Deux scripts utilisent deux environnements d’exécution mais appartiennent à la même famille de modèles. Pour la comparaison en mode réflexion, l’échantillonnage suit les paramètres indiqués sur la fiche du modèle, avec temperature=0.6 et top_p=0.95. L’agent fonctionne de manière plus prudente avec temperature=0.3, ce qui permet au JSON des outils de rester simple.

    Sur cette machine, le temps de trajet aller-retour de l’agent 8 bits a pris environ quatre secondes. Le modèle complet de 3 milliards de paramètres basé sur Transformers génère environ 10 tokens par seconde, ce qui est suffisant pour lire des journaux plus tard mais trop lent pour un client regardant une fenêtre de chat. Transformers est utile pour étudier le modèle de template. Lorsqu’une personne réelle est à l’autre bout, il faut passer à MLX ou llama.cpp.

    Penser est une décision liée au produit

    SmolLM3 pense par défaut. Il existe deux manières équivalentes pour l’éteindre. L’une consiste à utiliser un message système :

    messages = [
        {"role": "system", "content": "/no_think"},
        {"role": "user", "content": prompt},
    ]
    

    L’autre est un drapeau lors de l’affichage du modèle :

    tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
        enable_thinking=False,  # the /no_think flag in the system prompt wins if both are set
    )
    

    Le modèle fait plus que simplement inverser une valeur booléenne. Il écrit le mode de raisonnement dans l’en-tête du système, et lorsque /no_think est sélectionné, il insère en outre un bloc de réflexion vide à l’avance, afin que la génération puisse se poursuivre directement avec la réponse :

    <|im_start|>assistant
    <think>
    
    </think>
    

    C’est pourquoi une simple appel à generate() en mode no-think peut encore retourner <think></think> dans le texte. Pour une réponse destinée aux clients, il faut l’enlever :

    final = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
    

    La même demande d’assistance a été envoyée dans les deux modes : un client est facturé deux fois pour une seule commande, alors quels sont les trois pas concrets que l’équipe d’assistance doit suivre ?

    Deux horloges

    Le débit était à peu près identique dans les deux modes, mais la quantité de travail ne l’était pas. Le mode « think » a consacré la majeure partie de ses 939 tokens à l’intérieur du bloc « think ». Le client ne lit jamais ce suivi, mais il doit tout de même l’attendre.

    Deux manuels d’action

    /no_think a conduit directement à une liste de contrôle standard : confirmer la commande, contacter le vendeur, rembourser la facture. Cela fonctionne comme une première ébauche, mais cela n’est pas tout à fait adapté pour l’équipe d’assistance interne du vendeur, car « contacter le vendeur » est un conseil donné par une banque, et non quelque chose que son propre agent dirait à son client.

    /think a passé environ une page à débattre avec lui-même entre remboursement et crédit, gestion de l’escalade et question de vérification de l’expédition, avant de parvenir à un plan plus précis :

    1. Excuserse et obtenir le numéro de commande, la date et le mode de paiement.
    2. Localiser la transaction dupliquée et la annuler.
    3. Expliquer au client ce qui s’est passé et confirmer que l’argent sera remboursé, sous forme de remboursement ou de crédit en magasin.

    La répartition des tâches liées à l’expédition en découle :

    • /no_think pour les tâches à grande échelle : classification des intentions, demandes de type « où est ma commande », réponses préenregistrées aux politiques et appels aux outils.
    • /think lorsque chaque erreur entraîne de lourdes conséquences : remboursements, demandes de remboursement, problèmes de sécurité, ainsi que tout ce qui nécessite une liste de vérification similaire à celle utilisée par un humain avant de répondre.

    Les chiffres fournis par Hugging Face indiquent la même tendance en ce qui concerne l’utilisation des outils. Sur le benchmark BFCL, SmolLM3 obtient 92,3 sans réflexion approfondie et 88,8 avec. La réflexion n’est pas gratuite, et pour ce type de tâche elle ne rend même pas les résultats meilleurs. L’agent ci-dessous force l’utilisation de enable_thinking=False, car une recherche de commande doit être une simple appel de fonction et non un essai long.

    Un outil et une boucle à deux tours

    SmolLM3 attend que les outils soient fournis sous forme d’objets JSON-schema via xml_tools. Le modèle les copie dans un bloc dédié aux outils et lui indique de répondre selon cette structure :

    <tool_call>
    {"name": <function-name>, "arguments": <args-json-object>}
    </tool_call>
    

    Outil de support délibérément restreint :

    TOOLS = [
        {
            "name": "lookup_order_status",
            "description": (
                "Look up the current status, estimated delivery date, and carrier "
                "for a specific customer order. Call this when the customer mentions "
                "an order number or asks where their order is."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "order_id": {
                        "type": "string",
                        "description": "The order ID, usually in the format ORD-XXXXXX.",
                    }
                },
                "required": ["order_id"],
            },
        }
    ]
    

    Derrière cela se trouve une base de données fictive. En environnement de production, il s’agirait d’une requête SQL ou d’un appel à un service de commandes.

    ORDERS = {
        "ORD-4821": {"status": "shipped", "eta": "June 18, 2026", "carrier": "DHL"},
        "ORD-3307": {"status": "processing", "eta": "June 20, 2026", "carrier": None},
        "ORD-1190": {"status": "delivered", "eta": None, "carrier": "FedEx"},
    }
    

    La boucle représente l’ensemble du processus. Le modèle reçoit le message du client, génère une première réponse ; si il s’agit d’une requête vers un outil, le code exécute la fonction correspondante, renvoie le résultat et demande une deuxième réponse :

    def respond_with_tools(user_message: str) -> str:
        messages = [{"role": "user", "content": user_message}]
        turn1 = generate_reply(messages)
        name, args = parse_tool_call(turn1)
        if name == "lookup_order_status":
            result = lookup_order_status(**args)
            messages += [
                {"role": "assistant", "content": turn1},
                {"role": "tool", "content": json.dumps(result), "name": name},
            ]
            return generate_reply(messages)
        return turn1
    

    Le parcours idéal

    Un client demande où se trouve la commande ORD-4821 après une semaine. Le modèle ne répond pas. Il appelle l’outil :

    <tool_call>
    {"name": "lookup_order_status", "arguments": {"order_id": "ORD-4821"}}
    </tool_call>
    

    La fonction indique que la livraison a eu lieu via DHL le 18 juin 2026. La deuxième tentative, d’une durée totale de 4,2 secondes, produit une réponse indiquant que la commande a été expédiée et devrait arriver le 18 juin par DHL.

    Pour ORD-3307, le statut est « en traitement » sans transporteur indiqué, avec une estimation de livraison au 20 juin. Le modèle a indiqué que le transporteur n’était pas disponible plutôt que d’en inventer un, ce qui est exactement ce qu’on attend d’un modèle simple : lire le JSON de l’outil sans le modifier.

    Pour ORD-9999, l’appel à l’outil était correct et la fonction a renvoyé not_found. La réponse s’est excusée et a demandé au client de vérifier le numéro. Une réponse structurée indiquant « non trouvé » de la part de la fonction était suffisante, sans besoin d’aucune indication supplémentaire.

    Où échoue la boucle naïve

    La troisième requête était destinée à servir de contrôle négatif : le client souhaite simplement changer son adresse e-mail. Comme aucun outil d’envoi d’e-mails n’existe, un bon agent le lui dit et s’arrête. Le boucle naïve a fait autre chose :

    1. Elle a commencé correctement en indiquant qu’il était impossible de modifier les informations personnelles et que le client devait contacter le service d’assistance.
    2. Puis elle a continué et a généré une demande d’utilisation d’un outil contenant un champ de remplacement :
    <tool_call>
    {"name": "lookup_order_status", "arguments": {"order_id": "your_order_id"}}
    </tool_call>
    
    1. Le parseur a exécuté tout ce qui était encadré par <tool_call>. Il a recherché your_order_id dans la base de données fictive, a reçu la réponse not_found et l’a renvoyée au modèle.
    2. Lors de la deuxième tentative, le modèle a exposé le schéma de l’outil au client et lui a demandé de remplacer your_order_id par un numéro de commande réel.

    Quatre éléments ont échoué en même temps, les uns après les autres. La partie liée au modèle est la moins intéressante. Les petits modèles adoptent une approche prudente, ce qui est normal. Si votre boucle se résume à une expression régulière pour <tool_call> suivie d’un appel à la fonction avec les arguments disponibles, vous finirez par exécuter lookup_order_status("your_order_id") en production, mais cela ne se produira pas lors d’une démonstration.

    Protectionnements qui n’ont pas besoin d’ajustement

    Deux petites règles résolvent la plupart des problèmes sans modifier les poids.

    Tout d’abord, exécutez une appel d’outil uniquement lorsque toute la réponse se résume à cet appel. Un paragraphe suivi d’un appel est considéré comme du texte ordinaire, et l’XML doit être supprimé avant que le client ne le voie.

    Deuxièmement, validez les arguments avant qu’ils n’atteignent la base de données. Les IDs de commande ici sont du type ORD- suivi de chiffres, donc your_order_id ne sera jamais accepté.

    ORDER_RE = re.compile(r"^ORD-\d{4,}quot;)
    PURE_TOOL_RE = re.compile(r"^\s*<tool_call>(.*?)</tool_call>\s*quot;, re.DOTALL)
    
    def parse_executable_tool_call(output: str):
        cleaned = re.sub(r"<think>.*?</think>", "", output, flags=re.DOTALL).strip()
        match = PURE_TOOL_RE.match(cleaned)
        if not match:
            return None
        payload = json.loads(match.group(1).strip())
        order_id = str(payload.get("arguments", {}).get("order_id", "")).strip()
        if not ORDER_RE.match(order_id):
            return None
        return payload
    

    Raffinez également la description de l’outil en indiquant qu’il ne doit être utilisé que lorsque le client a déjà fourni un ID de commande concrète. Après ce changement, la question envoyée par e-mail lors du processus sécurisé n’a pas du tout fait appel à cet outil, prenant 3,5 secondes contre 15,4 pour la version défectueuse à deux étapes. La réponse s’est excusée, a indiqué qu’elle ne pouvait pas gérer les modifications de compte et a orienté vers le service client. Cela reste générique, mais en l’absence de l’outil update_email, il ne devrait d’ailleurs pas y avoir de solution autonome miracle. Au moins, cela a cessé de prétendre rechercher une commande.

    Les recherches dans le scénario idéal continuaient de fonctionner et ORD-9999 était toujours signalé comme manquant. Le modèle n’est devenu aucunement plus intelligent. Considérez cela comme une ceinture de sécurité plutôt que comme une greffe.

    Ce que cela ne prouve pas

    Ceci n’est qu’une démonstration, et un véritable copilote de support a besoin de bien plus que d’une seule fonction et d’un dictionnaire contenant trois commandes. Plusieurs affirmations méritent d’être remises en question :

    • Ce n’est pas une planification à l’aide d’outils multiples. Un outil sert uniquement à effectuer des recherches, et ce n’est qu’avec environ deux outils que les modèles 3B commencent à inventer des identifiants de commande pour s’adapter à des schémas qu’ils ne comprennent pas entièrement.
    • La base de données est fictive, donc l’authentification, les données personnelles ainsi que la fonction « permettre à cet utilisateur de voir cette commande » n’ont jamais fait partie des problèmes du modèle.
    • Le prélèvement d’échantillons était activé en permanence, ce qui signifie que la réexécution de la requête e-mail ne produira pas les mêmes résultats. Ce qui se reproduisait à chaque exécution, c’était l’échec lui-même, une appel d’outil rempli de placeholders. La formulation exacte varie, il ne faut donc pas s’y fier.
    • Rien n’a été comparé à un modèle cloud ni testé sur une exportation réelle de tickets, donc les chiffres décrivent un seul ordinateur et non une référence de performance.

    Cela donne une affirmation plus restreinte que « les petits modèles peuvent fournir un soutien » : un modèle de 3 milliards de paramètres peut appeler une outil bien décrit, résumer le résultat et s’écarter du chemin, à condition que l’appel à cet outil lui-même soit considéré comme quelque chose nécessitant une vérification de permissions et non comme quelque chose à considérer comme fiable simplement parce que le modèle l’a généré.

    Lancer le programme

    Dans le répertoire du projet avec son environnement virtuel activé, exécutez les trois scripts dans l’ordre : la comparaison avec et sans réflexion, l’agent simple et l’agent protégé.

    python llm-learning/smollm3-local-support-agent/code/think_vs_nothink.py
    python llm-learning/smollm3-local-support-agent/code/support_agent.py
    python llm-learning/smollm3-local-support-agent/code/support_agent_guarded.py
    

    Ces scripts génèrent des transcriptions que vous pouvez lire par la suite pour comparer les exécutions.