Brancher un LLM sur un catalogue produit avec Django
Retour d'expérience sur l'intégration d'un agent de diagnostic dans une marketplace de pièces détachées. Stack : Django en vues asynchrones, PostgreSQL full-text, Redis, API LLM.
Le problème du vocabulaire
Une marketplace de pièces détachées a un problème de vocabulaire. L'acheteur qui sait ce qu'il cherche — « tendeur de chaîne de distribution » — est déjà servi par une recherche full-text classique : il tape, il trouve. Le problème vient de l'autre visiteur, celui qui écrit « ma moto cale au ralenti quand elle est froide ». Il ne connaît pas le nom de la pièce, la recherche lui renvoie zéro résultat, et il repart. Entre le symptôme et la référence catalogue, il manque une couche de traduction.
Pourquoi un LLM plutôt qu'une recherche améliorée
Trois approches étaient envisageables. Une table de correspondance manuelle, fiable mais rigide, qui ne couvre que ce qui a été prévu. Une recherche sémantique par embeddings, élégante mais qui résout le mauvais problème : le lien entre un symptôme et une pièce n'est pas lexical, il est causal. Ou un LLM avec sortie structurée, où le modèle raisonne pendant que le backend fait le pont avec le catalogue.
C'est la troisième qui a été retenue, avec une nuance déterminante : le LLM ne voit jamais le catalogue. Il produit un diagnostic et des termes de recherche normalisés, rien de plus. La mise en relation avec le stock réel reste du code déterministe. Cette séparation est le point d'architecture le plus important du projet : le modèle ne peut pas halluciner un produit inexistant, inventer un prix ou promettre une disponibilité.
Architecture générale
Le flux est volontairement linéaire : pas de chaînage d'agents, pas de function calling, pas de RAG. Un appel LLM, une transformation, N requêtes SQL. Le point à noter est que le cache porte sur la réponse du LLM, pas sur la réponse finale envoyée au navigateur. Le diagnostic est stable dans le temps, le stock ne l'est pas : un produit mis en ligne ce matin apparaît immédiatement dans un diagnostic dont le texte est en cache depuis six jours.
Visiteur ──► Vue async
│
├─► Rate limit (Redis)
├─► Cache réponse LLM (Redis, 7 jours)
│ └─ miss ─► API → JSON structuré
├─► Recherche catalogue (SQL, à chaque requête)
└─► Log en base
Contraindre la sortie du modèle
Le prompt système impose un JSON strict, sans texte avant ni après. Le champ le plus important est aussi le moins visible pour l'utilisateur : nom_recherche ne sert qu'à interroger la base. Les premières versions renvoyaient des termes descriptifs comme « injecteur carburant » ou « bougies allumage », qui ne matchaient jamais des titres catalogue du type « Bougie er6 n de 2006 a 2008 kawasaki ». D'où la contrainte explicite : singulier, terme le plus court possible.
# prompts.py
SYSTEM_DIAGNOSTIC = """Tu es un mécanicien expert.
Le client décrit un symptôme, parfois en mentionnant son véhicule.
Réponds UNIQUEMENT en JSON, sans texte avant/après :
{
"vehicule": "modèle extrait du message, ou null",
"diagnostic": "explication claire (100 mots maximum)",
"pieces_probables": [
{"nom_recherche": "terme catalogue",
"raison": "40 mots maximum",
"urgence": "haute|moyenne|basse"}
],
"conseil_securite": "alerte si pertinent, sinon null"
}
Maximum 4 pièces, classées par probabilité.
Pour "nom_recherche", utilise le SINGULIER et le terme le plus court :
"injecteur" pas "injecteur carburant", "bougie" pas "bougies allumage".
"""
La leçon est généralisable : quand un champ de sortie LLM alimente une recherche, il faut le contraindre au format de l'index, pas au format naturel. Ce sont deux registres différents et le modèle ne le devine pas.
La vue asynchrone
Toutes les vues du projet sont async, ce qui impose quelques précautions. Le client HTTP doit être asynchrone, sinon la boucle d'événements se bloque pendant les trois à six secondes de génération. Le rate limit et le cache passent par les méthodes async de Django (cache.aget, cache.aset), et le log final utilise acreate.
# views.py
class AgentDiagnosticView(View):
async def post(self, request):
body = json.loads(request.body)
symptome = body.get("symptome", "").strip()[:500]
if len(symptome) < 5:
return JsonResponse({"erreur": "Précisez le symptôme."}, status=400)
ip = get_ip(request)
cle_limite = f"diag:limite:{ip}"
compteur = await cache.aget(cle_limite, 0)
# ... recherche catalogue puis JsonResponse
Trois pièges spécifiques aux vues async méritent d'être signalés. LoginRequiredMixin est inutilisable : il accède à request.user de manière synchrone avant l'exécution de la vue, ce qui lève une SynchronousOnlyOperation — il faut le remplacer par await request.auser(). Ensuite, .union() sur un queryset async provoque la même erreur. Enfin, les querysets doivent rester des querysets : le slicing en produit toujours un, donc reste itérable en async, alors qu'une liste Python matérialisée par list() ne l'est plus.
Le pont avec le catalogue
La recherche utilise le full-text natif de PostgreSQL, avec le vecteur construit à la volée. Trois détails font la différence entre un moteur qui fonctionne et un moteur qui renvoie du vide. La configuration french active le stemming, sans quoi « bougies » ne matche pas « bougie » et le taux d'échec explose. Le search_type="websearch" rend la requête tolérante, là où le mode par défaut exige la présence de tous les termes. Et le Q(icontains) en complément rattrape ce que le full-text laisse passer.
# views.py
vecteur = (
SearchVector("produit_titre", weight="A", config="french")
+ SearchVector("produit_description", weight="B", config="french")
)
async def chercher(termes, limite=3):
sq = SearchQuery(termes, config="french", search_type="websearch")
...
Trois échecs de pertinence
La première version fonctionnelle remontait des résultats. De mauvais résultats. Le mode plain exigeait tous les termes, modèle de véhicule compris, ce qui vidait trois blocs sur quatre alors que le catalogue contenait bien les pièces. Correction : passage en websearch et ajout d'un repli sans le modèle de véhicule.
Après cette correction, tous les blocs se remplissaient. Trop bien. Pour « capteur température », le moteur proposait un capteur de vitesse, un capteur de chute et un autre capteur de vitesse : trois capteurs, aucun le bon. La cause était un repli en cascade qui cherchait en dernier recours sur le premier mot significatif seul — et « capteur » matche évidemment tous les capteurs du catalogue. Correction : suppression de ce repli et introduction d'un seuil de rang minimal. Mieux vaut un bloc vide avec un lien « créer une alerte » qu'une pièce hors sujet : le premier génère un lead, le second génère un litige.
Le troisième échec est le plus insidieux. « Chaîne de distribution » (interne au moteur) et « chaîne secondaire » (transmission) n'ont rien à voir, mais le stemming français les rend indistinguables puisque les deux contiennent « chaîne ». Aucun réglage de seuil ne résout ça, parce que le score full-text est légitimement élevé. Il a fallu une liste d'exclusions par domaine.
# views.py
PARASITES = {
"distribution": ["arriere", "secondaire", "carter", "protection"],
"frein": ["levier", "durite", "bocal"],
}
for cle, exclusions in PARASITES.items():
if cle in termes.lower():
for mot in exclusions:
qs = qs.exclude(produit_titre__icontains=mot)
Ce n'est pas élégant : c'est de la connaissance métier codée en dur, qu'il faudra maintenir. Mais c'est la seule chose qui fonctionne quand deux concepts distincts partagent le même vocabulaire, et cette situation est la norme dans les catalogues techniques, pas l'exception.
La cause commune de ces échecs
Ces trois problèmes partagent une origine unique : le catalogue était trop petit. Avec quelques centaines de références concentrées sur quatre ou cinq modèles, un visiteur qui arrive avec un véhicule non couvert ne trouvera jamais de pièce compatible, quel que soit le réglage du seuil ou la finesse des exclusions. Un agent de ce type est un multiplicateur, pas un générateur : il transforme du trafic en conversions quand il y a de quoi convertir. Sur un catalogue clairsemé, il transforme surtout du trafic en pages vides.
HTML dans le prompt ou JSON structuré
La question revient dès qu'un LLM produit du contenu destiné à l'affichage. La première approche consiste à demander directement du HTML au modèle et à l'injecter dans le DOM. L'implémentation est immédiate et réutilise les classes CSS existantes, mais le résultat est instable : le modèle dérive, ajoute une classe, oublie une balise fermante, imbrique différemment. La mise en page casse de façon imprévisible, et le HTML dérivé reste en cache pendant toute la durée de vie de la clé.
La seconde approche demande un JSON structuré et laisse le front construire le markup. La structure est alors garantie, chaque champ est échappé individuellement, le traitement par élément devient trivial — animation décalée, effet machine à écrire — et le markup peut évoluer sans toucher au prompt ni purger le cache. La numérotation des étapes vient du code plutôt que du modèle, qui se trompe parfois.
Le principe à retenir tient en une phrase : le LLM produit du contenu, le code produit de la structure. Chaque fois qu'on confie la présentation au modèle, on introduit une source d'instabilité pour économiser vingt lignes de JavaScript.
Coûts et cache
La consommation tourne autour de 300 tokens en entrée et 300 en sortie par requête. Sur un modèle d'entrée de gamme, cela représente environ 0,0017 € par diagnostic, soit une vingtaine d'euros pour dix mille requêtes mensuelles. Le cache divise ce montant par deux, car les symptômes sont extrêmement répétitifs : « cale au ralenti », « freinage spongieux » et quelques dizaines d'autres reviennent en boucle, avec un taux de hit observé entre 40 et 60 %.
Un modèle rapide et économique suffit largement ici : la tâche est du mapping avec sortie contrainte, pas du raisonnement complexe. Les tests comparatifs avec un modèle supérieur n'ont pas montré d'écart justifiant le doublement du coût. En revanche, un plafond de dépense mensuel doit être posé dès le premier jour — un endpoint qui appelle une API payante sans limite est une facture qui attend de se produire.
Les erreurs à éviter
Plusieurs itérations ont été perdues à modifier le prompt sans purger le cache Redis, donc à tester une nouvelle version en recevant les anciennes réponses. Le réflexe à adopter : toute modification du fichier de prompts s'accompagne d'une purge des clés correspondantes.
Les premières versions du prompt étaient trop verbeuses, ce qui portait les temps de réponse au-delà de vingt secondes. Contraindre explicitement la longueur de chaque champ a ramené l'ensemble à quatre ou six secondes sans perte de qualité perçue.
Côté front, deux pièges classiques. L'attribut async sur la balise script exécute le code avant que le DOM contienne le formulaire, la garde de sortie s'active silencieusement et rien ne se passe : aucune erreur console, aucun comportement, le pire type de bug. Il faut defer. Et pour déclencher une soumission par programme, dispatchEvent(new Event("submit")) ne déclenche pas le handler — c'est form.requestSubmit() qu'il faut appeler.
Ce qu'il faut retenir
Le pattern est reproductible pour n'importe quel catalogue technique où le vocabulaire de l'utilisateur ne correspond pas à celui de l'index. Le LLM ne voit jamais le catalogue et se contente de produire des termes normalisés ; la sortie est du JSON strictement contraint, jamais du HTML ; le cache porte sur la génération, pas sur le résultat final ; le rate limit est en place dès la mise en production.
Deux points comptent plus que les autres. Le tracking de conversion doit précéder les fonctionnalités : un agent est facile à construire et difficile à évaluer, et sans mesure on optimise à l'aveugle quelque chose dont on ignore l'utilité. Et surtout, le stock reste le facteur limitant — la qualité du prompt ne compense jamais l'absence de produits. C'est le constat le plus important, et c'est celui qu'on découvre en dernier.
Retour d'expérience sur l'intégration d'un agent IA dans une application Django : vues asynchrones, recherche full-text PostgreSQL, cache Redis et contrainte de sortie JSON.