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

5장. LLM 외부 도구 연동

프로젝트 1 — 데이터를 찾아 답하는 챗봇

구름다리여행 상담 창구에 손님 질문 네 개가 들어왔습니다. 실습 4의 MCP 서버(상품 찾기, 파일 목록, 파일 읽기)와 직접 만든 요금 계산 도구(@tool)를 함께 쥐여 준 에이전트로 네 질문에 답하고, 질문마다 어떤 도구를 몇 번 불렀는지, 답에 정답 낱말이 들어 있는지를 표로 남기세요. 정답 낱말은 상품 표를 pandas 로 직접 찾아 정합니다. 상품과 공지는 연습용 가상 데이터입니다. 실습 4를 먼저 하세요. 과목 폴더의 .env 에 API 키가 있어야 돌아갑니다. 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다.

항목내용
MCP 도구out_ch05_server.py 의 find_products, list_files, read_file
직접 만든 도구calc_price(code, adults, children). 아동 요금은 어른의 80%
질문 1부산 상품을 가격이 싼 것부터 알려 주세요.
질문 2여수 밤바다 상품 아직 예약할 수 있어요? 안 되면 어떻게 해요?
질문 3강릉 2박 상품에 어른 2명, 아이 2명이면 모두 얼마예요?
질문 4어른 1명 30만 원 이하로 12월에 출발하는 상품이 있나요?

요구사항은 다섯 가지입니다.

  1. MCP 서버의 도구 셋과 calc_price 를 한 목록으로 모아 에이전트를 만듭니다. 시스템 지시에 "가격·자리·공지는 도구로 확인한다"와 "매진이면 공지의 안내를 함께 알려 준다"를 적습니다.
  2. 질문마다 상품 표에서 직접 찾은 정답 낱말(상품코드, 금액, "대기")을 정해 둡니다.
  3. 네 질문을 에이전트에 묻고, 질문마다 부른 도구의 이름을 차례로 모읍니다.
  4. 질문 번호, 부른 도구, 호출 수, 정답 낱말, 답에 모두 있는지를 한 표로 만들어 out_ch05_tool_usage.csv 로 저장합니다.
  5. 도구별로 모두 몇 번 불렸는지 세고, 결과를 두세 문장으로 정리합니다.

단계별 힌트입니다.

  1. MCP 도구는 await client.get_tools() 가 돌려주는 목록입니다. 목록끼리 + 로 이어 mcp_tools + [calc_price] 로 모읍니다.
  2. 질문 3의 정답 금액은 products 에서 지역이 강릉이고 박수가 2 인 행을 찾아 가격 * 2 + 가격 * 0.8 * 2 로 구합니다. 질문 4는 출발일.str.startswith("2026-12") 로 12월을 고릅니다.
  3. 네 질문은 한 async def 안의 for 문에서 차례로 묻습니다. 결과의 메시지 목록에서 m.tool_calls 의 name 을 모읍니다.
  4. "답에 모두 있음"은 all(word in answer for word in words) 로 확인합니다.
  5. 도구별 횟수는 부른 도구의 이름을 모두 이은 목록을 pd.Series(...).value_counts() 로 셉니다.

확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.

채점 기준배점
MCP 도구와 직접 만든 도구를 한 에이전트에 붙였다25
정답 낱말을 데이터에서 바르게 구했다20
질문마다 부른 도구와 횟수를 바르게 모았다25
표를 만들고 저장했다15
도구별 횟수와 정답 확인을 정리했다15

코드의 흐름

예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.

MCP 도구와 calc_price 모으기, 에이전트 만들기, 질문 넷 묻고 도구 호출 세기, 표로 남기기의 네 단계를 이은 코드 흐름도

상자 하나가 예시 풀이의 단계 하나이고, 회청 화살표를 따라가면 실행 차례입니다. 상자 아래의 글은 그 단계에서 쓰는 핵심 코드입니다. 1단계에서 정답 낱말을 먼저 정해 두는 것이 중요합니다. 에이전트의 답을 보고 나서 정답을 정하면, 틀린 답에 맞춰 정답을 고르게 되기 쉽습니다.

실제 데이터로 해 보려면

우리 가게의 실제 상품 표나 공지로 같은 챗봇을 만들 수 있습니다. 표는 엑셀이나 쇼핑몰 관리 화면에서 CSV 로 내려받아 data 폴더에 두고, 서버의 SHARED 목록에 그 파일 이름만 더합니다. 손님 정보(이름, 연락처, 예약 번호)가 든 열은 지우고 내려받으며, 자료를 쓰는 것에 대해 회사의 개인정보 처리 방침을 확인합니다.

자료구하는 곳쓰는 법
상품 표관리 화면에서 CSV 내려받기열 이름을 상품코드, 가격 처럼 기호 없이 고치고 find_products 의 열 이름을 맞춘다
공지·안내 글홈페이지의 공지 글을 txt 로 저장SHARED 에 파일 이름을 더한다
남이 만든 MCP 서버공식 MCP 저장소의 예제 서버 등서버가 읽는 폴더와 권한을 먼저 읽어 보고, 설정의 command, args 에 적는다
결과 보기 (예시 풀이)

1단계. 도구와 정답 낱말 준비하기

Python
import asyncio
import os
import sys

import pandas as pd
from dotenv import find_dotenv, load_dotenv
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_openai import ChatOpenAI

load_dotenv(find_dotenv(usecwd=True))
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
model = ChatOpenAI(model=MODEL, temperature=0)

products = pd.read_csv("trip_products.csv")
# 실습 4가 만든 MCP 서버에 붙는다
client = MultiServerMCPClient({
    "trip": {"command": sys.executable, "args": ["out_ch05_server.py"], "transport": "stdio"},
})


@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}만 원"


# 정답 낱말은 모델이 아니라 표에서 직접 찾아 정한다
busan = products[products["지역"] == "부산"].sort_values("가격")
gangneung = products[(products["지역"] == "강릉") & (products["박수"] == 2)].iloc[0]
g_total = round(gangneung["가격"] * 2 + gangneung["가격"] * 0.8 * 2, 1)
december = products[(products["가격"] <= 30) & products["출발일"].str.startswith("2026-12")]

QUESTIONS = [
    ("부산 상품을 가격이 싼 것부터 알려 주세요.", list(busan["상품코드"])),
    ("여수 밤바다 상품 아직 예약할 수 있어요? 안 되면 어떻게 해요?", ["매진", "대기"]),
    ("강릉 2박 상품에 어른 2명, 아이 2명이면 모두 얼마예요?", [gangneung["상품코드"], f"{float(g_total):g}"]),
    ("어른 1명 30만 원 이하로 12월에 출발하는 상품이 있나요?", list(december["상품코드"])),
]
for no, (q, words) in enumerate(QUESTIONS, start=1):
    print(no, q, "→ 정답 낱말:", words)
출력
1 부산 상품을 가격이 싼 것부터 알려 주세요. → 정답 낱말: ['P03', 'P04']
2 여수 밤바다 상품 아직 예약할 수 있어요? 안 되면 어떻게 해요? → 정답 낱말: ['매진', '대기']
3 강릉 2박 상품에 어른 2명, 아이 2명이면 모두 얼마예요? → 정답 낱말: ['P06', '118.8']
4 어른 1명 30만 원 이하로 12월에 출발하는 상품이 있나요? → 정답 낱말: ['P04', 'P07', 'P09']

코드가 의미하는 것 이 단계는 시험지를 내기 전에 정답지를 먼저 만드는 일입니다. 정답 낱말을 products 표에서 pandas 로 직접 골라 두므로, 에이전트의 답이 어떻게 나오든 같은 잣대로 채점할 수 있습니다. calc_price 는 @tool 로 만든 도구라 MCP 서버의 도구와 출신이 다르지만, 2단계에서 한 목록에 섞여 들어갑니다.

2단계. 에이전트에게 네 질문 묻기

Python
SYSTEM = ("너는 구름다리여행의 상담 직원이다. 상품, 가격, 남은 자리, 공지는 반드시 도구로 확인하고 짧게 답한다. "
          "답에는 상품코드를 함께 적는다. 남은 자리가 0 인 매진 상품이면 공지 파일(trip_notice.txt)에서 안내를 찾아 함께 알려 준다.")


async def main():
    # MCP 도구 셋과 직접 만든 도구 하나를 한 목록으로 모은다
    tools = await client.get_tools() + [calc_price]
    print("도구:", [t.name for t in tools])
    agent = create_agent(model, tools=tools, system_prompt=SYSTEM)
    records = []
    for no, (q, words) in enumerate(QUESTIONS, start=1):
        result = await agent.ainvoke({"messages": [{"role": "user", "content": q}]})
        msgs = result["messages"]
        # 부른 도구의 이름을 차례로 모은다
        called = [c["name"] for m in msgs if m.type == "ai" for c in m.tool_calls]
        answer = msgs[-1].content
        records.append({"번호": no, "부른 도구": " → ".join(called), "호출 수": len(called),
                        "정답 낱말": ", ".join(words), "답에 모두 있음": all(w in answer for w in words)})
        print(f"[{no}] {' → '.join(called)}")
        print("   ", answer)
    return records


records = asyncio.run(main())
출력
도구: ['list_files', 'read_file', 'find_products', 'calc_price']
[1] find_products
    부산 상품은 가격이 싼 순서로 P03 부산 야경과 시장 1박 2일(18만 원, 11월 28일 출발), P04 부산 바다 열차 2박 3일(29만 원, 12월 19일 출발)입니다.
[2] find_products → read_file
    P08 여수 밤바다 1박 2일은 남은 자리가 0 이라 매진입니다. 공지에 따르면 매진 상품은 홈페이지에서 대기 신청을 하실 수 있습니다.
[3] find_products → calc_price
    강릉 2박 상품은 P06 강릉 온천 쉼 2박 3일이고, 어른 2명과 아이 2명이면 모두 118.8만 원입니다.
[4] find_products
    12월에 출발하는 30만 원 이하 상품은 P07 경주 역사 걷기(27만 원, 12월 5일), P09 전주 한옥 하룻밤(15만 원, 12월 12일), P04 부산 바다 열차(29만 원, 12월 19일)입니다.

코드가 의미하는 것 await client.get_tools() + [calc_price] 는 MCP 서버에서 빌린 도구와 직접 만든 도구를 목록 하나로 이어 에이전트 하나에 넣는 줄입니다. 에이전트는 반복문 밖에서 한 번만 만들고 질문마다 새 메시지 목록으로 ainvoke 하므로, 네 질문은 같은 도구와 시스템 지시를 쓰지만 대화는 서로 이어지지 않습니다. 질문마다 called 로 "어디를 다녀왔는지"를, all(w in answer for w in words) 로 "정답을 담았는지"를 함께 기록하며, 이 둘은 예상 출력이라 키를 받은 뒤 다시 맞출 값입니다.

3단계. 표로 남기기

Python
usage = pd.DataFrame(records)
print(usage.to_string(index=False))

usage.to_csv("out_ch05_tool_usage.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch05_tool_usage.csv")
출력
 번호                      부른 도구  호출 수         정답 낱말  답에 모두 있음
  1              find_products     1      P03, P04      True
  2  find_products → read_file     2        매진, 대기      True
  3 find_products → calc_price     2    P06, 118.8      True
  4              find_products     1 P04, P07, P09      True
저장: out_ch05_tool_usage.csv

코드가 의미하는 것 pd.DataFrame(records) 는 질문마다의 기록 사전을 한 표로 모으고, encoding="utf-8-sig" 는 엑셀에서 열어도 한글이 깨지지 않게 저장하는 방식입니다. 이 표가 챗봇 답의 근거를 남기는 영수증이 됩니다.

4단계. 도구별 호출 횟수 세기

Python
# 질문마다의 "부른 도구" 글을 다시 이름 목록으로 풀어 모두 잇는다
all_calls = [name for text in usage["부른 도구"] for name in text.split(" → ") if name]
print(pd.Series(all_calls).value_counts().to_string())
print("모두 부른 횟수:", len(all_calls), "/ 정답 확인 통과:", int(usage["답에 모두 있음"].sum()), "/", len(usage))
출력
find_products    4
read_file        1
calc_price       1
모두 부른 횟수: 6 / 정답 확인 통과: 4 / 4

1단계의 정답 낱말은 상품 표에서 pandas 로 찾은 것이라 실행해도 같습니다. 부산은 P03(18만 원)과 P04(29만 원), 강릉 2박은 P06 으로 어른 둘과 아이 둘이면 33만 원 x 2 + 26.4만 원 x 2 = 118.8만 원, 30만 원 이하 12월 출발은 P04, P07, P09 세 개입니다. 2단계부터의 도구 차례와 답의 글은 예상 출력입니다. 예상 출력에서 에이전트는 네 질문 모두 find_products 로 시작했고, 매진을 본 질문 2에서는 공지를 읽으러 read_file 을, 금액을 묻는 질문 3에서는 calc_price 를 한 번 더 불러 모두 6번 불렀습니다. 네 답 모두 정답 낱말을 담았다고 보았습니다. 모델이 쓰는 답의 글은 실행할 때마다, 모델마다 조금씩 달라지므로 여러분의 출력에서는 도구의 차례나 횟수가 다를 수 있습니다. 예를 들어 질문 2에서 list_files 를 먼저 부르거나, 질문 3에서 금액을 직접 셈해 calc_price 를 건너뛸 수도 있습니다.

표의 "부른 도구" 열은 챗봇이 답을 어디서 가져왔는지 보여 주는 영수증입니다. 답이 틀렸을 때 이 열을 보면 도구를 부르지 않고 지어냈는지, 도구는 불렀는데 결과를 잘못 옮겼는지를 가를 수 있습니다. 질문 3처럼 금액을 묻는 질문에 calc_price 가 빠져 있다면, 모델이 계산을 어림했을 수 있으니 답의 금액을 다시 확인합니다. 그래서 도구를 쓰는 챗봇은 답과 함께 도구 호출 기록을 남겨 두는 것이 좋습니다.

코드가 의미하는 것 "부른 도구" 열의 글을 split(" → ") 으로 다시 이름 목록으로 풀고, value_counts() 로 도구마다 몇 번 불렸는지 셉니다. 어느 도구에 일이 몰리는지 보면 설명을 먼저 다듬을 도구가 보입니다. 여기의 횟수는 2단계의 예상 출력에서 나온 것이라 실제로 실행하면 함께 바뀝니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 비서에게 자료실 열쇠(MCP 서버)와 계산기(calc_price)를 함께 주고 손님 질문 네 개를 맡긴 뒤, 비서가 어디를 몇 번 다녀왔는지 출입 기록을 검사하는 일과 같습니다. 정답을 미리 적어 두고(1단계), 질문을 맡기고(2단계), 출입 기록을 표로 정리하고(3단계), 어느 방을 몇 번 갔는지 셉니다(4단계). 여기서 출입 기록이 "부른 도구" 열, 미리 적어 둔 정답이 "정답 낱말"입니다. 비유와 다른 점은, 사람 비서의 출입 기록은 따로 적어야 하지만 에이전트의 기록은 메시지 목록에 저절로 남는다는 것입니다.

단계출력뜻
1. 정답 낱말P03·P04, 매진·대기, P06·118.8, P04·P07·P09표에서 직접 찾은 값이라 실행해도 같습니다
2. 도구list_files, read_file, find_products, calc_priceMCP 도구 셋과 직접 만든 도구 하나가 한 목록이 됐습니다
2. 질문 2·3도구 2번씩첫 도구의 결과를 보고 다음 도구를 골랐습니다. 에이전트의 고리가 두 바퀴 돌았습니다
3. 표정답 확인 4 / 4(예상)네 답 모두 정답 낱말을 담았다고 보았습니다
4. 횟수find_products 4, read_file 1, calc_price 1상품을 찾는 도구가 가장 많이 불렸습니다

한 줄로 줄이면, MCP 서버의 도구와 직접 만든 도구를 한 에이전트에 섞어 쓰고, 답과 함께 도구 호출 기록을 남겨 답의 근거를 확인한다는 것을 해 본 프로젝트입니다. 데이터는 지어낸 12개 상품이라 질문이 쉽지만, 상품이 수백 개가 되어도 에이전트가 하는 일은 같습니다. 표가 커질수록 모델이 머리로 답하기는 어려워지고 도구의 몫이 커집니다.

← 이전 절5장 · 실습 5 — MCP 도구를 쓰는 에이전트다음 절 →5장 · 정리