Aller au contenu principal

La vie intérieure d'un module

Un service sans état peut être partagé. Un service avec état doit être seul.

L'épisode précédent a posé la question en la laissant ouverte : combien de temps vit chaque service ? La structure des modules ne suffit pas à y répondre — elle dit quoi, pas quand. Pour savoir quand, j'ai dû entrer à l'intérieur d'AgentModule et regarder chaque service en face.

Ce que le scope déclare

NestJS propose trois scopes pour ses providers. DEFAULT — ce que le framework appelle Singleton — crée une instance au démarrage et la partage entre tous les consommateurs jusqu'à l'extinction du processus. TRANSIENT crée une nouvelle instance à chaque injection. REQUEST crée une instance par requête HTTP — un scope conçu pour les API web, sans pertinence pour un service de messagerie comme AgenticLayer.

Deux scopes utiles, donc. Or la question n'est pas "lequel est plus rapide" — elle est "lequel est vrai".

ToolsRegistry est un registre. À l'initialisation du module, il reçoit les six tools enregistrés manuellement dans ToolsModule : search_code, get_file, get_files, list_files, analyze_ast, find_tests. Aucun tool n'est ajouté ni retiré pendant l'exécution. Aucune règle évaluée ne modifie cet état. Deux évaluations qui tournent en parallèle lisent le même registre sans interférence — non pas parce qu'elles sont bien synchronisées, mais parce qu'il n'y a rien à synchroniser. ToolsRegistry est Singleton parce qu'il n'a pas d'état par exécution.

LoopDetector est l'inverse. Sa raison d'être est de se souvenir — non pas de tout, mais de ce qui vient de se passer dans cette exécution précise : les derniers tool calls, leurs paramètres, leur fréquence. Si deux règles partagent la même instance, la mémoire de l'une contamine l'autre. LoopDetector doit mourir à la fin de chaque règle — non comme une précaution, mais comme une nécessité structurelle. Il est Transient parce qu'il est son état.

SchemaBuilder échappe à la question. Il génère à la volée le schéma Zod de validation depuis la configuration YAML d'une règle — une transformation pure, sans entrée mutable, sans mémoire entre les appels. Ses méthodes sont statiques. On pourrait lui attribuer un scope ; ce serait une fiction.

LLMFactory et PromptCompiler sont Singleton pour une raison différente. Ils portent un état — LLMFactory résout les clés API via ConfigService, PromptCompiler maintient un cache in-memory des templates déjà lus depuis le système de fichiers — mais cet état est initialisé une fois et ne mute plus. Deux évaluations parallèles lisent le même cache de templates sans risque : le cache est une optimisation, pas un canal de communication.

Pour un développeur junior : en NestJS, un @Injectable() est Singleton par défaut — une seule instance partagée entre tous les modules qui l'injectent. Pour déclarer un scope Transient, on écrit @Injectable({ scope: Scope.TRANSIENT }). La conséquence est subtile : si un provider Singleton injecte un provider Transient, le Transient est instancié une seule fois au démarrage — il perd son caractère transient. NestJS appelle cela la scope chain : le scope d'un provider est déterminé par le scope le plus long de ses consommateurs. AgentService est Transient — ce qui force LoopDetector et StructuredOutputHandler, qu'il injecte, à être effectivement recréés à chaque instanciation d'AgentService. Déclarer LoopDetector Transient sans que son consommateur le soit aussi n'aurait servi à rien.

Quand le LLM ne coopère pas

La boucle agentique fait une hypothèse : le LLM finira par répondre dans le format attendu. Cette hypothèse est raisonnable. Elle n'est pas toujours vraie.

StructuredOutputHandler est la réponse à ce que le LLM fait quand il décide de ne pas coopérer. Sa chaîne de fallback descend en trois niveaux. Le premier est le function calling natif — la méthode préférentielle pour Anthropic et OpenAI GPT-4, qui garantit une réponse structurée par contrat avec le modèle. Le second est le JSON mode — un mode explicite de certains providers (GPT-3.5, Azure, LiteLLM) qui demande au modèle de répondre en JSON sans garantir la structure exacte. Le troisième est le parse manuel : une combinaison de regex, JSON.parse, et validation Zod qui tente d'extraire quelque chose de sensé d'une réponse en langage naturel. Ce dernier niveau est le recours ultime — la preuve qu'on a pensé à ce que le LLM pourrait faire de pire.

StructuredOutputHandler est Transient non pas parce qu'il accumule de l'état au fil de la boucle, mais parce qu'il est lié à une instance LLM spécifique à une exécution. Deux règles évaluées avec des providers différents ont deux handlers distincts — chacun calibré pour les capacités déclarées de son provider.

LoopDetector, lui, surveille trois patterns. La répétition exacte : même tool, mêmes paramètres, N fois consécutives — le LLM qui insiste. La surcharge : même tool appelé trois fois sur les cinq derniers appels — le LLM qui revient toujours au même endroit sans raison apparente. L'oscillation : alternance A → B → A → B sur une fenêtre glissante — le LLM qui hésite entre deux chemins sans jamais trancher. Sur détection, il injecte un message de suggestion dans la conversation — c'est le comportement que j'ai voulu : non pas une interruption brutale, mais une indication que quelque chose ne va pas, laissant à l'agent la possibilité de s'en sortir par lui-même.

Ces deux services portent la même réponse à des problèmes différents : le système ne peut pas supposer que son environnement — le LLM — sera toujours prévisible. Il faut équiper chaque point de friction.

Le deuxième cerveau

Il y a une décision que j'avais gardée dans l'ombre en traçant la structure des modules — elle ne se lit pas dans un graphe de dépendances. AgentService peut recevoir deux configurations LLM distinctes : un modèle principal pour la boucle agentique, un modèle secondaire pour l'extraction de la sortie structurée en fin de boucle.

La raison est économique autant que technique. La boucle agentique fait de nombreux appels LLM — chaque tour, chaque réponse à un tool call. Utiliser à chaque fois le modèle le plus capable serait coûteux et lent pour des opérations qui ne l'exigent pas : lire un fichier, chercher un pattern, construire progressivement une liste d'évidences. Or l'extraction finale de la sortie structurée est différente. Elle demande que le modèle suive un schéma Zod précis, avec des contraintes de validation strictes sur les IDs de checkboxes, les niveaux de confiance, les longueurs minimales de raisonnement. C'est là que la précision compte. C'est là qu'un modèle plus capable vaut son coût.

Ce choix dit quelque chose sur comment je pense l'économie d'un agent : non pas "le meilleur modèle pour tout", mais "le modèle adapté à chaque moment de la boucle". La configuration dual-LLM est optionnelle — si un seul modèle est fourni, il fait tout. Mais la possibilité de les dissocier est là, pensée dès le début, parce que le coût d'un agent n'est pas une constante.

Ce que le contrat ferme

AgentModule s'expose au monde extérieur — à EvaluationModule, à terme à l'agent d'assistance — à travers deux interfaces.

AgentEvaluationRequest porte ce que le module reçoit : la configuration de la règle, le prompt compilé, le contexte des tools (collection Qdrant, chemin du dépôt, identifiant d'évaluation pour le cache Redis), la configuration de la passe. AgentEvaluationResult porte ce qu'il rend : le verdict structuré de la règle, le nombre de tool calls, les tokens consommés, la durée d'exécution.

Ce que EvaluationModule sait d'AgentModule, c'est exactement ça — et rien d'autre. Il ne sait pas quel LLM a été utilisé. Il ne sait pas combien de tours a pris la boucle. Il ne sait pas si LoopDetector a détecté quelque chose, ni si StructuredOutputHandler a dû descendre jusqu'au parse manuel. Ces détails sont capturés dans les traces Langfuse, pas dans le contrat. Le contrat expose le résultat — l'implémentation reste opaque.

C'est ce que "module" signifie, poussé jusqu'au bout : non pas un répertoire bien rangé, mais une frontière qui protège les deux côtés de ce qu'ils n'ont pas besoin de savoir.


La prochaine fois : AgentModule est refermé sur lui-même — ses services nommés, leurs scopes déclarés, leurs contrats posés. Ce qui reste, c'est ce qui l'orchestre : comment EvaluationModule reçoit un identifiant d'évaluation et en fait quarante règles évaluées en parallèle, comment les passes s'enchaînent selon un DAG, comment un verdict global émerge de tout cela. La prochaine fois, on monte d'un niveau.