Медичний агент, який забезпечує зв’язок між базами даних SQLite та веб-пошуком
Створіть агента LangChain, який вирішує, чи потребує медичне запитання SQL-запит до трьох наборів даних чи пошук у Інтернеті, а також два збої, які змінили дизайн.
Джерела: Building a Multi-Tool AI Agent That Knows When to Query a Database and When to Search the Web
Багато ШІ-чат-ботів є примітивними інструментами з однією функцією. Якщо поставити запитання, вони або вигадують відповідь з пам’яті, або відмовляються, оскільки не можуть отримати доступ до ваших даних. Справжні системи мають бути розумнішими та обирати правильне джерело інформації для кожного запитання. Цей проект саме цим і займається у сфері медичних запитань: це багатофункціональний агент, який читає запитання та сам вирішує, чи знаходиться відповідь у структурованій базі даних чи в відкритому Інтернеті. Він переходить між трьома базами даних SQLite та інструментом пошуку в Інтернеті, проте після того, як, за задумом, був завершений, зіткнувся з двома реальними проблемами.
Мета
У завданні на цьому курсі просили створити агента, який міг би виконувати три завдання:
- Відповідати на статистичні запитання з трьох реальних медичних наборів даних, що стосуються серцевих захворювань, раку та діабету; кожен з цих наборів зберігається у власній базі даних SQLite.
Один мозок, чотири інструменти та здатність підбирати відповідний інструмент для кожної задачі.
Архітектура
Ментальна модель є диспетчером:
┌────────────────────────┐
│ 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)
Основний агент ніколи не торкається самих даних. Його завдання — прочитати запит, вибрати спеціаліста та передати йому роботу. Запит про середній рівень холестерину в наборі даних про серцеві захворювання належить до HeartDiseaseDBTool, тоді як запит про причини цукрового діабету 2 типу передається MedicalWebSearchTool. Якщо запит поєднує обидва аспекти, агент використовує обидві інструменти та об’єднує їхні результати в одну послідовну відповідь.
Набори даних
Три справжніх публічних медичних набори даних Kaggle були перетворені на бази даних SQLite із колонками з правильним типом даних. Дані про хвороби серця зберігаються у файлі heart_disease.db та містять 303 рядки з інформацією про вік, стать, холестерин, артеріальний тиск та кінцевий результат. Дані про клітини раку знаходяться у файлі cancer.db та містять 101 рядок з показниками товщини утворень, розміру клітин, ядер без оболонки та процесу мітозу. Дані про діабет серед індіанців племені Піма зберігаються у файлі diabetes.db та містять 768 рядків з інформацією про рівень глюкози, індекс маси тіла, кількість вагітностей та результати лікування.
Перетворення CSV у SQLite здається простим, але для правильного виконання необхідно обирати змістовні назви таблиць та правильно визначати типи колонок (INTEGER, REAL, TEXT), щоб не записувати все у вигляді тексту. Логіка перетворення починається зі збігу типів даних у pandas та типів у SQLite:
DTYPE_MAP = {"int64": "INTEGER", "float64": "REAL", "object": "TEXT", "bool": "INTEGER"}
Допоміжний модуль створює визначення стовпців для оператора CREATE TABLE на основі типів даних 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)
Як тільки бази даних створені, можна бути впевненим, що схема відображає реальні дані, що має велике значення, коли ШІ готується писати SQL-запити до них.
Створення інструментів для баз даних
Усі три інструменти для баз даних виконують однакові три кроки, тому одна спільна функція замінює три версії логіки:
- Отримати запит користувача мовою природного мовлення.
- Змусити ШІ перетворити цей запит на SQL-запит, який використовує лише оператор SELECT, використовуючи схему таблиці як контекст.
- Виконати запит, а потім знову попросити ШІ переписати отримані дані у вигляді відповіді простою англійською мовою.
У запиті на генерацію модель розглядається як експерт з SQLite, і від неї вимагається синтаксично правильний запит SELECT з обмеженими правами читання:
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.
Решта шаблону містить схему та запит, вимагаючи використання лише сирого SQL без жодних пояснень та без форматування Markdown. Далі йде функція для очищення:
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
Цей захисний механізм _clean_sql виконує тиху, але важливу роботу. Що б не генерував ШІ, агент ніколи не може змінити базові дані, тому доступ залишається лише для читання.
Кожен із трьох інструментів є простим обгортком, який вказує назву бази даних, таблицю та, що особливо важливо, чіткий опис, який повідомляє агенту маршрутизації, коли його використовувати:
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."
),
)
Останнє речення кожного опису, пряме зазначення того, для чого не слід використовувати інструмент, має значення, більше ніж очікувалося. Ефективність маршрутизації на основі ШІ залежить від того, наскільки точно описано функції кожного інструменту.
Інструмент пошуку в Інтернеті
Для отримання загальних медичних знань API Tavily Search працює в рамках інструменту, опис якого також ретельно продуманий. Він обмежує можливості інструменту загальними знаннями, щоб його не плутали з запитами на статистичну інформацію:
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,
)
Складання агента
Після створення чотирьох інструментів останнім елементом є маршрутизатор: системний запит, який простою мовою описує логіку прийняття рішень, та конструктор агентів LangChain для об’єднання всього воєдино. Запит починається з інформації про те, що модель є професійним асистентом з медичними даними, який має чотири інструменти:
SYSTEM_PROMPT = """You are a professional medical data assistant with access to four tools.
Правила маршрутизації наведені нижче та мають суворо дотримуватися. Запитання щодо статистики, даних, чисел, підрахунків, середніх значень чи конкретних записів з набору даних слід направляти до відповідного інструменту бази даних, а запити щодо визначення, симптомів, причин чи методів лікування — до пошуку в Інтернеті:
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)
Якщо запитати про середній рівень холестерину в наборі даних про серцеві захворювання, відповідь становить приблизно 246,26. Якщо запитати про типові симптоми серцевих захворювань, агент змінює стратегію, шукає інформацію в Інтернеті та повертає структурований список, який включає біль у грудях, задишку, втому та інше, з посиланнями та без вигадок.
Коли все змінилося
Найбільш корисною частиною роботи над проектом не було те, що планувалося. Два елементи зламалися після того, як агент був створений.
Першою проблемою було зникнення безкоштовного бекенду LLM. Агент був створений на базі GitHub Models — безкоштовного інтерфейсу обробки даних, сумісного з OpenAI. Під час тестування кожен виклик почав зазнавати невдач через помилку github_models_retirement_brownout, оскільки GitHub остаточно припинив надання цього сервісу. Дебаггінг власного коду проекту не допоміг вирішити цю проблему. Рішенням стало переміщення всього бекенду на Google Gemini, що вимагало переробки файлу config.py, щоб він підтримував кілька постачальників (Gemini, Azure OpenAI та прямий доступ до OpenAI), що усунуло б можливість однократної точки збою.
Далі найновіші моделі Gemini порушили принципи роботи фреймворку агентів. Новіші моделі для обробки думок від Google (сімейство 3.x) позначають кожен виклик інструменту внутрішнім токеном thought_signature, який клієнт повинен надіслати назад у наступному кроці. Початковий агент поєднував старіші функції LangChain create_tool_calling_agent та AgentExecutor, і це поєднання створювало власний „буфер“ повідомлень між кроками, видаляючи позначку thought_signature без жодних попереджень. У результаті кожен виклик інструменту зазнавав невдачі через неzрозумілу помилку Function call is missing a thought_signature.
Жоден патч не вирішив цю проблему. Рішенням стало переходження на поточну функцію LangChain create_agent, яка базується на LangGraph та передає оригінальні об’єкти повідомлень між кроками, замість того щоб їх перебудовувати. Позначка thought_signature автоматично залишається, і вся ця група помилок зникає.
Головна ідея полягає у тому, що агентні системи ґрунтуються на інфраструктурі, яка постійно змінюється під ними. Постачальники припиняють підтримку певних кінцевих точок, а нові версії моделей вимагають дотримання нових протоколів. Справжньою навичкою, яка тут перевіряється, є здатність з’ясувати причину виникнення помилки 400, а не просто зрозуміти її повідомлення, що є важливішим за будь-який окремий рядок коду.
Спостереження за рішенням маршрутизатора
Найбільше задоволення приносило спостереження за тим, як агент по черзі робив правильні вибори, не отримуючи прямих вказівок. Приклад сеансу це ілюструє.
Коли агента попросили надати середній розмір окремої епітеліальної клітини у даних про рак, він звернувся до CancerDBTool, сформував запит SELECT AVG(single_epithelial_cell_size) FROM cancer_records; та відповів, що середній розмір становить приблизно 2,37.
Коли було запитано про середній вік пацієнтів, у яких діагностували цукровий діабет, система спрямувала запит до DiabetesDBTool та правильно відфільтрувала дані лише для пацієнтів із діагнозом (Outcome = 1) перед обчисленням середнього значення, а не для всього набору даних. Відповідь становила приблизно 37 років.
Останнє запитання про те, що таке цукровий діабет та який середній рівень глюкози в усьому наборі даних, стало справжнім випробуванням. Агент розпізнав складний запит, звернувшись до MedicalWebSearchTool за визначенням та до DiabetesDBTool за даними про рівень глюкози, та поєднав обидва результати в один послідовний абзац, хоча ніхто його про це не просив. Така поведінка походить від одного рядка у системних інструкціях: якщо запит поєднує обидва типи інформації, необхідно використати обидві інструменти та чітко поєднати відповіді. Спрямування запитів — це не магія, а лише точні інструкції та модель, яка їх добре виконує.
Проектування інструкцій для спрямування запитів до інструментів
Найбільш впливовим рішенням під час створення не була жодна рядок коду на Python. Це був спосіб написання поля description кожного інструменту. Початкові описи були занадто розпливчастими, схожими на відповіді на запитання щодо даних про серцеві захворювання, і агент іноді використовував результати пошуку в Інтернеті для вирішення статистичних запитань, оскільки ніщо це не забороняло.
Рішенням було зробити так, щоб кожен опис виконував дві функції: чітко вказувати, для чого призначений інструмент, та прямо зазначати, для чого він не призначений.
"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."
Написання того другого речення має приблизно таку саму вагу з точки зору напрямку обробки, як і системний запит. Чотири інструменти, лише один з яких призначений для загальних знань, означають, що будь-яка нечіткість у описах миттєво призводить до поставлення неправильного запиту. Саме точні, взаємовиключні описи інструментів були справжнім тестом у завданні, а робота з SQL та API була на другому місці.
Безпека з самого початку
Протягом усього дотримувалися кількох принципів:
- Завжди лише читальний доступ. Запити, що містять
INSERT,UPDATE,DELETE,DROPабоALTER, відхиляються кожним інструментом SQL, тож жоден з них не може отримати доступ до бази даних. - Жодних вигаданих чисел. Системний запит забороняє створювати фальшиві статистичні дані, і якщо інструмент нічого не повертає, агент про це повідомляє.
.env, який виключений з контролю версій за допомогою .gitignore, тож жодна конфіденційна інформація не потрапляє до репозиторію.Технологічна стек
- LangChain (
create_agent, створений на основі LangGraph) як механізм маршрутизації - Google Gemini (
gemini-3.5-flash-lite) як основний LLM - SQLite для легкого, базованого на файлах зберігання всіх трьох наборів даних
- API Tavily Search для отримання актуальних загальних знань у реальному часі
- Python з SQLAlchemy та Pandas як зв’язуючий елемент
Уроки з процесу створення
Щоб налаштувати правильну роботу системи, знадобився один день. Найскладнішим було її зміцнення: агенту довелося витримати припинення роботи послуги одного постачальника та кардинальні зміни від іншого, що вимагало уважного аналізу повідомлень про помилки та готовності до реструктуризації дизайну замість пошуку обхідних шляхів для усунення базової несумісності. Для тих, хто створює агентські системи штучного інтелекту, основний урок полягає у тому, що потрібно враховувати можливі зміни з боку постачальника моделей, формату API та навіть фреймворку агента під час розробки. Саме проекти, які витримують такі зміни, варто запускати у продакшн.
З цього випливають три менші уроки:
- З самого початку абстрагуйте постачальника LLM. Функція
get_llm(), яка перевіряє кілька можливих облікових даних замість жорсткого вкодування одного постачальника скрізь, перетворила міграцію на Gemini на зміни в одному файлі, а не на повну переписування коду.
github_models_retirement_brownout, так і Function call is missing a thought_signature на перший погляд схожі на звичайні помилки 400. Те, чи знадобилося п’ять хвилин чи півдня, щоб вирішити проблему, залежало від того, що було написано у тілі помилки, адже заголовок ніколи не розповідав повної історії.create_agent у коментарі до файлу requirements.txt детально пояснюється, чому старіша версія 0.3.x більше не працює, щоб ніхто не мусив знову шукати рішення самостійно.