はじめに
2026年7月31日にリリースされたMLflow 3.15.0で、LLMジャッジがトレース上の画像を「見る」ための get_span_image というツールが追加されました。
以前、MLflowのマルチモーダルトレーシングをDatabricksで試すという記事で、スパンに含まれる画像が自動的に添付として抽出される機能を試しました。今回はその続きで、記録された画像を今度は評価に使う話になります。
実際に動かしてみると、ジャッジがチャートの目盛りの数値まで読み取って判定を返してきました。一方で、ジャッジのモデル選択を間違えると何も動かなくなる、という落とし穴もあります。そのあたりも含めて書いていきます。
get_span_image とは
ジャッジはトレースをツール越しにしか見られない
make_judge() で作るジャッジの指示に {{ trace }} を含めると、そのジャッジはエージェントとして動作し、トレースを調べるためのツール群が渡されます。実際に確認すると、MLflow 3.15.1では以下の7つが渡っていました。
from mlflow.genai.judges.tools.registry import list_judge_tools
for t in list_judge_tools():
print(" -", t.name)
- get_trace_info
- get_root_span
- get_span
- get_span_image
- list_spans
- search_trace_regex
- get_span_performance_and_timing_report
ここで重要なのは、ジャッジがトレースをツール経由でしか見られないという点です。
MLflow 3.12から入ったマルチモーダルトレーシングは、スパン内のインライン画像 (data URL) を mlflow-attachment://<id>?content_type=...&trace_id=...&size=... という参照トークンに書き換えて、実体を別に保存します。ところが get_span や list_spans はテキストしか返さないため、ジャッジの目にはこの参照トークンという文字列しか届きません。
MLflowのソースコードには、この状況が明快に書かれています。
The text-only judge tools only surface that reference token, so a
{{ trace }}judge is structurally blind to the pixels. This tool bridges that gap.
get_span_image は、この参照を解決して実体をダウンロードし、base64のdata URLとしてマルチモーダルモデルに渡す橋渡し役です。人間はUIで画像を見られるのに、ジャッジだけが見られない、という非対称を埋めるための機能と言えます。
画像を含むトレースを作る
検証には、チャートを描画してマルチモーダルモデルに説明させるエージェントを使いました。ジャッジが本当に画像を見ているかを確かめたいので、「軸ラベルと凡例のあるチャート」と「それらを落としたチャート」の2種類を流します。テキストの出力は両者で同じになるため、画像を見ないと区別できない題材です。
まずはパッケージの準備から。
%pip install -U "mlflow[databricks]>=3.15.0"
dbutils.library.restartPython()
チャートを描画してdata URLを返す関数です。quality="bad" の側は、目盛りのフォントサイズを2ptにして凡例を落としています。
import base64
import io
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
REVENUE = [120, 135, 128, 160, 172, 190]
COST = [90, 95, 99, 105, 110, 118]
def render_chart(quality: str) -> str:
fig, ax = plt.subplots(figsize=(5, 3), dpi=110)
ax.plot(MONTHS, REVENUE, marker="o", label="Revenue")
ax.plot(MONTHS, COST, marker="s", label="Cost")
if quality == "good":
ax.set_xlabel("Month", fontsize=11)
ax.set_ylabel("Amount (K USD)", fontsize=11)
ax.set_title("Monthly Revenue vs Cost", fontsize=13)
ax.tick_params(labelsize=10)
ax.legend(fontsize=10)
ax.grid(alpha=0.3)
else:
# 軸ラベルなし、目盛りが極小、凡例なし
ax.tick_params(labelsize=2)
ax.set_title("chart", fontsize=3)
fig.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png")
plt.close(fig)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
エージェント側は mlflow.openai.autolog() を有効にしておくだけです。コードを変えなくても、送信した画像が添付として自動抽出されます。
mlflow.openai.autolog()
from databricks.sdk import WorkspaceClient
openai_client = WorkspaceClient().serving_endpoints.get_open_ai_client()
AGENT_MODEL = "databricks-claude-sonnet-4-5"
@mlflow.trace(name="chart_agent")
def chart_agent(quality: str) -> str:
data_url = render_chart(quality)
response = openai_client.chat.completions.create(
model=AGENT_MODEL,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "この売上チャートを1文で説明してください"},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
max_tokens=300,
)
return response.choices[0].message.content
trace_id_good = (chart_agent("good"), mlflow.get_last_active_trace_id())[1]
trace_id_bad = (chart_agent("bad"), mlflow.get_last_active_trace_id())[1]
mlflow.flush_trace_async_logging()
これで画像を含むトレースが2本できました。
トレースに画像が残っていることを確認する
Experimentページでトレースを開き、chart_agent の下の Completions スパンを選ぶと、Chatタブのメッセージに送信したチャートがインライン表示されます。
goodとbadの差が一目で分かります。bad側は目盛りが完全に潰れていて、青とオレンジの線が何を表しているのかも分かりません。
ノートブックの埋め込みウィジェットでは画像が出ない
ここが最初のハマりどころです。ノートブックのセル出力に埋め込まれるTrace UIウィジェットでは、画像が表示されません。
私はこれに気づかず、しばらく「マルチモーダルトレーシングが壊れた」と思い込んでいました。MLflowを3.12に戻して試しても表示されなかったので、バージョンの問題ではありません。Experimentページを開けば普通に表示されます。埋め込みウィジェット側の表示の問題です。
コードから確認する場合は、参照トークンだけでなく実体がダウンロードできるところまで見ておくと安心です。
import json
from mlflow.tracing.attachments import Attachment
from mlflow.tracing.client import TracingClient
trace_good = mlflow.get_trace(trace_id_good)
def find_image_span(trace):
for span in trace.data.spans:
serialized = json.dumps(span.to_dict(), default=str).replace("\\", "")
refs = [t for t in serialized.split('"') if t.startswith("mlflow-attachment://")]
if refs:
return span, refs
return None, []
span, refs = find_image_span(trace_good)
print("画像を含むスパン:", span.name)
print("参照:", refs[0])
parsed = Attachment.parse_ref(refs[0])
repo = TracingClient()._get_artifact_repo_for_trace(trace_good.info)
print("実体:", len(repo.download_trace_attachment(parsed["attachment_id"])), "bytes")
画像を含むスパン: Completions
参照: mlflow-attachment://7d804c23-3ad9-4a5d-9162-e7549f888b32?content_type=image%2Fpng&trace_id=tr-b4b93ff604b4ba8e5f66968fe2c24ae4&size=29435
実体: 29435 bytes
参照トークンに埋まっている size=29435 と、実際にダウンロードしたバイト数が一致しています。
なお、download_trace_attachment は内部APIで、公開APIはまだありません。ソースコードにも「there is no public API for it yet」というコメントが残っています。動作確認以外の用途で常用するのは避けた方がよさそうです。
ジャッジに渡す前に、ツール単体でも叩いておきます。
from mlflow.genai.judges.tools.get_span_image import GetSpanImageTool
result = GetSpanImageTool().invoke(trace_good, span_id=span.span_id)
if isinstance(result, str):
print("エラー:", result)
else:
print("content_type:", result.content_type)
from IPython.display import HTML, display
display(HTML(f'<img src="{result.data_url}" />'))
成功すると SpanImageResult が返り、失敗時は理由を説明する文字列が返ってきます。例外ではなく文字列を返す設計なので、ジャッジは失敗理由を読んで次の行動を決められるようになっています。
マルチモーダルジャッジで評価する
ジャッジの指示に特別な作法は要りませんが、対象スパンの探し方を書いておくと安定します。
from typing import Literal
from mlflow.genai.judges import make_judge
chart_readability_judge = make_judge(
name="chart_readability",
instructions=(
"あなたはチャート描画エージェントの出力品質を評価します。\n"
"\n"
"手順:\n"
"1. list_spans でスパン一覧を取得する\n"
"2. 入力または出力に mlflow-attachment:// の画像参照を含むスパンを特定する\n"
"3. そのスパンの span_id を指定して get_span_image を呼び、実際に画像を見る\n"
"\n"
"評価基準: 画像を見た上で、以下を全て満たす場合のみ 'pass' としてください。\n"
"- X軸とY軸のラベルが読める大きさで付いている\n"
"- 系列を区別する凡例がある\n"
"- 目盛りの数値が判読できる\n"
"\n"
"テキストの説明文だけで判断してはいけません。必ず画像を見てから判定してください。\n"
"評価対象は {{ trace }} です。"
),
model="databricks:/databricks-claude-sonnet-4-5",
feedback_value_type=Literal["pass", "fail"],
)
{{ trace }} を含めているので、template_variables は {'trace'} になります。逆に {{ inputs }} や {{ outputs }} しか使わないジャッジにはツールが一切渡らないため、画像を見せたいなら {{ trace }} が必須です。
2本のトレースに適用します。
for label, trace in [("good", trace_good), ("bad", trace_bad)]:
feedback = chart_readability_judge(trace=trace)
print(f"=== {label} -> {feedback.value} ===")
print(feedback.rationale)
good側の結果です。
=== good -> pass ===
画像を確認した結果、以下の評価基準を全て満たしています:(1) X軸に「Month」、Y軸に
「Amount (KUSD)」のラベルが読める大きさで付いている、(2) 左上に「Revenue」(青線)と
「Cost」(オレンジ線)を区別する凡例がある、(3) X軸の月名(Jan, Feb, Mar, Apr, May, Jun)
とY軸の数値(80, 100, 120, 140, 160, 180)が全て判読できる。
注目したいのは Y軸の数値 (80, 100, 120, 140, 160, 180) の部分です。この数値はコードのどこにも書いていませんし、エージェントが返したキャプションにも出てきません。matplotlibが自動で決めた目盛りなので、画像を見ないと絶対に書けない情報です。ジャッジが実際に画素を読んでいることの動かぬ証拠になります。
bad側も見てみます。
=== bad -> fail ===
画像を確認した結果、以下の問題が確認されました:1) X軸とY軸に軸ラベルが付いていません
(目盛りの数値のみで、軸が何を表しているかの説明がありません)、2) 凡例が完全に欠落しており、
青線とオレンジ線が何を表しているのか区別できません、3) 目盛りの数値は判読可能です。
評価基準の3つの条件のうち、目盛りの数値のみが満たされており、軸ラベルと凡例が
欠けているため、'fail'と判定します。
3つの評価基準を個別に判定した上で結論を出しています。「目盛りの数値は判読可能です」と、満たしている項目もきちんと認めているあたりも、テキストからの当てずっぽうではないことを示しています。
ジャッジモデルはマルチモーダル対応のものを指定する
ここが2つ目のハマりどころです。
make_judge() の model に何を指定するかで、内部で使われるアダプタが変わります。実際に確認したところ、こうなっていました。
| 指定 | 使われるアダプタ |
|---|---|
"databricks" |
DatabricksManagedJudgeAdapter |
"databricks:/databricks-claude-sonnet-4-5" |
GatewayAdapter |
"anthropic:/claude-sonnet-4-5" |
GatewayAdapter |
問題は model="databricks" を指定したときです。これはDatabricksマネージドジャッジの経路で、内部では gpt-oss-120b が使われます。MLflowのソースコードにも定数として定義されています。
_DATABRICKS_AGENTIC_JUDGE_MODEL = "gpt-oss-120b"
このモデルはテキスト専用なので、get_span_image を呼べても画像を受け取れません。画像を評価したいなら、databricks:/<エンドポイント名> の形でマルチモーダル対応モデルを明示的に指定する必要があります。
利用可能なエンドポイントは以下で確認できます。
from mlflow.deployments import get_deploy_client
names = sorted(ep["name"] for ep in get_deploy_client("databricks").list_endpoints())
for n in names:
if any(k in n for k in ("claude", "gemini", "gpt-5", "llama-4")):
print(" -", n)
対照実験: モデルだけテキスト専用に替える
get_span_image がどれだけ効いているのかを確かめるため、対照実験をしました。
ポイントは指示もテンプレート変数も変えず、モデルだけ差し替えることです。指示文はジャッジのオブジェクトから直接引き継いでいるので、書き写しのミスで差が混入することもありません。
CONTROL_MODEL = "databricks:/databricks-gpt-oss-120b"
text_only_judge = make_judge(
name="chart_readability_text_model",
instructions=chart_readability_judge.instructions, # 指示は完全に同じ
model=CONTROL_MODEL,
feedback_value_type=Literal["pass", "fail"],
)
この条件だと、ジャッジはツールを全て使えます。list_spans でスパンを列挙できますし、get_span_image も呼べます。違いはモデルが画素を受け取れるかどうかだけです。ツールが渡らないから負ける、という不公平な比較にはなりません。
失敗の仕方がゆらぐことがあったので、各トレース3回ずつ試しました。
ATTEMPTS = 3
for label, trace in [("good", trace_good), ("bad", trace_bad)]:
print(f"===== {label} =====")
for i in range(ATTEMPTS):
try:
feedback = text_only_judge(trace=trace)
print(f"[{i + 1}] 判定: {feedback.value}")
print(f" 根拠: {feedback.rationale}")
except Exception as e:
print(f"[{i + 1}] エラー: {type(e).__name__}")
print(f" {e}")
print()
結果は6回中6回すべてエラーでした。判定が一度も返りません。
===== good =====
[1] エラー: MlflowException
Failed to parse response from judge model. Response:
[2] エラー: MlflowException
Failed to invoke judge model: {"error_code":"BAD_REQUEST","message":"BAD_REQUEST: Image input is not supported for this endpoint: databricks-gpt-oss-120b."}
[3] エラー: MlflowException
Failed to parse response from judge model. Response:
===== bad =====
[1] エラー: MlflowException
Failed to invoke judge model: {"error_code":"BAD_REQUEST","message":"BAD_REQUEST: Image input is not supported for this endpoint: databricks-gpt-oss-120b."}
[2] エラー: MlflowException
Failed to parse response from judge model. Response:
[3] エラー: MlflowException
Failed to parse response from judge model. Response:
エラーメッセージが Image input is not supported for this endpoint と明示されている点が重要です。ジャッジはちゃんと get_span_image まで到達していて、取得した画像をモデルに渡す段で弾かれています。ツールを呼べていないのではありません。
もう一方の「応答が空」というエラーとの比率は、goodとbadのどちらにも紐づいていません。goodで3回中1回、badで3回中1回が BAD_REQUEST です。トレース固有の問題ではなく、同じ原因がランダムに2通りの壊れ方をしているだけ、と読めます。
マルチモーダルモデルならgoodとbadを判別できたのに、テキスト専用モデルでは判定そのものが成立しない。この差が get_span_image の埋めている穴です。
実装を読んで分かったこと
画像はツール応答ではなくuserターンで届く
MLflowのソースコードを読んでいて面白かったのが、画像の渡し方です。
画像ブロックは role="tool" のメッセージには載せられません。プロバイダ側が弾くためです。そこでMLflowは、ツール応答には「画像は次のuserメッセージに出す」というテキストの確認だけを返し、直後に別のuserターンとして画像を注入しています。
さらに、OpenAI系には「assistantの tool_calls に対するtool応答は連続していなければならない」という制約があるため、複数のツール呼び出しをまとめて処理してから画像ターンを後ろに付ける、という順序制御まで入っています。地味ですが、こういうところを自前で書くと確実にハマるので、ライブラリ側で吸収してくれるのはありがたいところです。
読み出しには常に10MBの上限がある
添付のサイズ上限には非対称があります。書き込み側の MLFLOW_TRACE_MAX_ATTACHMENT_SIZE はデフォルト未設定なのに対し、読み出し側は無条件で10MBにキャップされます。ジャッジのコンテキストが溢れるのを防ぐためです。
参照トークンに size= が埋め込まれているので、大きすぎる画像はダウンロードする前に弾かれます。実装としては丁寧です。
1スパンに複数画像があるとき
attachment_index (0始まり) で選択します。省略時は「最初の画像参照」が使われ、PDFなどの非画像添付は読み飛ばされる仕様です。画像とPDFが混在するエージェントでも、素直に書けば意図通りに動きます。
まとめ
get_span_image を試して分かったことをまとめます。
-
{{ trace }}を含むジャッジには7つのツールが自動で渡り、そこにget_span_imageが含まれる {{ inputs }}や{{ outputs }}だけのジャッジにはツールが一切渡らない。画像を見せたいなら{{ trace }}が必須- 指示に特別な作法は不要。ただし対象スパンの探し方を書いておくと安定する
- 3.12のマルチモーダルトレーシングで貯めた画像を、そのまま3.15のジャッジが評価できる
model="databricks"はテキスト専用のgpt-oss-120bを使うため画像を見られない。databricks:/<エンドポイント名>でマルチモーダル対応モデルを明示指定する- テキスト専用モデルに差し替えると6回中6回とも判定が返らない。
Image input is not supported for this endpointというエラーになる - ノートブックに埋め込まれるTrace UIウィジェットでは画像が表示されない。Experimentページを開く必要がある
- 読み出し側の添付サイズは常に10MBでキャップされる
- MLflow 3.15ではファイルストアのトラッキングバックエンドがメンテナンスモードになり例外で停止する。ローカルで試すなら
sqlite:///mlflow.dbを使う
一番の収穫は、判定根拠に「Y軸の数値 (80, 100, 120, 140, 160, 180)」と書かれていたことでした。ジャッジが画像を見ているかどうかは、pass/failの結果だけを眺めていても分かりません。コードにもテキストにも存在しない情報が根拠に出てくるかを見る、というのが確認方法として使えそうです。評価の自動化を進めるほど、その評価自体が本当に機能しているかを確かめる手段が必要になります。

