回归测试
周二坏掉的那次运行,会变成之后每一个版本都必须通过的用例。
这一切要闭合的那个环
一个智能体在一个真实问题上失败了。有人手工复现了它,改了一句提示词,看起来 好些了。三周之后它在同一个问题上又失败了,因为没有任何东西把这个问题留下来。
数据集会把它留下来。把失败的那次运行采集进去,它就不再是一则轶事,而变成一个 用例:有一份输入,在你写得出来的时候有一个期望答案,还有一个指回它来处的那条 trace 的指针。之后每一个版本都要拿它跑一遍。
采集一次运行
在界面里或者通过 API 找到那次运行,然后把它的 trace id 交给数据集。
把一次失败的运行采集成用例
curl -X POST localhost:4317/v1/api/datasets/$DATASET/capture \
-H 'content-type: application/json' \
-d '{"trace_ids": ["b3d9e71a4c0f428eb6a5d2317f90c684"]}'
采集下来的用例保留 source_trace_id,所以几个月之后你还能打开
当初促成它的那次运行,去读真正发生了什么,而不是从输入里猜。
- agent support_agent fail
- model chat
- tool returns_policy
- model chat
- tool returns_policy
- model chat
- tool returns_policy
The model called one tool three times and never answered. That is visible in four seconds here, and invisible in a log file.
运行自己的答案不是期望答案
use_output_as_expected 默认是关的,而这个默认值才是要紧的地方。
值得采集的运行,通常正是出了错的那些,把一个错答案记成参照,等于把这个缺陷
供起来:之后每一个版本都会按它复现这个错误的能力来衡量。
当你采集的是做对了的运行、想把一个你已经满意的行为固定下来时,就把它打开。 当你采集的是一次失败时,把它留在关上的状态,然后自己写出期望答案。
内置的评分器
mcpd 自带十三个确定性的评分器:就是普通代码,读一次存下来的运行,返回一个 数字。它们不花钱,从不调用模型,跑两遍给出同样的答案,而这正是一个回归测试 需要、一个裁判模型没有的性质。
| 评分器 | 什么时候算通过 |
|---|---|
answered | 这次运行给出了一个非空的答案。 |
no_errors | 这次运行里没有 span 失败。 |
exact_match | 答案与期望答案相等。 |
contains | 答案里含有一个必须出现的字符串。 |
json_valid | 答案能作为 JSON 解析。 |
latency_budget | 这次运行在一个以毫秒计的预算内跑完了。 |
cost_budget | 这次运行花的钱没有超过设定的数额。 |
no_tool_loop | 没有哪个工具被用同样的输入调用了两次。 |
tool_efficiency | 这次运行没有超出工具调用次数的预算。 |
not_a_refusal | 答案不是一次拒答,也不是一句道歉。 |
no_leaked_secret | 答案里没有出现密钥、令牌或凭据。 |
span_present | 这次运行记录下了要求的那种类型或名字的 span。 |
tool_succeeded | 要求的那个工具跑过了,并且至少有一次调用正常结束。 |
GET /v1/api/scorers 返回同一份清单,外加一个
needs_config 标志。一个需要期望答案却没拿到的评分器会弃权,
而不是去猜,并且这次弃权会被报出来。
让模型来打分的时候
有些问题,写多少确定性的代码都答不上来。一个答案有没有真的回应问题,一份 摘要有没有保住原意,一种语气对不对:这些都需要一位读者,而模型是便宜的 那位读者。
在你配置之前,裁判打分是关着的;而且除非你点名要,一个裁判评分器不会运行。 这两个默认值出于同一个原因:打分会为它读过的每一次运行花钱,而一个悄悄这么 做的功能,是那种你在账单上才发现的功能。
{
"judge": {
"endpoint": "http://localhost:11434/v1",
"model": "qwen2.5:14b",
"api_key": "",
"timeout_seconds": 30,
"criteria": {
"answer_relevance": "Does the answer address the question that was asked?",
"no_hedging": "Does the answer commit, rather than hedge?"
}
}
}
请求用的是 OpenAI chat-completions 的形状,这不是对某个厂商的偏好:Ollama、
vLLM、llama.cpp、LM Studio、OpenRouter 以及各家托管服务都接受它,所以同一份
配置既能连上你笔记本上的模型,也能连上生产环境里的网关。对一个不要密钥的
本地模型,把 api_key 留空。温度是零,因为一个每次答得都不一样的
裁判没法和它自己比较,而这正是评分唯一的用处。
准则是配置出来的,不是每次请求带上去的。一个评分器 id 会存在它产生的每一个 评分上,也是之后做比较时用来分组的东西,所以要是让每次调用都自己发明 id 和 问题,就会建起一列谁也不敢信的数据。
配置出来的准则不会出现在 GET /v1/api/scorers 里。那个端点只列
十三个内置评分器,别的什么都不列,所以一个照着它拼出评分器菜单的客户端,
不会把你的准则列出来。守护进程唯一一次把它们的名字报给你,是在你给出一个
未知评分器 id 时得到的那个错误里,它会把内置的和配置出来的准则一起列出来。
对最近一次运行跑一个裁判评分器
curl -X POST localhost:4317/v1/api/score \
-H 'content-type: application/json' \
-d '{"limit": 1, "scorers": ["answer_relevance"]}' timeout_seconds 是一次评分调用最多能花的时间,
从建立连接算到回复的最后一个字节:默认 30,范围 1 到 300。超过它的调用会弃权并写明这个键,
mcpd 最多只读回复的 1 MiB。
每个裁判评分器对每次运行就是一次模型调用,而请求必须在 --request-timeout-seconds 之内结束。
所以一次请求最多只评分这么多次运行:即使每次调用都用满 timeout_seconds,
也能在这个期限内完成。按默认的 30 和 30、一个裁判评分器来算,就是一次运行。
在 trace_ids 或 limit 里要求更多的请求,
会在调用任何模型之前,被一个写明上限的 400 拒绝。不写 limit,就按截止时间内能容纳的数量评最近的运行。
想一次评更多,就调高 --request-timeout-seconds。
每个裁判分数在模型一回答就保存,所以被中途截断的请求,也会保留已经完成的分数。
它宁可弃权,也不去猜
一个返回了并非自己推导出来的数字的裁判,比没有裁判更糟,因为它看起来像证据。
所以下面每一种情况都会带着理由写进 skipped,一个评分也不产生。
| 发生了什么 | mcpd 怎么做 |
|---|---|
| 这次运行没有记下答案 | 弃权。一次没有答案的运行不是在相关性上打得差,而是根本没有被衡量过。 |
| 模型用散文回话 | 弃权,并把回话原样引出来。包在代码围栏或一句话里的 JSON 仍然会被读出来。 |
| 模型答出了量程之外 | 弃权。一个满分为一的量程上的七不是十分之九,而截断它等于凭空造出一个数字。 |
| 连不上模型 | 弃权,并带上状态码和响应体。限流是一个理由,不是一个零分。 |
| 回复超过 1 MiB | 不读剩下的部分就弃权。一个裁决只有几百字节,那么大的回复不会是裁决。 |
| 请求的截止时间已不够这次调用 | 不调用模型就拒绝。这一轮评分会在截止时间内用已完成的分数作答,而不是返回 503。 |
每一个由裁判给出的评分,都把模型记为自己的 scorer_version。
来自两个不同裁判的两个评分不可比较,而这个字段就是在记录上把这件事说出来的
地方。
打分是事后进行的
POST /v1/api/score 对已经存下来的运行跑评分器。没有任何东西在
链路里同步评估。一个放在请求路径上的评估,会把自己的延迟加到它所评判的东西
上;而一个抛了异常的评估,绝不该让用户的请求失败。
给最近一百次运行打分
curl -X POST localhost:4317/v1/api/score \
-H 'content-type: application/json' \
-d '{"project": "support-desk", "limit": 100, "config": {"max_ms": 4000}}'
响应里带着它写下的那些评分,另外单独带着每一个弃权的评分器和它弃权的理由。
一个答不上来的评分器,比一个返回了数字的评分器更值得看,所以 mcpd 从不把它
咽下去。项目的接收上限也管到这道门:这一趟算出来、但会让项目越过
--quota-per-minute 的评分不会被存下,它会被列进
skipped 并说明原因,而不是越过上限写入,也不是悄悄丢掉。
发 "dry_run": true,就能看到这一趟会产生什么,而不存下任何东西。
一趟要读哪些运行,由 trace_ids、from 和
limit 决定。在 trace_ids 里点名,就正好给那些运行
打分。把它留空,这一趟就取项目里最新的 limit 次运行,最新的在
前,而 from 给这一页划出边界:带了 from 的一趟只读
在那个时刻或之后开始的运行,你就是这样在不列出任何 id 的情况下,给一次发布
以来的全部运行打分。
比较两个版本
为你想比较的每一个版本记一次实验,把它的评分打上那次实验的 id,然后向 mcpd 要它们的差别。
新的提示词有没有起作用
curl 'localhost:4317/v1/api/experiments/compare?baseline=v3&candidate=v4'
答案是按评分器给出的差值,两边各自带着样本量,还有一个
decidable 标志。观测数不到二十个时,mcpd 会说这次比较判定不了,
而不是报出一个变化。
这是一条故意做得很钝的规则,不是显著性检验。它挡住的是那个常见的失败:一个 在四次运行上动了的均值,不带次数就印了出来,被当成结果读,然后被发了出去。 把次数印在差值旁边,才是让噪声不被误当作发现的那一步。
这些跑在哪里
这一切都是 HTTP API,所以这个环属于 CI:失败一出现就采集下来,给测试套件
产生的运行打分,把这次构建注册成一次实验,并在一次可判定的比较返回
regressed 为真时让这个任务失败。