Crash Course de Vercel AI SDK v6
Aprende text generation, streaming, tools y embeddings, y crea tu propio ChatGPT en minutos

El objetivo de este artículo es compartir directamente mis aprendizajes con la v6 del Vercel AI SDK, veremos:
¿Qué es y para qué sirve Vercel AI SDK?
Generación de texto básica
Streaming de respuestas
Structured outputs
Tool calling
Embeddings
Construir un chat full-stack que soporta imágenes
Vamos a ello!
El código completo está en este repo.
¿Qué es Vercel AI SDK?
Es una librería de TypeScript que estandariza cómo interactúas con cualquier modelo de IA. Básicamente, resuelve este problema: cada proveedor tiene su propio SDK, sus propios métodos, su propia estructura de respuestas. Cambias de OpenAI a Anthropic y tienes que reaprender todo y reescribir tu código. Vercel AI SDK soluciona eso dándote una sola API que se encarga de todas las diferencias entre proveedores. Mismos métodos, misma estructura de respuestas, sin importar si estás usando OpenAI, Anthropic, Google o cualquier otro.
Tiene dos partes principales:
AI SDK Core: El lado del servidor: generateText, streamText, tools, structured outputs, embeddings, etc.
AI SDK UI: Hooks de React como useChat que manejan todo el estado y la comunicación entre tu UI y el backend.
El beneficio concreto: si hoy usas GPT-4 y mañana quieres cambiar a Claude, cambias 2 líneas de código y listo.
Ahora pasemos al código, te voy a dar los ejemplos más concretos y si tienes alguna duda, puedes pasarle este artículo a tu LLM y seguramente podrá guiarte.
Setup
Crea el proyecto e instala las dependencias:
mkdir node-examples
cd node-examples
npm init -y
npm install ai @ai-sdk/openai @ai-sdk/anthropic zod
npm install -D typescript tsx @types/node
Agrega "type": "module" a tu package.json:
{
"name": "node-examples",
"version": "1.0.0",
"type": "module"
}
Crea un .env con tus API keys:
OPENAI_API_KEY=tu_key_aqui
ANTHROPIC_API_KEY=tu_key_aqui
Y un tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true
}
}
1. generateText
Crea basic.ts:
import { generateText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
const result = await generateText({
model: anthropic("claude-haiku-4-5"),
prompt: "¿Cuál es la capital de Francia?",
});
console.log(result.text);
Córrelo:
npx tsx --env-file=.env basic.ts
Ahora lo interesante, cambia a OpenAI modificando solo 2 líneas:
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
const result = await generateText({
model: openai("gpt-4o-mini"),
prompt: "¿Cuál es la capital de Francia?",
});
console.log(result.text);
Mismo código, diferente proveedor, mismo result.text. Eso es lo que resuelve el SDK.
También puedes agregar system prompt y controlar el output:
const result = await generateText({
model: anthropic("claude-haiku-4-5"),
system: "Eres un pirata. Siempre responde como pirata.",
prompt: "¿Cuál es la capital de Francia?",
maxOutputTokens: 100,
temperature: 0.7,
});
system— define el comportamiento o personalidad del modelomaxOutputTokens— limita la longitud de la respuestatemperature— 0 es determinístico, 1 es más creativo
2. streamText
Sin streaming, el usuario ve un spinner hasta que la respuesta completa esté lista. Con streaming, ve el texto aparecer en tiempo real.
Crea stream.ts:
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
const result = streamText({
model: anthropic("claude-haiku-4-5"),
prompt: "Cuéntame un cuento corto sobre un robot.",
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}
Nota que no necesitas usar await con streamText, retorna inmediatamente. Los chunks llegan a través de result.textStream.
Regla práctica:
Usa
generateTextpara tareas en background donde el usuario no está esperando (clasificar documentos, procesar datos)Usa
streamTextpara cualquier cosa donde el usuario está mirando la pantalla
3. Structured Outputs
En lugar de texto libre, le dices al modelo exactamente qué estructura quieres de vuelta. Usas Zod para definir el schema.
Crea structured.ts:
import { generateText, Output } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const result = await generateText({
model: anthropic("claude-haiku-4-5"),
output: Output.object({
schema: z.object({
name: z.string(),
capital: z.string(),
population: z.number(),
funFact: z.string().describe("Un dato curioso o poco conocido sobre este país"),
}),
}),
prompt: "Dame información sobre Francia.",
});
console.log(result.output);
El modelo lee los nombres de los campos y sus tipos, y devuelve un objeto que coincide exactamente con tu schema. Sin parsing, sin adivinar, con type safety completo.
Seguramente notaste el .describe(). ¿Cuándo conviene usarlo? Cuando el nombre del campo puede interpretarse de más de una manera. Por ejemplo score sin descripción, ¿es de 1 a 10? ¿de -1 a 1? ¿un porcentaje? Con .describe("Puntaje de sentimiento de -1 a 1") el modelo sabe exactamente qué devolver.
Por cierto, Claude me sugirió un caso de uso buenísimo para esto: "Imagina que tienes 1,000 tickets de soporte y quieres extraer de cada uno { categoria: string, prioridad: 'alta' | 'media' | 'baja', resumen: string }. Sin structured outputs tendrías que parsear texto libre y rezar que el modelo siempre responda en el mismo formato. Con Output.object() recibes objetos tipados, validados, listos para escribir directo a tu base de datos. Sin una sola línea de parsing manual. Eso, envuelto en un cron job que corre cada noche ya es una app de soporte empresarial."
4. Tools
Hasta ahora el modelo solo puede responder con texto. Con tools puede tomar acciones: leer archivos, llamar APIs, consultar bases de datos. Tú defines la capacidad, el modelo decide cuándo usarla.
Crea tools.ts:
import { generateText, tool, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const result = await generateText({
model: anthropic("claude-haiku-4-5"),
stopWhen: stepCountIs(3),
tools: {
getWeather: tool({
description: "Obtiene el clima actual para una ciudad",
inputSchema: z.object({
city: z.string().describe("La ciudad para consultar el clima"),
}),
execute: async ({ city }) => {
// En producción aquí llamarías a una API real
return { city, temperature: 28, condition: "soleado" };
},
}),
},
prompt: "¿Cómo está el clima en Monterrey?",
});
console.log("Respuesta:", result.text);
console.log("Tool calls:", JSON.stringify(result.steps.map(s => ({
text: s.text,
toolCalls: s.toolCalls,
toolResults: s.toolResults,
})), null, 2));
Esto es el agentic loop:
El usuario manda el prompt
El modelo decide llamar tu tool con los argumentos correctos
Tu función
executecorre con esos argumentosEl resultado regresa al modelo
El modelo escribe la respuesta final usando ese resultado
stopWhen: stepCountIs(3) es importante: limita cuántos pasos puede dar el modelo. Sin esto podría llamar tools indefinidamente.
5. Embeddings
Los embeddings son la forma en que las computadoras entienden el significado, no solo las palabras. Un texto se convierte en un vector: una lista de números que representa su significado en un espacio matemático. Textos con significados similares producen vectores cercanos entre sí.
Por ejemplo: dog y puppy van a producir vectores muy parecidos porque semánticamente son casi lo mismo. Pero 'dog' y 'Paris' van a producir vectores muy distintos porque no tienen nada que ver. Eso es exactamente lo que vamos a comprobar con el código
Crea embeddings.ts:
import { embed, cosineSimilarity } from "ai";
import { openai } from "@ai-sdk/openai";
const [dog, puppy, paris] = await Promise.all([
embed({ model: openai.embedding("text-embedding-3-small"), value: "dog" }),
embed({ model: openai.embedding("text-embedding-3-small"), value: "puppy" }),
embed({ model: openai.embedding("text-embedding-3-small"), value: "Paris" }),
]);
console.log("Dimensiones del vector:", dog.embedding.length);
console.log("Primeros 5 números:", dog.embedding.slice(0, 5));
console.log("dog vs puppy:", cosineSimilarity(dog.embedding, puppy.embedding));
console.log("dog vs Paris:", cosineSimilarity(dog.embedding, paris.embedding));
El resultado es algo así:
Dimensiones del vector: 1536
dog vs puppy: 0.559
dog vs Paris: 0.195
"dog" y "puppy" son casi 3 veces más cercanos en significado que "dog" y "Paris". Eso es cosine similarity: va de -1 (significados opuestos) a 1 (idénticos).
¿Para qué sirve esto en la práctica?
RAG: Imagina que tienes 500 páginas de documentación y el usuario hace una pregunta. No puedes meter 500 páginas al contexto del modelo, es demasiado. Con embeddings, conviertes esas 500 páginas en vectores, conviertes la pregunta en un vector, encuentras las 3 o 4 páginas más cercanas en significado, y solo esas las metes al contexto. El modelo responde basándose en tu documentación real, no en lo que sabe de su entrenamiento.
Búsqueda semántica: La búsqueda tradicional por keywords es exacta: si el usuario escribe 'vuelos baratos' y tu base de datos tiene 'tarifas aéreas económicas', no encuentra nada porque las palabras no coinciden. Con embeddings encuentras resultados por significado, no por palabras exactas. El usuario escribe lo que se le ocurre y el sistema entiende lo que está buscando
Recomendaciones — encuentra productos o contenido con vectores similares
6. Chat UI Full-Stack con Next.js
Ya hemos visto los conceptos principales de la librería de Vercel. Ahora vamos a construir el frontend y verás lo fácil que es crear una chat app en minutos. Crea un nuevo proyecto:
npx create-next-app@latest nextjs-chat
cd nextjs-chat
npm install ai @ai-sdk/anthropic @ai-sdk/react
Crea .env.local:
ANTHROPIC_API_KEY=tu_key_aqui
Backend — app/api/chat/route.ts
import { streamText, convertToModelMessages } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-4-5"),
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}
10 líneas. Eso es todo el backend.
convertToModelMessages traduce el formato rico de la UI (con id, parts, metadata) al formato simple que el modelo espera. toUIMessageStreamResponse hace el viaje de regreso: convierte el stream de Claude al formato que el hook del frontend entiende.
Frontend — app/page.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { useState, useRef } from "react";
export default function Chat() {
const { messages, sendMessage, status } = useChat();
const [input, setInput] = useState("");
const [files, setFiles] = useState<FileList | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() && !files) return;
sendMessage({
text: input,
files: files ?? undefined,
});
setInput("");
setFiles(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
return (
<div className="flex flex-col h-screen bg-zinc-950 text-zinc-100">
<div className="flex-1 overflow-y-auto px-4 py-8 space-y-6 max-w-2xl mx-auto w-full">
{messages.length === 0 && (
<p className="text-zinc-500 text-center mt-20">
Envía un mensaje o adjunta una imagen.
</p>
)}
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`px-4 py-2 rounded-2xl max-w-[80%] text-sm leading-relaxed space-y-2 ${
message.role === "user"
? "bg-zinc-700 text-zinc-100"
: "bg-zinc-800 text-zinc-200"
}`}
>
{message.parts.map((part, i) => {
if (part.type === "text") {
return <p key={i}>{part.text}</p>;
}
if (part.type === "file" && part.mediaType?.startsWith("image/")) {
return (
<img
key={i}
src={part.url}
alt="imagen adjunta"
className="rounded-lg max-w-full"
/>
);
}
return null;
})}
</div>
</div>
))}
</div>
<div className="border-t border-zinc-800 px-4 py-4">
{files && files.length > 0 && (
<div className="max-w-2xl mx-auto mb-2 text-xs text-zinc-400">
📎 {files[0].name}
</div>
)}
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => setFiles(e.target.files)}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-400 px-3 py-2 rounded-xl text-sm transition-colors"
>
📎
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Escribe un mensaje..."
className="flex-1 bg-zinc-800 text-zinc-100 placeholder-zinc-500 rounded-xl px-4 py-2 text-sm outline-none focus:ring-1 focus:ring-zinc-600"
/>
<button
type="submit"
disabled={status === "streaming"}
className="bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-zinc-100 px-4 py-2 rounded-xl text-sm transition-colors"
>
{status === "streaming" ? "..." : "Enviar"}
</button>
</form>
</div>
</div>
);
}
Corre el proyecto:
npm run dev
Abre http://localhost:3000. Puedes chatear y adjuntar imágenes. El modelo las ve y responde sobre ellas. El backend no necesitó ningún cambio para soportar imágenes, el SDK lo maneja automáticamente.
Si te salió todo bien, deberías ver algo como lo de mi X post:
https://x.com/garosan1/status/2059707443223933190
Nótese el ambiente mundialista haha.
Lo que aprendimos con esto
generateText— generación de texto simple, cambio de provider en 2 líneasstreamText— respuestas en tiempo real para mejor UXOutput.object()— structured outputs con Zod para datos tipadostool()— darle capacidades de acción al modeloembed()+cosineSimilarity— búsqueda semántica y RAGuseChat+convertToModelMessages— chat full-stack en ~40 líneas
El código completo está en github.com/garosan/vercel-ai-sdk-crash-course.
En fin, espero que te haya servido, aprovecho el post de X para que me cuentes si lo intentaste, si te salió el ejercicio o te salió algún error, gracias por leer!



