Puntuaciones y feedback
Una puntuación es lo que pensó una máquina. El feedback es lo que pensó una persona. mcpd las guarda como dos señales distintas y nunca promedia una con la otra.
Dos señales, no un número
Una puntuación es una evaluación automática: un modelo juez, una expresión regular, una prueba unitaria, cualquier cosa capaz de producir un número sobre una ejecución. Un registro de feedback es el juicio de una persona: un pulgar abajo en tu producto, la valoración de un revisor, una respuesta corregida.
Ambos se enganchan a un trace_id o a un span_id,
ambos llegan como señales por derecho propio y cada uno se guarda en su
propia tabla. Nunca se combinan. Un juez que puntúa 0.9 en relevancia y un
cliente que pulsa el pulgar abajo son dos afirmaciones ciertas, y el
desacuerdo entre ellas es lo más útil que hay en esa ejecución. Un promedio
borraría justo eso y te dejaría con un número sobre el que nadie puede
actuar.
-
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
Una puntuación declara su escala
min y max forman parte de la puntuación. Sin
ellos el valor no se puede interpretar: 0.86 puede ser un buen resultado en
una escala de 0 a 1, uno malo en una escala de 0 a 5, o una distancia donde
menos es mejor. Seis meses después, con dos evaluadores en el mismo
proyecto, nadie sabe cuál era. La especificación les da 0 y 1 por defecto, y
todos los SDK los exigen en el punto de llamada para que ese valor por
defecto nunca se acepte por descuido.
| Campo | Para qué sirve |
|---|---|
scorer_id | Qué evaluador la produjo, por ejemplo answer_relevance. Obligatorio. |
scorer_version | Una puntuación solo se compara con otra de la misma versión. Si cambias el prompt de un juez tienes un evaluador nuevo, no la continuación del anterior. |
value | El número. |
min, max | La escala declarada sobre la que se sitúa el número. |
label | Un resultado categórico, cuando el evaluador produce uno junto al número. |
reason | El porqué. En un modelo juez es su explicación, y es lo que convierte una puntuación en algo discutible en lugar de en magia. |
source | En vivo, fuera de línea, de experimento o manual. |
score_trace_id | La traza de la propia ejecución de puntuación, para poder abrir y leer una puntuación sospechosa como cualquier otra ejecución. |
cost | Lo que costó producir la puntuación. |
Cómo registrar una
El ayudante del SDK encola una puntuación igual que encola un span, así que
la llamada no bloquea y no puede lanzar una excepción. Si no le pasas
traceId, se engancha a la traza del span activo.
puntuación y 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\" }
}"
Por HTTP eso es POST /v1/api/scores, con un Score
como cuerpo. Una puntuación tiene que nombrar el trace_id o el
span_id del que habla; una puntuación enganchada a nada se
rechaza con un 400 en vez de guardarse donde nunca se podrá encontrar. Envía
score_id como cadena vacía y el servidor genera uno.
De dónde viene una puntuación
source es distinto de scorer_id porque el mismo
evaluador se ejecuta en más de un contexto, y los contextos no son
comparables. Volver a puntuar el tráfico de la semana pasada con el juez de
hoy no es prueba de que la calidad haya mejorado.
| Origen | Significado |
|---|---|
live | Producida en línea, mientras ocurría la ejecución trazada. |
offline | Producida después, puntuando trazas ya guardadas. |
experiment | Producida por un experimento ejecutado contra un dataset. |
manual | Introducida por una persona, mediante la API o la interfaz. |
Una puntuación escrita a través de un SDK toma live por
defecto. Para los otros tres valores, ponlo tú.
Puntuar cuesta dinero, así que queda registrado
Una evaluación con juez suele ser otra llamada a un modelo. Puntuar cada
ejecución de producción con un juez grande puede costar más de lo que costó
servir el tráfico, y esa es una factura que la gente descubre tarde. Por eso
un Score lleva un cost con la misma forma que el de
un span: una moneda, el reparto entre entrada y salida, y el libro de
precios que produjo la cifra. Ponlo cuando escribas la puntuación por la API
HTTP o en un lote de ingesta nativo.
score_trace_id es la otra mitad de la misma idea. Traza la
propia ejecución de puntuación y su identificador queda en la puntuación,
así que un juez que empezó a bajarlo todo se puede abrir, leer y demostrar
que está roto.
Feedback humano
El feedback se escribe desde tu propio producto. Llámalo desde el control que
la persona pulsó de verdad y el juicio aterriza en la traza exacta que
produjo la respuesta a la que estaba reaccionando. kind decide
la forma de value.
| Tipo | value |
|---|---|
thumbs | up o down. |
rating | Un número, en una escala que declara quien lo produce. |
correction | La respuesta correcta, como texto. |
tag | Una etiqueta elegida de un conjunto. |
comment | Texto libre, sin puntuación asociada. |
un pulgar abajo desde tu producto
// No traceId: it attaches to the active span's trace.
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\" }
}"
La ocultación actúa sobre los nombres de las claves, y se aplica a los
metadata que adjuntas a un juicio. author_id se
guarda tal como lo envías, así que envía un identificador seudónimo en lugar
de una dirección de correo. Consulta
Ocultación y retención.
La consulta que encuentra respuestas malas
Para esto sirven las puntuaciones. has_score nombra un evaluador
y score_below pone el listón, y la lista de trazas devuelve las
ejecuciones que ese evaluador dejó por debajo. La comparación es estricta,
así que score_below=0.5 excluye una ejecución que puntuó
exactamente 0.5.
encuentra las ejecuciones con mala puntuación
// 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 solo significa algo con has_score al
lado: un listón sin evaluador nombrado es un listón sobre nada. Los dos son
parámetros de GET /v1/api/traces, así que se combinan con
cualquier otro filtro de ahí. Las respuestas malas de un modelo, en
producción, en el último día, son una sola solicitud.
Cómo volver a leerlas
GET /v1/api/scores lista las evaluaciones automáticas,
filtradas por scorer_id, source,
trace_id, experiment_id, min_value,
max_value y una ventana de tiempo, con paginación por cursor.
source acepta live, offline,
experiment o manual, que es como lees las
puntuaciones del propio juez sin las que escribió una persona.
GET /v1/api/feedback toma un trace_id y devuelve
los juicios sobre esa ejecución. Listar el feedback de un proyecto sin
nombrar una ejecución no está implementado en esta versión, y el endpoint lo
dice: sin trace_id rechaza con un 400 que nombra el parámetro.
Antes respondía una lista vacía, que se lee como si nadie hubiera juzgado
nada.
La lista de puntuaciones no acepta order: va de más reciente a
más antigua, y su cursor solo compara en un sentido. Las listas de
ejecuciones y de spans sí lo aceptan, así que aquí se rechaza por su nombre
en vez de aceptarse y descartarse. Lo mismo vale para los filtros
kind, from, to y cursor
que varios clientes de SDK te dejan poner en una consulta de feedback.
Ninguno se aplicó nunca, y los cuatro ahora se rechazan.
Para leer de una vez una ejecución y todo lo que cuelga de ella, pide los
extras por su nombre:
GET /v1/api/traces/{trace_id}?include=scores,feedback, o
include=all para incluir también los logs.
Qué lleva la lista de trazas, y qué no
Cada traza guardada conserva dos agregados para que la lista no necesite
ningún join. Uno es el valor medio por evaluador: una media dentro
de answer_relevance, y otra media aparte dentro de
groundedness. El otro es un recuento de feedback por
tipo: tres pulgares, una corrección. Recuentos, no una media, porque la
media de un pulgar arriba y una corrección no es ninguna cantidad.
No hay una puntuación global de calidad, y no la habrá. Una sola cifra que combinara un modelo juez, una expresión regular y el pulgar abajo de un cliente sería un número sin unidad y sin manera de rebatirlo. mcpd te enseña las piezas y te deja decidir a ti.
A continuación
- Todo lo demás por lo que puedes filtrar: Encontrar una ejecución.
- De qué está hecha una cifra de costo: Contabilidad de costos.
- Qué le pasa al identificador de un autor antes de guardarse: Ocultación y retención.