评分与反馈
一个评分是机器的看法。一条反馈是人的看法。mcpd 把它们当作两种信号保存,绝不把其中一个平均进另一个。
是两种信号,不是一个数字
一个评分是机器给出的评估:一个裁判模型、一个正则表达式、 一个单元测试,任何能对一次运行给出数字的东西。一条反馈 记录是人给出的判断:你的产品里的一次点踩、一位审核者的打分、一份改正过的 答案。
两者都挂在一个 trace_id 或一个 span_id 上,都作为
各自独立的信号到来,也都存在各自的表里。它们从不被合并。一个裁判模型在
相关性上打了 0.9,和一位客户按下点踩,是两句都为真的话,而它们之间的分歧
正是这次运行上最有用的东西。取平均恰好会把这一点抹掉,留给你一个谁也没法
据以行动的数字。
-
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
一个评分要声明自己的量程
min 和 max 是评分的一部分。没有它们,一个值就无法
解释:0.86 可能是 0 到 1 这个量程上的好结果,是 0 到 5 上的差结果,也可能是
一个越小越好的距离。半年之后,同一个项目里有两个评分器,谁也说不清是哪一种。
规范把它们默认为 0 和 1,而每个 SDK 都要求在调用处写出它们,这样一个默认值
就不会被不小心接受下来。
| 字段 | 用来做什么 |
|---|---|
scorer_id | 这是哪个评分器给出的,比如 answer_relevance。必填。 |
scorer_version | 一个评分只和同一版本的另一个评分比较。改了一个裁判模型的提示词,你得到的是一个新的评分器,而不是旧那个的延续。 |
value | 那个数字。 |
min、max | 这个数字所在的、已经声明出来的量程。 |
label | 一个分类结果,当评分器在数字之外还给出一个的时候。 |
reason | 为什么。对一个裁判模型来说这是它的解释,也正是这一项让评分可以被争论,而不是变成魔法。 |
source | 线上、离线、实验或人工。 |
score_trace_id | 打分这次运行本身的 trace,这样一个可疑的评分也能像别的运行一样被打开来读。 |
cost | 产生这个评分花了多少钱。 |
写入一个评分
SDK 的辅助方法把一个评分排进队列,和它排一个 span 的方式一样,所以这次调用
不会阻塞,也不会抛异常。不给 traceId 时,它挂在当前活动 span
所在的 trace 上。
写入评分与反馈
// 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\" }
}"
走 HTTP 就是 POST /v1/api/scores,请求体是一个
Score。一个评分必须说出它讲的是哪个 trace_id 或
哪个 span_id;一个什么都没挂上的评分会被 400 拒掉,而不是存到
一个再也找不到它的地方。把 score_id 发成空字符串,服务端会替你
生成一个。
一个评分是从哪里来的
source 和 scorer_id 是分开的,因为同一个评分器会在
不止一种场景下运行,而这些场景之间没有可比性。用今天的裁判模型重新给上周的
流量打一遍分,并不能证明质量变好了。
| 来源 | 含义 |
|---|---|
live | 在线产生,就在被记录的那次运行发生的时候。 |
offline | 之后产生,对存下来的 trace 打分。 |
experiment | 由一次针对数据集的实验运行产生。 |
manual | 由人通过 API 或界面录入。 |
通过 SDK 写入的评分默认是 live。另外三种要你自己设。
打分要花钱,所以它被记下来
一次由裁判模型做的评估,通常又是一次模型调用。用一个大裁判模型给每一次生产
运行打分,花的钱可能比服务这些流量还多,而这是一笔人们很晚才发现的账。所以
一个 Score 带着一个 cost,形状和一个 span 上的
一样:一种货币、输入与输出的拆分,以及产生这个数字的那份价目表。当你通过
HTTP API 或一批原生接收数据写入评分时,把它设上。
score_trace_id 是同一个想法的另一半。把打分这次运行本身也记录
下来,它的 id 就写在评分上,于是一个开始把什么都往低了打的裁判模型,可以被
打开、被读,也可以被指出它坏了。
人给出的反馈
反馈是从你自己的产品里写入的。就在那个人真正按下的控件里调用它,这份判断
就落在产生了他所回应的那个答案的那条 trace 上。kind 决定
value 的形状。
| 种类 | value |
|---|---|
thumbs | up 或 down。 |
rating | 一个数字,量程由写入它的一方声明。 |
correction | 正确的答案,以文本形式给出。 |
tag | 从一个集合里选出来的一个标签。 |
comment | 自由的文字,不带任何评分。 |
来自你自己产品的一次点踩
// 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\" }
}"
脱敏按键名匹配,它会跑过你挂在一份判断上的 metadata。
author_id 按你发来的样子存下,所以请发一个假名 id,不要发电子
邮件地址。参见脱敏与保留期。
找出糟糕回答的那个查询
评分就是为这个而存在的。has_score 指定一个评分器,
score_below 划一条线,trace 列表就把那个评分器打到这条线以下的
运行返回给你。这个比较是严格的,所以 score_below=0.5 不包含
正好打了 0.5 的那次运行。
找出打了低分的那些运行
// 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 只有旁边有 has_score 时才有意义:
一条不挂在任何评分器上的线,是划在虚空里的线。两者都是
GET /v1/api/traces 的参数,所以它们能和那里的每一个别的过滤
条件组合起来。某一个模型在生产环境里、最近一天内给出的糟糕回答,是一次请求。
把它们读回来
GET /v1/api/scores 列出机器给出的评估,可以按
scorer_id、source、trace_id、
experiment_id、min_value、max_value
和一个时间窗口过滤,并带游标分页。source 接受
live、offline、experiment 或
manual,你就是这样把裁判模型自己打的分读回来,而不带上人手
输入的那些。GET /v1/api/feedback 接受一个
trace_id,返回那次运行上的判断。不指定运行、只列一个项目的
反馈,在这个构建里还没有实现,端点也是这么说的:不给 trace_id
时,它用一个 400 拒掉,并点出这个参数的名字。它以前会答一份空列表,读起来
像是谁也没有判断过任何东西。
评分列表不接受 order:它是最新的在前,而它的游标只朝一个方向
比较。运行列表和 span 列表接受,所以这里是点着名字拒掉,而不是收下再丢掉。
kind、from、to 和
cursor 这几个过滤条件也一样,有几个 SDK 客户端会让你在一次
反馈查询上设它们。它们从来没有被应用过,现在四个都会被拒掉。
要一次读到一次运行和挂在它上面的所有东西,按名字把附加项要过来:
GET /v1/api/traces/{trace_id}?include=scores,feedback,
或者用 include=all 把日志也带上。
trace 列表带着什么,又不带什么
每一条存下来的 trace 都保留两个汇总值,这样列表不需要 join。一个是
按评分器算的均值:answer_relevance 内部的一个平均,
和 groundedness 内部另一个单独的平均。另一个是按种类
算的反馈条数:三条 thumbs,一条 correction。是计数,不是均值,因为一次点赞
和一次改正的平均不是一个量。
没有一个总体的质量分,以后也不会有。把一个裁判模型、一个正则表达式和一位 客户的点踩合成一个数字,那会是一个没有单位、也没法跟它争论的数。mcpd 把 各个部分摆给你看,让你自己判断。