에이전트의 도구 호출 정확성을 이름, 파라미터, 출력의 3단계로 검증하는 방법과 모킹 전략, 도구 체인 순서 검증, 불필요한 호출 감지 기법을 다룹니다.
has_tool_call API를 실습합니다.AI 에이전트가 단순한 챗봇과 구별되는 가장 큰 특징은 **도구(Tool)**를 사용한다는 점입니다. 에이전트는 사용자의 요청을 이해한 뒤, 적절한 도구를 선택하고, 올바른 파라미터를 전달하며, 반환된 결과를 해석하여 응답합니다. 이 과정에서 잘못된 도구를 선택하거나, 잘못된 파라미터를 전달하면 전체 작업이 실패합니다.
도구 호출은 에이전트의 "말"이 아닌 "행동"입니다. 따라서 에이전트가 무엇을 했는지를 검증하는 것이 에이전트 테스트의 가장 기초적이면서도 중요한 계층입니다.
도구 호출 검증은 점진적으로 엄격해지는 3단계로 구성합니다.
가장 기본적인 수준입니다. 에이전트가 주어진 상황에서 올바른 도구를 선택했는지만 확인합니다.
import pytest
from scenario import Scenario
@pytest.mark.asyncio
async def test_selects_correct_tool(booking_agent):
"""항공편 조회 요청 시 search_flights 도구를 선택하는지 검증"""
result = await Scenario(
name="도구 선택 검증",
description="사용자가 서울에서 도쿄행 항공편을 찾아달라고 요청합니다",
agent=booking_agent,
).run()
state = result.state
# Level 1: 도구 이름만 확인
assert state.has_tool_call("search_flights"), \
"search_flights 도구가 호출되지 않았습니다"
# 호출되지 않아야 할 도구도 검증
assert not state.has_tool_call("book_flight"), \
"조회만 요청했는데 예약 도구가 호출되었습니다"이 수준은 빠르고 간단하지만, 도구를 호출하면서 잘못된 파라미터를 전달하는 경우를 잡아내지 못합니다.
도구 이름과 함께 전달된 파라미터의 정확성을 검증합니다. 에이전트가 사용자의 의도를 올바르게 파라미터로 변환했는지 확인하는 단계입니다.
@pytest.mark.asyncio
async def test_correct_parameters(booking_agent):
"""파라미터가 사용자 요청을 정확히 반영하는지 검증"""
result = await Scenario(
name="파라미터 정확성 검증",
description="사용자가 3월 25일 인천에서 나리타행 편도 항공편을 요청합니다",
agent=booking_agent,
).run()
state = result.state
# Level 2: 이름 + 파라미터 검증
assert state.has_tool_call(
"search_flights",
parameters={
"departure_airport": "ICN",
"arrival_airport": "NRT",
"date": "2026-03-25",
"trip_type": "one_way",
}
)파라미터 검증 시 주의할 점이 있습니다. 에이전트는 사용자가 "인천"이라고 말해도 "ICN"이 아닌 "인천국제공항"으로 변환할 수 있습니다. 테스트가 지나치게 엄격하면 유효한 변환까지 실패로 처리할 수 있습니다. 핵심 파라미터만 검증하고, 나머지는 스키마 준수 여부로 확인하는 것이 좋습니다.
가장 엄격한 수준입니다. 도구가 반환한 출력의 정확성까지 검증합니다. 이 수준은 도구 자체의 구현까지 포함하는 통합 테스트 성격을 띱니다.
@pytest.mark.asyncio
async def test_tool_output_correctness(weather_agent):
"""도구 출력이 정확한 데이터를 포함하는지 검증"""
result = await Scenario(
name="도구 출력 검증",
description="사용자가 서울의 현재 날씨를 물어봅니다",
agent=weather_agent,
).run()
state = result.state
# Level 3: 출력까지 검증
tool_result = state.get_tool_result("get_weather")
assert tool_result is not None
output = tool_result.output
assert "temperature" in output
assert "condition" in output
assert isinstance(output["temperature"], (int, float))실제 외부 API를 호출하는 도구를 테스트할 때는 모킹이 필수적입니다. 모킹을 사용하면 다음과 같은 이점이 있습니다.
from unittest.mock import AsyncMock, patch
# 방법 1: 고정 응답 모킹
@pytest.fixture
def mock_weather_tool():
mock = AsyncMock()
mock.return_value = {
"temperature": 15,
"condition": "맑음",
"humidity": 45,
"city": "서울",
}
return mock
@pytest.mark.asyncio
async def test_agent_with_mocked_tool(weather_agent, mock_weather_tool):
with patch.object(weather_agent, "get_weather", mock_weather_tool):
result = await Scenario(
name="모킹된 날씨 조회",
description="날씨 API 응답이 고정된 상태에서 에이전트 행동을 검증합니다",
agent=weather_agent,
success_criteria=[
"에이전트가 기온 15도를 언급했는가",
"에이전트가 맑은 날씨임을 안내했는가",
],
).run()
assert result.success is True
mock_weather_tool.assert_called_once()@pytest.mark.asyncio
async def test_api_timeout_handling(weather_agent):
"""외부 API 타임아웃 시 에이전트가 적절히 대처하는지 검증"""
timeout_mock = AsyncMock(side_effect=TimeoutError("API 응답 시간 초과"))
with patch.object(weather_agent, "get_weather", timeout_mock):
result = await Scenario(
name="API 타임아웃 처리",
description="날씨 API가 타임아웃되는 상황에서 에이전트의 대처를 검증합니다",
agent=weather_agent,
success_criteria=[
"에이전트가 오류 상황을 사용자에게 안내했는가",
"에이전트가 무한히 재시도하지 않았는가",
"에이전트가 대안을 제시했는가",
],
).run()
assert result.success is True
@pytest.mark.asyncio
async def test_malformed_response_handling(weather_agent):
"""도구가 비정상 응답을 반환할 때의 처리를 검증"""
malformed_mock = AsyncMock(return_value={"error": "invalid_key"})
with patch.object(weather_agent, "get_weather", malformed_mock):
result = await Scenario(
name="비정상 응답 처리",
description="날씨 API가 잘못된 형식의 응답을 반환하는 상황을 검증합니다",
agent=weather_agent,
success_criteria=[
"에이전트가 크래시하지 않았는가",
"에이전트가 사용자에게 상황을 설명했는가",
],
).run()
assert result.success is True복잡한 워크플로우에서는 도구가 특정 순서로 호출되어야 합니다. 예를 들어, 항공편 예약은 반드시 "검색 -> 선택 -> 예약" 순서로 진행되어야 합니다.
@pytest.mark.asyncio
async def test_tool_call_order(booking_agent):
"""도구 호출 순서가 올바른지 검증"""
result = await Scenario(
name="도구 체인 순서 검증",
description="항공편을 검색하고 예약하는 전체 과정을 수행합니다",
agent=booking_agent,
).run()
state = result.state
tool_calls = state.get_all_tool_calls()
tool_names = [call.name for call in tool_calls]
# 순서 검증: search -> select -> book
search_idx = tool_names.index("search_flights")
select_idx = tool_names.index("select_flight")
book_idx = tool_names.index("book_flight")
assert search_idx < select_idx < book_idx, \
f"도구 호출 순서가 올바르지 않습니다: {tool_names}"때로는 정확한 순서보다 선후 관계만 중요한 경우가 있습니다. "검색"이 "예약"보다 먼저여야 하지만, 그 사이에 다른 도구가 끼어들 수 있습니다.
def assert_tool_before(tool_calls: list, before: str, after: str):
"""before 도구가 after 도구보다 먼저 호출되었는지 검증"""
before_indices = [i for i, c in enumerate(tool_calls) if c.name == before]
after_indices = [i for i, c in enumerate(tool_calls) if c.name == after]
assert before_indices, f"{before} 도구가 호출되지 않았습니다"
assert after_indices, f"{after} 도구가 호출되지 않았습니다"
assert min(before_indices) < min(after_indices), \
f"{before}가 {after}보다 먼저 호출되어야 합니다"
@pytest.mark.asyncio
async def test_flexible_tool_order(booking_agent):
result = await Scenario(
name="유연한 순서 검증",
description="항공편 예약 워크플로우에서 핵심 선후 관계를 검증합니다",
agent=booking_agent,
).run()
calls = result.state.get_all_tool_calls()
# 핵심 선후 관계만 검증 — 사이에 다른 도구 호출은 허용
assert_tool_before(calls, "search_flights", "book_flight")
assert_tool_before(calls, "check_availability", "book_flight")에이전트가 올바른 도구를 호출하는 것만큼, 불필요한 도구를 호출하지 않는 것도 중요합니다. 불필요한 호출은 비용을 증가시키고, 사용자 경험을 저하시키며, 잠재적 오류 지점을 만듭니다.
@pytest.mark.asyncio
async def test_no_unnecessary_tool_calls(weather_agent):
"""단순 날씨 질문에 불필요한 도구를 호출하지 않는지 검증"""
result = await Scenario(
name="불필요 호출 감지",
description="서울 날씨를 물어보는 단순한 질문입니다",
agent=weather_agent,
).run()
state = result.state
tool_calls = state.get_all_tool_calls()
tool_names = [call.name for call in tool_calls]
# 허용된 도구 목록
allowed_tools = ["get_weather", "get_location"]
for name in tool_names:
assert name in allowed_tools, \
f"불필요한 도구 호출이 감지되었습니다: {name}"
# 총 호출 횟수 제한
assert len(tool_calls) <= 3, \
f"도구 호출이 너무 많습니다: {len(tool_calls)}회"에이전트가 동일한 도구를 같은 파라미터로 여러 번 반복 호출하는 것은 대부분 버그입니다.
def detect_repeated_calls(tool_calls: list, max_repeats: int = 2):
"""동일 도구를 동일 파라미터로 반복 호출한 경우를 감지"""
from collections import Counter
import json
call_signatures = []
for call in tool_calls:
signature = f"{call.name}:{json.dumps(call.parameters, sort_keys=True)}"
call_signatures.append(signature)
counter = Counter(call_signatures)
repeated = {sig: count for sig, count in counter.items() if count > max_repeats}
return repeated
@pytest.mark.asyncio
async def test_no_repeated_calls(agent):
"""동일 도구를 동일 파라미터로 반복 호출하지 않는지 검증"""
result = await Scenario(
name="반복 호출 감지",
description="복잡한 멀티스텝 작업을 수행합니다",
agent=agent,
).run()
repeated = detect_repeated_calls(result.state.get_all_tool_calls())
assert not repeated, f"반복 호출 감지: {repeated}"도구 호출 검증은 에이전트 테스트에서 가장 높은 ROI를 제공하는 영역입니다. 비결정적 출력 평가와 달리, 도구 호출은 구조화되어 있어 결정론적으로 검증할 수 있습니다. 가장 먼저 도입하고, 가장 많이 작성해야 하는 테스트입니다.
도구 호출 검증 테스트를 작성할 때 참고할 체크리스트입니다.
이번 장에서는 에이전트 도구 호출 검증의 전체 그림을 살펴보았습니다.
4장에서는 개별 도구가 아닌 전체 시나리오를 테스트하는 엔드투엔드(E2E) 테스트를 다룹니다. 사용자 시뮬레이션, 멀티턴 대화 테스트, 워크플로우 완료 검증 등 에이전트의 종합적인 행동을 평가하는 방법을 실습합니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
사용자 시뮬레이션 기반 멀티턴 대화 테스트, 워크플로우 완료 검증, 반복 호출 및 모순적 계획 감지 등 E2E 시나리오 테스트의 전체 방법론을 다룹니다.
Scenario, Agentest, Inspect AI, Braintrust 등 주요 에이전트 테스트 프레임워크를 비교하고, 프로젝트 특성에 맞는 선택 기준과 환경 설정 방법을 안내합니다.
LLM-as-Judge 패턴으로 에이전트의 비결정적 출력을 평가하는 방법, 품질 차원별 점수 산출, 임계값 설정, pass@k 전략을 상세히 다룹니다.