écrits/tutorial/2026/06
Tutorial26 juin 2026·30 min

Vercel AI SDK 5 : Chat Type-Safe, Streaming et Boucles Agentiques avec Next.js

Maîtrisez la nouvelle architecture de Vercel AI SDK 5 : UIMessage vs ModelMessage, streaming SSE, contrôle des boucles agentiques avec stopWhen et prepareStep, messages personnalisés type-safe et génération vocale dans Next.js.

Qu'est-ce qu'AI SDK 5 ?

Vercel AI SDK 5 est la mise à jour la plus significative apportée à cette bibliothèque depuis son lancement. Avec plus de 30 millions de téléchargements npm hebdomadaires combinés pour le package ai principal et ses packages de fournisseurs, il constitue le standard TypeScript pour la création de produits alimentés par l'IA. La version 5 introduit une refonte architecturale fondamentale qui sépare les préoccupations de l'interface utilisateur de celles du modèle, rendant les applications IA full-stack nettement plus simples à construire, déboguer et maintenir.

Les changements phares d'AI SDK 5 :

  • UIMessage vs ModelMessage — deux types de messages distincts aux responsabilités différentes
  • useChat basé sur le transport — remplace la gestion HTTP interne par une couche de transport explicite et interchangeable
  • Streaming SSE — les Server-Sent Events remplacent le protocole de streaming binaire propriétaire, permettant le débogage natif via les DevTools du navigateur
  • Contrôle de la boucle agentiquestopWhen et prepareStep offrent un contrôle chirurgical sur les appels d'outils multi-étapes
  • Messages personnalisés type-safe — déduisez des types TypeScript complets depuis vos définitions d'outils et de schémas
  • Génération vocale — la primitive generateSpeech permet la conversion texte-vers-audio en première classe

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 18 ou supérieur installé
  • Un projet Next.js 15 créé avec npx create-next-app@latest my-ai-app --typescript --app
  • Une clé OpenAI API stockée sous OPENAI_API_KEY dans .env.local
  • Une connaissance de base de React, TypeScript et async/await

Ce que vous allez construire

À la fin de ce tutoriel, vous disposerez d'une application de chat IA full-stack qui :

  1. Diffuse les réponses en temps réel depuis un modèle OpenAI
  2. Utilise correctement l'architecture UIMessage/ModelMessage
  3. Appelle un outil personnalisé et gère son résultat dans le flux
  4. Contrôle la boucle agentique avec stopWhen et prepareStep
  5. Définit une forme de message personnalisée entièrement type-safe
  6. Convertit n'importe quel texte en parole avec generateSpeech

Le projet terminé fonctionne avec tout fournisseur compatible AI SDK 5 — Anthropic, Google Gemini, Mistral et plus de 20 autres — en changeant seulement deux lignes de code.

Étape 1 : Installer AI SDK 5

À la racine de votre projet Next.js, installez le package principal et le fournisseur OpenAI :

npm install ai @ai-sdk/openai

Vérifiez la version installée :

npm list ai

Vous devriez voir ai@5.x.x dans la sortie. Si vous migrez depuis AI SDK 4, consultez le guide de migration officiel — l'API useChat et la forme des messages ont considérablement changé.

Ajoutez votre clé API dans .env.local :

OPENAI_API_KEY=sk-...

Étape 2 : Comprendre UIMessage vs ModelMessage

C'est le concept le plus important d'AI SDK 5. Avant la version 5, un unique type Message remplissait deux rôles — il contenait à la fois l'état de l'interface rendu dans le navigateur et la charge utile brute envoyée au LLM. Cette conception créait de la complexité dans la sérialisation et rendait la persistance des conversations sujette aux erreurs.

AI SDK 5 sépare clairement ces deux préoccupations en deux types distincts :

TypeVit surContient
UIMessageFrontière client et serveurEtat complet du message : parties texte, résultats d'outils, métadonnées
ModelMessageServeur uniquement — envoyé au LLMCharge utile allégée optimisée pour la consommation du modèle

Le flux de données dans chaque requête :

  1. Le client envoie UIMessage[] à votre route API
  2. Le serveur appelle convertToModelMessages(messages) pour produire ModelMessage[]
  3. ModelMessage[] est transmis au LLM via streamText
  4. Le flux revient sous forme de UIMessageStreamResponse
  5. useChat côté client met à jour l'état local UIMessage depuis le flux

Cette séparation rend la persistance simple. Le callback onFinish fournit des UIMessage[] prêtes à être stockées sans aucune conversion manuelle.

Étape 3 : Construire la route serveur

Créez la route API dans app/api/chat/route.ts :

import { openai } from '@ai-sdk/openai';
import {
  convertToModelMessages,
  streamText,
  UIMessage,
  tool,
  stepCountIs,
} from 'ai';
import { z } from 'zod';
 
export const maxDuration = 30;
 
export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();
 
  const result = streamText({
    model: openai('gpt-4o'),
    system: "Vous êtes un assistant utile. Utilisez les outils quand c'est approprié.",
    messages: convertToModelMessages(messages),
    stopWhen: stepCountIs(5),
    tools: {
      getWeather: tool({
        description: "Obtenir la météo actuelle pour une ville",
        inputSchema: z.object({
          city: z.string().describe("Le nom de la ville"),
        }),
        execute: async ({ city }) => {
          // Remplacer par un vrai appel API météo en production
          return { city, temperature: 22, condition: 'Ensoleillé' };
        },
      }),
    },
    onFinish: async ({ messages: finalMessages }) => {
      // Persistez la conversation ici — finalMessages est UIMessage[]
      console.log('Conversation terminée :', finalMessages.length, 'messages');
    },
  });
 
  return result.toUIMessageStreamResponse();
}

Les décisions clés dans cette route :

  • convertToModelMessages(messages) traduit les UIMessage[] reçus en ModelMessage[] avant l'appel au LLM
  • stopWhen: stepCountIs(5) agit comme un plafond de sécurité — la boucle agentique s'arrête après 5 étapes maximum
  • toUIMessageStreamResponse() emballe le flux en SSE que useChat comprend nativement
  • Le callback onFinish est l'endroit canonique pour persister les conversations

Étape 4 : Construire l'interface chat côté client

Créez la page dans app/chat/page.tsx :

'use client';
 
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
 
export default function ChatPage() {
  const [input, setInput] = useState('');
  const { messages, sendMessage, status } = useChat();
 
  return (
    <div className="flex flex-col max-w-2xl mx-auto h-screen p-4">
      <div className="flex-1 overflow-y-auto space-y-4 py-4">
        {messages.map(message => (
          <div key={message.id} className="space-y-1">
            <strong className="capitalize text-sm text-gray-500">
              {message.role === 'user' ? 'Vous' : 'Assistant'}
            </strong>
            <div>
              {message.parts.map((part, i) => {
                if (part.type === 'text') {
                  return <p key={i} className="text-gray-800">{part.text}</p>;
                }
                if (part.type === 'tool-getWeather') {
                  return (
                    <div key={i} className="bg-blue-50 border border-blue-200 rounded p-3 text-sm">
                      <span className="font-medium">Météo à {part.result.city} :</span>{' '}
                      {part.result.temperature}°C, {part.result.condition}
                    </div>
                  );
                }
                return null;
              })}
            </div>
          </div>
        ))}
        {status === 'streaming' && (
          <div className="text-gray-400 text-sm italic">Réflexion en cours...</div>
        )}
      </div>
 
      <form
        onSubmit={e => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput('');
        }}
        className="flex gap-2 pt-4 border-t"
      >
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          placeholder="Demandez la météo dans n'importe quelle ville..."
          className="flex-1 border rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          disabled={status === 'streaming'}
        />
        <button
          type="submit"
          disabled={status === 'streaming'}
          className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50 hover:bg-blue-700"
        >
          Envoyer
        </button>
      </form>
    </div>
  );
}

La différence clé avec AI SDK 4 est la façon dont on accède au contenu des messages. Chaque UIMessage possède maintenant un tableau parts où chaque élément a un discriminant type explicite :

  • Contenu texte : { type: 'text', text: '...' }
  • Résultats d'outils : { type: 'tool-getWeather', result: { city, temperature, condition } }

Les types de parties d'outils suivent le format tool-<toolName> pour éviter les collisions de noms entre outils.

Étape 5 : Contrôler la boucle agentique

Les primitives stopWhen et prepareStep offrent un contrôle fin sur le comportement d'un agent multi-étapes — plus besoin d'implémenter votre propre logique de boucle.

Conditions d'arrêt

stopWhen accepte une condition unique ou un tableau. La boucle s'arrête quand n'importe quelle condition est satisfaite :

import { stepCountIs, toolCalls, textIncludes } from 'ai';
 
const result = streamText({
  model: openai('gpt-4o'),
  messages: convertToModelMessages(messages),
  stopWhen: [
    stepCountIs(10),                // limite stricte : arrêt après 10 étapes
    toolCalls('submitFinalAnswer'), // arrêt quand le modèle signale la fin
    textIncludes('TASK_COMPLETE'),  // arrêt sur une chaîne sentinelle dans la sortie
  ],
  tools: { /* ... */ },
});

Référence complète des conditions d'arrêt intégrées :

ConditionSe déclenche quand
stepCountIs(n)Le nombre d'étapes atteint n
toolCalls(name)Un outil spécifique est invoqué
toolResults(name)Un résultat d'un outil spécifique est reçu
textIncludes(str)La sortie texte contient la chaîne
textDoesNotInclude(str)La sortie texte ne contient plus la chaîne
custom(() => boolean)Votre prédicat personnalisé retourne true

Configuration par étape avec prepareStep

prepareStep s'exécute avant chaque étape et vous permet de modifier le modèle, les outils disponibles ou l'historique des messages :

const result = streamText({
  model: openai('gpt-4o'),
  messages: convertToModelMessages(messages),
  prepareStep: async ({ stepNumber, messages }) => {
    // Forcer la première étape à appeler l'outil météo immédiatement
    if (stepNumber === 0) {
      return {
        toolChoice: { type: 'tool', toolName: 'getWeather' },
        activeTools: ['getWeather'],
      };
    }
 
    // Tronquer l'historique pour les agents de longue durée
    if (messages.length > 20) {
      return { messages: messages.slice(-10) };
    }
 
    // Retourner un objet vide utilise les paramètres par défaut pour cette étape
    return {};
  },
  tools: { /* ... */ },
});

Un modèle courant consiste à utiliser prepareStep pour basculer vers un modèle moins coûteux lors des étapes intermédiaires et revenir à un modèle plus performant uniquement pour l'étape de synthèse finale.

Étape 6 : Définir un UIMessage personnalisé type-safe

AI SDK 5 vous permet de définir la forme TypeScript exacte de vos messages grâce à Zod et au helper InferUITools. Créez lib/ai-types.ts :

import { InferUITools, ToolSet, UIMessage, tool } from 'ai';
import { z } from 'zod';
 
// 1. Métadonnées attachées à chaque message
const metadataSchema = z.object({
  model: z.string(),
  latencyMs: z.number().optional(),
  tokensUsed: z.number().optional(),
});
export type MessageMetadata = z.infer<typeof metadataSchema>;
 
// 2. Parties de données personnalisées envoyées en milieu de réponse
const dataPartSchema = z.object({
  weatherCard: z.object({
    city: z.string(),
    temperature: z.number(),
    condition: z.string(),
  }),
});
export type MessageDataPart = z.infer<typeof dataPartSchema>;
 
// 3. Définitions des outils partagés — importez-les aussi dans votre route API
export const appTools: ToolSet = {
  getWeather: tool({
    description: "Obtenir la météo actuelle pour une ville",
    inputSchema: z.object({ city: z.string() }),
    execute: async ({ city }) => ({
      city,
      temperature: 22,
      condition: 'Ensoleillé',
    }),
  }),
};
 
// 4. Déduire automatiquement les types de résultats des outils
type AppTools = InferUITools<typeof appTools>;
 
// 5. Votre message entièrement typé — utilisez-le partout à la place de UIMessage
export type AppUIMessage = UIMessage<MessageMetadata, MessageDataPart, AppTools>;

Substituez AppUIMessage à UIMessage dans toute votre base de code :

// Route API — corps de requête typé
const { messages }: { messages: AppUIMessage[] } = await req.json();
 
// Composant client — hook typé
const { messages } = useChat<AppUIMessage>();
 
// TypeScript sait maintenant que :
// message.metadata.model         → string
// message.metadata.latencyMs     → number | undefined
// part.type === 'tool-getWeather' → part.result.city est une string

Cela élimine toute une catégorie d'erreurs à l'exécution où les formes de résultats d'outils divergent entre le serveur et la logique de rendu côté client.

Étape 7 : Ajouter la génération vocale

AI SDK 5 élève la génération vocale au rang de primitive API de première classe. Ajoutez une route dédiée dans app/api/speech/route.ts :

import { generateSpeech } from 'ai';
import { openai } from '@ai-sdk/openai';
 
export async function POST(req: Request) {
  const { text } = await req.json();
 
  const { audio } = await generateSpeech({
    model: openai.speech('tts-1-hd'),
    text,
    voice: 'nova',
  });
 
  return new Response(audio.uint8Array, {
    headers: {
      'Content-Type': 'audio/mpeg',
      'Cache-Control': 'no-store',
    },
  });
}

Appelez-la depuis le client pour vocaliser n'importe quel message de l'assistant :

async function speakMessage(text: string) {
  const response = await fetch('/api/speech', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  });
  const audioBlob = await response.blob();
  const audioUrl = URL.createObjectURL(audioBlob);
  new Audio(audioUrl).play();
}

Voix disponibles pour OpenAI TTS : alloy, echo, fable, onyx, nova, shimmer. Le modèle tts-1-hd offre une qualité audio supérieure avec une latence légèrement plus élevée par rapport à tts-1.

Tester votre implémentation

Démarrez le serveur de développement :

npm run dev

Ouvrez http://localhost:3000/chat et envoyez le message : "Quelle est la météo à Tunis ?"

Vous devriez observer :

  1. Le texte de l'assistant qui se diffuse mot par mot
  2. L'appel à l'outil getWeather et son résultat apparaissant dans l'interface
  3. Le texte de suivi de l'assistant résumant les données météo

Ouvrez les DevTools réseau de votre navigateur, filtrez par EventStream et inspectez la requête /api/chat. Vous verrez les événements SSE sous forme de texte lisible — une amélioration majeure par rapport au protocole de streaming binaire précédent.

Dépannage

"messages is not iterable" — Votre route API reçoit un corps vide. Vérifiez que le client envoie Content-Type: application/json et que la forme du corps est { messages: [...] }.

Résultat d'outil absent de l'interface — Le part.type doit correspondre exactement à tool-<toolName>. Si votre outil s'appelle get_weather avec underscore, le type de partie est tool-get_weather.

Le flux se termine après une étape — Le modèle ne génère pas d'appels d'outils. Vérifiez que votre prompt system encourage l'utilisation des outils et que le message de l'utilisateur est pertinent par rapport à la description de l'outil.

Erreurs TypeScript sur les parties de messages — Importez UIMessage et InferUITools depuis 'ai' (le package principal), pas depuis '@ai-sdk/react'. Les types fondamentaux doivent provenir du package racine.

Prochaines étapes

  • Ajoutez la persistance des conversations : sauvegardez UIMessage[] depuis onFinish dans une base de données Postgres ou SQLite via Drizzle ORM
  • Explorez la classe Agent légère pour les pipelines d'agents basés sur des nœuds non streamés
  • Ajoutez de l'observabilité LLM avec Langfuse pour tracer chaque appel d'outil, chaque étape et le comptage des tokens
  • Remplacez OpenAI par Anthropic Claude en changeant @ai-sdk/openai par @ai-sdk/anthropic — le reste du code reste identique
  • Construisez une configuration multi-fournisseurs avec vercel-ai-gateway-unified-ai-provider-routing-nextjs-2026 pour le routage par fallback et par coût

Conclusion

Vercel AI SDK 5 rend la création d'applications IA de production en TypeScript nettement plus robuste. La séparation UIMessage/ModelMessage élimine toute une classe de bugs de sérialisation, le streaming SSE rend le débogage naturel avec les outils navigateur standards, et les primitives de boucle agentique — stopWhen et prepareStep — vous donnent le contrôle dont vous avez besoin sans écrire votre propre logique de boucle. Avec plus de 30 millions de téléchargements hebdomadaires et la prise en charge de plus de 25 fournisseurs, AI SDK 5 est la base la plus pragmatique pour le développement IA full-stack dans l'écosystème JavaScript aujourd'hui.