Un agent médical qui assure la liaison entre les bases de données SQLite et les recherches sur le Web
Créez un agent LangChain qui détermine si une question médicale nécessite une requête SQL sur trois ensembles de données ou une recherche web en temps réel, ainsi que deux modifications qui ont changé la conception.
Sources: Building a Multi-Tool AI Agent That Knows When to Query a Database and When to Search the Web
De nombreux chatbots d’IA ne sont que des outils spécialisés dans une seule tâche. Si vous posez une question, ils inventent une réponse à partir de leur mémoire ou refusent de répondre car ils ne peuvent pas accéder à vos données. Les systèmes réels doivent être plus intelligents et choisir la bonne source d’information pour chaque question. Ce projet vise précisément cela pour les questions médicales : un agent polyvalent qui lit une question et décide par lui-même si la réponse se trouve dans une base de données structurée ou sur le web. Il commute entre trois bases de données SQLite et un outil de recherche en ligne, mais a rencontré deux problèmes réels même après avoir prétendument été achevé.
L’objectif
Les consignes de ce devoir universitaire demandaient un agent capable de réaliser trois choses :
- Répondre à des questions statistiques à partir de trois ensembles de données médicales réels, portant sur les maladies cardiaques, le cancer et le diabète, chacun stocké dans sa propre base de données SQLite.
Un seul cerveau, quatre outils, et le jugement nécessaire pour associer chaque tâche à l’outil adapté.
L’architecture
Le modèle mental agit comme un dispatcheur :
┌────────────────────────┐
│ Main AI Agent │
│ (LangChain create_agent) │
└────────────┬─────────────┘
│ routes based on question type
┌────────────────┬──────────┼──────────┬────────────────┐
▼ ▼ ▼ ▼ ▼
HeartDiseaseDBTool CancerDBTool DiabetesDBTool MedicalWebSearchTool
│ │ │ │
▼ ▼ ▼ ▼
heart_disease.db cancer.db diabetes.db Tavily Search API
(SQLite) (SQLite) (SQLite) (general knowledge)
L’agent principal ne touche jamais aux données elles-mêmes. Son rôle est de lire la question, de choisir un spécialiste et de lui transmettre le travail. Une question concernant le niveau moyen de cholestérol dans l’ensemble de données sur les maladies cardiaques relève de HeartDiseaseDBTool, tandis qu’une question sur les causes du diabète de type 2 est adressée à MedicalWebSearchTool. Si une question combine les deux aspects, l’agent fait appel aux deux outils et fusionne leurs résultats en une réponse cohérente.
Les ensembles de données
Trois ensembles de données médicales réels et publics de Kaggle ont été convertis en bases de données SQLite avec des colonnes au type approprié. Les données relatives aux maladies cardiaques se trouvent dans heart_disease.db et comprennent 303 lignes décrivant l’âge, le sexe, le cholestérol, la pression artérielle ainsi que la variable cible. Les mesures des cellules cancéreuses sont stockées dans cancer.db et contiennent 101 lignes indiquant l’épaisseur des amas, la taille des cellules, les noyaux nus et la mitose. Les données sur le diabète chez les Indiens Pima sont conservées dans diabetes.db et comprennent 768 lignes détaillant le glucose, le IMC, les grossesses et le résultat final.
Transformer un fichier CSV en SQLite semble simple, mais le faire correctement exige des noms de table et des types de colonnes (INTEGER, REAL, TEXT) bien choisis, déduits de manière appropriée, sans que tout ne soit stocké sous forme de texte. La logique de conversion commence par une correspondance entre les types de données pandas et les types SQLite :
DTYPE_MAP = {"int64": "INTEGER", "float64": "REAL", "object": "TEXT", "bool": "INTEGER"}
Un outil d’aide construit ensuite les définitions de colonne d’une instruction CREATE TABLE à partir des dtypes d’un DataFrame :
def infer_sqlite_schema(df: pd.DataFrame) -> str:
"""I build a CREATE TABLE column definition string from a DataFrame's dtypes."""
columns = []
for col in df.columns:
sqlite_type = DTYPE_MAP.get(str(df[col].dtype), "TEXT")
safe_col = col.replace(" ", "_").replace("-", "_")
columns.append(f'"{safe_col}" {sqlite_type}')
return ", ".join(columns)
Lorsque les bases de données existent, on peut être certain que le schéma reflète bien les données réelles, ce qui est d’une grande importance lorsque un LLM s’apprête à écrire des requêtes SQL à leur sujet.
Création des outils de base de données
Tous les trois outils de base de données suivent les mêmes trois étapes, de sorte qu’une seule fonction partagée remplace trois versions distinctes de la logique :
- Prendre la question formulée en langage naturel par l’utilisateur.
- Faire en sorte que le LLM transforme cette question en une instruction SQL de type SELECT uniquement, en utilisant le schéma de la table comme contexte.
- Exécuter la requête, puis demander au LLM de réécrire les résultats bruts sous forme de réponse en langue anglaise simple.
L’instruction de génération présente le modèle comme un expert en SQLite et exige une instruction SELECT syntaxiquement correcte et à lecture seule :
SQL_GENERATION_PROMPT = ChatPromptTemplate.from_template(
"""You are a SQLite expert. Given an input question and a table schema,
write a syntactically correct, READ-ONLY SQLite SELECT query that answers
the question.
Le reste du modèle fournit le schéma et la question, en exigeant uniquement du SQL brut, sans explication et sans formatage Markdown. Une fonction de nettoyage suit :
Table schema:
{table_info}Question: {question}Return ONLY the raw SQL query. No explanation, no markdown formatting.
SQL Query:"""
)def _clean_sql(raw: str) -> str:
"""Strips markdown fences (if any) and blocks write operations."""
sql = raw.strip()
if sql.startswith("```"):
sql = sql.strip("`")
if sql.lower().startswith("sql"):
sql = sql[3:]
sql = sql.strip() forbidden = ("insert", "update", "delete", "drop", "alter", "create")
if sql.lower().startswith(forbidden):
raise ValueError("Write operations are not permitted on this read-only tool.")
return sql
Cette fonction de protection _clean_sql effectue un travail discret mais essentiel. Quoi que génère le LLM, l’agent ne peut jamais modifier les données sous-jacentes, ce qui garantit un accès en lecture seule.
Chacun des trois outils est alors un enveloppe légère qui indique son nom de base de données, son nom de table et, ce qui est crucial, une description claire indiquant à l’agent de routage quand l’utiliser :
heart_disease_tool = make_db_tool(
db_path=HEART_DB_PATH,
table_name="heart_disease_records",
tool_name="HeartDiseaseDBTool",
description=(
"Use this tool to answer STATISTICAL or DATA questions about the "
"Heart Disease dataset (columns: age, sex, cp, trestbps, chol, "
"fbs, restecg, thalach, exang, oldpeak, slope, ca, thal, target). "
"Do NOT use this tool for general medical definitions or symptoms."
),
)
La dernière phrase de chaque description, l’énoncé explicite de l’usage pour lequel l’outil ne doit pas être employé, est plus importante que ce à quoi on pourrait s’attendre. Le succès du routage basé sur les LLM dépend de la précision avec laquelle les fonctions de chaque outil sont décrites.
L’outil de recherche sur le Web
Pour les connaissances médicales générales, l’API Tavily Search est intégrée à un outil dont la description est tout aussi réfléchie. Elle limite cet outil aux connaissances générales afin d’éviter toute confusion avec des questions statistiques :
medical_web_search_tool = StructuredTool.from_function(
func=_run_web_search,
name="MedicalWebSearchTool",
description=(
"Use this tool ONLY for GENERAL medical knowledge questions such "
"as definitions, symptoms, causes, risk factors, treatments, or "
"cures. Do NOT use this tool for statistics or numeric dataset "
"questions."
),
args_schema=WebSearchInput,
)
Montage de l’agent
Avec les quatre outils créés, la dernière étape consiste à mettre en place le routeur : une instruction système qui explique la logique de décision en termes simples, ainsi que le constructeur d’agent de LangChain pour relier tout cela. L’instruction commence en indiquant au modèle qu’il s’agit d’un assistant professionnel aux données médicales disposant de quatre outils :
SYSTEM_PROMPT = """You are a professional medical data assistant with access to four tools.
Les règles de routage suivantes doivent être appliquées strictement. Les questions concernant les statistiques, les données, les chiffres, les comptages, les moyennes ou des enregistrements spécifiques d’un ensemble de données doivent être adressées à l’outil de base de données correspondant, tandis que les demandes relatives à une définition, des symptômes, des causes ou des traitements doivent être effectuées via une recherche sur Internet :
Routing rules (follow strictly):
1. If the question asks about STATISTICS, DATA, NUMBERS, COUNTS, AVERAGES,
or specific RECORDS from a dataset -> use the matching DB tool.
2. If the question asks for a DEFINITION, SYMPTOM, CAUSE, RISK FACTOR, or
CURE/TREATMENT -> use MedicalWebSearchTool.
3. If a question mixes both, call both tools and combine the answers clearly.
4. Never invent numbers. Always answer in clear, professional natural language.
"""TOOLS = [heart_disease_tool, cancer_tool, diabetes_tool, medical_web_search_tool]def build_agent_executor():
llm = get_llm()
return create_agent(model=llm, tools=TOOLS, system_prompt=SYSTEM_PROMPT)
En demandant le niveau moyen de cholestérol dans l’ensemble de données sur les maladies cardiaques, on obtient une valeur d’environ 246,26. En demandant quels sont les symptômes typiques des maladies cardiaques, l’agent change de stratégie, effectue une recherche sur Internet et fournit une liste structurée comprenant des douleurs thoraciques, de l’essoufflement, de la fatigue et d’autres symptômes, accompagnée de sources sans aucune invention.
Lorsque les choses ont changé
La partie la plus instructive du processus de développement n’était pas prévue. Deux éléments ont fonctionné incorrectement après que l’agent ait été créé.
Le premier problème a été la disparition du backend LLM gratuit. L’agent avait été développé à l’aide de GitHub Models, une plateforme d’inference gratuite compatible avec OpenAI. Au cours des tests, toutes les requêtes ont commencé à échouer en raison de l’erreur github_models_retirement_brownout, car GitHub avait définitivement arrêté ce service. Aucune correction du code propre au projet n’a pu y remédier. La solution a consisté à déplacer tout le backend vers Google Gemini, ce qui a nécessité de redessiner config.py afin qu’il prenne en charge plusieurs fournisseurs (Gemini, Azure OpenAI et OpenAI directement), évitant ainsi tout point de défaillance unique.
Ensuite, les derniers modèles de Gemini ont brisé le cadre d’agent. Les nouveaux modèles de raisonnement de Google (la famille 3.x) ajoutent une thought_signature interne à chaque appel d’outil, un token que le client doit renvoyer lors du tour suivant. L’agent initial combinait l’ancien create_tool_calling_agent et AgentExecutor de LangChain, et cette combinaison réassemblait son propre carnet de messages entre les étapes, en supprimant la signature sans aucun avertissement. Chaque appel d’outil échouait alors avec l’erreur obscure Function call is missing a thought_signature.
Aucun correctif n’a résolu le problème. La solution a été de passer à l’actuel create_agent de LangChain, qui s’appuie sur LangGraph et conserve les objets de messages originaux à chaque tour au lieu de les reconstruire. La signature reste automatiquement attachée, et toute cette série d’erreurs disparaît.
Le point essentiel est que les systèmes agents reposent sur une infrastructure qui évolue constamment en arrière-plan. Les fournisseurs mettent à jour ou retirent des points d’accès, et de nouvelles générations de modèles introduisent de nouveaux exigences protocolaires. Comprendre pourquoi une erreur 400 se produit, et pas seulement ce que son message indique, constitue ici la véritable compétence à évaluer, bien plus que n’importe quelle ligne de code en particulier.
Observer le routeur prendre des décisions
Le moment satisfaisant a été de voir l’agent poser question après question pour trouver la bonne réponse, sans qu’on lui dise quoi faire. Une session d’exemple le montre.
Lorsqu’on lui a demandé la taille moyenne d’une cellule épithéliale dans les données sur le cancer, l’agent a fait appel à CancerDBTool, a généré la requête SELECT AVG(single_epithelial_cell_size) FROM cancer_records; et a répondu que la valeur moyenne était d’environ 2,37.
Lorsqu’on a demandé l’âge moyen des patients diagnostiqués avec du diabète, le système a fait appel à DiabetesDBTool et a correctement filtré les patients diagnostiqués (Outcome = 1) avant de calculer la moyenne, et non sur l’ensemble des données. La réponse était d’environ 37 ans.
La dernière question, portant sur ce qu’est le diabète et quel est le niveau de glucose moyen dans l’ensemble des données, constituait la véritable épreuve. L’agent a perçu une demande composite : il a utilisé MedicalWebSearchTool pour obtenir la définition et DiabetesDBTool pour obtenir le chiffre relatif au glucose, puis a combiné les deux résultats en un paragraphe cohérent, bien que personne ne le lui ait demandé. Ce comportement provient d’une ligne du prompt système : si une question mélange ces deux types d’informations, il faut utiliser les deux outils et combiner clairement les réponses. Le routage n’est pas de la magie, mais simplement des instructions précises accompagnées d’un modèle qui les suit bien.
Ingenierie des prompts pour le routage des outils
La décision la plus influente dans le développement n’a pas été une ligne de code Python. C’était plutôt la manière dont le champ description de chaque outil était rédigé. Les descriptions initiales étaient trop vagues, se contentant de répondre à des questions sur les données relatives aux maladies cardiaques, et l’agent cherchait parfois des informations en ligne pour répondre à des questions statistiques, car rien ne l’en empêchait.
La solution a été de faire en sorte que chaque description remplisse deux fonctions : indiquer clairement à quoi sert l’outil, et préciser explicitement à quoi il ne sert pas.
"Use this tool to answer STATISTICAL or DATA questions about the Heart
Disease dataset ... Do NOT use this tool for general medical definitions
or symptoms — use MedicalWebSearchTool for those."
Rédiger cette deuxième phrase a à peu près le même impact sur l’orientation des réponses que le prompt système. Quatre outils pour une seule finalité liée aux connaissances générales signifient que toute ambiguïté dans les descriptions se traduit immédiatement par une question mal orientée. Ce sont des descriptions d’outils précises et mutuellement exclusives que la tâche testait réellement, tandis que les aspects liés à SQL et aux API venaient en second plan.
Sécurité intégrée dès la conception
Quelques principes ont été respectés tout au long :
- Accès en lecture seule, toujours. Les instructions contenant
INSERT,UPDATE,DELETE,DROPouALTERsont rejetées par tous les outils SQL, de sorte qu’aucun ne peut accéder à la base de données. - Aucun chiffre inventé. Le prompt système interdit la fabrication de statistiques, et si un outil ne renvoie rien, l’agent le mentionne.
.env exclu du contrôle de version grâce à .gitignore, de sorte qu’aucune information sensible n’atteint le répertoire.Stack technique
- LangChain (
create_agent, basé sur LangGraph) en tant que cerveau de routage - Google Gemini (
gemini-3.5-flash-lite) en tant que modèle LLM principal - SQLite pour un stockage léger, basé sur des fichiers, de ces trois ensembles de données
- L’API Tavily Search pour la récupération en temps réel de connaissances générales
- Python avec SQLAlchemy et Pandas en tant que liant entre les composants
Leçons tirées du développement
Il a fallu une après-midi pour faire fonctionner le parcours idéal. Le passage à une version plus robuste a été la partie la plus longue : l’agent devait survivre à la fin du service d’un fournisseur ainsi qu’à des changements majeurs apportés par un autre, ce qui nécessitait une lecture attentive des messages d’erreur et la volonté de restructurer le design plutôt que de contourner une incompatibilité fondamentale. Pour quiconque développe de l’IA agentielle, la leçon principale est d’accepter que le fournisseur du modèle, le format API et même le framework de l’agent puissent évoluer au cours du développement. Ce sont les projets capables de résister à ces changements qui méritent d’être mis en production.
Trois leçons plus mineures en découlent :
- Abstraitez le fournisseur du LLM dès le premier jour. Une fonction
get_llm()qui vérifie plusieurs identifiants possibles, au lieu d’en coder un fixe partout, a transformé la migration vers Gemini en une simple modification de fichier plutôt qu’en une réécriture complète.
github_models_retirement_brownout que Function call is missing a thought_signature ressemblent à de simples erreurs 400. Le fait qu’un problème ait nécessité cinq minutes ou une demi-journée pour être résolu dépendait de ce que disait le corps de l’erreur, tandis que le texte de titre ne donnait jamais toutes les informations.create_agent choisi, un commentaire dans requirements.txt explique précisément pourquoi la version plus ancienne 0.3.x ne fonctionne plus, afin que personne n’ait à le découvrir par essais-erreurs.