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월에 출발하는 상품이 있나요? |
요구사항은 다섯 가지입니다.
- MCP 서버의 도구 셋과
calc_price를 한 목록으로 모아 에이전트를 만듭니다. 시스템 지시에 "가격·자리·공지는 도구로 확인한다"와 "매진이면 공지의 안내를 함께 알려 준다"를 적습니다. - 질문마다 상품 표에서 직접 찾은 정답 낱말(상품코드, 금액, "대기")을 정해 둡니다.
- 네 질문을 에이전트에 묻고, 질문마다 부른 도구의 이름을 차례로 모읍니다.
- 질문 번호, 부른 도구, 호출 수, 정답 낱말, 답에 모두 있는지를 한 표로 만들어
out_ch05_tool_usage.csv로 저장합니다. - 도구별로 모두 몇 번 불렸는지 세고, 결과를 두세 문장으로 정리합니다.
단계별 힌트입니다.
- MCP 도구는
await client.get_tools()가 돌려주는 목록입니다. 목록끼리+로 이어mcp_tools + [calc_price]로 모읍니다. - 질문 3의 정답 금액은
products에서 지역이 강릉이고 박수가 2 인 행을 찾아가격 * 2 + 가격 * 0.8 * 2로 구합니다. 질문 4는출발일.str.startswith("2026-12")로 12월을 고릅니다. - 네 질문은 한
async def안의for문에서 차례로 묻습니다. 결과의 메시지 목록에서m.tool_calls의name을 모읍니다. - "답에 모두 있음"은
all(word in answer for word in words)로 확인합니다. - 도구별 횟수는 부른 도구의 이름을 모두 이은 목록을
pd.Series(...).value_counts()로 셉니다.
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
상자 하나가 예시 풀이의 단계 하나이고, 회청 화살표를 따라가면 실행 차례입니다. 상자 아래의 글은 그 단계에서 쓰는 핵심 코드입니다. 1단계에서 정답 낱말을 먼저 정해 두는 것이 중요합니다. 에이전트의 답을 보고 나서 정답을 정하면, 틀린 답에 맞춰 정답을 고르게 되기 쉽습니다.
실제 데이터로 해 보려면
우리 가게의 실제 상품 표나 공지로 같은 챗봇을 만들 수 있습니다. 표는 엑셀이나 쇼핑몰 관리 화면에서 CSV 로 내려받아 data 폴더에 두고, 서버의 SHARED 목록에 그 파일 이름만 더합니다. 손님 정보(이름, 연락처, 예약 번호)가 든 열은 지우고 내려받으며, 자료를 쓰는 것에 대해 회사의 개인정보 처리 방침을 확인합니다.
| 자료 | 구하는 곳 | 쓰는 법 |
|---|---|---|
| 상품 표 | 관리 화면에서 CSV 내려받기 | 열 이름을 상품코드, 가격 처럼 기호 없이 고치고 find_products 의 열 이름을 맞춘다 |
| 공지·안내 글 | 홈페이지의 공지 글을 txt 로 저장 | SHARED 에 파일 이름을 더한다 |
| 남이 만든 MCP 서버 | 공식 MCP 저장소의 예제 서버 등 | 서버가 읽는 폴더와 권한을 먼저 읽어 보고, 설정의 command, args 에 적는다 |
결과 보기 (예시 풀이)
1단계. 도구와 정답 낱말 준비하기
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단계. 에이전트에게 네 질문 묻기
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단계. 표로 남기기
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단계. 도구별 호출 횟수 세기
# 질문마다의 "부른 도구" 글을 다시 이름 목록으로 풀어 모두 잇는다
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 / 41단계의 정답 낱말은 상품 표에서 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_price | MCP 도구 셋과 직접 만든 도구 하나가 한 목록이 됐습니다 |
| 2. 질문 2·3 | 도구 2번씩 | 첫 도구의 결과를 보고 다음 도구를 골랐습니다. 에이전트의 고리가 두 바퀴 돌았습니다 |
| 3. 표 | 정답 확인 4 / 4(예상) | 네 답 모두 정답 낱말을 담았다고 보았습니다 |
| 4. 횟수 | find_products 4, read_file 1, calc_price 1 | 상품을 찾는 도구가 가장 많이 불렸습니다 |
한 줄로 줄이면, MCP 서버의 도구와 직접 만든 도구를 한 에이전트에 섞어 쓰고, 답과 함께 도구 호출 기록을 남겨 답의 근거를 확인한다는 것을 해 본 프로젝트입니다. 데이터는 지어낸 12개 상품이라 질문이 쉽지만, 상품이 수백 개가 되어도 에이전트가 하는 일은 같습니다. 표가 커질수록 모델이 머리로 답하기는 어려워지고 도구의 몫이 커집니다.