Agent Evaluation

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page’s examples target Python 3.12 with LangChain 1.4.2 / LangGraph 1.2.12, langchain-ollama 1.1.0, agentevals 0.0.9 and pytest, Java 21 with LangChain4j 1.20.2 and JUnit 5, and Spring Boot 4.1 with Spring AI 2.0.1, using DocsAssistant's Ollama llama3.1:8b for both the agent and the judge, as checked on 2026-09-28 (see the AI Agents baseline).

The thing being evaluated here is the DocsAssistant agent built across this sub-section, on top of the corpus, models and retrieval pipeline described in the Vector Databases & RAG section’s scenario; retrieval quality itself (recall@k, MRR, faithfulness of RAG answers) is covered on that section’s Evaluating RAG Systems page. This page covers offline, agent-specific evaluation: test sets run before release to decide whether a change made the agent better or worse. Online monitoring of a deployed agent — live metrics, drift, user feedback — is the domain of LLMOps: Online Evaluation & Feedback; the tracing it relies on is introduced on Observability for Agents.

Component and end-to-end evaluation

An agent is a pipeline of decisions, and it can be evaluated at two levels:

  • Component evaluations test one part in isolation, with its inputs fixed: does the router classify correctly, does docs_search return the right page for this query, does the planner produce a valid plan, does the memory recall find the stored preference? They are fast, cheap, deterministic enough to run on every commit, and they localise failures.

  • End-to-end evaluations run the whole agent on a realistic task and judge the outcome: was the question answered correctly, with the right citations, within budget? They catch interaction failures no component test sees, but they are slower, noisier and harder to debug.

Use both: end-to-end evals say whether the agent got worse; component evals say where. The three failure families on Planning map directly onto what to test — planning failures (plan validity, goal achievement), tool failures (each tool’s own test set) and efficiency failures (steps, tokens and latency per task).

flowchart LR subgraph Component evals R[Router accuracy] T[Tool: search returns the right page] P[Planner: valid plan] M[Memory: recall finds the fact] end subgraph End-to-end evals TR[Trajectory: right tools, right order] O[Outcome: correct, cited, complete] E[Efficiency: steps, tokens, time] end R --> TR T --> TR P --> TR TR --> O TR --> E

Trajectory evaluation

An agent can reach a right answer by a wrong route (a lucky guess with no search) or a wrong answer by a reasonable route (the right search, a bad page). Trajectory evaluation grades the route: the sequence of tool calls the agent actually made, compared with a reference. Common matching modes, from strictest to loosest:

  • strict — the same tool calls in the same order;

  • unordered — the same set of calls, in any order;

  • subset / superset — the agent’s calls are contained in, or contain, the reference (for example "must at least have searched before filing").

Arguments can be matched exactly, ignored, or compared per tool with a custom rule (a query only has to contain the key term). Where no single reference trajectory exists, an LLM judge can grade whether the trajectory is logical, efficient and equivalent to a reference, instead of an exact match. LangChain’s agentevals package implements both:

Trajectory matching with agentevals
from agentevals.trajectory.match import create_trajectory_match_evaluator
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage

must_file_issue = create_trajectory_match_evaluator(
    trajectory_match_mode="superset",       # the agent may do more, but must do at least this
    tool_args_match_mode="ignore",
)

reference = [
    HumanMessage("The memory page is wrong -- report it."),
    AIMessage("", tool_calls=[{"name": "github_open_issue", "args": {}, "id": "ref-1"}]),
    ToolMessage("filed", tool_call_id="ref-1"),
    AIMessage("Filed."),
]

result = agent.invoke({"messages": [("user", "The memory page is wrong -- report it.")]})
must_file_issue(outputs=result["messages"], reference_outputs=reference)
# -> {"key": "trajectory_superset_match", "score": True, ...}

create_trajectory_llm_as_judge is the judge-based counterpart, with a default rubric for trajectory accuracy. See agentevals and LangSmith — Evaluation concepts.

Tool-call accuracy

Tool-call accuracy is the component metric most specific to agents: given a question, did the agent call the right tools with the right arguments — and not call the ones it should not? For a case with expected tool set and called set :

plus argument accuracy (the share of expected calls whose arguments match, exactly or by a per-tool rule) and a forbidden-call rate that must be zero for safety-relevant tools — a question about the weather must never reach github_open_issue. Tool descriptions are prompts, so this metric is also how to evaluate a change to a tool’s name, description or schema (see Tool Design).

LLM-as-judge rubrics

Final answers rarely have one correct string, so outcome evaluation usually uses a model as the grader. A judge is only as good as its rubric:

  • Score one criterion at a time on a small, anchored scale ("5 = every claim is supported by a cited page, 1 = mostly unsupported"), rather than one holistic score.

  • Ask for the reasoning first, then the score, and constrain the output with structured output.

  • Give the judge the evidence — the expected pages, the retrieved chunks — so it checks grounding rather than plausibility.

  • Calibrate the judge against humans. Label a sample by hand and measure agreement before trusting the judge’s numbers; a strong judge model grading a weaker agent model is the usual arrangement.

  • Watch for judge biases: preferring longer answers, answers in its own style, or whichever of two answers it sees first.

The rubric-plus-grounding approach is also what Spring AI’s built-in evaluators implement: RelevancyEvaluator checks that an answer is relevant to the question and its retrieved context, and FactCheckingEvaluator checks that a claim is supported by the supplied documents.

Evaluation datasets

An evaluation dataset is a list of cases, each with an input and whatever is needed to grade it: expected tools, expected pages, a reference answer or a rubric. Build it from:

  • real traffic — sampled, anonymised questions, which is where the long tail lives;

  • failures — every bug report and every escalation becomes a case, so the same mistake cannot return silently;

  • adversarial cases — out-of-scope questions, prompt injections, requests that should not trigger the write tool (see Agent Guardrails & Safety);

  • synthetic cases — model-generated variations to widen coverage, reviewed by a person before they count.

Keep the dataset versioned next to the code, tag cases by category (concept, how-to, report, out of scope, attack), and report metrics per category: an average over everything hides a regression in the one category that matters.

A DocsAssistant evaluation harness

The harness below runs the agent over a small dataset, extracts the trajectory from the returned messages, computes the tool metrics above, and asks a judge for rubric scores:

eval_harness.py
import json
from dataclasses import dataclass, field

from langchain_ollama import ChatOllama
from pydantic import BaseModel


@dataclass
class Case:
    question: str
    expected_tools: list[str]                     # in the order a good trajectory calls them
    expected_pages: list[str]                     # pages a correct answer must cite
    forbidden_tools: list[str] = field(default_factory=list)


DATASET = [
    Case("What does ef_search control in HNSW?", ["docs_search"],
         ["modules/ROOT/pages/database/vector-rag/ann-indexes.adoc"]),
    Case("The memory page says MessageWindowChatMemory keeps 10 messages -- report it.",
         ["docs_search", "github_open_issue"], ["modules/ROOT/pages/ai/agents/memory.adoc"]),
    Case("What's the weather in Madrid?", [], [], forbidden_tools=["github_open_issue"]),
]


def tool_calls(messages) -> list[dict]:
    return [{"name": c["name"], "args": c["args"]}
            for m in messages if m.type == "ai" for c in (m.tool_calls or [])]


def tool_metrics(case: Case, calls: list[dict]) -> dict:
    called = [c["name"] for c in calls]
    expected = set(case.expected_tools)
    hits = len(expected & set(called))
    remaining = iter(called)                      # subsequence: repeats and extra calls are allowed
    return {
        "tool_recall": hits / len(expected) if expected else 1.0,
        "tool_precision": hits / len(set(called)) if called else float(not expected),
        "in_order": all(t in remaining for t in case.expected_tools),
        "forbidden_called": any(n in case.forbidden_tools for n in called),
        "steps": len(calls),
    }


class Verdict(BaseModel):
    grounded: int      # 1-5: every claim supported by a cited page
    complete: int      # 1-5: answers the whole question
    cites_expected: bool
    reasoning: str


judge = ChatOllama(model="llama3.1:8b", temperature=0).with_structured_output(Verdict)
RUBRIC = """Grade the answer. grounded: 5 = every claim is supported by a cited page, 1 = mostly
unsupported. complete: 5 = fully answers the question, 1 = does not. cites_expected: does it cite
all of these pages: {pages}? Explain briefly in reasoning before scoring.
Question: {question}
Answer: {answer}"""


def evaluate(agent, dataset=DATASET) -> list[dict]:
    rows = []
    for case in dataset:
        result = agent.invoke({"messages": [("user", case.question)]})
        messages = result["messages"]
        verdict = judge.invoke(RUBRIC.format(pages=case.expected_pages, question=case.question,
                                             answer=messages[-1].content))
        rows.append({"question": case.question, **tool_metrics(case, tool_calls(messages)),
                     **verdict.model_dump(exclude={"reasoning"})})
    return rows

in_order is a subsequence check: the expected tools must appear in that order, but a trajectory that repeats a tool (a second docs_search after a thin first result) or makes an extra call in between still passes. The judge sees the expected pages, so cites_expected is a grounded check rather than a guess, and reasoning is requested but excluded from the aggregated row. See LangChain — Structured output. LangSmith offers the same loop as a managed service — datasets, experiments, trajectory and LLM-as-judge evaluators, and side-by-side comparison of runs; see LangSmith — Evaluation.

Regression suites

A regression suite turns the harness into a gate: thresholds that fail the build when a change — a new prompt, a new model, a new tool description — makes the agent worse.

test_regression.py
import statistics

import pytest

from docsassistant.agent import build_agent            # the agent under test, with stubbed write tools
from eval_harness import DATASET, evaluate


@pytest.fixture(scope="session")
def results():
    return evaluate(build_agent(dry_run=True))         # dry_run: github_open_issue records, never posts


def test_never_calls_forbidden_tools(results):
    assert not any(r["forbidden_called"] for r in results)


def test_tool_selection(results):
    assert statistics.mean(r["tool_recall"] for r in results) >= 0.9


def test_groundedness(results):
    assert statistics.mean(r["grounded"] for r in results) >= 4.0


def test_efficiency(results):
    assert max(r["steps"] for r in results) <= 6

Run it in CI on every change to prompts, tools or models, with the write tools replaced by recording stubs so the suite can never open a real issue. Because agents are non-deterministic, assert on aggregates with some margin rather than on single outputs, run each case more than once when the suite is small, and pin the model versions the thresholds were set with. See pytest documentation.

Spring AI: a fact-checking regression test
import org.springframework.ai.chat.evaluation.FactCheckingEvaluator;
import org.springframework.ai.document.Document;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;

@SpringBootTest
class DocsAssistantEvaluationTest {

    @Autowired DocsAssistantAgent agent;
    @Autowired ChatClient.Builder judgeBuilder;          // configured with the judge model

    @Test
    void answerIsSupportedByTheDocs() {
        String answer = agent.ask("What does ef_search control in HNSW?");
        List<Document> sources = agent.lastRetrievedDocuments();

        EvaluationResponse verdict = FactCheckingEvaluator.builder(judgeBuilder).build()
                .evaluate(new EvaluationRequest(sources, answer));

        assertThat(verdict.isPass()).as(verdict.getFeedback()).isTrue();
    }
}

See Spring AI — Evaluation testing. LangChain4j has no evaluation module; the same test is an ordinary JUnit test in which a judge AI service returns a typed verdict:

LangChain4j: a judge as an AI service in a JUnit test
record Verdict(int grounded, boolean citesExpected, String reasoning) {}

interface Judge {
    @UserMessage("""
            Grade the answer. grounded: 5 = every claim supported by a cited page, 1 = mostly unsupported.
            citesExpected: does it cite {{page}}? Explain in reasoning first.
            Question: {{question}}
            Answer: {{answer}}""")
    Verdict grade(@V("question") String question, @V("answer") String answer, @V("page") String page);
}

class DocsAssistantEvalTest {

    Judge judge = AiServices.create(Judge.class, judgeModel);

    @Test
    void efSearchAnswerIsGrounded() {
        String answer = assistant.chat("eval-user", "What does ef_search control in HNSW?");
        Verdict v = judge.grade("What does ef_search control in HNSW?", answer,
                "modules/ROOT/pages/database/vector-rag/ann-indexes.adoc");
        assertThat(v.grounded()).as(v.reasoning()).isGreaterThanOrEqualTo(4);
        assertThat(v.citesExpected()).isTrue();
    }
}

See LangChain4j — AI Services for typed return values.

In other frameworks

Framework Evaluation support

OpenAI Agents SDK

Runs are traced (tool calls, handoffs, guardrails), and traces feed OpenAI’s evaluation tooling; offline suites are built over Runner.run results.

Claude Agent SDK

Result messages report turns, cost and usage per run, which a harness aggregates; trajectories are read from the streamed messages.

Microsoft Agent Framework

Built-in evaluation types (evaluators, eval items and results, expected-tool-call checks such as tool_called_check) for agent tests.

CrewAI

Crew.test(n_iterations=…​, eval_llm=…​) runs a crew several times and scores each task’s output with a judge model.