ИИ-агент в «Первой Форме» работает со всеми типами бизнес-процессов: документы, регламенты, задачи, заявки, договоры. Текстовые вопросы он закрывал хорошо с самого начала. А вот финансовые — с галлюцинациями. Мы переделали подход — и теперь агент отвечает точно, с совпадением с 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 спасает диалог при ошибках. Такой подход не покрывает все мыслимые вопросы, но на тех, что покрывает, гарантирует точность. В финансовых процессах это важнее широты охвата.

Если вы строили похожую схему, расскажите в комментариях, как решали вопрос с произвольными пользовательскими запросами, которые выходят за рамки заданных контрактов.

Комментарии (1)


  1. Sap_ru
    25.05.2026 09:31

    Непонятно, как агрегация аж по десяткам тысяч записей занимает аж секунды. Что-то кажется, что при правильной структуре и настройки бызы, даже поиск даже по миллиону записей, это сотня милликенд.
    А ещё не понятно, зачем там LLM. Особенно в таком применении. Есть ощущение, что это лютый оверкил и количество варинтов запросов ограниченно, а потому задачу лучшеи и проще решать старым добрым программированием. Ну или в крайнем случае LLM с доступом к выскоуровневому бэкэнду. Тогда пользователь может и сам искать, указывая фильтры, и через LLM на естественном языке.