Додати чат-асистента у вебпродукт у демо виглядає просто: викликати 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.
Джерела
- Vercel. Документація AI SDK.
- Next.js. Route Handlers (route.js).
- MDN. Server-sent events і ReadableStream.
- Anthropic. Streaming messages.
- MDN. ARIA live regions.
- OWASP. Top 10 for LLM Applications 2025.