컴퓨터 액세스 시스템

도구 사용 패턴: 신뢰할 수 있는 에이전트-도구 인터페이스 구축


에이전트가 도구를 호출하고 40줄짜리 JSON 블롭을 받았다 — 원시 API 응답, 중첩된 객체, status 필드 안에 묻힌 오류 코드. 모델은 그것을 읽고 그럴듯해 보이는 값을 선택한 후 계속했다. 값은 틀렸다. 세 단계 후, 에이전트는 잘못된 데이터를 기반으로 자신 있게 보고서를 작성했다.

도구는 작동했다. 인터페이스가 실패했다.

도구 사용은 언어 모델을 에이전트로 변환하는 메커니즘이다. 에이전트의 모든 기능 — 데이터베이스 검색, 파일 쓰기, API 호출, 서비스 쿼리 — 은 도구 인터페이스를 통해 온다. 인터페이스가 잘못 설계되면 기반 서비스가 올바르게 작동하더라도 모델은 더 나쁜 결정을 내린다. 이 가이드는 정밀하고 신뢰할 수 있으며 프로덕션에 적합한 도구 인터페이스를 구축하기 위한 다섯 가지 패턴을 다룬다.

전제 조건: Python과 Claude API에 익숙해야 한다. 도구 전송 레이어로서의 MCP 배경은 첫 번째 MCP 서버 구축을 참조.


인터페이스 설계가 중요한 이유

에이전트가 도구를 선택하고 사용할 때 두 가지 결정을 내린다:

  1. 어떤 도구를 호출할지 — 도구의 namedescription에 의해 결정됨
  2. 어떤 인수를 전달할지 — 도구의 input_schema에 의해 결정됨

모호한 설명은 잘못된 도구 선택으로 이어진다. 느슨한 스키마는 모델이 잘못된 형식의 입력을 전달하게 한다. 비구조화된 결과는 모델이 무슨 일이 일어났는지 추측하게 만든다. 대부분의 에이전트 버그는 추론에 있지 않고 — 도구 경계에 있다.


패턴 1: 스키마 우선 설계

구현을 작성하기 전에 JSON 스키마를 작성한다. 엄격한 스키마는 무언가 실행되기 전에 입력 단계에서 모델 동작을 제한한다.

import anthropic
client = anthropic.Anthropic()
tools = [
{
"name": "search_products",
"description": (
"키워드로 제품 카탈로그를 검색한다. "
"ID, 이름, 가격이 포함된 일치하는 제품 목록을 반환한다. "
"사용자가 제품을 찾거나 탐색하고 싶을 때 사용한다."
),
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "검색할 키워드"
},
"category": {
"type": "string",
"enum": ["electronics", "clothing", "food", "home", "all"],
"description": "필터링할 제품 카테고리. 지정하지 않으면 'all' 사용."
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"description": "반환할 결과 수. 기본값: 5"
}
},
"required": ["query", "category"]
}
}
]
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
tools=tools,
messages=[{"role": "user", "content": "10만원 이하 전자제품 찾아줘"}]
)

오류를 줄이는 스키마 규칙:

  • 고정된 유효 값 집합이 있는 필드에는 enum 사용
  • 범위를 벗어난 입력을 방지하기 위해 숫자 필드에 minimum/maximum 설정
  • 도구가 정말로 없이는 실행할 수 없을 때만 필드를 required로 표시
  • 모델의 관점에서 설명 작성: ”…할 때 사용한다”는 모델에게 언제 도구를 호출할지 알려줌

사용 시기: 모든 도구 정의에서.


패턴 2: 구조화된 도구 결과

타입이 지정된 기계 읽기 가능한 결과를 반환한다. 원시 API 응답이나 산문 설명을 절대 반환하지 않는다.

import json
from dataclasses import dataclass, asdict
from typing import Any, Optional
@dataclass
class ToolResult:
success: bool
data: Optional[Any] = None
error: Optional[str] = None
def to_content(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False)
def search_products(
query: str,
category: str,
max_results: int = 5,
) -> ToolResult:
try:
raw_results = _query_database(query, category, limit=max_results)
products = [
{"id": r["product_id"], "name": r["title"], "price": r["price_usd"]}
for r in raw_results
]
return ToolResult(success=True, data={"products": products, "count": len(products)})
except ConnectionError as e:
return ToolResult(success=False, error=f"데이터베이스 사용 불가: {e}")
except Exception as e:
return ToolResult(success=False, error=f"검색 실패: {type(e).__name__}: {e}")
def _query_database(query, category, limit):
return []

일관된 {success, data, error} 봉투는 모델이 항상 어디를 봐야 할지 알게 한다. 장애의 우아한 처리는 에이전트 오류 복구 패턴을 참조.


패턴 3: 병렬 도구 호출

Claude는 단일 응답에서 여러 도구를 요청할 수 있다. 순차적이 아닌 병렬로 처리한다.

from concurrent.futures import ThreadPoolExecutor, as_completed
TOOL_REGISTRY = {
"search_products": search_products,
}
def dispatch_tool(name: str, inputs: dict) -> ToolResult:
handler = TOOL_REGISTRY.get(name)
if not handler:
return ToolResult(success=False, error=f"알 수 없는 도구: {name}")
return handler(**inputs)
def process_tool_calls(response: anthropic.types.Message) -> list[dict]:
tool_uses = [
block for block in response.content
if block.type == "tool_use"
]
if not tool_uses:
return []
def execute(tool_use):
result = dispatch_tool(tool_use.name, tool_use.input)
return {
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": result.to_content(),
}
with ThreadPoolExecutor(max_workers=len(tool_uses)) as executor:
futures = {executor.submit(execute, tu): tu for tu in tool_uses}
results = []
for future in as_completed(futures):
results.append(future.result())
return results
def run_agent(user_message: str) -> str:
messages = [{"role": "user", "content": user_message}]
while True:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
return block.text
return ""
if response.stop_reason == "tool_use":
tool_results = process_tool_calls(response)
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
else:
break
return ""

각각 도구를 호출하는 여러 에이전트 오케스트레이션은 멀티 에이전트 패턴을 참조.


패턴 4: 안전한 도구 호출 래퍼

도구 예외가 처리되지 않은 채 에이전트 루프에 도달하게 해서는 절대 안 된다.

import signal
def timeout_handler(signum, frame):
raise TimeoutError("도구 실행 시간 초과")
def safe_tool_call(
name: str,
inputs: dict,
timeout_seconds: int = 30,
) -> ToolResult:
"""
타임아웃으로 도구를 실행하고 모든 예외를 캐치한다.
항상 ToolResult를 반환한다 — 절대 raise하지 않는다.
"""
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(timeout_seconds)
try:
return dispatch_tool(name, inputs)
except TimeoutError:
return ToolResult(
success=False,
error=f"도구 '{name}'이 {timeout_seconds}초 후 시간 초과"
)
except Exception as e:
return ToolResult(
success=False,
error=f"도구 '{name}'이 {type(e).__name__} 발생: {e}"
)
finally:
signal.alarm(0)

도구가 success: false를 반환하면 모델은 재시도할지, 대안을 시도할지, 실패를 보고할지 결정할 수 있다. 더 넓은 재시도 전략은 오류 복구 패턴을 참조.


패턴 5: 결과 검증 및 절단

도구 결과를 모델에 반환하기 전에 검증한다.

MAX_TOOL_RESULT_CHARS = 8000
def validate_result(result: ToolResult, expected_keys: list[str]) -> ToolResult:
if not result.success or not isinstance(result.data, dict):
return result
missing = [k for k in expected_keys if k not in result.data]
if missing:
return ToolResult(
success=False,
error=f"도구 응답에 예상 필드 누락: {missing}"
)
return result
def truncate_result(result: ToolResult) -> ToolResult:
content = result.to_content()
if len(content) <= MAX_TOOL_RESULT_CHARS:
return result
truncated_data = {
"truncated": True,
"chars_omitted": len(content) - MAX_TOOL_RESULT_CHARS,
"content": content[:MAX_TOOL_RESULT_CHARS],
}
return ToolResult(
success=result.success,
data=truncated_data,
error="결과 절단됨 — 컨텍스트 창에 비해 너무 큼",
)
def safe_tool_call_validated(
name: str,
inputs: dict,
expected_keys: list[str] | None = None,
) -> ToolResult:
result = safe_tool_call(name, inputs)
if expected_keys:
result = validate_result(result, expected_keys)
result = truncate_result(result)
return result

프로덕션에서 이러한 실패를 관찰하고 디버그하려면 디버깅과 관찰 가능성을 참조.


흔한 실수들

실수 1: 원시 API 응답 반환

모델은 30개의 필드가 있는 중첩 객체를 받고, 대부분은 관련 없다. 잘못된 것을 고른다.

해결책: 반환하기 전에 응답을 형성한다. 모델이 다음 결정을 내리는 데 필요한 것만 반환한다.

실수 2: 확인 없이 부작용이 있는 도구

이메일을 보내거나 레코드를 삭제하거나 비용을 청구하는 도구는 조용히 실행되어서는 안 된다.

해결책: 돌이킬 수 없는 작업에는 두 도구 패턴 사용: plan_email은 미리보기를 반환하고, send_email이 실제로 보낸다.

실수 3: 겹치는 도구 책임

비슷한 일을 하는 두 도구는 모델이 어느 것을 사용할지 추측하게 만든다.

해결책: 각 도구는 뚜렷하고 겹치지 않는 목적을 가져야 한다.

실수 4: 외부 도구에 타임아웃 없음

느린 서드파티 API 호출이 전체 에이전트 루프를 무기한 차단한다.

해결책: 항상 타임아웃을 설정한다 (패턴 4).


프로덕션 체크리스트

  • 각 도구에 모델 관점의 설명 있음 (”…할 때 사용한다”)
  • 모든 고정 값 입력에 enum 필드
  • 각 도구가 {success, data, error} 반환 — 원시 응답 절대 아님
  • 도구 호출이 safe_tool_call로 래핑됨
  • 멀티 도구 응답에 병렬 실행
  • 각 외부 도구에 타임아웃 설정
  • 가변 크기 페이로드에 결과 절단
  • 외부 API를 호출하는 도구에 검증

다음 단계

  1. 스키마부터 시작하자 — 함수 본체 전에 input_schema 작성
  2. ToolResult 래퍼 추가 — 기존 모든 도구에
  3. safe_tool_call 도입 — 에이전트 루프 강화
  4. 결과 검증 설정 — 제어하지 않는 API를 호출하는 도구에

관련 가이드: