Notas e feedback
Uma nota é o que uma máquina achou. Um feedback é o que uma pessoa achou. O mcpd guarda os dois como dois sinais e nunca dilui um na média do outro.
Dois sinais, não um número
Uma nota é uma avaliação automática: um modelo juiz, uma expressão regular, um teste unitário, qualquer coisa capaz de produzir um número sobre uma execução. Um registro de feedback é o julgamento de uma pessoa: um polegar para baixo dentro do seu produto, a avaliação de um revisor, uma resposta corrigida.
Os dois se prendem a um trace_id ou a um span_id,
os dois chegam como sinais por direito próprio, e cada um fica na sua
própria tabela. Eles nunca são combinados. Um juiz dando 0,9 de relevância
e um cliente apertando o polegar para baixo são duas afirmações
verdadeiras, e a discordância entre elas é a coisa mais útil da execução.
Uma média apagaria justamente isso, e deixaria você com um número sobre o
qual ninguém consegue 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
Uma nota declara a sua escala
min e max fazem parte da nota. Sem eles um valor
não é interpretável: 0,86 pode ser um bom resultado numa escala de 0 a 1,
um resultado ruim numa escala de 0 a 5, ou uma distância em que menor é
melhor. Seis meses depois, com dois avaliadores no mesmo projeto, ninguém
consegue dizer qual é qual. A especificação usa 0 e 1 como padrão, e todo
SDK exige os dois no ponto da chamada, para que um padrão nunca seja aceito
por acidente.
| Campo | Para que serve |
|---|---|
scorer_id | Qual avaliador produziu esta nota, por exemplo answer_relevance. Obrigatório. |
scorer_version | Uma nota só se compara com outra nota da mesma versão. Mude o prompt de um juiz e você tem um avaliador novo, não a continuação do antigo. |
value | O número. |
min, max | A escala declarada em que o número está. |
label | Um resultado categórico, quando o avaliador produz um ao lado do número. |
reason | Por quê. Para um modelo juiz é a explicação dele, e é o que torna uma nota discutível em vez de mágica. |
source | Live, offline, experiment ou manual. |
score_trace_id | O trace da própria execução de avaliação, para que uma nota suspeita possa ser aberta e lida como qualquer outra execução. |
cost | Quanto custou produzir a nota. |
Como registrar uma
O auxiliar do SDK enfileira uma nota do mesmo jeito que enfileira um span,
então a chamada não bloqueia e não pode lançar exceção. Sem nenhum
traceId informado, ela se prende ao trace do span ativo.
nota 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\" }
}"
Por HTTP isso é POST /v1/api/scores, recebendo um
Score no corpo. Uma nota precisa nomear o
trace_id ou o span_id a que ela se refere; uma
nota presa a nada é recusada com 400 em vez de ficar guardada onde nunca
será encontrada. Mande score_id como string vazia e o servidor
gera um.
De onde veio uma nota
source é separado de scorer_id porque o mesmo
avaliador roda em mais de um contexto, e os contextos não são comparáveis.
Reavaliar o tráfego da semana passada com o juiz de hoje não é prova de que
a qualidade melhorou.
| Origem | O que significa |
|---|---|
live | Produzida on-line, enquanto a execução rastreada acontecia. |
offline | Produzida depois, avaliando traces já guardados. |
experiment | Produzida por um experimento rodado contra um dataset. |
manual | Digitada por uma pessoa, pela API ou pela interface. |
Uma nota escrita por um SDK vem como live por padrão. Para as
outras três, defina você mesmo.
Avaliar custa dinheiro, então isso fica registrado
Uma avaliação por juiz costuma ser mais uma chamada de modelo. Avaliar toda
execução de produção com um juiz grande pode custar mais do que custou
atender o tráfego, e essa é uma conta que as pessoas descobrem tarde. Por
isso um Score carrega um cost no mesmo formato de
um span, com moeda, a divisão entre entrada e saída, e a tabela de preços
que produziu o número. Defina esse valor quando você escrever a nota pela
API HTTP ou por um lote de ingestão nativo.
score_trace_id é a outra metade da mesma ideia. Rastreie a
própria execução de avaliação e o id vai parar na nota, então um juiz que
começou a rebaixar tudo pode ser aberto, lido e mostrado como defeituoso.
Feedback humano
O feedback é escrito a partir do seu próprio produto. Chame-o a partir do
controle que a pessoa realmente apertou e o julgamento cai no trace exato
que produziu a resposta a que ela estava reagindo. O kind
decide o formato do value.
| Tipo | value |
|---|---|
thumbs | up ou down. |
rating | Um número, numa escala que quem produz declara. |
correction | A resposta certa, em texto. |
tag | Um rótulo escolhido de um conjunto. |
comment | Prosa livre, sem nota nenhuma junto. |
um polegar para baixo vindo do seu produto
// 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\" }
}"
A ocultação age sobre nomes de chave, e ele roda sobre os
metadata que você prende a um julgamento. O
author_id é guardado exatamente como você o manda, então mande
um id pseudônimo em vez de um endereço de e-mail. Veja
Ocultação e retenção.
A consulta que encontra respostas ruins
É para isto que servem as notas. has_score nomeia um avaliador
e score_below define a barra, e a lista de traces devolve as
execuções que aquele avaliador colocou abaixo dela. A comparação é estrita,
então score_below=0.5 deixa de fora uma execução que tirou
exatamente 0.5.
encontre as execuções que foram mal
// 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 só significa alguma coisa com
has_score ao lado: uma barra sem avaliador nomeado é uma barra
sobre nada. Os dois são parâmetros de GET /v1/api/traces,
então se combinam com todos os outros filtros de lá. Respostas ruins de um
modelo, em produção, no último dia, é uma requisição só.
Como ler tudo de volta
GET /v1/api/scores lista as avaliações automáticas, filtradas
por scorer_id, source, trace_id,
experiment_id, min_value, max_value
e uma janela de tempo, com paginação por cursor. source aceita
live, offline, experiment ou
manual, que é como você lê de volta as notas do próprio juiz
sem as que uma pessoa digitou.
GET /v1/api/feedback recebe um trace_id e devolve
os julgamentos daquela execução. Listar o feedback de um projeto sem nomear
uma execução não está implementado nesta versão, e o endpoint diz isso: sem
trace_id ele recusa com um 400 nomeando o parâmetro. Antes ele
respondia uma lista vazia, o que se lê como se ninguém tivesse julgado
nada.
A lista de notas não aceita order: ela vem da mais nova para a
mais antiga, e o cursor dela compara em um sentido só. As listas de
execuções e de spans aceitam, então aqui o parâmetro é recusado pelo nome
em vez de ser aceito e descartado. O mesmo vale para os filtros
kind, from, to e
cursor que vários clientes de SDK deixam você definir numa
consulta de feedback. Nenhum deles chegou a ser aplicado, e os quatro agora
são recusados.
Para ler uma execução e tudo que está preso a ela de uma vez, peça os
extras pelo nome: GET /v1/api/traces/{trace_id}?include=scores,feedback,
ou include=all para levar os logs junto.
O que a lista de traces carrega, e o que ela não carrega
Cada trace guardado mantém dois resumos, para que a lista não precise de
nenhum join. Um é o valor médio por avaliador: uma média dentro de
answer_relevance, e outra média separada dentro de
groundedness. O outro é uma contagem de feedback
por tipo: três polegares, uma correção. Contagens, e não uma
média, porque a média de um polegar para cima com uma correção não é uma
quantidade.
Não existe uma nota geral de qualidade, e não vai existir. Um único número somando um modelo juiz, uma expressão regular e o polegar para baixo de um cliente seria um número sem unidade e sem como discutir com ele. O mcpd mostra as partes e deixa a decisão com você.
A seguir
- Tudo o mais que dá para filtrar: Encontrar uma execução.
- De que é feito um número de custo: Apuração de custos.
- O que acontece com um id de autor antes de ele ser guardado: Ocultação e retenção.