LLM 에이전트에 도구(tool)를 붙이는 순간, 데모는 화려하지만 프로덕션은 지옥이 됩니다. 모델이 존재하지 않는 도구를 부르고, 인자를 엉뚱하게 채우고, 같은 도구를 무한 반복 호출하고, 도구가 던진 에러를 그대로 사용자에게 토해냅니다. 툴콜링은 비결정적인 LLM과 결정적인 코드가 만나는 경계라서, 이 경계를 얼마나 단단하게 방어하느냐가 에이전트의 안정성을 좌우합니다.

이 글에서는 툴콜링을 프로덕션 수준으로 끌어올리는 네 가지 축 — 입력 검증, 재시도, 폴백, 그리고 루프 제어 — 를 코드와 함께 다룹니다. 특정 프레임워크에 얽매이지 않는 원리 중심으로 정리했습니다.

도구 스키마를 방어적으로 정의하라

첫 번째 방어선은 도구 정의 자체입니다. 모델은 스키마를 보고 인자를 채우므로, 스키마가 모호하면 잘못된 호출이 늘어납니다. 필수/선택을 명확히 하고, enum으로 값 범위를 좁히고, 설명에 사용 시점과 하면 안 되는 경우까지 적으세요.

{
  "name": "search_orders",
  "description": "고객의 주문을 조회한다. 주문 상태 확인 요청에만 사용하고, 환불 처리에는 절대 쓰지 않는다.",
  "input_schema": {
    "type": "object",
    "properties": {
      "customer_id": {
        "type": "string",
        "description": "고객 고유 ID. 이메일이나 이름이 아니다."
      },
      "status": {
        "type": "string",
        "enum": ["pending", "shipped", "delivered", "cancelled"]
      },
      "limit": {"type": "integer", "minimum": 1, "maximum": 50}
    },
    "required": ["customer_id"]
  }
}

enum과 min/max는 단순한 문서가 아니라 모델의 출력 공간을 좁히는 장치입니다. 선택지가 좁을수록 모델이 헛발질할 확률이 줄어듭니다.

실행 전 입력 검증은 필수

스키마를 아무리 잘 짜도 모델은 규칙을 어깁니다. 그래서 도구를 실제 실행하기 전에 인자를 반드시 검증해야 합니다. 검증 실패는 예외로 처리하지 말고, 모델에게 돌려주는 에러 메시지로 만드세요. 그래야 모델이 스스로 교정할 수 있습니다.

from pydantic import BaseModel, ValidationError, Field

class SearchOrdersArgs(BaseModel):
    customer_id: str = Field(min_length=1)
    status: str | None = None
    limit: int = Field(default=10, ge=1, le=50)

def call_tool(name: str, raw_args: dict) -> str:
    try:
        args = SearchOrdersArgs(**raw_args)
    except ValidationError as e:
        # 예외를 던지지 말고 모델이 읽을 에러로 반환
        return json.dumps({
            "error": "invalid_arguments",
            "detail": e.errors(),
            "hint": "customer_id는 필수이며 limit은 1~50 사이여야 합니다."
        })
    return run_search(args)

이 패턴의 핵심은 에러를 대화의 일부로 되먹이는 것입니다. 모델은 다음 턴에서 힌트를 읽고 인자를 고쳐 다시 호출합니다. 인간 개입 없이 자기 교정이 일어납니다.

재시도: 무엇을 재시도하고 무엇을 하지 않을까

도구 실행은 실패합니다. 하지만 모든 실패를 재시도하면 안 됩니다. 일시적 실패(네트워크, 타임아웃, 429)는 지수 백오프로 재시도하고, 영구적 실패(404, 검증 오류, 권한 없음)는 즉시 모델에게 결과를 돌려줘야 합니다. 후자를 재시도하면 시간만 낭비합니다.

import asyncio, random

RETRYABLE = {"timeout", "rate_limited", "upstream_5xx"}

async def execute_with_retry(fn, max_attempts=3):
    for attempt in range(max_attempts):
        try:
            return await fn()
        except ToolError as e:
            if e.code not in RETRYABLE or attempt == max_attempts - 1:
                # 재시도 불가 또는 마지막 시도 - 모델에 결과 전달
                return {"error": e.code, "message": str(e)}
            # 지터를 포함한 지수 백오프
            delay = (2 ** attempt) + random.uniform(0, 0.5)
            await asyncio.sleep(delay)

중요한 트레이드오프가 있습니다. 도구가 멱등(idempotent)하지 않으면 재시도가 위험합니다. “결제하기”를 재시도하면 이중 결제가 됩니다. 부작용이 있는 도구는 멱등 키를 요구하거나 재시도를 아예 금지해야 합니다.

폴백: 도구가 죽으면 무엇을 할까

재시도해도 도구가 계속 실패하면, 에이전트가 통째로 멈추게 두면 안 됩니다. 폴백 전략을 준비하세요. 대체 도구로 우회하거나, 캐시된 데이터를 쓰거나, 최소한 “지금 이 기능을 쓸 수 없다”는 사실을 모델에게 명확히 알려 우아하게 대응하게 합니다.

async def resilient_call(name: str, args: dict):
    result = await execute_with_retry(lambda: primary_tool(name, args))
    if "error" not in result:
        return result

    # 1차 폴백: 대체 데이터 소스
    if name == "search_orders":
        cached = read_cache(args["customer_id"])
        if cached:
            return {"data": cached, "note": "실시간 조회 실패로 캐시 데이터 사용 (다소 오래됨)"}

    # 최종 폴백: 실패를 모델에게 정직하게 전달
    return {
        "error": "tool_unavailable",
        "message": "주문 조회 시스템이 일시적으로 응답하지 않습니다.",
        "suggested_action": "사용자에게 잠시 후 재시도를 안내하세요."
    }

폴백에서 캐시를 쓸 때는 반드시 데이터가 오래됐을 수 있음을 명시하세요. 그래야 모델이 그 맥락을 사용자에게 정직하게 전달합니다. 조용히 낡은 데이터를 주는 것이 가장 위험합니다.

무한 루프와 반복 호출 차단

에이전트의 고질병은 같은 도구를 끝없이 부르는 것입니다. 검색이 원하는 답을 안 주면 같은 검색을 계속 반복하며 토큰을 태웁니다. 턴 상한중복 호출 감지로 이를 막아야 합니다.

class ToolLoopGuard:
    def __init__(self, max_turns=15, max_repeats=3):
        self.max_turns = max_turns
        self.max_repeats = max_repeats
        self.turn = 0
        self.call_history: dict[str, int] = {}

    def check(self, name: str, args: dict) -> str | None:
        self.turn += 1
        if self.turn > self.max_turns:
            return "최대 도구 호출 횟수를 초과했습니다. 지금까지의 정보로 답하세요."
        key = f"{name}:{json.dumps(args, sort_keys=True)}"
        self.call_history[key] = self.call_history.get(key, 0) + 1
        if self.call_history[key] > self.max_repeats:
            return f"동일한 호출({name})이 반복되고 있습니다. 다른 접근을 시도하거나 종료하세요."
        return None  # 통과

인자까지 포함한 키로 중복을 감지하는 것이 포인트입니다. 완전히 동일한 호출이 반복되면 진전이 없다는 뜻이므로, 모델에게 방향 전환을 지시하는 메시지를 되돌려 루프를 깹니다.

관측성: 무엇이 왜 실패했는지 남겨라

에이전트 디버깅은 로그 없이는 불가능합니다. 모든 툴콜에 대해 입력, 출력, 소요 시간, 재시도 횟수, 최종 결과를 구조화 로그로 남기세요. 어떤 도구가 자주 실패하는지, 모델이 어디서 헛도는지가 로그에서 드러납니다.

logger.info("tool_call", extra={
    "tool": name,
    "args": redact_pii(args),      # 민감정보는 마스킹
    "duration_ms": elapsed,
    "attempts": attempts,
    "outcome": "success" if ok else "fallback",
    "trace_id": ctx.trace_id,
})

마무리

안정적인 툴콜링은 더 똑똑한 프롬프트가 아니라 더 단단한 코드 경계에서 나옵니다. 방어적 스키마로 실수를 줄이고, 실행 전 검증으로 잘못된 인자를 걸러 모델에게 되먹이고, 재시도와 폴백으로 실패를 흡수하며, 루프 가드로 폭주를 막고, 구조화 로그로 관측성을 확보하는 것. 이 다섯 축이 갖춰지면 비결정적인 LLM 위에서도 예측 가능한 에이전트를 만들 수 있습니다. 에이전트의 신뢰성은 모델의 영리함이 아니라, 모델이 틀렸을 때를 얼마나 잘 대비했는지에 달려 있습니다.