Главная / Статьи / Хватит позволять LLM писать HTML: семантические JSON-компоненты для AI-отчётов

Хватит позволять LLM писать HTML: семантические JSON-компоненты для AI-отчётов

Почему команда перевела AI-отчёты с сырого HTML на ограниченный словарь JSON, который рендерит детерминированный код, и чем семантические компоненты лучше примитивов вёрстки.

2398 слов

Позволить языковой модели писать финальный HTML для продукта — нормальный способ начать и плохой способ продолжать. Возьмём команду, которая делает сгенерированные AI отчёты с инсайтами о клиентах. Первая версия далась легко: отдать анализ LLM и попросить HTML. Через несколько месяцев в пайплайне были десятки шагов, горы сгенерированной разметки и стилей и промпты, в которых с трудом разбирались даже их авторы.

Лучший промпт проблему не решил, модель побольше — тоже. Сработало другое: у модели забрали свободу. Она перестала выдавать интерфейс и начала выдавать структурированный JSON из небольшого набора семантических компонентов, а рендерингом занялся обычный код. Результат стал предсказуемее, дешевле в эксплуатации и проще в развитии.

Зачем вообще понадобились отчёты

Продукт начинался как чат. Люди открывали приложение, спрашивали про отзывы клиентов, и модель находила подходящие инсайты. Технически всё работало. На практике это сильно нагружало пользователя: ему нужно было понимать, что спрашивать, какие данные есть, как сформулировать полезный вопрос и на какие темы стоит смотреть. Опытные пользователи справлялись. Многие другие буксовали, и дело было не в ответах. Они просто не знали, о чём спросить.

Поэтому команда перешла от модели pull к push. Владелец данных один раз описывает анализ, а результаты приходят автоматически. Сначала это была почта, и для коротких еженедельных сводок она подходила.

Проблемы начались, когда анализы стали богаче. Длинные письма трудно просматривать, таблицы ломаются на маленьких экранах, а подробные разделы перегружают сообщение. После определённого момента почта — неподходящий контейнер.

Отсюда HTML-отчёты. Письмо стало уведомлением, а отчёт — местом назначения. На веб-странице хватает места для разделов, графиков, карточек, рейтингов и деталей, и ею легко поделиться. Пайплайн был простым:

Customer data
      │
      ▼
AI analysis workflow
      │
      ▼
Generated HTML + CSS
      │
      ▼
Hosted report
      │
      ▼
Email with link

Для первых нескольких отчётов это работало на удивление хорошо.

Сырой HTML — отличный прототип

Первая реализация была примитивной: передать модели данные, показать пример нужного результата и попросить HTML. На раннем этапе это привлекательно. Модели хорошо пишут HTML, а инфраструктуры почти не нужно. Поменять вёрстку, добавить раздел или попробовать новый тип карточки часто означает поправить промпт, а не писать фронтенд-компоненты. Для небольших отчётов такая гибкость была ценной.

С ростом проявилась слабость. Один большой процесс генерации отчёта дорос примерно до 40 шагов. Около 30 из них получали или готовили данные, и ещё примерно 10 существовали только для того, чтобы сгенерировать и собрать HTML. Один отчёт мог содержать сотни килобайт разметки и CSS.

На таком объёме проблемы подпитывали друг друга. Модели приходилось следовать сложной визуальной спецификации, писать огромные ответы и сохранять единообразие во многих разделах. Мелкие правки промпта неожиданно меняли вёрстку, а сам объём сгенерированной разметки делал всё неоправданно дорогим.

Очевидный обходной путь — разрезать страницу на задачи поменьше:

Analysis
   │
   ├── Generate sections 1–2 ─┐
   ├── Generate sections 3–4 ─┤
   ├── Generate sections 5–6 ─┼──► Combine HTML ─► Report
   ├── Generate sections 7–8 ─┤
   └── Generate sections 9–10 ┘

Это сняло непосредственные проблемы на стороне модели. Вызовы стали меньше, контекста для рассуждений — меньше, а лимиты на вывод перестали угрожать.

Но появилась новая проблема. Теперь кто-то должен был знать, какой промпт отвечает за какую часть страницы, как фрагменты складываются вместе, какие стили куда относятся и как изменение в одной части влияет на другие. Поддерживать отчёт значило разбираться в распределённой фабрике HTML из промптов.

Дело было не в промптах

Проще всего было бы считать это проблемой промптинга: чёткие инструкции, примеры получше, свежая модель для более стабильной разметки. Ничто из этого не затронуло бы настоящую причину — уровень абстракции.

Одна крайность даёт модели полную свободу выдавать произвольные HTML и CSS:

Data → LLM → arbitrary HTML/CSS

Это максимум гибкости и почти никакой предсказуемости.

Противоположная крайность — фиксированный шаблон, в котором модель только заполняет содержимое:

Data → LLM → fixed fields → hard-coded template

Это эффективно и предсказуемо, но тоже не подходит: разным отчётам действительно нужна разная структура. В одном пять разделов, в другом десять. Где-то нужны графики, где-то рейтинги, где-то длинный текст, где-то сетки количественных инсайтов.

Нужное лежало посередине. Модель должна составлять отчёт, но не реализовывать его. Именно это различие определило новый дизайн.

Уроки email и Slack

Та же проблема существует и вне AI. Разработчики писем живут с ней годами. Надёжный HTML для email печально известен своей сложностью, потому что почтовые клиенты поддерживают разные и часто древние подмножества HTML и CSS. Фреймворки вроде MJML отвечают на это языком более высокого уровня. Вы описываете структуру, а фреймворк генерирует уродливую разметку:

<mj-section>
  <mj-column>
    <mj-text>Hello world</mj-text>
  </mj-column>
</mj-section>

Block Kit в Slack построен на похожей идее. Приложения не отправляют в Slack сырой HTML. Они отправляют структурированный JSON с описанием разделов, текста, картинок, кнопок и других элементов, а Slack сам решает, как это будет выглядеть.

Это было близко к тому, что требовалось команде. Вместо того чтобы просить у модели что-то такое:

Create a blue card with a 24px title, two chips below it,
the percentage on the right and 16px padding.

они хотели получать что-то такое:

{
  "type": "QuantifiedInsight",
  "title": "Delivery complaints increased",
  "memoryCount": 184,
  "share": 0.27
}

Ответственность полностью смещается. Модель описывает смысл содержимого, а рендерер решает, как это реализовать визуально.

Почему JSON подходит моделям

Дальше встал вопрос, каким должен быть язык отчётов. Можно было сделать XML-формат в духе MJML, но победил JSON, и причина была связана не столько со вкусами разработчиков, сколько с моделями. Современные API для LLM отлично выдают структурированный JSON, а JSON Schema позволяет точно описать ожидаемую форму. Это даёт чёткий контракт и возможность проверить вывод до того, как что-либо будет отрендерено.

Упрощённое определение компонента выше:

{
  "type": "object",
  "properties": {
    "type": {
      "const": "QuantifiedInsight"
    },
    "title": {
      "type": "string"
    },
    "memoryCount": {
      "type": "integer",
      "minimum": 0
    },
    "share": {
      "type": "number",
      "minimum": 0,
      "maximum": 1
    }
  },
  "required": [
    "type",
    "title",
    "memoryCount",
    "share"
  ]
}

Свобода остаётся, но она ограничена. Чтобы выдать такой инсайт, модель обязана указать заголовок, количество и процент, причём количество не может быть отрицательным, а процент должен лежать между нулём и единицей.

Раньше промпт сводился к «вот данные и немного советов по дизайну, теперь собери страницу». Новый звучит так: «вот данные, а вот строительные блоки, которыми тебе разрешено пользоваться; опиши отчёт с их помощью». Такой системой управлять гораздо проще.

Adaptive Cards как основа

Изобретать весь формат с нуля не хотелось. Block Kit от Slack был полезным ориентиром, но принадлежит экосистеме Slack. Microsoft Adaptive Cards подошли лучше: открытая декларативная модель на JSON с готовыми решениями для текста, контейнеров, колонок, таблиц и картинок, плюс библиотеки рендеринга, на которые можно опереться. Главное — её можно было расширить собственными элементами более высокого уровня.

Слои в итоге выглядели так:

AI-controlled
                             │
                             ▼
Customer data ─► Analysis ─► Report JSON
                             │
─────────────────────────────┼────────────────────────────
                             │
                        Code-controlled
                             ▼
                         Validation
                             │
                             ▼
                    Deterministic renderer
                             │
                             ▼
                            HTML

Эта граница — ключевое архитектурное решение. Всё, что выше неё, может быть генеративным. Всё, что ниже, должно быть детерминированным: одинаковый JSON каждый раз должен давать одинаковый результат.

Общие примитивы всё ещё давали слишком много свободы

Сначала модель получила довольно общие строительные блоки UI. Она могла создать контейнер, добавить текст, вставить колонки и разместить в них элементы. Технически это работало.

Однако вскоре команда поняла, что просто воссоздала старую проблему уровнем выше. Когда модель продолжает выбирать количество колонок, место для процента, порядок чипов и расположение частей, у непоследовательности остаётся масса лазеек. Отчёт может провалиться, даже если его JSON абсолютно валиден.

Представьте карточку с инсайтом, абсолютным количеством и процентом. Общая версия могла бы выглядеть так:

{
  "type": "InsightCard",
  "chips": [
    {
      "label": "27%",
      "style": "secondary"
    },
    {
      "label": "184 memories",
      "style": "primary"
    }
  ]
}

Модель могла поменять чипы местами, продублировать один, выбрать не тот стиль или вовсе потерять один из них. Ни один из этих вариантов ничего не даёт пользователям.

Поэтому компонент стал семантическим:

{
  "type": "QuantifiedInsight",
  "title": "Delivery complaints increased",
  "memoryCount": 184,
  "share": 0.27
}

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

Этот небольшой шаг отражает важный урок: семантические компоненты лучше общих примитивов вёрстки. ColumnSet говорит, как что-то расположено. QuantifiedInsight говорит, что это значит. Чем ближе словарь к языку самого продукта, тем меньше работы по реализации остаётся модели.

Рендеринг становится обычным софтом

Приятный побочный эффект: рендеринг перестаёт быть AI-задачей. Упрощённая версия на TypeScript:

function renderElement(element: ReportElement) {
  switch (element.type) {
    case 'QuantifiedInsight':
      return renderQuantifiedInsight({
        title: element.title,
        count: element.memoryCount,
        percentage: element.share,
      });
    case 'Timeline':
      return renderTimeline(element);
    case 'Chart':
      return renderChart(element);
    default:
      return renderStandardAdaptiveCardElement(element);
  }
}

Реальная реализация может по-прежнему использовать Adaptive Cards под капотом, и собственный элемент в итоге превращается в контейнеры, текстовые блоки и колонки. Модели об этом знать не нужно.

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

Проектирование словаря

Остаётся вопрос дизайна: насколько гибким должен быть каждый компонент? Одна плохая крайность — единственный компонент с десятками необязательных свойств:

{
  "type": "EverythingCard",
  "icon": "...",
  "rank": 1,
  "percentage": 0.27,
  "image": "...",
  "timeline": true,
  "compact": false
}

Другая — новый компонент на каждую крошечную визуальную вариацию.

Этот компромисс похож на проектирование React-компонентов. Иногда два представления — это один компонент с парой пропсов. В других случаях это разные понятия, и их стоит держать отдельно. Лучший арбитр — смысл: если два блока передают одну идею, хватит ограниченного свойства, а если разные — им, скорее всего, нужны отдельные компоненты. Цель не в минимуме компонентов, а в словаре, которым модель может надёжно пользоваться.

Меньше свободы, больше полезной гибкости

Поначалу это звучит парадоксально. Модель больше не может генерировать произвольные HTML или CSS, не может свободно решать, как расположить каждый элемент, а у некоторых компонентов есть форма, которую она не способна изменить. И при этом система в целом стала гибче.

Дело в том, что полезная гибкость — не то же самое, что свобода реализации. Пользователям важно, может ли отчёт вместить нужные разделы, инсайты, данные и визуальные формы. Им всё равно, собран ли процент из одного <div> или из трёх вложенных <span>. Такая свобода добавляет вариативности и не добавляет ценности.

Когда её убрали, модели можно безопасно доверить больше решений, которые действительно важны: какие разделы уместны, какие компоненты лучше всего выражают инсайт, какой контент куда ставить и сколько компонентов нужно разделу. Чего она не может — так это заново изобретать реализацию каждого компонента при каждом запуске. Это более здоровое разделение обязанностей.

Паттерн обобщается

Урок на самом деле не про HTML. Он про то, где генеративному AI место внутри программной системы.

Для прототипа позволить модели выдавать финальный артефакт — мощный приём. Вы движетесь быстро, обходитесь без инфраструктуры и узнаёте, что нужно продукту. На том этапе сырая генерация была правильным выбором. Проблемы начинаются, когда прототип становится частью повторяемой системы, потому что прямой генерацией становится сложнее управлять, чем она того стоит. Хороший следующий шаг — поднять модель на уровень выше в архитектуре:

  • Не просите её строить интерфейс. Просите описать интерфейс.
  • Не просите собирать финальный документ. Просите семантическую структуру документа.
  • Не просите решать низкоуровневые детали. Дайте ей небольшой язык, покрывающий те решения, которые вы хотите ей доверить.

Общий паттерн:

Natural language
      │
      ▼
      AI
      │
      ▼
Small semantic language
      │
      ▼
Deterministic software
      │
      ▼
Final artifact

Когда работаешь так, многие вопросы перестают быть промпт-инжинирингом и снова становятся проектированием ПО. Какие понятия модель должна уметь выражать? Где гибкость действительно нужна? Что можно убрать? Как схема может исключить невалидные или нежелательные состояния? Где проходит граница между решениями модели и решениями кода? Это знакомые инженерные задачи, и генеративный AI их не отменяет. Он лишь делает выбор хороших абстракций ещё важнее.

Что дальше

Словарь всё ещё дорабатывается. Первый отчёт уже переведён на новый подход, и его сравнивают с исходным, прежде чем переносить другие процессы. Постепенный темп выбран сознательно: система компонентов, построенная вокруг одного отчёта, скорее всего, переобучена под него, а та, что работает на нескольких реальных отчётах, начинает превращаться в платформу. Какие-то компоненты, вероятно, исчезнут, другие упростятся, появятся новые семантические элементы.

Направление при этом понятно. Модель решает, что сказать и какие строительные блоки использовать. Софт точно определяет, как эти блоки ведут себя и рендерятся. Эта граница превратила растущий клубок HTML-промптов во что-то близкое к обычному ПО. Хорошее эмпирическое правило: тратьте модель на решения, которым нужен интеллект, а код — на решения, которым нужна определённость.