LangChain 챗봇5장 · LLM 외부 도구 연동16 / 21← 이전목차다음 →MetaContents

5장. LLM 외부 도구 연동

실습 3 — bind_tools 로 진짜 모델에게 도구 쥐여 주기

실습 1의 두 도구를 진짜 모델에 bind_tools 로 묶고, 손님 질문에 모델이 어떤 도구를 어떤 인자로 부르는지 봅니다. 요청대로 도구를 실행해 ToolMessage 로 돌려주고, 모델을 한 번 더 불러 최종 답을 받습니다. 상품 표는 가상 데이터입니다. 과목 폴더의 .env 에 API 키가 있어야 돌아갑니다. 이 교안은 gpt-4.1-mini 를 기본으로 하고, 교수자가 정한 모델이 있으면 .env 의 OPENAI_MODEL 로 바꿉니다. 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다.

1단계. 모델과 도구 준비하기

Python
import os

import pandas as pd
from dotenv import find_dotenv, load_dotenv
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI

# 과목 폴더의 .env 파일에서 OPENAI_API_KEY 를 읽어 환경 변수에 넣는다
# find_dotenv(usecwd=True): 지금 폴더(data)에서 위로 올라가며 .env 를 찾는다
load_dotenv(find_dotenv(usecwd=True))
# 모델 이름도 .env 에서 바꿀 수 있게 하고, 없으면 gpt-4.1-mini 를 쓴다
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
model = ChatOpenAI(model=MODEL, temperature=0)

products = pd.read_csv("trip_products.csv")


@tool
def find_products(region: str = "", max_price: int = 0) -> str:
    """구름다리여행 상품을 지역과 가격(어른 1명, 만 원 상한)으로 찾는다. 0 이면 가격을 따지지 않는다."""
    df = products
    if region:
        df = df[df["지역"] == region]
    if max_price > 0:
        df = df[df["가격"] <= max_price]
    if df.empty:
        return "조건에 맞는 상품이 없습니다."
    return "\n".join(
        f"{r.상품코드} {r.상품명} | {r.가격}만 원 | 남은 자리 {r.남은자리} | 출발 {r.출발일}" for r in df.itertuples()
    )


@tool
def calc_price(code: str, adults: int, children: int = 0) -> str:
    """상품코드와 인원으로 총 여행 요금(만 원)을 계산한다. 아동 요금은 어른의 80% 이다."""
    rows = products[products["상품코드"] == code]
    if rows.empty:
        return f"없는 상품코드입니다: {code}"
    row = rows.iloc[0]
    total = round(row["가격"] * adults + row["가격"] * 0.8 * children, 1)
    return f"{code} {row['상품명']} · 어른 {adults}명, 아동 {children}명 · {float(total):g}만 원"


TOOLS = {t.name: t for t in [find_products, calc_price]}
# bind_tools: 도구 설명서를 모델에 묶은 새 모델을 만든다
model_with_tools = model.bind_tools(list(TOOLS.values()))
print("모델:", MODEL, "/ 묶은 도구:", list(TOOLS))
출력
모델: gpt-4.1-mini / 묶은 도구: ['find_products', 'calc_price']

코드가 의미하는 것 load_dotenv(find_dotenv(usecwd=True)) 로 키를 읽고 ChatOpenAI(model=MODEL, temperature=0) 로 진짜 모델을 만든 뒤, model.bind_tools(...) 로 실습 1의 도구 설명서를 묶습니다. 이 장의 예시에서 손으로 만들던 tool_calls 를 이제 모델이 직접 채우게 하는 준비입니다. TOOLS 사전은 모델이 요청한 이름으로 도구를 꺼내 실행하는 데 다시 씁니다.

2단계. 모델이 고른 도구 보기

Python
from langchain_core.messages import HumanMessage, SystemMessage

SYSTEM = SystemMessage("너는 구름다리여행의 상담 직원이다. 상품, 가격, 남은 자리는 반드시 도구로 확인하고 답한다.")
messages = [SYSTEM, HumanMessage("P01 상품에 어른 2명, 아이 1명이 가면 모두 얼마예요?")]

ai = model_with_tools.invoke(messages)
print("글 답:", repr(ai.content))
for call in ai.tool_calls:
    # id 는 부를 때마다 바뀌므로 이름과 인자만 찍는다
    print("도구 호출:", call["name"], call["args"])
출력
글 답: ''
도구 호출: calc_price {'code': 'P01', 'adults': 2, 'children': 1}

코드가 의미하는 것 model_with_tools.invoke(messages) 는 질문과 함께 도구 설명서를 보내는 첫째 호출입니다. 시스템 지시의 "반드시 도구로 확인하고 답한다"는 모델이 숫자를 어림하지 말고 도구를 고르도록 이끄는 말입니다. 예상 출력에서는 모델이 글 답 없이 "아이 1명"을 children 에 넣은 요청을 냈다고 보았고, 실제로 실행하면 인자의 꼴이 다를 수 있습니다.

3단계. 결과를 돌려주고 최종 답 받기

Python
messages.append(ai)                          # 도구 호출 요청도 기록에 넣는다
for call in ai.tool_calls:
    # 요청(call)을 통째로 넘기면 같은 id 를 단 ToolMessage 가 돌아온다
    result = TOOLS[call["name"]].invoke(call)
    print("도구 결과:", result.content)
    messages.append(result)

final = model_with_tools.invoke(messages)     # 둘째 호출: 결과를 읽고 답을 쓴다
print("남은 도구 호출:", final.tool_calls)
print("최종 답:", final.content)
print("메시지:", [m.type for m in messages + [final]])
출력
도구 결과: P01 제주 동쪽 바다 2박 3일 · 어른 2명, 아동 1명 · 109.2만 원
남은 도구 호출: []
최종 답: P01 제주 동쪽 바다 2박 3일 상품은 어른 2명과 아이 1명이면 모두 109.2만 원입니다. 아이 요금은 어른 요금의 80%로 계산했습니다.
메시지: ['system', 'human', 'ai', 'tool', 'ai']

코드가 의미하는 것 messages.append(ai) 와 TOOLS[call["name"]].invoke(call) 은 실습 2의 실행기를 짧게 줄인 것이고, 둘째 model_with_tools.invoke(messages) 가 Tool Calling 흐름의 넷째 걸음입니다. 모델은 둘째 호출에서 tool 메시지의 결과를 읽고 손님에게 할 말을 씁니다. 그래서 도구를 쓰는 질문 하나에 모델 호출이 두 번이고, 토큰도 두 번의 호출만큼 듭니다.

4단계. 여러 도구를 부르는 질문과 도구가 필요 없는 질문

Python
def ask(question):
    """질문 하나로 모델을 부르고, 도구 요청이 있으면 한 번 실행한 뒤 최종 답을 받는다."""
    msgs = [SYSTEM, HumanMessage(question)]
    ai = model_with_tools.invoke(msgs)
    msgs.append(ai)
    for call in ai.tool_calls:
        print("  도구 호출:", call["name"], call["args"])
        msgs.append(TOOLS[call["name"]].invoke(call))
    answer = model_with_tools.invoke(msgs).content if ai.tool_calls else ai.content
    print(f"  도구 {len(ai.tool_calls)}개 → {answer}")


for q in ["P03 과 P05 중 어른 2명이면 어느 쪽이 더 싸요?", "안녕하세요. 상담 시간이 언제예요?"]:
    print("질문:", q)
    ask(q)
출력
질문: P03 과 P05 중 어른 2명이면 어느 쪽이 더 싸요?
  도구 호출: calc_price {'code': 'P03', 'adults': 2}
  도구 호출: calc_price {'code': 'P05', 'adults': 2}
  도구 2개 → 어른 2명이면 P03(부산 야경과 시장 1박 2일)은 36만 원, P05(강릉 커피와 바다 1박 2일)는 32만 원이라 P05 가 4만 원 더 쌉니다.
질문: 안녕하세요. 상담 시간이 언제예요?
  도구 0개 → 안녕하세요. 상담 시간은 제가 가진 도구로 확인할 수 없습니다. 구름다리여행 고객센터에 문의해 주세요.

코드가 의미하는 것 ask() 는 2·3단계를 함수 하나로 묶고, if ai.tool_calls 로 도구가 필요 없는 질문은 첫 답을 그대로 쓰게 갈랐습니다. 도구를 쥐여 주어도 부를지 말지는 모델이 고른다는 것을 보는 단계입니다. 예상 출력에서는 첫 질문에 calc_price 두 번, 인사에는 도구 0개라고 보았습니다.

이 실습이 뜻하는 것

이 실습은 직원에게 메뉴판(도구 설명서)을 건네고, 손님 주문을 주문서로 받아 주방에서 요리해 내가는 일을 처음부터 끝까지 해 본 것과 같습니다. 직원에게 메뉴판을 건네고(1단계), 주문서를 받고(2단계), 요리를 내가 한마디 듣고(3단계), 두 가지를 한꺼번에 시키는 손님과 메뉴에 없는 것을 묻는 손님을 받아 봅니다(4단계). 여기서 메뉴판을 든 직원이 model_with_tools, 주문서가 ai.tool_calls, 내간 요리가 ToolMessage 입니다. 비유와 다른 점은, 이 직원은 요리를 받은 뒤에도 우리가 한 번 더 불러 주어야 손님에게 말을 한다는 것입니다.

단계출력뜻
2. 도구 고르기글 답 '', calc_price {'code': 'P01', 'adults': 2, 'children': 1}모델이 답 대신 도구와 인자를 골랐습니다. "아이 1명"을 children 에 넣었습니다
3. 도구 결과109.2만 원표로 계산한 값이라 실행해도 같습니다
3. 최종 답"모두 109.2만 원입니다"모델이 도구 결과의 숫자를 그대로 옮겨 답했습니다
3. 메시지system, human, ai, tool, ai앞 절 그림의 차례대로 쌓였습니다
4. 두 상품도구 2개, 36만 원과 32만 원한 번의 답에 도구 호출 두 개가 함께 왔습니다
4. 인사도구 0개도구가 필요 없는 질문에는 tool_calls 가 비어 바로 답했습니다

한 줄로 줄이면, bind_tools 로 도구를 묶으면 모델은 질문을 읽고 필요한 도구와 인자를 스스로 고르며, 우리는 그 요청을 실행해 돌려주기만 하면 된다는 것을 본 실습입니다. 도구 결과(109.2만 원, 36만 원, 32만 원)는 표로 계산한 값이라 실행해도 같지만, 모델이 고르는 도구와 답의 글은 예상 출력입니다. 모델이 쓰는 답의 글은 실행할 때마다, 모델마다 조금씩 달라집니다. 예를 들어 4단계의 첫 질문에서 모델이 calc_price 대신 find_products 를 먼저 부르거나, 두 호출을 두 번에 나눠 부를 수도 있습니다. 상담 시간처럼 도구에 없는 것은 지어내지 말라는 규칙을 시스템 지시에 더 적어 두면 둘째 질문의 답이 더 안전해집니다.

코드 설명

  • model.bind_tools(list(TOOLS.values())) 는 도구 설명서를 묶은 새 모델을 돌려줍니다. 원래의 model 은 그대로라서 도구 없이 쓸 때는 model 을 부릅니다.
  • ai.tool_calls 는 요청의 목록입니다. 비어 있으면([]) 모델이 도구 없이 바로 답한 것이고, 그때의 답은 ai.content 에 있습니다.
  • TOOLS[call["name"]].invoke(call) 은 요청 하나를 통째로 넘겨 ToolMessage 를 받습니다. 실습 2의 실행기를 LangChain 이 대신해 주는 지름길입니다.
  • messages.append(ai) 를 빠뜨리면 둘째 호출에서 "이 결과가 어느 요청의 것인지" 모델 서비스가 찾지 못해 오류가 납니다. 요청과 결과를 함께 보내야 합니다.
  • ask() 는 도구 실행을 한 번만 합니다. 결과를 보고 또 도구를 불러야 하는 질문은 이 함수로 끝까지 풀지 못합니다. 그 고리는 실습 5의 에이전트가 맡습니다.
← 이전 절5장 · 실습 2 — 도구 실행기 만들기다음 절 →5장 · 실습 4 — 상품 자료 MCP 서버 만들고 붙이기