Никто не верил, что модель можно подпустить к таблицам и заставить не галлюцинировать. Цифры из воздуха, выдуманные колонки, суммы, которые не сходятся с источником, думаю на этом обжигались все, кто пробовал. Мы заставили, проблемы все еще есть, но выглядят решаемыми.
Сначала про то, почему любая LLM врёт на данных. Она достраивает правдоподобное продолжение токен за токеном. Таблица, которую вы положили ей в контекст, для модели такой же текст, как и всё остальное. Не хватило данных, неудобно читать, вопрос чуть в стороне — она не скажет «не знаю», а допишет то, что выглядит как правда. Прямой путь, отдать ей CSV и попросить посчитать, ровно поэтому и не работает.
Сработал другой подход. Мы взяли Claude Desktop и оказалось, что он прекрасно пережевывает большие датасеты: не захлёбывается и не врёт цифрой там, где обычная связка «модель плюс CSV» уже ломается. Стало интересно, за счёт чего. Мы начали его опрашивать и разбирать, как он это делает. Вот что выяснили — и что из этого перенесли в свой MCP-сервер.
Первый подход: Claude Desktop умеет, но непредсказуемо
Сначала честно: анализ данных Claude делает и сам, без всякой обвязки. Дай ему датасет и вопрос — думаю он посчитает. Он умеет, проблема в том, что делает это каждый раз по-разному. Тот же вопрос и он то возьмёт один разрез, то на полпути сменит подход, то выберет другую логику расчёта. Для разовой задачи сносно, но на должность дата аналитика не тянет.
Раз он справляется с данными, рецепт у него есть. Мы решили его просто спросить.
Что рассказал Claude Desktop
Мы стали опрашивать напрямую: как ты работаешь с большими датасетами, как устроена твоя песочница, как устроены навыки для работы с данными и тд. Устройство его песочницы я оставлю за скобками — это кухня Anthropic, и реконструировать её догадками в статье про то, как не выдумывать, было бы смешно. Если хотите спросите сами, он кое-что расскажет, а песочница claude code вообще с открытыми исходниками. Свою мы потом собрали сами, о ней дальше. Нас интересовало переносимое.
Переносимое свелось к трём вещам. Считать данные движком вместо счёта в уме — тут он сразу назвал DuckDB. Аналитику, которую нельзя выразить в SQL запрос, выносить в Python в песочницу. И описывать данные и схему так, чтобы модель не гадала, что внутри. Эти три вещи мы и зашили в свой MCP-сервер: фронт на Next.js (ai-sdk), бэкенд на FastMCP, под капотом DuckDB, модель ходит в данные только через инструменты. Смысл обвязки — забрать у модели свободу импровизировать и пользоваться инструментами. Дальше по порядку.
Движок: считать, а не прикидывать
Самая частая ложь модели при работе с данными — это арифметика и агрегации в уме. Спросите модель «какая средняя выручка по регионам», положив ей таблицу на 50 тысяч строк, и она с удовольствием выдаст число. Иногда даже близкое к правде.
Поэтому считать ей мы не даём. Она пишет SQL, а выполняет его DuckDB — тот самый движок, который Claude назвал первым. Среднее, сумма, группировка, джойн — всё уходит в движок, который не ошибается в арифметике по определению.
DuckDB выбран не случайно. Это встраиваемый OLAP-движок, который читает CSV, Parquet и JSON напрямую через read_csv_auto() и read_parquet(), а к внешним базам подключается через federation — ATTACH к PostgreSQL, MySQL, SQLite без копирования данных:
ATTACH 'host=... dbname=...' AS pg (TYPE postgres, READ_ONLY)
Подключение всегда READ_ONLY и всегда in-memory: данные живут на диске или во внешней базе, а DuckDB поднимает их в память только на время запроса.
Но дать модели писать произвольный SQL — это путь к биде. Поэтому каждый запрос проходит через гейт, еще один слой защиты. Функция validate_read_only() (в mcp_server/duckdb_utils.py) режет всё, что меняет данные или лезет мимо данных:
def validate_read_only(sql: str) -> None: """Бросает ValueError, если в SQL есть мутации. Блокирует INSERT / UPDATE / DELETE / DROP / CREATE / ALTER / ATTACH, а ещё функции glob(), sqlite_scan(), postgres_query() — они обходят изоляцию файловой системы."""
Проверка идёт по очищенному SQL — без комментариев и строковых литералов, чтобы фильтр нельзя было обмануть, спрятав что-то в комментарий. Рядом prepare_user_sql() разворачивает относительные пути файлов в полные и проверяет, что путь не вышел за пределы разрешённой папки.
Сам движок тоже закрыт на замок. При создании соединения мы запрещаем автозагрузку расширений и в конце замораживаем конфигурацию:
con.execute("SET autoinstall_known_extensions=false") con.execute("SET autoload_known_extensions=false") con.execute("SET lock_configuration=true") # после этого настройки не поменять
И последнее в этой части — то, без чего модель снова начинает врать даже на честно посчитанных данных. Результат запроса мы усекаем: если строк больше двух сотен, показываем первые и последние, а к ним прикладываем предупреждение прямо в выводе тула:
WARNING: показаны 200 из 5000 строк. Агрегации по этому выводу будут неверны — используйте SQL-агрегацию.
Без этой строчки модель честно получает 200 строк, видит, что это «таблица», и спокойно «усредняет» показанное, забыв про оставшиеся 4800. Предупреждение возвращает её к реальности.
Но костяк — это SQL. Не всё на него ложится: сложная статистика, прогнозы, нестандартные преобразования. Для этого есть вторая рука.
Вторая рука: песочница с Python
Второе, что назвал Claude, — Python для аналитики, которую неудобно гнать через SQL. У нас это отдельный сервис-песочница: изолированный контейнер на своём порту, код исполняется под непривилегированным пользователем. Модель пишет код, песочница его запускает и возвращает результат — тот же принцип, что и с SQL: считает среда, модель только формулирует.
Песочница приходит с предустановленным набором библиотек, чтобы модель не тянула зависимости на ходу:
данные: pandas, numpy, scipy, polars, pyarrow, duckdb;
статистика и прогнозы: statsmodels, scikit-learn, statsforecast, mlforecast, плюс бустинг lightgbm и catboost с интерпретацией через shap;
графика: matplotlib, seaborn, plotly;
выгрузки и документы: openpyxl, xlsxwriter, python-docx, python-pptx;
русский текст: natasha, razdel, pymorphy3.
Список залочен и заранее закеширован в образе: модель работает тем, что есть, и не тянет что попало из сети.
Песочница изолирована на сколько можно: изолированный контейнер, непривилегированный пользователь, фиксированный список либ. Свобода у модели есть, но внутри этих границ.
Описание данных: чтобы модель не гадала о схеме данных
Третий совет Claude был про описание данных, и он закрывает вторую частую ложь — выдуманные колонки и таблицы. Модель пишет SELECT revenue FROM sales, а колонка называется total_amount, и таблицы sales нет вовсе. Корень один: модель не знает структуру и достраивает «похоже на правду».
Лечится это тем, что структуру ей дают раньше, чем она успеет придумать. У нас за это отвечают два тула.
data_list показывает каталог: какие датасеты вообще есть, сколько в каждом строк и колонок, краткое описание. Модель сначала видит, с чем она работает, а не угадывает имена файлов.
data_schema идёт глубже — это профиль каждой колонки. Тип, доля пустых значений, число уникальных, минимум, максимум и среднее для чисел. Плюс то, что мы добавили специально под аналитику: детект валют и единиц измерения. Тул смотрит на сами значения и подсказывает модели, что profit лежит в долларах, а quantity — в тоннах:
Table: sales.csv Rows: 250 000 Columns: 8 Column Type Null Unique profit INTEGER 2% — quantity DECIMAL(10,2) 0% — region VARCHAR 0% 7 date DATE 0% — Подсказки по единицам: - profit → валюта: USD - quantity → единица: тонна
Это не косметика. Без подсказки по валютам модель спокойно сложит доллары с евро в одном SUM() и выдаст бессмысленную сумму с видом эксперта.
Чтобы не считать профиль на каждый чих, описания кэшируются. Метаданные датасетов лежат в PostgreSQL (таблица dataset_metadata: число строк, колонок, текст схемы, описание), а структура подключённых внешних баз — в cached_schema. Модель получает готовый контекст за один вызов.
И последнее — дисциплина. Системный промпт требует фазу PLAN: сначала data_list и data_schema, и только потом data_query. Узнай, что есть и как устроено, потом спрашивай данные. Этот порядок снимает большую часть выдуманных колонок ещё до первого запроса.
Когда данных нет: главный соблазн соврать
Самый опасный момент в работе с данными — не сложный запрос, а пустой ответ. Модель спросила, фильтр ничего не нашёл, вернулось ноль строк. Вот тут LLM особенно тянет «спасти» ситуацию: раз данных нет, выдам правдоподобное. Ровно так и берутся цифры из воздуха.
Поэтому просто пустой ответ мы модели не отдаем. На нулевом результате тул сам идёт разбираться, почему фильтр не сработал: прогоняет SELECT DISTINCT по колонкам из условия и возвращает реальные значения:
Запрос вернул 0 строк. Возможная причина в фильтре: колонка
yearсодержит значения: 2020, 2021, 2022. В вашем фильтре былоyear = 2025.
Теперь у модели есть зацепка, и она чинит фильтр. Промпт это поддерживает: до трёх попыток, прочитай ошибку, поменяй, повтори.
И ещё одна мелочь в ту же сторону. Каждый тул честно сообщает не только данные, но и их состояние: сколько вернулось строк, усечён ли ответ. Модель не гадает, полный ли у неё результат. Без такой честности она додумывает там, где достаточно посмотреть на факт.
Как это выглядит на одном запросе
Соберём вместе. Пользователь спрашивает: «покажи выручку по регионам».
data_list— модель видит, что есть датасетsalesна 250 тысяч строк.data_schema— узнаёт колонки:region,revenueв долларах,date.data_query— пишетSELECT region, SUM(revenue) ... GROUP BY region, сумму считает DuckDB.viz_chart— строит график на тех числах, что вернул запрос.
В финале каждое число прослеживается до конкретного выполненного SQL. Нет шага, где модель «прикинула» цифру по памяти. Та самая «выручка +23%» теперь либо берётся из запроса, либо не появляется вовсе.
Где модель всё равно может соврать
Честный взгляд со стороны, без которого разбор был бы не полным.
validate_read_only — это регулярки, а не разбор AST. Гейт ловит мутации и небезопасные функции, но не семантику. Кривой JOIN, который молча размножит строки, пройдёт проверку и даст неверную сумму. Движок посчитает её безупречно — и она будет ошибочной.
cached_schema устаревает. Если в подключённой базе поменяли таблицу, а кэш не пересканировали, модель сошлётся на колонку, которой больше нет. Модель это увидит и попытается поправить, пересканировать заголовок датасета, но это лишние шаги.
Предупреждение об усечении спасает не всегда, про «200 из 5000» будет в ответе, но это текст, а текст модель может проигнорировать. Жёсткого запрета агрегировать усечённое нет.
Смешанные валюты — та же история. Детект подсказывает, что складывать доллары с евро нельзя, но это подсказка, а не блок. Дисциплина держится на промпте, а промпт можно не дочитать.
Вывод один. Наша поделка не убирает галлюцинации, она сужает область, где они возможны. Мы перенесли ответственность с памяти модели на инструменты с жёсткими границами. Где граница вшита в код (read-only, скоуп файлов, схема данных), модели не надо выдумывать цифры, они все есть. Ответ воспроизводим: тот же вопрос завтра даёт ту же цифру — ровно то, чего Claude в одиночку не давал. Где осталась промпт-дисциплина, соврать ещё может, и за этими местами приходится следить руками.
И кажется, в этом вся работа. Каждый раз, когда наша модель уверенно выдумывала цифры, разбор упирался не в «модель тупая», а в место, где ей просто негде было взять данные. Вопрос всегда оказывался один: где проходит граница между «пусть посчитает модель» и «пусть посчитает движок».
