Додати чат-асистента у вебпродукт у демо виглядає просто: викликати API моделі, вивести відповідь. У продакшені користувачі очікують, що текст з'являтиметься під час генерації, асистент шукатиме дані у вашій системі, відповіді переживуть перезавантаження сторінки, а вся конструкція не розорить вас, коли ендпоінт знайде бот. У гайді покроково будуємо AI-чат у застосунку на Next.js App Router з Vercel AI SDK — від першого застрімленого токена до захищеної продакшен-функції. Ті самі патерни діють і при прямих викликах SDK провайдерів.

Архітектура на одній схемі

Браузер (React, useChat)
   │  POST /api/chat  (messages)
   ▼
Next.js Route Handler (сервер)
   ├─ автентифікація + rate limit
   ├─ завантаження контексту (користувач, RAG)
   ├─ streamText(model, system, messages, tools)
   │     └─ інструменти → ваша БД / API (з правами користувача)
   └─ стрім відповіді (SSE) ──► Браузер рендерить токени в міру надходження

Ключовий принцип: ключ API моделі й усе виконання інструментів залишаються на сервері. Браузер лише надсилає повідомлення й отримує стрім.

Чому стрімінг важливий

Типова відповідь на 300 токенів генерується кілька секунд. Без стрімінгу користувачі дивляться на спінер; зі стрімінгом перші слова з'являються менш ніж за секунду, і відчутна затримка різко падає. Стрімінг через HTTP — це довготривала відповідь частинами, зазвичай у форматі server-sent events. Route handlers у Next.js нативно підтримують стрімінгові відповіді через Web API ReadableStream (route handlers у Next.js), а AI SDK бере протокол на себе.

Стрімінг впливає й на інфраструктуру: проксі та балансувальники не мають буферизувати відповіді, а serverless-функціям потрібен таймаут, достатній для повної відповіді.

Сервер: route handler

// app/api/chat/route.ts
import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from "ai"
import { anthropic } from "@ai-sdk/anthropic"
import { auth } from "@/lib/auth"
import { rateLimit } from "@/lib/rate-limit"
import { orderTools } from "@/lib/ai/tools"

export const maxDuration = 60 // секунд, для serverless-платформ

export async function POST(req: Request) {
  const session = await auth()
  if (!session) return new Response("Unauthorized", { status: 401 })

  const { success } = await rateLimit(`chat:${session.user.id}`, { limit: 30, window: "10m" })
  if (!success) return new Response("Too many requests", { status: 429 })

  const { messages }: { messages: UIMessage[] } = await req.json()

  const result = streamText({
    model: anthropic(process.env.CHAT_MODEL!), // id моделі з конфігурації, а не в коді
    system: SYSTEM_PROMPT,
    messages: await convertToModelMessages(messages.slice(-20)), // обмеження історії
    tools: orderTools(session.user.id),
    stopWhen: stepCountIs(5), // максимум раундів викликів інструментів
    maxOutputTokens: 800,
    abortSignal: req.signal, // зупинити генерацію, якщо користувач пішов зі сторінки
  })

  return result.toUIMessageStreamResponse()
}

Деталі, що мають значення:

  • Спершу автентифікація. Чат-ендпоінт без автентифікації — відкритий проксі до платного API.
  • Rate limiting на користувача і глобально — до будь-якого виклику моделі.
  • Обмеження історії. Надсилання всієї розмови на кожному кроці збільшує вартість квадратично; залишайте останні N повідомлень або підсумовуйте старші; див. контекстна інженерія.
  • Ліміт кроків для циклів виклику інструментів і максимум вихідних токенів.
  • Abort signal, щоб закрита вкладка зупиняла генерацію й білінг.
  • Id моделі в конфігурації, щоб змінювати модель без деплою; див. як обрати LLM.

Клієнт: useChat

// app/(app)/assistant/chat.tsx
"use client"
import { useChat } from "@ai-sdk/react"
import { useState } from "react"

export function Chat() {
  const { messages, sendMessage, status, stop, error } = useChat()
  const [input, setInput] = useState("")
  const busy = status === "submitted" || status === "streaming"

  return (
    <section aria-label="Асистент">
      <ol aria-live="polite" className="space-y-4">
        {messages.map((m) => (
          <li key={m.id} data-role={m.role}>
            {m.parts.map((part, i) =>
              part.type === "text" ? <p key={i}>{part.text}</p> : null,
            )}
          </li>
        ))}
      </ol>
      {error && <p role="alert">Щось пішло не так. Спробуйте ще раз.</p>}
      <form
        onSubmit={(e) => {
          e.preventDefault()
          if (!input.trim() || busy) return
          sendMessage({ text: input })
          setInput("")
        }}
      >
        <label htmlFor="chat-input" className="sr-only">Ваше запитання</label>
        <textarea id="chat-input" value={input} onChange={(e) => setInput(e.target.value)} />
        {busy ? (
          <button type="button" onClick={stop}>Зупинити</button>
        ) : (
          <button type="submit">Надіслати</button>
        )}
      </form>
    </section>
  )
}

Повідомлення складаються з типізованих частин (parts) — текст, виклики інструментів, результати інструментів, міркування, файли, — тож можна показувати активність інструментів («Шукаю замовлення №4821…»), а не залишати користувачів чекати в тиші. Кнопка Зупинити не опційна: користувачам потрібен контроль над довгими генераціями.

Безпечний рендеринг виводу моделі

Вивід моделі — недовірений вхід для вашого UI. Якщо рендерите Markdown:

  • Використовуйте рендерер, що не дозволяє сирий HTML, або санітизуйте результат.
  • Не завантажуйте автоматично зображення за URL від моделі — це відомий канал витоку даних в атаках prompt injection.
  • Відкривайте посилання з rel="noopener noreferrer" і розгляньте allow-список доменів.

Ці заходи — частина ширшої картини зі статті безпека AI-агентів і класичних веб-ризиків з OWASP Top 10.

Інструменти: асистент працює з вашими даними

Інструменти — серверні функції, які модель може викликати. Описуйте їх схемами Zod і забезпечуйте права в коді:

// lib/ai/tools.ts
import { tool } from "ai"
import { z } from "zod"
import { db } from "@/lib/db"

export function orderTools(userId: string) {
  return {
    getOrderStatus: tool({
      description:
        "Get status, items and delivery estimate for one of the CURRENT user's orders. " +
        "Use when the user asks where their order is or what it contains.",
      inputSchema: z.object({
        orderNumber: z.string().regex(/^\d{4,8}$/).describe("Order number, digits only"),
      }),
      execute: async ({ orderNumber }) => {
        const order = await db.order.findFirst({
          where: { number: orderNumber, customerId: userId }, // перевірка прав
          select: { number: true, status: true, eta: true, items: { select: { name: true, qty: true } } },
        })
        return order ?? { error: `Order ${orderNumber} not found for this account.` }
      },
    }),
  }
}

Зверніть увагу: userId береться із сесії, а не від моделі. Наскільки добре це працює, визначає дизайн інструментів — назви, описи, компактні відповіді, корисні помилки; див. проєктування інструментів для LLM-агентів.

Структурований вивід для UI-функцій

Не кожна AI-функція — чат. Для форм, фільтрів чи підсумків, що керують UI, генеруйте типізовані об'єкти замість тексту:

import { generateObject } from "ai"
import { z } from "zod"

const { object } = await generateObject({
  model: anthropic(process.env.FAST_MODEL!),
  schema: z.object({
    category: z.enum(["bug", "billing", "feature_request", "other"]),
    summary: z.string().max(200),
    urgency: z.number().int().min(1).max(3),
  }),
  prompt: `Classify this support message:\n<message>${text}</message>`,
})

streamObject стрімить часткові об'єкти для поступового UI. Дизайн схем і валідація описані у статті структурований вивід LLM.

Збереження й відновлення розмов

Користувачі очікують, що розмови переживуть перезавантаження й зміну пристрою:

  • Зберігайте повідомлення на сервері (Postgres цілком підходить) за ідентифікаторами розмови й користувача, включно з частинами й результатами інструментів.
  • Зберігайте після завершення через finish-колбек SDK, а повідомлення користувача — одразу.
  • Завантажуйте початкові повідомлення в Server Component і передавайте їх клієнту.
  • Відновлювані стріми: якщо з'єднання обірвалося посеред відповіді, або відновлюйте з серверного буфера, або генеруйте заново. Для більшості продуктів достатньо показати часткову відповідь із кнопкою «Згенерувати знову».

Застосовуйте політики зберігання: логи чату містять персональні дані; див. приватність LLM-застосунків.

Відповіді на основі вашого контенту

Асистент продукту має відповідати з вашої документації, а не із загальних знань моделі. Додайте пошук перед генерацією — шукайте в документації за запитанням користувача й додавайте найкращі фрагменти в системний промпт або як інструмент, який модель може викликати. Повний RAG-пайплайн — у статті RAG для бізнесу.

Надійність і вартість

  • Повторні спроби й фолбеки: збої провайдерів і 429 трапляються; повторюйте з backoff і перемикайтеся на іншу модель чи провайдера для критичних функцій; див. надійність LLM API.
  • Кешування промптів: тримайте системний промпт і визначення інструментів стабільними й на початку, щоб максимізувати влучання в кеш.
  • Маршрутизація моделей: швидка дешева модель для класифікації й коротких відповідей; сильніша — коли потрібні інструменти чи міркування.
  • Бюджети: денні ліміти токенів на користувача й глобальні алерти; див. оптимізація витрат на LLM.
  • Спостережуваність: трасуйте кожен запит із токенами, затримкою й викликами інструментів; див. спостережуваність LLM.

Доступність і UX

AI-чат — це все одно вебінтерфейс, і European Accessibility Act поширюється на багато споживчих продуктів:

  • Використовуйте область aria-live="polite" для нових повідомлень, але не оголошуйте кожен токен — оголошуйте після завершення або порціями розміром із речення.
  • Переконайтеся, що поле вводу має видиму чи доступну для скрінрідера мітку, а кнопки «Надіслати» й «Зупинити» доступні з клавіатури.
  • Показуйте зрозумілий статус: думає, використовує інструмент, генерує, помилка.
  • Повідомляйте користувачам, що вони спілкуються з AI, і що він може, а чого ні, — це ще й вимога прозорості EU AI Act; див. гайд з EU AI Act.

Стежте й за продуктивністю: повторний розбір дерева Markdown на кожен токен може погіршити INP. Обмежуйте частоту оновлень або мемоізуйте завершені повідомлення.

Нотатки щодо розгортання

  • Edge чи Node runtime: Node — безпечніший вибір за замовчуванням для доступу до БД і сумісності SDK.
  • Буферизація: якщо використовуєте Nginx чи інший reverse proxy, вимкніть буферизацію відповідей для маршруту чату, інакше стрім приходитиме одним шматком.
  • Таймаути: узгодьте serverless maxDuration, таймаути проксі й максимальний час генерації моделі.
  • Захист від ботів: чат-ендпоінти приваблюють зловживання; поєднуйте автентифікацію, ліміти й, для публічних чатів, перевірку для анонімних користувачів.

FAQ

Чи обов'язково використовувати Vercel AI SDK? Ні. Він прибирає шаблонний код для стрімінгу, інструментів і стану React і підтримує багато провайдерів. Прямі виклики SDK провайдерів теж працюють; тоді протокол стрімінгу й стан клієнта реалізуєте самі.

Чи можна використати це із self-hosted моделлю? Так. Використовуйте OpenAI-сумісного провайдера, спрямованого на ваш ендпоінт vLLM чи Ollama; див. власний хостинг LLM.

Чи запускати чат у Server Action замість route handler? Route handlers краще підходять для стрімінгового чату, і їх простіше обмежувати й спостерігати. Server Actions добре працюють для одноразової генерації, прив'язаної до форм.

Як це тестувати? Покривайте юніт-тестами інструменти й перевірки прав, як будь-який серверний код, і запускайте набір реальних запитань проти всього ендпоінта на кожну зміну промпту чи моделі; див. evals для LLM.

Джерела

  1. Vercel. Документація AI SDK.
  2. Next.js. Route Handlers (route.js).
  3. MDN. Server-sent events і ReadableStream.
  4. Anthropic. Streaming messages.
  5. MDN. ARIA live regions.
  6. OWASP. Top 10 for LLM Applications 2025.