AI 에이전트의 도구 정의, 호출, 결과 통합의 전 과정을 다루고, 효과적인 도구 스키마 설계와 복합 도구 조합 전략을 살펴봅니다.
LLM은 본질적으로 텍스트를 생성하는 모델입니다. 아무리 뛰어난 추론 능력을 가지고 있어도, 실시간 데이터 조회, 외부 시스템 호출, 정확한 수학 계산 같은 작업은 텍스트 생성만으로는 수행할 수 없습니다. 도구 사용(Tool Use, 또는 Function Calling)은 이 한계를 극복하는 핵심 메커니즘입니다.
도구 사용 패턴은 네 단계의 순환 구조로 작동합니다.
도구 스키마(Tool Schema)는 에이전트 성능에 직접적인 영향을 미칩니다. 잘 설계된 스키마는 LLM이 올바른 도구를 올바른 인자로 호출하도록 안내합니다.
tool = {
"name": "search_database",
"description": (
"사내 지식 베이스에서 문서를 검색합니다. "
"기술 문서, 정책 문서, 회의록 등을 검색할 수 있습니다. "
"일반적인 웹 검색이 아닌, 조직 내부 데이터만 검색합니다."
),
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "검색 쿼리. 자연어 문장 또는 키워드 조합을 지원합니다."
},
"category": {
"type": "string",
"enum": ["tech", "policy", "meeting", "all"],
"description": "검색 범위를 특정 카테고리로 제한합니다. 기본값은 'all'입니다."
},
"max_results": {
"type": "integer",
"description": "반환할 최대 결과 수. 기본값은 5입니다.",
"default": 5
}
},
"required": ["query"]
}
}1. 도구 이름은 동사-명사 조합으로
# 좋은 예
"name": "search_documents"
"name": "create_ticket"
"name": "get_user_profile"
# 나쁜 예
"name": "documents" # 동작이 불분명
"name": "helper" # 용도가 모호
"name": "process_stuff" # 너무 일반적2. 설명에 "언제 사용해야 하는지"를 포함
# 좋은 설명
"description": (
"사용자의 주문 내역을 조회합니다. "
"주문 번호, 날짜 범위, 상태로 필터링할 수 있습니다. "
"사용자가 주문 관련 질문을 할 때 사용하십시오. "
"상품 검색에는 search_products를 사용하십시오."
)
# 나쁜 설명
"description": "주문을 가져옵니다."도구 설명에 "이 도구 대신 X 도구를 사용해야 하는 경우"를 명시하면, 유사한 도구 간의 혼동을 줄일 수 있습니다. 특히 도구 수가 많아질수록 이 전략이 중요해집니다.
3. 매개변수에 구체적인 예시와 제약 조건을 포함
"properties": {
"date_range": {
"type": "string",
"description": (
"조회 기간. ISO 8601 형식으로 입력합니다. "
"예: '2026-01-01/2026-03-31'. "
"시작일과 종료일을 슬래시(/)로 구분합니다."
)
},
"status": {
"type": "string",
"enum": ["pending", "shipped", "delivered", "cancelled"],
"description": "주문 상태로 필터링합니다. 지정하지 않으면 모든 상태를 조회합니다."
}
}도구의 실제 실행을 관리하는 아키텍처를 설계합니다.
여러 도구를 체계적으로 관리하기 위해 레지스트리 패턴을 사용합니다.
from typing import Callable, Any
from dataclasses import dataclass
@dataclass
class ToolDefinition:
name: str
description: str
input_schema: dict
handler: Callable[..., str]
class ToolRegistry:
def __init__(self):
self._tools: dict[str, ToolDefinition] = {}
def register(self, name: str, description: str,
input_schema: dict, handler: Callable[..., str]):
"""도구를 레지스트리에 등록합니다."""
self._tools[name] = ToolDefinition(
name=name,
description=description,
input_schema=input_schema,
handler=handler,
)
def get_schemas(self) -> list[dict]:
"""API에 전달할 도구 스키마 목록을 반환합니다."""
return [
{
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
}
for tool in self._tools.values()
]
def execute(self, name: str, input_data: dict) -> str:
"""도구를 실행하고 결과를 반환합니다."""
if name not in self._tools:
return f"등록되지 않은 도구입니다: {name}"
try:
return self._tools[name].handler(**input_data)
except Exception as e:
return f"도구 실행 오류 ({name}): {str(e)}"# 레지스트리 생성
registry = ToolRegistry()
# 도구 등록
def search_web(query: str) -> str:
# 실제 검색 로직
return f"검색 결과: {query}"
def read_file(path: str) -> str:
with open(path, "r") as f:
return f.read()
def run_python(code: str) -> str:
# 샌드박스에서 실행
import subprocess
result = subprocess.run(
["python", "-c", code],
capture_output=True, text=True, timeout=10
)
return result.stdout or result.stderr
registry.register(
name="search_web",
description="웹에서 정보를 검색합니다.",
input_schema={
"type": "object",
"properties": {
"query": {"type": "string", "description": "검색어"}
},
"required": ["query"]
},
handler=search_web,
)
registry.register(
name="read_file",
description="파일의 내용을 읽습니다.",
input_schema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "파일 경로"}
},
"required": ["path"]
},
handler=read_file,
)
registry.register(
name="run_python",
description="Python 코드를 실행하고 결과를 반환합니다.",
input_schema={
"type": "object",
"properties": {
"code": {"type": "string", "description": "실행할 Python 코드"}
},
"required": ["code"]
},
handler=run_python,
)최신 LLM API는 한 번의 응답에서 여러 도구를 동시에 호출할 수 있습니다. 이를 병렬 도구 호출(Parallel Tool Use)이라고 합니다.
import asyncio
from concurrent.futures import ThreadPoolExecutor
async def execute_tools_parallel(
tool_calls: list[dict],
registry: ToolRegistry
) -> list[dict]:
"""여러 도구를 병렬로 실행합니다."""
loop = asyncio.get_event_loop()
executor = ThreadPoolExecutor(max_workers=5)
async def run_one(call):
result = await loop.run_in_executor(
executor,
lambda: registry.execute(call["name"], call["input"])
)
return {
"type": "tool_result",
"tool_use_id": call["id"],
"content": result,
}
tasks = [run_one(call) for call in tool_calls]
return await asyncio.gather(*tasks)병렬 도구 호출은 독립적인 정보를 동시에 수집해야 할 때 특히 유용합니다. 예를 들어 "서울, 부산, 제주의 날씨를 알려 주세요"라는 요청에 대해, 세 도시의 날씨를 동시에 조회할 수 있습니다.
Claude API는 한 번의 응답에서 여러 tool_use 블록을 반환할 수 있습니다. 각 블록에는 고유한 id가 있으며, tool_result에서 이 id를 참조하여 결과를 매칭합니다.
도구의 실행 결과를 대화 히스토리에 어떻게 통합하느냐에 따라 에이전트의 후속 추론 품질이 달라집니다.
서로 다른 도구가 반환하는 결과의 형식을 일관되게 정규화합니다.
def normalize_result(tool_name: str, raw_result: Any) -> str:
"""도구 결과를 일관된 형식으로 정규화합니다."""
if isinstance(raw_result, dict):
# JSON 결과를 읽기 쉬운 형태로 변환
formatted = json.dumps(raw_result, ensure_ascii=False, indent=2)
return f"[{tool_name} 결과]\n{formatted}"
if isinstance(raw_result, list):
items = [f" - {item}" for item in raw_result[:10]]
result = "\n".join(items)
if len(raw_result) > 10:
result += f"\n ... 외 {len(raw_result) - 10}건"
return f"[{tool_name} 결과] {len(raw_result)}건 조회\n{result}"
return f"[{tool_name} 결과]\n{str(raw_result)}"동일한 도구를 같은 인자로 반복 호출하는 것을 방지합니다.
import hashlib
class CachedToolRegistry(ToolRegistry):
def __init__(self):
super().__init__()
self._cache: dict[str, str] = {}
def execute(self, name: str, input_data: dict) -> str:
cache_key = hashlib.md5(
f"{name}:{json.dumps(input_data, sort_keys=True)}".encode()
).hexdigest()
if cache_key in self._cache:
return f"[캐시됨] {self._cache[cache_key]}"
result = super().execute(name, input_data)
self._cache[cache_key] = result
return result도구 실행은 실패할 수 있습니다. 네트워크 오류, 인증 만료, 잘못된 입력 등 다양한 원인이 있습니다. 에이전트가 오류를 우아하게 처리하도록 설계해야 합니다.
from enum import Enum
class ToolErrorType(Enum):
INVALID_INPUT = "invalid_input"
TIMEOUT = "timeout"
AUTH_FAILURE = "auth_failure"
NOT_FOUND = "not_found"
RATE_LIMIT = "rate_limit"
UNKNOWN = "unknown"
def execute_with_error_handling(
registry: ToolRegistry,
name: str,
input_data: dict,
timeout: int = 30
) -> str:
"""오류 유형에 따라 LLM에 적절한 피드백을 제공합니다."""
try:
import signal
def handler(signum, frame):
raise TimeoutError()
signal.signal(signal.SIGALRM, handler)
signal.alarm(timeout)
result = registry.execute(name, input_data)
signal.alarm(0)
return result
except TimeoutError:
return (
f"[오류: 시간 초과] {name} 도구가 {timeout}초 내에 "
f"응답하지 않았습니다. 다른 접근 방식을 시도하거나, "
f"더 간단한 쿼리로 재시도하십시오."
)
except PermissionError:
return (
f"[오류: 권한 없음] {name} 도구에 접근할 권한이 없습니다. "
f"이 작업을 수행하려면 다른 방법을 찾아야 합니다."
)
except Exception as e:
return (
f"[오류: {type(e).__name__}] {name} 도구 실행 중 오류가 "
f"발생했습니다: {str(e)}. 입력을 확인하고 재시도하거나, "
f"다른 도구를 사용하십시오."
)오류 메시지에는 "다음에 무엇을 해야 하는지"에 대한 힌트를 포함시키는 것이 좋습니다. LLM은 이 힌트를 활용하여 대안적인 행동을 선택할 수 있습니다. 단순히 "오류가 발생했습니다"만 반환하면 에이전트가 같은 행동을 반복할 가능성이 높아집니다.
에이전트에 제공하는 도구의 수가 너무 많으면 LLM이 올바른 도구를 선택하기 어려워집니다. 반대로 너무 적으면 에이전트의 능력이 제한됩니다.
모든 도구를 항상 제공하는 대신, 사용자의 의도에 따라 관련 도구만 선택적으로 제공하는 전략입니다.
def select_relevant_tools(
user_message: str,
all_tools: list[dict],
max_tools: int = 10
) -> list[dict]:
"""사용자 메시지에 기반하여 관련 도구를 선택합니다."""
# 카테고리별 도구 분류
tool_categories = {
"search": ["search_web", "search_database", "search_documents"],
"data": ["query_sql", "read_csv", "get_analytics"],
"action": ["send_email", "create_ticket", "update_record"],
"code": ["run_python", "run_sql", "lint_code"],
}
# 간단한 키워드 매칭으로 관련 카테고리 판별
keywords = {
"search": ["검색", "찾아", "조회", "search"],
"data": ["데이터", "분석", "통계", "쿼리"],
"action": ["보내", "생성", "수정", "삭제"],
"code": ["코드", "실행", "스크립트", "프로그램"],
}
relevant_names = set()
for category, words in keywords.items():
if any(w in user_message for w in words):
relevant_names.update(tool_categories.get(category, []))
# 관련 도구가 없으면 전체 도구 중 상위 N개 반환
if not relevant_names:
return all_tools[:max_tools]
return [t for t in all_tools if t["name"] in relevant_names][:max_tools]4장에서는 리플렉션(Reflection) 패턴을 다룹니다. 에이전트가 자신의 출력을 스스로 평가하고 개선하는 이 패턴은 도구 사용과 결합하면 훨씬 강력해집니다. 리플렉션이 어떻게 에이전트의 결과물 품질을 체계적으로 향상시키는지 살펴보겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
에이전트가 자신의 출력을 평가하고 반복적으로 개선하는 리플렉션 패턴의 원리, 구현 방법, 실전 활용 전략을 다룹니다.
ReAct 패턴의 원리와 구조를 이해하고, 추론-행동-관찰 루프를 직접 구현하여 LLM의 문제 해결 능력을 극대화하는 방법을 다룹니다.
Plan-and-Execute 아키텍처의 원리와 구현, 적응적 재계획 전략, 그리고 계획 수립 패턴이 에이전트 성능에 미치는 영향을 다룹니다.