포스트

2026-04-21 강의 정리

[프로젝트3] 안내 + Tool Calling (Function Calling) + 에이전트(Agent) 개념

2026-04-21 강의 정리

목차

  1. 문제와 접근
  2. Tool Calling 동작 원리
  3. @tool 데코레이터로 도구 만들기
  4. StructuredTool.from_function + Pydantic 스키마
  5. BaseTool 클래스 상속 (고급)
  6. llm.bind_tools() & 도구 실행
  7. 다중 도구 처리 (병렬 tool_calls)
  8. tool_loop — 자동 반복 실행 패턴
  9. with_structured_output — 구조화된 출력
  10. tool_choice — 특정 도구 강제 호출
  11. 핵심 개념 총정리
  12. 자주 나오는 실수/주의사항
  13. 모델의 요청과 실행 책임 나누기

1. 문제와 접근

Tool Calling은 모델이 함수를 직접 실행하는 기능으로 이해하기보다 실행 요청과 실제 처리를 나누어 보는 편이 명확하다. 모델이 제안한 도구 이름과 인자를 애플리케이션이 검증하고 실행한 뒤 결과를 다시 전달하는 흐름을 살펴본다. 도구 스키마와 실행 정책의 책임도 구분한다.

전체 흐름

1
2
3
4
5
6
7
8
9
10
11
사용자 질문
    ↓
LLM (gpt-4o-mini) — bind_tools([...]) 로 도구 목록 전달
    ↓
LLM이 tool_calls 반환 (어떤 도구에 어떤 인자를 쓸지 결정)
    ↓
개발자 코드가 tool_calls를 읽어 실제 함수 호출
    ↓
결과를 ToolMessage로 포장 → 다시 LLM에 전달
    ↓
최종 답변

LLM이 도구를 “실행”하는 것이 아니다.
LLM은 “어떤 도구를 어떤 인자로 호출하라”는 명령(tool_calls)을 반환할 뿐이고,
실제 실행은 개발자가 작성한 코드가 담당한다.


2. Tool Calling 동작 원리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 수동으로 이해해보기
def get_weather(city):
    weather_db = {
        "seoul" : "맑음, 22도, 습도 45%",
        "tokyo" : "흐림, 19도, 습도 70%",
    }
    return weather_db.get(city, f"{city}: 정보 없음")

# LLM이 반환하는 tool_calls 구조
tool_calls = {"name": "get_weather", "args": {"city": "seoul"}}

# 개발자가 직접 실행
result = get_weather(**tool_calls['args'])
# → '맑음, 22도, 습도 45%'

3. @tool 데코레이터로 도구 만들기

기본 문법

1
2
3
4
5
6
7
8
9
10
11
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """날씨를 조회하는 함수입니다"""  # docstring이 description으로 사용됨
    weather_db = {
        "seoul" : "맑음, 22도, 습도 45%",
        "도쿄"  : "흐림, 19도, 습도 70%",
        "뉴욕"  : "비, 15도, 습도 80%"
    }
    return weather_db.get(city, f"{city}: 정보 없음")

@tool 이 붙으면 일반 함수가 StructuredTool 객체로 변환된다

도구 메타데이터 확인

1
2
3
4
5
get_weather.name         # → 'get_weather'
get_weather.description  # → '날씨를 조회하는 함수입니다'
get_weather.args         # → {'city': {'title': 'City', 'type': 'string'}}

type(get_weather)        # → langchain_core.tools.structured.StructuredTool

도구 실행

1
2
result = get_weather.invoke(tc['args'])  # tc['args'] = {'city': '서울'}
# → '맑음, 22도, 습도 45%'

.invoke()로 실행한다. 일반 함수처럼 직접 호출하지 않는다.


4. StructuredTool.from_function + Pydantic 스키마

복잡한 파라미터 유효성 검증이 필요할 때 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool

class ETFSearchInput(BaseModel):
    category:    str   = Field(description="ETF 카테고리")
    min_return:  float = Field(default=0,   ge=0,  le=100, description='최소 수익률')
    max_expense: float = Field(default=1.0, ge=0,  le=5,   description='최대 운용보수')

def search_etf_func(category, min_return=0, max_expense=1.0):
    etfs = [
        {"name": "KODEX 200",        "cat": "국내주식", "ret": 8.5,  "exp": 0.15},
        {"name": "KODEX S&P500TR",   "cat": "해외주식", "ret": 25.3, "exp": 0.05},
        {"name": "TIGER 나스닥100",  "cat": "해외주식", "ret": 30.2, "exp": 0.07},
        {"name": "ACE 미국배당다우", "cat": "배당",     "ret": 12.1, "exp": 0.01},
    ]
    result = [e for e in etfs
              if e['cat'] == category and e['ret'] >= min_return and e['exp'] <= max_expense]
    return json.dumps(result, ensure_ascii=False) if result else '조건에 맞는 ETF 없음'

search_etf = StructuredTool.from_function(
    func         = search_etf_func,
    name         = 'search_etf',
    description  = '조건에 맞는 ETF 상품을 검색합니다.',
    args_schema  = ETFSearchInput,
)

args_schema에 BaseModel을 지정하면 Field의 ge, le 같은 유효성 검사가 자동으로 적용된다.


5. BaseTool 클래스 상속 (고급)

인스턴스 변수(캐시, 카운터 등)가 필요할 때 BaseTool을 직접 상속한다.

기본 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from langchain_core.tools import BaseTool

class APICallerTool(BaseTool):
    name:        str  = "api_caller"
    description: str  = "REST API를 호출하여 데이터를 조회합니다"
    call_count:  int  = 0
    cache:       dict = {}

    def _run(self, endpoint: str) -> str:
        if endpoint in self.cache:
            return f"[cache] {self.cache[endpoint]}"

        self.call_count += 1
        data = {
            "users/1"    : {"name": "abcd", "email": "abcd@example.com"},
            "posts/1"    : {"title": "1st post", "userId": 1},
        }
        result = json.dumps(data.get(endpoint, {"error": "not found"}), ensure_ascii=False)
        self.cache[endpoint] = result
        return f"[API #{self.call_count}] {result}"

Pydantic 스키마 결합

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from typing import Type

class StockInput(BaseModel):
    ticker:          str  = Field(description="주식 티커 (예: AAPL)")
    include_history: bool = Field(default=False, description="과거 데이터 포함 여부")

class StockTool(BaseTool):
    name:        str               = "stock_lookup"
    description: str               = "주식 정보를 조회합니다"
    args_schema: Type[BaseModel]   = StockInput
    query_log:   list              = []

    def _run(self, ticker: str, include_history: bool = False) -> str:
        prices = {"AAPL": 100.5, "GOOGL": 102.1, "TSLA": 280.3}
        price = prices.get(ticker.upper())
        if not price:
            return f"{ticker} no data"
        self.query_log.append({"ticker": ticker, "time": time.time()})
        result = f"{ticker}: ${price}"
        if include_history:
            result += f" (30일 변동: +{price * 0.05})"
        return result

캐시 패턴 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CachedSearchTool(BaseTool):
    name:     str  = "cached_search"
    description: str = "검색 결과를 캐싱하는 툴입니다"
    cache:    dict = {}
    api_call: int  = 0

    def _run(self, query: str) -> str:
        if query in self.cache:
            return f"[cache] {self.cache[query]}"
        self.api_call += 1
        result = f"'{query}' 검색 결과 {self.api_call}건"
        self.cache[query] = result
        return f"[API] {result}"

search = CachedSearchTool()
print(search.invoke({'query': '파이썬 기초'}))  # → [API] '파이썬 기초' 검색 결과 1건
print(search.invoke({'query': '파이썬 기초'}))  # → [cache] '파이썬 기초' 검색 결과 1건

6. llm.bind_tools() & 도구 실행

도구 호출 인자를 받았다고 바로 실행이 끝난 것은 아니다. 허용된 도구인지와 인자 범위를 확인하고, 실행 결과를 해당 호출 ID에 연결해야 모델이 어떤 요청의 결과를 받았는지 구분할 수 있다.

도구 등록

1
2
llm_with_tool = llm.bind_tools([get_weather, get_exchange_rate])
response = llm_with_tool.invoke("서울 날씨 알려줘")

tool_calls 확인

1
2
response.tool_calls
# → [{'name': 'get_weather', 'args': {'city': '서울'}, 'id': 'call_xxx', 'type': 'tool_call'}]

실행 패턴

1
2
3
4
5
6
tool_map = {t.name: t for t in tools}  # 이름으로 도구 찾기

if response.tool_calls:
    tc = response.tool_calls[0]
    selected_tool = tool_map[tc['name']]
    result = selected_tool.invoke(tc['args'])

ToolMessage로 결과 전달

1
2
3
4
5
6
7
8
9
10
11
12
from langchain_core.messages import HumanMessage, ToolMessage

messages = [HumanMessage(content="서울 날씨 알려줘")]
ai_msg = llm_with_tool.invoke(messages)
messages.append(ai_msg)

for tc in ai_msg.tool_calls:
    tool_result = tool_map[tc['name']].invoke(tc['args'])
    messages.append(ToolMessage(content=str(tool_result), tool_call_id=tc['id']))

final = llm_with_tool.invoke(messages)
print(final.content)

ToolMessage에 tool_call_id를 반드시 넣어야 한다.
LLM이 어떤 tool_call의 결과인지 매핑해야 하기 때문이다.


7. 다중 도구 처리 (병렬 tool_calls)

LLM은 한 번의 응답에서 여러 도구를 동시에 호출할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
all_tools = [get_weather, get_exchange_rate, search_etf]
llm_multi = llm.bind_tools(all_tools)

resp = llm_multi.invoke("서울과 도쿄 날씨 비교하고 환율도 알려줘")

# resp.tool_calls → 3개
# [{'name': 'get_weather', 'args': {'city': '서울'}, ...},
#  {'name': 'get_weather', 'args': {'city': '도쿄'}, ...},
#  {'name': 'get_exchange_rate', 'args': {'from_currency': 'KRW', 'to_currency': 'JPY'}, ...}]

tool_map = {t.name: t for t in all_tools}
for tc in resp.tool_calls:
    result = tool_map[tc['name']].invoke(tc['args'])
    print(f"  {tc['name']} ({tc['args']}) → {result}")

8. tool_loop — 자동 반복 실행 패턴

반복 루프에는 정상 종료 조건과 호출 한도를 함께 둔다. 같은 도구 요청이 반복되는 상황, 도구가 실패한 상황, 최종 답변을 반환한 상황을 나누어 기록하면 루프가 멈춘 이유를 설명할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def tool_loop_with_log(query, tools, max_turns=5):
    tool_map = {t.name: t for t in tools}
    llm_t    = llm.bind_tools(tools)
    messages = [HumanMessage(content=query)]
    call_log = []

    for turn in range(max_turns):
        response = llm_t.invoke(messages)
        messages.append(response)

        if not response.tool_calls:          # tool_calls 없으면 최종 답변
            return {"answer": response.content, "log": call_log}

        for tc in response.tool_calls:
            tool_obj = tool_map.get(tc['name'])
            result   = tool_obj.invoke(tc['args']) if tool_obj else "도구 없음"
            call_log.append({"turn": turn+1, "tool": tc['name'], "args": tc['args'], "result": result})
            messages.append(ToolMessage(content=str(result), tool_call_id=tc['id']))

    return {"answer": "최대 턴 초과", "log": call_log}

흐름 다이어그램

1
2
3
4
5
6
7
질문 입력
    ↓
LLM 호출 → tool_calls 있음?
    ↓ 있음                    ↓ 없음
도구 실행 → ToolMessage 추가  최종 답변 반환
    ↓
LLM 재호출 (반복)

9. with_structured_output — 구조화된 출력

LLM의 출력을 Pydantic 모델 인스턴스로 받는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
from pydantic import BaseModel, Field

class WeatherReport(BaseModel):
    city:           str = Field(description='city name')
    temperature:    int = Field(description='temperature')
    condition:      str = Field(description='weather condition')
    recommendation: str = Field(description='activity recommendation')

structured_llm = llm.with_structured_output(WeatherReport)
report = structured_llm.invoke("서울의 봄 날씨를 보고서로 작성해줘")

report.city         # → '서울'
report.temperature  # → 17

tool_calls → 구조화된 출력으로 이어지는 파이프라인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 도구 강제 호출로 날씨 정보 수집
llm_forced = llm.bind_tools([get_weather], tool_choice='get_weather')

messages = [HumanMessage(content="안녕?")]
resp = llm_forced.invoke(messages)   # tool_choice='get_weather'이므로 항상 도구 호출
messages.append(resp)

tc = resp.tool_calls[0]
tool_result = get_weather.invoke(tc['args'])
messages.append(ToolMessage(content=str(tool_result), tool_call_id=tc['id']))

# 2. 결과를 구조화된 출력으로 변환
structured_llm = llm.with_structured_output(WeatherReport)
final_report = structured_llm.invoke(messages)
# → WeatherReport(city='Seoul', temperature=20, ...)

10. tool_choice — 특정 도구 강제 호출

1
2
3
4
5
6
# tool_choice='get_weather' → 어떤 질문에도 get_weather 강제 호출
llm_forced = llm.bind_tools([get_weather, calculate], tool_choice='get_weather')

resp = llm_forced.invoke("안녕하세요")
resp.tool_calls
# → [{'name': 'get_weather', 'args': {'city': 'Seoul'}, ...}]

특정 도구로 항상 정보를 먼저 수집해야 하는 파이프라인에서 사용한다.


11. 핵심 개념 총정리

도구 생성 방법 비교

방법용도특징
@tool단순 함수가장 빠름. docstring → description
StructuredTool.from_function복잡한 입력 검증Pydantic 스키마로 ge, le 등 검증
BaseTool 상속캐시, 카운터 등 상태 필요인스턴스 변수 사용 가능

도구 실행 흐름

1
2
3
4
5
6
7
8
9
llm.bind_tools(tools)
    ↓
llm.invoke(messages) → response.tool_calls 확인
    ↓
tool_map[tc['name']].invoke(tc['args']) → 실제 실행
    ↓
ToolMessage(content=result, tool_call_id=tc['id'])
    ↓
messages에 추가 → llm.invoke(messages) → 최종 답변

12. 자주 나오는 실수/주의사항

실수 1: ToolMessage에 tool_call_id 빠뜨리기

1
2
3
4
5
# 틀린 코드 → LLM이 어떤 tool_call의 결과인지 모름
messages.append(ToolMessage(content=result))

# 올바른 코드
messages.append(ToolMessage(content=str(result), tool_call_id=tc['id']))

실수 2: .invoke() 대신 직접 함수 호출

1
2
3
4
5
# 틀린 코드
result = get_weather({'city': '서울'})   # TypeError

# 올바른 코드
result = get_weather.invoke({'city': '서울'})

실수 3: tool_calls가 없는데 tool_calls 접근

1
2
3
4
5
6
7
# 틀린 코드 → IndexError 가능
tc = response.tool_calls[0]

# 올바른 코드
if response.tool_calls:
    tc = response.tool_calls[0]
    ...

실수 4: Field에 enum 잘못 사용

1
2
3
4
5
6
7
8
# Pydantic v2에서 경고 발생
department: str = Field(description="부서명", enum=["개발", "마케팅"])

# 올바른 방법
department: str = Field(description="부서명", json_schema_extra={"enum": ["개발", "마케팅"]})
# 또는 Literal 사용
from typing import Literal
department: Literal["개발", "마케팅"] = Field(description="부서명")

실수 5: max_turns 없이 무한 루프

1
2
# tool_loop 에는 반드시 max_turns 설정
# LLM이 도구 호출을 멈추지 않으면 무한 반복됨

13. 모델의 요청과 실행 책임 나누기

도구 설명을 정교하게 쓰는 것과 잘못된 호출을 막는 것은 별도의 작업이다. 입력 검증과 실행 가능 범위는 호출 코드에서 확인하고, 도구마다 결과와 실패를 일정한 형태로 반환하도록 맞춘다.

동작 확인에는 존재하지 않는 도구, 범위를 벗어난 인자, 같은 요청의 반복을 포함한다. 읽기 전용 도구와 상태를 변경하는 도구는 재실행의 영향이 다르므로 같은 재시도 규칙을 일괄 적용하지 않는다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

인기 태그