Poser des questions de routage via des outils SQL et des recherches sur le Web avec un agent Gemini
Comment un agent de appel d’outils LangChain sur Vertex AI choisit entre trois outils SQLite texte-vers-SQL et une recherche web en temps réel, ainsi que les bugs liés aux données, aux dépendances et à l’authentification à anticiper.
Sources: Building a Multi-Tool AI Agent for Bangladesh: Teaching Gemini to Pick the Right Tool for the Job
Pourquoi un orchestrateur est supérieur à un modèle qui sait tout
La plupart des systèmes d’IA les plus avancés aujourd’hui suivent un schéma commun : un modèle de langage capable de comprendre l’intention se trouve devant plusieurs outils fiables et à portée restreinte, spécialisés dans la récupération de faits. Les assistants de support alternent entre un système de facturation et une base de connaissances ; les assistants de recherche alternent entre des recherches sur le Web et des documents internes. Le modèle n’a pas besoin de conserver les réponses ; il doit plutôt déterminer quel outil utiliser.
Le projet présenté ici applique ce modèle avec quatre spécialistes et un orchestrateur :
┌──────────────────────────┐
question ──────────▶ │ AgentExecutor │
│ (Gemini + tool-calling) │
└────────────┬──────────────┘
│ picks 1+ tools based on
│ their description
┌──────────────┬───────┴───────┬───────────────┐
▼ ▼ ▼ ▼
InstitutionsDBTool HospitalsDBTool RestaurantsDBTool WebSearchTool
│ │ │ │
institutions.db hospitals.db restaurants.db Tavily API
(SQLite) (SQLite) (SQLite) (live web)
InstitutionsDBToolcouvre les écoles, collèges et madrassas issus du registre officiel EIIN.HospitalsDBToolcouvre les établissements de santé publics enregistrés auprès du DGHS.RestaurantsDBToolcouvre les restaurants dont les données ont été collectées via Google Places.
WebSearchTool effectue une recherche en temps réel (via l’API Tavily) pour tout ce que les bases de données ne peuvent pas répondre.Tout fonctionne grâce à AgentExecutor de LangChain, avec Gemini sur Google Vertex AI qui agit comme moteur de raisonnement. Chaque outil de base de données transforme une question en langage naturel en requête SQL, l’exécute, puis convertit les résultats en phrases lisibles.
Quelques exemples de décisions de routage illustrent ces intentions :
- La question « Combien d’universités y a-t-il dans la division de Rajshahi ? » est envoyée à
InstitutionsDBTool, car il s’agit d’un comptage structuré à partir des données EIIN. - La question « Faites la liste des hôpitaux publics dans le district de Chattogram » est envoyée à
HospitalsDBTool, car il s’agit d’une liste structurée des établissements du DGHS. - « Trouver des restaurants dont le nom contient “biryani” près de Pirojpur ” renvoie à
RestaurantsDBTool, car il s’agit d’une recherche dans la table des restaurants. - « Quel est le rôle du DGHS au Bangladesh ? » renvoie à
WebSearchTool, car aucune base de données ne stocke ce type de connaissances générales.
L’agent ne consulte jamais les bases de données elles-mêmes. Tout ce qu’il voit, ce sont quatre descriptions en langue anglaise simple des outils, et la qualité du routage dépend de la qualité d’écriture de ces descriptions plutôt que de toute logique de branchement préprogrammée.
Examinez les ensembles de données avant de concevoir quoi que ce soit
Avant même l’existence de tout code d’agent, les trois ensembles de données ont été téléchargés depuis Hugging Face et examinés ligne par ligne. Cette étape s’est avérée plus utile que toutes les autres, car le descriptif du projet se révélait erroné quant au contenu des données. Il prévoyait des informations sur la capacité des lits dans les données hospitalières ainsi que sur les programmes universitaires dans les données relatives aux institutions. La réalité était différente :
all-bangladeshi-hospitalsest un répertoire des établissements et bureaux enregistrés auprès du DGHS/MOPA : nom, type d’établissement, agence, division, district et un indicateur indiquant s’il s’agit d’une institution privée ou publique. Il ne contient aucune donnée sur la capacité des lits ni du personnel.Institutional-Information-of-Bangladeshcouvre les écoles, collèges, madrassas et instituts techniques disposant d’une inscription EIIN. Il ne comprend pas d’informations sur les universités ni sur les programmes de formation.
Bangladeshi-Restaurant-Data provient du scrapage de Google Places : nom, adresse, coordonnées, note et nombre d’avis. Il n’y a pas de colonne indiquant la cuisine.Cette disparité entre la description et le contenu est courante. La bonne approche consiste non pas à adapter les données aux exigences initiales, mais à s’appuyer sur ce qui existe réellement et à noter ces incohérences afin que personne ne les considère plus tard comme des bugs. Dans ce cas, une recherche sur « biryani » a abouti à une correspondance textuelle avec le nom du restaurant, et un filtre de localisation à une correspondance textuelle avec l’adresse, puisqu’il n’y a ni champ de cuisine ni champ de quartier. Le compromis est mineur et clairement documenté.
Cette habitude plus générale vaut la peine d’être adoptée dès le début : considérer l’ensemble de données comme la source de vérité, et tout schéma ou description préliminaire comme une simple supposition. Ce que .head() affiche constitue la véritable spécification.
Chargement de CSVs désordonnés dans des tables SQLite typées
Chaque ensemble de données est nettoyé, converti en types appropriés et enregistré dans son propre fichier SQLite. Le schéma est déclaré explicitement afin que SQLite ne devine pas les types de colonnes, et pour que la chaîne de conversion texte en SQL dispose ensuite de noms de colonnes significatifs. Notez la contrainte NOT NULL sur name ainsi que le flag entier is_private avec une valeur par défaut de zéro :
HOSPITALS_SCHEMA = """
CREATE TABLE hospitals (
facility_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
name_bangla TEXT,
code INTEGER,
agency TEXT,
facility_type TEXT,
division TEXT,
district TEXT,
city_corporation TEXT,
upazila TEXT,
is_private INTEGER NOT NULL DEFAULT 0
);
"""
La fonction de nettoyage associe les en-têtes bruts du CSV à ces noms de schéma, convertit l’identifiant et le flag de confidentialité en nombres (les valeurs invalides deviennent des nulls au lieu de provoquer une erreur), supprime les lignes manquant d’un identifiant ou d’un nom, et élimine les installations dupliquées :
def clean_hospitals(df: pd.DataFrame) -> pd.DataFrame:
rename_map = {
"Id": "facility_id", "Name": "name", "Name (Bangla)": "name_bangla",
"Code": "code", "Agency": "agency", "Type": "facility_type",
"Division": "division", "District": "district",
"City Corporation": "city_corporation", "Upazila": "upazila",
"Private": "is_private",
}
out = _select_and_rename(df, rename_map, required=["facility_id", "name"]).copy() out["facility_id"] = pd.to_numeric(out["facility_id"], errors="coerce").astype("Int64")
out["is_private"] = pd.to_numeric(out["is_private"], errors="coerce").fillna(0).astype(int) out = out.dropna(subset=["facility_id", "name"]).drop_duplicates(subset=["facility_id"])
return out.reset_index(drop=True)
Faites attention à l’appel à _select_and_rename : on pourrait s’attendre à un simple df.rename(). Ce outil d’aide n’a été ajouté qu’après une véritable panne, décrite dans la section des bugs ci-dessous. C’est un bon exemple de code défensif dont l’utilité n’apparaît que lorsque l’on sait ce qui a déraillé.
Une usine d’outils reutilisables pour convertir du texte en SQL
Tous les trois outils de base de données suivent un flux identique ; ils sont donc écrits une seule fois sous forme d’usine et appelés trois fois. L’usine ouvre la base de données via SQLDatabase.from_uri, crée une chaîne permettant d’écrire du SQL avec create_sql_query_chain, prépare un outil capable d’exécuter des requêtes avec QuerySQLDatabaseTool, et compose enfin une chaîne de formatage des réponses à partir d’une instruction, du modèle et d’un analyseur de chaînes de caractères :
def build_db_qa_tool(*, tool_name: str, description: str, db_path: Path, llm) -> Tool:
db = SQLDatabase.from_uri(f"sqlite:///{db_path}")
write_query = create_sql_query_chain(llm, db)
execute_query = QuerySQLDatabaseTool(db=db)
format_answer = _ANSWER_PROMPT | llm | StrOutputParser()
La fonction interne effectue le travail réel pour chaque question. Elle demande au modèle de générer du SQL, extrait la première instruction SELECT, impose un accès en lecture seule ainsi qu’une limite de lignes, l’exécute, puis transmet la question, la requête et les résultats bruts à la chaîne de formatage. Toute exception se transforme en un message lisible plutôt qu’en un traceur d’erreur, ce qui permet également à l’agent de disposer d’informations utiles pour raisonner :
def _answer(question: str) -> str:
try:
raw_sql = write_query.invoke({"question": question})
sql = _enforce_read_only_and_limit(_first_select_statement(raw_sql), MAX_SQL_RESULT_ROWS)
result = execute_query.invoke(sql)
except Exception as exc:
return f"I could not query the {tool_name} database for that question (reason: {exc})." return format_answer.invoke({"question": question, "query": sql, "result": result}) return Tool(name=tool_name, description=description, func=_answer)
Trois choix de conception dans ce fragment peuvent être négligés dans une première version :
- Le modèle génère le SQL via
create_sql_query_chain. Il n’y a pas de modèles de requêtes gérés manuellement pour rester en synchronisation avec le schéma.
_enforce_read_only_and_limit refuse tout ce qui n’est pas une requête SELECT et limite le nombre de lignes, de sorte qu’un modèle confus ou manipulé ne peut ni modifier les données ni exporter toute une table dans la fenêtre de contexte.Une mesure de sécurité supplémentaire utile, si vous adaptez ce modèle, consiste à ouvrir le fichier SQLite en mode lecture seule également. Les vérifications de chaînes dans le SQL peuvent être contournées de manières subtiles, et une connexion à la base de données qui ne peut physiquement pas écrire constitue une deuxième ligne de défense.
Laisser les descriptions des outils gérer le routage
C’est ici que le script devient un agent. L’invite du système commence par définir le rôle de l’assistant :
SYSTEM_PROMPT = """You are a multi-tool research assistant for Bangladesh.
Il énumère ensuite les quatre outils et indique trois règles de routage : utiliser l’outil de base de données correspondant pour les comptages, les listes ou les enregistrements spécifiques, et ne jamais estimer des chiffres ; utiliser la recherche sur le Web pour obtenir des connaissances générales ; et lorsque une question concerne plusieurs ensembles de données, appeler chaque outil pertinent puis fusionner les résultats. En dessous du prompt, create_tool_calling_agent relie le modèle, les outils et le prompt ensemble, tandis que AgentExecutor exécute la boucle avec une limite de six itérations et gère de manière tolérante les appels d’outils mal formatés :
You have four tools:
- InstitutionsDBTool : Bangladeshi schools, colleges, madrashas (EIIN registry)
- HospitalsDBTool : Bangladeshi government health facilities and offices
- RestaurantsDBTool : Bangladeshi restaurants and eateries (Google Places data)
- WebSearchTool : live web search, for anything the three databases don't coverRouting rules:
1. If the question asks for a count, list, or specific record from one of the
three datasets, call the matching DB tool. Never guess numbers yourself.
2. If the question is general knowledge or not covered by any DB tool, call
WebSearchTool.
3. If a question spans more than one dataset, call each relevant tool
separately and combine the results.
"""agent = create_tool_calling_agent(llm=llm, tools=tools, prompt=prompt)
executor = AgentExecutor(agent=agent, tools=tools, max_iterations=6, handle_parsing_errors=True)
Rien ici ne correspond à des mots-clés, n’applique de regex ni ne met en correspondance des phrases avec des outils. Le modèle compare la question à chaque description et choisit ensuite. Cela fait des descriptions les chaînes de caractères les plus importantes dans le codebase : si elles sont vagues ou se chevauchent, l’agent commencera à sélectionner le mauvais outil, et aucune modification des instructions ne pourra compenser pleinement ce problème. La limite max_iterations=6 est également utile, car elle limite les coûts et empêche un agent en boucle d’appeler indéfiniment des outils.
Pourquoi l’AgentExecutor classique plutôt que LangGraph
LangChain oriente progressivement son architecture d’agent recommandée vers LangGraph, qui offre un contrôle plus fin sur les workflows multi-étapes à état. Pour ce cas d’usage, create_tool_calling_agent avec AgentExecutor convient mieux : chaque requête correspond à une question, une ou deux appels d’outils et une réponse. Un cadre graphique ajouterait une structure dont le problème n’a pas besoin. Choisir la plus petite architecture capable de résoudre le problème est une compétence à part entière, distincte du simple fait de savoir qu’une option plus puissante existe. Si votre système de routage nécessite ultérieurement des branches, des tentatives répétées ou un état persistant, c’est alors que l’utilisation d’un graphique devient pertinente ; l’article sur la conception de graphes d’agents résilients aborde ce sujet.
Trois échecs qui n’avaient rien à voir avec le modèle
La partie la plus instructive du processus de développement n’a pas été ce qui a fonctionné dès la première tentative, mais plutôt ce qui a échoué. Aucun des trois problèmes ne concernait les prompts ni la qualité du modèle.
Deux versions de LangChain installées côte à côte
LangChain 1.0 a réorganisé son module agents et déplacé le modèle classique AgentExecutor dans un package distinct. L’environnement Colab a fini par avoir les deux versions installées, de sorte qu’une importation qui fonctionnait dans une cellule provoquait ImportError: cannot import name 'AgentExecutor' dans la suivante.
La correction était de nature procédurale et non une modification du code : il fallait désinstaller explicitement les paquets en conflit, spécifier des versions exactes au lieu de plages, puis redémarrer l’environnement d’exécution avant de continuer. La gestion des dépendances constitue un véritable travail d’ingénierie. Vérifiez la documentation actuelle de LangChain pour savoir où se trouve AgentExecutor dans la version que vous installez, car cela a changé d’une version à l’autre.
Un nom de colonne contenant un espace au lieu d’un underscore
L’outil de visualisation des données de Hugging Face affichait une colonne nommée INSTITUTE_NAME, mais le fichier téléchargé utilisait en réalité INSTITUTE NAME. Ce seul caractère a provoqué une erreur KeyError à plusieurs appels de fonction de l’endroit où se trouvait la véritable cause du problème.
La réponse était l’aide-mémoire _select_and_rename vue précédemment. Celui-ci supprime tous les caractères non alphanumériques avant de comparer les noms de colonnes, de sorte que INSTITUTE_NAME, INSTITUTE NAME et institute-name correspondent tous. Lorsqu’une colonne requise ne peut toujours pas être trouvée, il affiche la liste réelle des colonnes au lieu d’échouer silencieusement. Un bon message d’erreur constitue en soi un outil de débogage.
Format de clé API rejeté par l’API Gemini
Google a commencé à introduire un nouveau format de clé API qui commence par AQ. au lieu du format familier AIzaSy. Au moment où ce projet a été développé, les clés dans le nouveau format étaient rejetées par l’API Gemini directe avec une erreur opaque 401 ACCESS_TOKEN_TYPE_UNSUPPORTED. La logique de tentative ne peut pas corriger des identifiants que l’endpoint ne accepte pas.
La solution consistait à changer complètement le chemin d’authentification : l’agent s’authentifie désormais auprès de Google Vertex AI via OAuth, tout comme n’importe quel autre service Google Cloud. Cela évite le problème de format des clés et constitue sans doute la configuration la plus adaptée à un environnement de production. Comme ce comportement a pu changer depuis, veuillez le vérifier dans la documentation actuelle de Google Cloud avant de vous y fier.
Le schéma commun à ces trois cas : dans l’ingénierie actuelle de l’intelligence artificielle, les éléments liés au fonctionnement du modèle (versions des paquets, schémas de données et flux d’authentification) tombent en panne bien plus souvent que le modèle lui-même.
L’agent en action
Dès que les bases de données sont créées et que l’agent est connecté, une question portant sur le nombre d’universités à Rajshahi est directement acheminée vers l’outil dédié aux institutions, qui répond en fournissant une donnée extraite de la base de données :
You: How many colleges are registered in Rajshahi division?
> Entering new AgentExecutor chain...
[Tool: InstitutionsDBTool] → "There are 812 colleges registered in Rajshahi division."
Dans la même session, une question de connaissance générale concernant le DGHS est envoyée à une recherche sur Internet, tandis qu’une demande de renseignements sur les hôpitaux gouvernementaux à Chattogram est dirigée vers la base de données des hôpitaux :
You: What is the role of DGHS in Bangladesh?
> Entering new AgentExecutor chain...
[Tool: WebSearchTool] → "DGHS (Directorate General of Health Services) is the
government body responsible for planning, managing, and delivering public
healthcare services across Bangladesh..."You: List a few government hospitals in Chattogram district.
> Entering new AgentExecutor chain...
[Tool: HospitalsDBTool] → "Chattogram district includes several government
health facilities such as Chattogram Medical College Hospital and Chattogram
General Hospital, both operating under DGHS..."
Trois sources de données différentes sont utilisées au cours d’une même conversation, et il n’existe nulle part de code indiquant à l’agent laquelle choisir. Le modèle prend chaque décision en se basant uniquement sur les descriptions des outils.
Leçons à retenir pour la prochaine version
- Vérifier les données avant d’écrire le schéma. Découvrir des lacunes dans l’ensemble de données après avoir créé les outils aurait signifié devoir réécrire les trois.
- Fixer les dépendances de manière plus stricte que ce qui semble nécessaire. Dans un écosystème où de grandes réécritures ont lieu tous les quelques mois, une plage de versions qui fonctionne bien aujourd’hui peut se comporter différemment dans un environnement neuf quelques semaines plus tard.
Conclusion
Dès que Gemini a reçu des outils propres et clairement décrits, l’appel à son outil correspondant a fonctionné correctement. Le modèle n’était pas la partie difficile ; ce qui prenait du temps, c’était l’infrastructure environnante : des versions de bibliothèques incompatibles, un espace superflu dans l’en-tête d’une colonne et un format de identifiants que l’API refusait d’accepter. Si vous développez un agent multi-outils, investissez tôt dans trois éléments : des descriptions d’outils précises, des contrôles stricts sur tout ce que le modèle génère pour l’exécution, et un environnement reproductible. Ce sont eux qui déterminent si l’agent fonctionne aussi bien en production qu’en démonstration.