Vai al contenuto
mcpd
nessun daemon
Codice

Trovare un’esecuzione

Un’indagine comincia da una lamentela, non da una dashboard. Questa pagina spiega come trasformare una lamentela nell’unica esecuzione che l’ha provocata.

Cerca nel payload, non nell’etichetta

Quasi tutti gli strumenti di tracing indicizzano nomi e attributi. mcpd indicizza anche il prompt e il completion, perché quello di cui una persona si lamenta è una cosa che ha detto il modello, e le parole che ti ripete sono l’unica presa che hai. Il parametro q è quell’indice.

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

Che cosa copre q, per la precisione

L’indice tiene tre campi per span: il suo nome, il suo input e il suo output. Input e output sono JSON, quindi prima di indicizzare mcpd percorre il payload e ne tiene soltanto le stringhe e i numeri, scartando le parentesi graffe, le virgolette e le chiavi. Indicizzare il JSON grezzo farebbe di ogni segno di punteggiatura un token e seppellirebbe le parole che stai davvero cercando.

Sei conseguenze da conoscere prima di farci affidamento:

  • Un’esecuzione corrisponde quando corrisponde un suo span qualsiasi. Ti tornano indietro esecuzioni intere; aprine una per vedere di quale passo si trattava.
  • Le parole sono messe insieme in AND. q=refund delayed trova gli span che le contengono tutte e due, ovunque nel testo indicizzato, non una accanto all’altra.
  • La corrispondenza è a parola intera e non distingue maiuscole da minuscole, e i segni diacritici sono appiattiti, quindi resume trova résumé. Non c’è nessuno stemming: refund non trova refunds.
  • Nell’indice finiscono i primi 64 KiB dell’input e i primi 64 KiB dell’output. I payload più lunghi sono archiviati e mostrati per intero; soltanto la coda di un prompt molto grande resta fuori dalla ricerca.
  • L’oscuramento gira prima dello storage, quindi quello che ha tolto non è mai stato nemmeno indicizzato. Vedi Oscuramento e conservazione.
  • Uno span scritto più di una volta, com’è uno span di lunga durata quando finisce, sostituisce la propria riga nell’indice, così la ricerca mostra sempre la versione di adesso.

Una virgoletta di troppo è una ricerca, non un errore

L’indice che sta sotto ha un linguaggio di query suo: una virgoletta doppia apre una frase, e NEAR, OR e * sono operatori. Consegnargli il tuo testo così com’è vorrebbe dire che il messaggio di un cliente che contiene una virgoletta torna indietro come errore di sintassi invece che come risultato, e una casella di ricerca che può sollevare un errore è una casella di ricerca che la gente smette di usare.

Perciò il tuo testo non arriva mai a quel parser. mcpd divide quello che hai scritto sugli spazi, toglie da ogni parola qualunque virgoletta doppia, avvolge ogni parola come termine letterale e le unisce in AND. q=say "no" cerca le parole say e no. q=NEAR cerca la parola NEAR. Niente di quello che puoi scrivere è un errore di sintassi, e nessun operatore è a tua disposizione. È questo il compromesso, fatto in questa direzione di proposito.

Un q fatto di soli spazi viene scartato invece di non corrispondere a niente, e un q= vuoto è trattato come assente. Ogni parametro si comporta così, perché un modulo che manda anche le sue caselle di filtro vuote non finisca per filtrare sulla stringa vuota.

Restringere un elenco di esecuzioni

GET /v1/api/traces prende quello che segue. Quello che lasci fuori non viene applicato.

ParametroChe cosa seleziona
q Full text sul nome, sull’input e sull’output di ogni span.
status unset, ok, error o cancelled. Un’esecuzione è error quando un suo span qualsiasi è fallito, anche uno il cui errore è stato intercettato.
kind Il tipo dello span radice dell’esecuzione, per esempio agent_run o workflow_run.
name Il nome dello span radice, esatto. Non un prefisso e non uno schema.
tags Separati da virgola. Un’esecuzione deve portare tutti i tag elencati, non uno qualsiasi.
session_idOgni esecuzione di una stessa conversazione.
thread_idOgni esecuzione di uno stesso thread.
user_idOgni esecuzione per uno stesso utente finale.
environmentUn solo ambiente, per esempio production.
model Le esecuzioni con almeno uno span su questo id di modello.
from, to La finestra, misurata sull’ora di inizio dell’esecuzione.
min_duration_ms Le esecuzioni lente almeno così, in millisecondi.
min_cost Le esecuzioni che costano almeno questo. Un’esecuzione senza span valorizzati conta come zero, quindi qualunque valore positivo la esclude.
has_score Le esecuzioni che portano un punteggio di questo id di scorer.
score_below Insieme a has_score: le esecuzioni che quello scorer ha messo sotto questo valore.
sort start_time, duration, cost, span_count o score. Se non dici altro, start_time. score ordina le esecuzioni per il punteggio più basso che uno scorer qualsiasi ha dato loro, quindi in ordine crescente la peggiore viene per prima; un’esecuzione senza punteggio vale zero.
order asc o desc. Se non dici altro, desc.
limit La dimensione della pagina, tenuta fra 1 e 500. Se non dici altro, 50.
cursor Il cursore opaco della pagina precedente.
project Quale progetto leggere. Se non dici altro, il progetto predefinito del daemon, così un’installazione con un solo progetto non deve mai passarlo.

name, gli id di correlazione e environment sono test di uguaglianza, ed è questo che li rende leggeri. Se vuoi una corrispondenza parziale su un nome, usa q: anche i nomi degli span stanno nell’indice testuale.

Quanto costa una riga di elenco

Non c’è un secondo elenco di esecuzioni più leggero, perché il primo è già leggero. La proiezione archiviata di una traccia non porta nessun input, nessun output e nessun attributo: nomi, conteggi, tempi, costo, tag e correlazione, e niente di quello che ha detto un modello. È l’apertura di un’esecuzione a portarsi dietro il payload.

Misurato su un’esecuzione realistica, un agente di assistenza in otto passi che recupera tre documenti di policy, chiama due strumenti e fa due chiamate al modello con prompt e completion veri: la riga di elenco è 577 byte e l’esecuzione intera è 10.717 byte, cioè una riduzione del 94,6%. È questo daemon, su quell’esecuzione, sulla vera superficie HTTP. Le tue esecuzioni saranno diverse, e quello che conta è la direzione: il risparmio cresce con la dimensione dei prompt, perché la parte che cresce è la parte che l’elenco non porta.

La cifra è prodotta da un test invece di essere scritta qui a mano, così non può invecchiare in un posto senza far fallire l’altro. Esegui cargo test -p mcpd-server --test payload_size -- --nocapture e ti stampa tutti e due i conteggi di byte. Un campo aggiunto alla proiezione che si porti dietro del payload fa fallire quel test.

Tutto il resto è rifiutato, per nome

Quella tabella è tutto il vocabolario. Un parametro che non ci sta dentro è rifiutato con un 400 che lo nomina, invece di essere accettato e scartato. Questo è cambiato: mcpd ignorava un parametro che non conosceva, quindi ?statuss=error tornava 200 con ogni esecuzione del progetto e niente nella risposta diceva che il filtro non era stato applicato. Una risposta sbagliata ma credibile è peggio di un errore, perché non c’è niente da notare.

Vale la pena dire quale compromesso comporta. Un client che manda un filtro che un daemon più recente capisce ora prende un 400 da uno più vecchio, invece di un insieme di risultati più largo. È il migliore dei due guasti: il 400 nomina il parametro e si ferma.

Certi rifiuti dicono più di «sconosciuto». mcpd registra service_name, service_version, resource_id, run_id, request_id, experiment_id e release su ogni traccia, ma l’elenco delle esecuzioni non permette ancora di filtrarli. Dei sette, solo service_name è una faccetta della discovery, quindi la discovery elenca i suoi valori e nessuno degli altri. Quei sette sono rifiutati con un messaggio che lo dice, perché service_name=checkout-api non è un refuso, e dire a qualcuno che è un parametro sconosciuto lo manda a caccia di un errore di ortografia che non esiste.

La regola non è solo dell’elenco delle esecuzioni. GET /v1/api/spans, /v1/api/logs, /v1/api/scores, /v1/api/metrics e /v1/api/feedback fanno valere ciascuno il proprio vocabolario allo stesso modo. Vale la pena conoscerne due prima di incontrarli: l’elenco dei punteggi non prende order, perché va dal più recente e il suo cursore confronta in una sola direzione, e l’elenco dei feedback vuole un trace_id, perché mcpd non sa ancora elencare i feedback di un intero progetto e un elenco vuoto si leggerebbe come se non ce ne fosse nessuno.

Riempire una casella di filtro

Un filtro serve a qualcosa solo se sai che cosa scriverci dentro, e i valori non sono un elenco fisso: sono quello che i servizi che riferiscono a un progetto mandano di fatto. GET /v1/api/discovery risponde a questo. Chiedi una dimensione alla volta con facet, e ti restituisce i valori che quella dimensione ha preso nel progetto, ordinati per valore, ognuno con l’ultima volta che un segnale lo ha portato.

FacetChe cosa elenca
entity_kindAgent, workflow, tool, model, scorer, processor, pipeline o service.
entity_nameI nomi che quelle cose hanno, per esempio gli strumenti che sono stati chiamati.
service_nameI servizi che hanno riferito qualcosa.
environmentGli ambienti visti, per esempio production.
tagOgni tag che porti una qualunque esecuzione.
metric_nameI nomi di metrica registrati, compresi quelli che mcpd ricava.
metric_label_keyOgni chiave di etichetta vista nel progetto.
metric_label_valueI valori di una sola etichetta. Vuole key.

prefix restringe l’elenco per la digitazione assistita, e la corrispondenza è alla lettera e distingue maiuscole da minuscole, perché questi valori sono quelli con cui un filtro verrà confrontato e appiattirli qui vorrebbe dire offrirne uno che poi non corrisponde a niente. limit tiene la risposta entro 1000. I valori sono ordinati per valore e non per data, così un elenco che qualcuno sta leggendo non si rimescola sotto i suoi occhi fra un poll e l’altro.

C’è un limite che vale la pena conoscere prima di costruirci sopra. metric_label_key elenca ogni chiave di etichetta vista in qualunque punto del progetto, non le chiavi di una metrica con un nome preciso: l’elenco dei valori distinti è indicizzato per etichetta, non per la metrica che la portava.

Aggiornare un elenco senza rilanciarlo

GET /v1/api/deltas restituisce quello che è arrivato dopo un cursore, dal più vecchio, per traces, spans o logs. Tieni il cursore dell’ultimo poll, rimandalo indietro, e applica le righe all’elenco che hai già.

Un poll non è una pagina, e i due cursori non sono intercambiabili, di proposito. Sfogliare le pagine percorre all’indietro un insieme di risultati fisso, nell’ordinamento che hai scelto; fare polling va in avanti nell’ordine di arrivo e non finisce mai. Il watermark è l’ora di arrivo di una riga insieme al suo id, perché un batch scrive ogni sua riga con un solo timestamp, e un watermark capace di dire soltanto «dopo questo nanosecondo» o rigiocherebbe tutto il batch o ne salterebbe il resto. Un poll vuoto restituisce il cursore invariato: spostarlo in avanti farebbe cadere una riga che atterra un istante dopo con un’ora di arrivo precedente.

Su punteggi e feedback non si può fare polling. Portano il timestamp che ha mandato il chiamante e non hanno nessuna ora di arrivo messa dal server, quindi un watermark su di loro o salterebbe una riga in ritardo o ripeterebbe ogni riga per sempre. signal=scores è rifiutato, non risposto con una pagina vuota.

Che cosa sa fare questo deploy

mcpd ha un solo trait di storage e tre backend dietro di esso, e non sono uguali. Su ClickHouse una cancellazione viene accettata e applicata dopo che la chiamata ritorna, quindi i conteggi della sua risposta dicono che cosa corrispondeva e non che cosa ha già lasciato il disco. Su SQLite e Postgres le righe non ci sono più quando la chiamata ritorna. GET /v1/api/capabilities è il posto dove tutto questo è scritto: quale backend sta dietro il daemon, che cosa fa corrispondere q, se le maiuscole sono appiattite anche fuori da ASCII, se una cancellazione è su disco quando la chiamata ritorna, e se la discovery e il polling dei delta rispondono o no.

Leggilo una volta al momento della connessione e offri soltanto quello a cui il deploy risponde. Un endpoint la cui capacità è falsa rifiuta con 501 e con un corpo il cui campo capability nomina la stessa parola che pubblica l’insieme delle capacità, così i due non possono mai essere in disaccordo e un client che ha incontrato il rifiuto lo può andare a cercare invece di riprovare una cosa che non cambierà mai.

Tutti e tre i backend disponibili oggi rispondono sia alla discovery sia al polling dei delta, quindi il 501 qui sotto è la forma con cui rifiuterebbe un backend aggiunto più avanti, e non una risposta che vedrai da SQLite, da Postgres o da ClickHouse. L’insieme delle capacità è quello che ti dice di quale si tratta, e vale la pena leggerlo anche adesso: i tre differiscono sul fatto che una cancellazione sia su disco quando la chiamata ritorna, e su come viene fatta valere la conservazione. Tutti e tre appiattiscono le maiuscole anche fuori da ASCII.

su che cosa posso filtrare?

# Ogni ambiente che questo progetto ha riferito, per una casella di filtro.
curl -s --get http://localhost:4317/v1/api/discovery \
  --data-urlencode 'project=default' \
  --data-urlencode 'facet=environment'

# Digitazione assistita: i nomi di servizio che cominciano per "check".
curl -s --get http://localhost:4317/v1/api/discovery \
  --data-urlencode 'facet=service_name' \
  --data-urlencode 'prefix=check'

# I valori che prende un'etichetta di metrica.
curl -s --get http://localhost:4317/v1/api/discovery \
  --data-urlencode 'facet=metric_label_value' \
  --data-urlencode 'key=model'

Le finestre

from e to accettano un timestamp RFC 3339, oppure una forma breve relativa: un segno meno, un numero e una fra s, m, h e d. -15m, -1h e -7d funzionano tutte. La forma breve esiste perché scrivere a mano un timestamp ISO per guardare l’ultima ora è un attrito che nessuno dovrebbe pagare.

from è inclusivo e to è esclusivo, e tutti e due si confrontano con l’ora in cui l’esecuzione è partita. Un’esecuzione lunga che è cominciata prima della tua finestra ed era ancora in corso dentro di essa non viene restituita. Se stai dando la caccia a qualcosa che girava alle 09:00, allarga from all’indietro invece di stringerlo.

Un valore che non è né un timestamp né una durata è rifiutato per nome, invece che ignorato. Ogni parametro di ogni endpoint di elenco fallisce così, perché un filtro scartato in silenzio restituisce una risposta sbagliata ma credibile, e una risposta sbagliata ma credibile è peggio di un errore.

from=yesterday

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

La query che trova le risposte sbagliate

has_score=answer_relevance&score_below=0.5 restituisce ogni esecuzione che quello scorer ha messo sotto la metà. È questa la query da prendere quando la lamentela riguarda la qualità e non un guasto, perché una risposta sbagliata di solito sta dentro un’esecuzione riuscita.

has_score da solo restituisce ogni esecuzione che quello scorer ha guardato. score_below da solo non fa proprio niente: una soglia senza uno scorer attaccato non vuol dire niente, e mcpd dovrebbe indovinare quale scorer intendevi. Non indovina. Punteggi e feedback racconta da dove vengono quei valori.

Cercare passi invece di esecuzioni

GET /v1/api/spans cerca i singoli passi su tutte le esecuzioni in una volta sola. Usalo per le domande su un passo e non su una sessione: quale chiamata a uno strumento è lenta, quali chiamate a un modello hanno dato errore, quanto dura un recupero.

Prende q, kind, status, name, model, trace_id, from, to, min_duration_ms, sort, order, limit e cursor, con gli stessi significati e gli stessi valori predefiniti. Qui non c’è nessun filtro tags, session_id o user_id, perché quelli appartengono all’esecuzione, e tutti e tre sono rifiutati per nome invece che accettati e scartati.

sort prende qui meno valori che nell’elenco delle esecuzioni: start_time, duration o cost, e nient’altro. Uno span non porta nessun conteggio di span e nessun riepilogo dei punteggi, quindi span_count e score non hanno niente su cui ordinare e sono rifiutati per nome invece che ignorati.

Su un’esecuzione, model chiede se uno span qualsiasi lo ha usato. Su uno span, model è il modello di quello span. La stessa parola, due domande.

trovarla

# Le esecuzioni fallite dell'ultimo giorno che citano tutte e due le parole,
# ovunque in un nome di span, in un prompt o in un completion.
curl -s --get http://localhost:4317/v1/api/traces \
  --data-urlencode 'q=Leeds depot' \
  --data-urlencode 'status=error' \
  --data-urlencode 'from=-24h'

# Ogni passo che ha chiamato un modello ed è fallito, su tutte le
# esecuzioni in una volta sola.
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'

Le pagine, e perché il cursore è opaco

Un elenco restituisce al massimo limit righe e un oggetto page. page.next_cursor c’è quando c’è dell’altro, e page.has_more dice la stessa cosa come booleano.

Le pagine vanno a keyset, non a offset. Il cursore nomina l’ultima riga che ti è stata data, e la pagina dopo comincia subito dopo di essa. Le pagine a offset andrebbero alla deriva: le tracce arrivano di continuo, quindi quando chiedi le righe dalla 50 alla 99 la finestra si è già spostata sotto di te, e leggi due volte un’esecuzione mentre un’altra non compare mai. In un’indagine, l’esecuzione che ti sfugge è quella che stavi cercando.

Il cursore è una coppia, codificata in esadecimale, fatta del valore di ordinamento e dell’id della riga, e devi trattarlo come privo di significato. È opaco perché la codifica possa cambiare senza rompere ogni chiamante, e perché il valore che sta dentro è una quantità diversa per ogni ordinamento: nanosecondi per start_time e duration, un intero scalato per cost, un conteggio semplice per span_count, il punteggio più basso dell’esecuzione per score.

A questo è attaccata una regola. Rimanda indietro un cursore con gli stessi filtri, lo stesso sort e lo stesso order che lo hanno prodotto. mcpd non controlla che tu l’abbia fatto, e un cursore rigiocato su un ordinamento diverso si confronta con la colonna sbagliata e salta delle righe. Un cursore modificato o troncato è rifiutato con invalid_query e il messaggio malformed cursor.

Non c’è nessun totale. page.total è tenuto da parte per un backend che sappia contare le corrispondenze a poco prezzo, e lo storage SQLite integrato non lo riempie, quindi cicla su has_more invece di contare verso un numero.

scorrere le pagine

# La prima pagina restituisce un cursore in 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 pagina dopo: gli stessi filtri, lo stesso ordinamento, più il cursore.
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...'

Una volta che hai l’esecuzione

Un id di traccia preso da una qualunque di queste query va a GET /v1/api/traces/{trace_id}, che restituisce l’esecuzione e ogni span al suo interno, già ordinati perché un waterfall si disegni senza una seconda richiesta. Aggiungi include=logs,scores,feedback per avere nella stessa risposta tutto quello che le sta attaccato.

Da lì, Contabilità dei costi spiega le cifre su ogni passo, e il riferimento dell’API HTTP elenca ogni endpoint e ogni campo come li dichiara la specifica.