Python & AI Tutorials Logo
LangChain & LangGraph

13. LLM에 도구 연결하기

12장에서 우리는 에이전트가 사용할 수 있는 도구를 만들었습니다. 이제 에이전트가 이 도구를 사용할 수 있도록 LLM에 연결할 차례입니다.

도구가 아무리 잘 만들어져 있어도, LLM이 그 존재를 모르면 사용할 수 없습니다. 어떤 도구가 있는지 알고, 사용자의 요청을 보고 필요한 도구를 스스로 선택할 수 있어야 합니다. 그러나 12장에서 배웠듯이 LLM이 직접 도구를 실행하지는 않습니다. 대신 "이 도구를 이 인수로 호출해달라"고 요청하면, 우리 에이전트 코드가 해당 도구를 호출합니다. 이 전체 메커니즘을 도구 호출(tool calling)이라고 합니다.

이번 장에서는 도구를 LLM에 바인딩하고, LLM의 도구 호출 요청을 실행하여 결과를 LLM에게 돌려주는 전체 사이클을 구현합니다. 이 사이클이 14장에서 구축할 에이전트 루프의 토대가 됩니다.

13.1) 도구를 바인딩하고 LLM의 도구 호출 요청 확인하기

LLM이 도구를 사용할 수 있으려면, 먼저 어떤 도구가 있는지 알아야 합니다. 각 도구의 name, description, 입력 스키마를 LLM에게 전달하면, LLM은 언제 어떤 도구를 사용할 수 있는지 파악하게 됩니다. 이 과정을 도구 바인딩이라고 합니다.

이후, 사용자의 질문을 보내면 LLM은 두 가지 중 하나를 합니다. 도구가 필요 없으면 평소처럼 텍스트로 답변하고, 도구가 필요하면 "이 도구를 이 인수로 호출해달라"는 구조화된 요청을 반환합니다. 우리 에이전트는 이 요청을 파악하여 지정된 도구를 호출합니다.

먼저 도구를 바인딩하는 방법부터 살펴보겠습니다.

13.1.1) bind_tools()로 도구 바인딩하기

도구 바인딩은 bind_tools() 메서드 하나로 모두 처리됩니다. 도구 호출을 지원하는 모든 채팅 모델이 이 메서드를 제공합니다. bind_tools()를 호출하면, 전달된 도구 목록이 바인딩된 새로운 모델 객체가 반환됩니다.

12장에서 만든 get_weather 도구를 바인딩하겠습니다.

python
from langchain_openai import ChatOpenAI
from langchain.tools import tool
 
llm = ChatOpenAI(model="gpt-5-mini")
 
@tool
def get_weather(city: str) -> str:
    """주어진 도시의 현재 날씨를 가져옵니다."""
    return f"It's always sunny in {city}!"
 
# 도구를 모델에 바인딩합니다. 도구가 바인딩된 새로운 모델 객체가 반환됩니다.
llm_with_tools = llm.bind_tools([get_weather])

이것이 전부입니다. 반환된 llm_with_toolsget_weather 도구를 아는 모델입니다. 원래 모델인 llm 자체는 아무것도 바뀌지 않았다는 점에 주목하세요. llm은 여전히 도구를 모릅니다.

bind_tools()는 도구 목록을 인수로 받고, 해당 도구들이 바인딩된 새로운 모델 객체를 반환합니다. 내부적으로는 각 도구의 메타데이터(name, description, 입력 스키마)를 LLM 제공자가 이해하는 형식으로 변환하여, 모델 호출 시 함께 전달되도록 설정합니다.

13.1.2) LLM의 도구 호출 요청 확인하기

날씨 조회 도구가 바인딩된 모델에 날씨 질문을 보내면 어떤 일이 일어날까요?

python
response = llm_with_tools.invoke("What's the weather in Paris?")
 
print(f"type: {type(response)}\n")
print(f"tool_calls: {response.tool_calls}\n")
print(f"content: {repr(response.content)}\n")

출력:

type: <class 'langchain_core.messages.ai.AIMessage'>
 
tool_calls: [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': 'call_hgXrHGD', 'type': 'tool_call'}]
 
content: ''

llm_with_tools도 같은 채팅 모델이므로 AIMessage를 반환했습니다. 그러나 이번 응답에는 평소와 다른 점이 두 가지 있습니다.

첫째, response.tool_calls에 도구 호출 요청 리스트가 담겨 있습니다. 각 요청은 딕셔너리로 구성되어 있으며, 각 키가 의미하는 바는 다음과 같습니다.

  • name — 호출할 도구의 이름입니다. 도구 메타데이터의 name에 해당합니다.
  • args — 도구를 호출할 때 전달할 인수입니다. 모델이 입력 스키마와 사용자의 질문을 보고 {'city': 'Paris'}처럼 인수를 구성합니다.
  • id — 이 호출의 고유 식별자입니다. 나중에 LLM이 도구 실행 결과와 도구 호출 요청을 매칭할 때 사용합니다.
  • type — 항상 'tool_call'입니다.

둘째, response.content가 빈 문자열입니다. 이번 응답은 텍스트로 된 최종 답변이 아니라 도구 실행 요청이기 때문입니다.

도구 호출 요청인지 판단하기 위해서는 content가 아닌 tool_calls를 확인해야 합니다. tool_calls가 있으면 도구 실행 요청이고, 비어 있으면 텍스트로 최종 답변을 한 것입니다.

13.2) 도구 실행하고 결과 전달하기

앞에서 LLM은 get_weather 도구 호출을 요청했습니다. 이제는 우리 에이전트가 LLM 요청에 따라 도구를 실행하고 결과를 LLM에게 돌려줄 차례입니다.

도구 호출 요청을 처리하는 과정은 세 단계입니다.

  1. tool_calls에서 도구 이름과 인수를 꺼내 해당 도구를 실행합니다.
  2. 실행 결과를 ToolMessage변환합니다.
  3. 전체 대화(사용자 질문 + LLM의 도구 호출 요청 + 도구 실행 결과)를 LLM에게 다시 전송하여 응답을 받습니다.

이 과정을 다이어그램으로 보면 다음과 같습니다.

LLMget_weather에이전트사용자LLMget_weather에이전트사용자"Paris의 날씨는 어떤가요?"HumanMessageAIMessage (tool_calls: get_weather)get_weather(city='Paris')"It's always sunny in Paris!"ToolMessageAIMessage ("Paris의 날씨는 화창합니다!")"Paris의 날씨는 화창합니다!"

13.2.1) 도구 실행하고 ToolMessage 만들기

tool_calls에서 도구 이름과 인수를 꺼내 해당 도구를 실행합니다. 도구 이름으로 실행할 도구를 찾기 위해, 도구 이름을 키로 하는 딕셔너리를 만들어 둡니다.

python
# 도구 이름으로 도구를 찾을 수 있는 딕셔너리
tool_map = {get_weather.name: get_weather}

tool_calls에서 도구 호출 요청을 꺼내고, tool_map에서 해당 도구를 찾아 실행합니다.

python
if response.tool_calls:
    tool_call = response.tool_calls[0]
    # {'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': 'call_hgXrHGD', 'type': 'tool_call'}
 
    selected_tool = tool_map[tool_call["name"]]
    result = selected_tool.invoke(tool_call["args"])
    print(result)  # 출력: It's always sunny in Paris!

도구 실행 결과를 LLM에게 돌려주려면 ToolMessage에 담아야 합니다. ToolMessage에는 두 개의 필수 필드가 있습니다.

  • content — 도구 실행 결과 문자열입니다.
  • tool_call_idtool_callsid입니다. 이 값으로 LLM은 어떤 요청에 대한 결과인지 식별합니다.
python
from langchain_core.messages import ToolMessage
 
if response.tool_calls:
    tool_call = response.tool_calls[0]
 
    selected_tool = tool_map[tool_call["name"]]
    result = selected_tool.invoke(tool_call["args"])
 
    tool_message = ToolMessage(
        content=result,
        tool_call_id=tool_call["id"],   # 반드시 요청의 id와 일치해야 합니다
    )
    print(tool_message)

출력:

content="It's always sunny in Paris!" tool_call_id='call_hgXrHGD'

위 코드에서는 args 인자를 전달하여 도구를 실행하고, 실행 결과와 tool_call_id를 조합하여 ToolMessage를 만들었습니다.

이 과정을 한 번에 처리하는 방법이 있습니다. 도구를 실행할 때 args가 아닌 tool_call 딕셔너리 전체를 .invoke()에 전달하면, LangChain이 도구를 실행한 후 ToolMessage를 생성하여 반환합니다.

python
if response.tool_calls:
    tool_call = response.tool_calls[0]
 
    selected_tool = tool_map[tool_call["name"]]
    tool_message = selected_tool.invoke(tool_call)
    print(tool_message)

출력:

content="It's always sunny in Paris!" tool_call_id='call_hgXrHGD'

같은 결과이지만 코드가 훨씬 단순하고, tool_call_id 매칭 실수도 발생할 일이 없습니다. 앞으로는 이 방식을 사용하겠습니다.

13.2.2) 도구 실행 결과를 LLM에 전송하기

이제 도구 실행 결과를 LLM에게 전달하겠습니다. 전체 대화 기록(사용자 질문 + LLM의 도구 호출 요청 + 도구 실행 결과)을 순서대로 LLM에게 보내면, LLM이 도구 결과를 바탕으로 답변을 생성합니다.

지금까지의 과정을 처음부터 하나의 코드로 정리하겠습니다.

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.tools import tool
 
# 도구 정의 및 바인딩
llm = ChatOpenAI(model="gpt-5-mini")
 
@tool
def get_weather(city: str) -> str:
    """주어진 도시의 현재 날씨를 가져옵니다."""
    return f"It's always sunny in {city}!"
 
llm_with_tools = llm.bind_tools([get_weather])
tool_map = {get_weather.name: get_weather}
 
# 1단계: 사용자 질문을 보내고 LLM의 응답을 받습니다
messages = [HumanMessage("What's the weather in Paris?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
 
# 2단계: 도구 호출이 요청되었으면 실행하고 결과를 추가합니다
if ai_msg.tool_calls:
    tool_call = ai_msg.tool_calls[0]
    selected_tool = tool_map[tool_call["name"]]
    tool_message = selected_tool.invoke(tool_call)
    messages.append(tool_message)
 
    # 3단계: 전체 대화 기록을 LLM에게 다시 전송합니다
    final_response = llm_with_tools.invoke(messages)
    print(final_response.content)

출력:

The weather in Paris is currently sunny!

LLM이 도구 실행 결과를 바탕으로 최종 텍스트 답변을 생성했습니다. 13.1에서는 content가 빈 문자열이었지만, 도구 결과를 전달하자 실제 답변이 돌아온 것을 확인할 수 있습니다.

3단계 messages 목록에 어떤 메시지들이 들어 있는지 확인해 보겠습니다.

python
for m in messages:
    print(f"{type(m).__name__}: {m.content!r}")

출력:

HumanMessage: "What's the weather in Paris?"
AIMessage: ''
ToolMessage: "It's always sunny in Paris!"

사용자 질문, LLM의 도구 호출 요청(빈 content), 도구 실행 결과까지 세 개의 메시지가 순서대로 담겨 있습니다. 사용자 질문을 보고 LLM은 도구 사용이 필요하다고 판단하여 get_weather 도구 호출을 요청했고, 에이전트 코드는 get_weather를 실행한 후 결과를 메시지 목록에 추가했습니다. LLM은 답변에 필요한 모든 정보가 갖춰진 것을 확인하고 최종 답변을 생성했습니다.

13.3) LLM에 여러 도구 제공하기

실제 에이전트는 대부분 여러 도구를 갖고 있습니다. 앞에서 배운 패턴을 도구가 여러 개일 때도 동일하게 적용할 수 있습니다.

get_weathercalculatesearch_web을 추가하여 세 개의 도구를 바인딩하겠습니다.

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.tools import tool
 
llm = ChatOpenAI(model="gpt-5-mini")
 
@tool
def get_weather(city: str) -> str:
    """주어진 도시의 현재 날씨를 가져옵니다."""
    return f"It's always sunny in {city}!"
 
@tool
def calculate(expression: str) -> str:
    """'123 * 456'과 같은 간단한 산술 표현식을 계산합니다."""
    return str(eval(expression))  # 주의: eval()은 보안에 취약합니다. 프로덕션에서는 사용하지 마세요.
 
@tool
def search_web(query: str) -> str:
    """주제에 대한 최신 정보를 웹에서 검색합니다."""
    return f"Top result for '{query}': ..."
 
tools = [get_weather, calculate, search_web]
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}

계산 질문을 던져 봅시다.

python
response = llm_with_tools.invoke("What is 123 multiplied by 456?")
print(response.tool_calls)

출력:

[{'name': 'calculate', 'args': {'expression': '123 * 456'}, 'id': 'call_xyz789', 'type': 'tool_call'}]

LLM은 calculate 호출을 요청했습니다. 각 도구의 description과 사용자의 질문을 보고 calculate 도구 사용이 필요하다고 판단한 것입니다.

13.2에서와 동일한 패턴으로 도구를 실행하고 최종 답변을 받을 수 있습니다.

python
messages = [HumanMessage("What is 123 multiplied by 456?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
 
if ai_msg.tool_calls:
    for tool_call in ai_msg.tool_calls:
        selected_tool = tool_map[tool_call["name"]]
        tool_message = selected_tool.invoke(tool_call)
        messages.append(tool_message)
 
    final_response = llm_with_tools.invoke(messages)
    print(final_response.content)  # 출력: 123 multiplied by 456 is 56,088.

13.2에서는 tool_calls[0]으로 첫 번째 요청만 처리했지만, 여기서는 for 루프로 모든 요청을 순회하여 실행하였습니다. 이유는 LLM이 한 번에 여러 도구 사용을 요청할 수 있기 때문입니다.

여러 도구 동시 요청

실제로 여러 도구가 동시에 요청되는 경우를 확인해 보겠습니다. 코드는 앞과 동일하며 질문만 바꿉니다.

python
messages = [HumanMessage("What's the weather in Tokyo, and what is 123 * 456?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
 
if ai_msg.tool_calls:
   for tool_call in ai_msg.tool_calls:
       print(f"Tool: {tool_call['name']}")
       selected_tool = tool_map[tool_call["name"]]
       tool_message = selected_tool.invoke(tool_call)
       messages.append(tool_message)
 
   final_response = llm_with_tools.invoke(messages)
   print(final_response.content)

출력:

Tool: get_weather
Tool: calculate
The weather in Tokyo is currently sunny, and 123 multiplied by 456 is 56,088.

LLM이 get_weathercalculate를 요청했고, for 루프가 tool_calls의 모든 요청을 실행하여 그 결과를 메시지 목록에 추가했습니다. LLM은 이 결과들을 바탕으로 최종 답변을 생성했습니다.