12: 에이전트가 사용할 도구 만들기
Part IV에서는 에이전트(Agent)를 만들어보겠습니다. 에이전트는 사용자의 요청에 대해 스스로 판단하고 행동하는 AI입니다. 단순히 응답만 하는 챗봇과는 근본적으로 다릅니다.
차이를 구체적으로 보겠습니다. 사용자가 "#12345 주문을 취소해주세요"라고 요청하면, 챗봇은 "마이페이지 > 주문 내역에서 해당 주문의 '취소' 버튼을 클릭해 주세요"라고 안내합니다. 사용자가 챗봇의 안내에 따라 직접 진행해야 합니다. 반면 에이전트는 주문 정보를 조회하고, 취소 가능 여부를 확인하고, 취소를 처리합니다. 직접 행동하는 것입니다.
에이전트가 행동할 수 있는 이유는 도구(tool)를 가지고 있기 때문입니다. 주문을 조회하는 도구, 주문을 취소하는 도구, 이메일을 보내는 도구 — 이런 도구들을 통해 LLM은 텍스트 생성을 넘어 실제 작업을 수행할 수 있습니다.
Part IV에서는 이러한 에이전트를 만드는 방법을 하나씩 알아보겠습니다. 먼저 에이전트가 사용할 수 있는 도구를 만들고(이 장), 만든 도구를 LLM에 연결하고(13장), 판단 → 실행 → 관찰을 반복하는 에이전트 루프를 구축합니다(14장).
이 장에서는 도구를 정의하고, 실제 데이터에 연결하고, 오류를 안전하게 처리하도록 만드는 방법을 배웁니다.
12.1) @tool 데코레이터로 도구 정의하기
12.1.1) 도구는 어떻게 동작하는가?
도입부에서 에이전트가 도구를 통해 주문을 조회하고 취소하는 것을 보았습니다. 여기서 한 가지 짚고 넘어가야 할 것이 있습니다. "LLM이 도구를 사용한다"고 하면 LLM이 직접 도구를 호출하는 것처럼 들리지만, 실제로는 그렇지 않습니다. LLM은 어떤 코드도 실행하지 않습니다. LLM이 하는 일은 "이 도구를 이 인수로 호출해달라"고 요청하는 것뿐이고, 실제 실행은 우리 에이전트 코드가 담당합니다.
이 방식이 잘 동작하려면, 어떤 도구들이 있고 언제 사용해야 하는지 LLM이 판단할 수 있어야 합니다. 이를 위해 각 도구는 세 가지 메타데이터를 제공합니다.
name—get_order와 같은 짧은 식별자. LLM이 호출할 도구를 지정할 때 사용합니다.description— 도구가 무엇을 하고 언제 사용하는지 설명하는 문장. 언제 어떤 도구를 사용할지 판단하기 위해 LLM이 읽는 내용입니다.- 입력 스키마 — 도구를 호출할 때 전달하는 파라미터에 대한 정의. 파라미터 이름, 타입, 의미 등 LLM이 인수를 올바르게 채우기 위해 필요합니다.
이 세 가지 정보를 제공하기 위해 특별한 작업이 필요한 것은 아닙니다. name은 함수 이름에서 자동으로 가져오고, description은 독스트링에서, 입력 스키마는 매개변수의 타입 힌트에서 추출됩니다. 함수에 LangChain의 @tool 데코레이터를 붙이기만 하면 됩니다.
12.1.2) 첫 번째 도구 만들기
이제 간단한 도구를 만들어 보겠습니다. 함수에 타입 힌트와 독스트링을 작성하고 @tool을 붙이면 도구가 됩니다.
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""주어진 도시의 현재 날씨를 가져옵니다."""
return f"It's always sunny in {city}!"@tool이 만들어준 메타데이터를 확인해 보겠습니다.
print(get_weather.name) # 출력: get_weather
print(get_weather.description) # 출력: 주어진 도시의 현재 날씨를 가져옵니다.
print(get_weather.args)
# 출력: {'city': {'title': 'City', 'type': 'string'}}함수 이름 get_weather가 name이 되고, 독스트링이 description이 되고, 타입 힌트 city: str이 입력 스키마가 되었습니다. 앞에서 설명한 세 가지 메타데이터가 모두 자동으로 생성된 것을 확인할 수 있습니다. LLM은 이 정보를 기반으로 도구를 선택하고 인수를 채웁니다.
@tool을 붙이면 함수는 LangChain의 도구 객체로 변환됩니다. 따라서 일반 함수처럼 get_weather("Paris")로 호출할 수 없고, .invoke() 메서드를 사용해야 합니다. .invoke()는 6장에서 체인을 실행할 때 사용했던 것과 같은 LangChain 객체의 표준 실행 메서드입니다. 인수는 {"city": "Paris"}처럼 매개변수 이름을 키로 하는 딕셔너리로 전달합니다.
result = get_weather.invoke({"city": "Paris"})
print(result) # 출력: It's always sunny in Paris!12.1.3) name과 description 커스터마이징
기본적으로 name은 함수 이름에서, description은 독스트링에서 자동으로 가져옵니다. 그러나 이를 직접 작성할 수도 있습니다.
@tool의 첫 번째 인수로 name을 지정합니다.
@tool("web_search")
def search(query: str) -> str:
"""웹에서 정보를 검색합니다."""
return f"Results for: {query}"
print(search.name) # 출력: web_searchdescription 파라미터로 도구의 설명을 직접 작성할 수도 있습니다. 독스트링은 개발자를 위한 설명으로 두고, LLM에게는 도구에 맞는 설명을 전달하고 싶을 때 유용합니다.
@tool("calculator", description="산술 연산을 수행합니다. 모든 수학 문제에 이것을 사용하세요.")
def calc(expression: str) -> str:
"""수학 표현식 문자열을 평가합니다."""
return str(eval(expression)) # 주의: eval()은 보안에 취약합니다. 프로덕션에서는 사용하지 마세요.name은 snake_case로 작성하세요. 일부 LLM 프로바이더는 공백이나 특수 문자가 포함된 이름을 거부합니다.
12.1.4) Pydantic으로 입력 스키마 정의하기
파라미터가 여러 개이거나 파라미터별로 설명을 추가하고 싶다면 Pydantic 모델로 입력 스키마를 정의할 수 있습니다. 7장에서 구조화된 출력을 위해 사용했던 BaseModel과 Field를 여기서도 같은 방식으로 사용합니다.
from pydantic import BaseModel, Field
from langchain.tools import tool
class WeatherInput(BaseModel):
"""날씨 쿼리를 위한 입력."""
location: str = Field(description="도시 이름 (예: 서울, Tokyo)")
units: str = Field(default="celsius", description="온도 단위 (celsius 또는 fahrenheit)")
@tool(args_schema=WeatherInput)
def get_weather_detailed(location: str, units: str = "celsius") -> str:
"""선택한 온도 단위로 현재 날씨를 가져옵니다."""
temp = 22 if units == "celsius" else 72
return f"Current weather in {location}: {temp} degrees {units[0].upper()}"Field(description=...)에 작성한 설명은 LLM이 읽는 입력 스키마의 일부가 됩니다. 파라미터가 무엇을 의미하는지 LLM이 정확히 이해할 수 있도록 해줍니다. 대부분의 경우 타입 힌트와 독스트링만으로 충분하지만, 파라미터별 상세 설명이 필요할 때 args_schema를 사용하세요.
12.2) 도구 오류 처리하기
현실의 도구는 데이터베이스 연결이 끊기거나 예상치 못한 입력이 들어오는 등 오류가 발생할 수 있습니다. 이 섹션에서는 오류가 발생해도 에이전트가 멈추지 않고 적절히 대응할 수 있도록 도구 내부에서 오류를 처리하는 방법을 알아보겠습니다. 학습 진행에 앞서 도구가 사용할 DB 조회 함수를 먼저 만들겠습니다.
12.2.1) 학습 진행 준비: 실습용 DB 조회 함수
# product_service.py
PRODUCTS = {
1: {"name": "Wireless Mouse", "price": 29.99, "stock": 120},
2: {"name": "Mechanical Keyboard", "price": 89.99, "stock": 0},
3: {"name": "USB-C Hub", "price": 45.50, "stock": 35},
4: {"name": "Laptop Stand", "price": 39.00, "stock": 8},
}
def fetch_product(product_id: int) -> dict:
"""ID로 제품 정보를 조회합니다."""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product
def fetch_stock(product_id: int) -> int:
"""제품의 재고 수량을 반환합니다."""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product["stock"]두 함수 모두 존재하지 않는 제품 ID로 조회할 때 ValueError를 발생시키고 있습니다.
12.2.2) 도구에서 오류 처리하기
fetch_product 함수를 호출하는 get_product 도구를 만들어 보겠습니다.
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""ID로 제품을 조회합니다. 이름, 가격, 재고 수량을 반환합니다."""
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."존재하는 ID로 호출하면 정상적으로 동작합니다.
print(get_product.invoke({"product_id": 1}))
# 출력: Product 1: Wireless Mouse — $29.99, 120 in stock.하지만 존재하지 않는 ID를 전달하면 fetch_product이 ValueError를 발생시키고, 예외가 처리되지 않아 에이전트는 실행이 중단됩니다.
print(get_product.invoke({"product_id": 99}))
# ValueError: Product with ID 99 not found.이 문제를 해결하는 방법은 간단합니다. 도구 내부에서 예외를 잡아 LLM이 이해할 수 있는 오류 메시지 문자열로 반환합니다. 성공하든 실패하든 도구는 항상 문자열을 반환하고, LLM이 그 문자열을 읽고 다음 행동을 판단합니다.
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""ID로 제품을 조회합니다. 이름, 가격, 재고 수량을 반환합니다."""
try:
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error looking up product {product_id}: {e}"print(get_product.invoke({"product_id": 1}))
# 출력: Product 1: Wireless Mouse — $29.99, 120 in stock.
print(get_product.invoke({"product_id": 99}))
# 출력: Error: Product with ID 99 not found.이제 존재하지 않는 ID를 전달해도 예외가 발생하지 않고, LLM이 이해할 수 있는 오류 메시지가 반환됩니다.
check_stock에도 같은 패턴을 적용합니다.
from product_service import fetch_stock
@tool
def check_stock(product_id: int) -> str:
"""제품이 현재 재고에 있는지 확인합니다."""
try:
stock = fetch_stock(product_id)
if stock > 0:
return f"{stock} units available."
return "Out of stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error checking stock for product {product_id}: {e}"이렇게 만든 도구는 .invoke()로 직접 테스트해 볼 수 있습니다. 도구를 LLM에 연결하기 전에 도구가 올바르게 동작하는지 먼저 확인하세요. 도구 문제 확인 없이 진행하면 나중에 에이전트에서 문제가 생겼을 때 도구 문제인지, 모델의 결정 때문인지 구분하기 어렵기 때문입니다.