ИИ-агент в «Первой Форме» работает со всеми типами бизнес-процессов: документы, регламенты, задачи, заявки, договоры. Текстовые вопросы он закрывал хорошо с самого начала. А вот финансовые — с галлюцинациями. Мы переделали подход — и теперь агент отвечает точно, с совпадением с SQL до рубля. Ниже — как именно это устроено.
Почему RAG не умеет считать
Классическая связка LLM + RAG хорошо закрывает текстовые вопросы: «Как оформить заявку?», «Кто подписывает договор?», «Какой SLA у инцидента?» — поисковый движок находит релевантный фрагмент документа, LLM переформулирует его в ответ. Но стоит вопросу стать числовым, схема ломается.
«Какова сумма заявок на оплату за 2025 год?» — это уже не поиск по тексту, а запрос на агрегацию по базе данных. RAG найдёт фрагмент, где рядом стоят слова «заявка» и «сумма», передаст его LLM и она с высокой вероятностью выдаст число, которого в данных нет. Проблема в том, что ИИ не считает, а подбирает правдоподобные продолжения текста.
Мы столкнулись с этой проблемой на практике. Клиенты «Первой Формы» — это крупные enterprise-компании со многомиллиардными сделками. Для них было критически важно, чтобы ИИ-ассистент закрывал финансовые процессы без ошибок, поэтому мы взялись за пересмотр архитектуры.
Наше решение: инструменты вместо прямого SQL
Первый очевидный импульс — дать LLM доступ к SQL, пусть сама пишет запросы. Мы от этого отказались сразу. Агент, умеющий писать произвольные запросы, рано или поздно напишет некорректный. На больших данных полное сканирование занимает десятки секунд и бьёт по продуктивной базе. Контролировать такое поведение практически невозможно.
Вместо этого мы определили жёсткий набор инструментов с типизированными контрактами. LLM в этой схеме выступает как маршрутизатор: она распознаёт намерение пользователя и вызывает нужный инструмент, который выполняет SQL-запрос и возвращает структурированный результат.
Инструмент 1: analytics_aggregate_by_field
Агрегирует числовое поле по категории задач. Контракт состоит из следующих параметров:
-
Категория — в какой группе задач считать;
-
ID числового поля — что именно суммировать;
-
Тип агрегации — sum, avg, count, min, max;
-
Период — за какой промежуток;
-
Группировка и лимит — опционально, для «топ-N» запросов.
Примеры реальных вызовов:
|
> «Сумма заявок на оплату за 2025» → sum по полю «Сумма» за 2025 год → 292 249 846 141 ₽> «Топ-5 договоров по сумме оплат» → sum с группировкой по контрагенту, сортировка DESC, лимит 5> «Дельта между контрактами и выплатами» → два вызова (Сумма контракта — Оплачено) и вычитание |
Инструмент 2: analytics_category_overview
Счётчики состояния категории: сколько задач в работе, сколько закрыто за месяц, сколько просрочено, топ-исполнители.
|
> «Сколько договоров в работе?» → отображается счётчик активных задач |
Инструмент 3: meta-fallback
Самый важный с точки зрения UX. Когда агрегация невозможна — поле не найдено или тип группировки не поддерживается — инструмент не возвращает текст ошибки, а отдаёт helper_fields — массив доступных числовых полей с их бизнес-названиями.
Без этого механизма агент получает ошибку, пытается «исправить» вызов, подставляет произвольный ID поля, снова ошибается — и диалог уходит в бесконечный цикл.
С helper_fields агент переспрашивает, например, «Я могу посчитать по СуммеОплат, ДатеПлатежа, Контрагенту. Уточните, пожалуйста, что именно вас интересует?», а не зависает.
Два главных бага, которые мы закрыли
Баг 1: Кэш «запоминал» ошибку
Симптом. Пользователь спрашивает сумму за период. Агент вызывает инструмент с неправильным полем, получает field_not_found. Исправляет поле, снова получает field_not_found и зацикливается.
Причина. Ключ кэша строился по подмножеству входных параметров. Для analytics-инструментов default-обработчик включал поля, которых в этих запросах нет, — все ключи оказывались пустыми. Кэш запоминал первый вызов и возвращал его результат на любой последующий с той же категорией.
Как починили. Агрегация по десяткам тысяч записей занимает секунды, кэш необходим. Но ключ кэша обязан включать все входные параметры, а не подмножество. Мы решили это тремя отдельными case в switch с полным набором полей: для агрегации — 8 полей в ключе, для обзора категории — 4 поля, для поиска по исполнителю — 3 поля. Иначе кэш начинает возвращать не тот результат и отладка занимает часы, потому что внешне всё выглядит просто как «медленно работает».
Баг 2: Пустые названия категорий
Симптом. Агент показывает список категорий, но вместо названий — пустые строки. Пользователь видит: «1. [пусто] 2. [пусто] 3. [пусто]».
Причина. В базе данных у категории нет названия — поле пустое. Код пытался подставить резервное значение типа «Категория без названия», но проверка работала только на полное отсутствие значения (null). Пустая строка — это тоже значение, просто нулевой длины. Проверка считала «пустая строка есть, значит подмена не нужна» — и возвращала пустоту.
Как починили. Заменили проверку на явный тест «пусто или отсутствует»: string.IsNullOrEmpty. Теперь и null, и пустая строка, и строка из одних пробелов — всё получает читаемый fallback.
Верификация: реальные числа из настоящего диалога
Систему тестировали на живой площадке с 5 000+ активных пользователей и десятками тысяч заявок на оплату. Вот три сценария, которые мы проверили на живых данных и сверили с прямым SQL:
|
Вопрос |
Ответ |
Время |
|
Сумма заявок на оплату за 2025 |
292 249 846 141 ₽ |
26–38 сек, 3–4 подхода |
|
Топ-5 договоров по сумме оплат |
Реальные суммы и контрагенты |
Аналогично |
|
Дельта между контрактами и выплатами |
Точная разница двух агрегаций |
Больше подходов, но 100% точность |
Все ответы сверены с прямым SQL-запросом — галлюцинаций нет ни по одному из тестовых сценариев.
Выводы: что важно при проектировании
Главный инсайт, который стоит вынести из этой статьи:
LLM не должна быть аналитиком данных. Она должна быть интерпретатором — переводить человеческий вопрос на язык строгих контрактов, а за точность пусть отвечает код.
Из этого принципа вытекают практические следствия:
-
RAG — не для чисел. Если вопрос пользователя содержит «сколько», «сумма», «средний», «количество», нужен инструмент с SQL-агрегацией, а не поиск по документам.
-
Tool-контракт и meta-fallback важнее промпта. Проектируйте контракты с восстановлением: любая ошибка должна давать агенту helper_fields или другой путь вперёд, а не тупик.
-
Кэш по подмножеству параметров — коварный баг. Диагностический признак: второй вызов работает, третий — нет. Лечится только включением всех параметров в ключ.
-
MSSQL и PostgreSQL — разные языки. Особенно в части приведения типов, потому закладывайте на это время и тестируйте на боевых объёмах.
Самый главный технический вывод — тестировать нужно на реальных объёмах. Платформа работает одновременно с MS SQL Server и PostgreSQL. Логика маршрутизации простая: определить тип БД → вызвать соответствующую хранимую процедуру → обернуть результат в единый JSON. Но на практике «универсальный SQL, который одинаково работает везде» — это миф.
Тип «Деньги» особенно коварный: в MSSQL он без вопросов кастуется в integer, double и varchar, в PostgreSQL требует явного CAST. Мы портировали хранимую процедуру на тысячу строк синтаксически верно, но ошибки процесса проявились только на реальных данных. На тестовых объём был недостаточен.
Что дальше
Финансовые агрегации — это первый уровень. Следующий шаг — сложные аналитические запросы: несколько JOIN-ов, подзапросы, оконные функции.
Принцип остаётся тем же: LLM маршрутизирует намерение, инструмент отвечает за точность, meta-fallback спасает диалог при ошибках. Такой подход не покрывает все мыслимые вопросы, но на тех, что покрывает, гарантирует точность. В финансовых процессах это важнее широты охвата.
Если вы строили похожую схему, расскажите в комментариях, как решали вопрос с произвольными пользовательскими запросами, которые выходят за рамки заданных контрактов.
ссылка на оригинал статьи https://habr.com/ru/articles/1038888/