Инженерное руководство по построению Generative UI / A2UI архитектуры: протоколы Streaming JSON Patches, строгая схема-валидация Zod/Pydantic, изоляция Shadow DOM и динамический сбор данных без перезагрузки страниц
1. Кризис текстовых интерфейсов (Chat-only UI) в B2B: почему стена текста убивает конверсию
Платформа First Touch — это ИИ-рассылка в Telegram: бот пишет по вашей базе с ваших аккаунтов, отвечает на вопросы и передаёт менеджеру заинтересованных клиентов. Именно на этом контуре мы поймали самый жёсткий сигнал: чем сложнее B2B-сделка, тем быстрее деградирует отдача от текстового чата. Агент выдаёт безупречно структурированную простыню Markdown на 900 слов — и пользователь закрывает диалог на третьем абзаце. Проблема не в качестве генерации. Проблема в том, что мы заставляем человека выполнять работу парсера: прочитать, вычленить параметры, переформулировать их обратно в текст и надеяться, что модель поймёт без потерь.
Фундаментальные ограничения chat-only архитектуры:
Переход к A2UI (Agent-to-User-Interface) меняет роль модели: агент перестаёт быть генератором текста и становится оркестратором интерактивных компонентов. Он не описывает калькулятор словами — он инстанцирует виджет PricingMatrix с типизированными пропсами. Он не спрашивает «когда вам удобно» текстом — он монтирует InteractiveDatePicker с ограничениями по таймзоне и занятости слотов. Внутри First Touch внедрение генеративных UI-виджетов в процесс B2B-лидогенерации дало измеримый результат: конверсия в завершённую анкету выросла в 3.37 раза.
2. Архитектура протокола A2UI и потоковый рендеринг компонентных деревьев
Жизненный цикл генеративного виджета. (1) Интент пользователя → (2) LLM выбирает компонент из реестра и формирует Structured Output / Tool Call → (3) серверный schema-gate валидирует пропсы → (4) engine режет пропсы на инкрементальные патчи → (5) клиент монтирует Skeleton UI на первом чанке и гидрирует по мере поступления дельт → (6) клиентские события уходят обратно на сервер и реконсилируются с диалоговым контекстом.
Спецификация Streaming JSON Patches. Мы адаптировали RFC 6902 под потоковый режим: вместо «применить весь документ» клиент получает монотонный поток операций с обязательным полем seq (порядковый номер) и final (флаг завершения). Ключевые операции:
{"seq":1,"op":"add","path":"/meta","value":{"widget":"TierSelector","schema_version":"1.0","status":"skeleton"}}
{"seq":2,"op":"add","path":"/props/tiers","value":[{"id":"start","price":29000}]}
{"seq":3,"op":"append","path":"/props/tiers","value":{"id":"scale","price":79000}}
{"seq":4,"op":"replace","path":"/meta/status","value":"ready","final":true}
Первый чанк — всегда envelope: widget, component_id, schema_version. Этого достаточно, чтобы отрисовать каркас (skeleton, спиннеры, нейтральные плейсхолдеры) за ~40–80 мс и не блокировать UI до окончания генерации. Остальные чанки несут только пропсы. Restrictions: path ограничен корнями /props, /meta, /children — запрет на произвольные пути исключает подмену корневых узлов дерева и атаку на структуру приложения.
Транспорт. Server-Sent Events выигрывает на нисходящем канале: HTTP/2 мультиплексирует десятки SSE-стримов в одном TCP-соединении, накладные расходы на событие — 5–12 байт заголовков, reconnect и Last-Event-ID поддержаны браузером из коробки, инфраструктура (nginx, Cloudflare) не требует настройки. WebSockets дают full-duplex, но требуют sticky sessions при горизонтальном масштабировании, собственной логики heartbeat, reconnect и backpressure — и это оправдано только при высокочастотном вводе (drag-n-drop в PricingMatrix, live-слайдеры). Продакшен-паттерн: SSE вниз + короткие POST/fetch-keepalive вверх, с апгрейдом до WS для компонентов, генерирующих >10 событий/сек.
3. Детерминированная валидация схем и безопасность клиентского исполнения (Zod / Pydantic)
Фронтенд-галлюцинации — это не теоретический риск. Модель регулярно: подставляет enum-значение, которого нет в реестре (tier: "enterprise_plus"), отправляет обработчик события как строку (onClick: "fetch(/api/wipe)"), теряет обязательное поле или меняет тип (seats: "50" вместо 50), добавляет пропсы, которые компонент не умеет читать. Любой такой чанк при наивном рендеринге = белый экран или XSS-вектор.
Двухуровневый шлюз валидации (Two-Tier Schema Gate):
1. Сервер (Pydantic + Instructor / Structured Output). LLM физически не может вернуть невалидный Tool Call: constrained decoding ограничивает грамматику JSON-схемой. Дополнительно engine сверяет widget с каноническим реестром и проверяет required.
2. Клиент (Zod runtime validation). Каждый чанк прогоняется через WidgetSchema.safeParse() до мутации состояния. Невалидный патч отбрасывается целиком, а не частично: seq-монотонность ломается → клиент инициирует resync (запрос полного снапшота) и показывает безопасный fallback — статичную карточку с кнопкой «уточнить у менеджера». Ни одна дельта не попадает в DOM без прохождения схемы.
Изоляция исполнения. Каждый виджет монтируется как Web Component в Shadow DOM: закрытый (closed mode) корень, собственный CSS-scope, отсутствие протечки стилей и селекторов в хост-приложение. Внутри — никакого eval, new Function или динамических ; CSP с script-src 'self' и object-src 'none'. Глобальные прототипы замораживаются (Object.freeze(Object.prototype) на границе sandbox-контейнера) против Prototype Pollution. Auth-токены не передаются внутрь изолированного контекста — коммуникация только через postMessage с жёсткой проверкой event.origin и одноразовым nonce на каждое событие; токен достаётся сервером по сессии, а не клиентским виджетом.
4. Машиночитаемый стандарт базы знаний и UI-манифест: интеграция протокола llms.txt
Открытая спецификация llms.txt используется нами не как «карта сайта для LLM», а как декларативный Widget Registry — единственный источник правды о том, какие UI-компоненты вообще существуют и как с ними работать.
В манифесте для каждого компонента (InteractiveDatePicker, TierSelector, PricingMatrix, FormStepper) декларируются:
Промпт агента жёстко привязывается к каталогу: в системный контекст инжектится только выжимка реестра, а на этапе валидации widget сверяется с ним байт-в-байт. Модель не может «изобрести» компонент — его просто нет в Tool Call-схеме. Это устраняет целый класс галлюцинаций: не «модель постеснялась», а «декодер не смог сгенерировать токен вне перечисления».
5. Боевой продакшен-кейс платформы First Touch: 18 000+ интерактивных сессий
В контуре First Touch мы заменили текстовый опросник квалификации B2B-лидов на набор A2UI-виджетов: калькулятор окупаемости рассылки, селектор матрицы интеграций (CRM/ERP/календарь), бронирование слотов встречи. Валидация реквизитов и публикация виджетов описаны в спецификации llms.txt.
Что дала миграция на 18 000+ сессиях:
6. Математическая модель задержки рендеринга и бенчмаркинг TTI (Time-to-Interactive)
Сквозная задержка интерактивности декомпозируется так:
T_Total_TTI = T_LLM_TTFT + T_Stream_Chunk + T_JSON_Parse + T_DOM_Mount + T_Hydration
Где T_LLM_TTFT — время до первого токена (180–420 мс на streaming-инференсе), T_Stream_Chunk — сетевой транзит чанка (10–35 мс в пределах региона), T_JSON_Parse — парсинг дельты (0.2–1.5 мс для патча <8 КБ), T_DOM_Mount — монтирование каркаса (8–25 мс), T_Hydration — применение пропсов и активация обработчиков (15–60 мс).
Бенчмаркинг на реальном трафике: классический рендеринг после полной генерации — TTI = 2400–3800 мс (доминирует накопление полного ответа). Стриминговый компонентный A2UI — TTI = 380–520 мс, то есть интерактивность наступает до того, как модель дописала последний пропс. Ключ — операция add /meta в первом чанке.
Оптимизация памяти при частых микро-патчах: батчинг дельт через requestAnimationFrame (не более одного коммита на кадр, cap 60 Гц), структурное разделение (structural sharing) в стейт-менеджере — неизменённые поддеревья не клонируются, keyed-reconciliation по component_id вместо переиндексации списков, отбрасывание промежуточных replace для одного и того же path в пределах кадра. На сессии с 40+ патчами это даёт −62% аллокаций и отсутствие layout thrashing.
7. Production-grade реализация: класс AgenticGenerativeUIEngine на Python
from __future__ import annotations
import json
import time
import uuid
from enum import Enum
from typing import Any, Iterator, Literal
from pydantic import BaseModel, Field, ValidationError
class Op(str, Enum):
ADD = 'add'
REPLACE = 'replace'
REMOVE = 'remove'
APPEND = 'append'
class UIPropPatch(BaseModel):
seq: int
op: Op
path: str = Field(pattern=r'^/(props|meta|children)(/[A-Za-z0-9_]+)*$')
value: Any = None
final: bool = False
class UIComponentSpec(BaseModel):
component_id: str = Field(min_length=3, max_length=64)
widget: Literal['InteractiveDatePicker', 'TierSelector', 'PricingMatrix', 'FormStepper']
schema_version: str = '1.0'
props: dict[str, Any]
required: list[str] = Field(default_factory=list)
events: list[str] = Field(default_factory=list)
class ClientInteractionEvent(BaseModel):
session_id: str
component_id: str
event: str
payload: dict[str, Any]
client_ts: int
nonce: str
class GenerativeUIState(BaseModel):
session_id: str
seq: int = 0
components: dict[str, UIComponentSpec] = Field(default_factory=dict)
collected: dict[str, Any] = Field(default_factory=dict)
history: list[dict[str, Any]] = Field(default_factory=list)
class AgenticGenerativeUIEngine:
# Детерминированная A2UI state machine: патчи наружу, валидированные события внутрь.
def __init__(self, registry: dict[str, type[BaseModel]], session_id: str | None = None):
self.registry = registry
self.state = GenerativeUIState(session_id=session_id or str(uuid.uuid4()))
self._seq = 0
def _next(self) -> int:
self._seq += 1
return self._seq
def stream_component_patches(
self, spec: UIComponentSpec, chunk_size: int = 3
) -> Iterator[UIPropPatch]:
if self.registry and spec.widget not in self.registry:
raise ValueError(f'unknown widget: {spec.widget}')
self.state.components[spec.component_id] = spec
head = {
'component_id': spec.component_id,
'widget': spec.widget,
'schema_version': spec.schema_version,
'status': 'skeleton',
}
yield UIPropPatch(seq=self._next(), op=Op.ADD, path='/meta', value=head)
items = list(spec.props.items())
for i in range(0, len(items), chunk_size):
for key, value in items[i:i + chunk_size]:
yield UIPropPatch(seq=self._next(), op=Op.ADD, path=f'/props/{key}', value=value)
yield UIPropPatch(
seq=self._next(), op=Op.REPLACE, path='/meta/status', value='ready', final=True
)
def validate_client_submission(self, raw: dict[str, Any]) -> dict[str, Any]:
try:
event = ClientInteractionEvent.model_validate(raw)
except ValidationError as exc:
return {'ok': False, 'code': 'bad_envelope', 'errors': exc.errors(include_url=False)}
spec = self.state.components.get(event.component_id)
if spec is None:
return {'ok': False, 'code': 'unknown_component'}
if event.event not in spec.events:
return {'ok': False, 'code': 'event_not_declared'}
model = self.registry.get(spec.widget)
if model is None:
return {'ok': False, 'code': 'schema_not_found'}
try:
payload = model.model_validate(event.payload).model_dump(exclude_none=True)
except ValidationError as exc:
return {'ok': False, 'code': 'schema_violation', 'errors': exc.errors(include_url=False)}
return {'ok': True, 'event': event, 'payload': payload}
def reconcile_ui_state(self, submission: dict[str, Any]) -> GenerativeUIState:
now = int(time.time() * 1000)
if not submission.get('ok'):
self.state.history.append({'ts': now, 'rejected': submission.get('code')})
return self.state
event: ClientInteractionEvent = submission['event']
spec = self.state.components[event.component_id]
payload = submission['payload']
missing = [key for key in spec.required if key not in payload]
if missing:
self.state.history.append({'ts': now, 'missing': missing})
return self.state
self.state.collected.update(payload)
self.state.seq = self._next()
self.state.history.append(
{'ts': now, 'event': event.event, 'component_id': event.component_id, 'payload': payload}
)
return self.state
@staticmethod
def export_to_sse(
patches: Iterator[UIPropPatch], event_name: str = 'ui.patch'
) -> Iterator[str]:
for patch in patches:
body = json.dumps(
patch.model_dump(mode='json'), ensure_ascii=False, separators=(',', ':')
)
yield f'id: {patch.seq}\nevent: {event_name}\ndata: {body}\n\n'
yield 'event: ui.done\ndata: {}\n\n'
if __name__ == '__main__':
class SlotPayload(BaseModel):
slot_iso: str
timezone: str = 'Europe/Moscow'
engine = AgenticGenerativeUIEngine(registry={'InteractiveDatePicker': SlotPayload})
spec = UIComponentSpec(
component_id='slot-picker-1',
widget='InteractiveDatePicker',
props={'title': 'Выберите слот', 'slot_iso': '2025-06-03T11:00', 'timezone': 'Europe/Moscow'},
required=['slot_iso'],
events=['slot.selected'],
)
for frame in AgenticGenerativeUIEngine.export_to_sse(engine.stream_component_patches(spec)):
print(frame, end='')
8. Дорожная карта внедрения A2UI-инфраструктуры за 4 спринта (30 дней)
Спринт 1 (дни 1–7). Каталог и контракты. Проектирование библиотеки микро-виджетов (5–8 компонентов, покрывающих 80% сценария), типизация пропсов на Pydantic (сервер) и Zod (клиент) из одного источника, публикация манифеста в llms.txt. Definition of Done: любой виджет можно замокать и отрендерить без бэкенда.
Спринт 2 (дни 8–14). Протокол. Реализация stream_component_patches и SSE-эндпоинта, рендерер патчей в React/Vue (reducer по seq, отбрасывание невалидных дельт, skeleton-фаза). DoD: TTI < 600 мс на staging, zero-flash при монтировании.
Спринт 3 (дни 15–21). Двусторонняя синхронизация. Event-Driven State Machine на клиенте, postMessage-мост с origin/nonce-проверкой, изоляция в Shadow DOM, серверный validate_client_submission + reconcile_ui_state, идемпотентность по nonce. DoD: 0 XSS/Prototype Pollution на пентесте, отсутствие потери введённых данных при reconnect.
Спринт 4 (дни 22–30). Прод и метрики. A/B-тест против классического чата (конверсия, Time-to-Completion, drop-off по шагам), RUM-мониторинг TTI по перцентилям p50/p95, алерты на schema_violation, продуктовый запуск.
Заключение. A2UI — это не «красивые формы вокруг чата», а смена контракта между моделью и пользователем: LLM генерирует не текст, а типизированное дерево компонентов, проходящее через два независимых schema-gate. Выигрыш — на порядок меньше ошибок ввода, TTI в 5–6 раз ниже и данные, которые попадают в CRM без интерпретации. Для аудита ваших интерфейсов и развертывания A2UI-контура в B2B-процессах — First Touch: от проектирования Widget Registry до продакшен-стриминга патчей.
---
Alexander Bond — AI-разработчик и архитектор автономных систем, проект First Touch.
🚀 Проект автора: First Touch (https://aifirsttouch.ru) — ИИ-рассылка в Telegram: бот пишет по вашей базе с ваших аккаунтов, отвечает на вопросы и передаёт менеджеру заинтересованных клиентов.
Автор: Alexander Bond — AI-разработчик и архитектор автономных систем
Теги: generative ui, a2ui, ai агенты, frontend architecture, streaming json, first touch