Scores et retours
Un score est ce qu’une machine a pensé. Un retour est ce qu’une personne a pensé. mcpd en fait deux signaux et ne fond jamais l’un dans l’autre par une moyenne.
Deux signaux, pas un chiffre
Un score est une évaluation automatique : un modèle juge, une expression régulière, un test unitaire, tout ce qui peut produire un nombre au sujet d’une exécution. Un enregistrement de retour est le jugement d’une personne : un pouce vers le bas dans votre produit, la note d’un relecteur, une réponse corrigée.
Les deux se rattachent à un trace_id ou à un
span_id, les deux arrivent comme des signaux à part entière, et
chacun est stocké dans sa propre table. Ils ne sont
jamais combinés. Un juge qui note 0.9 en pertinence et un client qui appuie
sur le pouce vers le bas disent tous les deux quelque chose de vrai, et leur
désaccord est ce qu’il y a de plus utile sur l’exécution. Une moyenne
effacerait précisément cela et vous laisserait un nombre sur lequel personne
ne peut agir.
-
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 score déclare son échelle
min et max font partie du score. Sans eux, une
valeur ne s’interprète pas : 0.86 peut être un bon résultat sur une échelle
de 0 à 1, un mauvais sur une échelle de 0 à 5, ou une distance où plus bas
vaut mieux. Six mois plus tard, avec deux scorers dans le même projet,
personne ne peut trancher. La spécification leur donne 0 et 1 par défaut, et
chaque SDK les exige à l’appel pour qu’une valeur par défaut ne soit jamais
acceptée par accident.
| Champ | À quoi il sert |
|---|---|
scorer_id | Quel scorer a produit ce score, par exemple answer_relevance. Obligatoire. |
scorer_version | Un score ne se compare qu’à un autre score de la même version. Changez le prompt d’un juge et vous avez un nouveau scorer, pas la suite de l’ancien. |
value | Le nombre. |
min, max | L’échelle déclarée sur laquelle le nombre se place. |
label | Un résultat catégoriel, quand le scorer en produit un à côté du nombre. |
reason | Pourquoi. Pour un modèle juge, c’est son explication, et c’est ce qui rend un score discutable plutôt que magique. |
source | Live, offline, experiment ou manual. |
score_trace_id | La trace de l’exécution de notation elle-même, pour qu’un score suspect s’ouvre et se lise comme n’importe quelle autre exécution. |
cost | Ce que la production du score a coûté. |
En enregistrer un
La fonction utilitaire du SDK met un score en file d’attente comme elle y met
un span : l’appel ne bloque pas et ne peut pas lever d’exception. Sans
traceId, il se rattache à la trace du span actif.
score et retour
// 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\" }
}"
En HTTP, c’est POST /v1/api/scores, avec un
Score pour corps. Un score doit nommer le
trace_id ou le span_id qu’il concerne ; un score
rattaché à rien est refusé par un 400 plutôt que stocké là où on ne le
retrouvera jamais. Envoyez score_id comme chaîne vide et le
serveur en génère un.
D’où vient un score
source est distinct de scorer_id parce que le même
scorer tourne dans plusieurs contextes, et que ces contextes ne se comparent
pas. Renoter le trafic de la semaine dernière avec le juge d’aujourd’hui ne
prouve pas que la qualité s’est améliorée.
| Source | Signification |
|---|---|
live | Produit en ligne, pendant que l’exécution tracée avait lieu. |
offline | Produit plus tard, en notant des traces déjà stockées. |
experiment | Produit par une expérience lancée sur un dataset. |
manual | Saisi par une personne, par l’API ou par l’interface. |
Un score écrit par un SDK vaut live par défaut. Réglez-le
vous-même pour les trois autres.
Noter coûte de l’argent, donc c’est enregistré
Une évaluation par juge est en général un autre appel de modèle. Noter
chaque exécution de production avec un gros juge peut coûter plus cher que
n’a coûté le trafic servi, et c’est une facture que l’on découvre tard. Un
Score porte donc un cost de la même forme que
celui d’un span, avec une devise, une répartition entre entrée et sortie, et
la grille tarifaire qui a produit le chiffre. Renseignez-le quand vous écrivez le score
par l’API HTTP ou par un lot d’ingestion natif.
score_trace_id est l’autre moitié de la même idée. Tracez
l’exécution de notation elle-même et son identifiant se pose sur le score,
de sorte qu’un juge qui s’est mis à tout dévaluer peut être ouvert, lu, et
montré comme défectueux.
Les retours humains
Un retour s’écrit depuis votre propre produit. Appelez l’endpoint depuis le
contrôle sur lequel la personne a réellement appuyé et le jugement se pose
sur la trace exacte qui a produit la réponse à laquelle elle réagissait.
kind décide de la forme de value.
| Type | value |
|---|---|
thumbs | up ou down. |
rating | Un nombre, sur une échelle que le producteur déclare. |
correction | La bonne réponse, en texte. |
tag | Une étiquette choisie dans un ensemble. |
comment | Du texte libre, sans score attaché. |
un pouce vers le bas depuis votre produit
// Sans traceId : il se rattache à la trace du span actif.
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\" }
}"
Le masquage s’applique aux noms de clés, et il passe sur les
metadata que vous attachez à un jugement. author_id
est stocké tel que vous l’envoyez : envoyez donc un identifiant pseudonyme
plutôt qu’une adresse e-mail. Voir
Masquage et conservation.
La requête qui trouve les mauvaises réponses
C’est à cela que servent les scores. has_score nomme un scorer
et score_below place la barre ; la liste des traces renvoie les
exécutions que ce scorer a mises en dessous. La comparaison est stricte,
donc score_below=0.5 exclut une exécution notée exactement 0.5.
trouver les exécutions mal notées
// 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 ne veut dire quelque chose qu’avec
has_score à côté : une barre sans scorer nommé est une barre
sur rien. Les deux sont des paramètres de
GET /v1/api/traces, donc ils se combinent avec tous les autres
filtres de cet endpoint. Les mauvaises réponses d’un modèle donné, en
production, sur la dernière journée : une seule requête.
Les relire
GET /v1/api/scores liste les évaluations automatiques, filtrées
par scorer_id, source, trace_id,
experiment_id, min_value, max_value
et une fenêtre temporelle, avec pagination par curseur. source
accepte live, offline, experiment ou
manual, ce qui vous permet de relire les scores du juge sans
ceux qu’une personne a saisis.
GET /v1/api/feedback prend un trace_id et renvoie
les jugements portés sur cette exécution. Lister les retours d’un projet
sans nommer d’exécution n’est pas implémenté dans cette version, et
l’endpoint le dit : sans trace_id, il refuse par un 400 qui
nomme le paramètre. Il répondait autrefois une liste vide, ce qui se lit
comme si personne n’avait rien jugé.
La liste des scores n’accepte pas order : elle va du plus
récent au plus ancien, et son curseur ne compare que dans un sens. Les
listes d’exécutions et de spans en acceptent un, donc il est ici refusé
nommément plutôt qu’accepté puis ignoré. Il en va de même pour les filtres
kind, from, to et
cursor que plusieurs clients SDK vous laissent poser sur une
requête de retours. Aucun n’a jamais été appliqué, et les quatre sont
désormais refusés.
Pour lire une exécution et tout ce qui y est attaché en une fois, demandez
les suppléments par leur nom :
GET /v1/api/traces/{trace_id}?include=scores,feedback,
ou include=all pour avoir aussi les logs.
Ce que porte la liste des traces, et ce qu’elle ne porte pas
Chaque trace stockée conserve deux agrégats pour que la liste n’ait besoin
d’aucune jointure. Le premier est la valeur moyenne par scorer :
une moyenne à l’intérieur d’answer_relevance, et une moyenne
distincte à l’intérieur de groundedness. Le second est un
décompte des retours par type : trois pouces, une correction. Des
décomptes, pas une moyenne, parce que la moyenne d’un pouce levé et d’une
correction n’est pas une quantité.
Il n’y a pas de score de qualité global, et il n’y en aura pas. Un chiffre unique combinant un modèle juge, une expression régulière et le pouce baissé d’un client serait un nombre sans unité et sans prise pour le contester. mcpd vous montre les pièces et vous laisse décider.
Ensuite
- Tout ce sur quoi vous pouvez filtrer par ailleurs : Retrouver une exécution.
- De quoi est fait un chiffre de coût : Comptabilité des coûts.
- Ce qui arrive à un identifiant d’auteur avant d’être stocké : Masquage et conservation.