Punteggi e feedback
Un punteggio è quello che ha pensato una macchina. Un feedback è quello che ha pensato una persona. mcpd li tiene come due segnali e non fa mai la media dell’uno nell’altro.
Due segnali, non un numero solo
Un punteggio è una valutazione automatica: un modello giudice, un’espressione regolare, uno unit test, qualunque cosa sappia produrre un numero su un’esecuzione. Un record di feedback è il giudizio di una persona: un pollice giù nel tuo prodotto, il voto di un revisore, una risposta corretta.
Tutti e due si attaccano a un trace_id o a uno
span_id, tutti e due arrivano come segnali a pieno titolo, e
tutti e due sono archiviati in una tabella propria. Non vengono mai
combinati. Un giudice che dà 0.9 di pertinenza e un cliente che preme
pollice giù dicono tutti e due il vero, e il disaccordo fra i due è la cosa
più utile che c’è sull’esecuzione. Una media cancellerebbe esattamente
quello, e ti lascerebbe un numero su cui nessuno può agire.
-
Spans
What ran, nested, with timings
-
Logs
Lines the run emitted
-
Metrics
Numbers, with exemplars back to a run
-
Scores
Machine evaluation
-
Feedback
What a person thought
-
Aspect notes
A sentence a model wrote about the run
Un punteggio dichiara la sua scala
min e max fanno parte del punteggio. Senza di loro
un valore non è interpretabile: 0.86 può essere un buon risultato su una
scala da 0 a 1, uno scarso su una scala da 0 a 5, oppure una distanza in cui
più basso è meglio. Sei mesi dopo, con due scorer nello stesso progetto,
nessuno sa più dire quale delle tre. La specifica li porta a 0 e 1 per
impostazione predefinita, e ogni SDK li richiede sul posto della chiamata,
così un valore predefinito non viene mai accettato per sbaglio.
| Campo | A che cosa serve |
|---|---|
scorer_id | Quale scorer lo ha prodotto, per esempio answer_relevance. Obbligatorio. |
scorer_version | Un punteggio si confronta solo con un altro punteggio della stessa versione. Cambia il prompt di un giudice e hai un nuovo scorer, non il seguito di quello vecchio. |
value | Il numero. |
min, max | La scala dichiarata su cui sta il numero. |
label | Un risultato categoriale, quando lo scorer ne produce uno accanto al numero. |
reason | Il perché. Per un modello giudice è la sua spiegazione, ed è quello che rende un punteggio discutibile invece che magico. |
source | Live, offline, esperimento o manuale. |
score_trace_id | La traccia dell’esecuzione che ha prodotto il punteggio, così un punteggio sospetto si apre e si legge come qualunque altra esecuzione. |
cost | Quanto è costato produrre il punteggio. |
Registrarne uno
L’helper dell’SDK accoda un punteggio nello stesso modo in cui accoda uno
span, quindi la chiamata non blocca e non può sollevare eccezioni. Senza un
traceId si attacca alla traccia dello span attivo.
punteggio e feedback
// A machine evaluation of the run. `min` and `max` are required: 0.86 on an
// undeclared scale cannot be compared to anything.
mcpd.score({
traceId,
scorerId: 'answer_relevance',
value: 0.86,
min: 0,
max: 1,
reason: 'Answered the question and gave the delivery date.',
})
// A human evaluation. Kept separate from a score, and never averaged with
// one, because the two answer different questions.
mcpd.feedback({
traceId,
kind: 'thumbs',
value: 'up',
comment: 'Exactly what the customer asked for.',
authorKind: 'end_user',
})
// Anything you already count can travel with the trace.
mcpd.metric('support.tickets_handled', 1, 'counter', { labels: { queue: 'tier-1' } }) # A machine evaluation. `min` and `max` are required: a bare number with no
# declared scale cannot be compared to anything.
mcpd.score(
"answer_relevance",
value=round(random.uniform(0.6, 0.95), 2),
min=0.0,
max=1.0,
trace_id=trace_id,
reason="named the order and gave a delivery window, without the live lookup",
)
# A human evaluation. Kept separate from a score, because a person's
# judgement and a model's must never be averaged together.
mcpd.feedback(
FeedbackKind.THUMBS,
"up",
trace_id=trace_id,
comment="fine given the outage",
author_kind="end_user",
) // A score is a machine judgement. The scale is not optional: a bare 0.9 with
// no declared range cannot be compared to anything.
mcpd.score(
Target::trace(&trace_id),
"answer_relevance",
0.9,
Scale::UNIT,
Some("Names the carrier and the delivery day the customer asked about."),
);
// Feedback is a person's judgement, kept separate from a score because the
// two answer different questions and must never be averaged together.
mcpd.feedback(Target::trace(&trace_id), FeedbackKind::Thumbs, "up", Some("fast and correct")); // A machine evaluation of the run. min and max are required: 0.86 means
// nothing until you know the scale it sits on.
m.Score(ctx, mcpd.TraceTarget(traceID), "answer_relevance", 0.86, 0, 1,
mcpd.ScoreReason("Answered the billing question and cited the invoice."),
mcpd.ScoreVersion("2"),
)
// A person's judgement of the same run. Wire this to your product's
// thumbs-up control and it lands on the exact trace that produced the
// answer.
m.Feedback(ctx, mcpd.TraceTarget(traceID), mcpd.FeedbackKindThumbs, "up",
mcpd.FeedbackComment("Fixed it first time."),
mcpd.FeedbackAuthor("u-317", "end_user"),
) // A machine evaluation. `min` and `max` are required: 7 is excellent out of
// 10 and poor out of 100, and a score with no scale cannot be compared.
mcpd.score(
traceId = traceId,
scorerId = "answer_relevance",
value = 0.86,
min = 0.0,
max = 1.0,
reason = "answered the question asked, without the delivery estimate",
)
// A person's judgement, from the thumbs-up control in your own product.
mcpd.feedback(
traceId = traceId,
kind = FeedbackKind.THUMBS,
value = "up",
comment = "fast and clear",
authorId = "customer-4477",
)
// Nothing above touched the network. This is where it goes.
mcpd.flush() # A machine evaluation. `min` and `max` are required: 7 is excellent out of 10
# and poor out of 100, and a score with no scale cannot be compared.
Mcpd.score(mcpd, trace_id, "answer_relevance", 0.86,
min: 0,
max: 1,
reason: "answered the question asked, without the delivery estimate"
)
# A person's judgement, from the thumbs-up control in your own product.
Mcpd.feedback(mcpd, trace_id, :thumbs, "up",
comment: "fast and clear",
author_id: "customer-4477"
)
# Nothing above touched the network. This is where it goes.
:ok = Mcpd.flush(mcpd) # Attach an evaluation. `min` and `max` are required: a bare number with no
# declared scale cannot be compared to anything.
curl -s -X POST "$MCPD/v1/api/scores" \
-H 'content-type: application/json' \
-d "{
\"score_id\": \"\",
\"trace_id\": \"$TRACE\",
\"scorer_id\": \"answer_relevance\",
\"source\": \"live\",
\"value\": 0.94,
\"min\": 0,
\"max\": 1,
\"reason\": \"Names the order and the depot.\",
\"timestamp\": \"$NOW\",
\"correlation\": { \"project\": \"default\" }
}"
Su HTTP è POST /v1/api/scores, che prende uno
Score come corpo. Un punteggio deve nominare il
trace_id o lo span_id di cui parla; un punteggio
attaccato a niente viene rifiutato con 400 invece di essere archiviato dove
non lo troverà mai nessuno. Manda score_id come stringa vuota e
il server ne genera uno.
Da dove viene un punteggio
source è separato da scorer_id perché lo stesso
scorer gira in più di un contesto, e i contesti non sono confrontabili.
Ridare un punteggio al traffico della settimana scorsa con il giudice di
oggi non è la prova che la qualità sia migliorata.
| Origine | Che cosa vuol dire |
|---|---|
live | Prodotto online, mentre l’esecuzione tracciata avveniva. |
offline | Prodotto dopo, dando un punteggio a tracce già archiviate. |
experiment | Prodotto da un esperimento eseguito su un dataset. |
manual | Inserito da una persona, tramite l’API o l’interfaccia. |
Un punteggio scritto attraverso un SDK vale live se non dici
altro. Per gli altri tre lo imposti tu.
Dare un punteggio costa, quindi viene registrato
Una valutazione giudicata di solito è un’altra chiamata al modello. Dare un
punteggio a ogni esecuzione di produzione con un giudice grande può costare
più di quanto sia costato servire il traffico, ed è una fattura che le
persone scoprono tardi. Uno Score porta quindi un
cost nella stessa forma in cui lo porta uno span, con una
valuta, una divisione fra input e output, e il listino che ha prodotto la
cifra. Impostalo quando scrivi il punteggio tramite l’API HTTP o un batch di
ingest nativo.
score_trace_id è l’altra metà della stessa idea. Traccia
l’esecuzione che produce il punteggio e l’id finisce sul punteggio, così un
giudice che ha cominciato a bocciare tutto si può aprire, leggere e mostrare
rotto.
Il giudizio delle persone
Il feedback si scrive dal tuo prodotto. Chiamalo dal controllo che la
persona ha davvero premuto e il giudizio atterra esattamente sulla traccia
che ha prodotto la risposta a cui stava reagendo. kind decide
la forma di value.
| Tipo | value |
|---|---|
thumbs | up oppure down. |
rating | Un numero, su una scala che dichiara chi lo produce. |
correction | La risposta giusta, come testo. |
tag | Un’etichetta scelta da un insieme. |
comment | Prosa libera, senza nessun punteggio attaccato. |
un pollice giù dal tuo prodotto
// Nessun traceId: si attacca alla traccia dello span attivo.
mcpd.feedback({
kind: 'thumbs',
value: 'down',
comment: 'Named the wrong depot.',
authorKind: 'end_user',
}) curl -s -X POST "$MCPD/v1/api/feedback" \
-H 'content-type: application/json' \
-d "{
\"feedback_id\": \"\",
\"trace_id\": \"$TRACE\",
\"kind\": \"thumbs\",
\"value\": \"down\",
\"comment\": \"Named the wrong depot.\",
\"author_kind\": \"end_user\",
\"timestamp\": \"$NOW\",
\"correlation\": { \"project\": \"default\" }
}"
L’oscuramento cerca corrispondenze sui nomi delle chiavi, e passa sopra i
metadata che attacchi a un giudizio. author_id
viene archiviato come lo mandi, quindi manda uno pseudonimo invece di un
indirizzo email. Vedi
Oscuramento e conservazione.
La query che trova le risposte sbagliate
È a questo che servono i punteggi. has_score nomina uno scorer
e score_below fissa l’asticella, e l’elenco delle tracce
restituisce le esecuzioni che quello scorer ha messo sotto. Il confronto è
stretto, quindi score_below=0.5 esclude un’esecuzione che ha
preso esattamente 0.5.
trovare le esecuzioni con un punteggio basso
// The same client reads. This is the query that finds bad answers: every run
// that a scorer put below the bar.
const suspect = await mcpd.listTraces({
hasScore: 'answer_relevance',
scoreBelow: 0.5,
limit: 10,
})
console.log(`runs scoring under 0.5: ${suspect.traces.length}`)
const recent = await mcpd.listTraces({ limit: 5, sort: 'start_time', order: 'desc' })
for (const trace of recent.traces) {
const seconds = ((trace.durationNs ?? 0) / 1e9).toFixed(2)
console.log(
`${trace.traceId.slice(0, 8)} ${trace.name.padEnd(16)} ${trace.status.padEnd(6)} ` +
`${seconds}s ${trace.spanCount} spans $${(trace.cost?.totalCost ?? 0).toFixed(6)}`,
)
}
// One run, with every span in it, ordered so a waterfall renders directly.
const detail = await mcpd.getTrace(traceId, { include: ['scores', 'feedback'] })
for (const span of detail.spans) {
console.log(` ${span.kind.padEnd(18)} ${span.name}`)
} # Nothing has been sent yet: recording appends to an in-memory queue and a
# background worker does the I/O. Flush before you read your own writes.
mcpd.flush()
detail = mcpd.get_trace(trace_id, include=["logs", "scores", "feedback"])
print(f"\n{detail.trace.name} {detail.trace.duration_ns / 1e6:.0f} ms "
f"{detail.trace.span_count} spans {detail.trace.error_count} errors")
for span in detail.spans:
cost = f" ${span.cost.total_cost:.6f}" if span.cost else ""
print(f" {span.kind.value:<18} {span.name:<14} {span.status.value}{cost}")
summary = mcpd.analytics_summary("-1h", "-0s")
print(f"\nlast hour traces={summary.trace_count} errors={summary.error_count} "
f"p95={summary.latency.p95:.0f}ms cost=${summary.total_cost:.4f}") // The same client reads. This is the trace you just wrote.
match mcpd.get_trace(&trace_id, Include::all()).await {
Ok(detail) => {
println!("{}: {} spans", detail.trace.name, detail.spans.len());
for span in &detail.spans {
println!(
" {:<22} {:>8} {}",
span.name,
span.duration_ms().map_or("open".to_owned(), |ms| format!("{ms:.1}ms")),
span.status,
);
}
if let Some(cost) = detail.trace.cost.as_ref().and_then(|c| c.total_cost) {
println!(" cost: {cost:.6} USD");
}
for score in detail.scores.iter().flatten() {
println!(" score {}: {}", score.scorer_id, score.value);
}
}
Err(e) => eprintln!("could not read the trace back: {e}"),
} // Recording is asynchronous, so wait for the batch to land before reading
// it back. Outside an example you would never need this.
if err := m.Flush(ctx); err != nil {
log.Printf("flush: %v", err)
}
detail, err := m.GetTrace(ctx, traceID, "scores", "feedback")
if err != nil {
log.Fatalf("read the run back: %v", err)
}
fmt.Printf("%s: %s in %s, %d spans\n",
detail.Trace.TraceID, detail.Trace.Status, duration(detail.Trace.DurationNs), len(detail.Spans))
for _, span := range detail.Spans {
fmt.Printf(" %-18s %-18s %s\n", span.Kind, span.Name, span.Status)
}
summary, err := m.AnalyticsSummary(ctx, time.Now().Add(-time.Hour), time.Now(), "model")
if err != nil {
log.Fatalf("read the summary: %v", err)
}
fmt.Printf("last hour: %d runs, %.1f%% failed, %.4f %s\n",
summary.TraceCount, summary.ErrorRate*100, summary.TotalCost, summary.Currency) val detail = mcpd.getTrace(traceId, include = listOf("scores", "feedback"))
println("${detail.trace.name}: ${detail.spans.size} spans, ${detail.trace.status}")
for (span in detail.spans) {
val ms = (span.durationNs ?: 0) / 1_000_000
println(" ${span.kind} ${span.name} ${ms}ms ${span.error?.message ?: ""}")
}
val summary = mcpd.analyticsSummary(
from = Clock.System.now() - 1.hours,
to = Clock.System.now(),
)
println(
"last hour: ${summary.traceCount} runs, " +
"${summary.errorCount} failed, ${summary.totalCost} USD",
) {:ok, detail} = Mcpd.get_trace(mcpd, trace_id, include: ["scores", "feedback"])
IO.puts("#{detail.trace.name}: #{length(detail.spans)} spans")
for span <- detail.spans do
ms = div(span.duration_ns || 0, 1_000_000)
error = (span.error && span.error.message) || ""
IO.puts(" #{span.kind} #{span.name} #{ms}ms #{error}")
end # Read it back. Any filter in the API works here.
curl -s "$MCPD/v1/api/traces?project=default&limit=10"
# The whole run, with its logs, scores and feedback in one request.
curl -s "$MCPD/v1/api/traces/$TRACE?include=all"
# Find the run by something the model actually said.
curl -s --get "$MCPD/v1/api/traces" \
--data-urlencode 'project=default' \
--data-urlencode 'q=Leeds depot'
# Find the runs a scorer marked down. This is the query that finds bad answers.
curl -s --get "$MCPD/v1/api/traces" \
--data-urlencode 'project=default' \
--data-urlencode 'has_score=answer_relevance' \
--data-urlencode 'score_below=0.5'
# Where the money went, over the last week, by model.
curl -s --get "$MCPD/v1/api/analytics/cost" \
--data-urlencode 'project=default' \
--data-urlencode 'from=-7d' \
--data-urlencode 'group_by=model' score_below vuol dire qualcosa solo con has_score
accanto: un’asticella senza nessuno scorer nominato è un’asticella su
niente. Sono tutti e due parametri di GET /v1/api/traces,
quindi si combinano con ogni altro filtro che c’è lì. Le risposte sbagliate
di un solo modello, in produzione, nell’ultimo giorno, sono una richiesta
sola.
Rileggerli
GET /v1/api/scores elenca le valutazioni automatiche, filtrate
per scorer_id, source, trace_id,
experiment_id, min_value, max_value
e una finestra temporale, con paginazione a cursore. source
prende live, offline, experiment o
manual, ed è così che rileggi i punteggi del giudice senza
quelli che ha digitato una persona.
GET /v1/api/feedback prende un trace_id e
restituisce i giudizi su quell’esecuzione. Elencare il feedback di un
progetto senza nominare un’esecuzione non è implementato in questa build, e
l’endpoint lo dice: senza trace_id rifiuta con un 400 che
nomina il parametro. Prima rispondeva con un elenco vuoto, che si legge come
se nessuno avesse giudicato niente.
L’elenco dei punteggi non prende nessun order: è dal più
recente, e il suo cursore confronta in un verso solo. Gli elenchi delle
esecuzioni e degli span invece uno lo prendono, quindi qui viene rifiutato
per nome invece che accettato e buttato via. Lo stesso vale per i filtri
kind, from, to e
cursor che diversi client SDK ti lasciano impostare su una
query di feedback. Nessuno dei quattro è mai stato applicato, e adesso tutti
e quattro vengono rifiutati.
Per leggere un’esecuzione e tutto quello che le è attaccato in una volta
sola, chiedi gli extra per nome:
GET /v1/api/traces/{trace_id}?include=scores,feedback,
oppure include=all per avere anche i log.
Che cosa porta l’elenco delle tracce, e che cosa no
Ogni traccia archiviata tiene due aggregati, così l’elenco non ha bisogno di
nessuna join. Uno è il valore medio per scorer: una media dentro
answer_relevance, e una media separata dentro
groundedness. L’altro è un conteggio del feedback
per tipo: tre pollici, una correzione. Conteggi, non una media,
perché la media di un pollice su e di una correzione non è una quantità.
Non c’è nessun punteggio complessivo di qualità, e non ci sarà. Una cifra sola che mette insieme un modello giudice, un’espressione regolare e il pollice giù di un cliente sarebbe un numero senza unità e senza nessun modo di discuterci. mcpd ti mostra le parti e ti lascia decidere.
Poi
- Tutto il resto su cui puoi filtrare: Trovare un’esecuzione.
- Di che cosa è fatta una cifra di costo: Contabilità dei costi.
- Che cosa succede all’id di un autore prima di essere archiviato: Oscuramento e conservazione.