도구 호출 vs 에이전트 간 통신

··은 추상이 한 단계씩 올라간 같은 아이디어다. 셋의 경계가 흐려질수록 설계가 자유로워진다.


진화의 세 단계

단계누가 무엇을 부르나추상 수준
이 JSON으로 함수를 지목가장 낮음
LLM이 도구 세트 중 골라 호출중간
에이전트가 동료 에이전트 호출가장 높음

같은 구조다: “이름 + 입력 스키마”로 정의된 호출 가능한 단위. 자릿수만 다르다. 이 1바이트라면, 은 한 페이지짜리 작업이다.


function-calling: 기본기

OpenAI·Anthropic의 SDK가 노출하는 가장 작은 단위. , 호출은 tool_use 블록이다. 은 결국 모델이 “이 함수를 이 인자로 부르고 싶다”고 선언하는 것일 뿐, 실제 실행은 클라이언트 몫이다.

# Verified against: https://platform.claude.com/docs/en/docs/build-with-claude/tool-use
# Verified at: 2026-06-02
from anthropic import Anthropic

client = Anthropic()
tools = [{
  "name": "get_weather",
  "description": "도시의 현재 날씨 조회",
  "input_schema": {
      "type": "object",
      "properties": {"city": {"type": "string"}},
      "required": ["city"],
  },
}]

r = client.messages.create(
  model="claude-sonnet-4-6",
  max_tokens=1024,
  tools=tools,
  messages=[{"role": "user", "content": "서울 날씨 알려줘"}],
)
print(r.stop_reason)  # "tool_use"
print(r.content)

구조화 출력 vs tool use

OpenAI가 같은 문제에 살짝 다른 답을 제시했다 — . 를 강제하지만, 결과는 **이 아니라 모델 출력 그 자체다. 이 도구를 부를 의도가 없을 때도 같은 구조를 보장한다.

# Verified against: https://platform.openai.com/docs/guides/structured-outputs
# Verified at: 2026-06-02
from openai import OpenAI
from pydantic import BaseModel

class Weather(BaseModel):
  city: str
  temperature_c: float
  summary: str

client = OpenAI()
r = client.beta.chat.completions.parse(
  model="gpt-4o",
  messages=[{"role": "user", "content": "서울 날씨를 객체로 줘"}],
  response_format=Weather,
)
print(r.choices[0].message.parsed)

규칙: 호출이 필요하면 tool, 구조화된 답만 필요하면 structured output.


tool-calling 루프

다이어그램 로딩…

은 한 번으로 끝나지 않는다. 의 본질은 루프다.

  1. LLM에 메시지 + tools 전달
  2. LLM이 stop_reason="tool_use"로 응답
  3. 호출자가 도구 실행 → 받음
  4. 메시지 히스토리에 tool_result 추가
  5. 다시 LLM 호출 → 답 또는 다음 tool_use

이 루프가 의 SDK 버전이다.


tool-result와 tool-error

가 성공·실패 둘 다 반환할 수 있어야 한다. 를 같은 블록 구조로 흘리는 게 표준이다. Anthropic SDK의 메시지 블록 모양:

# Verified against: https://platform.claude.com/docs/en/docs/build-with-claude/tool-use
# Verified at: 2026-06-02
def tool_result(tool_use_id: str, content, is_error=False):
  return {
      "type": "tool_result",
      "tool_use_id": tool_use_id,
      "content": content,
      "is_error": is_error,
  }

ok = tool_result("toolu_01", [{"type": "text", "text": "서울 18도 맑음"}])
err = tool_result("toolu_02", "rate limited", is_error=True)

is_error: true를 LLM이 보면 대안 도구 또는 사용자에게 묻기로 전환한다.


재시도·폴백·에스컬레이션

실패는 평범한 일이다. 가 났을 때 은 코드에 미리 박아둬야 한다. 생각하는 시점에 임시 장애까지 처리하게 두면 토큰만 낭비된다.

# Verified against: https://docs.python.org/3/library/asyncio.html
# Verified at: 2026-06-02
import asyncio, random

async def call_with_retry(fn, *, retries=3, base=0.5):
  for i in range(retries):
      try:
          return await fn()
      except Exception as e:
          if i == retries - 1:
              raise
          # 지수 백오프 + 지터
          await asyncio.sleep(base * (2 ** i) + random.random() * 0.1)

규칙: 재시도가 통해야 멱등하다. 부수효과 있는 도구는 를 함께 보낸다.


tool-router: 도구 폭증 해소

가 50개를 넘어가면 이 헷갈리기 시작한다 — . 해법은 다.

# Verified against: https://platform.claude.com/docs/en/docs/build-with-claude/tool-use
# Verified at: 2026-06-02
from typing import Callable

TOOLS: dict[str, dict] = {...}  # 전체 카탈로그

def route(query: str, k: int = 5) -> list[dict]:
  """질의에 가장 관련 높은 k개만 노출."""
  # 실제로는 임베딩 유사도. 데모는 키워드.
  scored = sorted(TOOLS.items(),
                  key=lambda kv: score(query, kv[1]["description"]),
                  reverse=True)
  return [v for _, v in scored[:k]]

def score(q: str, desc: str) -> float:
  return sum(w in desc for w in q.split())

agent-as-tool: 에이전트를 도구로 노출

여기가 추상 점프 지점이다. — 다른 를 호출자 입장에서 일반 처럼 다룬다.

# Verified against: https://platform.claude.com/docs/en/docs/build-with-claude/tool-use
# Verified at: 2026-06-02
import httpx

researcher_as_tool = {
  "name": "delegate_to_researcher",
  "description": "주제를 받아 보고서를 만드는 동료 에이전트",
  "input_schema": {
      "type": "object",
      "properties": {"topic": {"type": "string"}},
      "required": ["topic"],
  },
}

async def call_researcher(topic: str) -> str:
  body = {
      "jsonrpc": "2.0", "id": 1, "method": "message/send",
      "params": {"message": {"messageId": "m1", "role": "user",
                              "parts": [{"text": topic}]}},
  }
  async with httpx.AsyncClient() as c:
      r = await c.post("https://example.com/a2a/v1", json=body)
      return r.json()["result"]["status"]["message"]

호출자 LLM은 그냥 함수 하나를 본다. 안쪽에서는 이 도는데도.


같은 기능, 세 가지 노출 방식

문서 검색을 가정해보자. 어떻게 노출하느냐에 따라 시스템 모양이 달라진다.

방식호출 인터페이스장단
search(query) 직접빠름, 단발
tools/call over MCP호스트 앱에서 재사용
검색 에이전트가 RAG 전체 담당큰 단위 위임, 상태

같은 문제, 세 결정. 시스템이 작을수록 1번이 맞고, 도메인이 복잡할수록 3번이 맞다.


에러를 위로 흘리는 법

가 났을 때 세 갈래 길이 있다.

  1. 재시도(같은 도구): 일시 장애. + .
  2. 폴백(다른 도구): 비슷한 기능 도구로 대체. LLM이 골라야 한다.
  3. 에스컬레이션(위로): 위 두 가지가 안 되면 는 부모 또는 사람에게 넘긴다 — .

LLM의 이 위 셋 중 어디로 갈지를 결정한다. 시스템 프롬프트에 규칙을 박아두자: “동일 도구 3회 실패 시 다른 도구 시도, 5회 시 사용자에게 보고”.


다음 챕터로

, 자연스럽게 한 명이 여럿을 지휘하는 패턴이 보인다. 다음 장에서는 — 한 감독자가 여러 를 디스패치하고 결과를 합치는 가장 흔한 구조다.