실무 Multi-Agent 오케스트레이션 7장 · 다중 도구 라우팅 5 / 7 ← 이전목차다음 → TechLead Cro

7장. 다중 도구 라우팅

따라하기 — 도구 네 개와 라우팅 테스트

출력 안내: 실행 코드는 답변·도구 결과·청크 본문을 글자 수로 자르지 않고 출력합니다. 아래의 기존 실행 예시는 일부 축약된 기록이며, 실제 실행 화면에서 전체 내용을 확인하세요.

목표

6장의 주문 도구 두 개에 상품 검색과 재고 확인을 더해 도구 네 개를 상담원에 연결합니다. 그리고 질문 네 개에 기대하는 도구를 적어 두고, 모델이 실제로 그 도구를 불렀는지 세어 라우팅 정확도를 확인합니다.


0. 실습 준비

VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.

$ conda activate myenv

이번 장은 새로 설치할 것이 없습니다. 대신 확인할 것이 하나 있습니다. 이번 장의 파일은 6장에서 만든 파일을 불러 씁니다.

from lesson06_order_tools import get_order_status, track_shipping

haru-market 폴더 맨 위에 lesson06_order_tools.py가 있는지 확인하세요.

$ ls lesson06_order_tools.py

파일 이름이 그대로 출력되면 준비된 것입니다. 없으면 실행할 때 이런 오류가 납니다.

ModuleNotFoundError: No module named 'lesson06_order_tools'

이 오류가 나면 6장의 「따라하기」로 돌아가 lesson06_order_tools.py를 먼저 만듭니다. 파일 이름의 철자가 다르거나 data 폴더 안에 만들었을 때도 같은 오류가 납니다.


1. 파일 만들기

haru-market 폴더 맨 위에 새 파일을 만듭니다.

lesson07_multi_tools.py

아래 코드 전체를 복사해 붙여 넣고 저장합니다.

# -*- coding: utf-8 -*-
"""[7장] 상품·재고 조회 도구 + 다중 도구 라우팅

문제 상황: 제품 문의도 들어온다. 도구가 2개에서 4개로 늘었다.
  모델이 질문마다 알맞은 도구를 고르는지 '눈으로'가 아니라 '숫자로' 확인해야 한다.
이번 장에서 하는 것:
  - 상품 검색·재고 확인 도구를 추가한다 (6장의 주문 도구 2개는 import 해서 다시 쓴다)
  - 모델이 도구를 고르는 단서는 도구의 이름·설명(독스트링)·인자 설명뿐이다
  - 질문마다 '기대하는 도구'를 먼저 적어 두고, 호출 이력과 맞춰 보아 정확도를 센다

실행:  python lesson07_multi_tools.py
"""
import pandas as pd
from google.genai import types

from config import DATA_DIR, MODEL, get_client
# 6장에서 만든 주문 도구를 그대로 가져온다
from lesson06_order_tools import get_order_status, track_shipping

client = get_client()
PRODUCTS = pd.read_csv(DATA_DIR / "products.csv", dtype=str)


# ── 새 도구 3: 상품 검색 ─────────────────────────────────────────────
def search_products(keyword: str) -> list[dict]:
    """상품명·카테고리·설명에서 키워드로 하루마켓 판매 상품을 검색한다.
    상품의 가격, 옵션, 특징, 소재 등 '상품 자체'에 대한 문의에 사용한다.
    주문·배송 상태 조회에는 사용하지 않는다.

    Args:
        keyword: 검색어. 예: '텀블러', '이불커버', '운동화'
    """
    mask = (
        PRODUCTS["product_name"].str.contains(keyword, na=False)
        | PRODUCTS["category"].str.contains(keyword, na=False)
        | PRODUCTS["description"].str.contains(keyword, na=False)
    )
    hits = PRODUCTS[mask].head(5)
    if hits.empty:
        return [{"found": False, "message": f"'{keyword}' 검색 결과가 없습니다."}]
    return [
        {
            "product_id": r["product_id"], "product_name": r["product_name"],
            "category": r["category"], "price": r["price"],
            "options": r["options"], "rating": r["rating"],
            "description": r["description"],
        }
        for _, r in hits.iterrows()
    ]


# ── 새 도구 4: 재고 확인 ─────────────────────────────────────────────
def check_stock(product_id: str) -> dict:
    """상품ID로 현재 재고 수량을 확인한다. 교환 가능 여부·재입고 문의에 사용한다.

    Args:
        product_id: 상품ID. 'P'로 시작한다. 예: P008
    """
    row = PRODUCTS[PRODUCTS["product_id"] == product_id]
    if row.empty:
        return {"found": False, "message": f"상품 {product_id} 를 찾을 수 없습니다."}
    r = row.iloc[0]
    stock = int(r["stock"])
    return {
        "found": True, "product_id": product_id, "product_name": r["product_name"],
        "stock": stock, "in_stock": stock > 0,
    }


ALL_TOOLS = [get_order_status, track_shipping, search_products, check_stock]

SYSTEM = """당신은 하루마켓 고객지원 상담원 '하루'입니다.
반드시 도구로 조회한 실제 데이터로만 답합니다. 존댓말, 3~5문장.
조회 결과에 없는 내용(도착 예정일, 옵션별 재고 등)은 추측하지 않고, \
확인되지 않는다고 말합니다."""


def ask(question: str, tools=ALL_TOOLS) -> list[str]:
    """질문을 보내고, 호출된 도구 이름 목록을 반환 (라우팅 검증용)."""
    response = client.models.generate_content(
        model=MODEL, contents=question,
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM, temperature=1.0, tools=tools,
            automatic_function_calling=types.AutomaticFunctionCallingConfig(
                maximum_remote_calls=4),
        ),
    )
    called = []
    for content in response.automatic_function_calling_history or []:
        for part in content.parts or []:
            if part.function_call:
                called.append(part.function_call.name)
                print(f"  [도구] {part.function_call.name}({dict(part.function_call.args)})")
    print(f"하루: {(response.text or '').strip()}")
    return called


if __name__ == "__main__":
    shipped = pd.read_csv(DATA_DIR / "orders.csv", dtype=str)
    oid = shipped[shipped["order_status"] == "배송중"].iloc[0]["order_id"]

    # ── 라우팅 테스트 세트: 질문마다 '기대하는 도구'를 명시 ──────────
    test_set = [
        (f"주문 {oid} 배송 어디쯤이에요?", "track_shipping"),
        ("텀블러 보온 몇 시간 가나요?", "search_products"),
        ("에어프라이어 재고 있어요?", "search_products"),  # 검색→재고 2단계도 정답
        ("P008 텀블러 민트색 재고 몇 개 남았어요?", "check_stock"),
    ]

    print("■ 라우팅 정확도 테스트")
    correct = 0
    for q, expected in test_set:
        print(f"\n고객: {q}  (기대 도구: {expected})")
        called = ask(q)
        ok = expected in called
        correct += ok
        print(f"  → {'기대한 도구 호출됨' if ok else '라우팅 어긋남: ' + str(called)}")

    print(f"\n라우팅 정확도: {correct}/{len(test_set)}")

    print("""
──────────────────────────────────────────────────────────
라우팅이 어긋날 때 점검 순서
  1) 도구 설명이 서로 겹치지 않는가? ("~에 사용한다 / ~에는 사용하지 않는다"를 명시)
  2) 인자 예시가 있는가? (P008, HR2026... 같은 형식 예시)
  3) 도구가 너무 많지 않은가? (모든 도구 선언은 매 호출마다 입력 토큰으로 실려 나간다
     → 도구 수 = 비용. 나중에 21장에서 에이전트별로 도구를 나누는 이유)
남은 문제: 아무나 남의 주문을 조회하면? → 8장 안전한 도구 실행
──────────────────────────────────────────────────────────""")

2. 코드에서 볼 곳

부분 하는 일
from lesson06_order_tools import … 6장의 도구 두 개를 다시 만들지 않고 불러옵니다
search_products(keyword) 상품명·카테고리·설명에서 키워드를 찾아 최대 5건을 돌려줍니다
check_stock(product_id) 상품ID로 재고 수량과 in_stock을 돌려줍니다
ALL_TOOLS 도구 네 개를 담은 리스트
ask(question, tools=ALL_TOOLS) 질문을 보내고 불린 도구 이름 목록을 돌려줍니다
test_set 질문과 기대 도구의 짝 네 개

(1) 다른 파일의 함수를 불러온다

# 6장에서 만든 주문 도구를 그대로 가져온다
from lesson06_order_tools import get_order_status, track_shipping

파이썬 파일은 다른 파일에서 import로 불러 쓸 수 있습니다. 이때 불려 오는 파일의 맨 위 코드가 한 번 실행됩니다. 6장 파일을 불러와도 6장의 질문 세 개가 다시 실행되지 않는 것은, 그 부분이 if __name__ == "__main__": 아래에 있기 때문입니다. 이 조건은 그 파일을 직접 실행했을 때만 참이 됩니다.

(2) 도구 네 개를 한 번에 넘긴다

ALL_TOOLS = [get_order_status, track_shipping, search_products, check_stock]

SYSTEM = """당신은 하루마켓 고객지원 상담원 '하루'입니다.
반드시 도구로 조회한 실제 데이터로만 답합니다. 존댓말, 3~5문장.
조회 결과에 없는 내용(도착 예정일, 옵션별 재고 등)은 추측하지 않고, \
확인되지 않는다고 말합니다."""

시스템 프롬프트에 "재고 질문에는 check_stock을 써라" 같은 도구 고르는 법이 없습니다. 고르는 근거는 각 도구의 이름과 독스트링뿐입니다.

(3) 불린 도구를 모아 돌려준다

called = []
for content in response.automatic_function_calling_history or []:
    for part in content.parts or []:
        if part.function_call:
            called.append(part.function_call.name)
            print(f"  [도구] {part.function_call.name}({dict(part.function_call.args)})")
print(f"하루: {(response.text or '').strip()}")
return called

6장의 ask()와 달라진 곳은 called 리스트를 만들어 돌려준다는 점 하나입니다. 답변은 전체를 출력해 사람이 확인합니다.


3. 실행하기

실행하기 전에 짐작해 보세요.

  • "에어프라이어 재고 있어요?"에 모델은 도구를 몇 번, 어떤 순서로 부를까요? 고객은 상품ID를 말하지 않았습니다.
  • "P008 텀블러 민트색 재고 몇 개 남았어요?"에서 check_stock은 민트색의 재고를 알려 줄 수 있을까요? products.csv의 열을 다시 떠올려 보세요.
$ python lesson07_multi_tools.py

LLM을 열 번 가까이 호출하므로 20~40초 걸립니다.

실행 결과 (문장은 실행할 때마다 달라집니다)

■ 라우팅 정확도 테스트

고객: 주문 HR20260620069 배송 어디쯤이에요?  (기대 도구: track_shipping)
  [도구] track_shipping({'order_id': 'HR20260620069'})
하루: 고객님의 주문(HR20260620069)은 우체국택배(송장번호: 83455521031)를 통해 2026년 6월 21일에 출고되어 현재 배송 중에 있습니다. 구체적인 배송 위치나 도착 예정일은 조회된 정보 외에 확인이 어렵습니다. 택배사 홈페이지를 통해 상세한 이동 경로를 확인하실 수 있습니다.
  → 기대한 도구 호출됨

고객: 텀블러 보온 몇 시간 가나요?  (기대 도구: search_products)
  [도구] search_products({'keyword': '텀블러'})
하루: 하루마켓에서 판매 중인 '스테인리스 텀블러 500ml'는 12시간 동안 보온 및 보냉 기능을 제공합니다. 입구가 넓어 세척이 편리하며, 실버·블랙·민트 세 가지 색상으로 준비되어 있습니다. 추가로 궁금하신 점이 있으시면 언제든지 말씀해 주세요!
  → 기대한 도구 호출됨

고객: 에어프라이어 재고 있어요?  (기대 도구: search_products)
  [도구] search_products({'keyword': '에어프라이어'})
  [도구] check_stock({'product_id': 'P011'})
하루: '에어프라이어 5L' 상품은 현재 25개 재고가 있어 구매하실 수 있습니다. 5L 대용량에 예약·보온 기능과 분리 세척 바스켓이 포함된 제품입니다. 추가로 궁금한 점이 있으시면 언제든지 말씀해 주세요!
  → 기대한 도구 호출됨

고객: P008 텀블러 민트색 재고 몇 개 남았어요?  (기대 도구: check_stock)
  [도구] check_stock({'product_id': 'P008'})
하루: P008 스테인리스 텀블러 500ml의 전체 재고는 현재 150개 남아 있습니다. 민트색 등 개별 옵션별 재고 수량은 시스템에 별도로 표기되어 있지 않아 정확한 안내가 어려운 점 양해 부탁드립니다. 더 궁금한 점이 있으시면 언제든 편하게 말씀해 주세요.
  → 기대한 도구 호출됨

라우팅 정확도: 4/4
(이하 생략)

4. 무엇을 관찰했나

네 질문 모두 기대한 도구가 불렸다

[도구] 줄만 따라 읽습니다.

질문 불린 도구와 인자
주문 HR… 배송 어디쯤 track_shipping('HR20260620069')
텀블러 보온 몇 시간 search_products('텀블러')
에어프라이어 재고 search_products('에어프라이어') → check_stock('P011')
P008 텀블러 민트색 재고 check_stock('P008')

코드에는 질문을 도구로 나누는 if 문이 없습니다. 도구를 고른 것은 모델, 실행한 것은 우리 코드(SDK), 기대와 맞춰 센 것도 우리 코드입니다.

인자도 봅니다. "텀블러 보온 몇 시간 가나요?"에서 모델은 문장 전체가 아니라 '텀블러' 만 검색어로 넣었습니다. 인자 설명의 예시(예: '텀블러', '이불커버', '운동화')가 "검색어는 이런 모양"이라고 알려 준 것입니다.

세 번째 질문 — 모델이 두 단계를 스스로 이었다

'P011'이라는 상품ID는 고객의 질문에 없었습니다. 첫 번째 도구의 결과에서 나온 값입니다. 모델은 검색 결과에서 에어프라이어의 상품ID를 읽고, 그것을 두 번째 도구의 인자로 썼습니다.

이 순서를 정한 코드는 없습니다. check_stock의 인자 설명("상품ID. 'P'로 시작한다")을 읽고, 상품ID가 없으니 먼저 찾아야 한다고 모델이 판단한 것입니다. 한 도구의 결과를 보고 다음 도구를 정하는 이 동작이 9장에서 직접 만드는 에이전트 루프의 씨앗입니다.

데이터에 없는 것은 없다고 말했다

짐작해 본 둘째 질문의 답입니다. 네 번째 답을 다시 봅니다. "전체 재고는 현재 150개 남아 있습니다. 민트색 등 개별 옵션별 재고 수량은 … 정확한 안내가 어려운 점 양해 부탁드립니다."

products.csv의 stock은 상품 하나에 숫자 하나입니다. 옵션(색상)별 재고는 데이터에 없습니다. 모델은 150개가 상품 전체의 수량임을 밝히고, 민트색만의 수량은 확인되지 않는다고 답했습니다. 첫 번째 답도 같습니다. track_shipping이 돌려주지 않는 도착 예정일은 "확인이 어렵다"고 했습니다.

이것은 SYSTEM의 셋째 줄이 한 일입니다.

조회 결과에 없는 내용(도착 예정일, 옵션별 재고 등)은 추측하지 않고, 확인되지 않는다고 말합니다.

코드에서 이 문장 가운데에 있는 \는 긴 줄을 두 줄로 나눠 적은 표시입니다. 모델에게는 한 줄로 전달됩니다.

도구가 사실을 가져오고, 프롬프트가 사실의 경계를 지키게 합니다. 이 교재를 준비하며 여섯 번 실행했을 때 여섯 번 모두 옵션별 재고는 확인되지 않는다고 답했습니다.

라우팅 정확도는 "도구를 맞게 골랐는가"를 셉니다. "답의 내용이 데이터와 맞는가"는 답을 도구 결과와 맞춰 보는 다른 검사입니다. 둘 다 봅니다.


5. 지금 폴더의 모습

haru-market/
├── config.py
├── data/
│   ├── orders.csv
│   ├── products.csv              ← 이번 장에서 처음 읽은 파일
│   └── …
├── lesson02_first_call.py
├── lesson03_prompt.py
├── lesson04_intent_classifier.py
├── lesson05_function_calling.py
├── lesson06_order_tools.py       ← 이번 장이 불러 쓰는 파일
└── lesson07_multi_tools.py       ← 이번 장

핵심 정리

  • 6장의 도구는 다시 만들지 않고 import로 불러 썼습니다. 6장 파일이 없으면 ModuleNotFoundError가 납니다.
  • 도구 네 개를 주고 질문 네 개를 보냈더니 모델이 네 번 모두 기대한 도구를 골랐습니다. 고르는 코드는 우리가 쓰지 않았습니다.
  • 상품ID를 모르는 질문에는 모델이 검색 → 재고 확인을 스스로 이었습니다.
  • 데이터에 없는 옵션별 재고와 도착 예정일은 확인되지 않는다고 답했습니다. SYSTEM의 한 줄이 그 경계를 지키게 했습니다.
  • 이번 장의 산출물은 도구 네 개와 기대 도구 표로 라우팅을 세는 방법입니다.
← 이전 절라우팅을 숫자로 확인하기 — 기대 도구를 먼저 적는다다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com