Brancher un LLM sur un catalogue produit avec Django
Brancher un LLM sur un catalogue produit avec Django
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<br>├─► Rate limit (Redis)<br>├─► Cache réponse LLM (Redis, 7 jours)<br>│ └─ miss ─► API → JSON structuré<br>├─► Recherche catalogue (SQL, à chaque requête)<br>└─► 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<br>SYSTEM_DIAGNOSTIC = """Tu es un mécanicien expert.<br>Le client décrit un symptôme, parfois en mentionnant son véhicule.
Réponds UNIQUEMENT en JSON, sans texte avant/après :<br>"vehicule": "modèle extrait du message, ou null",<br>"diagnostic": "explication claire (100 mots maximum)",<br>"pieces_probables": [<br>{"nom_recherche": "terme catalogue",<br>"raison": "40 mots maximum",<br>"urgence": "haute|moyenne|basse"}<br>],<br>"conseil_securite": "alerte si pertinent, sinon null"
Maximum 4 pièces, classées par probabilité.<br>Pour "nom_recherche", utilise le SINGULIER et le terme le plus court :<br>"injecteur" pas "injecteur carburant", "bougie" pas "bougies allumage".<br>"""
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<br>class AgentDiagnosticView(View):
async def post(self, request):<br>body = json.loads(request.body)<br>symptome = body.get("symptome", "").strip()[:500]
if len(symptome)
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...