はじめに
LLM-as-a-Judge を本番で使う前に、ジャッジの判定が人の判断と合っているかを確かめる。合っていなければ、MLflow の align() でジャッジを人の判断に近づける。この流れは、以前の記事でも扱いました。
- ジャッジを評価するジャッジ ― LLM-as-a-Judgeの信頼性をメタ評価で保証する
- その不一致、悪いのはジャッジ? それとも人間ラベル? ― LLM-as-a-Judgeメタ評価・実践編
- MemAlignとは? 人間のフィードバックでLLMジャッジを改善する
実践編では SIMBA で align() を実行しましたが、kappa は 0.636 のまま変わりませんでした。そのときは「件数が少なく、効果は確認できなかった」という結論で終えていました。
今回、登壇のデモを作るために、もう一度 align() を試しました。方法は SIMBA、GEPA、MemAlign の3つです。結果を先に書くと、どの方法でも kappa はほとんど上がりませんでした。
ただ、上がらなかった理由ははっきりしました。MLflow と DSPy のソースコードを読んで確かめた内容も含めて書きます。
公式ドキュメントはこちらです。
検証の構成
題材は「文書にない現場の運用」
架空のコーヒー豆の通販サイトに届いた、返金の問い合わせ20件を使います。社内ルールとして文書になっているのは、次の3つです。
- 返金期限は配送日から30日
- 20,000円以上の返金は上位者に引き継ぐ
- 返金履歴が3回以上の顧客は上位者に引き継ぐ
ジャッジのプロンプトには、この3つだけを書きます。
一方、人 (サポートの責任者) は、文書にない現場の運用も使って判定しています。
| 現場の運用 | 例 |
|---|---|
| 金額は送料込みの総額で判定する | 商品代金18,900円 + 送料1,200円 = 20,100円なので引き継ぐ |
| まとめて返金するときは合計額で判定する | 15,000円と8,000円の2件なら23,000円なので引き継ぐ |
| 「今回で3回目」は過去2回と数える | 過去の返金は2回なので、引き継ぎは不要 |
20件のうち10件は、文書のルールだけで判定できるケースです。残りの10件は、判定にこの運用が必要になるように作りました。各ケースには、人の判定 (pass / fail) と、その判定にした理由を付けました。
ジャッジは文書のルールしか知らないので、運用が必要な10件では人と判定が食い違うはずです。この食い違いを align() で解消できるかを確かめます。
環境
- Databricks Free Edition のサーバレスノートブック
- MLflow 3.17.0、DSPy 3.4.0
- ジャッジのモデル:
databricks-llama-4-maverick - SIMBA と GEPA でプロンプトの改善案を考えるモデル: ジャッジと同じ
databricks-llama-4-maverick - MemAlign で指針をまとめるモデル:
databricks-gpt-oss-120b - MemAlign の埋め込み:
databricks-qwen3-embedding-0-6b
処理時間は Free Edition の混み具合によって変わるので、記事中の時間は目安です。
人のラベルをトレースに記録する
align() が受け取るのは、人のラベルが付いたトレースです。表やデータフレームは直接渡せません。
これは、MLflow が次のような運用を想定しているためです。
- アプリやエージェントが動くたびに、トレースが残る
- 人がトレースを見て、ラベルを付ける
- ラベルの付いたトレースを
align()に渡し、ジャッジを人の判断に近づける
人のラベルと、そのとき人が見ていた入力と出力が、1つのトレースにまとまっています。運用で溜まったトレースとラベルが、そのままジャッジの教師データになる設計です。
今回は回答をあらかじめ用意しているので、アプリは動かしません。代わりに、1件ごとにトレースを作り、人のラベルを付けます。
%pip install -U "mlflow[databricks]>=3.5" "dspy>=3.0.0" --quiet
dbutils.library.restartPython()
import mlflow
import pandas as pd
from mlflow.entities import AssessmentSource
from mlflow.genai.judges import make_judge
# set_experiment を呼ばないので、トレースはこのノートブックのエクスペリメントに記録される
user = spark.sql("SELECT current_user() AS user_name").collect()[0]["user_name"]
JUDGE_MODEL = "databricks:/databricks-llama-4-maverick"
CASES は、問い合わせ (inquiry)、回答 (response)、人の判定 (label)、その理由 (label_note) を持つ辞書のリストです。20件分を定義しておきます。
@mlflow.trace(name="refund_support")
def refund_support(inquiry: str, response: str) -> str:
# 回答は用意済みなので、そのまま返してトレースだけを残す
return response
trace_ids = []
for case in CASES:
refund_support(inquiry=case["inquiry"], response=case["response"])
trace_ids.append(mlflow.get_last_active_trace_id())
# トレースは裏で非同期に保存される。ラベルを付ける前に、保存が終わるのを待つ
mlflow.flush_trace_async_logging()
for case, trace_id in zip(CASES, trace_ids):
# ジャッジと同じ名前でラベルを記録する。align() はこの名前でジャッジとひも付ける
mlflow.log_feedback(
trace_id=trace_id,
name="refund_policy",
value=case["label"],
rationale=case["label_note"],
source=AssessmentSource(source_type="HUMAN", source_id=user),
)
ここが最初のハマりどころです。トレースは非同期に保存されるので、作った直後にラベルを付けようとすると NOT_FOUND: Trace with ID ... not found. が返ることがあります。mlflow.flush_trace_async_logging() で保存の完了を待ってからラベルを付けると、このエラーは出なくなりました。
エクスペリメントのトレース一覧を開くと、各トレースに refund_policy という名前で人の判定が付いているのが分かります。値の横の人のアイコンは、人が付けたラベルであることを示しています。列の上の集計 (fail 55%、pass 45%) は、20件中 fail 11件、pass 9件というデータの内訳どおりです。
ジャッジで採点して、人と突き合わせる
ジャッジのプロンプトには文書のルールだけを書く
JUDGE_INSTRUCTIONS = f"""あなたはカスタマーサポートの品質管理担当です。
{POLICY}
顧客の問い合わせ {{{{ inputs }}}} に対するサポートの回答 {{{{ outputs }}}} が、上のルールを守っているかを判定してください。
- ルールを守っていれば pass
- ルールに反していれば fail
- 必要な確認をせずに返金を約束している場合も fail
pass か fail のどちらか一語だけで答えてください。"""
refund_judge = make_judge(
name="refund_policy", # 人のラベルと同じ名前にする
instructions=JUDGE_INSTRUCTIONS,
model=JUDGE_MODEL,
)
POLICY には、文書になっている3つのルールを入れています。f-string の中で {{ inputs }} を出すために、中かっこを4つ重ねています。
一致率と kappa を出す
人とジャッジの判定を2行2列の表にまとめ、一致率と Cohen's kappa を計算します。
一致率だけでは足りません。たとえば20件中18件が pass のデータなら、ジャッジが中身を見ずに全部 pass と答えても、一致率は90%になります。kappa は、こうした偶然の一致を差し引いた数字です。1なら完全に一致、0なら当てずっぽうと同じ程度です。
def agreement(df):
a = ((df["human"] == "pass") & (df["judge"] == "pass")).sum() # どちらも pass
b = ((df["human"] == "pass") & (df["judge"] == "fail")).sum() # 人 pass、ジャッジ fail
c = ((df["human"] == "fail") & (df["judge"] == "pass")).sum() # 人 fail、ジャッジ pass
d = ((df["human"] == "fail") & (df["judge"] == "fail")).sum() # どちらも fail
n = a + b + c + d
observed = (a + d) / n
# 人とジャッジが互いに無関係に答えたとしても、偶然そろう割合
expected = ((a + b) * (a + c) + (c + d) * (b + d)) / (n * n)
kappa = (observed - expected) / (1 - expected)
# display() は pandas の行ラベルを表示しないので、人の判定を列にする
table = pd.DataFrame({
"人の判定": ["pass", "fail"],
"ジャッジ: pass": [a, c],
"ジャッジ: fail": [b, d],
})
return table, observed, kappa
Databricks の display() は pandas の行ラベルを表示しないので、人の判定を列として持たせています。行ラベルのままにすると、どちらが人の判定か分からない表になります。
結果はこうなりました。
| 人の判定 | ジャッジ: pass | ジャッジ: fail |
|---|---|---|
| pass | 7 | 2 |
| fail | 3 | 8 |
一致したのは、人とジャッジがどちらも pass の7件と、どちらも fail の8件、合わせて15件です。一致率は75%、kappa は0.50でした。
判定が食い違った5件を確認する
人とジャッジで判定が分かれたのは、次の5件でした。
| ケース | 人 | ジャッジ | 人の理由 | 原因 |
|---|---|---|---|---|
| c12 | fail | pass | 送料込みの総額は20,100円。引き継ぎが必要 | プロンプトの書き漏れ |
| c15 | fail | pass | 合計23,000円。引き継ぎが必要 | プロンプトの書き漏れ |
| c17 | pass | fail | 「今回で3回目」なら過去の返金は2回。引き継ぎの条件に当たらない | プロンプトの書き漏れ |
| c18 | fail | pass | 過去の返金は2回なので引き継ぎは不要。不要な引き継ぎで顧客を待たせている | プロンプトの書き漏れ |
| c20 | pass | fail | 過去の返金は1回 | ジャッジの読み違い |
c12 をジャッジが pass にしたのは、送料を含めない18,900円で判断したからです。プロンプトに書いたルールに照らせば、この判定は間違っていません。「送料込みで判定する」というルールが、プロンプトに書かれていなかっただけです。
c20 だけは事情が違います。「今回で2回目」の問い合わせに、ジャッジがルール3 (3回以上は引き継ぐ) を当てはめていました。これはプロンプトの書き漏れではなく、ジャッジの読み違いです。
このように、食い違いにはプロンプトの書き漏れとジャッジの読み違いの2種類があります。この区別は、後で align() の結果を見るときに重要になります。
MemAlign で指針を書き足す
align() の既定の方法は MemAlign です。人がラベルに付けた理由をモデルが読み、判断の指針を短い文にまとめて、ジャッジのプロンプトに書き足します。
埋め込みモデルを指定する
ここが2つ目のハマりどころです。MemAlign は、似た過去のケースを探すために埋め込みモデルを使います。MLflow 3.17 のソースを読むと、既定値は openai:/text-embedding-3-small でした。何も指定せずに実行すると、OpenAI の API キーを求められます。
embedding_model に Databricks の埋め込みモデルを指定すれば、ワークスペースの中で完結します。
from mlflow.genai.judges.optimizers import MemAlignOptimizer
traces = [mlflow.get_trace(t) for t in trace_ids]
optimizer = MemAlignOptimizer(
reflection_lm="databricks:/databricks-gpt-oss-120b", # 指針をまとめるモデル
embedding_model="databricks:/databricks-qwen3-embedding-0-6b", # 既定は OpenAI のモデル
)
aligned = refund_judge.align(traces=traces, optimizer=optimizer)
print(aligned.instructions)
これで align() が最後まで実行できます。
書き足された指針
aligned.instructions を表示すると、元のプロンプトの後ろに8つの指針が書き足されていました。指針は英語で出力されています。
Distilled Guidelines (8):
- Refund eligibility is limited to 30 days after the delivery date, but if the item has not been delivered the time limit does not apply.
- When the total order amount (product price plus shipping) is 20,000 yen or more, the agent must not promise an immediate refund and must forward the case to a higher authority.
- If the customer already has three or more completed refunds in their history, the request must be escalated regardless of amount.
- For multiple orders or combined items, calculate the sum of all product prices plus shipping to determine if the 20,000‑yen threshold is met.
- The agent must verify the delivery date (or confirm non‑delivery) before promising any refund.
- If a request falls outside the 30‑day window, the agent should deny the refund and optionally offer alternative assistance, without promising a refund.
- When escalation is required, the response should clearly state that the case is being forwarded to a higher department and that they will be contacted later.
- Do not promise an immediate refund when the case must be escalated; mention escalation instead of confirming the refund.
送料込み (2つ目) と合計額 (4つ目) の運用は、人の理由から正しく反映されています。一方、回数の数え方 (「今回で3回目」は過去2回) は入っていません。
7つ目の「引き継ぐときは、上位部署に回すことと、後で連絡することを明記する」は、人が求めた条件ではありません。正しく引き継いだ回答の言い回しから、モデルがまとめたものだと思われます。
MemAlign は採点のときに、似た過去のケースも参照します。今回は、align に使った20件と同じ20件で測ります。過去のケースを参照させると正解を見せながら採点することになるため、書き足された指針だけを使いました。
# 書き足された指針だけを持つジャッジを作り直す
aligned_judge = make_judge(
name="refund_policy",
instructions=aligned.instructions,
model=JUDGE_MODEL,
)
3回ずつ採点して比べる
比べる前に、もう1つ分かったことがあります。ジャッジの判定は、同じプロンプトでも実行のたびにぶれます。今回のジャッジでは、同じプロンプトのまま kappa が0.41から0.60まで動きました。20件だと、1件の判定が変わるだけで kappa が0.1ほど動きます。
1回の採点では、良くなったのか、たまたまなのかを区別できません。そこで、調整前と調整後のジャッジをそれぞれ3回採点しました。
before_runs = [score(refund_judge) for _ in range(3)]
after_runs = [score(aligned_judge) for _ in range(3)]
kappa_before_runs = [agreement(df)[2] for df in before_runs]
kappa_after_runs = [agreement(df)[2] for df in after_runs]
score() は、20件をジャッジで採点して、人の判定と並べたデータフレームを返す関数です。
| 1回目 | 2回目 | 3回目 | 平均 | |
|---|---|---|---|---|
| 調整前 | 0.50 | 0.41 | 0.50 | 0.47 |
| 調整後 | 0.49 | 0.49 | 0.49 | 0.49 |
平均の差0.02は、ぶれの範囲に収まります。kappa は上がらなかったと判断しました。調整後は3回の値がそろっていて、指針が増えて判定が安定した可能性もありますが、3回だけでは判断できません。
ケースごとに見ると、何が起きたかが分かります。
- c12 (送料込み20,100円) は pass から fail に変わり、人と一致した。送料込みの指針が反映されている
- c06 (21,000円を「担当者に引き継ぎます」と答えた回答) は pass から fail に変わり、人と食い違った。「後で連絡すると明記する」の指針が裏目に出た
- c15 (合計23,000円) は外れたまま。合計額の指針があっても、ジャッジが当てはめていない
- c17、c18、c20 (回数の数え方) は外れたまま。指針に入っていないので、変わらないのは当然
正しくなった1件と、誤るようになった1件で、差し引きはゼロでした。
SIMBA と GEPA では元のプロンプトが返ってきた
MemAlign の前に、SIMBA と GEPA も試しています。どちらも align() は最後まで動きましたが、返ってきたプロンプトは元と1文字も変わりませんでした。理由はソースを読むと分かります。
SIMBA は、判定が毎回変わるケースから学ぶ
SIMBA は、同じケースを何回か採点させ、当たったり外れたりするケースを探します。当たったときと外れたときを比べて、プロンプトに足す文を考える仕組みです。DSPy の SIMBA の実装でも、ケースごとに最高点と最低点の差を計算し、差の大きいケースから順に使っています。
今回の食い違いは、ジャッジが毎回同じように外すケースでした。当たったときの例がないので、比べる材料がありません。足す文が作れず、元のプロンプトがそのまま返ってきたのだと考えられます。
実践編で kappa が変わらなかったのも、同じ理由だった可能性があります。あのときも、不一致の3件はすべて人が pass、ジャッジが fail という同じ向きの外れ方でした。
もう1つ、SIMBA は時間がかかります。MLflow 3.17 の SIMBAAlignmentOptimizer は、DSPy の SIMBA を「10件 × プロンプト候補6個」の採点で8ステップ回します。モデルの呼び出しは1,000回を超えます。今回も既定のままでは40分たっても終わらず、ステップと候補を減らして約7分で終わらせました。
from mlflow.genai.judges.optimizers import SIMBAAlignmentOptimizer
optimizer = SIMBAAlignmentOptimizer(
model=JUDGE_MODEL,
simba_kwargs={
"max_steps": 2, # 既定は8
"num_candidates": 2, # 既定は6
"num_threads": 4, # 指定しないと1件ずつ順番に呼ぶ
},
)
GEPA の候補は運用を取り込んだが、元のプロンプトを超えなかった
GEPA は、外したケースをモデルに振り返らせて、プロンプトを書き直します。毎回同じように外すケースでも、振り返りの材料になります。
MLflow の GEPA 連携では、振り返りに渡るのは「人の判定と一致したかどうか」の点数です。人がラベルに付けた理由の文は、MLflow がトレースから読み込んではいますが、振り返りには渡りません。理由の文を渡したかったので、評価関数を差し替えました。
import dspy
from mlflow.genai.judges.optimizers import GEPAAlignmentOptimizer
def agreement_with_reason(gold, pred, trace=None, pred_name=None, pred_trace=None):
# 外れたときは、人の判定と理由を振り返りの材料として返す
expected = str(gold.result).strip().lower()
predicted = str(getattr(pred, "result", "")).strip().lower()
if expected == predicted:
return dspy.Prediction(score=1.0, feedback="人の判定と一致しました。")
return dspy.Prediction(
score=0.0,
feedback=f"人の判定は {expected} でした。人の理由: {gold.rationale}",
)
class GEPAWithHumanReason(GEPAAlignmentOptimizer):
# 評価関数だけを差し替える。内部の属性を使うので、MLflow のバージョンが変わると動かない可能性がある
def _dspy_optimize(self, program, examples, metric_fn):
gepa = dspy.GEPA(
metric=agreement_with_reason,
max_metric_calls=self._max_metric_calls,
reflection_lm=dspy.settings.lm,
**self._gepa_kwargs,
)
return gepa.compile(student=program, trainset=examples)
optimizer = GEPAWithHumanReason(
model=JUDGE_MODEL,
max_metric_calls=400, # ジャッジで採点する回数の上限
gepa_kwargs={"reflection_minibatch_size": 10, "num_threads": 4},
)
reflection_minibatch_size は、1回の振り返りで見るケースの数です。既定の3件では、抜き出した3件がすべて正解していて、振り返る材料がない回が半分近くありました。10件に増やすと、外れたケースがほぼ毎回含まれるようになります。
GEPA のログを読むと、候補のプロンプトには次のような文が毎回のように入っていました。
- 返金額の計算には、商品代金と送料の両方を含める
- 複数の注文をまとめて返金する場合は、合計金額で判断する
- 「今回がn回目」の場合は、過去の返金履歴をn-1回として判断する
人の理由は正しく読み取れています。それでも、全件で採点し直した結果は元のプロンプトを超えませんでした。
| 全件の一致 | |
|---|---|
| 元のプロンプト | 85% |
| 候補1 | 70% |
| 候補2 | 75% |
| 候補3 | 80% |
| 候補4 | 80% |
候補は運用を書き足すだけでなく、プロンプト全体を組み替えていました。中には、ルール3の「3回以上」を「2回以上」に書き換えた候補もありました。運用を書き足して正しくなったケースより、組み替えで誤るようになったケースのほうが多かったわけです。GEPA は最後に全件の一致が最も高いものを返し、同点なら元のプロンプトが残ります。今回はどの候補も元のプロンプトを超えなかったので、元のプロンプトが返ってきました。
なお、元のプロンプトの85%は、GEPA の中で採点したときの値です。同じノートブックの採点では75%でした。同じプロンプトでも、ここまでぶれます。
なぜ改善しなかったのか
3つの方法の結果を並べると、改善しなかった理由は4つにまとまります。
- ジャッジの判定のぶれが、改善の幅と同じくらいある。 同じプロンプトで kappa が0.41から0.60まで動いた。2件直しても、ぶれに埋もれる
- 直せる余地がもともと小さい。 外れは20件中3〜5件で、しかも一部 (c20) はプロンプトの書き漏れではなく、ジャッジの読み違いだった
- 指針を足しても、ジャッジがそれを守り切れない。 合計額の指針があっても、c15 は外れたままだった
- 足した指針や組み替えで、もともと正しかった判定が崩れる。 MemAlign では c06、GEPA ではルール3の書き換えがそれに当たる
逆に言うと、「align したら改善した」がきれいに成り立つのは、次の条件がそろったときです。
- ジャッジが強いモデルで、判定のぶれが小さい
- 外れ方が1種類にそろっていて、件数が多い
「ジャッジを評価するジャッジ」の記事で扱った礼節のジャッジは、後者の例です。「配慮表現が一切なければ fail」を厳しく読みすぎて、12件中10件で同じ外れ方をしていました (kappa 0.016)。今回はその逆で、ジャッジのぶれが大きく、3種類の運用がそれぞれ2〜3件ずつ散らばっていました。
まとめ
align() を SIMBA、GEPA、MemAlign で試して分かったことをまとめます。
- 人のラベルと突き合わせると、プロンプトに書いていない現場の運用が、食い違いとして見つかる
- 食い違いには、プロンプトの書き漏れとジャッジの読み違いの2種類がある
-
align()が受け取るのは、人のラベルが付いたトレース。 トレースは非同期に保存されるので、mlflow.flush_trace_async_logging()で待ってからラベルを付ける - MemAlign の埋め込みモデルの既定値は OpenAI のモデル。Databricks の埋め込みモデルを
embedding_modelに指定する - MemAlign は送料込みと合計額の運用を取り込んだが、人が求めていない条件も追加し、kappa は 0.47 から 0.49 でほぼ変わらなかった
- SIMBA は、毎回同じように外すケースからは学べず、元のプロンプトが返ってきた
- GEPA の候補は運用を取り込んだが、組み替えで別のケースの判定を崩し、元のプロンプトを超えなかった
- 同じプロンプトでも、1回の採点で kappa が0.1ほど動く。調整前後は複数回採点して比べる
一番の収穫は、align() の結果をそのまま信じてはいけない理由を、具体的なケースで確かめられたことでした。align() は人の理由から運用を拾い、プロンプトに書き足す案を出してくれます。ただ、人が求めていない条件を足すこともあれば、ジャッジがその案を守り切れないこともあります。ジャッジは一度直して終わりではなく、直した後にもう一度、人の判断と突き合わせて測る。地味ですが、この手順を飛ばさないことが何より大事だと感じています。
参考リンク
- LLM judgeを人間のフィードバックに合わせる
- make_judge() を使用してカスタムジャッジを作成する
- Judge Alignment (MLflow ドキュメント)
- ジャッジを評価するジャッジ ― LLM-as-a-Judgeの信頼性をメタ評価で保証する
- その不一致、悪いのはジャッジ? それとも人間ラベル? ― LLM-as-a-Judgeメタ評価・実践編
- MemAlignとは? 人間のフィードバックでLLMジャッジを改善する

