Evaluations with Phoenix 101
- AI Engineer NYC · October 12, 2026
- Laurie Voss

Hello
Get set up!
- github.com/seldo/aie-nyc
- Follow "Setup" at the top of the README
Open localhost:6006
You shipped an agent.
Is it any good?
Today you leave with a repo
whose evals run in CI
What is an eval?
Traces are logs. Evals are tests.
- Traces = logs, for AI
- Evals = tests, for AI
Spans
are the building blocks of a trace
- One span = one step
- LLM call, tool call, agent turn
- Input, output, timing, tokens, cost
"Looks good to me"
is not a test suite
Without evals,
every change is a gamble
You can't switch models without evals
- New models every few months
- Without evals: weeks of manual testing
- With evals: an afternoon
Code evals are fast, free, and brittle
- Deterministic functions you write
- Milliseconds, no cost, same answer every time
- Brittle when there are many right answers
LLM judges understand meaning,
and they make mistakes
- A second LLM grades the output against a rubric
- Handles accuracy, relevance, tone, grounding
- Costs money, non-deterministic, needs checking
Humans are the gold standard,
but they don't scale
Which eval when
- Code → format, structure, required content
- LLM judge → accuracy, grounding, tone, usefulness
- Human → new kinds of failure, checking the judges
Agents make all of this harder
- One LLM call: input → output
- Agent: input → tool → result → reasoning → tool → … → output
- Every step can go wrong, and errors cascade
Confidently wrong is worse
than obviously wrong
Sometimes the agent is right
and your eval is wrong
An eval result is a score, a label,
and an explanation
score: 0
label: "not actionable"
explanation: "The report summarizes revenue
and margins but never says whether
to buy, hold, or sell..."
The explanation is where the value is
This is the loop we're building today
Step 1: tracing
Before you can evaluate anything, you need data
Phoenix
is open-source AI observability
- Open source, runs anywhere
- Traces from any framework, via OpenTelemetry
- Evals, datasets, experiments, prompts
The repo is a set of small scripts
- src/instrumentation.ts — tracing setup
- src/agent.ts — the agent
- src/prompts.ts — the prompts you'll edit
- src/evals/ — the evals we'll write
- data/queries.json — test queries
We're building a financial analyst
The Claude Agent SDK
- Anthropic's agent framework
- Tools, web search, sessions
- Traced by OpenInference
Tracing takes about five lines
import { register } from "@arizeai/phoenix-otel";
import { ClaudeAgentSDKInstrumentation } from
"@arizeai/openinference-instrumentation-claude-agent-sdk";
import * as ClaudeAgentSDKModule from "@anthropic-ai/claude-agent-sdk";
register({ projectName: "phoenix-101" });
const instrumentation = new ClaudeAgentSDKInstrumentation();
export const ClaudeAgentSDK =
instrumentation.manuallyInstrument(ClaudeAgentSDKModule);
OpenTelemetry does the heavy lifting
manuallyInstrument
hands you a copy.
Use it.
Our agent's options
const options = {
model: "claude-haiku-4-5",
allowedTools: ["WebSearch"],
permissionMode: "dontAsk",
settingSources: [],
skills: [],
env: withoutClaudeCodeVariables(process.env),
};
Keep your laptop out of your agent
One turn is one query() call
async function runTurn(prompt: string, resume?: string) {
let text = "", sessionId = resume;
for await (const msg of ClaudeAgentSDK.query({
prompt, options: { ...options, resume },
})) {
if (msg.type === "result" && msg.subtype === "success") {
text = msg.result;
sessionId = msg.session_id;
}
}
return { text, sessionId };
}
Two turns, one trace
export const financialReport = traceAgent(
async ({ tickers, focus }: Query) => {
const research = await runTurn(prompts.research(tickers, focus));
const report = await runTurn(prompts.write, research.sessionId);
return { report: report.text, research: research.text };
},
{ name: "financial_report",
processOutput: (r) => ({ "output.value": r.report }) },
);
The prompts are deliberately bad
- RESEARCH: "Research {tickers}. Focus on: {focus}. Use web search to find current financial data."
- WRITE: "Now write a concise financial report based on your research above."
Run it
npm run agent -- TSLA "growth outlook"
Non-deterministic by design
Every decision is in the trace
What lives in a span
span kind: AGENT
input.value: {"tickers":"TSLA","focus":"growth outlook"}
output.value: "I'll provide the financial report here instead…"
tokens, latency, llm.cost.total
One trace isn't a test suite.
Run fifteen.
npm run batch
Edge cases are where agents fall over
- AAPL · MSFT: compare two companies
- RIVN: much less data out there
- KO: dividends, not growth
- ZZZQ: a ticker that doesn't exist
- TSLA: "yo is it a buy rn"
Write down what "good" means before you measure it
Defining "good"
is not just an engineering job
No users yet?
Make some up, carefully.
- Same intent, different words:
- "Research TSLA financial performance"
- "what's going on with tesla stock"
- "yo is TSLA a buy rn"
Or let your coding agent do the setup
npx @arizeai/phoenix-cli setup
Step 2: read your data
The step everyone skips
Read your traces
before you write a single eval
Reading traces doesn't scale.
Your coding agent does.
Install the Phoenix plugin or skill
# Claude Code:
claude plugin marketplace add Arize-ai/phoenix
claude plugin install arize-phoenix@arize-phoenix
# Anything else:
npx skills add Arize-ai/phoenix --skill phoenix-error-analysis
Ask your agent to do the error analysis
- "Use Phoenix at localhost:6006, project phoenix-101.
- Read the financial_report traces.
- Propose failure categories, count how many traces fall in each,
- and annotate each trace with its category."
What I found when I read my traces
The data looks right but isn't
- "Analyst Consensus: BUY (50% buy | 52.6% hold | 13.1% sell)"
Ask why it failed
- Bad search results → retrieval failure
- Right data, wrong conclusion → reasoning error
- Made-up numbers → hallucination
- Answered something else → scope problem
Write notes first, categories second
- 1. Open coding: one note per trace, in plain words
- 2. Axial coding: group the notes into categories
- 3. Then count
The Swiss cheese model
Stack your eval layers
Check your agent's homework
Frequency × severity = priority
Questions so far?
Step 3: code evals
No model, no API call, just TypeScript
Does the report mention
the stock we asked about?
Get your agent runs out of Phoenix
const { spans } = await getSpans({
project: { projectName: "phoenix-101" },
parentId: null,
limit: 200,
});
The ticker check
export const mentionsTicker = createEvaluator(
({ input, output }: { input: Query; output: string }) => {
const tickers = input.tickers.split(",").map((t) => t.trim());
const missing = tickers.filter((t) => !output.includes(t));
return missing.length === 0
? { label: "pass", score: 1 }
: { label: "fail", score: 0,
explanation: `Missing: ${missing.join(", ")}` };
},
{ name: "mentions_ticker", kind: "CODE" },
);
Structured inputs make evals easier
Run it, then log it back to Phoenix
await logSpanAnnotations({
spanAnnotations: results.map((r) => ({
spanId: r.spanId, name: "mentions_ticker",
label: r.label, score: r.score,
explanation: r.explanation, annotatorKind: "CODE",
})),
sync: true,
});
npm run eval:code
Five lines of code
just caught a real bug
Code evals aren't toys
- Did it parse as JSON?
- Is it under the length limit?
- Does it avoid "As an AI language model…"?
- Does the product it mentions exist in the database?
Grade the outcome, not the path
Step 4: LLM-as-a-judge
Built-in evals
Code can't tell you
if the analysis is any good
Every judge has three parts
- 1. A judge model
- 2. A rubric
- 3. The data being judged
Phoenix ships a shelf of built-in evals
- Correctness · Faithfulness · Completeness · Conciseness
- Hallucination · Refusal · Toxicity · PII
- Tool selection · Tool invocation · Tool response handling
- Retrieval relevance · User friction
Use a different model to judge
import { anthropic } from "@ai-sdk/anthropic";
const judge = anthropic("claude-sonnet-5-5");
const telemetry = { isEnabled: false };
Suppress tracing on your judge
- telemetry: { isEnabled: false }
Correctness is the obvious first choice
import { createCorrectnessEvaluator } from "@arizeai/phoenix-evals";
const correctness =
createCorrectnessEvaluator({
model: judge,
telemetry
});
const result = await correctness.evaluate({ input, output });
Every score is zero
The eval is half wrong and half right
Built-in evals
are just prompts you can read
Faithfulness asks a better question
- Correctness: "Is this true?" (judge has no context)
- Faithfulness: "Does this match what the agent found?" (judge gets the research)
The context is already in the trace
Faithfulness takes three things
const faithfulness =
createFaithfulnessEvaluator({ model: judge, telemetry });
await faithfulness.evaluate({
input: formatQuery(run.input),
output: run.output,
context: `${run.searchResults}\n\nResearch notes:\n${run.research}`,
});
Grade the tool calls too
- createToolSelectionEvaluator
- createToolInvocationEvaluator
- createToolResponseHandlingEvaluator
Tool selection takes three things
await toolSelection.evaluate({
input: turn.prompt,
availableTools: AVAILABLE_TOOLS,
toolSelection: turn.tools.join("\n"),
});
npm run eval:tools
Research turns: 16 for 16.
Writing turns: 9 for 16.
The judge only knows
what you told the agent
Eval results live in the trace
Choosing the right eval
beats tuning it
Step 5: write your own judge
No built-in checks
whether a report is actionable
Five parts of a good eval prompt
- 1. The judge's role
- 2. Explicit criteria
- 3. The data, clearly marked
- 4. Labeled examples
- 5. A constrained answer
Part 1: give the judge a role,
and context
- "You are evaluating a financial report.
- Your job is to decide whether it gives actionable
- investment guidance, not just a summary of data."
Part 2: explicit criteria
- ACTIONABLE: the report
- - makes a specific recommendation (buy / hold / sell or equivalent)
- - names concrete risks, with supporting data
- - looks forward, not just back
- - explains *why* it recommends what it does
- NOT ACTIONABLE: the report
- - only summarizes data, with no interpretation
- - makes no recommendation or next step
- - mentions risks with no evidence
- - only describes the past
Your criteria come
from your error analysis
Part 3: mark the data clearly
<data>
<request>
{{input}}
</request>
<report>
{{output}}
</report>
</data>
Part 4: add labeled examples, carefully
My examples
made my judge slightly worse
- With examples: agreed with me 15, 15, 15 times out of 16
- Without examples: 16, 15, 16
An actionable example
- "NVDA's data center revenue grew 120% year on year and margins are
- expanding; at 35x forward earnings against a sector median of 22x,
- the premium is justified only if that growth holds. Key risk: most
- revenue comes from a handful of cloud customers. Recommendation:
- accumulate on pullbacks; trim if data center growth drops below 40%."
A not-actionable example
- "NVDA is a major player in the semiconductor industry. The company
- has seen significant growth in recent years driven by AI demand.
- Investors should consider various factors when making decisions."
Part 5: constrain the answer
- "Is this report ACTIONABLE or NOT ACTIONABLE?"
Your turn: write the rubric
- src/evals/actionability.ts
Wire it up
export const actionability = createClassificationEvaluator({
name: "actionability",
model: judge,
telemetry,
promptTemplate: ACTIONABILITY_TEMPLATE,
choices: { actionable: 1, "not actionable": 0 },
});
npm run eval:custom
Read the explanations
Treat eval prompts like code
I added some tags
and the verdict flipped
- Examples as plain text: not actionable ×5
- Examples wrapped in XML tags: actionable ×5
One evaluator per dimension
Guardrails versus north stars
Step 6: test your tests
Can you trust your judge?
Your judge is a classifier
- It predicts: actionable or not actionable
- Predictions can be checked against the truth
- The truth is: what a human expert says
Label some reports yourself
Human labels are your golden dataset
Write tasks a human could pass
- 0% pass rate across many runs → probably a broken task
- Each task needs a known-good answer
- Test that behaviors happen and that they don't
Don't tune on your test set
- dev (75%): iterate on the rubric
- test (25%): check it once, at the end
Build the golden dataset, with splits
labeled.sort((a, b) => a.spanId.localeCompare(b.spanId));
await createDataset({
name: "actionability-golden",
examples: labeled.map((run, i) => ({
input: { input: run.input, output: run.output },
output: { label: run.humanLabel },
splits: i % 4 === 0 ? "test" : "dev",
spanId: run.spanId,
})),
});
Run the judge against the humans
await runExperiment({
dataset: { datasetName: "actionability-golden", splits: ["dev"] },
task: async (example) => {
const { label, explanation } = await actionability.evaluate(example.input);
return { label, explanation };
},
evaluators: [agreesWithHuman],
});
npm run meta
The judge agreed with me
twelve times out of twelve
Judge vs. human on the dev split (12 reports)
judge: fail judge: pass
human: fail 9 0
human: pass 0 3
Agreement: 100%
The two reports I hesitated over
The judge read my rubric
the way I meant it
The tempting fix made it worse
- + "A report is ACTIONABLE only if it meets ALL FOUR criteria."
- dev: 12/12 → 11/12
- test: 4/4 → 3/4
Precision and recall
- Precision: when the judge says "fail", how often is it right?
- Recall: of all the real fails, how many did it catch?
Prioritize recall
Judges have biases
- Position: prefers the first (or last) option
- Length: longer answers score higher
- Confidence: fooled by confident wrong answers
- Self-preference: likes its own model's work
Humans don't agree
with each other either
- Two human experts, same rubric:
- Cohen's kappa often 0.2–0.3
Failures should seem fair
The eval was broken, not the model
- Expected "96.124991", rejected "96.12"
- Ambiguous task descriptions
- Tasks that couldn't be reproduced
- 42% → 95% after fixing the eval
Step 7: experiments
Prove the fix worked
A prompt change you haven't measured is a vibe
Save your failures and your passes
Fix what the evals found
- WRITE_PROMPT: "Now write a concise financial report based on your research above.
- The report MUST be actionable:
- - Make an explicit buy / hold / sell recommendation, with reasons
- - Name concrete risks, with supporting data
- - Look forward, not just back
- - If there are several tickers, include a section comparing them
- - Only use numbers that appear in your research
- - Return the report as your reply. Don't write it to a file."
Every change maps to a finding
An experiment is a dataset, a task, and evaluators
await runExperiment({
experimentName: "improved-prompts",
dataset: { datasetName: "financial-agent-regressions" },
task: async (example) => financialReport(example.input),
evaluators: [
asExperimentEvaluator({ name: "actionability", kind: "LLM",
evaluate: ({ input, output }) => actionability.evaluate({
input: formatQuery(input), output: output.report }) }),
// ...and the same for mentions_ticker and faithfulness
],
repetitions: 1,
concurrency: 5,
});
npm run experiment
How many examples do you need?
- Today: 10–20 examples → a direction
- Shipping decisions: hundreds
Why I ran it three times
- pass@k: succeeds at least once in k tries
- pass^k: succeeds every time in k tries
While it runs:
fix things in the right order
- 1. Data quality
- 2. Prompts
- 3. Model choice
- 4. Hyperparameters
Compare the runs
- mentions_ticker: 15/16 → 48/48
- actionability: 5/16 → 48/48
- faithfulness: 2/16 → 3/48
We fixed what we measured
Same input, different advice
Same question, same call: a code eval
export function recommendationOf(report: string) {
const lines = report.split("\n");
const headline =
lines.find((l) => /recommendation|rating|verdict/i.test(l) && CALL.test(l)) ??
lines.find((l) => CALL.test(l));
return headline?.match(CALL)?.[1] ?? null;
}
export const consistentCall = createEvaluator(({ calls }) => ({
label: calls.every((c) => c === calls[0]) ? "consistent" : "inconsistent",
explanation: calls.join(", "),
}), { name: "consistent_call", kind: "CODE" });
It runs on the experiment
you already have
npm run eval:consistency
✘ RIVN: financial health and cash runway HOLD, HOLD, SELL
✘ MSFT: cloud computing growth BUY, HOLD, BUY
✘ TSLA: yo is it a buy rn HOLD, SELL, HOLD
✔ KO: dividend yield and stability BUY, BUY, BUY
8 of 16 questions got the same call every time.
A capability eval that fails today
Would a newer model fix it?
- AGENT_MODEL=claude-haiku-5-5 npm run experiment
Haiku 4.5 vs. Haiku 5.5
- mentions_ticker: 48/48 → 48/48
- actionability: 48/48 → 48/48
- faithfulness: 3/48 → 10/48
- consistent_call: 8/16 → 14/16
- cost per run: $0.30 → $1.17
The newer model is more careful
Consistent, or just cautious?
- Haiku 5.5, 45 runs on real tickers:
- HOLD 41 · BUY 4 · SELL 0
Four times the price
The eval-iterate cycle
- Find failures → read the explanations → change the agent → run the experiment → repeat
Step 8: evals in CI
Keep it fixed
Write the eval before the feature
Capability evals
become regression evals
- Capability eval: can it do this new thing yet? (expected to fail at first)
- Regression eval: can it still do everything it used to?
Evals are tests, so run them like tests
import * as px from "@arizeai/phoenix-client/vitest";
px.describe("financial agent", () => {
px.test.each(regressionQueries)(
(row) => `report for ${row.input.tickers}`,
async ({ input }) => {
const { report } = await financialReport(input);
px.logOutput(report);
await px.evaluate(mentionsTicker);
await px.evaluate(actionability, { input: formatQuery(input) });
});
}, { acceptanceCriteria: [...] });
Gate the build on acceptance criteria
acceptanceCriteria: [
{ annotationName: "mentions_ticker", metric: "passRate",
passFn: (a) => a.score === 1, minPassRate: 1 },
{ annotationName: "actionability", metric: "average",
threshold: 0.8 },
]
npm test
This is what a red build looks like
financial agent 4/4 passed · 1 miss
mentions_ticker 1.00 actionability 0.75
✗ report for RIVN
actionability 0 · The report is data-rich and forward-looking: …
✗ acceptance actionability average 0.750 (need mean >= 0.800; 4 samples)
Every test run
is an experiment in Phoenix
The same suite
runs in GitHub Actions
services:
phoenix:
image: arizephoenix/phoenix:version-20.19.0
ports: ["6006:6006"]
steps:
- run: npm ci
- run: npm test
env:
PHOENIX_ENDPOINT: http://localhost:6006
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
Now a new model
is an afternoon, not a month
Anyone who knows what "good" looks like can write evals
Don't run your whole agent
on every commit
This is the repo I promised you
Once you're live, keep evaluating
- Run evals on a sample of live traffic
- Alert on trends, not on single failures
- Feed production failures back into your datasets
Here's what we built
Start small
- 1. Read 20 traces
- 2. Write one code eval
- 3. Add one LLM eval for the thing that matters most
Read your traces. Then write one eval. Then put it in CI.
Go try it
- github.com/seldo/aie-nyc
- arize.com/docs/phoenix
- github.com/Arize-ai/phoenix
Thank you!
- @seldo.com on Bluesky 🦋
- Laurie Voss on LinkedIn
Phoenix 101 (AIE NYC)
By Laurie Voss
Phoenix 101 (AIE NYC)
- 21