はじめに
MLflowの公式ブログに、Agent Skillを評価して改善する記事が出ていました。
スキルは自然言語の指示文なので、型チェックも単体テストも効きません。代わりに評価データセットで回帰テストしよう、という話です。
読んでいて面白かったのは、スコアラーがエージェントの出力ではなく トレース を受け取っている点でした。返金の可否という答えが合っていても、必要な確認を必要な順でやっていなければ業務としてはアウトです。この種の欠陥は、最終的な文面をLLM-as-a-judgeに採点させても検出できません。
スパンの開始時刻を比べるだけなら、judgeを呼ばずに判定できます。コストもゼロ、実行ごとのブレもゼロ。単体テストと同じ感覚でエージェントの手順をテストできることになります。
Databricks上で実際に組んでみたので、書いていきます。
スキルを評価するとき、実際に動かしているのはエージェント
最初に構造を整理しておきます。
スキルというテキストは単体では実行できません。エージェントに読ませて初めて振る舞いになるので、評価は必然的にエージェントごと動かすことになります。つまりこういう実験計画です。
- 固定する: エージェントのコード、ツールの定義、モデルエンドポイント、
temperature=0、評価データセット - 動かす: システムプロンプトに入るスキルのテキストだけ
- 測る: エージェントの振る舞い。トレースのスパン順序と最終出力
他が全部同じなので、スコアの差はスキルに帰属できる、という理屈になります。
ここで注意が要るのは、出てくるスコアがスキル単体の性質ではないことです。スキル × モデル × ツールスキーマ の組み合わせに対するスコアなので、モデルを替えれば結果は変わります。今回のモデルは databricks-claude-sonnet-4-5 です。
題材: 返金対応エージェント
ブログにならって、ECサイトの返金対応を題材にしました。
ツールをトレースで包む
ツールは4つです。それぞれ @mlflow.trace(span_type=SpanType.TOOL) で包みます。これがそのままスコアラーの検査対象になります。
import mlflow
from mlflow.entities import SpanType
@mlflow.trace(span_type=SpanType.TOOL)
def verify_identity(customer_id: str, provided_email: str) -> dict:
"""登録メールアドレスと申告内容を突き合わせて本人確認する"""
cust = CUSTOMERS.get(customer_id)
ok = cust["email"].lower() == (provided_email or "").lower()
return {"verified": ok, "customer_name": cust["name"] if ok else None}
@mlflow.trace(span_type=SpanType.TOOL)
def get_order_history(customer_id: str) -> dict:
"""顧客の直近の注文情報を返す"""
return {"found": True, **ORDERS[customer_id]}
@mlflow.trace(span_type=SpanType.TOOL)
def check_refund_ledger(customer_id: str) -> dict:
"""顧客の過去12ヶ月の返金回数を返す"""
return {"customer_id": customer_id, "refunds_last_12_months": REFUND_LEDGER.get(customer_id, 0)}
エージェント本体は、Foundation Model APIをOpenAI互換クライアントで叩く普通のツール呼び出しループです。mlflow.openai.autolog() を有効にしておくと、LLMの各ターンも同じトレースにぶら下がります。
client = w.serving_endpoints.get_open_ai_client()
# 評価中に差し替えるスキル。predict_fn は inputs しか受け取らないため、モジュール変数で持つ
ACTIVE_SKILL = {"version": "v1", "body": skill_v1["body"]}
@mlflow.trace(span_type=SpanType.AGENT)
def run_agent(request: str, customer_id: str, provided_email: str) -> dict:
messages = [{"role": "system", "content": ACTIVE_SKILL["body"]}, ...]
...
mlflow.genai.evaluate の predict_fn にはデータセットの inputs しか渡らないので、スキルは引数ではなくモジュール変数で持ちます。ここを差し替えるだけでv1とv2を切り替えられます。
ツールの説明文にはルールを書かない
地味ですが、ここが評価の成否を分けます。
{
"type": "function",
"function": {
"name": "check_refund_ledger",
"description": "顧客の過去の返金履歴を取得する",
...
},
}
説明文に「本人確認のあとで呼ぶこと」「3回以上ならエスカレーション」のように書いてしまうと、スキルが空でもエージェントは正しく動きます。そうなると測っているのはツール定義であってスキルではありません。順序の制約も判断基準も、スキル側にだけ書きます。
2つのスキルを用意する
v1は、一般的な返金対応として書けば書けてしまうレベルの指示文です。
## 返金ポリシー
- 購入から30日以内の注文のみ返金できます
- final sale (最終セール) 商品は返金できません
- 本人確認が取れない場合は承認せず、エスカレーションします
v2には、この会社にしかない取り決めを足します。手順と、判断の優先順位です。
## 手順
判断を確定する前に、次の3つを必ずこの順で呼んでください。
1. `verify_identity` で本人確認を行う
2. `check_refund_ledger` で過去12ヶ月の返金履歴を取得する
3. `get_order_history` で注文情報を取得する
返金履歴の照会は注文照会より前です。履歴が3回以上の顧客は注文の内容にかかわらず
エスカレーションになるため、先に履歴を見て、無駄な注文照会を避けます。
## 判断基準
上から順に評価し、最初に該当したものを採用します。
1. 本人確認が取れない → `escalate`
2. 過去12ヶ月の返金回数が3回以上 → `escalate`
3. final sale (最終セール) 商品 → `deny`
4. 注文金額が 20,000円以上 → `escalate`
5. 返金期限を過ぎている → `deny`
6. いずれにも該当しない → `approve`
### 返金期限の数え方
30日の起算日は購入日ではなく配送日です。`days_since_delivery` が 30 を超えていたら期限切れです。
`days_since_purchase` は返金期限の判定に使わないでください。
スキルはUCボリュームに置いて差し替える
スキル本体はUnity Catalogのボリュームに SKILL.md として置き、フロントマターを剥がして読み込む関数を用意しました。コードを触らずにスキルだけ入れ替えられる形にしておくと、比較が楽になります。
なお Unity Catalog Skills というベータ機能もありますが、今回は使っていません。あちらはローカルのコーディングエージェントに権限付きでスキルを配るためのレジストリで、ノートブックから読み込んで使う想定の機能ではないためです。評価の対象は SKILL.md のテキストなので、置き場が変わっても仕組みはそのまま使えます。
評価データセットを作る
8件用意しました。30日の境界、final sale、本人確認NG、金額しきい値、返金履歴といったケースを揃えています。
正解ラベルはルールから生成する
期待値を手で書くと、データを直したときに黙ってずれます。v2のルールをそのままコードにして、そこから正解を作りました。
def expected_decision(customer_id: str, provided_email: str) -> tuple:
"""v2 のルールをコードにしたもの。上から順に評価し、最初に該当したものを返す"""
cust = CUSTOMERS[customer_id]
if cust["email"].lower() != provided_email.lower():
return "escalate", "本人確認が取れない"
if REFUND_LEDGER[customer_id] >= MAX_PRIOR_REFUNDS:
return "escalate", f"過去12ヶ月の返金が{REFUND_LEDGER[customer_id]}回"
o = ORDERS[customer_id]
if o["final_sale"]:
return "deny", "final sale 商品"
...
さらに「そのルールを知らない素朴な判断」も別関数として実装し、正解と食い違うことを確認しています。食い違わないケースは、そのルールを測れていません。ここはLLMを呼ばずに検算できるので、評価を回す前に設計の穴が見つかります。
モデルが素で満たすルールは選ばない
ここが最初のハマりどころでした。
v1から抜くルールを選ぶとき、モデルが書かれていなくても当然やることを選ぶと、v1とv2の差が出ません。「本人確認をしてから個人情報を見る」のような一般的な規範がこれにあたります。
弁別しやすいのは、モデルの事前知識に存在しない値です。20,000円というしきい値も3回という回数も、どんなモデルでも当てられません。日付フィールドを2つ用意して、どちらを使うかをスキルにしか書かない、というのも効きます。
トレースのスパン順序を見るスコアラー
トレースにはツールの呼び出し順が残る
まずトレースを見てみます。@mlflow.trace で包んだツールが、呼ばれた順にスパンとして並びます。
この順序が、スコアラーの判定材料になります。
judgeを使わずに決定的に判定する
@scorer は inputs / outputs / expectations / trace をキーワード引数で受け取れます。使う引数だけを書けば、MLflowが必要なものを渡してくれます。
from mlflow.entities import Feedback, Trace
from mlflow.genai.scorers import scorer
@scorer
def ledger_before_orders(trace: Trace) -> Feedback:
"""返金履歴の照会は注文照会より前か"""
verify = trace.search_spans(name="verify_identity")
ledger = trace.search_spans(name="check_refund_ledger")
orders = trace.search_spans(name="get_order_history")
# 本人確認NGで打ち切った場合は対象外
if verify and not (verify[0].outputs or {}).get("verified"):
return Feedback(value=True, rationale="本人確認NGで打ち切ったため対象外")
if not ledger:
return Feedback(value=False, rationale="check_refund_ledger が呼ばれていません")
if not orders:
return Feedback(value=True, rationale="注文照会がないため順序の対象外")
ok = ledger[0].start_time_ns < orders[0].start_time_ns
return Feedback(
value=ok,
rationale="履歴照会が注文照会より先です" if ok else "注文照会のほうが先に呼ばれました",
)
trace.search_spans(name=...) でスパンを名前で取り、start_time_ns を比べているだけです。LLMを一度も呼んでいません。
スパンの outputs にはツールの戻り値がそのまま入っているので、「本人確認に失敗したケースは判定対象から外す」のような分岐も書けます。ツールが何を返したかまで見て採点できる、ということです。
rationale に判断根拠を入れておくのは大事です。スコアが落ちたときに、数字だけのスコアラーでは何も分かりません。「注文照会のほうが先に呼ばれました」まで書いてあると、結果表を眺めるだけで原因にたどり着けます。
今回は4つ用意しました。
| スコアラー | 見るもの |
|---|---|
ledger_before_orders |
返金履歴の照会が注文照会より前か。トレースのスパン順序 |
verify_before_orders |
本人確認が注文照会より前か。トレースのスパン順序 |
policy_compliance |
承認してよくないものを承認していないか。スパンの戻り値と最終判断を突き合わせる |
decision_correctness |
期待ラベルとの一致 |
呼んだかどうかではなく、いつ呼んだかを見る
ledger_before_orders は当初「check_refund_ledger を呼んだか」で判定していました。これだと差がつきません。モデルは使える状態のツールを一通り呼ぶので、v1でも呼ばれます。
順序には自然な正解がありません。「履歴を先に見て早期に打ち切る」という業務判断はありえますが、モデルには知りようがない。トレース系のスコアラーは、存在ではなくタイミングを見るほうが素直に差が出ます。
v1とv2を比べる
評価の実行
スキルを差し替えて2回回すだけです。
SCORERS = [verify_before_orders, ledger_before_orders, policy_compliance, decision_correctness]
ACTIVE_SKILL = {"version": "v1", "body": skill_v1["body"]}
with mlflow.start_run(run_name="refund-skill-v1"):
mlflow.log_params({"skill_version": "v1", "endpoint": ENDPOINT})
result_v1 = mlflow.genai.evaluate(
data=eval_dataset,
predict_fn=run_agent,
scorers=SCORERS,
)
mlflow.start_run で包んでおくと、スコアとパラメータが同じrunに入ります。あとからUIでrun同士を比較できるので、この一手間は入れておくのがおすすめです。
結果
| スコアラー | v1 | v2 |
|---|---|---|
decision_correctness |
62.5% | 100% |
ledger_before_orders |
12.5% | 100% |
policy_compliance |
75% | 100% |
verify_before_orders |
100% | 100% |
MLflowのUIでrun同士を比較すると、ケース単位でどちらがどう判断したかが並びます。
ledger_before_orders のv1が12.5%なのは、8件中1件が本人確認NGで打ち切られて対象外判定になったぶんです。残り7件すべてで注文照会が先に来ていました。
verify_before_orders はv1でもv2でも100%です。どちらのスキルにも書いていないルールなので、これは動かないと分かっているスコアラーです。他が動いたときに測定系が生きていると言えるので、こういう行を1つ残しておくと切り分けが楽になります。
ケースごとに何が起きたか
判断が割れたケースを並べます。
| ケース | データ | v1 | v2 | 正解 |
|---|---|---|---|---|
| キーボード | 購入35日 / 配送25日 | deny | approve | approve |
| モニターアーム | 24,800円 | approve | escalate | escalate |
| デスクマット | 返金4回目 | approve | escalate | escalate |
v1が「キーボード」で返した理由は「購入から35日が経過しており、返金ポリシーの30日以内という条件を満たしていないため」でした。素直に購入日で数えています。日付フィールドを2つ用意して、どちらを使うかをスキルにしか書かない、という設計が効きました。
policy_compliance はスパンの戻り値まで見ているので、v1が「モニターアーム」を承認したケースで 違反: 24800円は自動承認の上限超え という rationale を返しています。最終判断だけでなく、そのとき手元にあったデータに照らして採点できるのが、トレースを見るスコアラーの利点です。
まとめ
MLflowでAgent Skillを評価してみて分かったことをまとめます。
@scorerはtraceをキーワード引数で受け取れる。search_spans(name=...)でスパンを取り、start_time_nsを比べれば手順の正しさをjudgeなしで判定できる- スパンの
outputsにはツールの戻り値が入っているので、ツールが何を返したかまで見て採点できる - トレース系のスコアラーは「呼んだか」ではなく「いつ呼んだか」を見る。存在で測ると、モデルは使えるツールを一通り呼ぶので満たされてしまう
- ツールの説明文に順序や判断基準を書くと、スキルではなくツール定義を評価することになる
-
rationaleに判断根拠を書いておくと、結果表を眺めるだけで原因にたどり着ける - 正解ラベルは判断ルールをコードにして生成する。手で書くとデータを変えたときに黙ってずれる
- 動かないと分かっているスコアラーを1つ残しておくと、測定系が生きているかの切り分けに使える
一番の収穫は、エージェントの手順に対して単体テストと同じ書き方ができると分かったことでした。judgeを使う評価は文面の良し悪しには向いていますが、「本人確認の前に個人情報を見ていないか」のような、業務上ゆずれない条件の検査には向いていません。決定的に書ける部分はコードで書き、判断が要る部分だけjudgeに回す。この切り分けができると、評価にかかるコストも実行ごとのブレも一気に下がります。
参考リンク
- Evaluating and Improving Agent Skills with MLflow
- コードベースのスコアラー
- スコアラーの概念
- Unity Catalog Skillsとは
- 基盤モデルAPIで利用できるDatabricksホストの基盤モデル

