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

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

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


  1. Sap_ru
    25.05.2026 09:31

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


    1. MrZorg
      25.05.2026 09:31

      Скорее всего это ответ на "я не хочу вникать в этот ваш птичий язык, дайте цифры вот так и вот так", вполне жизненный сценарий когда руководитель только читаем почту и смотрит камеры , а в особенностях ИТ систем разбираться не хочет


    1. 1forma Автор
      25.05.2026 09:31

      Про скорость. Точечный поиск по индексу и аналитическая агрегация — это разные классы операций. Когда система отвечает на «сколько мы должны контрагентам по категории "электроэнергия" за квартал в разрезе ЦФО», она не ищет одну строку, а делает GROUP BY с join по нескольким таблицам реквизитов, фильтрами по статусам и валютной нормализацией. В OLTP-базе на живых данных это честные 2–4 секунды, и мы считаем, это нормальная цифра.

      Сотни миллисекунд по миллиону записей — это про columnar storage и pre-aggregated кубы (ClickHouse, Druid, Vertica). Они работают, но требуют отдельного ETL-контура и теряют свежесть данных. Для ad-hoc аналитики в ERP, где директор хочет ответ по состоянию «сейчас», цена такого контура не оправдана.

      Отдельно про модель данных: реквизиты заявок у нас гибкие — клиент сам определяет, какие поля и категории ему нужны, без релиза бэкенда. За эту гибкость платим join-ами. Это сознательный архитектурный выбор, а не недонастроенная база. Кэш в такой схеме — стандартная практика BI: один и тот же дашборд открывают десятки раз в день, пересчитывать одно и то же бессмысленно.

      Про «зачем тут LLM». Тут важная развилка: LLM в нашей схеме ничего не считает. Считает строгий SQL-инструмент с фиксированной семантикой, чтобы исключить галлюцинации в цифрах. LLM тут маршрутизатор, она помогает перевести «сколько мы должны за свет за прошлый месяц» в вызов нужного инструмента с правильными ID категорий, статусов и периода.

      Тезис про «ограниченное количество вариантов» — здесь как раз обратное. В реальном контуре 40+ категорий заявок × 15+ статусов × произвольные контрагенты × любой временной срез × агрегация по любому из десятков реквизитов, десятки тысяч осмысленных комбинаций, сколько точно сказать не можем, NDA. Конструктор фильтров такое тянет, но директору придется потратить на фильтр много времени, а у агента на естественном языке порог входа нулевой.

      Вообще модель «LLM с доступом к высокоуровневому бэкенду, пользователь либо сам фильтрует, либо спрашивает на естественном языке» — это как раз то, что у нас построено. Конструктор отчётов для аналитиков остался, агент добавлен сверху для тех, кому быстрее спросить. Со старым добрым программированием тоже сложно, тогда каждый новый отчёт нужно запрашивать через тикет на разработку, а LLM-это закрывает одной интеграцией. У нас система катится в десятки разных клиентов с разными процессами, так что тут ещё момент оптимизации ресурсов.