에이전트형 평가자

에이전트형 평가자 (Agentic Evaluators)

에이전트의 궤적 -- 즉 최종 출력뿐 아니라 툴 호출의 순서와 인자 -- 을 평가하는 결정적(deterministic) 스팬 기반 평가자예요.

출처: 문서

본문

logfire SDK가 필요해요

이 평가자들은 실행 중에 캡처된 OpenTelemetry 스팬 트리를 읽기 때문에, logfire SDK가 설치되고 구성되어 있어야 해요. Pydantic Logfire 계정은 필요 없어요. SDK가 스팬을 로컬에서 캡처해주니까요.

Terminal

pip install 'pydantic-evals[logfire]'

스팬을 사용할 수 없으면 각 평가자는 예외를 던지는 대신 실패 결과(불리언 평가자는 False, TrajectoryMatch0.0)와 함께 logfire 구성으로 안내하는 이유를 반환해요.

로컬에서 실행되는 툴만 해당돼요

이 평가자들은 실행 시 로컬 OpenTelemetry 스팬을 만드는 툴 -- 즉 Pydantic AI가 직접 호출하는 툴 -- 을 봐요. 네이티브 툴(예: OpenAI의 file search나 Anthropic의 web search)은 로컬 스팬을 만들지 않아서 이 평가자들에게 보이지 않아요. 그런 툴을 평가하려면 제공자의 스팬이나 모델 출력을 대상으로 HasMatchingSpan을 사용해요.

무엇이 툴 호출로 간주될까요

모든 실행 시도 는 스팬을 만들며, 다음과 같이 구분돼요:

  • 오류로 끝난 시도 -- 툴 본문이 예외를 던지거나 ModelRetry로 재시도를 요청한 경우 -- 는 기본적으로 계수되지 않아요. 모든 시도를 세려면 include_failed=True를 전달해요. 예외: MaxToolCalls는 기본적으로 실패한 시도도 계수해요(그것도 예산을 소비하니까요). 거기서 성공 호출만 세려면 include_failed=False를 전달해요.
  • 지연된 호출(ApprovalRequired / CallDeferred)은 절대 계수되지 않아요: 이번 실행에서 실행되지 않았으니까요.
  • 캡처된 트레이스의 모든 일치 스팬이 계수돼요. 중첩된 하위 에이전트(agent-as-tool 위임)가 만든 툴 호출도 포함돼요. 하위 에이전트에 위임해서 거기가 자체 툴을 호출한다면 그 호출들을 기대치와 예산에 반영해요.

에이전트형 평가자는 순수한 입력/출력 검사로는 답할 수 없는 "에이전트가 올바른 일을 했나?"라는 질문들에 답해줘요:

  • 툴 커버리지 -- 에이전트가 호출해야 할 특정 툴들을 호출했나요? (ToolCorrectness)
  • 툴 호출 순서 -- 올바른 순서로 호출했나요, 적어도 올바른 집합을 사용했나요? (TrajectoryMatch)
  • 인자 품질 -- 툴이 기대한 입력을 받았나요? (ArgumentCorrectness)
  • 예산 규율 -- 에이전트가 툴 호출 및/또는 모델 요청 예산 안에서 끝냈나요? (MaxToolCalls, MaxModelRequests)

이들 모두 결정적이고, LLM을 호출하지 않으며, 모든 실험의 모든 케이스에서 실행해도 충분히 저렴해요.

ToolCorrectness

에이전트가 특정한 멀티셋의 툴을 호출했는지 판정해요. 이름이 반복되면 반복 호출이 필요해요.

from pydantic_evals import Case, Dataset
from pydantic_evals.evaluators import ToolCorrectness

dataset = Dataset(
    name='rag_agent',
    cases=[Case(inputs='Summarize the latest papers on X')],
    evaluators=[
        ToolCorrectness(
            expected_tools=['search', 'rerank', 'generate'],
        ),
    ],
)

매개변수:

  • expected_tools (list[str]): 에이전트가 호출할 것으로 기대되는 툴 이름. 순서는 상관없지만 중복은 의미가 있어요 -- ['search', 'search']search 호출 두 번을 요구해요.
  • allow_extra (bool, 기본 False): 기본적으로 expected_tools에 없는 툴 호출은 검사를 실패시켜요. True로 설정하면 기대 툴이 호출되기만 하면 되고 추가는 허용돼요.
  • include_failed (bool, 기본 False): 오류로 끝난 툴 호출 시도를 계수할지 여부.
  • evaluation_name (str | None): 보고서의 커스텀 이름.

반환: bool 값을 가진 EvaluationReason. reason은 누락되고 예상치 못한 툴을 지목해줘요.

TrajectoryMatch

실제 툴 이름의 정렬된 목록을 기대 목록과 비교하는데, 세 가지 모드 중 하나를 사용해요.

from pydantic_evals import Case, Dataset
from pydantic_evals.evaluators import TrajectoryMatch

dataset = Dataset(
    name='ordered_tools',
    cases=[Case(inputs='Process and file this request')],
    evaluators=[
        TrajectoryMatch(
            expected_trajectory=['validate', 'enrich', 'submit'],
            order='in_order',
        ),
    ],
)

매개변수:

  • expected_trajectory (list[str]): 기대하는 툴 이름의 정렬된 목록.
  • order (Literal['exact', 'in_order', 'any_order'], 기본 'in_order'):
    • 'exact' -- 수열이 같으면 1.0, 아니면 0.0.
    • 'in_order' -- 최장 공통 부분 수열(LCS)에서 F1을 계산해요. 정밀도 = LCS / len(actual), 재현율 = LCS / len(expected). 기대 순서 사이에 추가 호출이 섞여도 허용되지만 정밀도를 낮춰요.
    • 'any_order' -- 멀티셋 교집합에서 F1을 계산해요. 정밀도 = overlap / len(actual), 재현율 = overlap / len(expected). 순서는 무시되지만 추가·누락 호출 모두 점수를 낮춰요.
  • include_failed (bool, 기본 False): 궤적에 오류로 끝난 툴 호출 시도를 포함할지 여부.
  • evaluation_name (str | None): 보고서의 커스텀 이름.

반환: [0.0, 1.0] 범위의 float 값을 가진 EvaluationReason. F1 기반 모드에서는 reason 텍스트가 overlap, 정밀도, 재현율, F1을 풀어써서 불일치로부터 점수를 재현할 수 있어요.

예를 들어 expected = ['a', 'b', 'c']이고 에이전트가 ['a', 'x', 'b']를 호출했다면 LCS는 ['a', 'b'](길이 2)가 되어 정밀도 2/3, 재현율 2/3, F1 ≈ 0.667이 돼요.

기대 궤적과 실제 궤적이 모두 비어 있으면 모든 모드가 1.0을, 둘 중 하나만 비어 있으면 모든 모드가 0.0을 채점해요.

ArgumentCorrectness

특정 툴 호출이 특정 인자들을 받았는지 확인해요.

from pydantic_evals import Case, Dataset
from pydantic_evals.evaluators import ArgumentCorrectness

dataset = Dataset(
    name='support_agent',
    cases=[Case(inputs='Refund order 12345')],
    evaluators=[
        ArgumentCorrectness(
            tool_name='issue_refund',
            expected_arguments={'order_id': '12345'},
            match_mode='subset',
            occurrence='first',
        ),
    ],
)

매개변수:

  • tool_name (str): 검사할 툴.
  • expected_arguments (dict[str, Any]): 기대하는 인자 키/값.
  • match_mode (Literal['exact', 'subset'], 기본 'subset'):
    • 'subset' -- 모든 기대 키/값이 실제 인자에 존재해요. 이는 최상위 키에만 적용된다는 점에 주의해요. 기대 (중첩 딕셔너리 포함)은 실제 값과 완전히 동일해야 해요.
    • 'exact' -- 깊은 동등성. 예상치 못한 키도 실패해요.
  • occurrence (Literal['first', 'last'] | int, 기본 'first'): 툴이 여러 번 호출됐을 때 검사할 호출. 정수 인덱스는 0부터 시작해요.
  • include_failed (bool, 기본 False): 오류로 끝난 툴 호출 시도를 고려할지 여부. True면 각 시도가 별개의 occurrence로 계수돼요.
  • evaluation_name (str | None): 보고서의 커스텀 이름.

반환: bool 값을 가진 EvaluationReason.

우아한 축소(Graceful degradation): 이 평가자는 인자를 사용할 수 없을 때 크래시하지 않아요. 예를 들어 에이전트가 include_content=False로 계측됐다면 평가자는 상황을 설명하는 이유와 함께 False를 반환해서 보고서가 여전히 의미 있게 해줘요.

MaxToolCalls와 MaxModelRequests

에이전트가 툴 호출 및/또는 모델 요청 예산 안에 머물렀는지 판정해요. 이것들은 MaxDuration처럼 동작해요. 평가자당 하나의 예산이고, 각각 별도의 불리언 판정으로 보고돼요.

from pydantic_evals import Case, Dataset
from pydantic_evals.evaluators import MaxModelRequests, MaxToolCalls

dataset = Dataset(
    name='budget_aware',
    cases=[Case(inputs='Draft a short reply')],
    evaluators=[
        MaxToolCalls(max_calls=5),
        MaxModelRequests(max_requests=3),
    ],
)

매개변수:

  • MaxToolCalls: max_calls (int) -- 허용되는 로컬 실행 툴 호출의 최대 수. include_failed (bool, 기본 True)는 오류로 끝난 시도가 예산에 계수되는지 제어해요(기본적으로 계수돼요 -- 여전히 시간과 토큰을 소비하니까요).
  • MaxModelRequests: max_requests (int) -- 허용되는 모델(채팅) 요청의 최대 수. 가능하면 ctx.metricsrequests 값을 우선하고, 아니면 LLM 요청 스팬을 직접 계수해요(둘 다 같은 기준을 사용해요).
  • 둘 다 evaluation_name (str | None)을 받아 보고서의 이름을 커스터마이즈할 수 있어요 -- 같은 예산 검사가 데이터셋과 케이스 수준 양쪽에 나타날 때 유용해요.

반환: bool 값을 가진 EvaluationReason. reason은 관측된 개수와 예산을 포함해요.

레시피

RAG 에이전트

검색 파이프라인이 search → rerank → generate 순서로 실행되고 예상치 못한 툴 호출이 없는지 확인해요.

from pydantic_evals import Case, Dataset
from pydantic_evals.evaluators import ToolCorrectness, TrajectoryMatch

dataset = Dataset(
    name='rag_pipeline',
    cases=[Case(inputs='Find papers on in-context learning')],
    evaluators=[
        ToolCorrectness(
            expected_tools=['search', 'rerank', 'generate'],
        ),
        TrajectoryMatch(
            expected_trajectory=['search', 'rerank', 'generate'],
            order='exact',
        ),
    ],
)

순서가 중요한 멀티 툴 에이전트

가끔 재시도를 허용하되, 주요 단계는 순서대로 일어나도록 요구해요.

from pydantic_evals import Case, Dataset
from pydantic_evals.evaluators import TrajectoryMatch

dataset = Dataset(
    name='ordered_with_slack',
    cases=[Case(inputs='Process shipment 99')],
    evaluators=[
        TrajectoryMatch(
            expected_trajectory=['validate', 'enrich', 'submit'],
            order='in_order',  # F1 기반: 추가 호출은 정밀도만 낮추고 순서는 유지돼야 함
        ),
    ],
)

ArgumentCorrectness와 예산 검사를 쓰는 지원 에이전트

올바른 입력으로 올바른 조치가 -- 합리적인 단계 수 안에서 -- 이뤄졌는지 확인해요.

from pydantic_evals import Case, Dataset
from pydantic_evals.evaluators import (
    ArgumentCorrectness,
    MaxModelRequests,
    MaxToolCalls,
)

dataset = Dataset(
    name='refund_handling',
    cases=[
        Case(
            name='valid_refund',
            inputs={'query': 'Refund my order', 'order_id': '12345'},
            evaluators=[
                ArgumentCorrectness(
                    tool_name='issue_refund',
                    expected_arguments={'order_id': '12345'},
                ),
            ],
        ),
    ],
    evaluators=[
        MaxToolCalls(max_calls=4),
        MaxModelRequests(max_requests=2),
    ],
)

툴 호출 궤적으로 작업 완료 판단하기

결정적 검사로 충분하지 않은 작업에서는 LLM이 툴 호출 궤적과 함께 작업 결과를 판단하게 할 수 있어요. LLMJudge는 케이스 입력, 출력, 기대 출력만 볼 뿐 다른 평가자의 결과나 스팬 트리는 보지 못해요. 그래서 판단자에게 에이전트가 어떻게 거기에 도달했는지 보여주려면, 스팬 트리에서 궤적을 추출해서 judge_input_output에 직접 전달하는 작은 커스텀 평가자를 작성해요:

from dataclasses import dataclass

from pydantic_evals import Case, Dataset
from pydantic_evals.evaluators import EvaluationReason, Evaluator, EvaluatorContext
from pydantic_evals.evaluators.llm_as_a_judge import judge_input_output
from pydantic_evals.otel import SpanTreeRecordingError


@dataclass
class TrajectoryJudge(Evaluator):
    rubric: str

    async def evaluate(self, ctx: EvaluatorContext) -> EvaluationReason:
        try:
            span_tree = ctx.span_tree
        except SpanTreeRecordingError:
            # 이 페이지의 내장 평가자처럼 우아하게 축소
            return EvaluationReason(value=False, reason='No span tree available.')

        # 내장 평가자가 기본적으로 툴 호출로 계수하는 것과 일치하도록
        # 평문 궤적 요약을 만든다: 툴 스팬은 'running tool'(v2) 또는
        # 'execute_tool {name}'(v3+)로 이름이 붙고, 지연 호출은 절대
        # 실행되지 않았으며, output function은 툴 스팬 형태를 공유하지만
        # 툴 호출이 아니고, 실패 시도(status 'error')는 내장 평가자의
        # `include_failed=False` 기본값처럼 제외한다.
        tool_names = [
            node.attributes['gen_ai.tool.name']
            for node in span_tree
            if 'gen_ai.tool.name' in node.attributes
            and 'pydantic_ai.tool.deferral.name' not in node.attributes
            and node.status != 'error'
            and (node.name == 'running tool' or node.name.startswith('execute_tool '))
            and not str(node.attributes.get('logfire.msg', '')).startswith('running output function:')
        ]
        trajectory = ', '.join(str(n) for n in tool_names) or '(none)'
        grading_output = await judge_input_output(
            {'query': ctx.inputs, 'tool_trajectory': trajectory},
            ctx.output,
            self.rubric,
        )
        return EvaluationReason(value=grading_output.pass_, reason=grading_output.reason)


dataset = Dataset(
    name='task_completion',
    cases=[Case(inputs='Resolve ticket 42')],
    evaluators=[
        TrajectoryJudge(
            rubric=(
                'The agent completed the task correctly, and the tool trajectory '
                'included in the input is reasonable for the given query.'
            ),
        ),
    ],
)

이 패턴은 위의 결정적 검사를 저렴하고 재현 가능하게 유지하면서, 질적이고 개방적인 판단은 LLM에게 맡기되 궤적을 판단자가 보는 내용에 명시적으로 포함시켜요.

다음 단계

더 알아보기 (Learn more)