Agen medyczny, który łączy bazy danych SQLite z wyszukiwarką internetową
Stwórz agenta LangChain, który decyduje, czy pytanie medyczne wymaga zapytania SQL w trzech zestawach danych, czy też wyszukiwania w sieci w czasie rzeczywistym, plus opis dwóch zmian, które wpłynęły na projekt.
Źródła: Building a Multi-Tool AI Agent That Knows When to Query a Database and When to Search the Web
Wiele chatbotów opartych na sztucznej inteligencji to rozwiązania o ograniczonych możliwościach. Zadaj pytanie, a one albo wymyślą odpowiedź z pamięci, albo odmówią, ponieważ nie mogą uzyskać dostępu do twoich danych. Prawdziwe systemy muszą być inteligentniejsze i wybierać odpowiedni źródło informacji dla każdego pytania. Ten projekt właśnie temu się poświęca w przypadku pytań medycznych: jest to agent wielofunkcyjny, który odczytuje pytanie i sam decyduje, czy odpowiedź znajduje się w strukturyzowanej bazie danych, czy na otwartym internecie. Łączy on trzy bazy danych SQLite z narzędziem do wyszukiwania w sieci w czasie rzeczywistym, a po rzekomym zakończeniu pracy napotkał dwa prawdziwe problemy.
Cel
Zadanie w ramach tego kursu wymagało stworzenia agenta, który potrafiłby wykonywać trzy zadania:
- Odpowiadać na pytania statystyczne dotyczące trzech rzeczywistych zbiorów danych medycznych – chorób serca, raka i cukrzycy – z których każdy jest przechowywany w osobnej bazie danych SQLite.
Jeden mózg, cztery narzędzia oraz umiejętność dopasowania każdego zadania do odpowiedniego narzędzia.
Architektura
Model mentalny pełni rolę dyspozytora:
┌────────────────────────┐
│ 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)
Główny agent nigdy nie dotyka samej danych. Jego zadaniem jest odczytanie pytania, wybór specjalisty i przekazanie mu zadania. Pytanie dotyczące średniego poziomu cholesterolu w zbiorze danych na choroby serca należy do HeartDiseaseDBTool, natomiast pytanie o przyczyny cukrzycy typu 2 trafia do MedicalWebSearchTool. Jeśli pytanie łączy oba te aspekty, agent wykorzystuje obie narzędzia i łączy ich wyniki w jedną spójną odpowiedź.
Zbiory danych
Trzy rzeczywiste, publiczne zestawy danych medycznych z Kaggle zostały przekształcone w bazy danych SQLite z kolumnami o odpowiednich typach danych. Dane dotyczące chorób serca znajdują się w pliku heart_disease.db i zawierają 303 wiersze opisujące wiek, płeć, cholesterol, ciśnienie krwi oraz cechę docelową. Pomiary komórek nowotworowych są przechowywane w pliku cancer.db i obejmują 101 wierszy opisujących grubość skupisk, rozmiar komórek, jądra bez otoczenia oraz proces mitozy. Dane dotyczące cukrzycy u Indian Pima znajdują się w pliku diabetes.db i zawierają 768 wierszy opisujących poziom glukozy, BMI, liczbę ciąż oraz wynik choroby.
Przekształcanie plików CSV w bazy danych SQLite wydaje się proste, ale prawidłowe wykonanie tego zadania wymaga sensownych nazw tabel oraz poprawnie określonych typów kolumn (INTEGER, REAL, TEXT), a także unikania umieszczania wszystkiego jako tekstu. Logika konwersji zaczyna się od mapowania typów danych z pandas na typy SQLite:
DTYPE_MAP = {"int64": "INTEGER", "float64": "REAL", "object": "TEXT", "bool": "INTEGER"}
Następnie pomocnik tworzy definicje kolumn w instrukcji CREATE TABLE na podstawie typów danych 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)
Gdy bazy danych już istnieją, można polegać na tym, że ich schemat odzwierciedla rzeczywiste dane, co ma ogromne znaczenie, gdy model językowy ma za zadanie napisać do nich zapytanie SQL.
Budowanie narzędzi do bazy danych
Wszystkie trzy narzędzia do bazy danych postępują zgodnie z tymi samymi trzema krokami, dzięki czemu jedna wspólna funkcja zastępuje trzy wersje tej logiki:
- Bierze się pytanie użytkownika w języku naturalnym.
- Model językowy przekształca to pytanie w zapytanie SQL typu SELECT tylko, wykorzystując schemat tabeli jako kontekst.
- Eksploatuje się to zapytanie, a następnie ponownie prosi model językowy o przepisanie surowych wyników na odpowiedź w zwykłym języku angielskim.
Prompt służący do generowania tekstu przedstawia model jako eksperta od SQLite i wymaga syntaktycznie poprawnego zapytania SELECT tylko do odczytu:
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.
Pozostała część szablonu dostarcza schemat i pytanie oraz wymaga wyłącznie surowego SQL, bez żadnych wyjaśnień i bez markdowna. Następuje funkcja oczyszczania:
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
To zabezpieczenie _clean_sql wykonywa niepozorną, ale ważną funkcję. Bez względu na to, co wygeneruje model językowy, agent nie może modyfikować leżących u jego podstaw danych, dzięki czemu dostęp pozostaje wyłącznie do odczytu.
Każde z tych trzech narzędzi stanowi w rzeczywistości prostą otoczkę, która określa nazwę bazy danych, tabeli oraz – co kluczowe – zawiera jasny opis wskazujący agentowi routingu, kiedy należy je użyć:
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."
),
)
Ostatnie zdanie każdego opisu, czyli wyraźne stwierdzenie, do czego nie powinno się używać danego narzędzia, ma większe znaczenie, niż można by się spodziewać. Skuteczność routingu opartego na LLM zależy od tego, jak dokładnie opisany jest zakres działania każdego narzędzia.
Narzędzie do wyszukiwania w internecie
Dla ogólnej wiedzy medycznej API Tavily Search znajduje się za narzędziem, którego opis jest równie starannie przemyślany. Ogranicza ono to narzędzie do wiedzy ogólnej, aby nie mogło być mylone z pytaniami statystycznymi:
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,
)
Konfiguracja agenta
Gdy cztery narzędzia zostały już stworzone, ostatnim elementem jest router: instrukcja systemowa wyjaśniająca logikę decyzyjną w prostych słowach, plus konstruktor agenta z LangChain służący do połączenia wszystkich elementów. Instrukcja zaczyna się od poinformowania modelu, że jest on profesjonalnym asystentem do danych medycznych posiadającym cztery narzędzia:
SYSTEM_PROMPT = """You are a professional medical data assistant with access to four tools.
Zasady routingu są poniżej i muszą być ściśle przestrzegane. Pytania dotyczące statystyk, danych, liczb, ilości, średnich lub konkretnych rekordów z zestawu danych kierowane są do odpowiedniego narzędzia bazy danych, natomiast prośby o definicję, objawy, przyczyny lub metody leczenia kierowane są do wyszukiwarki internetowej:
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)
Zapytaj o średni poziom cholesterolu w zestawie danych dotyczącym chorób serca – odpowiedź wynosi około 246,26. Zapytaj o typowe objawy chorób serca, a agent zmienia kierunek działania, wyszukuje informacje w internecie i zwraca uporządkowaną listę obejmującą ból w klatce piersiowej, duszność, zmęczenie i inne objawy, wraz z źródłami i bez dodawania informacji z własnej fantazji.
Gdy sytuacja się zmieniła
Najbardziej pouczająca część procesu tworzenia nie była zaplanowana. Dwie rzeczy uległy awarii po ukończeniu pracy agenta.
Pierwszym problemem było to, że usługa darmowego backendu LLM zniknęła. Agent został stworzony na bazie GitHub Models, darmowego punktu końcowego do przetwarzania danych kompatybilnego z OpenAI. W trakcie testów każda próba połączenia zaczęła zawodzić z błędem github_models_retirement_brownout, ponieważ GitHub ostatecznie wycofał tę usługę. Żadne naprawy w kodzie projektu nie mogły tego naprawić. Rozwiązaniem było przeniesienie całego backendu do Google Gemini, co wymagało przeprojektowania pliku config.py, aby obsługiwał kilku dostawców (Gemini, Azure OpenAI oraz bezpośrednio OpenAI), dzięki czemu nie pozostałby żaden pojedynczy punkt awarii.
Następnie najnowsze modele Gemini złamały ramy agenta. Nowsze modele myślenia Google’a (rodzina 3.x) oznaczają każde wezwanie narzędzia wewnętrznym thought_signature – tokenem, który klient musi odesłać w następnej turze. Oryginalny agent łączył starsze funkcje LangChain, mianowicie create_tool_calling_agent i AgentExecutor, a to połączenie odbudowywało własną pamięć przetwarzania wiadomości między krokami, usuwając podpis bez żadnego ostrzeżenia. W rezultacie każde wezwanie narzędzia kończyło się niepowodzeniem z tajemniczym błędem Function call is missing a thought_signature.
Żaden patch tego nie naprawił. Rozwiązaniem było przejście na obecną funkcję LangChain create_agent, która opiera się na LangGraph i przekazuje oryginalne obiekty wiadomości w każdej turze, zamiast je odbudowywać. Podpis pozostaje automatycznie dołączony, a cała ta grupa błędów znika.
Główna myśl polega na tym, że systemy agentowe opierają się na infrastrukturze, która ciągle się zmienia. Dostawcy wycofują stare punkty końcowe, a nowe generacje modeli wprowadzają nowe wymagania protokołowe. Prawdziwą umiejętnością, która jest tu sprawdzana, jest diagnozowanie przyczyn występowania błędu 400, a nie tylko rozumienie treści jego komunikatu – to ważniejsze niż jakikolwiek pojedynczy wiersz kodu.
Obserwowanie decyzji routera
Najbardziej satysfakcjonujący moment nastąpił, gdy zobaczyliśmy, jak agent zadaje pytania po pytaniu i podejmuje właściwe decyzje, bez żadnych wskazówek co robić. Przykładowa sesja to ilustruje.
Gdy zapytano o średni rozmiar pojedynczej komórki nabłonkowej w danych dotyczących raka, agent skierował się do CancerDBTool, wygenerował zapytanie SELECT AVG(single_epithelial_cell_size) FROM cancer_records; i odpowiedział, że średnia wynosi około 2,37.
Gdy zapytano o średni wiek pacjentów zdiagnozowanych z cukrzycą, system skierował się do DiabetesDBTool i poprawnie wyfiltrował tylko tych pacjentów z diagnozą (Outcome = 1) przed obliczaniem średniej, a nie całą bazę danych. Odpowiedź wyniosła około 37 lat.
Ostatnie pytanie, dotyczące tego, czym jest cukrzyca i jaki jest średni poziom glukozy we wszystkich danych, stanowiło prawdziwą próbę. Agent rozpoznał złożone żądanie, wywołując MedicalWebSearchTool w celu uzyskania definicji oraz DiabetesDBTool w celu uzyskania danych o poziomie glukozy, a następnie połączył oba wyniki w jeden spójny akapit, mimo że nikt mu tego nie kazał. Takie zachowanie wynika z jednego wersu instrukcji systemowych: jeśli pytanie łączy oba rodzaje informacji, należy użyć obu narzędzi i jasno połączyć odpowiedzi. Kierowanie zapytaniami to nie magia, lecz precyzyjne instrukcje w połączeniu z modelem, który je dobrze realizuje.
Inżynieria instrukcji dla kierowania narzędziami
Najbardziej wpływową decyzją podczas tworzenia nie była żadna linijka kodu w Pythonie. Chodziło o to, jak napisane zostały pola description każdego narzędzia. Początkowe opisy były zbyt niejasne, odpowiadając na pytania dotyczące danych o chorobach serca, a agent czasami korzystał z wyszukiwarek internetowych w celu znalezienia informacji statystycznych, ponieważ nic tego nie wykluczało.
Rozwiązaniem było sprawienie, by każdy opis pełnił dwie funkcje: jasno określał, do czego służy narzędzie, oraz wyraźnie wskazywał, do czego nie służy.
"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."
Napisanie tego drugiego zdania ma mniej znaczenia pod względem kierowania zapytaniem niż sam prompt systemu. Cztery narzędzia, z których tylko jedno służy do uzyskiwania ogólnej wiedzy, sprawiają, że każda niejasność w opisach natychmiast skutkuje błędnie skierowanym zapytaniem. To dokładne, wzajemnie wykluczające się opisy narzędzi były tym, co naprawdę sprawdzano w tym zadaniu, a aspekty związane z SQL i API były ważne w drugim rzędzie.
Bезpieczeństwo od samego projektu
Przez cały czas obowiązywało kilka zasad:
- Zawsze dostęp tylko do odczytu. Zapytania zawierające
INSERT,UPDATE,DELETE,DROPlubALTERsą odrzucane przez wszystkie narzędzia SQL, więc żadne z nich nie może uzyskać dostępu do bazy danych. - Brak sfabrykowanych liczb. Prompt systemu zabrania tworzenia fałszywych statystyk, a jeśli narzędzie nie zwraca żadnych danych, agent o tym informuje.
.env, który jest wykluczony z kontroli wersji za pomocą pliku .gitignore, dzięki czemu żadne dane wrażliwe nie trafiają do repozytorium.Stack technologiczny
- LangChain (
create_agent, oparty na LangGraph) jako element zarządzający routowaniem - Google Gemini (
gemini-3.5-flash-lite) jako główne model LLM - SQLite do lekkiego przechowywania w formie plików wszystkich trzech zbiorów danych
- Tavily Search API do wyszukiwania wiedzy ogólnej w czasie rzeczywistym
- Python z SQLAlchemy i Pandas jako narzędzia łączące
Lekcje z procesu tworzenia
Ustalenie poprawnego funkcjonowania systemu zajęło całe popołudnie. Najtrudniejszym etapem było wzmocnienie jego odporności: agent musiał przetrwać wycofanie usługi jednego dostawcy oraz istotne zmiany w usługach innego, co wymagało uważnego analizowania informacji o błędach oraz gotowości do przebudowy architektury zamiast szukania obejść wobec podstawowej niekompatybilności. Dla wszystkich, którzy tworzą systemy oparte na agentach AI, kluczowym wnioskiem jest założenie, że dostawca modeli, format API, a nawet framework agenta mogą ulec zmianie w trakcie rozwoju. To właśnie projekty, które przetrwają takie zmiany, są warte wprowadzenia do użycia.
Powstają z tego trzy dodatkowe wnioski:
- Od samego początku należy abstrahować dostawcę modeli LLM. Funkcja
get_llm(), która sprawdza kilka możliwych danych uwierzytelniających zamiast wszędzie używać hardkodowanych danych jednego dostawcy, sprawiła, że migracja na Gemini polegała tylko na zmianie w jednym pliku, a nie na całkowitej przepracowaniu kodu.
github_models_retirement_brownout, jak i Function call is missing a thought_signature na pierwszy rzut oka wyglądają jak zwykłe błędy 400. To, czy rozwiązanie problemu zajęło pięć minut, czy pół dnia, zależało od tego, co mówiła treść błędu – nagłówek nigdy nie przedstawiał pełnego obrazu sytuacji.create_agent, komentarz w pliku requirements.txt wyjaśnia dokładnie, dlaczego starsza wersja 0.3.x już nie działa, dzięki czemu nikt nie musi odkrywać tego na własną rękę.Literatura pokrewna
- Rozdzielanie zapytań klientów za pomocą wielu agentów z LangGraph i Ollamą — Stworzenie grafu do rozdzielania zapytań przy użyciu trzech agentów i LangGraph: klasyfikatora, osoby odpowiadającej oraz ludzkiego moderatora, działających na lokalnym Ollamie lub przez API przy użyciu zaledwie jednej zmienionej linijki kodu.
- Budowa systemu badawczego z wykorzystaniem wielu agentów i LangChain Deep Agents — Praktyczny przegląd wzorca Deep Agents: orkiestratora, specjalistycznych agentów, bezpiecznych narzędzi oraz interfejsu Streamlit, przetestowanego zarówno z lokalnymi, jak i chmurowymi modelami językowymi.