Навигация

AI UI: стриминг и generative UI

Пользователь AI-продукта не видит модель — он видит интерфейс: как появляется текст, что происходит, пока агент думает, и что нажимать, когда агент собрался удалить данные. Устаревший взгляд «стриминг — это чат с печатающимся текстом» больше не описывает реальность 2026 года: ответ содержит вызовы инструментов, генеративный UI и шаги многошагового агента, которые нужно уметь показать. Эта статья — о паттернах интерфейсов AI-продуктов на примере Vercel AI SDK: стриминг, tool-calling UI, generative UI, human-in-the-loop, состояния многошагового агента, ошибки и чек-лист. Примеры кода — на React, но паттерны framework-agnostic: SDK поддерживает React, Vue/Nuxt, Svelte и Angular, и всё описанное переносится между ними. Прод-надёжность и стоимость — отдельная тема статьи AI-фичи в production, безопасность рендера — Безопасность вывода.

Содержание

  1. Стриминг: зачем и как ощущается
  2. Что делает SDK
  3. Tool-calling UI
  4. Generative UI
  5. Human-in-the-loop
  6. Многошаговый агент в UI
  7. Ошибки и восстановление
  8. Кейс: бронирование с подтверждением
  9. Чек-лист AI-интерфейса
  10. Ключевые тезисы для интервью
  11. Заключение
  12. Полезные ссылки

Стриминг: зачем и как ощущается

Модель генерирует ответ токен за токеном. Ответ на 500 токенов при скорости 50 токенов/сек — это 10 секунд. Если показывать его только целиком, пользователь всё это время видит пустой экран. Стриминг отдаёт текст по мере генерации, поэтому первый токен появляется через TTFT — сотни миллисекунд, — и человек начинает читать почти сразу.

Без стриминга:  запрос → [ генерация 10 сек ] → весь ответ разом
Со стримингом:  запрос → токен₁ → токен₂ → … → токен₅₀₀
                          ↑ виден через ~TTFT (сотни мс)

Ключевая мысль: стриминг не ускоряет генерацию, он снижает воспринимаемую latency. Реальное время то же, но ощущение — мгновенный отклик. Природа TTFT и total latency разобрана в статье Ментальная модель LLM; здесь важно следствие для UI.

МетрикаБез стримингаСо стримингом
Первый видимый контентпосле полной генерации (~10 с)через TTFT (~сотни мс)
Воспринимаемая скоростьмедленномгновенно
Можно читать в процессенетда
Реальное время генерации10 с10 с (то же)

Из этого следуют обязательные элементы интерфейса:

  • Индикация состояния. Пока контента нет — skeleton или спиннер; как только пошли токены — курсор/статус. Пользователь всегда понимает, что процесс идёт, а не сломался.
  • Кнопка «Стоп». Прерывание генерации — это не вежливость, а контроль бюджета: сервер перестаёт жечь токены.
  • Блокировка повторной отправки. Пока идёт стриминг, ввод недоступен (с понятным плейсхолдером), иначе запросы конкурируют.
  • Автоскролл с уважением к пользователю. Следим за потоком, только если пользователь внизу; если он отскроллил вверх читать — не трогаем.

Что делает SDK

Ручной парсинг потока (readable stream, сборка чанков, AbortController) — решённая задача. В Vercel AI SDK за это отвечает useChat из @ai-sdk/react; фронтендер работает с состоянием, а не с байтами.

'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

export function Chat() {
  const { messages, sendMessage, status, stop, regenerate, error } = useChat({
    transport: new DefaultChatTransport({ api: '/api/chat' }),
    throttle: 50, // сглаживаем частоту ре-рендеров
  });

  const busy = status === 'submitted' || status === 'streaming';
  // ...
}

status принимает четыре значения и заменяет ручные булевы флаги:

statusЧто означаетЧто показывать
submittedзапрос отправлен, ждём начало потокаспиннер/skeleton, «Стоп»
streamingчанки идутпоток текста, «Стоп»
readyответ получен полностьюввод разблокирован
errorзапрос завершился ошибкойсообщение об ошибке, retry

На стороне сервера тот же SDK превращает поток модели в поток частей интерфейса:

import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
} from 'ai';

const result = streamText({
  model: 'anthropic/claude-sonnet-5.5',
  messages: await convertToModelMessages(messages),
});

return createUIMessageStreamResponse({
  stream: toUIMessageStream({ stream: result.stream }),
});

Под капотом — data stream protocol поверх Server-Sent Events: события text-start / text-delta / text-end, tool-input-*, tool-output-*, start-step/finish-step, error, финальный [DONE]. Понимать этот формат нужно, только если вы пишете бэкенд на другом языке (тогда ставится заголовок x-vercel-ai-ui-message-stream: v1) — в обычном приложении его берёт на себя SDK.

ЗадачаSDKUI (вы)
Парсинг SSE и сборка чанков✅—
Накопление частей сообщения✅—
status, stop, regenerate, error✅кнопки и индикаторы на их основе
Индикация генерации—skeleton, курсор, статус-бар
Автоскролл и блокировка ввода—логика в компоненте
Рендер частей (текст, tool, шаги)—switch (part.type)

Главное отличие от старого подхода: SDK 5+ не отдаёт строку content. Сообщение — это массив частей (parts), и рендерить нужно именно их:

{messages.map((message) => (
  <div key={message.id}>
    {message.parts.map((part, i) => {
      switch (part.type) {
        case 'text':
          return <span key={i}>{part.text}</span>;
        case 'reasoning':
          return <pre key={i}>{part.text}</pre>;
        // tool-*, file, source-url, step-start — ниже
      }
    })}
  </div>
))}

Vue, Nuxt и другие фреймворки

Все описанные паттерны — протокольные, а не React-специфичные: сообщение приходит тем же потоком типизированных частей, и рендерить его можно в любом фреймворке. AI SDK UI поддерживает React, Vue, Svelte и Angular; для Vue/Nuxt это пакет @ai-sdk/vue. Отличия от React косметические: вместо хуков — композабл с реактивными ref, вместо Next route handler — Nitro API route.

<script setup lang="ts">
import { useChat } from '@ai-sdk/vue';
import { DefaultChatTransport } from 'ai';

const { messages, sendMessage, status, stop, regenerate, error } = useChat({
  transport: new DefaultChatTransport({ api: '/api/chat' }),
  throttle: 50, // опция throttle есть и в React, и во Vue
});
</script>

Серверный код при этом идентичен и переиспользуется между фреймворками: streamText, convertToModelMessages, createUIMessageStreamResponse — те же, потому что клиент и сервер общаются одним data stream protocol. Экосистема готовых компонентов у Nuxt своя: Nuxt UI даёт ChatMessages, ChatPrompt, ChatReasoning и ChatTool, причём последний уже умеет раскрывать состояния tool-частей из раздела ниже.

Единственное React/Next-эксклюзивное — генеративный UI через React Server Components (streamUI, стриминг серверных компонентов). Его развитие приостановлено, а «современный» generative UI (результат инструмента → типизированный компонент на клиенте) доступен во Vue/Nuxt в полном объёме.


Tool-calling UI

Когда модель вызывает инструмент, это не «побочный эффект», а часть интерфейса. Каждый вызов приходит как часть с типизированным именем tool-<имя> и проходит набор состояний через поле state.

input-streaming → input-available → (approval-requested → approval-responded)
                                   → output-available | output-error | output-denied

Типовая ошибка — рендерить только финальный результат. Пользователь при этом видит «ничего → готово» и не понимает, что происходило. Правильный подход: своему состоянию — свой компонент.

case 'tool-getWeather': {
  const id = part.toolCallId;
  switch (part.state) {
    case 'input-streaming':
      return <Skeleton key={id} />;               // аргументы ещё формируются
    case 'input-available':
      return <Spinner key={id} label="Проверяю погоду…" />;
    case 'output-available':
      return <Weather key={id} {...part.output} />; // generative UI, см. ниже
    case 'output-error':
      return <ToolError key={id} text={part.errorText} />;
    case 'output-denied':
      return <Denied key={id} />;
  }
}

Важные нюансы:

  • Инструменты бывают серверные и клиентские. Серверные имеют execute и выполняются на бэкенде; клиентские (браузерные API, состояние UI) обрабатываются в onToolCall, а результат возвращается через addToolOutput.
  • Tool call streaming включён по умолчанию в SDK 5+, поэтому аргументы (part.input) видны по мере генерации — это состояние input-streaming.
  • Динамические инструменты (без схемы, например из MCP) приходят с типом dynamic-tool, а не tool-<имя>; их обрабатывают общим компонентом.
  • Для type-safety есть хелперы InferUITool / InferUITools, а результат можно типизировать через обобщённый UIMessage.
import { InferUITools, UIMessage, UIDataTypes } from 'ai';
type MyUITools = InferUITools<typeof tools>;
type MyUIMessage = UIMessage<never, UIDataTypes, MyUITools>;

Generative UI

Generative UI — это когда результат инструмента попадает напрямую в React-компонент, а не в абзац текста. Модель решает, какой инструмент вызвать, инструмент возвращает структурированные данные, а вы рендерите под них свой компонент.

Запрос → модель вызывает displayWeather({ location }) → { weather, temperature, location }
       → <Weather {...output} />  ← вместо generic-текста

Пример: инструмент объявлен со схемой и execute, а на клиенте его результат подставлен в компонент.

// ai/tools.ts
export const weatherTool = createTool({
  description: 'Display the weather for a location',
  inputSchema: z.object({ location: z.string() }),
  execute: async ({ location }) => ({ weather: 'Sunny', temperature: 22, location }),
});
case 'tool-displayWeather':
  if (part.state === 'output-available') return <Weather {...part.output} />;

Когда generative UI уместен:

УместенНе уместен
Структурный результат: погода, курс, таблица, карточка товараОбъяснение, рассуждение, ответ на вопрос
Данные, которые лучше показать графическиДлинный связный текст
Результат известного инструмента со схемойСвободная генерация HTML/кода

Граница практическая: если результат можно типизировать и нарисовать компонентом — это generative UI; если это объяснение — оставляйте текст. Не пытайтесь «рендерить разметку, которую сгенерировала модель»: это одновременно и хрупко, и опасно (см. Безопасность Вывода).

Паттерн framework-agnostic: во Vue/Nuxt результат инструмента так же подставляется в компонент (<Weather v-bind="part.output" />), а Nuxt UI поставляет готовые компоненты для типовых частей диалога — переносить нужно только разметку, но не логику.


Human-in-the-loop

Агенту нельзя без спроса выполнять необратимые действия: списать деньги, удалить файлы, отправить письмо, оформить заказ. Паттерн human-in-the-loop (HITL) встраивает подтверждение прямо в поток: инструмент объявляется требующим одобрения, и цикл останавливается до решения пользователя.

На сервере:

const result = streamText({
  model: 'anthropic/claude-sonnet-5.5',
  messages,
  tools: { deleteFile },
  toolApproval: { deleteFile: 'user-approval' },
  experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET,
});

Инструмент всё ещё выполняется на сервере — но только после одобрения. На клиенте вы ловите состояние approval-requested и рисуете диалог:

if (part.type === 'tool-deleteFile' && part.state === 'approval-requested') {
  return (
    <div>
      <p>Удалить {part.input.path}?</p>
      <button onClick={() =>
        addToolApprovalResponse({ id: part.approval.id, approved: true })
      }>Удалить</button>
      <button onClick={() =>
        addToolApprovalResponse({ id: part.approval.id, approved: false })
      }>Отмена</button>
    </div>
  );
}

Что важно понимать:

  • Состояния одобрения — approval-requested → approval-responded, а отказ приводит к output-denied. Одобрение/отказ бывает и автоматическим: тогда part.approval.isAutomatic === true, и кнопки рисовать не нужно.
  • После ответа, если ничего не происходит, нужно продолжить диалог: вручную вызвать sendMessage или включить sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses.
  • Безопасность: в схеме useChat клиент каждый раз отправляет всю историю на сервер, поэтому изменённый клиент мог бы подделать одобрение. Для чувствительных инструментов сервер подписывает решение (experimental_toolApprovalSecret) и проверяет его криптографически.
  • HITL — это не только «модалка Да/Нет». Запрос уточнения, выбор из вариантов, редактирование аргументов до запуска — всё это расширения одного паттерна: остановить цикл и вернуть управление человеку.

Многошаговый агент в UI

Современный агент — это цикл: модель вызывает инструмент, получает результат, вызывает следующий и только потом отвечает текстом. В UI такой ответ — не одно сообщение, а последовательность шагов, разделённых частями step-start.

Границы цикла задаются на сервере через stopWhen (агент останавливается по условию) и prepareStep (настройки каждого шага):

import { ToolLoopAgent, isStepCount, hasToolCall } from 'ai';

const agent = new ToolLoopAgent({
  model: 'anthropic/claude-sonnet-5.5',
  tools,
  stopWhen: [isStepCount(20), hasToolCall('done')], // страховка от зацикливания
  prepareStep: async ({ stepNumber }) => {
    // на первом шаге — детерминированно, дальше — свободнее
    return stepNumber === 0 ? { temperature: 0 } : {};
  },
});

У ToolLoopAgent есть дефолтный лимит в 20 шагов — защита от «убежавшего» цикла и расходов. Условия комбинируются: isStepCount(N), hasToolCall(...), isLoopFinished() или собственная функция (например, «стоп, если стоимость шагов превысила $0.50»).

В интерфейсе шаги рендерятся по частям:

message.parts.map((part, i) => {
  if (part.type === 'step-start') {
    return i > 0 ? <hr key={i} className="step-divider" /> : null;
  }
  // text, tool-*, reasoning — как обычно
});

Как не допустить «мигания»

Главная UX-проблема многошагового агента — интерфейс дёргается: то пусто, то tool-карточка, то снова пусто, то текст. Правила:

  • Стабильные ключи. Ключ — part.toolCallId, а не индекс: React не должен пересоздавать узлы при добавлении шагов.
  • Не сбрасывать уже показанное. Текст и результаты предыдущих шагов остаются в parts; не очищайте контейнер при переходе к следующему шагу.
  • Placeholder вместо пустоты. Для каждого состояния tool-части показывайте skeleton со «скелетом» будущего результата — так высота не скачет.
  • Не переключать режимы без нужды. Если дизайн показывает tool-карточку, не заменяйте её на текст и обратно в середине потока; состояние input-streaming — это уже карточка, а не пустое место.
  • Сглаживать частоту обновлений. Опция throttle (например, 50 мс) снижает число ре-рендеров, не влияя на обработку потока.

Ошибки и восстановление

Ошибки в AI-интерфейсе — нормальное состояние, а не исключение. Есть два принципиально разных случая.

СлучайЧто уже показаноЧто делать UI
Ошибка до первого токенаничегоerror state + retry
Ошибка в середине потокачасть ответасохранить частичный ответ + ошибка + retry

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

{error && (
  <div className="message-error">
    <span>Не удалось получить ответ.</span>
    <button onClick={() => regenerate()}>Повторить</button>
  </div>
)}

Прерывание — отдельный сценарий, но с тем же принципом:

  • stop() отменяет текущий запрос (под капотом — AbortController), и уже сгенерированное остаётся в истории.
  • Ошибка клиентского инструмента фиксируется через addToolOutput со state: 'output-error' и errorText, а не «проглатывается» — пользователь видит, какой шаг упал.
  • Сообщение об ошибке показывайте общее («Что-то пошло не так»): не выносите в UI детали сервера.
  • Инструменты, исполняемые провайдером, могут обходить стандартное маскирование ошибок — не полагайтесь на клиент для их редактирования.

Детали деградации, fallback-цепочек и наблюдаемости ошибок в проде — в статье AI-фичи в production.


Кейс: бронирование с подтверждением

Соберём паттерны в одном сценарии: пользователь просит «забронируй столик в итальянском ресторане на 20:00».

Шаг 1. Модель вызывает поисковый инструмент. UI показывает input-streaming → input-available («Ищу рестораны…») → output-available со списком. Это generative UI: результат findRestaurant рендерится карточками, а не текстом.

Шаг 2. Пользователь выбирает ресторан. Выбор уходит как результат клиентского инструмента через addToolOutput, и sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls автоматически запускает следующий шаг цикла — без ручного «Отправить».

Шаг 3. Модель собирается бронировать. bookTable объявлен как toolApproval: 'user-approval'. Цикл останавливается на approval-requested: карточка «Забронировать столик в Trattoria на 20:00?» с кнопками.

Шаг 4. Подтверждение. addToolApprovalResponse({ approved: true }) продолжает цикл; сервер, проверив подпись одобрения, выполняет бронь и возвращает output-available с подтверждением.

Шаг 5. Ошибка на брони. Если слот занят, инструмент возвращает output-error — частичный результат (выбранный ресторан) остаётся на экране, а UI предлагает выбрать другое время. Пользователь не теряет контекст и видит путь дальше.

Что показывает кейс: стриминг, tool-calling UI, generative UI, HITL и многошаговость — не отдельные фичи, а слои одного интерфейсного цикла, где человек и агент действуют по очереди.


Чек-лист AI-интерфейса

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

  • Индикация генерации появляется сразу (skeleton до первого токена, курсор/статус после)
  • Кнопка «Отправить» превращается в «Стоп» во время генерации
  • После «Стоп» генерация реально прекращается, уже сгенерированное остаётся
  • Ввод заблокирован во время стриминга с понятным плейсхолдером
  • Автоскролл следует за потоком, но не трогает пользователя, поднявшегося вверх
  • Каждое состояние tool-части (input-streaming, input-available, output-available, output-error, output-denied) имеет свой рендер
  • Необратимые действия проходят через approval-requested, и для чувствительных инструментов включена серверная проверка
  • Ошибка до стрима → error state + retry; ошибка в середине → частичный ответ сохранён + retry
  • Многошаговый ответ разделён step-start, а ключи частей стабильны (toolCallId) — UI не мигает
  • После любого финала (ready или error) интерфейс возвращается в рабочее состояние

Этот чек-лист — та же спецификация, которую передают агенту или команде при разработке AI-интерфейса.


Ключевые тезисы для интервью

  • Стриминг не ускоряет генерацию, а снижает воспринимаемую latency: пользователь читает с первого токена (TTFT), пока модель генерирует остальное.
  • AI SDK берёт на себя транспорт и состояние: useChat, status, stop, regenerate, накопление частей и data stream protocol поверх SSE; фронтенду остаются индикация, автоскролл и рендер.
  • Сообщение — это массив parts (текст, tool-<имя>, reasoning, файлы, источники, step-start), а не строка content; рендерить нужно именно части.
  • AI SDK UI framework-agnostic: те же паттерны (стриминг, tool-части, HITL) работают в React, Vue/Nuxt, Svelte и Angular, потому что опираются на общий data stream protocol; React/Next-специфичен только приостановленный RSC-подход streamUI.
  • Tool call — часть интерфейса: состояния input-streaming → input-available → (approval-*) → output-available | output-error | output-denied, каждому соответствует свой компонент.
  • Generative UI подключает результат инструмента прямо к React-компоненту; это уместно для структурных данных, но не вместо текстовых объяснений.
  • Human-in-the-loop обязателен для необратимых действий: approval-requested + addToolApprovalResponse, а для чувствительных инструментов — серверная подпись решения.
  • Многошаговый агент управляется stopWhen (isStepCount, hasToolCall, isLoopFinished) и prepareStep; дефолтный лимит ToolLoopAgent — 20 шагов против зацикливания.
  • «Мигание» UI лечится stable keys (toolCallId), placeholder’ами по состоянию и отказом от сброса уже показанного между шагами.
  • При ошибке частичный ответ сохраняется, а regenerate/retry даёт путь дальше — очищать экран нельзя.

Заключение

AI-интерфейс — это не чат с печатающимся текстом, а несколько слоёв поверх одного стрима:

  • Стриминг снижает воспринимаемую latency; индикация, «Стоп», автоскролл и блокировка ввода — обязательный минимум.
  • SDK делает транспорт и состояние (useChat, status, stop, regenerate), а фронтенд рендерит части сообщения.
  • Tool-calling UI показывает вызовы инструментов с их состояниями, а generative UI превращает результат в типизированный компонент.
  • Human-in-the-loop возвращает управление человеку перед необратимыми действиями, с серверной проверкой одобрения.
  • Многошаговые агенты требуют управления циклом (stopWhen, prepareStep) и аккуратного рендера шагов без «мигания».
  • Ошибки — нормальное состояние: частичный ответ сохраняется, пользователь всегда получает путь дальше.

Начните с чек-листа: если каждый сценарий (генерация, «Стоп», скролл, tool-состояния, подтверждение, ошибка) имеет предсказуемое поведение — интерфейс готов. Дальше — прод-надёжность и стоимость в AI-фичах в production.


Полезные ссылки