Déployer des agents d’IA en tant qu’applications Databricks

Par: Ayush Sharma

La méthode standard pour déployer des agents sur Databricks a longtemps été le point de terminaison de service de modèle (model serving endpoint). Toutefois, Databricks recommande maintenant les applications Databricks (Databricks Apps) comme cible de déploiement pour les nouveaux agents, tandis que le point de terminaison de service de modèle demeure toujours pris en charge pour les agents existants.

Ce billet explore pourquoi les applications sont l’option privilégiée pour les charges de travail agentiques. Nous verrons comment le serveur d’agents MLflow (MLflow Agent Server) alimente les agents sur les applications Databricks, nous examinerons la méthode lifespan de l’application FastAPI sous-jacente et comment la configurer pour des tâches d’initialisation, comme l’ouverture d’une connexion à Lakebase au démarrage de l’application.

Ensuite, nous examinerons en détail le gabarit d’interface de clavardage (chat UI) fourni par Databricks et la façon dont il s’intègre à la même application où l’on déploie notre agent. Enfin, je décrirai comment, chez Cavallo, nous avons migré un agent de production d’un point de terminaison de service de modèle vers une application Databricks, et comment l’autorisation pour le compte de l’utilisateur (on-behalf-of-user, ou OBO) a changé par rapport au point de terminaison de service.

Ce billet n’est pas une suite d’étapes à suivre. Databricks fournit déjà des gabarits d’agents et des compétences (skills) préconstruites qui permettent à un assistant de programmation de configurer et de déployer une de ces applications pour vous. L’objectif ici est de vous aider à comprendre le fonctionnement sous-jacent de l’application. Quand vient le temps de la personnaliser, de la déboguer ou de l’expliquer, il faut savoir ce qui se passe réellement.

Pourquoi choisir les applications Databricks plutôt que le point de terminaison de service?

Figure 1: Diagramme comparatif entre le point de terminaison de service du modèle et l’application Databricks

Avant de comprendre les détails sous-jacents du déploiement d’agents sous forme d’application, voyons d’abord pourquoi on voudrait migrer du point de terminaison de service vers les applications Databricks.

1. Itération rapide : Lorsqu’on déploie son agent comme point de terminaison de service, même pour une simple mise à jour d’une ligne dans le prompt, le déploiement prend environ 10 à 20 minutes, comparativement à un déploiement sous forme d’application, qui se termine en une ou deux minutes. Cela permet une itération et un développement plus rapides.

Nous avons nous-mêmes constaté cet avantage lors du déploiement de notre propre agent sur l’application Databricks. Le temps de déploiement très court (environ une minute) nous a permis de développer et d’itérer rapidement sur notre agent, comparativement aux points de terminaison de service, où le déploiement prenait au moins 10 à 20 minutes, ce qui ralentissait considérablement notre rythme de développement.

2. Aucune tâche de déploiement distincte : Une fois le code de l’agent écrit, le déployer comme point de terminaison de service exige de développer une tâche de déploiement distincte qui enregistre le code de l’agent comme modèle MLflow, puis déploie ce modèle enregistré comme point de terminaison de service. En revanche, si l’agent est déployé comme application Databricks, on déploie le code directement dans l’application via un DAB (Databricks Asset Bundle), sans avoir besoin d’une tâche de déploiement distincte.

Cela réduit la charge de travail liée au développement et à la maintenance d’une tâche de déploiement supplémentaire pour les points de terminaison de service, alors qu’avec les applications Databricks, il suffit de configurer l’application comme ressource dans le fichier YAML du DAB.

3. Ajout d’intergiciels (middleware) et de routes personnalisés au serveur : Lorsqu’on déploie son agent comme point de terminaison de service, la seule chose qu’on expose et contrôle réellement est la fonction predict, ce qui rend difficile l’ajout d’un comportement personnalisé autour de l’agent. Par exemple, si l’on souhaite que l’agent se comporte différemment selon la provenance de la requête — donner des réponses courtes lorsqu’il est appelé depuis Slack et des réponses détaillées lorsqu’il est appelé depuis l’interface Web —, il n’y a pas d’endroit propre où placer cette logique.

Lorsqu’on déploie son agent comme application, on contrôle le serveur lui-même, ce qui permet d’ajouter ses propres routes ou intergiciels au code. Dans le même exemple, un intergiciel peut examiner chaque requête entrante, déterminer sa provenance et charger le prompt correspondant, ce qui rend relativement facile l’ajout de ce genre de logique et de comportement personnalisés au serveur.

Il existe encore quelques domaines où le point de terminaison de service de modèles présente un avantage par rapport à l’application Databricks:

  • Mise à l’échelle et scale-to-zero : Les points de terminaison de service conservent un avantage en matière de mise à l’échelle. Un point de terminaison de service s’ajuste automatiquement selon le trafic et peut réduire ses ressources à zéro en période d’inactivité, alors qu’une application s’exécute dans un conteneur de taille fixe que l’on doit ajuster manuellement. En raison de la présence du scale-to-zero pour les points de terminaison de service et de son absence pour les applications Databricks, les coûts sont plus élevés pour les applications que pour les points de terminaison lorsque les deux sont inactifs. En état d’inactivité, notre application migrée nous coûtait 0,45 $ US par heure (ce montant peut varier selon votre cas).
  • Gestion des versions de l’agent : La gestion des versions est également plus complexe pour les applications Databricks que pour les points de terminaison de service. Lorsqu’on enregistre un modèle derrière un point de terminaison de service, Unity Catalog attribue et suit automatiquement la version, et Databricks permet d’associer directement des évaluations et des mesures à cette version, ce qui permet de comparer la performance des différentes versions de l’agent au fil du temps. Une application n’a pas d’équivalent intégré; pour identifier et suivre les différentes versions de son agent, il faut donc parfois récupérer le commit Git et enregistrer un modèle MLflow nommé d’après ce commit afin de marquer la version de façon unique.

Serveur d’agents MLflow

Figure 2: Architecture du serveur d’agents MLflow sur le calcul d’une application Databricks

Lorsqu’on déploie son agent comme application, il faut un serveur qui accepte la requête provenant du client, la valide et exécute la logique de l’agent selon le type de requête. Cette validation se fait par rapport au schéma de l’API Responses.

C’est ici qu’intervient le serveur d’agents MLflow, qui sert votre agent au sein de l’application. Le serveur d’agents MLflow est un serveur basé sur FastAPI livré avec MLflow, dont la responsabilité principale est d’exécuter la logique de l’agent chaque fois qu’une requête d’un client arrive sur la route d’API « /invocations ». Il fournit deux décorateurs, @invoke et @stream. Les méthodes décorées par ces deux décorateurs contiennent la logique de l’agent, et ce sont ces méthodes qui sont associées à la route /invocations.

Le champ stream de la requête déterminera laquelle des deux méthodes s’exécute. La méthode décorée par @stream est responsable de générer les réponses en continu (streaming). Chaque fois qu’une requête arrive avec le champ « stream » à true, le serveur invoque la méthode @stream et transmet chaque événement au client au fur et à mesure qu’il est produit. Sinon, c’est la méthode @invoke qui s’exécute, et la réponse finale complète est renvoyée au client en un seul bloc.

@invoke()
async def invoke_handler(request: ResponsesAgentRequest) -> ResponsesAgentResponse:
    # exécutez votre agent ici et retournez la réponse complète
    ...

@stream()
async def stream_handler(request: ResponsesAgentRequest) -> AsyncGenerator[ResponsesAgentStreamEvent, None]:
    # exécutez votre agent ici et générez (yield) les événements au fur et à mesure qu'ils sont produits
    ...

agent_server = AgentServer("ResponsesAgent", enable_chat_proxy=True)
app = agent_server.app

La logique de l’agent n’est pas écrite deux fois. On écrit la logique principale une seule fois dans la méthode @stream, et la méthode @invoke se contente de l’appeler. Plutôt que d’envoyer les fragments de la réponse au client, la méthode @invoke recueille les événements diffusés en continu pour en faire la réponse finale, qu’elle renvoie ensuite à l’utilisateur final.

En mettant tout cela ensemble, voici ce qui se passe lorsqu’un utilisateur envoie une requête:

1. Le client envoie une requête POST à la route /invocations avec l’entrée de la conversation.

2. Le serveur valide le corps de la requête par rapport au schéma de l’API Responses.

3. Le serveur lit le champ stream de la requête pour décider quelle méthode exécuter.

4. Si ce champ est à true, la méthode @stream s’exécute et chaque événement qu’elle génère est retransmis au client au fur et à mesure qu’il est produit.

5. Sinon, la méthode @invoke s’exécute, recueille la réponse complète et la renvoie au client sous la forme d’une seule réponse JSON.

Figure 3: Répartition des requêtes du serveur d’agents MLflow — valider, puis acheminer vers @stream ou @invoke selon le champ stream de la requête.

Le serveur d’agents enregistre également chaque requête faite à la route « /invocations » comme trace dans une expérience MLflow, ce que l’on peut configurer en définissant l’identifiant de l’expérience dans la variable d’environnement MLFLOW_EXPERIMENT_ID.

Le serveur s’exécute sur uvicorn et écoute par défaut sur le port 8000; et puisque le serveur d’agents est une application FastAPI, il hérite aussi de la façon FastAPI d’exécuter du code une fois au démarrage et une fois à l’arrêt. Ce mécanisme s’appelle le lifespan, et c’est ce que nous examinons maintenant.

Le lifespan du serveur d’agents MLflow

Figure 4: Diagramme de flux du cycle de vie du serveur d’agent MLflow

Dans une application FastAPI, les gestionnaires de requêtes s’exécutent une fois par requête, alors que certains traitements doivent avoir lieu exactement une seule fois au démarrage de l’application — par exemple, ouvrir une connexion à une base de données — avant même que la première requête ne soit traitée. Le lifespan est le mécanisme de FastAPI permettant d’exécuter du code au démarrage et à l’arrêt d’une application. Il s’écrit comme une seule fonction contenant une instruction yield en son milieu, et le serveur l’exécute en trois phases. Le code avant le yield s’exécute au démarrage du serveur, qui n’accepte aucune requête avant que ce code ne soit terminé. La fonction reste ensuite en pause au yield pendant toute la durée où le serveur fonctionne et traite les requêtes. Le code après le yield s’exécute à l’arrêt du serveur.

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app):
    # Phase 1: s'exécute une seule fois au démarrage. Le serveur n'accepte aucune requête avant que ceci ne soit terminé.
    pool = await open_lakebase_pool()
    app.state.pool = pool
    yield  # Phase 2 : la fonction reste en pause ici pendant toute la durée d'exécution du serveur.
    # Phase 3: s'exécute une seule fois à l'arrêt du serveur.
    await pool.close()

# associer le lifespan à l'application FastAPI
app.router.lifespan_context = lifespan

Pour un agent, l’utilisation principale du lifespan concerne les connexions à la base de données. Ouvrir une connexion à Lakebase implique de résoudre l’hôte, d’effectuer une poignée de main TLS et de générer un jeton OAuth, ce qui entraîne un délai notable. Faire cela à l’intérieur d’un gestionnaire ajouterait ce délai à chaque message envoyé par un utilisateur. Le lifespan effectue ce travail une seule fois, conserve le bassin de connexions ouvert pendant toute la durée de vie du serveur, et chaque requête ne fait qu’emprunter une connexion au bassin avant de la remettre.

Servir l’interface de clavardage (Chat UI) frontale

Sur les applications Databricks, l’interface de clavardage peut être servie par la même application qui sert l’agent, sans déploiement distinct. Databricks fournit déjà cette interface sous forme de gabarit appelé e2e-chatbot-app-next, une application de clavardage avec diffusion en continu, historique de conversation et rétroaction déjà intégrés, que nous utiliserons comme référence pour cette section.

Figure 5: Interface de clavardage de l’agent superviseur WAM Analytics (application Databricks).

Une application Databricks n’expose qu’un seul port au monde extérieur, celui défini par la variable d’environnement DATABRICKS_APP_PORT, si bien que toute requête qui atteint l’application depuis l’extérieur doit arriver sur ce même port, quel qu’en soit l’objet. La commande de démarrage de l’application exécute un script appelé start_app.py, qui lance deux processus à l’intérieur du conteneur pour gérer cela. Le premier est le serveur d’agents dont nous avons parlé plus haut, qui écoute sur le port 8000, soit le port que la plateforme expose réellement au monde extérieur. Le second est le serveur de l’interface de clavardage, qui écoute par défaut sur le port 3000 par l’intermédiaire de la variable d’environnement CHAT_APP_PORT, et ce second processus n’est accessible que depuis l’intérieur du conteneur. Cela signifie qu’une requête pour la page de clavardage elle-même doit également atteindre l’application par le port 8000, même si le code qui affiche cette page se trouve sur le port 3000; quelque chose à l’intérieur du conteneur doit donc faire le pont entre les deux.

Ce pont se trouve à l’intérieur même du serveur d’agents, et ce comportement d’acheminement provient de la bibliothèque MLflow plutôt que du code du gabarit. Définir enable_chat_proxy=True à la création de l’AgentServer installe un intergiciel sur l’application FastAPI, et chaque requête entrante y passe avant tout autre traitement. Si le chemin de la requête correspond à une route enregistrée par le serveur d’agents, comme /invocations, le serveur d’agents la traite directement et l’intergiciel n’intervient pas davantage. Si le chemin ne correspond à aucune route enregistrée, l’intergiciel le vérifie par rapport à une liste de chemins d’interface autorisés; si le chemin figure sur cette liste, la requête est transmise au serveur d’interface sur le port 3000 par localhost, et la réponse est relayée vers le client.

L’intergiciel ne transmet que les requêtes figurant sur cette liste autorisée, et ce, de façon délibérée, plutôt que de simplement transmettre tout ce qui n’est pas déjà réclamé par le serveur d’agents. Transmettre systématiquement toute requête non associée, sans liste, permettrait à une requête d’atteindre n’importe quelle adresse visible par le conteneur, ce qui représente un véritable risque de sécurité. L’intergiciel ne transmet donc que les chemins explicitement autorisés et renvoie un 404 pour tout le reste.

La liste comprend d’abord les chemins requis par l’interface du gabarit, soit /, /favicon.ico et /ping comme chemins exacts, ainsi que /assets/, /api/ et /chat/ comme préfixes. Si vous personnalisez l’interface et qu’elle sert de nouveaux chemins, vous pouvez étendre cette liste à l’aide de deux variables d’environnement, qui acceptent toutes deux des valeurs séparées par des virgules. CHAT_PROXY_ALLOWED_EXACT_PATHS ajoute des chemins comparés dans leur intégralité; ainsi, une entrée comme /robots.txt ne transmet une requête que lorsque son chemin est exactement /robots.txt. CHAT_PROXY_ALLOWED_PATH_PREFIXES ajoute des chemins comparés selon leur début; ainsi, une entrée comme /docs/ transmet toute requête dont le chemin commence par /docs/. Cela permet d’exposer de nouvelles parties de l’interface sans modifier le code. Le délai d’attente pour les requêtes transmises est contrôlé par CHAT_PROXY_TIMEOUT_SECONDS et est fixé par défaut à 300 secondes.

Variable d’environnementCe qu’elle faitValeur par défaut
CHAT_APP_PORTPort sur lequel le serveur de l’interface de clavardage écoute à l’intérieur du conteneur3000
CHAT_PROXY_ALLOWED_EXACT_PATHSChemins séparés par des virgules transmis à l’interface lorsqu’ils correspondent exactement
CHAT_PROXY_ALLOWED_PATH_PREFIXESDébuts de chemins séparés par des virgules transmis à l’interface
CHAT_PROXY_TIMEOUT_SECONDSDélai d’attente, en secondes, pour les requêtes transmises à l’interface300
Tableau 1: Variables d’environnement du proxy de clavardage.

Le serveur d’interface doit aussi appeler l’agent lui-même une fois qu’un utilisateur envoie effectivement un message; il rappelle alors le serveur d’agents sur le port 8000, à la route /invocations, par localhost — de la même façon que l’intergiciel avait atteint le serveur d’interface plus tôt — en utilisant une adresse lue dans la variable d’environnement API_PROXY. Comme cet appel reste à l’intérieur du conteneur plutôt que de repasser par le réseau externe, il atteint le serveur d’agents comme une requête normale vers une route enregistrée, et l’intergiciel laisse simplement le serveur d’agents la traiter, sans devoir la transmettre ailleurs.

En somme, un seul message de clavardage fait une courte boucle à travers le conteneur. Il entre par le port 8000 depuis l’extérieur, où l’intergiciel le transmet au serveur d’interface sur le port 3000 par localhost, puisque son chemin figure sur la liste autorisée; puis, une fois que le serveur d’interface a tiré ce qu’il lui faut de ce message, il rappelle le port 8000 par localhost à la route /invocations pour atteindre l’agent et obtenir une réponse.

Figure 6: Un conteneur, deux processus — le serveur d’agents MLflow et le serveur de l’interface de clavardage, reliés par la boucle locale (loopback).

Migration de notre agent de production depuis le service de modèle

Nous avions un agent superviseur financier déployé en production comme point de terminaison de service de modèle. Il s’agit d’un superviseur ReAct de LangGraph doté d’un outil Genie pour les questions sur des données structurées, d’un outil agent RAG qui est lui-même un autre point de terminaison de service, ainsi que d’un ensemble d’outils MCP.

Pour migrer l’agent vers une application, nous avons utilisé le gabarit d’application de migration ainsi qu’une compétence (skill) migrate-from-model-serving fournie par Databricks lui-même. Les deux ont été utilisés par l’assistant de programmation — Claude Code, dans notre cas — pour migrer entièrement le point de terminaison de service vers une application Databricks.

L’assistant télécharge les artefacts du modèle original depuis le point de terminaison de service, convertit les méthodes predict et predict_stream du point de terminaison en gestionnaires @invoke et @stream du serveur d’agents MLflow, échafaude l’application autour de ceux-ci et guide la mise à l’essai locale de l’agent avant son déploiement. Il a migré l’agent en entier et l’a livré sous la forme d’un projet autonome de type Databricks asset bundle. Notre projet global comprend davantage de composants que le seul agent, si bien que l’intégration de l’application générée dans la structure existante de notre projet est demeurée un travail que nous avons dû faire nous-mêmes.

Figure 7: Agent superviseur WAM Analytics avant (service de modèle).
Figure 8: Agent superviseur WAM Analytics après (application Databricks).

Comment l’autorisation pour le compte de l’utilisateur (OBO) a changé

Un des changements importants apportés par l’assistant concernait l’autorisation pour le compte de l’utilisateur (on-behalf-of-user, ou OBO), qui permet à l’agent d’appeler ses outils avec les permissions de l’utilisateur ayant fait la requête. Le point de terminaison de service gérait l’OBO par l’entremise de ModelServingUserCredentials, qui fournissait au modèle les identifiants de l’utilisateur demandeur afin d’autoriser ses appels d’outils.

L’application, quant à elle, reçoit plutôt directement le jeton de l’utilisateur : chaque requête transporte ce jeton dans l’en-tête x-forwarded-access-token, et l’agent en crée un client d’espace de travail (workspace client) chaque fois qu’il appelle un outil. L’appel d’outil s’exécute alors avec les permissions de cet utilisateur.

Même si l’assistant s’est occupé du code, nous avons tout de même tout testé manuellement, et la configuration de l’OBO a échoué à ce test. L’assistant avait programmé le principal de service (service principal) de l’application comme solution de repli pour l’OBO. Lorsqu’une requête provenait d’un utilisateur n’ayant pas accès à une ressource, l’appel d’outil se rabattait sur le principal de service et réussissait tout de même. Nous avons apporté des modifications au code pour supprimer ce mécanisme de repli et imposer l’OBO pour chaque appel d’outil. Cela montre l’importance d’effectuer, en fin de compte, des tests manuels de bout en bout (e2e) afin de s’assurer que le code produit par l’agent fonctionne comme prévu.

Du côté client, les consommateurs de l’agent doivent également remplacer l’adresse de l’agent, en passant de l’URL du point de terminaison de service à celle de l’application.

Mots de la fin

L’objectif de ce billet était de favoriser la compréhension plutôt que de fournir une suite d’étapes. Les gabarits et compétences d’agents s’occuperont d’échafauder, de migrer et de déployer une application d’agent pour vous. Ce qui reste, c’est de savoir comment les différentes pièces s’assemblent : les gestionnaires @invoke et @stream sur /invocations, le lifespan qui ouvre le bassin de connexions Lakebase une seule fois, l’intergiciel proxy qui réunit l’agent et l’interface de clavardage dans un même conteneur, et le jeton OBO dans l’en-tête x-forwarded-access-token. C’est cette connaissance qui permet de personnaliser le serveur, d’étendre l’interface de clavardage et de repérer ce que l’automatisation fait mal, comme l’a démontré notre bogue de repli OBO.

Si vous démarrez un nouvel agent, commencez par le guide de création d’agents et les gabarits d’application. Si vous effectuez une migration, le guide officiel de migration complète bien l’expérience décrite ici.


Posted

in

,

by

Tags: