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

5장. LLM 외부 도구 연동

실습 2 — 도구 실행기 만들기

모델이 돌려줄 법한 도구 호출 요청(AIMessage 의 tool_calls)을 손으로 만들고, 그 요청을 받아 도구를 실행해 ToolMessage 목록으로 돌려주는 "도구 실행기" 함수를 짭니다. 없는 도구 이름이나 잘못된 인자가 와도 멈추지 않고 "오류: …"를 결과로 돌려주게 합니다. 모델 없이 Tool Calling 고리의 가운데(실행과 돌려주기)만 떼어 보는 실습이라 API 키가 없어도 됩니다. 상품 표는 실습 1과 같은 가상 데이터입니다.

1단계. 실습 1의 도구 준비하기

Python
import pandas as pd
from langchain_core.tools import tool

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]}
print("도구 상자:", list(TOOLS))
출력
도구 상자: ['find_products', 'calc_price']

코드가 의미하는 것 TOOLS = {t.name: t ...} 사전은 실행기가 쓸 도구 상자입니다. 모델의 요청에는 도구 객체가 아니라 이름 글자만 오므로, 이름으로 도구를 꺼낼 길이 있어야 합니다. 실습 1과 같은 도구를 다시 적은 것은 실습 절이 바뀌면 처음부터 다시 실행되기 때문입니다.

2단계. 도구 호출 요청을 손으로 만들기

Python
from langchain_core.messages import AIMessage, HumanMessage

question = HumanMessage("부산 상품을 보여 주고, P04 에 어른 2명이면 얼마인지도 알려 주세요.")
# 모델이 한 번에 두 도구를 부르겠다고 답했다고 보고 손으로 만든 메시지
ai = AIMessage(content="", tool_calls=[
    {"name": "find_products", "args": {"region": "부산"}, "id": "call_1"},
    {"name": "calc_price", "args": {"code": "P04", "adults": 2}, "id": "call_2"},
])

print("질문:", question.content)
for call in ai.tool_calls:
    print(call["id"], call["name"], call["args"])
출력
질문: 부산 상품을 보여 주고, P04 에 어른 2명이면 얼마인지도 알려 주세요.
call_1 find_products {'region': '부산'}
call_2 calc_price {'code': 'P04', 'adults': 2}

코드가 의미하는 것 AIMessage(tool_calls=[...]) 에 요청 두 개를 담은 것은 모델이 한 번의 답에서 도구 두 개를 함께 부르는 경우를 흉내 낸 것입니다. 질문 하나에 "찾아보기"와 "계산하기"가 함께 들어 있으면 모델은 요청을 여러 개 낼 수 있습니다. 요청마다 다른 id(call_1, call_2)가 붙어 있어야 결과를 하나씩 짝지을 수 있습니다.

3단계. 도구 실행기 짜기

Python
from langchain_core.messages import ToolMessage


def run_tool_calls(ai_message):
    """AIMessage 의 tool_calls 를 하나씩 실행해 ToolMessage 목록으로 돌려준다."""
    results = []
    for call in ai_message.tool_calls:
        name, args, call_id = call["name"], call["args"], call["id"]
        if name not in TOOLS:
            # 없는 도구: 멈추지 않고 오류를 결과로 돌려준다(모델이 다시 고를 수 있게)
            content, status = f"오류: {name} 라는 도구는 없습니다. 쓸 수 있는 도구: {', '.join(TOOLS)}", "error"
        else:
            try:
                content, status = TOOLS[name].invoke(args), "success"
            except Exception as e:
                # 인자가 틀린 경우(예: 숫자 자리에 글자): 오류 종류와 첫 줄만 돌려준다
                content, status = f"오류: {type(e).__name__} - {str(e).splitlines()[0]}", "error"
        # tool_call_id 에 요청 번호를 그대로 적어 짝을 맞춘다. status 는 성공·오류 표시
        results.append(ToolMessage(content=content, tool_call_id=call_id, name=name, status=status))
    return results


for msg in run_tool_calls(ai):
    print(f"[{msg.tool_call_id} {msg.name} {msg.status}]")
    print(msg.content)

# 모델이 틀리게 부른 두 가지 경우
bad = AIMessage(content="", tool_calls=[
    {"name": "book_product", "args": {"code": "P04"}, "id": "call_3"},
    {"name": "calc_price", "args": {"code": "P04", "adults": "둘"}, "id": "call_4"},
])
for msg in run_tool_calls(bad):
    print(f"[{msg.tool_call_id} {msg.name} {msg.status}] {msg.content}")
출력
[call_1 find_products success]
P03 부산 야경과 시장 1박 2일 | 18만 원 | 남은 자리 12 | 출발 2026-11-28
P04 부산 바다 열차 2박 3일 | 29만 원 | 남은 자리 4 | 출발 2026-12-19
[call_2 calc_price success]
P04 부산 바다 열차 2박 3일 · 어른 2명, 아동 0명 · 58만 원
[call_3 book_product error] 오류: book_product 라는 도구는 없습니다. 쓸 수 있는 도구: find_products, calc_price
[call_4 calc_price error] 오류: ValidationError - 1 validation error for calc_price

코드가 의미하는 것 run_tool_calls 는 Tool Calling 고리의 셋째 걸음, 곧 "우리 코드가 실행"하는 자리를 맡는 함수입니다. 없는 도구는 if name not in TOOLS 로, 잘못된 인자는 try … except 로 받아 모두 status="error" 인 ToolMessage 로 바꾸므로, 요청 하나에 결과 하나라는 짝이 어떤 경우에도 깨지지 않습니다. 챗봇이 멈추지 않으려면 모델의 실수를 프로그램 오류가 아니라 모델이 읽을 글로 돌려줘야 합니다.

4단계. 메시지 목록을 기록으로 남기기

Python
import json
from pathlib import Path

# 둘째로 모델에 보낼 메시지 목록: 질문, 도구 호출 요청, 결과 둘
messages = [question, ai] + run_tool_calls(ai)
print("메시지:", [m.type for m in messages])

log = []
for m in messages:
    item = {"type": m.type, "content": m.content}
    if m.type == "ai":
        item["tool_calls"] = [{"name": c["name"], "args": c["args"], "id": c["id"]} for c in m.tool_calls]
    if m.type == "tool":
        item["tool_call_id"] = m.tool_call_id
    log.append(item)

Path("out_ch05_tool_log.json").write_text(json.dumps(log, ensure_ascii=False, indent=2), encoding="utf-8")
print("저장: out_ch05_tool_log.json")
출력
메시지: ['human', 'ai', 'tool', 'tool']
저장: out_ch05_tool_log.json

코드가 의미하는 것 [question, ai] + run_tool_calls(ai) 는 모델을 두 번째로 부를 때 보낼 기록 그대로입니다. 이 목록을 JSON 파일로 남기면 챗봇이 어떤 요청을 받고 무엇을 돌려줬는지 나중에 되짚어 보는 기록장이 됩니다. 프로젝트에서 질문마다 부른 도구를 모으는 것도 같은 생각입니다.

이 실습이 뜻하는 것

이 실습은 주방장이 주문서 한 장을 받아 메뉴마다 요리해 내보내고, 메뉴에 없는 주문이나 잘못 적힌 주문에는 "이건 안 됩니다" 쪽지를 붙여 돌려보내는 일과 같습니다. 주방의 메뉴를 갖추고(1단계), 주문서를 받고(2단계), 요리하거나 쪽지를 붙이고(3단계), 오간 주문서와 요리를 장부에 적습니다(4단계). 여기서 주문서가 AIMessage 의 tool_calls, 요리와 쪽지가 ToolMessage, 주방장이 run_tool_calls 입니다. 비유와 다른 점은, 주방장은 주문서를 쓴 직원에게 되물어 볼 수 있지만 실행기는 되묻지 않고 쪽지(오류 글)를 돌려보낸 뒤 모델이 다시 주문하기를 기다린다는 것입니다.

단계출력뜻
1. 도구 상자['find_products', 'calc_price']이름으로 도구를 꺼내는 사전입니다
2. 요청 만들기call_1 find_products, call_2 calc_price모델의 답 하나에 도구 호출 요청 둘이 들었다고 보았습니다
3. 실행기success 둘, 부산 2개와 58만 원요청마다 ToolMessage 가 하나씩, 같은 요청 번호로 돌아왔습니다
3. 틀린 요청error 둘없는 도구(book_product)와 숫자 자리의 글자("둘")가 멈추지 않고 오류 글이 됐습니다
4. 기록human, ai, tool, tool둘째로 모델에 보낼 메시지 네 개를 파일로 남겼습니다

한 줄로 줄이면, Tool Calling 고리의 가운데, 곧 요청을 받아 실행하고 요청 번호로 짝을 맞춰 돌려주는 일은 모델이 아니라 우리 코드의 몫이라는 것을 모델 없이 확인한 실습입니다. 요청 두 개에 결과 두 개가 call_1, call_2 의 같은 번호로 돌아왔고, P04 에 어른 2명은 29 x 2 = 58만 원입니다. 틀린 요청 두 개도 프로그램을 멈추지 않고 status 가 error 인 결과가 됐습니다. 인자가 틀린 경우는 Pydantic(4장)의 ValidationError 가 calc_price 의 인자를 검사해 걸러 낸 것입니다. 진짜 모델이라면 이 오류 글을 읽고 adults 에 2 를 넣어 다시 부르거나, 그런 도구는 없다고 손님에게 말합니다. 실습 3에서 진짜 모델로 고리 전체를 돌립니다.

코드 설명

  • TOOLS = {t.name: t for t in [...]} 는 도구 이름으로 도구를 찾는 사전입니다. 모델은 도구를 이름으로 가리키므로 실행기는 이 사전에서 꺼내 씁니다.
  • run_tool_calls 는 Tool Calling 고리의 셋째 걸음(우리 코드가 실행)을 맡는 함수입니다. 요청마다 ToolMessage 하나를 만들어 요청 수와 결과 수를 늘 같게 합니다.
  • try … except Exception 은 인자가 틀려 도구가 오류를 내도 프로그램을 멈추지 않게 합니다. 오류를 글로 돌려주면 모델이 읽고 인자를 고쳐 다시 부를 수 있습니다.
  • ToolMessage(content=…, tool_call_id=…, name=…, status=…) 의 status 는 "success" 또는 "error" 입니다. 사람이 기록을 읽을 때 어느 호출이 실패했는지 바로 보입니다.
  • out_ch05_tool_log.json 에는 질문, 요청, 결과가 차례로 남습니다. 진짜 모델이었다면 이 목록 전체가 둘째 호출의 입력이 됩니다.
← 이전 절5장 · 실습 1 — @tool 로 상품 찾기·요금 계산 도구 만들기다음 절 →5장 · 실습 3 — bind_tools 로 진짜 모델에게 도구 쥐여 주기