スコアとフィードバック
スコアは機械が考えたことです。フィードバックは人が考えたことです。mcpd は両者を別のシグナルとして保ち、一方を他方に平均で溶かし込むことはありません。
1 つの数値ではなく、2 つのシグナル
スコアは機械による評価です。ジャッジモデル、正規表現、 ユニットテストなど、実行について数値を出せるものすべてが含まれます。 フィードバックのレコードは人の判断です。あなたの製品で押された 低評価、レビュアーの採点、訂正された回答などです。
どちらも trace_id または span_id に紐づき、
どちらもそれ自体が 1 つのシグナルとして届き、どちらも専用のテーブルに保存されます。
両者が合成されることはありません。関連性に 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 の尺度なら悪い結果、
低いほど良い距離ならまた別の意味になります。半年後、同じプロジェクトにスコアラーが
2 つあれば、どちらの尺度なのか誰にも分かりません。仕様は既定値を 0 と 1 としていますが、
既定値が事故で受け入れられることのないよう、どの SDK も呼び出し側での指定を必須にしています。
| フィールド | 何のためのものか |
|---|---|
scorer_id | これを出したスコアラー。たとえば answer_relevance。必須です。 |
scorer_version | スコアを比べられる相手は、同じバージョンから出たスコアだけです。ジャッジのプロンプトを変えたなら、それは古いものの続きではなく新しいスコアラーです。 |
value | 数値そのもの。 |
min、max | その数値が乗る、宣言された尺度。 |
label | スコアラーが数値と併せてカテゴリ的な結果も出す場合の、その結果。 |
reason | 理由。ジャッジモデルならその説明であり、スコアを魔法ではなく議論できるものにしている部分です。 |
source | live、offline、experiment、manual のいずれか。 |
score_trace_id | スコアリングの実行そのもののトレース。疑わしいスコアも、他の実行と同じように開いて読めます。 |
cost | そのスコアを出すのにかかった費用。 |
スコアを 1 つ記録する
SDK のヘルパーはスパンと同じやり方でスコアをキューに入れます。そのため呼び出しは
ブロックせず、例外も投げません。traceId を渡さなければ、
アクティブなスパンのトレースに紐づきます。
スコアとフィードバック
// 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 | 保存済みのトレースを後から採点して出されたもの。 |
experiment | データセットに対する実験の実行で出されたもの。 |
manual | API または画面から人が入力したもの。 |
SDK 経由で書かれたスコアの既定値は live です。
残りの 3 つは自分で設定してください。
スコアリングには費用がかかるので、記録します
ジャッジによる評価は、たいていもう一度モデルを呼ぶことです。本番の実行すべてを
大きなジャッジで採点すると、トラフィックを処理するより高くつくことがあり、
その請求は気づくのが遅れがちです。そのため Score は、
スパンと同じ形の cost を持ちます。通貨、入力と出力の内訳、
そしてその金額を出した価格表が入ります。HTTP API またはネイティブの取り込み
バッチでスコアを書くときに設定してください。
score_trace_id は同じ考えのもう半分です。スコアリングの実行そのものを
トレースすれば、その id がスコアに載ります。何もかも低く付け始めたジャッジを開いて読み、
壊れていることを示せます。
人によるフィードバック
フィードバックはあなたの製品から書き込みます。その人が実際に押したコントロールから
呼び出せば、反応のもとになった回答を出したまさにそのトレースに判断が届きます。
kind が value の形を決めます。
| 種別 | value |
|---|---|
thumbs | up または down。 |
rating | 数値。尺度は書き込む側が宣言します。 |
correction | 正しい回答を、テキストで。 |
tag | 決められた集合から選んだラベル。 |
comment | スコアを伴わない自由記述。 |
あなたの製品からの低評価
// traceId なし。アクティブなスパンのトレースに紐づきます。
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 で基準を決めると、トレース一覧はそのスコアラーが
基準より下に付けた実行を返します。比較は厳密なので、
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 のパラメーターなので、そこにある他のすべての
フィルターと組み合わせられます。特定のモデルによる、本番の、直近 1 日の悪い回答は、
リクエスト 1 つです。
読み出す
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 を取りません。新しい順に固定で、そのカーソルは
一方向にしか比較しません。実行一覧とスパン一覧は取るので、ここでは黙って受け取って
捨てるのではなく、名前を挙げて拒否します。同じことが、いくつかの SDK クライアントが
フィードバックのクエリに設定させてしまう kind、from、
to、cursor のフィルターにも当てはまります。
4 つとも一度も適用されたことがなく、現在は 4 つとも拒否されます。
実行とそれに紐づくものを一度に読むには、追加分を名前で指定してください。
GET /v1/api/traces/{trace_id}?include=scores,feedback、
ログも欲しいなら include=all です。
トレース一覧が持つもの、持たないもの
保存された各トレースは、結合が要らないように 2 つのロールアップを持ちます。
1 つはスコアラーごとの平均値です。answer_relevance の中での平均と、
groundedness の中での平均は別のものです。もう 1 つは種別ごとの
フィードバックの件数です。thumbs が 3 件、correction が 1 件、というように。
平均ではなく件数なのは、thumbs-up と correction の平均が量として成り立たないからです。
全体的な品質スコアはありませんし、これから作ることもありません。ジャッジモデルと 正規表現と利用者の低評価を 1 つにまとめた数値は、単位を持たず、反論のしようもない数値です。 mcpd は部品を見せ、判断はあなたに任せます。