Aller au contenu
mcpd
aucun daemon
Source

Retrouver une exécution

Une enquête commence par une plainte, pas par un tableau de bord. Cette page montre comment transformer une plainte en l’exécution précise qui l’a causée.

Cherchez le payload, pas l’étiquette

La plupart des outils de traçage indexent les noms et les attributs. mcpd indexe aussi le prompt et la complétion, parce que ce dont une personne se plaint est quelque chose que le modèle a dit, et que les mots qu’elle vous cite sont la seule prise dont vous disposez. Le paramètre q est cet index.

Search
shipped on Tuesdayscore_below=0.5min_duration_ms=5000model=gpt-4o AND status=error
  • 3 hits the run whose completion said it
  • 12 hits every answer a scorer marked down
  • 7 hits runs slower than five seconds
  • 2 hits failures on one model

Ce que q couvre, exactement

L’index tient trois champs par span : son nom, son entrée et sa sortie. L’entrée et la sortie sont du JSON, donc avant l’indexation mcpd parcourt le payload et n’en garde que les chaînes et les nombres, en écartant les accolades, les guillemets et les clés. Indexer le JSON brut ferait de chaque signe de ponctuation un token et enfouirait les mots que vous cherchez vraiment.

Six conséquences valent d’être connues avant de vous y fier :

  • Une exécution correspond dès que l’un quelconque de ses spans correspond. Vous récupérez des exécutions entières ; ouvrez-en une pour voir de quelle étape il s’agissait.
  • Les mots sont combinés par ET. q=refund delayed trouve les spans qui contiennent les deux, n’importe où dans le texte indexé, sans qu’ils soient voisins.
  • La correspondance porte sur le mot entier, sans distinction de casse, et les diacritiques sont repliés : resume trouve donc résumé. Il n’y a pas de racinisation : refund ne trouve pas refunds.
  • Les 64 premiers KiB de l’entrée et de la sortie entrent dans l’index. Les payloads plus longs sont stockés et affichés en entier ; seule la fin d’un très gros prompt échappe à la recherche.
  • Le masquage a lieu avant le stockage : ce qu’une règle a retiré n’a jamais été indexé non plus. Voir Masquage et conservation.
  • Un span écrit plus d’une fois, comme l’est un span de longue durée quand il se termine, remplace sa ligne d’index : la recherche reflète donc toujours la version courante.

Un guillemet égaré est une recherche, pas une erreur

L’index sous-jacent a son propre langage de requête : un guillemet double ouvre une phrase, et NEAR, OR et * sont des opérateurs. Lui remettre votre texte tel quel voudrait dire qu’un message client contenant un guillemet revient en erreur de syntaxe au lieu d’un résultat, et une boîte de recherche qui peut échouer est une boîte de recherche que les gens cessent d’utiliser.

Votre texte n’atteint donc jamais cet analyseur. mcpd découpe ce que vous avez tapé sur les espaces, retire tout guillemet double de chaque mot, enveloppe chaque mot comme un terme littéral et les joint par ET. q=say "no" cherche les mots say et no. q=NEAR cherche le mot NEAR. Rien de ce que vous pouvez taper n’est une erreur de syntaxe, et aucun opérateur ne vous est offert. C’est le marché, conclu dans ce sens à dessein.

Un q qui ne contient que des espaces est abandonné plutôt que de ne rien faire correspondre, et un q= vide est traité comme absent. Tous les paramètres se comportent ainsi, de sorte qu’un formulaire qui soumet ses boîtes de filtre vides ne filtre pas par accident sur la chaîne vide.

Restreindre une liste d’exécutions

GET /v1/api/traces accepte ce qui suit. Tout ce que vous omettez n’est pas appliqué.

ParamètreCe qu’il sélectionne
q Plein texte sur le nom, l’entrée et la sortie de chaque span.
status unset, ok, error ou cancelled. Une exécution est en error dès qu’un span en elle a échoué, même si son erreur a été rattrapée.
kind Le genre du span racine de l’exécution, par exemple agent_run ou workflow_run.
name Le nom du span racine, exactement. Ni un préfixe, ni un motif.
tags Séparés par des virgules. Une exécution doit porter toutes les étiquettes listées, et non l’une d’elles.
session_idToutes les exécutions d’une conversation.
thread_idToutes les exécutions d’un fil.
user_idToutes les exécutions d’un utilisateur final.
environmentUn environnement, par exemple production.
model Les exécutions ayant au moins un span contre cet identifiant de modèle.
from, to La fenêtre, mesurée sur l’heure de début de l’exécution.
min_duration_ms Les exécutions au moins aussi lentes, en millisecondes.
min_cost Les exécutions qui coûtent au moins cela. Une exécution sans span tarifé compte pour zéro : toute valeur positive l’exclut donc.
has_score Les exécutions portant un score de ce scorer.
score_below Avec has_score : les exécutions que ce scorer a mises sous cette valeur.
sort start_time, duration, cost, span_count ou score. Vaut start_time par défaut. score ordonne les exécutions par le plus bas score que tout scorer leur a donné : en ordre croissant, la pire vient donc en tête, et une exécution sans score compte pour zéro.
order asc ou desc. Vaut desc par défaut.
limit La taille de page, ramenée entre 1 et 500. Vaut 50 par défaut.
cursor Le curseur opaque de la page précédente.
project Le projet à lire. Vaut le projet par défaut du daemon, de sorte qu’une installation à projet unique n’a jamais à le passer.

name, les identifiants de corrélation et environment sont des tests d’égalité, et c’est ce qui les rend peu coûteux. Si vous voulez une correspondance partielle sur un nom, passez par q : les noms de span sont dans l’index de texte eux aussi.

Ce que coûte une ligne de liste

Il n’existe pas de seconde liste d’exécutions plus légère, parce que la première l’est déjà. La projection de trace stockée ne porte aucune entrée, aucune sortie et aucun attribut : des noms, des comptes, des temps, un coût, des étiquettes et la corrélation, et rien de ce qu’un modèle a dit. C’est l’ouverture d’une exécution qui va chercher le payload.

Mesuré sur une exécution réaliste, un agent de support en huit étapes qui récupère trois documents de procédure, appelle deux outils et fait deux appels de modèle avec de vrais prompts et de vraies complétions : la ligne de liste fait 577 octets et l’exécution entière 10 717 octets, soit une réduction de 94.6 %. Il s’agit de ce daemon, sur cette exécution, à travers la vraie surface HTTP. Vos exécutions différeront, et c’est le sens qui compte : l’économie croît avec la taille des prompts, parce que ce qui grossit est justement ce que la liste ne porte pas.

Le chiffre est produit par un test plutôt que tapé ici, si bien qu’il ne peut pas devenir périmé d’un côté sans faire échouer l’autre. Lancez cargo test -p mcpd-server --test payload_size -- --nocapture et il affiche les deux volumes en octets. Un champ ajouté à la projection et qui porte du payload fait échouer ce test.

Tout le reste est refusé, nommément

Ce tableau est tout le vocabulaire. Un paramètre qui n’y figure pas est refusé par un 400 qui le nomme, plutôt qu’accepté puis abandonné. C’est un changement : mcpd ignorait autrefois un paramètre qu’il ne connaissait pas, si bien que ?statuss=error revenait en 200 avec toutes les exécutions du projet et que rien dans la réponse ne disait que le filtre n’avait pas été appliqué. Une réponse fausse mais plausible est pire qu’une erreur, parce qu’il n’y a rien à remarquer.

Le marché ainsi conclu mérite d’être nommé. Un client qui envoie un filtre qu’un daemon plus récent comprend reçoit désormais un 400 d’un daemon plus ancien, au lieu d’un ensemble de résultats plus large. C’est le meilleur des deux échecs : le 400 nomme le paramètre et s’arrête.

Certains refus en disent plus que « inconnu ». mcpd enregistre service_name, service_version, resource_id, run_id, request_id, experiment_id et release sur chaque trace, mais la liste d’exécutions ne sait pas encore filtrer dessus. Des sept, seul service_name est une facette de la découverte, qui liste donc ses valeurs et aucune de celles des autres. Ces sept-là sont refusés avec un message qui le dit, car service_name=checkout-api n’est pas une faute de frappe, et répondre à quelqu’un qu’il s’agit d’un paramètre inconnu l’envoie chasser une faute d’orthographe qui n’existe pas.

La règle n’appartient pas qu’à la liste d’exécutions. GET /v1/api/spans, /v1/api/logs, /v1/api/scores, /v1/api/metrics et /v1/api/feedback imposent chacun leur propre vocabulaire de la même façon. Deux méritent d’être connus avant que vous ne les rencontriez : la liste des scores ne prend pas d’order, parce qu’elle va du plus récent au plus ancien et que son curseur ne compare que dans un sens ; et la liste des retours exige un trace_id, parce que mcpd ne sait pas encore lister les retours d’un projet entier et qu’une liste vide se lirait comme s’il n’y en avait aucun.

Remplir une boîte de filtre

Un filtre n’est utilisable que si vous savez quoi y taper, et les valeurs ne forment pas une liste figée : ce sont celles que les services rapportant à un projet envoient. GET /v1/api/discovery y répond. Demandez une dimension à la fois avec facet, et il rend les valeurs que cette dimension a prises dans le projet, ordonnées par valeur, chacune avec la date à laquelle un signal l’a portée pour la dernière fois.

FacetteCe qu’elle liste
entity_kindAgent, workflow, outil, modèle, scorer, processeur, pipeline ou service.
entity_nameLes noms que portent ces choses, par exemple les outils qui ont été appelés.
service_nameLes services qui ont rapporté.
environmentLes environnements vus, par exemple production.
tagToutes les étiquettes que porte une exécution quelconque.
metric_nameLes noms de métrique enregistrés, y compris ceux que mcpd dérive.
metric_label_keyToutes les clés d’étiquette vues dans le projet.
metric_label_valueLes valeurs d’une étiquette. Demande key.

prefix restreint la liste pour la saisie assistée, et il compare littéralement et en distinguant la casse, parce que ces valeurs sont ce à quoi un filtre sera comparé et que replier ici en proposerait une qui ne correspondrait ensuite à rien. limit plafonne la réponse à 1000. Les valeurs sont ordonnées par valeur et non par fraîcheur, de sorte qu’une liste que quelqu’un est en train de lire ne se réorganise pas sous ses yeux entre deux interrogations.

Une limite mérite d’être connue avant que vous ne construisiez dessus. metric_label_key liste toutes les clés d’étiquette vues où que ce soit dans le projet, et non les clés d’une métrique nommée : la liste des valeurs distinctes est indexée par l’étiquette, pas par la métrique qui la portait.

Rafraîchir une liste sans la relancer

GET /v1/api/deltas rend ce qui est arrivé depuis un curseur, du plus ancien au plus récent, pour traces, spans ou logs. Gardez le curseur de la dernière interrogation, renvoyez-le, et appliquez les lignes à la liste que vous détenez déjà.

Une interrogation n’est pas une page, et les deux curseurs ne sont délibérément pas interchangeables. La pagination parcourt un ensemble de résultats figé, à rebours et dans le tri que vous avez choisi ; l’interrogation avance dans l’ordre d’arrivée et ne finit jamais. Le repère est l’heure d’arrivée d’une ligne et son identifiant pris ensemble, car un lot écrit toutes ses lignes avec un même horodatage, et un repère qui ne pourrait dire que « après cette nanoseconde » rejouerait le lot entier ou en sauterait le reste. Une interrogation vide rend le curseur inchangé : l’avancer ferait perdre une ligne qui atterrit un instant plus tard avec une heure d’arrivée antérieure.

Les scores et les retours ne peuvent pas être interrogés ainsi. Ils portent l’horodatage que l’appelant a envoyé et aucune heure d’arrivée posée par le serveur, donc un repère sur eux sauterait une ligne tardive ou répéterait chaque ligne à l’infini. signal=scores est refusé plutôt que répondu par une page vide.

Ce que ce déploiement sait faire

mcpd a un seul trait de stockage et trois backends derrière lui, et ils ne sont pas équivalents. Sur ClickHouse, une suppression est acceptée puis appliquée après le retour de l’appel, donc les décomptes de sa réponse disent ce qui correspondait et non ce qui a quitté le disque. Sur SQLite et Postgres, les lignes ont disparu au retour de l’appel. GET /v1/api/capabilities est l’endroit où cela est écrit : quel backend se trouve derrière le daemon, ce à quoi q correspond, si la casse est repliée hors ASCII, si une suppression est sur disque au retour de l’appel, et si la découverte et l’interrogation par deltas répondent tout court.

Lisez-les une fois à la connexion et n’offrez que ce à quoi le déploiement répond. Un endpoint dont la capacité est fausse refuse par un 501 et un corps dont le champ capability nomme le mot même que publie le jeu de capacités : les deux ne peuvent donc jamais se contredire, et un client qui a rencontré le refus peut aller le consulter plutôt que réessayer une chose qui ne changera jamais.

Les trois backends livrés aujourd’hui répondent à la découverte comme à l’interrogation par deltas ; le 501 ci-dessous est donc la forme du refus d’un backend ajouté plus tard, et non une réponse que vous verrez de SQLite, de Postgres ou de ClickHouse. C’est le jeu de capacités qui vous dit lequel est là, et il vaut la peine d’être lu dès maintenant : les trois diffèrent sur le fait qu’une suppression soit sur disque au retour de l’appel, et sur la façon dont la conservation est appliquée. Les trois replient la casse hors ASCII.

sur quoi puis-je filtrer ?

# Tous les environnements que ce projet a déclarés, pour une boîte de filtre.
curl -s --get http://localhost:4317/v1/api/discovery \
  --data-urlencode 'project=default' \
  --data-urlencode 'facet=environment'

# Saisie assistée : les noms de service qui commencent par "check".
curl -s --get http://localhost:4317/v1/api/discovery \
  --data-urlencode 'facet=service_name' \
  --data-urlencode 'prefix=check'

# Les valeurs que prend une étiquette de métrique.
curl -s --get http://localhost:4317/v1/api/discovery \
  --data-urlencode 'facet=metric_label_value' \
  --data-urlencode 'key=model'

Les fenêtres

from et to acceptent un horodatage RFC 3339, ou une forme relative abrégée : un signe moins, un nombre et l’une des lettres s, m, h ou d. -15m, -1h et -7d marchent tous. Cette abréviation existe parce que taper un horodatage ISO à la main pour regarder la dernière heure est une friction que personne ne devrait payer.

from est inclusif et to exclusif, et tous deux se comparent à l’heure à laquelle l’exécution a démarré. Une longue exécution commencée avant votre fenêtre et toujours en cours pendant celle-ci n’est pas rendue. Si vous cherchez quelque chose qui tournait à 09:00, élargissez from vers l’arrière plutôt que de le resserrer.

Une valeur qui n’est ni un horodatage ni une durée est refusée nommément plutôt qu’ignorée. Chaque paramètre de chaque endpoint de liste échoue de cette façon, car un filtre silencieusement abandonné rend une réponse fausse mais plausible, et une réponse fausse mais plausible est pire qu’une erreur.

from=yesterday

{
  "error": "bad_parameter",
  "field": "from",
  "message": "\"yesterday\" is not an RFC 3339 timestamp or a relative duration like -1h"
}

La requête qui trouve les mauvaises réponses

has_score=answer_relevance&score_below=0.5 rend toutes les exécutions que ce scorer a mises sous la moitié. C’est celle qu’il faut saisir quand la plainte porte sur la qualité plutôt que sur une panne, parce qu’une mauvaise réponse vient le plus souvent d’une exécution qui a réussi.

has_score seul rend toutes les exécutions que ce scorer a examinées. score_below seul ne fait strictement rien : un seuil sans scorer attaché n’a pas de sens, et mcpd devrait deviner de quel scorer vous parlez. Il ne devine pas. Scores et retours explique d’où viennent ces valeurs.

Chercher des étapes plutôt que des exécutions

GET /v1/api/spans cherche des étapes individuelles sur toutes les exécutions à la fois. Servez-vous-en pour les questions qui portent sur une étape plutôt que sur une session : quel appel d’outil est lent, quels appels à un modèle ont échoué, combien de temps prend une récupération.

Il accepte q, kind, status, name, model, trace_id, from, to, min_duration_ms, sort, order, limit et cursor, avec les mêmes sens et les mêmes valeurs par défaut. Il n’y a ici aucun filtre tags, session_id ou user_id, parce que ceux-là appartiennent à l’exécution, et tous les trois sont refusés nommément plutôt qu’acceptés puis abandonnés.

sort prend ici moins de valeurs que sur la liste d’exécutions : start_time, duration ou cost, et rien d’autre. Un span ne porte ni nombre de spans ni cumul de scores, donc span_count et score n’ont rien sur quoi trier et sont refusés nommément plutôt qu’ignorés.

Sur une exécution, model demande si un span quelconque l’a utilisé. Sur un span, model est le modèle de ce span. Le même mot, deux questions.

la trouver

# Les exécutions en échec du dernier jour qui mentionnent les deux mots,
# où que ce soit dans un nom de span, un prompt ou une complétion.
curl -s --get http://localhost:4317/v1/api/traces \
  --data-urlencode 'q=Leeds depot' \
  --data-urlencode 'status=error' \
  --data-urlencode 'from=-24h'

# Toutes les étapes qui ont appelé un modèle et échoué, sur toutes les
# exécutions à la fois.
curl -s --get http://localhost:4317/v1/api/spans \
  --data-urlencode 'model=gpt-4o-mini' \
  --data-urlencode 'status=error' \
  --data-urlencode 'min_duration_ms=2000'

La pagination, et pourquoi le curseur est opaque

Une liste rend au plus limit lignes et un objet page. page.next_cursor est présent quand il y a davantage à lire, et page.has_more dit la même chose sous forme de booléen.

La pagination se fait par clé, non par décalage. Le curseur nomme la dernière ligne qui vous a été donnée, et la page suivante commence strictement après elle. Une pagination par décalage dériverait : les traces arrivent en continu, donc le temps que vous demandiez les lignes 50 à 99, la fenêtre a glissé sous vous et vous lisez une exécution deux fois pendant qu’une autre n’apparaît jamais. Dans une enquête, l’exécution que vous ratez est celle que vous cherchiez.

Le curseur est une paire encodée en hexadécimal formée de la valeur de tri et de l’identifiant de ligne, et vous devriez le tenir pour dépourvu de sens. Il est opaque pour que l’encodage puisse changer sans casser tous les appelants, et parce que la valeur qu’il contient est une grandeur différente selon le tri : des nanosecondes pour start_time et duration, un entier mis à l’échelle pour cost, un simple compte pour span_count, le plus bas score de l’exécution pour score.

Une règle s’y attache. Renvoyez un curseur avec les mêmes filtres, le même sort et le même order que ceux qui l’ont produit. mcpd ne vérifie pas que vous l’avez fait, et un curseur rejoué contre un autre tri se compare à la mauvaise colonne et saute des lignes. Un curseur modifié ou tronqué est refusé avec invalid_query et le message malformed cursor.

Il n’y a pas de total. page.total est réservé à un backend capable de compter les correspondances à bas coût, et le stockage SQLite intégré ne le remplit pas : bouclez donc sur has_more plutôt que de compter vers un nombre.

parcourir les pages

# La première page rend un curseur dans page.next_cursor.
curl -s --get http://localhost:4317/v1/api/traces \
  --data-urlencode 'q=refund' \
  --data-urlencode 'sort=cost' \
  --data-urlencode 'limit=100'

# La page suivante : mêmes filtres, même tri, plus le curseur.
curl -s --get http://localhost:4317/v1/api/traces \
  --data-urlencode 'q=refund' \
  --data-urlencode 'sort=cost' \
  --data-urlencode 'limit=100' \
  --data-urlencode 'cursor=MzEzNzQyfDBhZjM...'

Une fois l’exécution trouvée

Un identifiant de trace issu de n’importe laquelle de ces requêtes va à GET /v1/api/traces/{trace_id}, qui rend l’exécution et tous ses spans, déjà ordonnés pour qu’une cascade se dessine sans seconde requête. Ajoutez include=logs,scores,feedback pour obtenir dans la même réponse tout ce qui y est rattaché.

À partir de là, Comptabilité des coûts explique les chiffres portés par chaque étape, et la référence de l’API HTTP liste tous les endpoints et tous les champs tels que la spécification les déclare.