Définir des workflows d'IA

Les requêtes de modèle génératif constituent le cœur des fonctionnalités d'IA de votre application. Toutefois, il est rare que vous puissiez simplement prendre l'entrée utilisateur, la transmettre au modèle et afficher la sortie du modèle à l'utilisateur. En général, des étapes de prétraitement et de posttraitement doivent accompagner l'appel du modèle. Exemple :

  • Récupérer des informations contextuelles à envoyer avec l'appel du modèle
  • Récupérer l'historique de la session en cours de l'utilisateur, par exemple dans une application de chat
  • Utiliser un modèle pour reformater l'entrée utilisateur de manière à pouvoir la transmettre à un autre modèle
  • Évaluer la "sécurité" de la sortie d'un modèle avant de la présenter à l'utilisateur
  • Combiner la sortie de plusieurs modèles

Chaque étape de ce workflow doit fonctionner ensemble pour que toute tâche liée à l'IA aboutisse.

Dans Genkit, vous représentez cette logique étroitement liée à l'aide d'une construction appelée "flux". Les flux sont écrits comme des fonctions, à l'aide de code TypeScript ordinaire, mais ils ajoutent des fonctionnalités supplémentaires destinées à faciliter le développement de fonctionnalités d'IA:

  • Sécurité des types: schémas d'entrée et de sortie définis à l'aide de Zod, qui fournit à la fois une vérification statique et dynamique des types
  • Intégration à l'UI du développeur: déboguez les flux indépendamment du code de votre application à l'aide de l'UI du développeur. Dans l'UI du développeur, vous pouvez exécuter des flux et afficher des traces pour chaque étape du flux.
  • Déploiement simplifié: déployez des flux directement en tant que points de terminaison d'API Web, à l'aide de Cloud Functions pour Firebase ou de toute plate-forme pouvant héberger une application Web.

Contrairement aux fonctionnalités similaires d'autres frameworks, les flux de Genkit sont légers et discrets, et ne forcent pas votre application à se conformer à une abstraction spécifique. Toute la logique du flux est écrite en TypeScript standard, et le code d'un flux n'a pas besoin d'être compatible avec le flux.

Définir et appeler des flux

Dans sa forme la plus simple, un flux encapsule simplement une fonction. L'exemple suivant encapsule une fonction qui appelle generate():

export const menuSuggestionFlow = ai.defineFlow(
  {
    name: 'menuSuggestionFlow',
  },
  async (restaurantTheme) => {
    const { text } = await ai.generate({
      model: gemini15Flash,
      prompt: `Invent a menu item for a ${restaurantTheme} themed restaurant.`,
    });
    return text;
  }
);

En encapsulant vos appels generate() de cette manière, vous ajoutez des fonctionnalités : cela vous permet d'exécuter le flux à partir de la CLI Genkit et de l'UI du développeur, et est obligatoire pour plusieurs fonctionnalités de Genkit, y compris le déploiement et l'observabilité (ces sujets sont abordés dans les sections suivantes).

Schémas d'entrée et de sortie

L'un des principaux avantages des flux Genkit par rapport à l'appel direct d'une API de modèle est la sûreté du typage des entrées et des sorties. Lorsque vous définissez des flux, vous pouvez définir des schémas pour eux à l'aide de Zod, de la même manière que vous définissez le schéma de sortie d'un appel generate(). Toutefois, contrairement à generate(), vous pouvez également spécifier un schéma d'entrée.

Voici un affinement du dernier exemple, qui définit un flux qui prend une chaîne en entrée et produit un objet:

const MenuItemSchema = z.object({
  dishname: z.string(),
  description: z.string(),
});

export const menuSuggestionFlowWithSchema = ai.defineFlow(
  {
    name: 'menuSuggestionFlow',
    inputSchema: z.string(),
    outputSchema: MenuItemSchema,
  },
  async (restaurantTheme) => {
    const { output } = await ai.generate({
      model: gemini15Flash,
      prompt: `Invent a menu item for a ${restaurantTheme} themed restaurant.`,
      output: { schema: MenuItemSchema },
    });
    if (output == null) {
      throw new Error("Response doesn't satisfy schema.");
    }
    return output;
  }
);

Notez que le schéma d'un flux n'a pas nécessairement besoin de correspondre au schéma des appels generate() dans le flux (en fait, un flux peut même ne pas contenir d'appels generate()). Voici une variante de l'exemple qui transmet un schéma à generate(), mais qui utilise la sortie structurée pour mettre en forme une chaîne simple, que le flux renvoie.

export const menuSuggestionFlowMarkdown = ai.defineFlow(
  {
    name: 'menuSuggestionFlow',
    inputSchema: z.string(),
    outputSchema: z.string(),
  },
  async (restaurantTheme) => {
    const { output } = await ai.generate({
      model: gemini15Flash,
      prompt: `Invent a menu item for a ${restaurantTheme} themed restaurant.`,
      output: { schema: MenuItemSchema },
    });
    if (output == null) {
      throw new Error("Response doesn't satisfy schema.");
    }
    return `**${output.dishname}**: ${output.description}`;
  }
);

Flux d'appels

Une fois que vous avez défini un flux, vous pouvez l'appeler à partir de votre code Node.js:

const { text } = await menuSuggestionFlow('bistro');

L'argument du flux doit respecter le schéma d'entrée, si vous en avez défini un.

Si vous avez défini un schéma de sortie, la réponse du flux s'y conformera. Par exemple, si vous définissez le schéma de sortie sur MenuItemSchema, la sortie du flux contiendra ses propriétés:

const { dishname, description } =
  await menuSuggestionFlowWithSchema('bistro');

Flux en streaming

Les flux sont compatibles avec le streaming à l'aide d'une interface semblable à celle de generate(). Le streaming est utile lorsque votre flux génère une grande quantité de sortie, car vous pouvez présenter la sortie à l'utilisateur pendant qu'elle est générée, ce qui améliore la réactivité perçue de votre application. Par exemple, les interfaces LLM basées sur le chat transmettent souvent leurs réponses à l'utilisateur pendant qu'elles sont générées.

Voici un exemple de flux compatible avec le streaming:

export const menuSuggestionStreamingFlow = ai.defineStreamingFlow(
  {
    name: 'menuSuggestionFlow',
    inputSchema: z.string(),
    streamSchema: z.string(),
    outputSchema: z.object({ theme: z.string(), menuItem: z.string() }),
  },
  async (restaurantTheme, streamingCallback) => {
    const response = await ai.generateStream({
      model: gemini15Flash,
      prompt: `Invent a menu item for a ${restaurantTheme} themed restaurant.`,
    });

    if (streamingCallback) {
      for await (const chunk of response.stream) {
        // Here, you could process the chunk in some way before sending it to
        // the output stream via streamingCallback(). In this example, we output
        // the text of the chunk, unmodified.
        streamingCallback(chunk.text);
      }
    }

    return {
      theme: restaurantTheme,
      menuItem: (await response.response).text,
    };
  }
);
  • L'option streamSchema spécifie le type de valeurs que votre flux diffuse. Il ne doit pas nécessairement s'agir du même type que outputSchema, qui est le type de la sortie complète du flux.
  • streamingCallback est une fonction de rappel qui accepte un seul paramètre, du type spécifié par streamSchema. Chaque fois que des données deviennent disponibles dans votre flux, envoyez-les au flux de sortie en appelant cette fonction. Notez que streamingCallback n'est défini que si l'appelant de votre flux a demandé une sortie de streaming. Vous devez donc vérifier qu'il est défini avant de l'appeler.

Dans l'exemple ci-dessus, les valeurs diffusées par le flux sont directement associées aux valeurs diffusées par l'appel generate() dans le flux. Bien que ce soit souvent le cas, cela n'est pas obligatoire: vous pouvez envoyer des valeurs au flux à l'aide du rappel aussi souvent que nécessaire pour votre flux.

Flux de streaming d'appels

Les flux de streaming sont également appelables, mais ils renvoient immédiatement un objet de réponse plutôt qu'une promesse:

const response = menuSuggestionStreamingFlow('Danube');

L'objet de réponse comporte une propriété de flux, que vous pouvez utiliser pour itérer sur la sortie de streaming du flux au fur et à mesure de sa génération:

for await (const chunk of response.stream) {
  console.log('chunk', chunk);
}

Vous pouvez également obtenir la sortie complète du flux, comme vous pouvez le faire avec un flux non en streaming:

const output = await response.output;

Notez que la sortie en streaming d'un flux peut ne pas être du même type que la sortie complète. La sortie en streaming est conforme à streamSchema, tandis que la sortie complète est conforme à outputSchema.

Exécuter des flux à partir de la ligne de commande

Vous pouvez exécuter des flux à partir de la ligne de commande à l'aide de l'outil de ligne de commande Genkit:

genkit flow:run menuSuggestionFlow '"French"'

Pour les flux de streaming, vous pouvez imprimer la sortie de streaming dans la console en ajoutant l'option -s:

genkit flow:run menuSuggestionFlow '"French"' -s

Exécuter un flux à partir de la ligne de commande est utile pour tester un flux ou pour exécuter des flux qui effectuent des tâches nécessaires de manière ponctuelle, par exemple pour exécuter un flux qui ingère un document dans votre base de données de vecteurs.

Flux de débogage

L'un des avantages de l'encapsulation de la logique d'IA dans un flux est que vous pouvez tester et déboguer le flux indépendamment de votre application à l'aide de l'interface utilisateur du développeur Genkit.

Pour démarrer l'UI du développeur, exécutez les commandes suivantes à partir du répertoire de votre projet:

genkit start -- tsx --watch src/your-code.ts

Dans l'onglet Run (Exécuter) de l'UI du développeur, vous pouvez exécuter n'importe lequel des flux définis dans votre projet:

Capture d'écran de l'exécuteur de flux

Après avoir exécuté un flux, vous pouvez inspecter une trace de l'appel du flux en cliquant sur Afficher la trace ou en consultant l'onglet Inspecter.

Dans l'outil d'affichage des traces, vous pouvez consulter les détails de l'exécution de l'ensemble du flux, ainsi que les détails de chaque étape du flux. Prenons l'exemple suivant, qui contient plusieurs requêtes de génération:

const PrixFixeMenuSchema = z.object({
  starter: z.string(),
  soup: z.string(),
  main: z.string(),
  dessert: z.string(),
});

export const complexMenuSuggestionFlow = ai.defineFlow(
  {
    name: 'complexMenuSuggestionFlow',
    inputSchema: z.string(),
    outputSchema: PrixFixeMenuSchema,
  },
  async (theme: string): Promise<z.infer<typeof PrixFixeMenuSchema>> => {
    const chat = ai.chat({ model: gemini15Flash });
    await chat.send('What makes a good prix fixe menu?');
    await chat.send(
      'What are some ingredients, seasonings, and cooking techniques that ' +
        `would work for a ${theme} themed menu?`
    );
    const { output } = await chat.send({
      prompt:
        `Based on our discussion, invent a prix fixe menu for a ${theme} ` +
        'themed restaurant.',
      output: {
        schema: PrixFixeMenuSchema,
      },
    });
    if (!output) {
      throw new Error('No data generated.');
    }
    return output;
  }
);

Lorsque vous exécutez ce flux, l'outil d'affichage des traces affiche des informations sur chaque requête de génération, y compris sa sortie:

Capture d&#39;écran de l&#39;outil d&#39;inspection des traces

Étapes du flux

Dans le dernier exemple, vous avez vu que chaque appel generate() s'affiche comme une étape distincte dans l'outil d'affichage des traces. Chacune des actions fondamentales de Genkit s'affiche sous la forme d'étapes distinctes d'un flux:

  • generate()
  • Chat.send()
  • embed()
  • index()
  • retrieve()

Si vous souhaitez inclure du code autre que celui ci-dessus dans vos traces, vous pouvez le faire en encapsulant le code dans un appel run(). Vous pouvez le faire pour les appels de bibliothèques tierces qui ne sont pas compatibles avec Genkit ou pour toute section de code critique.

Par exemple, voici un flux en deux étapes: la première étape récupère un menu à l'aide d'une méthode non spécifiée, et la seconde inclut le menu comme contexte pour un appel generate().

export const menuQuestionFlow = ai.defineFlow(
  {
    name: 'menuQuestionFlow',
    inputSchema: z.string(),
    outputSchema: z.string(),
  },
  async (input: string): Promise<string> => {
    const menu = await run('retrieve-daily-menu', async (): Promise<string> => {
      // Retrieve today's menu. (This could be a database access or simply
      // fetching the menu from your website.)

      // ...

      return menu;
    });
    const { text } = await ai.generate({
      model: gemini15Flash,
      system: "Help the user answer questions about today's menu.",
      prompt: input,
      docs: [{ content: [{ text: menu }] }],
    });
    return text;
  }
);

Étant donné que l'étape de récupération est encapsulée dans un appel run(), elle est incluse en tant qu'étape dans l'outil de visualisation des traces:

Capture d&#39;écran d&#39;une étape définie explicitement dans l&#39;outil d&#39;inspection des traces

Déployer des flux

Vous pouvez déployer vos flux directement en tant que points de terminaison d'API Web, prêts à être appelés à partir des clients de votre application. Le déploiement est abordé en détail sur plusieurs autres pages, mais cette section fournit un bref aperçu de vos options de déploiement.

Cloud Functions for Firebase

Pour déployer des flux avec Cloud Functions for Firebase, utilisez le plug-in firebase. Dans vos définitions de flux, remplacez defineFlow par onFlow et incluez un authPolicy.

import { firebaseAuth } from '@genkit-ai/firebase/auth';
import { onFlow } from '@genkit-ai/firebase/functions';

export const menuSuggestion = onFlow(
  ai,
  {
    name: 'menuSuggestionFlow',
    authPolicy: firebaseAuth((user) => {
      if (!user.email_verified) {
        throw new Error('Verified email required to run flow');
      }
    }),
  },
  async (restaurantTheme) => {
    // ...
  }
);

Pour en savoir plus, consultez les pages suivantes :

Express.js

Pour déployer des flux à l'aide de n'importe quelle plate-forme d'hébergement Node.js, telle que Cloud Run, définissez vos flux à l'aide de defineFlow(), puis appelez startFlowServer():

export const menuSuggestionFlow = ai.defineFlow(
  {
    name: 'menuSuggestionFlow',
  },
  async (restaurantTheme) => {
    // ...
  }
);

ai.startFlowServer({
  flows: [menuSuggestionFlow],
});

Par défaut, startFlowServer traitera tous les flux définis dans votre codebase en tant que points de terminaison HTTP (par exemple, http://localhost:3400/menuSuggestionFlow). Vous pouvez appeler un flux avec une requête POST comme suit:

curl -X POST "http://localhost:3400/menuSuggestionFlow" \
  -H "Content-Type: application/json"  -d '{"data": "banana"}'

Si nécessaire, vous pouvez personnaliser le serveur de flux pour qu'il diffuse une liste spécifique de flux, comme indiqué ci-dessous. Vous pouvez également spécifier un port personnalisé (il utilisera la variable d'environnement PORT si elle est définie) ou des paramètres CORS.

export const flowA = ai.defineFlow({ name: 'flowA' }, async (subject) => {
  // ...
});

export const flowB = ai.defineFlow({ name: 'flowB' }, async (subject) => {
  // ...
});

ai.startFlowServer({
  flows: [flowB],
  port: 4567,
  cors: {
    origin: '*',
  },
});

Pour en savoir plus sur le déploiement sur des plates-formes spécifiques, consultez les pages Déployer avec Cloud Run et Déployer des flux sur n'importe quelle plate-forme Node.js.