Un agente médico que enruta entre bases de datos SQLite y búsquedas en la web
Construir un agente de LangChain que decida si una pregunta médica requiere una consulta SQL en tres conjuntos de datos o una búsqueda en la web en tiempo real, además de dos cambios que modificaron el diseño.
Fuentes: Building a Multi-Tool AI Agent That Knows When to Query a Database and When to Search the Web
Muchos chatbots de IA son como caballos de un solo truco: si haces una pregunta, o inventan una respuesta de memoria o se niegan a responder porque no pueden acceder a tus datos. Los sistemas reales deben ser más inteligentes y elegir la fuente de información adecuada para cada pregunta. Este proyecto aborda precisamente eso en el caso de las preguntas médicas: un agente multifuncional que lee una pregunta y decide por sí mismo si la respuesta se encuentra en una base de datos estructurada o en la web abierta. Se comunica entre tres bases de datos SQLite y una herramienta de búsqueda en la web en tiempo real, y encontró dos problemas reales incluso después de haberse considerado terminado.
El objetivo
La instrucción para esta tarea del curso solicitaba un agente capaz de realizar tres cosas:
- Responder preguntas estadísticas a partir de tres conjuntos de datos médicos reales, relacionados con enfermedades cardíacas, cáncer y diabetes, cada uno almacenado en su propia base de datos SQLite.
Un cerebro, cuatro herramientas y el juicio para asignar cada tarea a la herramienta correspondiente.
La arquitectura
El modelo mental funciona como un despachador:
┌────────────────────────┐
│ 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)
El agente principal nunca manipula los datos directamente. Su función es leer la pregunta, elegir a un especialista y transferirle el trabajo. Una pregunta sobre el nivel promedio de colesterol en el conjunto de datos de enfermedades cardíacas corresponde a HeartDiseaseDBTool, mientras que una pregunta sobre las causas de la diabetes tipo 2 se envía a MedicalWebSearchTool. Si una pregunta combina ambos temas, el agente llama a ambas herramientas y fusiona sus resultados en una respuesta coherente.
Los conjuntos de datos
Tres conjuntos de datos médicos reales y públicos de Kaggle fueron convertidos en bases de datos SQLite con columnas de tipo adecuado. Los datos sobre enfermedades cardíacas se encuentran en heart_disease.db y contienen 303 filas que incluyen edad, sexo, colesterol, presión arterial y el valor objetivo. Las mediciones de las células cancerosas están en cancer.db y tienen 101 filas que abarcan el grosor de los grupos celulares, el tamaño de las células, los núcleos desnudos y la mitosis. Los datos sobre diabetes de los indígenas Pima se almacenan en diabetes.db y constan de 768 filas que incluyen glucosa, IMC, embarazos y el resultado final.
Convertir CSV a SQLite parece sencillo, pero hacerlo correctamente implica elegir nombres de tablas y tipos de columna significativos (INTEGER, REAL, TEXT) de forma acertada, y no almacenar todo como texto. La lógica de conversión comienza con una correspondencia entre los tipos de datos de pandas y los tipos de SQLite:
DTYPE_MAP = {"int64": "INTEGER", "float64": "REAL", "object": "TEXT", "bool": "INTEGER"}
Luego, un asistente crea las definiciones de columna de una instrucción CREATE TABLE a partir de los dtypes de 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)
Una vez que existen las bases de datos, se puede confiar en que el esquema refleje los datos reales, lo cual es de suma importancia cuando un LLM está a punto de escribir consultas SQL contra ellas.
Creación de las herramientas para bases de datos
Todas las tres herramientas para bases de datos siguen los mismos tres pasos, por lo que una única función compartida reemplaza a tres copias de la lógica:
- Tomar la pregunta en lenguaje natural del usuario.
- Hacer que el LLM convierta esa pregunta en una sentencia SQL de tipo SELECT únicamente, utilizando el esquema de la tabla como contexto.
- Ejecutar la consulta y, a continuación, pedirle al LLM que reescriba las filas en bruto como una respuesta en lenguaje sencillo.
El prompt de generación presenta al modelo como un experto en SQLite y exige una consulta SELECT de solo lectura, sintácticamente correcta:
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.
El resto del plantilla proporciona el esquema y la pregunta, e insiste únicamente en SQL sin formato, sin explicaciones y sin Markdown. A continuación se encuentra una función de limpieza:
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
Esa función de protección _clean_sql realiza un trabajo sutil pero importante. Sea lo que sea que genere el LLM, el agente nunca puede modificar los datos subyacentes, por lo que el acceso permanece de solo lectura.
Cada uno de los tres herramientas es entonces un envoltorio simple que indica su base de datos, su tabla y, lo más importante, una descripción clara que indica al agente de enrutamiento cuándo utilizarlo:
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 última oración de cada descripción, la declaración explícita sobre para qué no se debe utilizar la herramienta, es más importante de lo que se podría pensar. El funcionamiento del enrutamiento basado en LLMs depende en gran medida de cuán precisamente se describa la función de cada herramienta.
La herramienta de búsqueda en la web
Para conocimientos médicos generales, la API Tavily Search se encuentra detrás de una herramienta cuya descripción es igualmente cuidadosa. Limita su uso a conocimientos generales para evitar confusión con preguntas estadísticas:
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,
)
Montaje del agente
Una vez creadas las cuatro herramientas, el último componente es el enrutador: una instrucción al sistema que explica la lógica de toma de decisiones en términos sencillos, además del constructor de agentes de LangChain para unir todo. La instrucción comienza indicándole al modelo que es un asistente profesional de datos médicos con cuatro herramientas:
SYSTEM_PROMPT = """You are a professional medical data assistant with access to four tools.
Las reglas de enrutamiento que se detallan a continuación deben aplicarse estrictamente. Las preguntas sobre estadísticas, datos, números, conteos, promedios o registros específicos de un conjunto de datos deben dirigirse a la herramienta de base de datos correspondiente, mientras que las solicitudes de definiciones, síntomas, causas o tratamientos deben realizarse mediante búsquedas en la web:
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)
Pide el nivel promedio de colesterol en el conjunto de datos de enfermedades cardíacas y la respuesta es aproximadamente 246.26. Pregunta cuáles son los síntomas típicos de las enfermedades cardíacas y el agente cambia de enfoque, busca en la web y devuelve una lista estructurada que incluye dolor torácico, falta de aliento, fatiga y otros síntomas, con fuentes y sin inventar información.
Cuando cambió todo
La parte más instructiva del proceso de desarrollo no estaba planeada. Dos cosas fallaron después de que el agente estuvo listo.
El primer problema fue que el backend LLM gratuito desapareció. El agente se había desarrollado con GitHub Models, un endpoint de inferencia gratuito compatible con OpenAI. A mitad de las pruebas, todas las llamadas comenzaron a fallar con el error github_models_retirement_brownout, ya que GitHub había retirado permanentemente el servicio. Ninguna corrección en el código del proyecto podía solucionar ese problema. La solución fue trasladar todo el backend a Google Gemini, lo que implicó rediseñar config.py para que soportara varios proveedores (Gemini, Azure OpenAI y OpenAI directamente), de modo que no quedara ningún punto único de fallo.
A continuación, los modelos más recientes de Gemini rompieron el marco del agente. Los modelos de razonamiento más nuevos de Google (la familia 3.x) añaden a cada llamada a una herramienta una thought_signature interna, un token que el cliente debe devolver en el turno siguiente. El agente original combinaba los métodos antiguos create_tool_calling_agent y AgentExecutor de LangChain, y esa combinación reconstituía su propio registro de mensajes entre pasos, descartando la firma sin ninguna advertencia. Como resultado, cada llamada a una herramienta fallaba con el error poco claro Function call is missing a thought_signature.
Ningún parche lo solucionó. La solución fue pasar a utilizar el método actual create_agent de LangChain, que se basa en LangGraph y transmite los objetos de mensaje originales en cada turno en lugar de reconstruirlos. La firma se mantiene adjunta automáticamente, y toda esa serie de errores desaparece.
El punto principal es que los sistemas agentes se basan en una infraestructura que cambia continuamente. Los proveedores dejan obsoletos ciertos puntos finales, y las nuevas generaciones de modelos introducen nuevos requisitos de protocolo. Diagnosticar por qué ocurre un error 400, y no solo entender lo que indica su mensaje, es la verdadera habilidad que se pone a prueba aquí, más que cualquier línea de código en particular.
Viendo cómo decide el router
El momento satisfactorio llegó al observar cómo el agente tomaba las decisiones correctas una tras otra sin que se le indicara qué hacer. Una sesión de ejemplo lo demuestra.
Al solicitar el tamaño promedio de una célula epitelial individual en los datos del cáncer, el agente se dirigió a CancerDBTool, generó la consulta SELECT AVG(single_epithelial_cell_size) FROM cancer_records; y respondió que el promedio es aproximadamente 2.37.
Al solicitar la edad promedio de los pacientes diagnosticados con diabetes, se dirigió a DiabetesDBTool y filtró correctamente los pacientes diagnosticados (Outcome = 1) antes de calcular la media, y no sobre todo el conjunto de datos. La respuesta fue aproximadamente 37 años.
La última pregunta, sobre qué es la diabetes y cuál es el nivel promedio de glucosa en todo el conjunto de datos, fue la verdadera prueba. El agente interpretó una solicitud compuesta: llamó a MedicalWebSearchTool para obtener la definición y a DiabetesDBTool para obtener el valor de glucosa, y combinó ambos resultados en un único párrafo coherente, aunque nadie se lo pidió. Ese comportamiento se debe a una línea del prompt del sistema: si una pregunta combina ambos tipos de información, se deben utilizar ambas herramientas y las respuestas se deben combinar claramente. La redirección no es magia, sino instrucciones precisas junto con un modelo que las sigue adecuadamente.
Ingeniería de prompts para la redirección de herramientas
La decisión más influyente en el desarrollo no fue una línea de código en Python. Fue la forma en que se redactaba el campo description de cada herramienta. Las descripciones iniciales eran demasiado vagas, respondiendo más bien a preguntas sobre datos relacionados con enfermedades cardíacas, y el agente ocasionalmente realizaba búsquedas en la web para responder a preguntas estadísticas, ya que nada lo impedía.
La solución fue hacer que cada descripción cumpliera dos funciones: indicar claramente para qué sirve la herramienta y especificar explícitamente para qué no sirve.
"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."
Escribir esa segunda oración tiene aproximadamente el mismo peso en la orientación del proceso que el prompt del sistema. Cuatro herramientas con solo una destinada al conocimiento general significan que cualquier vaguedad en las descripciones se convierte de inmediato en una pregunta mal dirigida. Lo que realmente se evaluó en la tarea fueron descripciones precisas y mutuamente excluyentes de las herramientas, mientras que los aspectos relacionados con SQL y API eran secundarios.
Seguridad por diseño
Algunos principios se mantuvieron constantemente:
- Acceso solo de lectura, siempre. Las instrucciones que contienen
INSERT,UPDATE,DELETE,DROPoALTERson rechazadas por todas las herramientas SQL, por lo que ninguna puede acceder a la base de datos. - Ningún número inventado. El prompt del sistema prohíbe fabricar estadísticas, y si una herramienta no devuelve nada, el agente lo indica.
.env que está excluido del control de versiones mediante .gitignore, por lo que nada sensible llega al repositorio.Stack tecnológico
- LangChain (
create_agent, basado en LangGraph) como el cerebro de enrutamiento - Google Gemini (
gemini-3.5-flash-lite) como el LLM principal - SQLite para un almacenamiento ligero basado en archivos de los tres conjuntos de datos
- API de búsqueda Tavily para la recuperación en tiempo real de conocimientos generales
- Python con SQLAlchemy y Pandas como el elemento de unión
Lecciones aprendidas durante el desarrollo
Lograr que el flujo óptimo funcionara tomó toda una tarde. Reforzar su estabilidad fue la parte lenta: el agente tuvo que resistir hasta que finalizara el servicio de un proveedor y superar un cambio drástico en otro, lo que requirió leer detenidamente los mensajes de error y estar dispuesto a reestructurar el diseño en lugar de intentar solucionar la incompatibilidad básica de otra forma. Para quienes desarrollan IA basada en agentes, la lección principal es asumir que el proveedor del modelo, el formato de la API e incluso el marco del agente pueden cambiar durante el proceso de desarrollo. Solo los proyectos que logran superar esto merecen ser lanzados.
De esto se derivan tres lecciones menores:
- Abstraiga al proveedor del LLM desde el primer día. Una función
get_llm()que verifica varias credenciales posibles, en lugar de codificar de forma fija un proveedor en todas partes, convirtió la migración a Gemini en un cambio en un solo archivo y no en una reescritura completa.
github_models_retirement_brownout como Function call is missing a thought_signature parecen errores 400 comunes a simple vista. El hecho de que una solución tomara cinco minutos o medio día dependía de lo que indicaba el cuerpo del error, ya que el texto principal nunca contaba toda la historia.create_agent, un comentario en requirements.txt explica con exactitud por qué la versión anterior 0.3.x ya no funciona, de modo que nadie tenga que descubrirlo por las malas.Lecturas relacionadas
- Triaje de tiquetes de soporte multiagente con LangGraph y Ollama — Crea un grafo de triaje de tiquetes con tres agentes utilizando LangGraph: un clasificador, un respondiente y un control humano, que funciona con Ollama local o a través de una API, con solo una línea modificada.
- Construcción de un sistema de investigación multiagente con LangChain Deep Agents — Una explicación práctica del patrón de Deep Agents: un orquestador, agentes especializados, herramientas seguras y una interfaz frontal en Streamlit, probado con modelos LLM locales y en la nube.