Arize Phoenix で LLM アプリの観測と評価を試してみた

Arize Phoenix で LLM アプリの観測と評価を試してみた

2026.04.22

はじめに

こんにちは、クラスメソッド製造ビジネステクノロジー部の森茂です。

LLM アプリを育てていくと、そのうち「どのリクエストが遅かったのか」「どのプロンプトで精度が落ちたのか」「新しい judge モデルに差し替えたらスコアがどれくらい動くのか」といった観測と評価の仕事が増えてきますね。

そこで今回は Arize Phoenix を主役に据えて、トレース収集・セッション集約・データセット管理・実験比較・LLM-as-judge 評価・プロンプト管理・アノテーションまで、ひととおり手を動かしてみたノートをまとめておきます。検証は DGX Spark 上で行いましたが、記事内のコードは Mac でも Linux でも動く形で書いています。

https://github.com/Arize-ai/phoenix

Arize Phoenix とは何か

Arize Phoenix は、Arize AI 社がスポンサードしている OSS の LLM 可観測性プラットフォームです。サーバー本体と arize-phoenix-evals は Elastic License 2.0、OTel への送信ラッパーである arize-phoenix-otel のみ Apache 2.0 で配布されています。いずれもセルフホストで商用利用が可能な緩やかなライセンスで、Docker 1 行でサーバーが立ち上がり、評価ライブラリと LLM プレイグラウンドも一式が同梱されているのが特徴です。

特徴を一枚の図でまとめるとこんな感じになります。

他の LLM 可観測性ツールと位置づけを比べるとこうなります。

比較軸 Arize Phoenix Langfuse LangSmith MLflow Tracing
ライセンス OSS(EL 2.0) OSS(MIT) 商用 SaaS OSS(Apache)
Prompt Playground 同梱 同梱 同梱 なし
LLM-as-judge Evals 同梱 同梱 同梱 外部連携
OTel / OpenInference 準拠 ネイティブ 独自 SDK 寄り 独自 部分対応
セルフホスト構成 Docker 1 本 Redis + Clickhouse 限定的 単体可

OSS + 全機能同梱という点では Langfuse も同じで、どちらを選ぶかは細かい好みの世界に入ってきます。個人的には「OTel / OpenInference ネイティブ」と「Docker 1 本で立ち上がる」の 2 点が決め手で、手元のサンドボックスでは Phoenix に寄せています。Langfuse は SDK が独自寄りの分、LangChain 以外のフレームワーク越しにトレースを取るとき少し手間がかかる印象です。

3 分で動かす Phoenix

Phoenix の起動には大きく 3 つの選択肢があります。

選択肢 ユースケース 永続化
px.launch_app() Jupyter で手早く試したい プロセス限り
Docker 1 行 手元でサクッと立てて開発に使いたい SQLite(内蔵)
Docker Compose チーム共有、認証やバックアップが必要 PostgreSQL

最小構成(Docker 1 行)

docker run -d --name phoenix \
  -p 6006:6006 -p 4317:4317 \
  arizephoenix/phoenix:14.9.1

ポート 6006 は Web UI と OTLP HTTP(/v1/traces)、4317 は OTLP gRPC 用です。ブラウザで http://localhost:6006 を開けば、もう空のプロジェクト一覧が見えています。

arizephoenix/phoenix:latest は multi-arch イメージとして amd64 と arm64 の両方をサポートしているので、手元の Mac(Apple Silicon)でも Linux サーバーでもそのまま動きます。docker manifest inspectlinux/arm64 の manifest が同梱されているのも確認できます。

PostgreSQL 永続化(Docker Compose)

compose.yaml
services:
  phoenix:
    image: arizephoenix/phoenix:14.9.1
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - '6006:6006'
      - '4317:4317'
    environment:
      PHOENIX_SQL_DATABASE_URL: postgresql+psycopg://phoenix:phoenix@postgres:5432/phoenix
      PHOENIX_WORKING_DIR: /mnt/phoenix
    volumes:
      - phoenix-data:/mnt/phoenix
  postgres:
    image: postgres:17
    environment:
      POSTGRES_USER: phoenix
      POSTGRES_PASSWORD: phoenix
      POSTGRES_DB: phoenix
    volumes:
      - phoenix-pg:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U phoenix']
      interval: 3s
volumes:
  phoenix-data:
  phoenix-pg:

v14 系では読み取りレプリカ用の PHOENIX_SQL_DATABASE_READ_REPLICA_URL も追加されているので、本番運用の読み出し負荷分散まで視野に入れられます。小規模で試す段階では SQLite で十分ですが、span の保存期間を長めにしたい場合やチームで共有したい場合は PostgreSQL 版を選んでおくと後で楽です。

クライアント側の環境

本記事の検証コードは uv で環境を作りました。Python 3.12 以降なら同じ手順で動くはずです。

cd ~/works/phoenix-handson
uv venv --python 3.12
uv pip install \
  "arize-phoenix>=14.9.1" \
  "arize-phoenix-otel" \
  "arize-phoenix-evals" \
  "arize-phoenix-client" \
  "openinference-instrumentation-langchain" \
  "openinference-instrumentation-openai" \
  "openinference-instrumentation-anthropic" \
  "langchain" "langchain-openai" "langchain-anthropic" \
  "openai" "anthropic" "pandas" "datasets"

arize-phoenix が Web UI と REST API の本体、arize-phoenix-otelregister() 関数のラッパー、arize-phoenix-evals は純正 LLM-as-judge、arize-phoenix-client は Python から Datasets・Experiments・Annotations を叩くクライアントです。細かくパッケージが分かれているのは、v14 で「サーバーと軽量クライアントを分離する」リファクタが入ったためですね。

起動して http://localhost:6006 を開くと Projects 画面が出てきます。本記事で作っていくプロジェクトは、ここにどんどん追加されていくので、後から「どのスクリプトがどの trace を生んだか」を辿る起点になります。

Phoenix の Projects 画面(プロジェクト一覧)

OpenInference で LLM アプリからトレースを取る

Phoenix の特徴は、独自 SDK に縛らずに OpenInference という OTel 向けの semantic convention をベースに、フレームワークからトレースを取る点です。LangChain / LlamaIndex / OpenAI SDK / Anthropic SDK / CrewAI / DSPy / Bedrock / Vertex AI など、主要な LLM フレームワークに対する openinference-instrumentation-* パッケージが用意されており、register() を呼ぶだけで auto-instrument が走ります。

一例として、LangChain の QA チェーンにトレースを仕込むコードがこちらです。

instrument_langchain.py
import os
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from phoenix.otel import register

tracer_provider = register(
    project_name=os.environ.get("PHOENIX_PROJECT_NAME", "phoenix-handson-ch4"),
    endpoint="http://localhost:6006/v1/traces",
    auto_instrument=True,
)

# 環境変数で OpenAI / Anthropic / OpenAI 互換エンドポイントを切り替え
def build_llm():
    if os.environ.get("OPENAI_API_KEY") or os.environ.get("OPENAI_BASE_URL"):
        from langchain_openai import ChatOpenAI
        return ChatOpenAI(
            model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
            temperature=0,
            base_url=os.environ.get("OPENAI_BASE_URL"),
        )
    from langchain_anthropic import ChatAnthropic
    return ChatAnthropic(model="claude-haiku-4-5", temperature=0)

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a concise assistant. Reply in a single sentence."),
    ("human", "{question}"),
])
chain = prompt | build_llm() | StrOutputParser()

for q in [
    "What is the capital of Japan?",
    "Who wrote 'Pride and Prejudice'?",
    "Briefly: why is the sky blue?",
]:
    print(chain.invoke({"question": q}))

ポイントは register(auto_instrument=True) の 1 行で、インストール済みの OpenInference 系パッケージをすべて検出してフックに差し込んでくれる点です。自分の環境だと openinference-instrumentation-langchainopeninference-instrumentation-anthropic が入っていたので、LangChain の Runnable 階層と Anthropic SDK の messages.create 呼び出しが両方スパンになって Phoenix に送られてきます。

環境変数でプロバイダーを差し替えられる作りにしているので、OpenAI API に API Key があればそちら、なければ Anthropic にフォールバック、OPENAI_BASE_URL を指定すればローカルの vLLM や Ollama も使えます。「手元のエンドポイントで試したい」という要件にそのまま乗せられるのはありがたいところです。

対応している主な Python 向けトレース取得パッケージをまとめるとこうなります。

分類 パッケージ名
LLM SDK openinference-instrumentation-openai
openinference-instrumentation-anthropic
openinference-instrumentation-google-genai
openinference-instrumentation-mistralai
openinference-instrumentation-litellm
エージェント基盤 openinference-instrumentation-langchain
openinference-instrumentation-llama-index
openinference-instrumentation-dspy
openinference-instrumentation-crewai
openinference-instrumentation-openai-agents
ツール・ガード openinference-instrumentation-haystack
openinference-instrumentation-guardrails
openinference-instrumentation-mcp
クラウド openinference-instrumentation-bedrock
openinference-instrumentation-vertexai

TypeScript 側も 2026-04 に @arizeai/phoenix-otel の 1.0 がリリースされていて、Vercel AI SDK や LangChain.js のトレース取得が同じノリで書けます。Python でバックエンドを書きつつ、Next.js のフロントから直接 OTLP を飛ばしたいケースでも揃えやすくなっていますね。

スクリプトを実行した直後に Phoenix の Spans タブを開くと、3 つの質問分のスパンが 15 件流れ込んでいるのが見えます。

phoenix-handson-ch4 のスパン一覧(CHAIN と LLM が混在)

Tracing のスパンを読み解く

この 15 件のスパンは、3 つの質問 × 5 スパンずつの内訳で並んでいます。

スパン名 span_kind 役割
RunnableSequence CHAIN LangChain の LCEL パイプライン全体
ChatPromptTemplate PROMPT プロンプトのレンダリング
ChatAnthropic LLM LangChain 抽象層から見た LLM 呼び出し
messages.create LLM Anthropic SDK の生レベル API
StrOutputParser UNKNOWN 出力パーサー(OpenInference に分類定義なし)

CHAIN → PROMPT + LLM + UNKNOWN という入れ子で、親子関係は Phoenix の UI で waterfall 表示されます。各スパンには input.value / output.value / llm.token_count.prompt / llm.token_count.completion / llm.model_name などの attribute が自動で付与されるので、「どの質問でトークン数が跳ねたのか」「どこで時間を食ったのか」が一目で分かります。

トレースを開くと左ペインにスパンツリー、中央に Input / Output、右に Annotation エディタが出て、まさに「どこでどんな文字が通ったか」をそのまま追える作りになっています。

RunnableSequence のトレース詳細(スパンツリーと入出力ペイン)

気になる点として、ChatAnthropicmessages.create が同じリクエストを表現する 2 段重ねの LLM スパンになるのは、LangChain 側と Anthropic SDK 側の OpenInference フックが両方有効になっているためです。どちらか片方に絞りたい場合は、register(auto_instrument=False) にして、使いたい Instrumentor だけを LangChainInstrumentor().instrument() のように明示的に有効化する方法があります。

StrOutputParserUNKNOWN になっているのは、OpenInference の semantic convention に「出力パーサー」カテゴリが定義されていないため、LangChain 側が fallback として UNKNOWN を付けているからです。挙動としては正しいのですが、UI で眺めると少し気になるので、span_kind だけを頼りに集計するような運用は避けたほうが楽ですね。

Python からスパンをまとめて取得したいときは phoenix-client 経由で DataFrame を引けます。

from phoenix.client import Client

c = Client(base_url="http://localhost:6006")
df = c.spans.get_spans_dataframe(project_identifier="phoenix-handson-ch4")
print(df[["name", "span_kind", "status_code"]].head())

CSV エクスポートや pandas での集計が絡む場面では、UI を見るより API を叩くほうが速いです。

Sessions でマルチターン会話を束ねる

単発の Q&A ではなくチャットアプリを組むと「この会話はトータルで何秒かかったのか」「どのターンで脱線したのか」を見たくなります。Phoenix の Sessions 機能を使うと、関連するトレースを session.id というキーで束ねて表示できます。

openinference-instrumentation パッケージの using_session() コンテキストマネージャを使うと、その中で出る最上位スパンに attributes.session.id が自動で付きます。子スパンは親子関係から UI 側で辿ってくれるので、付与は最上位のみで OK です。

sessions_demo.py
import os, uuid
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_anthropic import ChatAnthropic
from openinference.instrumentation import using_session
from phoenix.otel import register

register(project_name="phoenix-handson-ch6",
         endpoint="http://localhost:6006/v1/traces",
         auto_instrument=True)

def run_session(session_id: str, turns: list[str]) -> None:
    llm = ChatAnthropic(model="claude-haiku-4-5", temperature=0)
    history = [SystemMessage(content="You are a concise assistant. Keep every reply under 30 words.")]
    with using_session(session_id):
        for user_text in turns:
            history.append(HumanMessage(content=user_text))
            result = llm.invoke(history)
            history.append(result)

run_session(f"handson-{uuid.uuid4()}", [
    "Tell me three famous sights in Kyoto.",
    "Which one is closest to Kyoto Station?",
    "How long does it take on foot?",
])

これを 2 セッション分実行してから Phoenix の Sessions タブを開くと、session id ごとに最初の入力・最後の出力・開始終了時刻・annotation がまとめて表示されます。セッションクリックで各ターンのトレースに飛べるので、デバッグのときに「どのターンでハルシネーションしたか」を追いやすくなります。

phoenix-handson-ch6 のセッション一覧(session_id ごとに 2 行に集約)

v14 系で増えた Sessions API を使うと、SDK から順序付きで会話を取り出せます。

from phoenix.client import Client

c = Client(base_url="http://localhost:6006")
turns = c.sessions.get_session_turns(session_id="<session-id>")

Sessions の挙動でよくハマるのは、using_session() を使わずに直接 span に attribute を書こうとするパターンです。tracer.start_as_current_span(...) の中で span.set_attribute("session.id", "xxx") と書いても UI には反映されますが、子 span への伝搬は自動ではないので、LangChain などのフレームワーク越しに使うときは using_session() 側のほうが素直です。

Datasets で回帰テスト用データを整える

「プロンプトを差し替えたら以前と比べてスコアがどう動いたか」を見るには、固定のデータセットを用意しておくのが王道です。Phoenix は phoenix.client.Client.datasets.create_dataset に pandas DataFrame を渡すだけでデータセットが立ち上がります。

upload_dataset.py
import pandas as pd
from phoenix.client import Client

QA = [
    ("What is the capital of France?", "Paris"),
    ("Who painted the Mona Lisa?", "Leonardo da Vinci"),
    # ... 一般知識 QA 30 件
]

df = pd.DataFrame(QA, columns=["question", "answer"])
df["id"] = [f"qa-{i:03d}" for i in range(len(df))]

client = Client(base_url="http://localhost:6006")
ds = client.datasets.create_dataset(
    name="handson-general-qa",
    dataframe=df,
    input_keys=["question"],
    output_keys=["answer"],
    metadata_keys=["id"],
)
print(f"dataset id={ds.id} rows={len(df)}")

実行すると dataset id と version_id が払い出され、UI の Datasets タブに 30 行が見えます。input_keys / output_keys / metadata_keys の振り分けは、あとで Experiments を書くときに example["input"]["question"] のようにアクセスする名前になるので、ここで決めた名前は少し先まで効いてきます。

handson-general-qa データセットの 30 examples(input / output / metadata が並ぶ)

v14 では Splits という概念が追加されていて、同じデータセットを train / test のように分けて管理できます。たとえば 30 件のうち 20 件を train、10 件を test にして、train で prompt を仕上げてから test で最終評価、という流れが Phoenix 内で完結します。

データセット作成のルートは DataFrame の他に CSV アップロード(UI)、JSONL の直接 PUT(REST)、HuggingFace Datasets からの取り込みなども用意されています。個人的には Python から作ってそのまま ds.id を次のステップに渡すのが一番楽でした。

Experiments でプロンプトと judge を比較する

Datasets が揃ったら、同じ入力に対して複数の実装をぶつけて比較するのが Experiments の仕事です。run_experiment に「タスク関数」と「評価関数(evaluator)」を渡すと、各 example に対してタスクを実行 → evaluator で採点 → DB に記録 → 比較 UI に並ぶ、というところまでやってくれます。

今回は「1 語だけで答えろ」プロンプト A と「最大 3 語で答えろ」プロンプト B を、同じ 30 件 QA に対して走らせてみました。評価には (1) 期待値が出力に含まれているかの contains_match(コード評価)と (2) LLM-as-judge で yes/no を判定させる llm_correctness(Claude Haiku 4.5 を judge に使う)を並べます。

run_experiment.py
from anthropic import Anthropic
from phoenix.client import Client

anthropic = Anthropic()
client = Client(base_url="http://localhost:6006")
dataset = client.datasets.get_dataset(dataset="handson-general-qa")

def _ask(system: str, question: str) -> str:
    resp = anthropic.messages.create(
        model="claude-haiku-4-5", max_tokens=128, system=system,
        messages=[{"role": "user", "content": question}],
    )
    return resp.content[0].text.strip()

def task_one_word(example):
    return _ask("Answer in exactly one word.", example["input"]["question"])

def task_short_phrase(example):
    return _ask("Provide a short factual answer in at most 3 words.", example["input"]["question"])

def contains_match(output, expected):
    return float(expected.get("answer", "").lower() in str(output).lower())

def llm_correctness(input, output, expected):
    prompt = (
        f"Question: {input['question']}\n"
        f"Expected answer: {expected['answer']}\n"
        f"Model answer: {output}\n\n"
        "Is the model answer semantically correct? Reply 'yes' or 'no'."
    )
    resp = anthropic.messages.create(
        model="claude-haiku-4-5", max_tokens=5,
        messages=[{"role": "user", "content": prompt}],
    )
    return 1.0 if resp.content[0].text.strip().lower().startswith("yes") else 0.0

client.experiments.run_experiment(
    dataset=dataset, task=task_one_word,
    evaluators=[contains_match, llm_correctness],
    experiment_name="prompt-A-one-word",
)
client.experiments.run_experiment(
    dataset=dataset, task=task_short_phrase,
    evaluators=[contains_match, llm_correctness],
    experiment_name="prompt-B-short-phrase",
)

手元の実行では running tasks のプログレスバーが出て、30 件のタスクが約 30 秒、30 件 × 2 evaluator = 60 件の評価が約 27 秒で流れます。プロンプト A / B それぞれで 120 件の評価が Phoenix に記録され、Experiments タブを開くと実験名で並び、スコアの平均・中央値・分布が比較できます。

Experiments Analysis のスコア × latency グラフと 2 行のリスト

同じ Dataset に紐付いた Experiments は、そのままダブルクリックで「example 単位のサイドバイサイド差分」ビューに入れるので、「この質問だけプロンプト A で落ちているな」が見つけやすいです。

Experiments Compare(プロンプト A vs プロンプト B を example 単位で並列表示)

タスク関数のシグネチャは柔軟で、example だけを受け取る形のほか、input / expected / metadata などのキーで分解受け取りもできます。evaluator も関数の引数名で紐付くので、同じ evaluator を Experiments・Evals 両方で使い回すのが楽です。戻り値は dict / bool / float / str / (float, str) タプル / EvaluationResult のどれでも OK なので、スクリプト的に書き捨てるノリで済みます。

LLM-as-judge を phoenix-evals で動かす

Experiments の中でも evaluator は書けますが、「出力だけ pandas に取ってきて後から評価したい」という日もあります。そんなときは arize-phoenix-evals パッケージが独立して使えるので、Jupyter などから気軽に呼べます。v3 系のモダン API では、LLM プロバイダーを LLM(provider=..., model=...) で指定し、CorrectnessEvaluatorConcisenessEvaluator のような metric クラスに注入する形に変わりました。

run_evals.py
import pandas as pd
from anthropic import Anthropic
from phoenix.evals import LLM, evaluate_dataframe
from phoenix.evals.metrics import ConcisenessEvaluator, CorrectnessEvaluator

QA = [
    ("What is the capital of France?", "Paris"),
    ("Who painted the Mona Lisa?", "Leonardo da Vinci"),
    # ... 10 件
]

anthropic = Anthropic()

def answer(q: str) -> str:
    r = anthropic.messages.create(model="claude-haiku-4-5", max_tokens=128,
                                   messages=[{"role": "user", "content": q}])
    return r.content[0].text.strip()

rows = [{"input": q, "output": answer(q), "expected": ex} for q, ex in QA]
df = pd.DataFrame(rows)

judge = LLM(provider="anthropic", model="claude-haiku-4-5")
scored = evaluate_dataframe(df, evaluators=[
    CorrectnessEvaluator(llm=judge),
    ConcisenessEvaluator(llm=judge),
])
print(scored[["correctness_score", "conciseness_score"]].describe())

手元で 10 QA × 2 evaluator を走らせると、CorrectnessEvaluator は 10 件すべて correct(score=1.0)、ConcisenessEvaluator は 1 件だけ concise で 9 件が verbose という結果になりました。Claude Haiku 4.5 は質問に対して「回答 + 補足 + 背景情報」を返してくる傾向があるので、「正しいけど冗長」という分布が素直に可視化された形です。

スコアはラベル(correct / concise など)と 0.0〜1.0 の score、そして LLM が書いた explanation がセットで返ってきます。explanation の日本語化や rubric のカスタマイズは create_classifier / create_evaluator で任意の prompt template を書けるので、「日本語の自社規約でアウトプットをチェックする」ような用途にもそのまま持っていけます。

Evals で用意されている代表的な metric は次のとおりです。arize-phoenix-evals 3.x 系では旧 API の QAEvaluator 等もレガシーとして残っていますが、新しく書くなら phoenix.evals.metrics のクラス API が無難ですね。

分類 Evaluator
回答品質 CorrectnessEvaluator / FaithfulnessEvaluator(旧 HallucinationEvaluator の後継)
文体・拒否 ConcisenessEvaluator / RefusalEvaluator
RAG DocumentRelevanceEvaluator
エージェント ToolSelectionEvaluator / ToolInvocationEvaluator / ToolResponseHandlingEvaluator
コード評価 MatchesRegex / PrecisionRecallFScore / exact_match

Prompt Hub でプロンプトをバージョン管理する

プロンプトを git 管理する派も多いと思いますが、Phoenix には Prompt Hub が同梱されているので「実験に使ったプロンプトそのもの」を Phoenix 内で履歴管理できます。Python クライアントから PromptVersion を作って push するだけでバージョンが増えていきます。

prompts_and_annotations.py
from phoenix.client import Client
from phoenix.client.types import PromptVersion

c = Client(base_url="http://localhost:6006")

v1 = PromptVersion(
    [
        {"role": "system", "content": "You are a concise assistant. Reply in a single sentence."},
        {"role": "user", "content": "{{question}}"},
    ],
    model_name="claude-haiku-4-5",
    model_provider="ANTHROPIC",
    description="v1 baseline concise prompt",
    template_format="MUSTACHE",
)
v2 = PromptVersion(
    [
        {"role": "system", "content": "You are a precise factual assistant. Answer with exactly one short sentence, no preamble."},
        {"role": "user", "content": "{{question}}"},
    ],
    model_name="claude-haiku-4-5",
    model_provider="ANTHROPIC",
    description="v2 stricter one-sentence prompt",
    template_format="MUSTACHE",
)

pv1 = c.prompts.create(name="handson-concise-qa", version=v1)
pv2 = c.prompts.create(name="handson-concise-qa", version=v2)
c.prompts.tags.create(prompt_version_id=pv1.id, name="baseline")
c.prompts.tags.create(prompt_version_id=pv2.id, name="production")

latest = c.prompts.get(prompt_identifier="handson-concise-qa")
baseline = c.prompts.get(prompt_identifier="handson-concise-qa", tag="baseline")

model_providerANTHROPIC / OPENAI / AZURE_OPENAI / GOOGLE / AWS(Bedrock) / OLLAMA / GROQ / DEEPSEEK / XAI / TOGETHER など主要どころを網羅しています。template_formatMUSTACHE / F_STRING / NONE から選べて、上の例は {{question}} をプレースホルダとして使っています。

Phoenix UI の Prompts タブでは、バージョンごとの差分(v1 と v2 の system prompt の変更箇所)が色付きで表示され、production タグでピン留めされたバージョンが何かも一覧できます。

handson-concise-qa プロンプトの詳細(baseline / production タグ付き 2 バージョン、claude-haiku-4-5 / Anthropic プロバイダ表示)

また Playground 画面ではプロンプトを直接編集して「その場で LLM に投げて結果を見る」動作確認ができます。Custom Providers として OpenAI 互換エンドポイントを登録すれば、手元の vLLM や Ollama に対しても Playground から叩けます。

履歴の確認は CLI からも px prompts(一覧)や px prompt <identifier>(参照)で参照できます。git とは別の履歴系として Phoenix Hub を使うなら、CI の中で phoenix-client から push する運用が自然かもしれません。

Annotations で自動スコアと人手レビューを揃える

可観測性ツールを運用していると、「自動評価のスコアに加えて、レビュアーが付けた手動のラベルも同じ画面で見たい」という要望が出てきます。Phoenix はこれを Annotations という機構で統一していて、自動スコアも人手フィードバックも span_annotations テーブルに同じスキーマで乗ります。

既存 span に対して手動で annotation を付けるのは Python クライアントの 1 行です。

from phoenix.client import Client

c = Client(base_url="http://localhost:6006")
spans_df = c.spans.get_spans_dataframe(project_identifier="phoenix-handson-ch4")
llm_span_id = str(spans_df[spans_df["span_kind"] == "LLM"].index[0])

c.spans.add_span_annotation(
    span_id=llm_span_id,
    annotation_name="handson-review",
    annotator_kind="HUMAN",
    label="correct",
    score=1.0,
    explanation="Manually reviewed in handson session — looks good.",
    sync=True,
)

annotator_kindLLM / CODE / HUMAN の 3 択で、同じ span に複数の annotation を付けてもそれぞれ別に保存されます。UI の各 span 詳細ビューでは、annotation 一覧が展開表示され、誰が・いつ・どのラベルとスコアを付けたかが追えます。

Annotation が付いたスパンの詳細(右上に handson-review μ 1.00 のバッジ)

v14 で REST エンドポイントが整理されて、旧 /v1/evaluations は廃止、以下の 3 つに分割されています。

エンドポイント 対象
POST /v1/span_annotations スパン単位の評価
POST /v1/trace_annotations トレース単位の評価
POST /v1/document_annotations RAG ドキュメント単位

evaluator から流した自動スコアと、手でフラグを立てた human レビューを同じ画面で串刺し検索できるので、「LLM judge が 0.9 と言っているけど人は NG を付けている span」のような食い違いを見つけやすくなります。バッチで流したい場合は c.spans.log_span_annotations_dataframe(df) で pandas DataFrame から一括登録できるので、評価パイプラインの出力をまとめて書き戻すのも簡単です。

触っていない機能と競合比較、そしてハマりどころ

Phoenix には他にもいくつか面白い機能があって、今回はさわりきれませんでした。気になったものを一覧にしておきます。

機能 概要
Inferences px.Dataset にエンベディングを入れて UMAP + HDBSCAN 可視化
A2A Tracing Agent-to-Agent プロトコル(Google / Anthropic 提案)のトレース
PostgreSQL read replica v14.0 から、読み取り側を別 DB に逃がして負荷分散
Shareable Project URL /redirects/projects/<name> で名前ベースの共有 URL
LDAP / OAuth2 認証 v12.20〜 のエンタープライズ向け認証統合

Inferences は歴史的には Phoenix の出発点だった機能(embedding ドリフト可視化)ですが、最近のドキュメントは Tracing 中心になっていて、公式のポジションとしては「レガシー寄り」で残っているようです。RAG の retriever チューニングで使いたいときは便利なので、用途が合えば触ってみる価値はあります。

競合との使い分けマップ

シナリオ 向いているツール
OSS で全機能をセルフホストしたい Arize Phoenix
チーム全体でプロンプト管理を徹底したい(有料可) Langfuse / LangSmith
LangChain 密結合で追加コストをかけたくない LangSmith
ML / LLM を MLflow に統合したい MLflow Tracing + Phoenix 併用
クラウドで楽に始めたい Phoenix Cloud / Langfuse Cloud

Phoenix と Langfuse はどちらも魅力的で、自分の中では「単一コンテナで OTel ネイティブ、OpenInference 資産を活かしたい → Phoenix」「MIT で会社の規定に通しやすく、Redis / Clickhouse を含む重め構成にも抵抗がない → Langfuse」という棲み分けです。LangSmith は LangChain / LangGraph の一員として使うなら自然ですが、OSS ではないので選定判断が変わります。

v14 で踏んだ細かいハマり

最後に、ハンズオン中に引っかかった点をまとめておきます。記事を書いている時点(Phoenix 14.9.1 / phoenix-otel 0.15.0 / phoenix-evals 3.0.0)での挙動です。

  1. プロジェクト名が default に集約される: register()project_name を指定するか、PHOENIX_PROJECT_NAME 環境変数で渡さないと、既存プロジェクトに混ざります。
  2. Annotation の書き込みは /v1/span_annotations に移動: v13 以前の /v1/evaluations を叩いていると 404 になります。Python クライアント経由なら気にしなくて OK ですが、シェルで直接 POST していた人は移行が必要です。加えて、旧 px.Client() は廃止され、phoenix-client パッケージの Client(base_url=...) に移行しているので、v13 以前のコードをそのままにしていると import から通りません。
  3. LangChain + Anthropic で LLM スパンが二重に出る: ChatAnthropic(LangChain 側のトレース)と messages.create(Anthropic SDK 側のトレース)が両方スパンになります。整理したい場合は register(auto_instrument=False) にして、必要な Instrumentor を明示的に呼びます。
  4. StrOutputParser の span_kind が UNKNOWN: OpenInference の semantic convention に「出力パーサー」のカテゴリがないためで、表示上の都合です。フィルタリングで span_kind=LLM に絞るときに取りこぼさないよう注意。
  5. gRPC で大文字ヘッダーを送ると H2Error: protocol_error: HTTP/2 の仕様でヘッダーキーは小文字固定なので、自前で OTLP を書くときは小文字でそろえます。PHOENIX_API_KEY 経由にしておくと自動で正しい形で付きます。
  6. PostgreSQL に切り替えた初回起動で空テーブルになる: PHOENIX_SQL_DATABASE_URL を設定した状態で初めて起動すると migration が自動実行されるはずなのに、タイミングによっては空 DB のまま UI が立つことがあります。その場合はコンテナを docker restart するか、phoenix db migrate を明示的に叩くと復旧します。
  7. ARM64 Docker イメージは multi-arch だがタグによって混同する: :latest は multi-arch ですが、arizephoenix/phoenix:14.9.1 のようなバージョン固定タグでも問題なく ARM64 を掴みます。どうしても x86 を掴んでほしいときは --platform linux/amd64 を明示します。
  8. authlib.jose の Deprecation 警告: 14.9.1 時点で joserfc への移行が完了していないため警告が出ますが、挙動には影響ありません。気になる場合は warnings.filterwarnings で抑制できます。

まとめ

Arize Phoenix をひととおり触ってみて、「観測・評価・プロンプト管理・人手レビューが 1 つの OSS に揃っている」ことの強さを改めて感じました。OpenInference で LangChain / OpenAI SDK / Anthropic SDK / LlamaIndex などフレームワーク越しにトレースを取れるので、既存の LLM アプリに差し込むのも数行で済みますし、評価系も arize-phoenix-evals 単体で完結しているので Jupyter でさっと回せます。

試してみる順番としては、まず Docker 1 行で立ち上げてローカルの LLM アプリを register() で繋いで span を眺めるのが最短です。そこから Datasets / Experiments / Prompts / Annotations を 1 つずつ足していけば、自然に「継続的に育てるワークフロー」が出来上がります。

Phoenix Cloud やエンタープライズ認証(LDAP / OAuth2)まで行くとさらに話が広がりますが、まずは手元で全機能を無料で触れるのが一番のセールスポイントかなと思っています。気になる方は、ぜひ今回の検証スクリプトを雛形にして自分のアプリに組み込んでみてください。

参考

この記事をシェアする

関連記事