Aller au contenu
Retour aux Articles

Construire un assistant agentique dans Laravel avec le SDK PHP Anthropic : outils, voies de modèles, prompt caching et comptage par tenant

Byte Smith · · 15 min de lecture

Une boîte de chat n’est pas un assistant. Un modèle qui ne sait que parler de votre entreprise est une page d’aide très coûteuse. Ce qui rend l’assistant de Zenpresa utile, c’est qu’il peut faire des choses : réserver le rendez-vous, envoyer la facture, retrouver les clients perdus de vue, appliquer le thème. Chacune de ces actions est un outil, et la conception du contrat d’outil s’est révélée compter davantage que le choix du modèle, le prompt ou tout le reste sur lequel nous avons passé du temps.

Ce billet est le récit d’ingénierie de cet assistant. Il tourne sur Laravel 12 et le SDK PHP officiel d’Anthropic, s’inscrit dans l’architecture à une base de données par tenant décrite dans le billet précédent, et doit être sûr, économique et rapide pour de nombreuses entreprises à la fois. Les prompts, les limites exactes et les identifiants de modèles sont délibérément omis ; ce sont les patterns qui comptent.

Le contrat d’outil

Chaque action que l’assistant peut effectuer implémente une seule interface PHP. Légèrement simplifiée, avec des noms inventés :

interface AssistantTool
{
    /** nom en snake_case exposé au modèle */
    public function name(): string;

    /** Prescriptif : « Appelle ceci quand l'utilisateur veut… » */
    public function description(): string;

    /** JSON Schema de l'entrée */
    public function inputSchema(): array;

    /** Module qui doit être activé pour le tenant, ou null */
    public function moduleKey(): ?string;

    /** Cet outil est-il utilisable dans ce déploiement, tout court ? */
    public function isAvailable(): bool;

    /** Cette action nécessite-t-elle une confirmation humaine explicite ? */
    public function requiresConfirmation(): bool;

    /** Cet utilisateur peut-il l'exécuter ? Revérifié à chaque appel. */
    public function authorize(User $user): bool;

    /** Renvoie summary, data, link et un diff optionnel */
    public function execute(array $input, User $user): array;
}

Deux aspects de cette forme ont tenu bon sur des dizaines d’outils.

Premièrement, le résultat est uniforme. Chaque outil renvoie un court summary lisible par un humain (affiché à l’utilisateur et renvoyé au modèle), un tableau data (renvoyé au modèle uniquement), un link optionnel vers un endroit précis de l’application, et un diff optionnel avec l’état avant/après. L’interface de chat affiche ces quatre champs de la même manière pour chaque outil, donc ajouter un outil n’implique jamais de toucher au frontend. Le diff en particulier a été l’élément d’interface le plus digne de confiance du produit : quand l’assistant modifie le prix d’une prestation, l’utilisateur voit l’ancien et le nouveau côte à côte.

Deuxièmement, la description est prescriptive, pas descriptive. « Réserve un rendez-vous » est une description. « Appelle ceci quand l’utilisateur veut placer un client précis dans un créneau précis pour une prestation précise ; appelle d’abord get_available_slots si l’horaire est incertain » est une instruction. Le second type produit une sélection d’outils spectaculairement meilleure, et c’est la raison pour laquelle nos descriptions d’outils se lisent comme un runbook.

Un registre filtré par l’abonnement

Les outils vivent dans un registre indexé par nom. Quand une conversation démarre, le registre construit la liste que le modèle verra en filtrant sur trois critères : isAvailable() (la plateforme est-elle configurée pour cela), moduleKey() (ce module fait-il partie de l’abonnement du tenant) et authorize() pour l’utilisateur courant. Un tenant sans le module marketing ne voit jamais d’outil « envoyer une campagne ». Un membre du personnel sans permissions de facturation ne voit jamais « créer une facture ».

Ce n’est pas qu’une question de propreté. Le modèle ne peut pas appeler un outil dont on ne lui a jamais parlé, et un outil dont on ne lui a pas parlé ne peut pas révéler dans sa description que la fonctionnalité existe. Le registre est l’unique endroit où « que peut faire cet utilisateur via l’assistant » est décidé, et il lit les mêmes feature flags et vérifications de permissions que le reste de l’application.

Autoriser à chaque appel, côté serveur

Le filtrage du registre a lieu une fois par conversation. L’autorisation a lieu à chaque exécution d’outil, à nouveau, côté serveur. Les permissions peuvent changer en cours de conversation, une conversation peut être reprise des heures plus tard, et la transcription du modèle n’est une source de vérité pour rien.

Dans execute(), l’entrée est validée avec les mêmes règles de validation Laravel que celles du contrôleur HTTP équivalent. Nous n’avons pas écrit de validateur séparé, plus laxiste, pour l’assistant. Si l’API dit qu’un prix de prestation doit être un entier non négatif en unités mineures, l’outil dit la même chose, et une ValidationException est transformée en erreur structurée que le modèle peut lire et corriger. Le modèle ne porte jamais d’autorité ; il porte une requête que le serveur évalue exactement comme si elle venait d’un clic sur un bouton.

Des voies de modèles épinglées par conversation

Tous les messages n’ont pas besoin du même modèle. « Quel a été mon jour le plus chargé la semaine dernière ? » a besoin d’un modèle rapide et bon marché, doué pour appeler correctement les outils. « Réécris le texte d’accroche de ma page d’accueil pour qu’il sonne plus chaleureux » a besoin d’un modèle avec plus d’amplitude. Nous appelons cela des voies (lanes) : une voie standard et une voie créative, chacune avec son propre modèle et ses propres réglages de raisonnement, définis explicitement plutôt qu’hérités d’un défaut.

La voie est choisie une fois, au début d’une conversation, par un petit classifieur sur le premier message. Elle est ensuite épinglée pour toute la durée de la conversation. La raison, c’est le prompt caching : les préfixes en cache sont liés à un modèle, donc changer de modèle en cours de conversation jette le cache et refacture tout le system prompt et la liste d’outils au tour suivant. Épingler signifie que le deuxième tour et tous les suivants touchent le cache. Le coût d’être occasionnellement dans la « mauvaise » voie pour un message est bien plus faible que le coût d’un cache miss à chaque message.

Des breakpoints de prompt cache qui touchent vraiment

Le prompt caching d’Anthropic facture les tokens d’entrée en cache à une fraction du tarif normal, mais seulement si le préfixe est identique à l’octet près entre les requêtes. La requête que nous envoyons est agencée pour que les parties coûteuses et stables viennent en premier et les parties volatiles en dernier :

  1. Le system prompt, identique pour chaque tenant.
  2. Les définitions d’outils, générées depuis le registre, stables pour un ensemble donné de modules activés.
  3. Un breakpoint de cache avec une longue durée de vie après les outils.
  4. Le contexte spécifique au tenant et la conversation jusqu’ici.
  5. Un second breakpoint de cache avec une courte durée de vie sur la queue des messages.

Le premier breakpoint est celui qui rapporte : chaque tenant avec le même ensemble de modules activés partage le même préfixe en cache, parce que rien avant ce breakpoint n’est spécifique au tenant. Le second breakpoint rend les conversations multi-tours peu coûteuses, parce que chaque tour n’ajoute que les messages les plus récents après un préfixe mis en cache au tour précédent.

L’invariant « rien avant le premier breakpoint n’est spécifique au tenant » est imposé par un test. Il construit le préfixe pour deux tenants différents et vérifie que les octets sont identiques. Il a échoué exactement une fois, quand quelqu’un a mis le nom de l’entreprise dans le system prompt pour un ton « plus amical », et il a attrapé la régression avant que le taux de cache hit ne chute en production.

Streaming et boucle d’outils en PHP

PHP n’est pas le premier langage vers lequel on se tourne pour streamer une boucle d’agent, et cela fonctionne très bien. L’endpoint de l’assistant renvoie une StreamedResponse Laravel avec le type de contenu text/event-stream et écrit des Server-Sent Events au fur et à mesure que l’itérateur de streaming du SDK les produit. Les deltas de texte vont directement au client. Quand le modèle émet un appel d’outil, la boucle exécute l’outil, ajoute le résultat à la transcription et lance la requête suivante, le tout dans la même réponse HTTP.

La boucle est bornée. Un tour de conversation ne peut parcourir la boucle d’outils qu’un nombre fixe de fois avant que le serveur ne s’arrête et n’explique à l’utilisateur ce qu’il a fait jusque-là. Sans cette borne, un modèle désorienté face à un outil défaillant peut boucler indéfiniment à vos frais.

Deux choses ont dû être vérifiées plutôt que supposées. Premièrement, le reverse proxy ne doit pas mettre en tampon : l’application définit l’en-tête qui désactive le buffering du proxy, et nous avons testé de bout en bout que les tokens arrivaient progressivement dans le navigateur, parce qu’un proxy qui met en tampon transforme le streaming en une très longue pause suivie de tout d’un coup. Deuxièmement, côté client, l’EventSource natif du navigateur ne peut envoyer ni en-têtes ni corps de requête, donc le frontend utilise fetch avec un lecteur ReadableStream et parse lui-même les trames SSE. Cela représente une quarantaine de lignes de TypeScript et elles n’ont pas eu besoin de changer depuis.

Comptage par tenant

Chaque requête enregistre les tokens d’entrée, les tokens d’entrée en cache, les tokens de sortie et le modèle qui les a servis. Le coût est calculé à partir d’une table de tarification et stocké en micro-dollars sous forme d’entier, parce que la monnaie en virgule flottante à grande échelle, c’est ainsi qu’on finit avec un projet de réconciliation.

Les plafonds par tenant sont exprimés en tokens, pas en dollars. C’était un choix délibéré : si nous routons une conversation vers un modèle moins cher, le tenant obtient plus de tours pour la même allocation, et la marge de la plateforme s’améliore en même temps. Un plafond en dollars rendrait le routage de modèles invisible pour le client ; un plafond en tokens en fait une fonctionnalité.

Le dernier détail de comptage est celui qui a pris le plus de temps à bien faire. Quand un utilisateur ferme l’onglet en pleine réponse, le flux s’interrompt. Les tokens ont déjà été générés et facturés par le fournisseur. L’usage est donc persisté dans un bloc finally qui s’exécute que le flux se soit terminé, ait échoué ou que le client se soit déconnecté, et l’enregistrement persisté porte un flag signalant la déconnexion. Avant ce changement, le compteur sous-comptait chaque fois que les utilisateurs étaient impatients, c’est-à-dire souvent.

Apprendre au modèle ce qui n’a pas d’outil

Le meilleur rapport de bug que nous ayons reçu au sujet de l’assistant venait d’un client qui a tapé « réserver une séance » et a obtenu, correctement, un rendez-vous réservé. Sauf que son entreprise appelait les séances autrement, et que ce qu’il voulait dire, c’était « ajouter un bouton Réserver une séance à ma page de contact ». L’assistant avait un outil pour réserver et aucun outil pour modifier cette page précise, alors il a fait ce qu’il pouvait faire.

La solution n’a pas été un outil de plus. C’était une référence de capacités : un document que le modèle lit, décrivant chaque outil dont il dispose, chaque tâche pour laquelle il n’a pas d’outil, et quoi faire dans chacun de ces cas (expliquer, renvoyer l’utilisateur vers la bonne page de paramètres, ou poser une question de clarification). L’assistant dit désormais « je ne peux pas modifier cette page directement, mais voici le lien et voici ce qu’il faut changer » au lieu de deviner.

Le document est tenu honnête par un test : il énumère le registre et fait échouer la CI si un outil enregistré manque dans la référence. Une documentation qu’un test impose est une documentation qui reste vraie.

Ce que nous ferions différemment

Commencer par le champ diff. Nous avons ajouté le diff avant/après au résultat d’outil après le lancement, et c’est la fonctionnalité qui a le plus accru la confiance. Elle aurait dû être dans le contrat dès le premier jour.

Épingler les voies dès le début. Nous reclassifiions initialement chaque message et regardions le taux de cache hit rester obstinément bas. L’épinglage a été un changement d’une ligne avec un effet démesuré sur le coût.

Écrire la référence de capacités avant les outils. Décrire ce que l’assistant ne peut pas faire a forcé une réflexion plus claire sur ce qu’il devrait faire.

Compter dans le finally dès le premier jour. Chaque token sous-compté est de l’argent que vous avez déjà dépensé.

Si vous construisez quelque chose de similaire, le billet sur le harness d’évaluation de ce site couvre comment scorer la sélection d’outils et la sécurité avant de déployer, et la comparaison entre l’OpenAI Agents SDK et la Responses API est un contraste utile si vous choisissez entre les SDK des fournisseurs. Tout ce qui concerne les limites de débit et l’application des budgets devant le modèle se trouve dans le billet sur le contrôle des coûts LLM.

À suivre dans la série : Sécurité des outils d’agent : injection de prompt, codes de confirmation hors bande et clés d’idempotence pour les outils qui déplacent de l’argent

FAQ : assistants agentiques dans Laravel

Peut-on construire une boucle d’agent IA en streaming en PHP ?

Oui. La StreamedResponse de Laravel avec le type de contenu text/event-stream, alimentée par l’itérateur de streaming du SDK PHP Anthropic, délivre les deltas de tokens sous forme de Server-Sent Events. La boucle d’outils tourne dans la même réponse : exécuter l’outil, ajouter le résultat, demander la complétion suivante, jusqu’à une borne fixe. Les deux choses à vérifier sont que votre reverse proxy ne met pas la réponse en tampon et que le client utilise fetch avec un ReadableStream plutôt qu’EventSource, qui ne peut envoyer ni en-têtes ni corps.

Comment garder le prompt caching efficace sur de nombreux tenants ?

Ordonnez la requête pour que tout ce qui est stable et indépendant du tenant vienne en premier : le system prompt et les définitions d’outils, suivis d’un breakpoint de cache à longue durée de vie. Placez le contexte tenant et l’historique de conversation après, avec un breakpoint à courte durée de vie sur la queue. Épinglez le modèle pour toute la conversation, parce que les préfixes en cache sont liés au modèle. Imposez l’invariant « préfixe sans tenant » avec un test qui construit le préfixe pour deux tenants et vérifie l’égalité des octets.

Pourquoi plafonner l’usage de l’IA en tokens plutôt qu’en dollars ?

Parce que cela récompense le routage. Si un modèle moins cher gère une conversation, un plafond en tokens donne au tenant plus de tours pour la même allocation pendant que le coût de la plateforme baisse. Un plafond en dollars cache ce bénéfice au client. En interne, le coût est toujours suivi en micro-dollars entiers pour la réconciliation.

Comment empêcher le modèle d’appeler des outils que l’utilisateur n’a pas le droit d’utiliser ?

Deux fois. Le registre d’outils filtre ce que le modèle peut voir selon la disponibilité sur la plateforme, les modules activés du tenant et les permissions de l’utilisateur. Puis chaque exécution d’outil relance l’autorisation et applique les mêmes règles de validation que l’endpoint HTTP équivalent. La transcription du modèle n’est jamais traitée comme une preuve de permission.

Que fait le modèle pour les tâches qui n’ont pas d’outil ?

Il lit une référence de capacités décrivant chaque outil, chaque tâche sans outil et la bonne réponse pour chacune : expliquer, renvoyer vers la page de paramètres pertinente, ou demander. Un test CI énumère le registre d’outils et échoue si un outil enregistré manque dans la référence, pour que le document ne puisse pas dériver du code.