Задавання запитів маршрутизації через інструменти SQL та веб-пошук за допомогою агента Gemini
Як агент для виклику інструментів LangChain у Vertex AI обирає між трьома інструментами SQLite для перетворення тексту на SQL та живим пошуком у Інтернеті, а також які проблеми з даними, залежностями та автентифікацією можна очікувати.
Джерела: 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. На момент створення цього проекту ключі нового формату відхилялися безпосередньою Gemini API з незрозумілою помилкою 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 не приймав. Якщо ви створюєте агента з кількома інструментами, рано варто інвестувати у три речі: ческі описи інструментів, суворі обмеження щодо всього, що модель генерує для виконання, та відтворюване середовище. Саме це визначає, чи буде агент працювати так само добре в реальних умовах, як і під час демонстрації.