How to Evaluate Agents Locally with Arize Phoenix
Trace and judge your agent on your own machine, no hosted service required
Verdict first: Phoenix is the easiest way I know to get LangSmith-style tracing and evals without sending a byte of your data to anyone's cloud. It is open source, free to self-host with no feature gates, and because it is built on OpenTelemetry, you are not marrying its SDK. If your evaluation data cannot leave your infrastructure, or you just do not want another SaaS bill while you iterate, this is the tool.
What Phoenix is#
Phoenix is an open-source AI observability and evaluation platform from Arize and the community. It does four things: tracing (see every step of a run), evals (score outputs with LLM judges, code checks, or human labels), prompt management with a playground, and datasets with experiments for regression testing. Under the hood it accepts traces over OTLP and ships auto-instrumentation through OpenInference, a set of OpenTelemetry-compatible conventions for LLM apps.
Arize has two products with confusingly parallel APIs: Phoenix (open source, phoenix.otel, optional PHOENIX_API_KEY) and Arize AX (their commercial cloud, arize.otel, space and API keys). Docs, tutorials, and AI coding assistants mix them up constantly. Everything below is Phoenix.
Step 1: run Phoenix on your machine#
No signup, no API key for the basics:
pip install arize-phoenix
phoenix serveThat gives you the UI at http://localhost:6006, with OTLP ingest over gRPC on 4317 and over HTTP at /v1/traces on the same port as the UI. Prefer containers? The image is one command away:
docker run -p 6006:6006 -p 4317:4317 arizephoenix/phoenix:latestPin a version tag (arizephoenix/phoenix:version-X.X.X) for anything you intend to keep, and there is a Helm chart when you outgrow a single container. Self-hosted Phoenix keeps every trace, prompt, and dataset on your infrastructure; the docs are explicit that it can run fully air-gapped, and nothing is sent to Arize.
Step 2: point your agent at it#
Phoenix's register helper wraps the OpenTelemetry setup:
pip install arize-phoenix-otel openinference-instrumentation-openai openaiimport os
os.environ["PHOENIX_COLLECTOR_ENDPOINT"] = "http://localhost:6006"
from phoenix.otel import register
tracer_provider = register(
project_name="my-agent",
auto_instrument=True,
)Two things are happening. register configures an OTel tracer provider that exports to your local Phoenix, filed under a project name. auto_instrument=True activates whichever OpenInference instrumentors you have installed, so calls through the OpenAI SDK (or Anthropic, LangChain, LlamaIndex, CrewAI, and a long list of others) get traced with zero further code. Running locally, no API key is needed; that is only for authenticated deployments and Phoenix Cloud.
Now run something worth looking at. A minimal tool-calling agent is enough:
import json
from openai import OpenAI
client = OpenAI()
def get_weather(city: str) -> str:
return json.dumps({"city": city, "temp_c": 21, "forecast": "clear"})
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
messages = [{"role": "user", "content": "Should I cycle to work in Amsterdam today?"}]
response = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=tools
)
while response.choices[0].message.tool_calls:
messages.append(response.choices[0].message)
for call in response.choices[0].message.tool_calls:
result = get_weather(**json.loads(call.function.arguments))
messages.append(
{"role": "tool", "tool_call_id": call.id, "content": result}
)
response = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=tools
)
print(response.choices[0].message.content)Open the Traces view in the UI and the run is there: nested spans for each model call and tool execution, with latencies, inputs, outputs, and token counts attached. If you have read my post on trace anatomy, this is that tree, rendered.
Because Phoenix ingests standard OTLP, you are not limited to their instrumentors. Anything that exports OpenTelemetry spans can send to it, which is exactly the no-lock-in argument from the OpenTelemetry post.
Step 3: evaluate what you traced#
Traces tell you what happened; evals tell you whether it was any good. The workflow is: pull spans out as a DataFrame, score them, log the scores back as annotations.
Pull the spans:
from phoenix.client import Client
spans_df = Client().spans.get_spans_dataframe(project_name="my-agent")Start with a code evaluator, because they are free and deterministic. For example, flag runs whose final answer never used the tool:
eval_df = spans_df[spans_df["span_kind"] == "LLM"].copy()
eval_df.set_index("context.span_id", inplace=True)
eval_df["label"] = eval_df["attributes.llm.input_messages"].apply(
lambda msgs: "used_tool" if any(m.get("message.role") == "tool" for m in msgs) else "no_tool"
)
eval_df["score"] = (eval_df["label"] == "used_tool").astype(int)Then the LLM-as-judge layer, using the current phoenix.evals API. A ClassificationEvaluator takes a prompt template, a judge model, and a fixed set of choices (the rails that keep judge output aggregatable):
from phoenix.evals import ClassificationEvaluator, async_evaluate_dataframe
from phoenix.evals.llm import LLM
template = """
You are judging whether an assistant's answer is grounded in the tool results
it received.
Tool results and conversation: {input}
Final answer: {output}
Answer "grounded" if every factual claim is supported by the tool results,
or "hallucinated" if any claim is not.
"""
groundedness = ClassificationEvaluator(
name="groundedness",
llm=LLM(provider="openai", model="gpt-4o-mini"),
prompt_template=template,
choices={"grounded": 1, "hallucinated": 0},
)
results_df = await async_evaluate_dataframe(eval_df, evaluators=[groundedness])Log the results back so they show up on the spans in the UI:
from phoenix.client import Client
from phoenix.evals.utils import to_annotation_dataframe
Client().spans.log_span_annotations_dataframe(
dataframe=to_annotation_dataframe(results_df)
)Now every trace in the UI carries a groundedness verdict next to it, and you can filter to the hallucinated ones and read exactly what the agent saw before it went wrong. The evals library also ships pre-tested evaluators (hallucination, Q&A correctness, retrieval relevance) if you would rather not write templates from scratch, and once you want proper regression testing, Phoenix's datasets and experiments let you re-run a fixed set of inputs against every change.
One honest caveat about "local": an LLM-as-judge sends the span content to whatever model is judging. If strict data locality is the whole point, either stick to code evaluators or point the judge at a model you serve yourself; the evals LLM wrapper supports multiple providers, so check the current docs for what fits your setup.
Phoenix or LangSmith?#
I wrote a full guide to evaluating agents with LangSmith, and the honest comparison is short. LangSmith is more polished and deeply integrated if you live inside LangChain and LangGraph, and its hosted collaboration features are ahead. Phoenix wins on self-hosting (free, no feature gates, air-gappable), on OTel-native openness, and on being usable from any framework without adopting anyone's ecosystem. For a team with data that cannot leave the building, Phoenix is not the compromise option; it is the correct one.
Wrapping up#
The loop that matters: phoenix serve, register(auto_instrument=True), run your agent, pull spans, judge them, log annotations back. An afternoon of setup gets you the debugging view and the quality scores, all on hardware you control. Start with one code evaluator and one judge, wire them into your dev loop, and expand only when the scores start driving decisions.

Folarin Akinloye is an AI Engineer based in London, UK. He builds production-ready agentic AI systems, multi-agent architectures, and sophisticated RAG implementations, and writes about the engineering decisions behind them.