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, безопасность рендера — Безопасность вывода.
Содержание
- Стриминг: зачем и как ощущается
- Что делает SDK
- Tool-calling UI
- Generative UI
- Human-in-the-loop
- Многошаговый агент в UI
- Ошибки и восстановление
- Кейс: бронирование с подтверждением
- Чек-лист AI-интерфейса
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Стриминг: зачем и как ощущается
Модель генерирует ответ токен за токеном. Ответ на 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.
| Задача | SDK | UI (вы) |
|---|---|---|
| Парсинг 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.
Полезные ссылки
- AI SDK — Chatbot (useChat) — хук,
status,stop,regenerate, метаданные и кастомные транспорты - AI SDK — Chatbot Tool Usage — серверные и клиентские инструменты, состояния tool-частей, одобрение
- AI SDK — Generative User Interfaces — рендер React-компонентов из результатов инструментов
- AI SDK — Stream Protocols — data stream protocol поверх SSE: части
text-*,tool-*,start-step,[DONE] - AI SDK — Agents Overview (ToolLoopAgent) — агент как LLM + инструменты + цикл
- AI SDK — Loop Control —
stopWhen,isStepCount,hasToolCall,prepareStep, компакция - AI SDK — Tool Approvals — безопасность одобрений для чувствительных инструментов
- AI SDK — Vue.js (Nuxt) Quickstart — стриминг-чат на
@ai-sdk/vueс Nitro API route - Nuxt UI — Chat components —
ChatMessages,ChatTool,ChatReasoning,ChatPromptдля AI SDK - Nuxt UI — Build an AI Chatbot — полноценный пример чата на Nuxt + AI SDK
- MDN — Server-Sent Events — транспорт, поверх которого идёт поток