跳到主要内容
mcpd
无守护进程
源码

评分与反馈

一个评分是机器的看法。一条反馈是人的看法。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

trace_id Store Search Export

一个评分要声明自己的量程

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' } })

走 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
thumbsup 或 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',
})

脱敏按键名匹配,它会跑过你挂在一份判断上的 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}`)
}

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 把 各个部分摆给你看,让你自己判断。

接下来