Главная / Статьи

Медицинский агент, обеспечивающий связь между базами данных SQLite и веб-поиском

Создайте агента LangChain, который будет определять, требуется ли для медицинского вопроса SQL-запрос к трем наборам данных или поиск в интернете, а также укажите две проблемы, которые изменили структуру проекта.

Издатель: REACTAPP.TOP11 мин чтения2494 слов

Источники: Building a Multi-Tool AI Agent That Knows When to Query a Database and When to Search the Web

Многие чат-боты на основе ИИ — это устройства с одной функцией. Если задать вопрос, они либо выдумывают ответ из памяти, либо отказываются отвечать, так как не могут получить доступ к вашим данным. Настоящие системы должны быть умнее и выбирать подходящий источник информации для каждого вопроса. Этот проект решает именно эту проблему для медицинских вопросов: это универсальный агент, который читает вопрос и самостоятельно определяет, находится ли ответ в структурированной базе данных или в открытом интернете. Он работает с тремя базами данных SQLite и инструментом поиска в интернете, но после того, как, казалось бы, был завершен, столкнулся с двумя реальными проблемами.

Цель

В задании на этот курс требовался агент, способный выполнять три задачи:

  1. Отвечать на статистические вопросы, основанные на трех реальных медицинских наборах данных, посвященных сердечным заболеваниям, раку и диабету; каждый набор хранится в отдельной базе данных 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-запросы к ним.

    Создание инструментов для баз данных

    Все три инструмента для работы с базами данных следуют одинаковым трём шагам, поэтому одна общая функция заменяет три варианта логики:

    1. Принимаем вопрос пользователя на естественном языке.
    2. При помощи ИИ-модели преобразуем этот вопрос в SQL-запрос, содержащий только операцию SELECT, используя схему таблицы в качестве контекста.
    3. Выполняем запрос, а затем снова просим ИИ-модель переписать полученные данные в виде ответа на простом английском языке.

    Подсказка для генерации представляет модель как эксперта по 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 без какого-либо предупреждения. В результате каждый вызов инструмента завершался с неясной ошибкой 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, поэтому ни один из них не может получить доступ к базе данных.
    • Никаких выдуманных чисел. Системное указание запрещает создавать фиктивные статистические данные, и если инструмент не возвращает ничего, агент об этом сообщает.
  • Секреты остаются локальными. Ключи API хранятся в файле .env, который исключен из контроля версий с помощью .gitignore, поэтому ничего конфиденциального не попадает в репозиторий.
  • Технологическая стек

    • LangChain (create_agent, основанный на LangGraph) в качестве механизма маршрутизации
    • Google Gemini (gemini-3.5-flash-lite) в качестве основного языка больших моделей
    • SQLite для легкого хранения всех трех наборов данных в файловом формате
    • API Tavily Search для получения информации из общедоступных источников в реальном времени
    • Python с библиотеками SQLAlchemy и Pandas в качестве связующего звена

    Уроки, полученные в процессе разработки

    На настройку корректной работы системы ушел один день. Наиболее сложным оказалось усиление ее защиты: агенту пришлось выдержать прекращение работы сервиса одного поставщика и кардинальные изменения в работе другого, что потребовало тщательного анализа сообщений об ошибках и готовности к реструктуризации архитектуры вместо попыток обойти фундаментальные несовместимости. Для тех, кто разрабатывает агентные системы искусственного интеллекта, главный урок заключается в необходимости предполагать, что поставщик моделей, формат API и даже собственная рамка агента могут измениться в процессе разработки. Именно те проекты, которые способны выдержать такие изменения, стоят того, чтобы их выпустить.

    Из этого следуют три более мелких урока:

    • С самого начала необходимо абстрагировать поставщика LLM. Функция get_llm(), проверяющая несколько возможных учетных данных вместо жесткой привязки к одному поставщику, позволила выполнить миграцию на Gemini с помощью изменений в одном файле, а не полной переработки кода.
  • Необходимо прочитать весь содержимое сообщения об ошибке, а не только его заголовок. С первого взгляда и github_models_retirement_brownout, и Function call is missing a thought_signature кажутся обычными ошибками 400. То, заняло ли решение проблемы пять минут или полдня, зависело от того, что говорилось в самом сообщении об ошибке, поскольку заголовок никогда не содержал полной информации.
  • Целенаправленно фиксируйте зависимости и документируйте причины этого. После выбора стека LangChain 1.x вместе с create_agent комментарий в файле requirements.txt подробно объясняет, почему более старая версия 0.3.x больше не работает, чтобы никому не пришлось выяснять это заново.