Ein Medizinischer Agent, der zwischen SQLite-Datenbanken und Web-Suchen vermittelt
Erstellen Sie einen LangChain-Agenten, der entscheidet, ob eine medizinische Frage eine SQL-Abfrage in drei Datensätzen oder eine Live-Web-Suche erfordert, sowie zwei Designänderungen, die den Aufbau verändert haben.
Quellen: Building a Multi-Tool AI Agent That Knows When to Query a Database and When to Search the Web
Viele KI-Chattbots sind auf eine einzige Funktion beschränkt. Man stellt eine Frage, und sie erfinden entweder aus dem Gedächtnis eine Antwort oder lehnen ab, weil sie nicht auf die Daten zugreifen können. Reale Systeme müssen intelligenter sein und für jede Frage die richtige Quelle der Wahrheit auswählen. Dieses Projekt befasst sich genau damit bei medizinischen Fragen: ein Mehrzweck-Agent, der eine Frage liest und selbst entscheidet, ob die Antwort in einer strukturierten Datenbank oder im offenen Internet zu finden ist. Er wechselt zwischen drei SQLite-Datenbanken sowie einem Live-Web-Suchtool hin und her, und nachdem er angeblich fertiggestellt worden war, traten zwei echte Probleme auf.
Das Ziel
In der Aufgabenbeschreibung für diese Kursarbeit wurde ein Agent gefordert, der drei Dinge leisten kann:
- Statistische Fragen aus drei echten medizinischen Datensätzen beantworten, die Herzkrankheiten, Krebs und Diabetes umfassen, wobei jeder Datensatz in seiner eigenen SQLite-Datenbank gespeichert ist.
Ein Gehirn, vier Tools und die Fähigkeit, jede Aufgabe dem passenden Tool zuzuordnen.
Die Architektur
Das mentale Modell fungiert als Dispatcher:
┌────────────────────────┐
│ 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)
Der Hauptagent berührt die Daten selbst nie. Seine Aufgabe besteht darin, die Frage zu lesen, einen Spezialisten auszuwählen und die Aufgabe an ihn weiterzuleiten. Eine Frage zum durchschnittlichen Cholesterinspiegel im Herzkrankheits-Datensatz fällt unter HeartDiseaseDBTool, während eine Frage nach den Ursachen von Typ-2-Diabetes an MedicalWebSearchTool weitergeleitet wird. Wenn eine Frage beides umfasst, ruft der Agent beide Tools auf und fügt ihre Ausgaben zu einer zusammenhängenden Antwort zusammen.
Die Datensätze
Drei echte, öffentliche medizinische Datensätze von Kaggle wurden in SQLite-Datenbanken mit korrekt typisierten Spalten umgewandelt. Die Herzkrankheitsdaten befinden sich in heart_disease.db und umfassen 303 Zeilen mit Informationen zu Alter, Geschlecht, Cholesterin, Blutdruck sowie dem Zielwert. Die Messwerte von Krebszellen sind in cancer.db gespeichert und umfassen 101 Zeilen mit Angaben zur Dicke der Klumpen, Zellgröße, nackten Kernen und Mitose. Die Diabetesdaten der Pima-Indianer liegen in diabetes.db vor und umfassen 768 Zeilen mit Werten zu Glukose, BMI, Schwangerschaften und dem Ausgangsergebnis.
CSV in SQLite umzuwandeln scheint trivial, doch eine ordnungsgemäße Umsetzung erfordert sinnvolle Tabellennamen sowie korrekt ermittelte Spaltenarten (INTEGER, REAL, TEXT), wobei nicht alles als Text gespeichert wird. Die Umwandlungslogik beginnt mit einer Zuordnung von pandas-Datentypen zu SQLite-Typen:
DTYPE_MAP = {"int64": "INTEGER", "float64": "REAL", "object": "TEXT", "bool": "INTEGER"}
Dann erstellt ein Hilfsprogramm die Spaltendefinitionen einer CREATE TABLE-Anweisung aus den dtypes eines DataFrames:
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)
Sobald die Datenbanken vorhanden sind, kann man davon ausgehen, dass das Schema die tatsächlichen Daten widerspiegelt – was von großer Bedeutung ist, wenn ein LLM daraufhin SQL-Anfragen ausführen soll.
Erstellung der Datenbankwerkzeuge
Alle drei Datenbankwerkzeuge folgen denselben drei Schritten, wodurch eine einzige gemeinsame Funktionsdatei drei identische Logikteile ersetzt:
- Nehmen Sie die Frage des Benutzers in natürlicher Sprache entgegen.
- Lassen Sie das LLM diese Frage unter Verwendung des Schema der Tabelle als Kontext in einen SELECT-only SQL-Ausdruck umwandeln.
- Führen Sie die Abfrage aus und fordern Sie das LLM anschließend erneut auf, die Rohdaten in eine Antwort auf Alltagssprache umzuformulieren.
Der Erstellungsaufruf stellt das Modell als SQLite-Experten dar und verlangt einen syntaktisch korrekten, nur zum Lesen geeigneten SELECT-Ausdruck:
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.
Der Rest des Templates liefert das Schema sowie die Frage und verlangt ausschließlich rohen SQL-Code, ohne Erklärungen und ohne Markdown. Anschließend folgt eine Reinigungsfunktion:
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
Die Schutzfunktion _clean_sql erledigt zwar unauffällige, aber wichtige Aufgaben. Unabhängig davon, was das LLM erzeugt, kann der Agent die zugrunde liegenden Daten niemals ändern, sodass der Zugriff weiterhin nur für das Lesen bestimmt ist.
Jedes der drei Tools ist anschließend eine schlichte Umhüllung, die den Namen der Datenbank, der Tabelle sowie – was entscheidend ist – eine klare Beschreibung angibt, die dem Routing-Agenten mitteilt, wann es verwendet werden soll:
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."
),
)
Der letzte Satz jeder Beschreibung, die ausdrückliche Angabe dazu, wofür das Tool nicht verwendet werden sollte, ist wichtiger, als man erwarten würde. Die Funktionsfähigkeit von auf großen Sprachmodellen basierenden Routings hängt davon ab, wie präzise die Aufgabe jedes Tools beschrieben wird.
Das Web-Suchtool
Für allgemeines medizinisches Wissen steht die Tavily Search API hinter einem Tool, dessen Beschreibung genauso überlegt ist. Sie beschränkt das Tool auf allgemeines Wissen, damit es nicht mit einer statistischen Frage verwechselt werden kann:
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,
)
Zusammenbau des Agenten
Nachdem die vier Tools erstellt wurden, bleibt noch das Router-Element übrig: ein Systemprompt, der die Entscheidungslogik in einfachen Worten erläutert, sowie LangChains Agent-Konstruktor, um alles miteinander zu verbinden. Der Prompt beginnt damit, dem Modell mitzuteilen, dass es ein professioneller medizinischer Datenassistent mit vier Tools ist:
SYSTEM_PROMPT = """You are a professional medical data assistant with access to four tools.
Die Routing-Regeln sind folgende und müssen streng befolgt werden. Fragen zu Statistiken, Daten, Zahlen, Auflistungen, Durchschnitten oder spezifischen Einträgen aus einem Datensatz richten sich an das entsprechende Datenbankwerkzeug, während Anfragen nach Definitionen, Symptomen, Ursachen oder Behandlungen an die Websuche gerichtet werden:
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)
Fragt man nach dem durchschnittlichen Cholesterinspiegel im Herzkrankheits-Datensatz, ergibt sich als Antwort etwa 246,26. Fragt man nach den typischen Symptomen einer Herzkrankheit, ändert das System seinen Ansatz, durchsucht das Internet und liefert eine strukturierte Liste mit Symptomen wie Brustschmerzen, Atemnot, Müdigkeit und weiteren, inklusive Quellen ohne Erfindungen.
Als sich die Situation änderte
Der aufschlussreichste Teil der Entwicklung war nicht geplant. Zwei Dinge funktionierten nach Fertigstellung des Systems nicht mehr richtig.
Das erste Problem war, dass der kostenlose LLM-Backend verschwand. Der Agent war ursprünglich auf GitHub Models basiert, einem kostenlosen, OpenAI-kompatiblen Inferenz-Endpunkt. Während der Tests begannen alle Aufrufe wegen des Fehlers github_models_retirement_brownout zu versagen, da GitHub den Dienst endgültig eingestellt hatte. Keine Fehlerbehebung am eigenen Code des Projekts konnte das Problem beheben. Die Lösung bestand darin, den gesamten Backend auf Google Gemini zu verlegen, was eine Neugestaltung von config.py erforderte, um mehrere Anbieter (Gemini, Azure OpenAI und direkter OpenAI) zu unterstützen und so sicherzustellen, dass es keinen einzigen Ausfallpunkt mehr gibt.
Die neuesten Modelle von Gemini durchbrachen außerdem das Agentenframework. Googles neuere Denkmodelle (die 3.x-Familie) kennzeichnen jeden Tool-Aufruf mit einer internen thought_signature – einem Token, das der Client im folgenden Schritt zurücksenden muss. Das ursprüngliche Agentenkonzept kombinierte LangChains ältere Funktionen create_tool_calling_agent und AgentExecutor; diese Kombination erstellte zwischen den Schritten erneut ihre eigenen Nachrichten-Strukturen und warf die Signatur ohne Warnung weg. Dadurch fehlte bei jedem Tool-Aufruf die Signatur, was zum unklaren Fehler Function call is missing a thought_signature führte.
Kein Patch löste das Problem. Die Lösung bestand darin, auf LangChains aktuelle Funktion create_agent umzusteigen, die auf LangGraph basiert und die ursprünglichen Nachrichtenobjekte in jedem Schritt beibehält, anstatt sie neu zu erstellen. Dadurch bleibt die Signatur automatisch dabei, und alle damit verbundenen Fehler verschwinden.
Im Grunde beruhen agierende Systeme auf einer Infrastruktur, die sich ständig verändert. Anbieter deaktivieren Endpunkte, und neue Modellgenerationen bringen neue Protokollanforderungen mit sich. Die eigentliche Fähigkeit, die hier auf die Probe gestellt wird, besteht darin, herauszufinden, warum ein 400-Fehler auftritt – und nicht nur zu verstehen, was die Fehlermeldung besagt – mehr noch als jede einzelne Codezeile.
Beobachtung der Entscheidungsfindung des Routers
Der zufriedenstellende Moment entstand, als man beobachtete, wie der Agent Frage für Frage die richtige Entscheidung traf, ohne dass ihm gesagt wurde, was er tun soll. Eine Beispielsitzung zeigt dies.
Als nach der durchschnittlichen Größe einer einzelnen epithelialen Zelle in den Krebsdaten gefragt wurde, wendete sich der Agent an CancerDBTool, generierte den Befehl SELECT AVG(single_epithelial_cell_size) FROM cancer_records; und antwortete, dass der Durchschnitt etwa 2,37 beträgt.
Als nach dem Durchschnittsalter der mit Diabetes diagnostizierten Patienten gefragt wurde, wurde an DiabetesDBTool weitergeleitet und zuerst korrekt nur die diagnostizierten Patienten (Outcome = 1) gefiltert, bevor der Durchschnitt berechnet wurde – und nicht über den gesamten Datensatz. Die Antwort lag bei etwa 37 Jahren.
Die letzte Frage, welche Krankheit Diabetes ist und welcher der Durchschnittsglukosespiegel im gesamten Datensatz beträgt, stellte die eigentliche Prüfung dar. Der Agent erkannte eine kombinierte Anfrage, rief für die Definition MedicalWebSearchTool und für den Glukosespiegelwert DiabetesDBTool auf, und fügte beide Ergebnisse in einen zusammenhängenden Absatz zusammen – obwohl ihm das niemand gesagt hatte. Dieses Verhalten geht auf eine Zeile im Systemprompt zurück: Wenn eine Frage beide Arten von Informationen enthält, sollen beide Tools aufgerufen und die Antworten klar zusammengefasst werden. Die Weiterleitung ist keine Magie, sondern nur präzise Anweisungen zusammen mit einem Modell, das sie gut befolgt.
Prompt-Engineering für Tool-Weiterleitung
Die einflussreichste Entscheidung bei der Entwicklung war nicht eine Zeile Python. Es ging vielmehr darum, wie das description-Feld jeder Tool beschrieben wurde. Die ersten Beschreibungen waren zu vage und beantworteten eher Fragen zu Herzkrankheitsdaten; außerdem suchte das System gelegentlich im Internet nach Statistiken, weil nichts dagegen sprach.
Die Lösung bestand darin, jede Beschreibung dazu zu nutzen, zwei Dinge klarzustellen: erstens wofür das Tool gedacht ist und zweitens wofür es nicht gedacht ist.
"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."
Die Erstellung dieses zweiten Satzes hat ungefähr denselben Einfluss auf die Routierung wie der Systemprompt selbst. Vier Tools mit nur einem, das für allgemeines Wissen bestimmt ist, bedeuten, dass jede Unklarheit in den Beschreibungen sofort zu einer falsch gerouteten Frage führt. Genaue, sich gegenseitig ausschließende Toolbeschreibungen waren das eigentliche Prüfungskriterium, während die SQL- und API-Verarbeitung an Bedeutung zurückstand.
Sicherheit durch Design
Mehrere Prinzipien galten überall:
- Immer nur Lesezugriff. Aussagen mit
INSERT,UPDATE,DELETE,DROPoderALTERwerden von jedem SQL-Tool abgelehnt, sodass keine Anfragen die Datenbank erreichen können. - Keine erfundenen Zahlen. Der Systemprompt verbietet das Erfinden von Statistiken, und wenn ein Tool nichts zurückgibt, teilt der Agent dies mit.
.env-Datei, die durch .gitignore vom Versionskontrollsystem ausgeschlossen wird, sodass keine sensiblen Daten ins Repository gelangen.Technologiestack
- LangChain (
create_agent, basierend auf LangGraph) als Steuerungseinheit - Google Gemini (
gemini-3.5-flash-lite) als primäres Large Language Model - SQLite für eine leichte, auf Dateien basierende Speicherung aller drei Datensätze
- Tavily Search API zur Echtzeit-Auswertung allgemeiner Kenntnisse
- Python mit SQLAlchemy und Pandas als Verbindungsstück
Lektionen aus der Entwicklung
Es dauerte einen Nachmittag, bis der optimale Ablauf funktionierte. Die Sicherstellung seiner Stabilität war der langwierigste Teil: Der Agent musste länger bestehen als die Einstellung des Services eines Anbieters und eine grundlegende Änderung bei einem anderen, was ein geduldiges Durchlesen der Fehlermeldungen sowie die Bereitschaft erforderte, das Design umzustrukturieren, anstatt mit einer einfachen Inkompatibilität umzugehen. Für alle, die agentbasierte KI entwickeln, lautet die wichtigste Erkenntnis, davon auszugehen, dass sich der Modellanbieter, das API-Format und sogar das Agent-Framework während der Entwicklung ändern können. Nur Projekte, die solche Veränderungen überstehen, lohnen sich für eine Veröffentlichung.
Daraus ergeben sich drei weitere Lehren:
- Ab dem ersten Tag den LLM-Anbieter abstrahieren. Eine
get_llm()-Funktion, die mehrere mögliche Anmeldeinformationen überprüft, anstelle von überall fest codierten Anbietern, machte die Migration zu Gemini zu einer Änderung in nur einem Datei statt zu einer vollständigen Neuschreibung.
github_models_retirement_brownout als auch Function call is missing a thought_signature sehen auf den ersten Blick wie gewöhnliche 400-Fehler aus. Ob ein Problem fünf Minuten oder einen halben Tag zur Lösung brauchte, hing davon ab, was im Fehlerinhalt stand – die Überschrift gab dabei nie den vollständigen Überblick.create_agent-Stack entschieden hatte, erklärt ein Kommentar in requirements.txt genau, warum die ältere 0.3.x-Version nicht mehr funktioniert, sodass niemand dies auf die harte Tour wiederentdecken muss.