回帰テスト
火曜日に壊れたあの実行が、以降のすべての版が通らなければならないケースになります。
閉じたいのはこのループです
エージェントが実際の質問で失敗します。誰かが手で再現し、プロンプトを直し、 良くなったように見えます。3 週間後、同じ質問でまた失敗します。 それを残しておいたものが何もなかったからです。
データセットがそれを残します。失敗した実行を取り込めば、それは逸話であることをやめ、 入力と、分かっていれば期待される回答と、元になったトレースへの参照を持つケースになります。 以降のどの版も、それに対して実行されます。
実行を取り込む
画面または API でその実行を見つけ、トレース 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 には決定的なスコアラーが 13 種類付属します。保存済みの実行を読んで数値を返す、 ふつうのコードです。費用はかからず、モデルを呼ぶこともなく、二度実行すれば同じ答えを返します。 これは回帰テストが必要とする性質であり、ジャッジモデルが持たない性質です。
| スコアラー | 合格する条件 |
|---|---|
answered | 実行が空でない回答を出していること。 |
no_errors | 実行のどのスパンも失敗していないこと。 |
exact_match | 回答が期待されるものと一致すること。 |
contains | 回答が必要な文字列を含むこと。 |
json_valid | 回答が JSON として解析できること。 |
latency_budget | 実行がミリ秒の上限内で終わっていること。 |
cost_budget | 実行の費用が決めた額を超えていないこと。 |
no_tool_loop | 同じ入力で二度呼ばれたツールがないこと。 |
tool_efficiency | 実行がツール呼び出し回数の上限内に収まっていること。 |
not_a_refusal | 回答が拒否でも謝罪でもないこと。 |
no_leaked_secret | 鍵、トークン、資格情報のいずれも回答に現れないこと。 |
span_present | 実行が、求められた種類または名前のスパンを記録した。 |
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、そしてホスティング型の各社がいずれも受け付けるので、
同じ設定で手元のノート PC のモデルにも、本番のゲートウェイにも届きます。
キーを必要としないローカルのモデルには api_key を空のままにしてください。
temperature はゼロです。毎回違う答えを返すジャッジは自分自身と比べられず、
比べることだけがスコアの用途だからです。
基準はリクエストごとに渡すのではなく、設定します。スコアラー id は生成されるすべてのスコアに 保存され、後の比較がまとめる単位になります。呼び出しごとに id と問いの両方を作らせれば、 誰も信用できない列ができあがります。
設定した基準は GET /v1/api/scorers には現れません。このエンドポイントが並べるのは
組み込みの 13 種類だけなので、そこからスコアラーの選択肢を組み立てるクライアントは
あなたの基準を出しません。デーモンが基準の名前を返してくる唯一の場所は、
未知のスコアラー id に対するエラーです。そこには組み込みと設定済みの基準が一緒に並びます。
直近 1 件の実行にジャッジ型スコアラーをかける
curl -X POST localhost:4317/v1/api/score \
-H 'content-type: application/json' \
-d '{"limit": 1, "scorers": ["answer_relevance"]}' timeout_seconds は、1 回の採点呼び出しにかけてよい時間で、
接続から応答の最後のバイトまでを数えます。既定は 30、範囲は 1 から 300 です。
これを超えた呼び出しはキー名を示して見送られ、mcpd が読む応答は最大 1 MiB です。
ジャッジ型スコアラー 1 つにつき、実行 1 件ごとに 1 回モデルを呼び出し、
リクエストは --request-timeout-seconds の中で終わらなければなりません。
そのため 1 回のリクエストで採点できるのは、すべての呼び出しが timeout_seconds を使い切ってもその期限に収まる件数までです。
既定値の 30 と 30、ジャッジ型スコアラー 1 つなら、それは 1 件です。
trace_ids や limit でそれより多くを求めるリクエストは、
モデルを呼ぶ前に上限を示す 400 で拒否されます。limit を省略すると、収まるだけの直近の実行を採点します。
1 回でもっと採点するには --request-timeout-seconds を上げてください。
ジャッジ型のスコアはモデルが答えた時点で保存されるので、途中で打ち切られたリクエストでも、
終わった分のスコアは残ります。
推測はせず、見送ります
導き出していない数値を返すジャッジは、ジャッジがいないより悪いものです。証拠のように見えるからです。
そのため以下はいずれも理由とともに skipped に報告され、スコアは生成されません。
| 起きたこと | mcpd の動作 |
|---|---|
| 実行が回答を記録していない | 見送ります。回答のない実行は関連性で低く出たのではなく、そもそも測られていません。 |
| モデルが文章で返した | その返答を引用して見送ります。コードフェンスや文に包まれた JSON はそれでも読み取ります。 |
| モデルが尺度の外の値を答えた | 見送ります。1 点満点の 7 は 10 分の 9 ではなく、丸め込めば数値をでっち上げることになります。 |
| モデルに到達できなかった | ステータスと本文を添えて見送ります。レート制限は理由であって、ゼロではありません。 |
| 応答が 1 MiB を超えた | 残りを読まずに見送ります。判定は数百バイトなので、その大きさの応答は判定ではありません。 |
| リクエストの期限に呼び出しの時間が残っていなかった | モデルを呼ばずに辞退します。採点の一回分は、503 ではなく、期限内に終えたスコアで答えます。 |
ジャッジ型のスコアはいずれもモデルを scorer_version として記録します。
別々のジャッジから出た 2 つのスコアは比較できず、そのことをレコード上で述べているのがこのフィールドです。
採点は事後に行われます
POST /v1/api/score は、すでに保存されている実行に対してスコアラーをかけます。
実行中にその場で評価するものはありません。リクエスト経路に置かれた評価は、
判定している当のものにその待ち時間を上乗せしますし、
例外を投げる評価が利用者のリクエストを失敗させてよいはずがありません。
直近 100 件の実行を採点する
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 を並べずに採点する方法がこれです。
2 つの版を比べる
比べたい版ごとに実験を登録し、そのスコアに実験の id を付け、mcpd に差を尋ねます。
新しいプロンプトは効いたのか
curl 'localhost:4317/v1/api/experiments/compare?baseline=v3&candidate=v4'
返るのはスコアラーごとの差分で、両側のサンプル数と decidable フラグが付きます。
観測が 20 件に満たない場合、mcpd は動きを報告するのではなく、この比較は判断できないと述べます。
これは意図的に大雑把な規則であって、有意差検定ではありません。防ぎたいのはよくある失敗です。 4 件の実行で動いた平均が、件数を伏せたまま印字され、結果として読まれ、そのまま出荷されることです。 差分の横に件数を並べることが、ノイズを発見と取り違えさせない歯止めになります。
これが動く場所
すべて HTTP API なので、このループは CI に置くのが向いています。失敗をその場で取り込み、
テストスイートが生む実行を採点し、ビルドを実験として登録し、判断可能な比較で
regressed が true で返ってきたらジョブを失敗させます。
次に
- スコアが並び立つ 2 つのシグナル: スコアとフィードバック。
- 本番でスコアラーの平均を見張る: モニターとアラート。
- すべてのエンドポイントとフィールド: HTTP API。