Vercel AI SDK i useChat: streaming bez klucza API
Działający czat na Vercel AI SDK 7 w Next.js: jak useChat streamuje odpowiedź, jak wyglądają status i części wiadomości, na modelu-atrapie bez klucza API.

Żeby użyć Vercel AI SDK w aplikacji Next.js, wyrenderuj useChat z @ai-sdk/ w Client Component, skieruj jego DefaultChatTransport na route handler, a w nim zwróć wynik streamText przez createUIMessageStreamResponse({ stream: toUIMessageStream(...) }). useChat daje wtedy messages, których tekst przychodzi w message.parts, oraz status, który przechodzi przez submitted, streaming i ready. Nasze demo robi dokładnie to na AI SDK 7 (ai 7.0.152, @ai-sdk/ 4.0.156) i Next.js 16.4.3, z modelem-atrapą w miejscu dostawcy: bez klucza API, bez płatnych wywołań, a osiem testów w Playwright na buildzie produkcyjnym sprawdza, co robi demo.
Czym jest Vercel AI SDK: AI SDK Core i AI SDK UI
Wprowadzenie do AI SDK (otwiera się w nowej karcie) dzieli zestaw na dwie części. AI SDK Core to „ujednolicone API do generowania tekstu, obiektów, wywołań narzędzi i budowania agentów z LLM”: działa na serwerze, a streamText to jego wywołanie ze streamingiem. AI SDK UI to „zestaw niezależnych od frameworka hooków do budowania czatów i interfejsów generatywnych”: do czatu służy useChat, który działa w przeglądarce. Aplikacja z czatem używa obu, połączonych strumieniem HTTP, a demo ma po jednym pliku na każdą stronę i osobny na model-atrapę.
| Działa w | W demie | Pakiet | |
|---|---|---|---|
| AI SDK UI | przeglądarce, w Client Component | useChat w chat.tsx | @ai-sdk/ 4.0.156 |
| AI SDK Core | na serwerze, w route handlerze | streamText w api/ | ai 7.0.152 |
| Model | tam, gdzie dostawca | MockLanguageModelV4 w canned-model.ts | ai/ |
useChat to hook Reacta, więc plik, który go wywołuje, zaczyna się od "use client", a wszystko, co importuje, trafia do przeglądarki: nasze demo o tym, co trafia do przeglądarki z Server i Client Components, pokazuje, gdzie przebiega ta granica. Route handler zostaje na serwerze, czyli tam, gdzie powinien być klucz API dostawcy.
Co daje useChat: status, wiadomości i części
Przewodnik po czatach (otwiera się w nowej karcie) wymienia cztery wartości status: submitted, gdy wiadomość wysłano, a strumień odpowiedzi jeszcze się nie zaczął, streaming, gdy przychodzą kolejne fragmenty, ready, gdy cała odpowiedź dotarła i można wysłać następną wiadomość, oraz error, gdy żądanie się nie powiodło. messages to rozmowa, a każda wiadomość ma parts zamiast jednego stringa content, więc tekst, wywołania narzędzi i inne typy części przychodzą na jednej liście. Wysyła się przez sendMessage({ text }). Pole tekstowe należy do ciebie: useChat już nim nie zarządza.
Cały interfejs to komponent kliencki dema:
"use client"; import { useChat } from "@ai-sdk/react";import { DefaultChatTransport } from "ai";import { useState } from "react"; import styles from "./chat.module.css"; export function Chat() { const { messages, sendMessage, status } = useChat({ // The default endpoint is /api/chat; this demo's route sits next to its page. transport: new DefaultChatTransport({ api: "/demos/vercel-ai-sdk-usechat-streaming/api/chat" }), }); const [input, setInput] = useState(""); // Busy only while a reply is on its way, so an "error" status does not lock the input. const busy = status === "submitted" || status === "streaming"; return ( <section className={styles.chat} aria-label="Chat"> <p className={styles.status}> status <output aria-label="Status">{status}</output> </p> <ol className={styles.messages} aria-label="Messages"> {messages.map((message) => ( <li key={message.id} className={styles.message} data-role={message.role}> <span className={styles.role}>{message.role === "user" ? "You" : "Assistant"}</span> <p> {message.parts.map((part, index) => part.type === "text" ? ( <span key={index} data-part="text"> {part.text} </span> ) : null, )} </p> {message.role === "assistant" && ( <code className={styles.parts}>parts: {message.parts.map((part) => part.type).join(", ")}</code> )} </li> ))} </ol> <form className={styles.form} onSubmit={(event) => { event.preventDefault(); if (!input.trim()) return; sendMessage({ text: input }); setInput(""); }} > <input aria-label="Message" value={input} onChange={(event) => setInput(event.target.value)} disabled={busy} placeholder="Say something" /> <button type="submit" disabled={busy || !input.trim()}> Send </button> </form> </section> );}Trzy szczegóły warto skopiować:
- Pole tekstowe to zwykły stan Reacta. Formularz czyści je po wywołaniu
sendMessage, auseChatod razu dodaje wiadomość użytkownika domessages: test widzi ją na liście tuż po kliknięciu, gdy status nadal pokazujesubmittedalbostreaming. - Tekst pochodzi z części. Odpowiedź powstaje z części o
type === "text". Demo wypisuje pod odpowiedzią typ każdej części, a test odczytujeparts: step-start, text: częśćstep-startoznacza początek kroku modelu, a tekst leży w jednej częścitext, która rośnie z każdym fragmentem. - Zajęty znaczy submitted albo streaming. Przykład z dokumentacji blokuje pole, gdy
status !== 'ready', czyli również przyerror. Demo blokuje je tylko wtedy, gdy odpowiedź jest w drodze. Test sprawdza, że pole jest zablokowane w trakcie streamingu, znów działa przyreadyi że druga wiadomość przechodzi.
Jak useChat streamuje odpowiedź
useChat wysyła całą rozmowę do trasy jako POST po HTTP i czyta odpowiedź jako strumień. DefaultChatTransport domyślnie wysyła na /; trasa dema leży obok jego strony, więc podaje api. Trasa wzoruje się na trasie z dokumentacji, z atrapą zamiast identyfikatora modelu i bez instructions oraz maxDuration:
import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream, type UIMessage } from "ai"; import { cannedModel } from "../../canned-model"; // useChat posts the whole conversation here and reads the reply as a UI message stream.export async function POST(req: Request) { const { messages }: { messages: UIMessage[] } = await req.json(); const result = streamText({ model: cannedModel(), // a real provider's model goes here; nothing else in this file changes messages: await convertToModelMessages(messages), }); return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }), });}convertToModelMessages zamienia wiadomości UI (z ich częściami) na to, co przyjmuje model. streamText wywołuje model i streamuje to, co zwraca. toUIMessageStream zamienia strumień modelu na fragmenty wiadomości UI, a createUIMessageStreamResponse wysyła je jako odpowiedź HTTP. Te dwa bezstanowe helpery to sposób odpowiadania w AI SDK 7: przewodnik migracji do 7.0 (otwiera się w nowej karcie) oznacza jako przestarzałe result.toUIMessageStreamResponse(), które pokazuje większość tutoriali. W v7 nadal działa, z ostrzeżeniem, i zniknie w następnej wersji głównej.
Route handlery w Next.js (otwiera się w nowej karcie) nie są cache’owane, poza handlerami GET, które się do tego włączy, więc trasa POST taka jak ta działa przy każdym żądaniu.
Co płynie w strumieniu
Strumień wiadomości UI to Server-Sent Events: jedna linia data: z JSON-em na fragment. Strona o protokole strumienia (otwiera się w nowej karcie) wymienia wszystkie typy fragmentów i mówi, że własny backend musi wysłać nagłówek x-vercel-ai-ui-message-stream: v1. Test dema wysyła jedną wiadomość prosto do trasy i czyta, co wraca:
| Zdarzenie, po kolei | Co oznacza (protokół strumienia) | W odpowiedzi dema |
|---|---|---|
start | zaczyna się nowa wiadomość | raz |
start-step | zaczyna się jedno wywołanie modelu | raz, staje się częścią step-start |
text-start | zaczyna się blok tekstu, z id | raz |
text-delta | kolejny tekst dla tego bloku, w delta | 17 razy, po jednym na słowo |
text-end | blok tekstu jest kompletny | raz |
finish-step, finish | koniec wywołania modelu, potem wiadomości | po razie |
[DONE] | koniec strumienia | na końcu |
Odpowiedź przychodzi jako text/ z nagłówkiem v1, a siedemnaście złączonych delt daje całą odpowiedź. W przeglądarce test zapisuje każdą wersję tekstu odpowiedzi, jaką pokazuje strona: jest ich co najmniej pięć, każda wyrasta z poprzedniej, więc tekst przychodzi w kilku fragmentach, a nie naraz. W tej samej wymianie status pokazuje kolejno ready, submitted, streaming, ready.
Domyślnie useChat publikuje aktualizację wiadomości po każdym przetworzonym fragmencie. Dla długich, szybkich odpowiedzi przewodnik po czatach proponuje throttle (na przykład throttle: 50, w milisekundach), żeby renderować rzadziej; demo go nie używa, żeby było widać każde słowo.
Jak uruchomić to bez klucza API: model-atrapa
Model to jedyna część dema, która nie jest kodem produkcyjnym:
import { simulateReadableStream } from "ai";import { MockLanguageModelV4 } from "ai/test"; const reply = "This reply is canned: a mock model streams it word by word, as a real provider would."; // A stand-in for a provider's model: no key, no network, no cost. It answers every message with// the same reply, one word per chunk, slow enough to watch. A new one per request, because a mock// keeps every call it receives (doStreamCalls), which a test wants and a server does not.export function cannedModel() { return new MockLanguageModelV4({ doStream: async () => ({ stream: simulateReadableStream({ initialDelayInMs: 600, // the wait for the first token chunkDelayInMs: 150, // the gap between tokens chunks: [ { type: "text-start", id: "text-1" }, ...reply.split(/(?<= )/).map((delta) => ({ type: "text-delta" as const, id: "text-1", delta })), { type: "text-end", id: "text-1" }, { type: "finish", finishReason: { unified: "stop", raw: undefined }, usage: { inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined }, outputTokens: { total: 17, text: 17, reasoning: undefined }, }, }, ], }), }), });}MockLanguageModelV4 pochodzi z ai/, czyli z narzędzi do testów (otwiera się w nowej karcie) AI SDK. Jego doStream zwraca to samo, co model dostawcy: text-start, fragmenty text-delta, text-end i finish ze zużyciem tokenów. simulateReadableStream (otwiera się w nowej karcie) wysyła je z opóźnieniami, które domyślnie wynoszą 0. Demo czeka 600 ms przed pierwszym słowem, w zastępstwie czasu do pierwszego tokena, i 150 ms między słowami, żeby dało się to obejrzeć. simulateReadableStream importuj z ai, tak jak robi to strona o testach.
Inny test sprawdza, że nic nie wychodzi na zewnątrz: każde żądanie strony podczas wysyłania idzie do jej własnego originu, a jedyny POST trafia do trasy dema.
Dlaczego route handler, a nie DirectChatTransport
AI SDK ma jeszcze dwa sposoby na ominięcie sieci. DirectChatTransport (otwiera się w nowej karcie) rozmawia z agentem „w tym samym procesie, bez HTTP” i nie umie wznowić strumienia. Strona o testach pokazuje też trasę, która zwraca ręcznie napisane stringi SSE przez simulateReadableStream. Oba sposoby testują interfejs, ale żaden nie uruchamia trasy, którą wdrożysz. Z atrapą za streamText wszystko poza modelem jest kodem, który trafi na produkcję, a test może sprawdzić, co przechodzi przez sieć.
Jak dodać czat na Vercel AI SDK do aplikacji Next.js
Zainstaluj dwa pakiety
Uruchom
pnpm add ai @ai-sdk/. Według przewodnika migracji do 7.0 AI SDK 7 wymaga Node.js 22 lub nowszego i działa tylko jako ESM.react Napisz route handler
Utwórz
app/zapi/ chat/ route.ts POST, który czytamessages, zamienia je przezconvertToModelMessages, wywołujestreamTexti zwracacreateUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) }). Prompt systemowy podaj winstructions: AI SDK 7 zmieniło tę nazwę zsystem.Zacznij od modelu-atrapy
Jako model przekaż
MockLanguageModelV4zai/, z fragmentami ztest simulateReadableStream. Interfejs da się zbudować i przetestować, zanim ktokolwiek dostanie klucz API.Zbuduj czat w Client Component
W pliku z
"use client"wywołajuseChatzDefaultChatTransport(ustawapi, jeśli trasa nie leży pod/), trzymaj pole tekstowe wapi/ chat useState, przy wysłaniu wywołujsendMessage({ text })i renderuj części wiadomości otype === "text".Steruj interfejsem przez status
Blokuj formularz, gdy
statustosubmittedalbostreaming, pokaż spinner przysubmittedi daj przycisk Stop wywołującystop(), gdy odpowiedź jest streamowana.Przetestuj na buildzie produkcyjnym
Uruchom
next buildinext start, a potem sprawdź w Playwright to, co widzi użytkownik: kolejność statusów, tekst rosnący w kilku krokach i żądania idące tylko do twojej trasy.Podmień model na prawdziwy
Zastąp atrapę modelem dostawcy w trasie, trzymaj klucz w zmiennej środowiskowej na serwerze i ustaw
maxDurationw trasie, żeby długie odpowiedzi nie były ucinane. Po stronie klienta nic się nie zmienia.
Co psuje czat na useChat
Tutoriale pisane pod AI SDK 4
Wiele tutoriali o Vercel AI SDK nadal używa input i handleSubmit z ai/. Przewodnik migracji do 5.0 (otwiera się w nowej karcie) usunął to API, a AI SDK 7 znów zmieniło stronę serwerową:
| W starszym tutorialu | W AI SDK 7 |
|---|---|
import { useChat } from 'ai/ | import { useChat } from '@ai-sdk/ |
input, handleInputChange | własny useState |
handleSubmit | obsługa wysłania formularza, która wywołuje sendMessage({ text }) |
append(message) | sendMessage(message) |
useChat({ api: '/ | useChat({ transport: new DefaultChatTransport({ api }) }) |
message.content | message.parts |
isLoading | status |
result.toUIMessageStreamResponse() | createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) }) |
system: '…' | instructions: '…' |
result.fullStream | result.stream |
Trzy ostatnie nadal działają w v7 jako przestarzałe aliasy. Pierwsze siedem nie działa.
Przykład streamingu w dokumentacji Next.js
Na dzień 2 listopada 2026 przykład streamingu w dokumentacji route.js w Next.js (otwiera się w nowej karcie) nadal importuje StreamingTextResponse i wywołuje result.toAIStream(). Przewodnik migracji do AI SDK 4.0 (otwiera się w nowej karcie) usunął oba: „The StreamingTextResponse function has been removed”, a toAIStream jest na jego liście metod usuniętych z wyniku streamText. Kształt trasy bierz z przewodnika po czatach AI SDK.
Trasa obok strony
route.ts nie może leżeć w tym samym segmencie trasy co page.tsx, jak mówi przewodnik po route handlerach. Daj jej własny folder (app/ albo api/ pod stroną, jak w demie) i powiedz transportowi, gdzie jest.
Zablokowane pole przy błędzie
Przy disabled={status !== 'ready'} nieudane żądanie zostawia formularz zablokowany, bo error to nie ready. Blokuj tylko przy submitted i streaming, a błąd pokaż z przyciskiem Retry, który wywołuje regenerate(), jak w przewodniku po czatach.
Długie odpowiedzi na hostingu serverless
Trasa z przewodnika po czatach ustawia maxDuration = 30, żeby pozwolić na streaming odpowiedzi do 30 sekund. maxDuration (otwiera się w nowej karcie) to opcja segmentu w Next.js, w sekundach, którą platformy hostingowe mogą odczytać z wyniku builda, żeby ustawić limit czasu funkcji. Ustaw ją tak, żeby starczyła na najdłuższą odpowiedź, w granicach twojego planu.
Na jakich wersjach to sprawdziliśmy
AI SDK 7 to obecna wersja główna, a jej pakiety szybko się zmieniają: wersje w labie są tydzień za najnowszymi. Daty poniżej to czasy publikacji z wpisów w rejestrze npm dla pakietu ai (otwiera się w nowej karcie) i pakietu @ai-sdk/react (otwiera się w nowej karcie), odczytane 2 listopada 2026.
| Pakiet | Pierwsze wydanie 7.x / 4.x | W labie | Najnowsze 2 listopada 2026 |
|---|---|---|---|
ai | 7.0.0, 25 czerwca 2026 | 7.0.152, 23 października 2026 | 7.0.159, 30 października 2026 |
@ai-sdk/ | 4.0.0, 25 czerwca 2026 | 4.0.156, 23 października 2026 | 4.0.163, 30 października 2026 |
Na 7.0.159 i 4.0.163 jeszcze dema nie uruchamialiśmy. Gdy lab się zaktualizuje, te same osiem testów przejdzie ponownie, a ten tekst zmieni się, jeśli któryś nie przejdzie.
Co to jest Vercel AI SDK?
Otwartoźródłowy zestaw narzędzi w TypeScripcie od Vercela do budowania funkcji AI. AI SDK Core działa na serwerze i wywołuje modele (streamText, generateText), a AI SDK UI to hooki takie jak useChat, które trzymają stan czatu w przeglądarce i czytają streamowaną odpowiedź.
Czy Vercel AI SDK jest darmowy?
Pakiet ai jest open source na licencji Apache-2.0, a jego instalacja nic nie kosztuje. Za wywołania API płaci się dostawcy modelu, którego podłączysz; z modelem-atrapą, jak w naszym demie, nie ma żadnych opłat.
Dlaczego useChat nie ma handleSubmit ani input?
Usunęło je AI SDK 5. useChat nie zarządza już polem tekstowym: trzymaj jego wartość we własnym useState i wywołuj sendMessage({ text }) w obsłudze wysłania formularza. append zamieniło się w sendMessage, opcja api przeszła do transportu, message.content do message.parts, a isLoading do status.
Czym różni się useChat od useCompletion?
useChat trzyma listę wiadomości złożonych z części i przez całą wymianę podaje status. useCompletion obsługuje jedno uzupełnienie tekstu: zwraca pojedynczy string completion i sam zarządza polem input, więc pasuje do jednorazowego promptu, a nie do rozmowy.
Czy Vercel AI SDK działa bez hostingu na Vercelu?
Tak. Trasa to zwykły route handler Next.js, a useChat to hook Reacta, nic w nich nie zależy od platformy Vercela. Testy naszego dema działają na buildzie produkcyjnym uruchomionym przez next start na lokalnym komputerze.
