Вопросы маршрутизации через инструменты SQL и веб-поиск с помощью агента Gemini
Как агент для вызова инструментов LangChain на Vertex AI выбирает между тремя инструментами преобразования текста в SQL на SQLite и поиском в интернете в режиме реального времени, а также какие ошибки с данными, зависимостями и авторизацией можно ожидать.
Источники: Building a Multi-Tool AI Agent for Bangladesh: Teaching Gemini to Pick the Right Tool for the Job
Чат-бот, построенный на одном источнике данных, хорошо справляется только с одним типом вопросов и терпит неудачу, как только пользователь задает вопрос по смежной теме: он либо выдумывает ответ, либо сдаётся. Более полезной инженерной целью является система, которая осознаёт пределы собственных знаний и передаёт каждый вопрос тому компоненту, который действительно может на него ответить. В этом руководстве создаётся именно такой агент: единый элемент, отвечающий на вопросы о Бангладеш (школы, больницы, рестораны и общие сведения) путём выбора для каждого вопроса одного из четырёх специализированных инструментов. По ходу работы вы узнаете, как превращать базы данных SQLite в безопасные инструменты преобразования текста в SQL, почему описания инструментов важнее кода маршрутизации и какие сбои, не связанные с моделями, чаще всего могут заблокировать развитие подобного проекта.
Почему оркестратор лучше, чем модель, знающая всё
Современные самые серьезные системы ИИ имеют общую структуру: перед языковой моделью, способной понимать намерения пользователя, располагаются несколько надежных инструментов с узкой специализацией, предназначенных для поиска фактов. Помощники-ассистенты переключаются между системой учета расходов и базой знаний; помощники-исследователи — между поиском в интернете и внутренними документами. Модели не нужно хранить ответы, им достаточно определять, к какому инструменту обратиться.
В данном проекте эта схема применяется с использованием четырех специалистов и одного координатора:
┌──────────────────────────┐
question ──────────▶ │ AgentExecutor │
│ (Gemini + tool-calling) │
└────────────┬──────────────┘
│ picks 1+ tools based on
│ their description
┌──────────────┬───────┴───────┬───────────────┐
▼ ▼ ▼ ▼
InstitutionsDBTool HospitalsDBTool RestaurantsDBTool WebSearchTool
│ │ │ │
institutions.db hospitals.db restaurants.db Tavily API
(SQLite) (SQLite) (SQLite) (live web)
InstitutionsDBToolохватывает школы, колледжи и мадрасы, взятые из официального реестра EIIN.HospitalsDBToolохватывает государственные медицинские учреждения, зарегистрированные в DGHS.RestaurantsDBToolохватывает рестораны, данные о которых были собраны из Google Places.
WebSearchTool выполняет поиск в реальном времени (через API Tavily) для тех случаев, когда базы данных не могут дать ответ.Все процессы осуществляются с использованием AgentExecutor от LangChain, причем движок рассуждений представлен моделью Gemini в Google Vertex AI. Каждый инструмент базы данных преобразует вопрос на натуральном языке в SQL-запрос, выполняет его и преобразует полученные результаты обратно в читаемое предложение.
Некоторые примеры решений по маршрутизации помогают более четко определить цель запроса:
- Вопрос «Сколько колледжей есть в округе Раджшахи?» направляется в
InstitutionsDBTool, поскольку речь идет о структурированном подсчёте на основе данных EIIN. - Вопрос «Перечислите государственные больницы в округе Чаттаграм» направляется в
HospitalsDBTool, так как требуется структурированный список учреждений DGHS.
RestaurantsDBTool, поскольку речь идет о поиске в таблице ресторанов.WebSearchTool, так как ни одна база данных не хранит подобные общие знания.Агент никогда не проверяет сами базы данных. Он видит только четыре описания инструментов на простом английском языке, и качество маршрутизации зависит от того, насколько хорошо написаны эти описания, а не от каких-либо заранее заданных правил разветвления.
Проверьте наборы данных перед тем, как что-либо проектировать
До появления любых кодов агентов три набора данных были скачаны с Hugging Face и изучены по строкам. Этот час принес больше пользы, чем любой другой этап, поскольку в описании проекта были ошибки относительно содержимого данных. Там ожидались данные о количестве кроватей в больницах и информация о учебных программах вузов. На деле всё было иначе:
all-bangladeshi-hospitals— это каталог учреждений и офисов, зарегистрированных в DGHS/MOPA: название, тип учреждения, агентство, отдел, район и флаг, указывающий на частный или государственный статус. В нём отсутствуют данные о количестве кроватей или сотрудников.Institutional-Information-of-Bangladeshвключает информацию о школах, колледжах, мадрасах и технических училищах, имеющих регистрацию EIIN. В нём нет данных о вузах или информации об учебных программах.
Bangladeshi-Restaurant-Data получается в результате скрапинга Google Places: название, адрес, координаты, оценка и количество отзывов. Столбца с информацией о кухне нет.Такая разница между описанием и фактическим содержимым — обычное явление. Правильный подход заключается не в приспособлении данных к краткому описанию, а в работе с тем, что действительно имеется, и фиксации несоответствий, чтобы позже никто не считал это ошибкой. В данном случае поиск по слову «бирьяни» приводил к совпадению с названием ресторана, а фильтр по местоположению — к совпадению с адресом, поскольку отсутствуют поля для указания кухни и района. Компромисс незначителен и четко задокументирован.
Стоит с самого начала выработать более общую привычку: рассматривать набор данных как источник истины, а любые схемы или краткие описания — как предположения кого-то ещё. То, что выводит метод .head(), и является настоящей спецификацией.
Загрузка «грязных» CSV-файлов в типизированные таблицы SQLite
Каждый набор данных очищается, преобразуется в соответствующие типы и сохраняется в отдельный файл SQLite. Схема объявляется явно, чтобы SQLite не определял типы столбцов самостоятельно, а также чтобы последующий процесс преобразования текста в SQL имел осмысленные имена столбцов. Обратите внимание на ограничение NOT NULL для поля name и на целочисленный флаг is_private с значение по умолчанию ноль:
HOSPITALS_SCHEMA = """
CREATE TABLE hospitals (
facility_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
name_bangla TEXT,
code INTEGER,
agency TEXT,
facility_type TEXT,
division TEXT,
district TEXT,
city_corporation TEXT,
upazila TEXT,
is_private INTEGER NOT NULL DEFAULT 0
);
"""
Функция очистки соотносит исходные заголовки CSV с именами столбцов из схемы, преобразует идентификаторы и флаги в числа (недопустимые значения заменяются на null вместо того, чтобы привести к сбою), удаляет строки без идентификатора или имени, а также устраняет дублирующиеся записи:
def clean_hospitals(df: pd.DataFrame) -> pd.DataFrame:
rename_map = {
"Id": "facility_id", "Name": "name", "Name (Bangla)": "name_bangla",
"Code": "code", "Agency": "agency", "Type": "facility_type",
"Division": "division", "District": "district",
"City Corporation": "city_corporation", "Upazila": "upazila",
"Private": "is_private",
}
out = _select_and_rename(df, rename_map, required=["facility_id", "name"]).copy() out["facility_id"] = pd.to_numeric(out["facility_id"], errors="coerce").astype("Int64")
out["is_private"] = pd.to_numeric(out["is_private"], errors="coerce").fillna(0).astype(int) out = out.dropna(subset=["facility_id", "name"]).drop_duplicates(subset=["facility_id"])
return out.reset_index(drop=True)
Обратите внимание на вызов _select_and_rename — здесь можно было ожидать простого df.rename(). Этот вспомогательный метод был добавлен только после реальной ошибки, описанной в разделе с багами ниже. Это хороший пример защитного кода, цель которого становится понятной лишь после того, как вы узнаете, что пошло не так.
Один фабрикатор инструментов для преобразования текста в SQL
Все три инструмента для работы с базами данных используют одинаковый процесс, поэтому он реализуется один раз в виде фабрикатора и вызывается трижды. Фабрикатор открывает базу данных с помощью SQLDatabase.from_uri, создаёт цепочку для генерации SQL-запросов с помощью create_sql_query_chain, готовит инструмент для выполнения запросов с помощью QuerySQLDatabaseTool, а также формирует цепочку для форматирования ответа на основе подсказки, модели и парсера строк:
def build_db_qa_tool(*, tool_name: str, description: str, db_path: Path, llm) -> Tool:
db = SQLDatabase.from_uri(f"sqlite:///{db_path}")
write_query = create_sql_query_chain(llm, db)
execute_query = QuerySQLDatabaseTool(db=db)
format_answer = _ANSWER_PROMPT | llm | StrOutputParser()
Внутренняя функция выполняет основную работу по каждому вопросу. Она запрашивает у модели SQL-запрос, извлекает первое предложение типа SELECT, обеспечивает режим только для чтения и ограничение количества строк, выполняет его, а затем передает вопрос, запрос и необработанные результаты в цепочку форматирования. Любые исключения преобразуются в читаемое сообщение вместо трассировки стека, что также позволяет агенту иметь основания для логических рассуждений:
def _answer(question: str) -> str:
try:
raw_sql = write_query.invoke({"question": question})
sql = _enforce_read_only_and_limit(_first_select_statement(raw_sql), MAX_SQL_RESULT_ROWS)
result = execute_query.invoke(sql)
except Exception as exc:
return f"I could not query the {tool_name} database for that question (reason: {exc})." return format_answer.invoke({"question": question, "query": sql, "result": result}) return Tool(name=tool_name, description=description, func=_answer)
В этом фрагменте три аспекта дизайна можно проигнорировать в первой версии:
- Модель генерирует SQL-запросы с помощью функции
create_sql_query_chain. Не существует вручную поддерживаемых шаблонов запросов, необходимых для синхронизации с схемой.
_enforce_read_only_and_limit отклоняет любые запросы, кроме SELECT, и ограничивает количество строк, так что заблуждающаяся или манипулируемая модель не может изменять данные или выводить всю таблицу в окно контекста.Полезной дополнительной мерой безопасности, если вы используете эту схему, будет открытие файла SQLite также в режиме только для чтения. Проверки строк в SQL можно обойти тонкими способами, поэтому подключение к базе данных, физически не позволяющее выполнять записи, служит второй линией защиты.
Использование описаний инструментов для маршрутизации
Именно здесь скрипт превращается в агента. Системное сообщение сначала определяет роль помощника:
SYSTEM_PROMPT = """You are a multi-tool research assistant for Bangladesh.
Затем перечисляются четыре инструмента и указываются три правила маршрутизации: использовать соответствующий инструмент базы данных для подсчёта количеств, составления списков или получения конкретных записей, никогда не оценивать числа приблизительно; использовать веб-поиск для получения общих знаний; а когда вопрос касается нескольких наборов данных, вызывать каждый соответствующий инструмент и объединять результаты. Под запросом функция create_tool_calling_agent связывает модель, инструменты и запрос вместе, а AgentExecutor запускает цикл с лимитом в шесть итераций и обеспечивает толерантное обработание некорректных вызовов инструментов:
You have four tools:
- InstitutionsDBTool : Bangladeshi schools, colleges, madrashas (EIIN registry)
- HospitalsDBTool : Bangladeshi government health facilities and offices
- RestaurantsDBTool : Bangladeshi restaurants and eateries (Google Places data)
- WebSearchTool : live web search, for anything the three databases don't coverRouting rules:
1. If the question asks for a count, list, or specific record from one of the
three datasets, call the matching DB tool. Never guess numbers yourself.
2. If the question is general knowledge or not covered by any DB tool, call
WebSearchTool.
3. If a question spans more than one dataset, call each relevant tool
separately and combine the results.
"""agent = create_tool_calling_agent(llm=llm, tools=tools, prompt=prompt)
executor = AgentExecutor(agent=agent, tools=tools, max_iterations=6, handle_parsing_errors=True)
Здесь нет ничего, что соответствовало бы ключевым словам, применялось бы регулярные выражения или связывало бы фразы с инструментами. Модель сравнивает вопрос с каждым описанием и выбирает подходящее. Поэтому описания являются наиболее важными строками в кодовой базе: если они нечеткие или перекрываются, агент начнет выбирать неправильные инструменты, и никакие изменения в запросах не смогут полностью это компенсировать. Также стоит сохранить ограничение max_iterations=6, поскольку оно контролирует количество итераций и предотвращает бесконечное использование инструментов агентом.
Почему классический AgentExecutor вместо LangGraph
LangChain постепенно переходит от рекомендуемой архитектуры агентов к LangGraph, которая обеспечивает более тонкий контроль над многоэтапными, имеющими состояние рабочими процессами. Для таких случаев более подходит функция create_tool_calling_agent с использованием AgentExecutor: каждый запрос представляет собой один вопрос, один или два вызова инструментов и один ответ. Фреймворк в виде графа добавит структуру, которая не требуется в данной ситуации. Выбор наименьшей архитектуры, способной решить проблему, — это отдельный навык, отличный от знания о существовании более мощных вариантов. Если позже потребуется разветвление путей обработки, повторные попытки или сохранение состояния, именно тогда графовая архитектура становится целесообразной; статья о создании устойчивых графов агентов посвящена именно этой теме.
Три сбоя, не связанных с моделью
Самой познавательной частью процесса разработки было не то, что сработало с первой попытки, а то, что сломалось. Ни одна из трех проблем не связана с запросами к модели или её качеством.
Две версии LangChain установлены параллельно
В версии LangChain 1.0 был реорганизован модуль agents, и классический паттерн AgentExecutor был перенесён в отдельный пакет. В среде Colab оказались установлены обе версии, поэтому импорт, который успешно сработал в одной ячейке, вызывал ошибку ImportError: cannot import name 'AgentExecutor' в следующей.
Исправление заключалось в изменении процедуры, а не в правке кода: необходимо было явно отключить конфликтующие пакеты, указать точные версии вместо диапазонов и перезагрузить среду выполнения перед продолжением работы. Соблюдение правил зависимостей — это настоящая инженерная работа. Проверьте актуальную документацию LangChain, чтобы узнать, где находится AgentExecutor в версии, которую вы устанавливаете, поскольку это менялось в разных релизах.
Название столбца с пробелом вместо подчеркивания
В редакторе наборов данных Hugging Face отображался столбец с названием INSTITUTE_NAME, однако в загруженном файле фактически использовалось название INSTITUTE NAME. Этот один символ приводил к возникновению ошибки KeyError на несколько вызовов функций раньше, чем возникала настоящая причина проблемы.
Ответом стал помощник _select_and_rename, рассмотренный ранее. Он удаляет все неалфавитно-цифровые символы перед сравнением имен столбцов, поэтому INSTITUTE_NAME, INSTITUTE NAME и institute-name считаются эквивалентными. Если необходимый столбец всё равно не находится, программа выводит реальный список столбцов вместо того, чтобы молча сработать с ошибкой. Хорошее сообщение об ошибках само по себе является инструментом для отладки.
Формат ключа API, отклонённый Gemini API
Google вводит новый формат ключей API, который начинается с AQ. вместо привычного AIzaSy. На момент создания этого проекта ключи нового формата отклонялись прямым API Gemini с неясной ошибкой 401 ACCESS_TOKEN_TYPE_UNSUPPORTED. Логика повторных попыток не может исправить учетные данные, которые конечная точка не принимает.
Решением стало полное изменение пути аутентификации: теперь агент аутентифицируется в Google Vertex AI с использованием OAuth, как и любой другой сервис Google Cloud. Это избавляет от проблемы с форматом ключей и, пожалуй, является более подходящей настройкой для реальных условий работы. Поскольку это поведение могло измениться со временем, перед тем как на него полагаться, убедитесь в соответствии с текущей документацией Google Cloud.
Общая закономерность для всех трех случаев: в современной разработке ИИ инфраструктура, связанная с моделью (версии пакетов, схемы данных и процессы аутентификации), ломается гораздо чаще, чем сама модель.
Агент в действии
После создания баз данных и подключения агента вопрос о количестве колледжей в Раджшахи направляется непосредственно в инструмент для работы с учебными заведениями, который отвечает цифрой, взятой из базы данных:
You: How many colleges are registered in Rajshahi division?
> Entering new AgentExecutor chain...
[Tool: InstitutionsDBTool] → "There are 812 colleges registered in Rajshahi division."
В том же сеансе веб-поиск получает вопрос общего характера о DGHS, в то время как запрос на информацию о государственных больницах в Чаттаграме направляется в базу данных больниц:
You: What is the role of DGHS in Bangladesh?
> Entering new AgentExecutor chain...
[Tool: WebSearchTool] → "DGHS (Directorate General of Health Services) is the
government body responsible for planning, managing, and delivering public
healthcare services across Bangladesh..."You: List a few government hospitals in Chattogram district.
> Entering new AgentExecutor chain...
[Tool: HospitalsDBTool] → "Chattogram district includes several government
health facilities such as Chattogram Medical College Hospital and Chattogram
General Hospital, both operating under DGHS..."
В ходе одного разговора используются три разных источника данных, но нет кода, указывающего агенту, какой из них выбрать. Модель принимает каждое решение исключительно на основе описаний инструментов.
Уроки для следующей версии
- Проверяйте данные перед созданием схемы. Обнаружение пробелов в наборе данных после создания инструментов потребовало бы переписывания всех трех из них.
- Фиксируйте зависимости более строго, чем это кажется необходимым. В экосистеме, где каждые несколько месяцев происходят крупные переписывания, диапазон версий, который сегодня работает стабильно, может вести себя совершенно иначе в новой среде через несколько недель.
Заключение
Инструмент Gemini начал корректно обрабатывать запросы сразу после того, как ему были предоставлены четко описанные инструменты. Сама модель не стала проблемой; время заняла окружающая инфраструктура: противоречивые версии библиотек, лишняя пробел в заголовке столбца и формат учетных данных, неприемлемый для API. Если вы создаете агента с несколькими инструментами, заранее вложите усилия в три аспекта: точные описания инструментов, строгие ограничения на всё, что модель генерирует для выполнения, и воспроизводимую среду. Именно они определяют, будет ли агент работать так же хорошо в производственных условиях, как и в демонстрации.