Узнайте, как мы научили ИИ строить графики: LLM пишет только спецификацию, числа проверяются, а рендер идет через Vega-Lite и vl-convert. Читайте и внедряйте!
Представьте: у вас есть данные, которые никто не читает. Не «данные», а дорогое кладбище метрик. Мы в LiveReview строим сервис, который ревьюит код и оценивает, насколько плохо будет, если изменение сломает прод. И накопили гору ревью-данных: кто, как часто, какие репозитории горят. Руководители спрашивали: «Растёт ли внедрение?» — и получали «ну, вроде да». Тогда мы сделали Livi — чат-бота, который отвечает на реальные вопросы с реальными графиками. Этот пост — о том, как Livi рисует эти графики. Главная идея: мы никогда не даём LLM касаться пикселей, а заставляем его описывать график декларативно. Читайте, как один и тот же спектакль превращается в интерактивный график в браузере и плоскую PNG в Slack, и почему выбор правильной формы графика — это глубокая кроличья нора.
Соблазнительная, но неправильная идея — «пусть LLM сгенерирует изображение». Не делайте так. Модели генерации изображений — это другая зверушка, и даже если вы заставите её нарисовать столбчатую диаграмму, вы не сможете проверить, реальные ли числа на ней. Вы доверитесь модели, которая галлюцинирует правдоподобные числа, и попросите её же отрисовать их в пикселях. Это не график, а фанфик в форме графика.
Правильная идея, к которой приходят все серьёзные интеграции, — пусть LLM пишет Vega-Lite, JSON-грамматику для декларативного описания графиков. Вы не говорите «нарисуй синий столбик вверх». Вы говорите:
{
"mark": "bar",
"encoding": {
"x": {"field": "month", "type": "temporal"},
"y": {"field": "review_count", "type": "quantitative"}
}
}Вот и весь график. Никаких пикселей, только описание того, что данные означают и как их сопоставить с картинкой. Рисованием занимается Vega-Lite. Задача LLM сужается до того, что он реально умеет: заполнить хорошо определённую схему. Модели гораздо лучше справляются с выбором mark: bar или mark: line, чем с галлюцинированием 600 пикселей правильной оси Y.
Критически важно: LLM никогда не видит реальные числа до рендера. Он пишет SQL, мы его выполняем, и настоящий результат вставляется в data.values нашим Go-кодом. Модель может быть сколь угодно креативной в оформлении, но ноль креатива в числах.
Вот конвейер, примерно:
Потому что модели нужно знать, сколько строк будет в ответе, до того, как решить, имеет ли смысл график. Никому не нужен столбчатый график с 4000 столбцов. Первый шаг — «насколько это будет большим», второй — «окей, теперь реально получи данные и скажи, как их рисовать».
Потому что LLM, пишущий сырой SQL против мультитенантной базы, — это один уверенный промпт от org_id = 1 OR 1 = 1. Мы прогоняем каждый сгенерированный запрос через гард, который отклоняет всё, что не read-only, проверяет каждую таблицу по денай-листу и ищет форму обхода изоляции тенанта (сравнения констант, голый OR TRUE и прочую классику). Это не гламурная работа, но это разница между «крутой AI-фичей» и «почему Org A видит данные Org B» в инцидент-канале.
Потому что реальная схема — более 50 таблиц, и сваливать их все в каждый промпт — дорого и сбивает модель с толку: какой created_at к какой таблице относится. Об этом дальше отдельный раздел.
Вот проблема, которая не видна, пока схема — игрушечная. У нас 58 таблиц: ревью, PR, AI-комментарии, обратная связь, биллинг, лицензии, очереди задач. Если вставлять полную схему в каждый промпт, мы будем жечь тысячи токенов на описание license_seat_assignments для вопроса «сколько ревью было в прошлом месяце».
Вместо «вот вся база, удачи» мы используем dbctx — Go-библиотеку, которая по естественно-языковому вопросу и живому Postgres-соединению возвращает только нужные таблицы, отформатированные как компактный LLM-дружественный текст, а не сырой дамп information_schema.
dbctx использует многослойный конвейер: лексическое и нечёткое сопоставление имён таблиц и колонок, полнотекстовый поиск, сопоставление с реальными значениями в данных, опциональный семантический эмбеддинг и курируемый слой терминологии (чтобы «LOC» резолвился в billable_loc, а «MR» и «PR» — в одно понятие). Всё, что набрало балл выше нуля, попадает в контекст, плюс всё, что достижимо через внешний ключ от отобранного. Выигрыш огромен: типичный вопрос сужает 58 таблиц до 25–30 — это вдвое меньше контекста до того, как модель написала хоть символ SQL. Это не оптимизация для галочки, это разница между промптом, в котором модель ясно мыслит, и промптом, где важные таблицы погребены под шумом биллинга.
Итак, у нас есть спецификация Vega-Lite. Если пользователь в веб-дашборде — мы почти закончили. Но что насчёт Slack? Discord? Эти платформы не запускают JS-библиотеку внутри сообщения. Сообщение в Slack — не вкладка браузера. Нельзя попросить Slack интерпретировать спецификацию и отрисовать SVG.
Поэтому для всего, что не наш фронтенд, нужен реальный файл изображения. Здесь вступает vl-convert: Rust-бинарь (с Python и Node биндингами, но мы вызываем CLI), который принимает спецификацию Vega-Lite и растеризует её прямо в PNG, без headless-браузера. Это важно: старый способ — поднять headless Chrome, загрузить страницу с библиотекой графиков, сделать скриншот и молиться, чтобы Docker-образ не раздулся до двух гигабайт. vl-convert пропускает всё это: один бинарь, JSON на вход, PNG на выход, и так быстро, что никто не замечает.
Вот что я считаю действительно изящным. Мы генерируем одну спецификацию на график. Что с ней происходит дальше, зависит от того, куда она идёт.
В вебе фронтенд отдаёт сырую спецификацию в react-vega, и браузер делает всю работу: ховер-тултипы, ресайз, интерактив. Бэкенд для этого пути не рендерит изображения вообще — просто отдаёт JSON.
Для Slack и Discord та же спецификация уходит в vl-convert, превращается в плоскую PNG и прикрепляется к сообщению. Бот не знает и не заботится, что это «тот же» график, просто другой путь. С точки зрения конвейера, спецификация Vega-Lite — просто спецификация. Куда она попадёт, решает, станет ли она живым SVG или скриншотом в чате.
Это разделение позволяет дёшево добавлять новые места назначения: захотели еженедельный email-дайджест с графиками — та же спецификация, тот же vl-convert, новый механизм доставки. Сложная проблема (получить корректную, осмысленную спецификацию из LLM) решается ровно один раз.
Смысл Livi был не «добавить чат-бота», а замкнуть петлю между данными, которые мы уже собираем, и человеком, которому нужно действовать. CTO, спрашивающий «инженеры вообще используют эту штуку?», должен получить календарный хитмэп ритма использования, а не таблицу и пожатие плечами.
Рецепт, если вы строите похожее:
Попробуйте сами: возьмите свою базу, подключите dbctx, заставьте LLM генерировать Vega-Lite, и вы увидите, как просто получить честные графики без галлюцинаций. А если понравится — загляните в наш репозиторий и поддержите звездой.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →