도구와 함수 호출로 행동하는 애플리케이션 만들기
답변하는 앱에서 행동하는 앱으로
지금까지 우리가 만든 LangChain 애플리케이션은 주로 "묻고 답하는" 시스템이었습니다. 문서를 검색하고, 검색 결과를 바탕으로 답변하고, 대화 이력을 기억하는 정도까지 왔습니다. 이것만으로도 꽤 많은 일을 할 수 있지만, 실제 제품을 만들다 보면 곧 한계가 드러납니다.
사용자는 이렇게 묻습니다.
"이번 달 미납 고객을 찾아서 담당자에게 알려줘."
"서울의 오늘 날씨를 보고 외근 일정을 조정해야 할지 판단해 줘."
"주문 번호 1234의 배송 상태를 확인해 줘."
이 질문들은 단순한 언어 문제가 아닙니다. 데이터베이스를 조회해야 하고, 외부 API를 호출해야 하며, 때로는 이메일을 보내거나 결제를 취소하거나 내부 시스템의 상태를 바꿔야 합니다. LLM은 문장을 잘 만들지만, 그 자체로 우리 회사 데이터베이스에 접속하거나 날씨 API를 호출하거나 메일 서버에 명령을 내릴 수는 없습니다.
여기서 등장하는 개념이 도구 호출, 즉 tool calling입니다. 함수 호출(function calling)이라고도 부릅니다. 핵심은 간단합니다. 개발자가 "모델이 사용할 수 있는 함수 목록"을 미리 정의하고, 모델은 사용자 요청을 읽은 뒤 필요한 도구의 이름과 인자를 구조화된 형태로 반환합니다. 실제 함수 실행은 모델이 아니라 애플리케이션 코드가 담당합니다. LangChain 문서에서도 도구는 실시간 데이터 조회, 코드 실행, 데이터베이스 질의, 외부 세계의 행동을 가능하게 하는 함수 인터페이스로 설명됩니다.1
도구를 연결하는 순간 LangChain 앱은 단순 답변기를 넘어 실제 작업을 수행하는 시스템이 됩니다. 다만 힘이 생기는 만큼 책임도 커집니다. 잘못 설계한 도구는 엉뚱한 API를 호출하고, 잘못된 인자로 실패하며, 더 나쁘게는 사용자의 승인 없이 메일을 보내거나 데이터를 삭제할 수도 있습니다. 이번 장에서는 도구를 정의하고, 모델에 연결하고, 실행 결과를 다시 모델에 전달하는 기본 루프를 직접 구현하면서 안전한 경계까지 함께 설계해 보겠습니다.

도구 호출의 전체 흐름
도구 호출을 처음 접하면 "LLM이 함수를 실행한다"고 생각하기 쉽습니다. 하지만 실제로는 그렇지 않습니다. 모델은 파이썬 코드를 직접 실행하지 않습니다. 모델이 하는 일은 "이 도구를 이런 인자로 호출해야 한다"는 의도를 구조화해서 반환하는 것입니다.
흐름을 한 번에 보면 다음과 같습니다.
개발자가 도구를 정의합니다. 도구에는 이름, 설명, 입력 스키마, 실행 함수가 들어갑니다.
bind_tools로 채팅 모델에 도구 목록을 알려줍니다.사용자가 질문하면 모델이 도구가 필요한지 판단합니다.
도구가 필요하면 모델 응답의
tool_calls에 도구 이름, 인자, 호출 ID가 담깁니다.애플리케이션이 해당 도구를 실제로 실행합니다.
실행 결과를
ToolMessage로 만들어 모델에 다시 전달합니다.모델이 도구 결과를 읽고 최종 답변을 생성합니다.
이 과정에서 중요한 점은 네 번째와 다섯 번째 사이입니다. tool_calls가 왔다고 해서 함수가 자동 실행된 것이 아닙니다. 개발자가 실행 여부를 판단하고, 권한을 확인하고, 인자를 검증한 뒤 호출해야 합니다. LangChain의 도구 호출 인터페이스는 모델 제공자마다 다른 function-calling 형식을 표준화하여 tool_calls 같은 공통 형태로 다룰 수 있게 해 줍니다.2
간단히 말하면 모델은 조종석의 조종사가 아니라, "이 버튼을 눌러야 할 것 같습니다"라고 제안하는 부조종사에 가깝습니다. 실제 버튼을 누를 권한은 여전히 애플리케이션에 있습니다.
@tool로 가장 작은 도구 만들기
가장 쉬운 도구 정의 방법은 @tool 데코레이터를 사용하는 것입니다. 평범한 파이썬 함수에 데코레이터를 붙이면 LangChain이 함수 이름, 타입 힌트, docstring을 읽어 도구 스키마를 만듭니다.
from langchain_core.tools import tool
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two integers and return the result."""
return a * b
print(multiply.name)
print(multiply.description)
print(multiply.args)이 코드를 실행하면 multiply라는 도구가 만들어집니다. 함수의 타입 힌트 a: int, b: int는 입력 스키마로 변환되고, docstring은 모델이 도구 사용 여부를 판단할 때 참고하는 설명이 됩니다. LangChain 문서에서도 타입 힌트는 도구 입력 스키마를 정의하므로 필요하며, docstring은 모델이 도구의 목적을 이해하는 데 사용된다고 설명합니다.1
이 작은 예제는 단순하지만 도구 설계의 핵심을 모두 담고 있습니다. 모델은 함수 내부 코드를 보지 않습니다. 모델이 보는 것은 대체로 도구의 이름, 설명, 파라미터 이름, 타입, 설명입니다. 그러므로 이 정보가 부정확하면 모델도 도구를 잘못 선택하거나 잘못된 인자를 생성합니다.
예를 들어 다음 도구는 별로 좋지 않습니다.
@tool
def run(x: str) -> str:
"""Run something."""
return x이름도 모호하고, 설명도 모호하고, 인자 x가 무엇인지 알 수 없습니다. 사람에게 "run에 x 넣어서 실행하세요"라고 말하면 당황하듯, 모델도 당황합니다.
반대로 다음처럼 쓰면 훨씬 낫습니다.
@tool
def search_customer_orders(customer_name: str, limit: int = 5) -> str:
"""Search recent customer orders by customer name.
Args:
customer_name: Customer name to search for. Use the exact name if provided.
limit: Maximum number of orders to return. Default is 5.
"""
return f"Found up to {limit} orders for {customer_name}."이제 모델은 이 도구가 고객 주문 검색용이라는 것을 압니다. customer_name에는 고객 이름을 넣어야 하고, limit에는 결과 개수를 넣어야 한다는 것도 알 수 있습니다.
이름, 설명, 스키마가 모델의 행동을 결정한다
도구 호출 품질은 모델 성능만으로 결정되지 않습니다. 도구 설계가 절반입니다. 특히 도구의 이름과 설명은 모델에게 전달되는 사용 설명서입니다.
도구 이름은 짧고 구체적이어야 합니다. search보다 search_customer_orders가 낫고, process_data보다 calculate_refund_amount가 낫습니다. 모델은 도구 목록을 보고 어느 도구가 사용자 요청과 가장 가까운지 판단합니다. 비슷한 도구가 여러 개 있다면 이름과 설명이 더욱 중요해집니다.
설명은 "무엇을 하는가"뿐 아니라 "언제 사용해야 하는가"를 알려주는 것이 좋습니다.
@tool("get_weather")
def get_weather(city: str) -> str:
"""Get the current weather for a city.
Use this when the user asks about current weather, temperature,
rain, snow, or outdoor conditions for a specific city.
"""
fake_weather = {
"서울": "맑음, 22°C",
"부산": "흐림, 20°C",
"제주": "비, 18°C",
}
return fake_weather.get(city, f"No weather data for {city}.")입력 타입도 느슨하게 두지 않는 편이 좋습니다. dict 하나로 모든 것을 받게 만들면 모델은 어떤 키를 넣어야 하는지 추측해야 합니다. str, int, Literal, Pydantic 모델처럼 명확한 스키마를 사용하면 잘못된 호출 가능성이 줄어듭니다.
from typing import Literal
from pydantic import BaseModel, Field
from langchain_core.tools import tool
class WeatherInput(BaseModel):
city: str = Field(description="City name, for example '서울' or 'Busan'")
unit: Literal["celsius", "fahrenheit"] = Field(
default="celsius",
description="Temperature unit for the response"
)
@tool(args_schema=WeatherInput)
def get_current_weather(city: str, unit: str = "celsius") -> str:
"""Get current weather for a city."""
if unit == "fahrenheit":
return f"{city}: 72°F and sunny"
return f"{city}: 22°C and sunny"Pydantic 모델을 사용하면 각 필드에 설명을 붙일 수 있고, 허용 가능한 값도 제한할 수 있습니다. unit처럼 선택지가 정해진 값은 Literal이나 enum 형태로 제한하는 것이 좋습니다. 모델에게 "아무 문자열이나 넣어도 된다"고 말하는 것보다 "이 둘 중 하나만 넣어라"라고 말하는 편이 훨씬 안전합니다.
반환 형식도 중요합니다. 도구 결과는 최종적으로 모델이 읽고 답변에 반영합니다. 너무 복잡한 객체를 그대로 반환하면 모델이 핵심을 놓칠 수 있습니다. 모델에게 보여줄 결과는 간결한 문자열이나 명확한 JSON 형태로 정리하고, 원본 객체나 대용량 데이터는 애플리케이션 내부에서 별도로 보관하는 편이 좋습니다.
예를 들어 데이터베이스 조회 결과 전체를 그대로 던지기보다 다음처럼 필요한 필드만 요약하는 편이 안전합니다.
@tool
def get_order_status(order_id: str) -> str:
"""Look up the delivery status for a customer order by order ID."""
# 실제로는 DB나 API를 조회한다.
order = {
"order_id": order_id,
"status": "배송 중",
"expected_arrival": "2026-05-11",
}
return (
f"Order {order['order_id']} status: {order['status']}. "
f"Expected arrival: {order['expected_arrival']}."
)모델에게 필요한 것은 대부분 "사용자에게 답하기 위한 관찰 결과"입니다. 내부 시스템의 모든 세부 정보가 아닙니다.
BaseTool로 더 세밀하게 제어하기
대부분의 경우 @tool이면 충분합니다. 하지만 도구 내부에 클라이언트 객체를 보관하거나, 동기·비동기 실행을 분리하거나, 공통 설정을 클래스 상태로 관리해야 할 때는 BaseTool을 직접 상속할 수 있습니다.
from typing import Type
from pydantic import BaseModel, Field
from langchain_core.tools import BaseTool
class OrderLookupInput(BaseModel):
order_id: str = Field(description="Order ID to look up")
class OrderLookupTool(BaseTool):
name: str = "lookup_order_status"
description: str = "Look up the current delivery status of an order."
args_schema: Type[BaseModel] = OrderLookupInput
def _run(self, order_id: str) -> str:
# 실제 환경에서는 DB 클라이언트나 내부 API를 호출한다.
return f"Order {order_id} is currently in transit."
async def _arun(self, order_id: str) -> str:
# 비동기 API 클라이언트를 쓴다면 여기서 await 호출을 수행한다.
return self._run(order_id)
lookup_order_status = OrderLookupTool()BaseTool 방식은 코드가 조금 길지만, 복잡한 도구를 제품 코드로 관리하기에 좋습니다. 예를 들어 도구마다 타임아웃, 재시도 정책, 로깅, 권한 검사, API 클라이언트 주입이 필요하다면 클래스로 분리하는 편이 더 깔끔합니다.
단, 클래스 기반 도구에서도 이름, 설명, 스키마의 원칙은 그대로 적용됩니다. 오히려 더 중요합니다. 복잡한 도구일수록 모델에게 노출되는 인터페이스는 단순해야 합니다.
모델에 도구 연결하기
도구를 만들었다면 이제 모델에 연결할 차례입니다. 이때 사용하는 메서드가 bind_tools입니다. bind_tools는 모델에게 "너는 다음 도구들을 사용할 수 있다"고 알려줍니다.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [multiply, get_current_weather, get_order_status]
llm_with_tools = llm.bind_tools(tools)이제 llm_with_tools를 호출하면 모델은 사용자 질문을 보고 도구 호출 여부를 판단합니다. 도구가 필요 없는 질문이라면 일반 텍스트 답변을 반환합니다.
response = llm_with_tools.invoke("안녕하세요!")
print(response.content)
print(response.tool_calls)예상 형태는 다음과 비슷합니다.
안녕하세요! 무엇을 도와드릴까요?
[]반대로 도구가 필요한 질문이라면 content가 비어 있고 tool_calls에 호출 정보가 들어갈 수 있습니다.
response = llm_with_tools.invoke("3 곱하기 7은 얼마야?")
print(response.content)
print(response.tool_calls)예상 형태는 다음과 같습니다.
[
{
"name": "multiply",
"args": {"a": 3, "b": 7},
"id": "call_abc123",
"type": "tool_call",
}
]여기서 id는 매우 중요합니다. 나중에 도구 실행 결과를 ToolMessage로 돌려줄 때, 어떤 도구 호출에 대한 결과인지 연결하는 데 사용됩니다. LangChain의 도구 호출 결과는 도구 이름, 구조화된 인자, 호출 ID를 포함하며, 실행 결과는 ToolMessage로 다시 전달되는 형태를 가집니다.2
도구 실행 루프 직접 구현하기
이제 핵심 루프를 직접 만들어 보겠습니다. 다음 코드는 모델이 반환한 tool_calls를 읽고, 해당 도구를 실행한 뒤, 결과를 다시 모델에 전달합니다.
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two integers and return the result."""
return a * b
@tool
def add(a: int, b: int) -> int:
"""Add two integers and return the result."""
return a + b
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [multiply, add]
llm_with_tools = llm.bind_tools(tools)
tool_map = {tool.name: tool for tool in tools}
messages = [HumanMessage(content="3 곱하기 7에 10을 더하면 얼마야?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
for tool_call in ai_msg.tool_calls:
selected_tool = tool_map[tool_call["name"]]
tool_result = selected_tool.invoke(tool_call["args"])
messages.append(
ToolMessage(
content=str(tool_result),
tool_call_id=tool_call["id"],
name=tool_call["name"],
)
)
final_msg = llm_with_tools.invoke(messages)
print(final_msg.content)이 코드는 매우 중요합니다. 다음 장에서 다룰 에이전트는 이 루프를 더 자동화한 형태라고 볼 수 있습니다. 지금은 직접 구현했기 때문에 각 단계가 눈에 보입니다.
모델은 처음 호출에서 최종 답을 바로 말하지 않을 수 있습니다. 대신 multiply를 호출하라는 요청을 반환합니다. 애플리케이션은 multiply를 실행하고, 그 결과를 ToolMessage로 붙입니다. 그다음 모델을 다시 호출하면 모델은 도구 결과를 근거로 자연스러운 최종 답변을 만듭니다.
여러 도구 호출이 동시에 들어올 수도 있습니다. 예를 들어 모델이 multiply(3, 7)과 add(21, 10)을 한 번에 요청할 수 있습니다. 모델 제공자와 모델에 따라 병렬 도구 호출을 지원하는 방식은 다르지만, 애플리케이션 코드는 여러 tool_calls를 순회할 수 있게 작성하는 편이 좋습니다.
도구 수가 늘어나면 다음처럼 실행 함수를 분리해 두면 편합니다.
from langchain_core.messages import ToolMessage
class UnknownToolError(Exception):
pass
def execute_tool_calls(ai_msg, tool_map):
tool_messages = []
for tool_call in ai_msg.tool_calls:
name = tool_call["name"]
args = tool_call["args"]
call_id = tool_call["id"]
if name not in tool_map:
content = f"Error: unknown tool '{name}'."
else:
try:
result = tool_map[name].invoke(args)
content = str(result)
except Exception as e:
content = f"Error while running tool '{name}': {e}"
tool_messages.append(
ToolMessage(
content=content,
tool_call_id=call_id,
name=name,
)
)
return tool_messages이 함수는 성공과 실패를 모두 ToolMessage로 바꿉니다. 에러가 발생했다고 바로 프로그램을 죽이는 대신, 모델에게 "이 도구 실행은 이런 이유로 실패했다"고 알려줄 수 있습니다. 그러면 모델은 사용자에게 실패를 설명하거나, 다른 도구를 시도하거나, 필요한 정보를 다시 물을 수 있습니다.
외부 API 도구 만들기
실전에서는 계산기보다 API 호출 도구를 더 자주 만납니다. 날씨, 결제, CRM, 검색, 사내 데이터베이스, 티켓 시스템 등 대부분의 기능은 HTTP API나 SDK 뒤에 숨어 있습니다.
다음은 날씨 API를 흉내 낸 도구입니다.
import requests
from pydantic import BaseModel, Field
from langchain_core.tools import tool
class WeatherRequest(BaseModel):
city: str = Field(description="City name to get weather for")
@tool(args_schema=WeatherRequest)
def fetch_weather(city: str) -> str:
"""Fetch current weather for a city.
Use this when the user asks about live weather, temperature,
rain, snow, or outdoor conditions.
"""
try:
# 예시 URL이다. 실제 서비스에서는 사용하는 날씨 API에 맞게 수정한다.
response = requests.get(
"https://api.example.com/weather",
params={"city": city},
timeout=3,
)
response.raise_for_status()
data = response.json()
except requests.Timeout:
return "Weather API timed out. Please try again later."
except requests.HTTPError as e:
return f"Weather API returned an HTTP error: {e.response.status_code}."
except Exception as e:
return f"Weather lookup failed: {e}"
condition = data.get("condition", "unknown")
temp = data.get("temperature_c", "unknown")
return f"{city}: {condition}, {temp}°C"여기서 눈여겨볼 부분은 timeout=3입니다. 도구는 외부 시스템에 의존하므로 언제든 느려지거나 실패할 수 있습니다. 타임아웃이 없으면 사용자 요청 하나가 오래 붙잡혀 전체 앱 경험을 망칠 수 있습니다. 외부 API 도구에는 거의 항상 타임아웃, 오류 처리, 재시도 정책이 필요합니다.
또 하나 중요한 점은 API 키를 모델에게 인자로 받지 않는다는 것입니다. 절대 다음처럼 만들지 마세요.
@tool
def fetch_weather_bad(city: str, api_key: str) -> str:
"""Fetch weather."""
...이렇게 만들면 모델이 api_key를 생성하려고 하거나, 사용자에게 API 키를 물어보거나, 더 나쁘게는 로그와 메시지에 민감 정보가 섞일 수 있습니다. API 키는 서버 환경 변수나 런타임 컨텍스트에서 주입해야 합니다. 모델에게는 도시 이름처럼 정말 필요한 입력만 노출해야 합니다.
데이터베이스 조회 도구 만들기
데이터베이스 도구는 특히 조심해야 합니다. LLM에게 SQL 문자열을 자유롭게 만들게 하고 그대로 실행하는 방식은 위험합니다. 가능하면 "허용된 작업"을 좁은 함수로 감싸야 합니다.
나쁜 예는 다음과 같습니다.
@tool
def run_sql(query: str) -> str:
"""Run SQL query."""
...이 도구는 너무 강력합니다. 모델이 실수로 DELETE나 UPDATE를 만들 수도 있고, 사용자가 프롬프트 인젝션으로 위험한 쿼리를 유도할 수도 있습니다.
더 나은 방식은 조회 목적별 도구를 따로 만드는 것입니다.
from pydantic import BaseModel, Field
from langchain_core.tools import tool
class CustomerSearchInput(BaseModel):
email: str = Field(description="Customer email address to search")
@tool(args_schema=CustomerSearchInput)
def find_customer_by_email(email: str) -> str:
"""Find a customer profile by email address.
This is a read-only lookup. It does not modify customer data.
"""
# 실제 환경에서는 파라미터 바인딩된 쿼리를 사용한다.
fake_db = {
"[email protected]": {
"name": "Alice",
"plan": "Pro",
"status": "active",
}
}
customer = fake_db.get(email)
if not customer:
return "No customer found."
return (
f"Customer: {customer['name']}, "
f"plan: {customer['plan']}, "
f"status: {customer['status']}"
)이 도구는 읽기 전용이고, 입력도 이메일 하나로 제한됩니다. 모델이 직접 SQL을 만들지 않으므로 위험 범위가 크게 줄어듭니다. SQL이 필요한 경우에도 내부 코드에서 파라미터 바인딩을 사용하고, 허용된 테이블과 컬럼만 조회하게 제한하는 것이 좋습니다.
읽기 도구와 쓰기 도구를 구분하라
도구는 크게 읽기 도구와 쓰기 도구로 나눌 수 있습니다. 읽기 도구는 외부 상태를 바꾸지 않습니다. 검색, 조회, 계산, 요약, 가격 확인, 배송 상태 확인 등이 여기에 속합니다. 쓰기 도구는 외부 상태를 바꿉니다. 메일 전송, 결제 취소, 환불 처리, 데이터베이스 수정, 파일 삭제, 티켓 생성, Slack 메시지 전송 등이 여기에 속합니다.
읽기 도구는 실패해도 비교적 피해가 작습니다. 물론 개인정보 노출 같은 문제가 있을 수 있으므로 권한 검사는 필요합니다. 하지만 쓰기 도구는 훨씬 조심해야 합니다. 모델이 사용자의 의도를 잘못 해석하면 실제 피해가 발생할 수 있기 때문입니다.
예를 들어 다음 도구는 쓰기 도구입니다.
class EmailInput(BaseModel):
recipient: str = Field(description="Email recipient address")
subject: str = Field(description="Email subject")
body: str = Field(description="Email body")
@tool(args_schema=EmailInput)
def send_email(recipient: str, subject: str, body: str) -> str:
"""Send an email to a recipient.
This tool changes external state by sending a real email.
It must only be executed after explicit user approval.
"""
# 실제 메일 전송 로직
return f"Email sent to {recipient}."이 도구는 절대 모델의 첫 판단만으로 실행해서는 안 됩니다. 실행 전 사용자에게 미리보기를 보여주고 승인받아야 합니다.
def needs_approval(tool_name: str) -> bool:
write_tools = {"send_email", "cancel_payment", "delete_file", "update_customer"}
return tool_name in write_tools
def preview_tool_call(tool_call: dict) -> str:
return (
f"도구 실행 승인 요청n"
f"도구: {tool_call['name']}n"
f"인자: {tool_call['args']}n"
)
def require_user_approval(tool_call: dict) -> bool:
print(preview_tool_call(tool_call))
answer = input("실행할까요? yes/no: ")
return answer.strip().lower() in {"yes", "y"}실제 웹 애플리케이션에서는 input() 대신 UI에서 승인 버튼을 제공하면 됩니다. 중요한 것은 승인 전 미리보기입니다. "메일을 보낼까요?"가 아니라 "누구에게, 어떤 제목으로, 어떤 본문을 보낼까요?"를 보여줘야 합니다. LangChain 생태계에서도 신뢰하기 어려운 도구 호출 앞에 사람의 승인을 두는 human-in-the-loop 패턴이 자주 사용됩니다.3
쓰기 도구에는 다음 네 가지 장치를 두는 것이 좋습니다.
사용자 승인: 실행 전 어떤 작업이 수행될지 명확히 보여주고 승인받습니다.
권한 검증: 현재 사용자가 이 고객, 주문, 파일, 결제에 접근하거나 수정할 권한이 있는지 확인합니다.
실행 전 미리보기: 실제 변경 내용을 사람이 이해할 수 있는 형태로 표시합니다.
감사 로그: 누가, 언제, 어떤 요청으로, 어떤 도구를, 어떤 인자로 실행했는지 기록합니다.
도구 호출에서 안전은 프롬프트 문장 하나로 해결되지 않습니다. "위험한 일은 하지 마"라는 시스템 프롬프트보다 서버 코드의 승인 절차와 권한 검사가 더 믿을 만합니다.
안전한 도구 실행기 만들기
앞에서 만든 실행 루프에 승인과 감사 로그를 추가해 보겠습니다.
from datetime import datetime
from langchain_core.messages import ToolMessage
WRITE_TOOLS = {"send_email", "cancel_payment", "delete_file", "update_customer"}
def log_tool_event(user_id: str, tool_call: dict, status: str, result: str | None = None):
event = {
"timestamp": datetime.utcnow().isoformat(),
"user_id": user_id,
"tool_name": tool_call["name"],
"tool_args": tool_call["args"],
"tool_call_id": tool_call["id"],
"status": status,
"result": result,
}
print("AUDIT_LOG", event)
def execute_tool_calls_safely(ai_msg, tool_map, user_id: str):
tool_messages = []
for tool_call in ai_msg.tool_calls:
name = tool_call["name"]
call_id = tool_call["id"]
if name not in tool_map:
content = f"Error: unknown tool '{name}'."
log_tool_event(user_id, tool_call, "rejected_unknown_tool", content)
elif name in WRITE_TOOLS and not require_user_approval(tool_call):
content = f"Tool '{name}' was not executed because the user did not approve it."
log_tool_event(user_id, tool_call, "rejected_by_user", content)
else:
try:
log_tool_event(user_id, tool_call, "started")
result = tool_map[name].invoke(tool_call["args"])
content = str(result)
log_tool_event(user_id, tool_call, "succeeded", content)
except Exception as e:
content = f"Error while running tool '{name}': {e}"
log_tool_event(user_id, tool_call, "failed", content)
tool_messages.append(
ToolMessage(content=content, tool_call_id=call_id, name=name)
)
return tool_messages이 실행기는 모델이 도구를 요청하더라도 무조건 실행하지 않습니다. 알 수 없는 도구는 거절하고, 쓰기 도구는 승인 절차를 통과해야 실행합니다. 모든 결과는 로그로 남깁니다.
실무에서는 여기에 더 많은 검사가 들어갑니다. 예를 들어 send_email 도구라면 수신자가 회사 도메인인지 확인할 수 있고, cancel_payment라면 결제 금액이 일정 금액 이상일 때 관리자 승인을 요구할 수 있습니다. delete_file이라면 휴지통으로 이동만 하고 즉시 영구 삭제하지 않는 정책을 둘 수도 있습니다.
오류를 모델이 이해할 수 있게 반환하기
도구는 실패합니다. 잘못된 인자, 스키마 검증 실패, API 오류, 빈 결과, 시간 초과, 권한 부족, 외부 서비스 장애가 모두 일어납니다. 실패를 숨기면 모델은 엉뚱한 답변을 만들 수 있습니다. 실패를 그대로 예외로 터뜨리면 사용자 경험이 깨집니다. 좋은 도구는 실패를 "모델이 다음 행동을 선택할 수 있는 관찰 결과"로 바꿔 줍니다.
LangChain에는 도구 오류를 다루기 위한 ToolException과 handle_tool_error 패턴이 있습니다. 도구 내부에서 ToolException을 발생시키고, 도구에 오류 처리 방식을 지정하면 예외를 도구 출력으로 바꿀 수 있습니다.4
from langchain_core.tools import ToolException, tool
@tool
def get_supported_city_weather(city: str) -> str:
"""Get weather for supported Korean cities: 서울, 부산, 제주."""
weather = {
"서울": "맑음, 22°C",
"부산": "흐림, 20°C",
"제주": "비, 18°C",
}
if city not in weather:
raise ToolException(
f"Unsupported city: {city}. Supported cities are 서울, 부산, 제주."
)
return weather[city]
get_supported_city_weather.handle_tool_error = True이렇게 하면 도구 실행 실패가 곧바로 전체 애플리케이션 실패로 번지는 것을 줄일 수 있습니다. 모델은 "지원하지 않는 도시"라는 결과를 보고 사용자에게 지원 도시를 다시 물을 수 있습니다.
API 오류도 마찬가지입니다. 다음처럼 오류 유형별로 메시지를 구분해 주면 모델과 사용자 모두에게 도움이 됩니다.
import requests
from langchain_core.tools import ToolException, tool
@tool
def lookup_invoice(invoice_id: str) -> str:
"""Look up invoice payment status by invoice ID."""
try:
response = requests.get(
f"https://api.example.com/invoices/{invoice_id}",
timeout=3,
)
if response.status_code == 404:
return f"No invoice found for id {invoice_id}."
response.raise_for_status()
data = response.json()
except requests.Timeout:
raise ToolException("Invoice API timed out. Try again later.")
except requests.HTTPError as e:
raise ToolException(f"Invoice API HTTP error: {e.response.status_code}.")
except Exception as e:
raise ToolException(f"Invoice lookup failed: {e}")
return f"Invoice {invoice_id}: status={data['status']}, amount={data['amount']}"
lookup_invoice.handle_tool_error = True오류 메시지는 너무 내부적이면 안 됩니다. 스택 트레이스, SQL, API 키, 내부 호스트명 같은 정보는 모델이나 사용자에게 노출하지 않는 편이 좋습니다. 대신 "무엇이 실패했는지"와 "다음에 무엇을 할 수 있는지"를 알려주는 정도가 적절합니다.
빈 결과도 오류처럼 다루어야 합니다. 검색 도구가 빈 리스트를 반환했는데 모델에게 아무 설명 없이 []만 던지면, 모델은 결과가 없는지, 오류인지, 형식이 잘못된 것인지 헷갈릴 수 있습니다.
@tool
def search_knowledge_base(query: str) -> str:
"""Search the internal knowledge base for relevant articles."""
results = [] # 실제로는 검색 결과
if not results:
return (
"No matching knowledge base articles were found. "
"Ask the user for more specific keywords or try a broader query."
)
return "n".join(results)도구 결과는 모델의 다음 행동을 안내하는 신호입니다. 성공 결과뿐 아니라 실패 결과도 잘 설계해야 합니다.
재시도와 대체 응답
외부 API는 일시적으로 실패할 수 있습니다. 네트워크 오류나 503 응답처럼 잠시 후 성공할 가능성이 있는 오류에는 재시도를 적용할 수 있습니다. 다만 모든 오류를 재시도하면 안 됩니다. 404 Not Found나 권한 부족은 다시 호출해도 보통 해결되지 않습니다.
간단한 재시도 래퍼를 만들어 보겠습니다.
import time
def retry_tool_call(tool, args: dict, max_attempts: int = 3, delay: float = 0.5):
last_error = None
for attempt in range(1, max_attempts + 1):
try:
return tool.invoke(args)
except Exception as e:
last_error = e
if attempt < max_attempts:
time.sleep(delay * attempt)
raise last_error실전에서는 tenacity 같은 라이브러리를 사용해 지수 백오프, 재시도 가능한 예외 구분, 최대 대기 시간을 더 정교하게 설정할 수 있습니다. 중요한 원칙은 재시도 자체가 아니라 "모델과 사용자에게 최종 상태를 명확히 알려주는 것"입니다.
def run_tool_with_fallback(tool, args: dict) -> str:
try:
result = retry_tool_call(tool, args)
return str(result)
except Exception:
return (
"The tool failed after several attempts. "
"Tell the user that the external service is temporarily unavailable "
"and suggest trying again later."
)모델은 이 메시지를 보고 "현재 외부 서비스가 불안정해 확인할 수 없습니다. 잠시 후 다시 시도해 주세요"처럼 사용자 친화적인 답변을 만들 수 있습니다.
모델에게 노출하지 말아야 할 값들
도구 스키마에 들어가는 값은 모델이 생성할 수 있는 값입니다. 따라서 모델이 직접 생성하면 안 되는 값은 스키마에서 빼야 합니다. 대표적으로 API 키, 사용자 ID, 조직 ID, 세션 ID, 권한 범위, 내부 토큰, 데이터베이스 연결 정보가 여기에 속합니다.
예를 들어 현재 사용자의 계정 정보를 조회하는 도구를 만든다고 해 봅시다. 나쁜 설계는 다음과 같습니다.
@tool
def get_account_balance(user_id: str) -> str:
"""Get account balance by user ID."""
...이 도구를 모델에게 노출하면 모델이 user_id를 임의로 만들거나, 사용자 입력에 포함된 다른 사람의 ID를 그대로 넣을 수 있습니다. 대신 현재 로그인한 사용자의 ID는 서버가 알고 있어야 하며, 모델에게는 노출하지 않아야 합니다.
LangChain의 최신 도구 런타임은 상태, 컨텍스트, 저장소, 실행 정보처럼 모델이 직접 생성하지 않아야 하는 런타임 정보를 도구 안에서 접근하는 방식을 제공합니다.1 예를 들어 에이전트 런타임 컨텍스트를 사용하면 사용자 ID를 모델 입력 스키마에 포함하지 않고도 도구 내부에서 사용할 수 있습니다.
from dataclasses import dataclass
from langchain.tools import tool, ToolRuntime
@dataclass
class UserContext:
user_id: str
USER_BALANCES = {
"user-123": "120,000원",
"user-456": "54,000원",
}
@tool
def get_my_balance(runtime: ToolRuntime[UserContext]) -> str:
"""Get the current authenticated user's account balance."""
user_id = runtime.context.user_id
balance = USER_BALANCES.get(user_id)
if balance is None:
return "No account found for the current user."
return f"Your current balance is {balance}."이 도구의 중요한 특징은 모델이 user_id를 인자로 만들지 않는다는 점입니다. 사용자 ID는 런타임 컨텍스트에서 주입됩니다. 모델에게 노출되는 것은 "현재 사용자의 잔액을 조회할 수 있다"는 도구의 목적뿐입니다.
수동 bind_tools 루프를 쓰는 경우에도 같은 원칙을 적용할 수 있습니다. 클로저나 서비스 객체에 서버 측 사용자 정보를 주입하고, 도구 스키마에는 사용자 입력만 남깁니다.
def make_user_order_tool(current_user_id: str):
@tool
def list_my_orders(limit: int = 5) -> str:
"""List recent orders for the current authenticated user."""
# current_user_id는 모델이 만든 값이 아니라 서버가 주입한 값이다.
return f"Recent {limit} orders for user {current_user_id}."
return list_my_orders
current_user_id = "user-123" # 실제로는 세션/인증 미들웨어에서 가져온다.
list_my_orders = make_user_order_tool(current_user_id)이 방식은 간단하지만 효과적입니다. 모델은 limit만 생성할 수 있고, current_user_id는 건드릴 수 없습니다.
도구 결과와 아티팩트 분리하기
도구 실행 결과에는 모델이 읽어야 하는 내용과 애플리케이션 내부에서만 필요한 내용이 섞일 수 있습니다. 예를 들어 검색 도구가 문서 객체 전체를 가져왔다고 해 봅시다. 모델에게는 요약된 검색 결과와 출처만 보여주고, 원본 문서 객체는 후속 처리나 UI 렌더링을 위해 따로 보관하고 싶을 수 있습니다.
LangChain 도구는 response_format="content_and_artifact"를 사용해 모델에게 전달할 content와 내부에서 사용할 artifact를 나눌 수 있습니다.4
from typing import Tuple, list
from langchain_core.tools import tool
@tool(response_format="content_and_artifact")
def search_products(query: str) -> Tuple[str, list[dict]]:
"""Search product catalog by keyword."""
products = [
{"id": "p1", "name": "무선 키보드", "price": 59000},
{"id": "p2", "name": "기계식 키보드", "price": 129000},
]
content = (
"Found 2 products: "
"무선 키보드(59,000원), 기계식 키보드(129,000원)."
)
artifact = products
return content, artifact모델은 content를 읽고 답변을 만들 수 있습니다. 애플리케이션은 artifact를 사용해 상품 카드 UI를 렌더링하거나, 후속 도구 호출에서 상품 ID를 참조할 수 있습니다. 이 분리는 RAG에서 원문 문서와 답변용 요약을 나누던 감각과 비슷합니다.
도구가 너무 많을 때 생기는 문제
도구는 많을수록 좋은 것이 아닙니다. 모델에게 도구 3개를 주면 선택이 쉽지만, 50개를 주면 헷갈릴 수 있습니다. 특히 이름과 설명이 비슷한 도구가 많으면 잘못된 도구를 고를 가능성이 올라갑니다.
도구 목록이 커질 때는 먼저 도구를 업무 영역별로 나누는 것을 고려하세요. 고객 지원 대화에는 고객 조회, 주문 조회, 환불 정책 검색 도구만 연결하고, 회계 업무에는 송장 조회, 결제 상태 확인, 세금 계산 도구만 연결하는 식입니다.
또한 하나의 거대한 도구보다 좁고 명확한 도구 여러 개가 낫습니다. 하지만 너무 잘게 쪼개도 선택지가 많아져 혼란스럽습니다. 기준은 "사람이 봤을 때 언제 이 도구를 써야 하는지 명확한가"입니다. 이 기준을 통과하지 못하면 모델도 대체로 헷갈립니다.
도구 설계 시 다음 질문을 던져 보세요.
이 도구의 이름만 보고 목적을 짐작할 수 있는가?
설명에 사용 시점과 사용하지 말아야 할 시점이 드러나는가?
입력 파라미터가 모델이 생성해도 되는 값만 포함하는가?
반환값이 모델이 읽고 다음 행동을 결정하기에 충분히 간결한가?
실패했을 때 모델이 사용자에게 무엇을 말해야 하는지 알 수 있는가?
이 질문들에 답하지 못하면 아직 도구가 아니라 "그냥 함수"에 가깝습니다. LLM에게 제공할 도구는 사람이 쓰는 내부 함수보다 더 친절한 인터페이스를 가져야 합니다.
실전 예제: 주문 조회 후 메일 초안 만들기
이제 읽기 도구와 쓰기 도구를 함께 사용하는 작은 예제를 만들어 보겠습니다. 사용자가 "주문 1001 상태 확인해서 고객에게 안내 메일 보내줘"라고 요청했다고 가정합니다.
메일 전송은 쓰기 도구이므로 바로 실행하지 않고 승인 절차를 둡니다.
from pydantic import BaseModel, Field
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
class OrderStatusInput(BaseModel):
order_id: str = Field(description="Order ID to look up")
@tool(args_schema=OrderStatusInput)
def lookup_order(order_id: str) -> str:
"""Look up order delivery status by order ID. Read-only."""
fake_orders = {
"1001": "배송 중이며 2026-05-11 도착 예정입니다.",
"1002": "상품 준비 중입니다.",
}
return fake_orders.get(order_id, "No order found.")
class SendEmailInput(BaseModel):
recipient: str = Field(description="Recipient email address")
subject: str = Field(description="Email subject")
body: str = Field(description="Email body")
@tool(args_schema=SendEmailInput)
def send_email(recipient: str, subject: str, body: str) -> str:
"""Send an email. Requires explicit user approval before execution."""
return f"Email sent to {recipient}."모델에는 두 도구를 모두 연결합니다.
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [lookup_order, send_email]
tool_map = {tool.name: tool for tool in tools}
llm_with_tools = llm.bind_tools(tools)실행 루프는 쓰기 도구를 승인 대상으로 분기합니다.
WRITE_TOOLS = {"send_email"}
def execute_with_approval(ai_msg):
tool_messages = []
for tool_call in ai_msg.tool_calls:
name = tool_call["name"]
args = tool_call["args"]
if name in WRITE_TOOLS:
print("n실행 전 미리보기")
print(f"도구: {name}")
print(f"인자: {args}")
approved = input("이 작업을 실행할까요? yes/no: ")
if approved.strip().lower() not in {"yes", "y"}:
result = "User rejected this tool call. Do not send the email."
else:
result = tool_map[name].invoke(args)
else:
result = tool_map[name].invoke(args)
tool_messages.append(
ToolMessage(
content=str(result),
tool_call_id=tool_call["id"],
name=name,
)
)
return tool_messages전체 대화는 다음처럼 진행됩니다.
messages = [
HumanMessage(
content=(
"주문 1001 상태를 확인해서 [email protected] 고객에게 "
"안내 메일을 보내줘."
)
)
]
while True:
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
if not ai_msg.tool_calls:
print(ai_msg.content)
break
tool_messages = execute_with_approval(ai_msg)
messages.extend(tool_messages)이 예제에서 모델은 먼저 lookup_order를 호출해 주문 상태를 확인할 수 있습니다. 그 결과를 받은 뒤 메일 제목과 본문을 구성하고 send_email을 호출하려 할 수 있습니다. 애플리케이션은 메일 전송 전 수신자, 제목, 본문을 보여주고 사용자의 승인을 받습니다. 승인되면 실행하고, 거부되면 실행하지 않았다는 결과를 모델에게 전달합니다.
이 구조가 중요한 이유는 모델의 자연어 이해 능력과 애플리케이션의 통제권을 분리하기 때문입니다. 모델은 "무엇을 해야 할지" 제안하고, 애플리케이션은 "해도 되는지" 결정합니다.
도구 호출과 에이전트의 차이
이번 장에서는 도구 호출 루프를 직접 구현했습니다. 다음 장에서 배울 에이전트는 이 루프를 더 일반화한 구조입니다. 에이전트는 모델 호출, 도구 선택, 도구 실행, 결과 재전달, 다음 행동 결정을 반복합니다.
하지만 에이전트를 쓰기 전에 수동 루프를 이해하는 것은 매우 중요합니다. 자동화된 에이전트가 내부에서 무엇을 하는지 알아야 디버깅할 수 있기 때문입니다. 도구 호출이 실패했을 때 문제는 여러 곳에 있을 수 있습니다. 도구 설명이 모호했을 수도 있고, 스키마가 잘못되었을 수도 있고, 모델이 인자를 잘못 만들었을 수도 있고, 애플리케이션의 권한 검사가 막았을 수도 있습니다.
수동 루프를 이해하면 다음 질문에 답할 수 있습니다.
모델이 도구를 호출하지 않은 이유는 무엇인가?
모델이 왜 이 도구를 선택했는가?
생성된 인자는 스키마에 맞는가?
도구 실행은 성공했는가?
ToolMessage의tool_call_id가 올바르게 연결되었는가?최종 답변에 도구 결과가 제대로 반영되었는가?
이 질문들이 곧 에이전트 디버깅의 기본 질문입니다.
마무리: 도구는 LLM의 손과 발이다
LLM은 언어를 이해하고 계획을 세우는 데 강합니다. 하지만 외부 세계와 연결되지 않으면 실제 일을 할 수 없습니다. 도구는 LLM의 손과 발입니다. 검색 도구는 눈이 되고, 계산 도구는 계산기가 되고, 데이터베이스 도구는 기억 창고가 되고, 이메일 도구는 외부로 행동하는 팔이 됩니다.
하지만 손과 발이 생겼다고 마음대로 움직이게 해서는 안 됩니다. 도구의 이름과 설명을 명확히 하고, 입력 스키마를 엄격하게 만들고, 모델이 생성하면 안 되는 값은 런타임에서 주입해야 합니다. 읽기 도구와 쓰기 도구를 구분하고, 외부 상태를 바꾸는 작업에는 승인과 감사 로그를 두어야 합니다. 오류는 숨기지 말고 모델이 이해할 수 있는 관찰 결과로 바꿔야 합니다.
이번 장에서 만든 구조는 다음 장의 에이전트로 자연스럽게 이어집니다. 에이전트는 방금 우리가 직접 만든 루프를 더 유연하게 반복하는 시스템입니다. 이제 우리는 모델에게 도구를 쥐여 주는 법을 배웠습니다. 다음 단계는 모델이 여러 도구를 사용해 스스로 계획하고 행동하도록 만드는 것입니다.
참고
2Tool calling - Docs by LangChain
3LangChain을 활용한 Tool Calling # 4
4Tools and Function Calling | langchain-ai/langchain | DeepWiki
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.