실무 Multi-Agent 오케스트레이션 21장 · 전문 에이전트 구현 7 / 9 ← 이전목차다음 → TechLead Cro

21장. 전문 에이전트 구현

따라하기 2 — 전문 에이전트 만들고 하나씩 시험하기

수업 모델: 모든 장은 config.py의 MODEL을 사용합니다. .env의 GEMINI_MODEL=gemini-3.8-flash를 확인하세요.

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

목표

파일 두 개를 차례로 만듭니다. 먼저 전문 에이전트 셋을 담은 재사용 모듈 haru_agents.py 를 만들고, 이어서 lesson21_specialist_agents.py 로 셋을 하나씩 따로 불러 일을 시켜 봅니다. 읽는 에이전트 둘이 맡은 일을 하는지, 요청처리 에이전트가 직접 처리 · 처리하지 못한 이유 보고 · 승인 요청을 가려서 하는지, 그리고 맡지 않은 일을 받았을 때 물러서는지를 확인합니다.


0. 실습 준비

이 장의 실습 고객 — C003 김도윤 고객(VIP 등급)으로 로그인한 상태라고 정해 두고 실습합니다.

앞 절에서 haru_actions.py를 만들고 check21_actions.py로 도구를 직접 불러 본 상태여야 합니다.

정책안내 에이전트는 13장이 만든 chroma_db/ 폴더에서 정책 문서를 검색합니다. 13장을 먼저 실행했어야 합니다.

이렇게 나오면 조치
[준비 필요] chroma_db/ 폴더가 없습니다. 13장 lesson13_rag_embedding.py 를 먼저 실행하세요. 13장 파일을 실행합니다: python lesson13_rag_embedding.py

이 확인은 build_agents 안에 들어 있어서, 이 모듈을 가져다 쓰는 22장과 24장에서도 같은 줄이 나옵니다.


1. 첫 번째 파일 — haru_agents.py

haru-market 폴더 맨 위에 새 파일을 만듭니다. 이 파일도 이름에 번호가 없는 재사용 모듈입니다.

haru_agents.py

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

# -*- coding: utf-8 -*-
"""하루마켓 전문 에이전트 3종 (21장 산출물) — 22~24장이 재사용한다.

분리 기준: "도구 세트 · 프롬프트 · 책임"이 다르면 다른 에이전트다.
  주문조회 에이전트: orders/products 읽기 도구, 사실 보고 책임
  정책안내 에이전트: RAG 검색 도구, 근거(조항) 표기 책임
  요청처리 에이전트: 처리 도구(배송지 변경·교환 접수·환불 승인 요청·티켓), 실행 책임

각 에이전트의 입출력 계약: 자연어 질문 → 자연어 보고 (내부 도구 사용은 캡슐화)
"""
import os

from langchain.agents import create_agent
from langchain.tools import tool
from langchain_chroma import Chroma
from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings

from config import EMBEDDING_MODEL, MODEL, ROOT
from haru_actions import make_action_tools
from haru_tools import CustomerSession, make_tools


def _llm() -> ChatGoogleGenerativeAI:
    # temperature 는 넣지 않는다. Gemini 3 계열은 기본값을 그대로 쓰라는 것이 공식 권장이고,
    # 모든 장의 생성 모델은 config.MODEL 을 사용한다.
    return ChatGoogleGenerativeAI(
        model=MODEL, google_api_key=os.getenv("GOOGLE_API_KEY"))


def build_agents(session: CustomerSession):
    """고객 세션에 묶인 전문 에이전트 3종을 생성한다."""
    raw = make_tools(session)
    act = make_action_tools(session)

    # ── 1) 주문조회 에이전트 ─────────────────────────────────────────
    order_agent = create_agent(
        model=_llm(),
        tools=[tool(raw["get_my_orders"]), tool(raw["get_order_status"]),
               tool(raw["get_my_membership"]),
               tool(raw["search_products"]), tool(raw["check_stock"])],
        system_prompt=(
            "당신은 하루마켓 주문조회 전문 에이전트입니다. 고객은 이미 로그인되어 있습니다.\n"
            "- 반드시 도구로 조회한 데이터로만 사실을 보고합니다.\n"
            "- 고객에게 주문번호를 묻지 마십시오. 주문번호가 없으면 반드시 get_my_orders 를 "
            "먼저 호출해 본인 주문 목록에서 해당 주문(상품명·시기)을 직접 찾습니다.\n"
            "- '운동화', '이어폰', '지난주에 산 것' 같은 표현은 get_my_orders 결과의 "
            "상품명·주문일과 대조해 특정합니다.\n"
            "- 재고 확인 절차: search_products 로 상품을 찾아 product_id 를 얻은 뒤 "
            "check_stock 을 호출합니다. '확인이 어렵다'고 하기 전에 "
            "반드시 이 절차를 시도합니다.\n"
            "- 재고 수량은 상품 전체 기준입니다. 색상·사이즈 같은 옵션별 수량은 데이터에 "
            "없으므로 옵션별 재고처럼 말하지 않습니다. '상품 전체 재고 N개 "
            "(옵션별 수량은 데이터에 없음)' 형식으로 보고합니다.\n"
            "- 고객 등급·포인트는 get_my_membership 으로 확인합니다.\n"
            "- 정책(환불 규정·멤버십 혜택 기준 등) 질문에는 답하지 않고 "
            "'정책 담당 확인 필요'라고 보고합니다.\n"
            "- 상품 설명에 없는 성능·기능은 '설명에 없음'이라고 보고합니다.\n"
            "- 결과는 간결한 사실 위주로 정리합니다 (주문번호·상품·옵션·상태 포함)."),
    )

    # ── 2) 정책안내 에이전트 (RAG) ───────────────────────────────────
    # 13장에서 만든 벡터 저장소가 있는지 먼저 확인한다. 폴더가 없는데 Chroma(...) 를
    # 부르면 빈 폴더를 새로 만들어 버려서, 오류 없이 검색 결과만 0건이 된다.
    chroma_dir = ROOT / "chroma_db"
    need_13 = "13장 lesson13_rag_embedding.py 를 먼저 실행하세요."
    if not chroma_dir.exists():
        raise SystemExit(f"[준비 필요] chroma_db/ 폴더가 없습니다. {need_13}")

    vectorstore = Chroma(
        collection_name="haru_policy",
        embedding_function=GoogleGenerativeAIEmbeddings(
            model=EMBEDDING_MODEL, google_api_key=os.getenv("GOOGLE_API_KEY")),
        persist_directory=str(chroma_dir))
    if not vectorstore.get(limit=1)["ids"]:
        raise SystemExit(f"[준비 필요] chroma_db/ 에 정책 문서가 없습니다. {need_13}")

    @tool
    def search_policy(query: str) -> str:
        """하루마켓 정책 문서에서 관련 조항을 검색한다.
        대상 문서: 반품·교환·환불 정책, 하루클럽 멤버십 정책(등급·적립금·혜택).

        Args:
            query: 검색할 정책 관련 질문. 예: '단순 변심 반품 배송비', 'VIP 등급 혜택'
        """
        docs = vectorstore.similarity_search(query, k=5)
        if not docs:
            return "검색 결과 없음"
        return "\n\n".join(
            f"[{d.metadata.get('doc_title', '정책')} · "
            f"{d.metadata.get('article') or '조항 미상'}]\n{d.page_content}"
            for d in docs)

    policy_agent = create_agent(
        model=_llm(),
        tools=[search_policy],
        system_prompt=(
            "당신은 하루마켓 정책안내 전문 에이전트입니다. "
            "반품·교환·환불 정책과 하루클럽 멤버십 정책을 담당합니다.\n"
            "- 모든 답변은 search_policy 검색 결과에 근거해야 합니다.\n"
            "- 질문에 두 정책이 얽혀 있으면(예: VIP의 반품 배송비) 필요한 만큼 "
            "search_policy 를 여러 번 검색해 종합합니다.\n"
            "- 답변 끝에 (근거: 문서명 제N조) 를 표기합니다. 꼬리표에 조항이 여럿이면 "
            "본문을 읽어, 답한 내용이 실제로 적힌 문단 바로 위의 조항만 적습니다. "
            "같은 내용이 조항과 자주 묻는 질문(FAQ) 양쪽에 있으면 조항을 적습니다.\n"
            "- 배송비는 반품(편도)과 교환(왕복)을 구분해 보고합니다. 기간·금액이 "
            "결제수단이나 사유에 따라 다르면 경우별로 모두 보고합니다.\n"
            "- 검색 결과에 없는 내용은 '정책 문서에서 확인되지 않음'이라고 보고합니다.\n"
            "- 금액·기간 숫자는 검색 결과 그대로만 사용합니다."),
    )

    # ── 3) 요청처리 에이전트 ─────────────────────────────────────────
    # 읽기만 하는 앞의 두 에이전트와 달리, 이 에이전트는 실제로 '처리'한다.
    action_agent = create_agent(
        model=_llm(),
        tools=[tool(act["change_shipping_address"]), tool(act["request_exchange"]),
               tool(act["request_refund"]), tool(act["get_my_requests"]),
               tool(raw["get_my_orders"]), tool(raw["create_ticket"])],
        system_prompt=(
            "당신은 하루마켓 요청처리 전문 에이전트입니다. 고객은 이미 로그인되어 있습니다.\n"
            "- 전달받은 요청을 도구로 처리합니다. 필요한 정보가 부족하면 확인합니다.\n"
            "- 배송지 변경은 change_shipping_address, 교환은 request_exchange, "
            "환불·반품·주문 취소는 request_refund 를 호출합니다.\n"
            "- 반품·교환 사유는 고객 설명의 전체 의미를 해석해 reason_category 로 전달합니다. "
            "단순변심(취향·사이즈 등), 상품하자(고장·기능 이상), 오배송(주문과 다른 상품), "
            "확인필요(사유가 없거나 불명확) 중 고릅니다. 특정 단어의 유무로 판정하지 마세요. "
            "'불량은 아니고' 같은 부정 표현을 반영하고, 고객이 말하지 않은 사유는 만들지 않습니다. "
            "reason 에는 고객이 설명한 사유를 보존합니다. 사유가 없으면 '사유 미제공'으로 씁니다.\n"
            "- 사유가 불명확해도 해당 도구에 확인필요를 전달합니다. "
            "needs_clarification=True 이면 사유를 질문해야 한다고 보고합니다. "
            "이 경우 접수 완료로 보고하거나 사유 부족만으로 상담원 티켓을 만들지 않습니다. "
            "출고 전 주문 취소는 사유 분류와 무관하게 도구가 처리합니다.\n"
            "- 주문번호가 전달되지 않았으면 get_my_orders 로 본인 주문에서 해당 주문을 "
            "찾아 그 주문번호로 처리합니다.\n"
            "- 도구가 ok=False 를 돌려주면 처리하지 않은 것입니다. 그 reason 을 그대로 "
            "보고하고, 처리했다고 말하지 않습니다.\n"
            "- request_refund 는 '승인 요청'만 만듭니다. 환불이 완료됐다고 말하지 않고 "
            "'승인 요청을 등록했고 담당자 승인 후 처리된다'고 보고합니다.\n"
            "- 사람과의 상담을 원하거나, 위 도구로 처리할 수 없는 요청(계정·결제 오류, "
            "보상 요구 등)은 create_ticket 으로 상담원에게 접수합니다. summary 에는 "
            "고객 상황 + 이미 확인된 사실 + 요청 사항을 적습니다.\n"
            "- 처리 내역이나 진행 상태를 물으면 get_my_requests 로 확인합니다.\n"
            "- 보고에는 처리번호(A로 시작) 또는 티켓번호(T로 시작)와 처리 결과를 적습니다."),
    )

    return {"order": order_agent, "policy": policy_agent, "action": action_agent}


def extract_text(message) -> str:
    """메시지 content 에서 순수 텍스트를 뽑는다.

    모델에 따라 content 가 문자열이 아니라 콘텐츠 블록 리스트
    ([{'type': 'text', 'text': ...}, ...]) 로 올 수 있다. 둘 다 처리한다.
    """
    c = message.content
    if isinstance(c, str):
        return c
    if isinstance(c, list):
        parts = []
        for block in c:
            if isinstance(block, dict):
                parts.append(block.get("text", ""))
            elif isinstance(block, str):
                parts.append(block)
        return "".join(parts)
    return str(c)


def run_agent(agent, question: str, verbose: bool = True) -> str:
    """에이전트 실행 + 도구 사용 추적. 마지막 AI 메시지를 반환한다."""
    result = agent.invoke({"messages": [{"role": "user", "content": question}]})
    if verbose:
        for msg in result["messages"]:
            if msg.__class__.__name__ == "AIMessage" and getattr(msg, "tool_calls", None):
                for tc in msg.tool_calls:
                    print(f"    [도구] {tc['name']}({tc['args']})")
    return extract_text(result["messages"][-1])

이 파일의 이름과 함수 이름을 바꾸지 마세요. 뒤의 장들이 from haru_agents import build_agents처럼 이 이름 그대로 불러 씁니다.

이 파일은 함수를 정의만 합니다. 터미널에서 한 줄로 불러와지는지만 확인합니다.

$ python -c "from haru_agents import build_agents, run_agent, extract_text; print('불러오기 성공')"

실행 결과

불러오기 성공
이렇게 나오면 원인과 조치
ModuleNotFoundError: No module named 'haru_agents' 파일 이름이 다르거나 폴더 맨 위가 아닌 곳에 만들었습니다
ModuleNotFoundError: No module named 'haru_actions' 앞 절의 haru_actions.py가 없습니다
ImportError: cannot import name 'build_agents' 코드가 일부만 붙여 넣어졌습니다. 전체를 다시 복사해 붙여 넣습니다

2. 코드에서 볼 곳

파일에는 함수가 네 개 있습니다.

함수 하는 일 누가 쓰나
_llm() Gemini 모델 객체를 만든다 이 파일 안에서만 (이름 앞의 _는 "안에서만 쓴다"는 표시)
build_agents(session) 고객 세션을 받아 에이전트 셋을 만들어 딕셔너리로 돌려준다 다른 파일
extract_text(message) 메시지에서 글자만 뽑는다 다른 파일, run_agent
run_agent(agent, question) 에이전트에 요청을 보내고 마지막 보고를 돌려준다 다른 파일

build_agents 안의 세 덩어리

build_agents는 길어 보이지만 같은 모양이 세 번 반복됩니다. create_agent(model=…, tools=…, system_prompt=…)입니다.

에이전트 tools system_prompt의 첫 문장
order_agent 조회 도구 5개 "당신은 하루마켓 주문조회 전문 에이전트입니다."
policy_agent search_policy 1개 "당신은 하루마켓 정책안내 전문 에이전트입니다."
action_agent 처리 도구 6개 "당신은 하루마켓 요청처리 전문 에이전트입니다."

도구는 두 묶음에서 꺼냅니다.

raw = make_tools(session)             # 8장: 조회 도구와 create_ticket
act = make_action_tools(session)      # 앞 절: 처리 도구

요청처리 에이전트의 도구 목록입니다.

tools=[tool(act["change_shipping_address"]), tool(act["request_exchange"]),
       tool(act["request_refund"]), tool(act["get_my_requests"]),
       tool(raw["get_my_orders"]), tool(raw["create_ticket"])],

act에서 넷, raw에서 둘을 꺼냈습니다. 반대로 주문조회 에이전트의 목록에는 act에서 꺼낸 것이 하나도 없고 create_ticket도 없습니다. 주문조회 에이전트는 아무것도 바꿀 수 없습니다. 프롬프트에 "바꾸지 마라"고 적은 것이 아니라, 애초에 도구를 주지 않았습니다.

extract_text — 답이 문자열이 아닐 수 있다

def extract_text(message) -> str:
    c = message.content
    if isinstance(c, str):
        return c
    if isinstance(c, list):
        parts = []
        for block in c:
            if isinstance(block, dict):
                parts.append(block.get("text", ""))
            elif isinstance(block, str):
                parts.append(block)
        return "".join(parts)
    return str(c)

LangChain에서 모델의 답은 message.content에 들어 있습니다. 이 값은 보통 문자열이지만, 모델과 상황에 따라 [{'type': 'text', 'text': '…'}, …] 같은 조각의 목록으로 오기도 합니다. 그대로 출력하면 대괄호와 중괄호가 섞인 글이 나옵니다. 이 함수가 두 경우를 모두 받아 글자만 돌려줍니다.


3. 두 번째 파일 — lesson21_specialist_agents.py

같은 위치(폴더 맨 위)에 새 파일을 하나 더 만듭니다.

lesson21_specialist_agents.py

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

# -*- coding: utf-8 -*-
"""[21장] 전문 에이전트 3종 — 역할을 나누면 정확해진다

문제 상황: 하나의 에이전트에 도구 11개와 모든 규칙을 다 넣으면
  - 프롬프트가 길어져 규칙을 빼먹고
  - 엉뚱한 도구를 고르는 일이 는다

해결: 책임 단위로 에이전트를 나눈다 (분리 기준 = 도구 세트 · 프롬프트 · 책임)
  주문조회 에이전트 / 정책안내(RAG) 에이전트 / 요청처리 에이전트
구현은 haru_agents.py (처리 도구는 haru_actions.py) — 22장 Supervisor 가 그대로 재사용한다.

실행:  python lesson21_specialist_agents.py  (13장 선행 필요)
"""
from haru_agents import build_agents, run_agent
from haru_tools import CustomerSession

session = CustomerSession("C003")
agents = build_agents(session)

if __name__ == "__main__":
    # ── 각 에이전트를 '단독으로' 시험한다 — 에이전트별 입출력 계약 확인 ──
    print("■ 1) 주문조회 에이전트")
    print("  질문: 제 최근 주문 뭐가 있고 배송 상태 어때요?")
    print("  보고:", run_agent(agents["order"],
                             "제 최근 주문 뭐가 있고 배송 상태 어때요?"))

    print("\n■ 2) 정책안내 에이전트")
    print("  질문: 단순 변심 교환 배송비는 얼마인가요?")
    print("  보고:", run_agent(agents["policy"],
                             "단순 변심 교환 배송비는 얼마인가요?"))

    print("\n■ 3) 요청처리 에이전트 — 직접 처리")
    request = "주문 HR20260726004 배송지를 '서울 중구 세종대로 110'으로 바꿔 주세요."
    print(f"  요청: {request}")
    print("  보고:", run_agent(agents["action"], request))

    print("\n■ 4) 요청처리 에이전트 — 처리할 수 없으면 이유를 보고")
    request = "주문 HR20260725002 배송지를 '서울 중구 세종대로 110'으로 바꿔 주세요."
    print(f"  요청: {request}")
    print("  보고:", run_agent(agents["action"], request))

    print("\n■ 5) 요청처리 에이전트 — 환불은 승인 요청까지만")
    request = "주문 HR20260721001 환불해 주세요. 사유는 단순 변심입니다."
    print(f"  요청: {request}")
    print("  보고:", run_agent(agents["action"], request))

    # ── 경계 확인: 남의 일을 시키면 거절하는가 ──
    print("\n■ 6) 경계 테스트 — 주문조회 에이전트에게 정책 질문")
    print("  질문: 반품 배송비 얼마예요?")
    print("  보고:", run_agent(agents["order"], "반품 배송비 얼마예요?"))

    print("""
──────────────────────────────────────────────────────────
전문화가 정확도를 올리는 이유
  - 도구가 11개에서 5·1·6개로: 고를 선택지가 줄면 오호출이 준다 (+ 입력 토큰도 준다)
  - 프롬프트가 짧고 단일 책임이면 규칙 누락이 준다
  - 에이전트별로 따로 테스트·개선할 수 있다 (회귀 세트도 에이전트별로)
  - 읽기만 하는 에이전트와 처리하는 에이전트가 나뉜다: 위험한 도구를 쥔 쪽이 하나로 좁혀진다
남은 문제: 고객 문의를 누가 받아서 누구에게 시키나? → 22장 Supervisor
──────────────────────────────────────────────────────────""")

파일이 짧습니다. 에이전트를 만드는 코드는 모두 haru_agents.py에 있고, 이 파일은 불러서 쓰기만 합니다.

from haru_agents import build_agents, run_agent
from haru_tools import CustomerSession

session = CustomerSession("C003")
agents = build_agents(session)

그 아래는 같은 모양의 호출이 여섯 번입니다.

시험 누구에게 무엇을 볼 것
1 agents["order"] 최근 주문과 배송 상태 되묻지 않고 조회하는가
2 agents["policy"] 단순 변심 교환 배송비 검색하고 근거를 다는가
3 agents["action"] 출고 전 주문(콜드브루)의 배송지 변경 직접 처리하고 처리번호를 보고하는가
4 agents["action"] 배송중인 주문(이어폰)의 배송지 변경 처리했다고 하지 않고 이유를 보고하는가
5 agents["action"] 운동화 주문 환불 환불했다고 하지 않고 승인 요청까지만 하는가
6 agents["order"] 반품 배송비 (정책 질문) 물러서는가

여섯 번 모두 run_agent(에이전트, 요청) 한 가지 방법으로 부릅니다. "받는 것과 돌려주는 것의 모양이 셋 다 같다"가 이것입니다.


4. 실행하기

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

  • 시험 3과 4는 요청 문장이 주문번호만 빼고 똑같습니다. 에이전트는 두 번 다 같은 도구를 부를까요? 보고는 어떻게 달라질까요?
  • 시험 5가 끝나면 기록 파일에 처리 기록이 몇 건 있을까요? 그 상태는 무엇일까요?
  • 시험 6에서 주문조회 에이전트는 "3,000원입니다"라고 답할까요? 도구를 부를까요?
$ python lesson21_specialist_agents.py

LLM을 열 번쯤 호출하므로 15~20초 걸립니다.

실행 결과 (문장은 실행할 때마다 달라집니다. 보고는 전체를 출력합니다)

■ 1) 주문조회 에이전트
  질문: 제 최근 주문 뭐가 있고 배송 상태 어때요?
    [도구] get_my_orders({})
  보고: 고객님의 최근 주문 목록 및 배송 상태입니다.

1. **콜드브루 원액 500ml 2병**
   - 주문번호: HR20260726004
   - 옵션: 단일/단일
   - 주문일시: 2026-07-26 22:05
   - 상태: 결제완료

2. **무선 블루투스 이어폰**
   - 주문번호: HR20260725002
   - 옵션: 블랙/단일
   - 주문

■ 2) 정책안내 에이전트
  질문: 단순 변심 교환 배송비는 얼마인가요?
    [도구] search_policy({'query': '단순 변심 교환 배송비'})
  보고: 단순 변심으로 인한 교환 배송비(왕복)는 **6,000원**이며 고객이 부담합니다.

(근거: 반품교환환불정책 제4조)

■ 3) 요청처리 에이전트 — 직접 처리
  요청: 주문 HR20260726004 배송지를 '서울 중구 세종대로 110'으로 바꿔 주세요.
    [도구] change_shipping_address({'order_id': 'HR20260726004', 'new_address': '서울 중구 세종대로 110'})
  보고: 주문번호 HR20260726004(콜드브루 원액 500ml 2병)의 배송지를 '서울 중구 세종대로 110'으로 변경 완료했습니다. (처리번호: A00001)

■ 4) 요청처리 에이전트 — 처리할 수 없으면 이유를 보고
  요청: 주문 HR20260725002 배송지를 '서울 중구 세종대로 110'으로 바꿔 주세요.
    [도구] change_shipping_address({'new_address': '서울 중구 세종대로 110', 'order_id': 'HR20260725002'})
  보고: 주문번호 HR20260725002의 배송지 변경을 시도했으나 실패했습니다.

**사유:** 이미 '배송중' 상태라 배송지를 바꿀 수 없습니다.

■ 5) 요청처리 에이전트 — 환불은 승인 요청까지만
  요청: 주문 HR20260721001 환불해 주세요. 사유는 단순 변심입니다.
    [도구] request_refund({'order_id': 'HR20260721001', 'reason': '단순 변심', 'reason_category': '단순변심'})
  보고: 주문번호 HR20260721001(상품명: 쿠션 운동화)의 환불 승인 요청이 접수되었습니다. 

- **처리번호**: A00002
- **상태**: 승인대기

담당자 승인 후 최종 처리될 예정입니다.

■ 6) 경계 테스트 — 주문조회 에이전트에게 정책 질문
  질문: 반품 배송비 얼마예요?
  보고: 반품 배송비 정책에 대해서는 정책 담당 확인이 필요합니다.
(이하 생략)

이 교재를 준비하며 기록 파일이 없는 상태에서 이 파일을 세 번 실행했고, 세 번 모두 부른 도구와 처리번호(A00001 배송지 변경, A00002 환불 승인 요청)가 같았습니다.

한 번 더 실행하면

처리 기록이 남아 있는 채로 이 파일을 다시 실행하면 두 곳이 달라집니다.

시험 처음 실행 다시 실행
3 (배송지 변경) 처리번호 A00001 한 번 더 처리되어 새 번호 A00003
5 (환불 승인 요청) 새 요청 A00002, 승인대기 새 요청을 만들지 않고 "이미 승인을 기다리는 요청이 있습니다" 와 함께 A00002를 알려 준다

고장이 아니라 기록이 쌓여서 그렇습니다. 교재와 같은 번호로 다시 보고 싶으면 앞 절의 기록 지우기 한 줄을 먼저 실행합니다.

$ python -c "from haru_actions import ACTIONS_PATH; ACTIONS_PATH.unlink(missing_ok=True)"

5. 무엇을 관찰했나

시험 1 — 묻지 않고 조회했다

[도구] get_my_orders({}) 한 줄이 먼저 찍혔습니다. 고객은 주문번호를 말하지 않았고, 에이전트도 묻지 않았습니다. 목록을 직접 조회해 주문번호·옵션·주문일시·상태를 항목별로 정리했습니다.

보고의 모양도 봅니다. 공감 표현 없이 사실만 나열했습니다. 이 글은 고객에게 바로 보낼 답변이 아니라 일을 맡긴 쪽에게 올리는 보고입니다.

괄호 안의 {}는 인자 없이 불렀다는 뜻입니다. get_my_orders에는 "누구의 주문인지"를 넘기는 인자가 없습니다. 그것은 세션(C003)이 정합니다(8장).

시험 2 — 검색어를 스스로 만들었다

[도구] search_policy({'query': '단순 변심 교환 배송비'})를 봅니다. 요청은 "단순 변심 교환 배송비는 얼마인가요?"였는데, 검색어는 조사와 물음을 뗀 핵심어입니다. 고객 문장을 그대로 넣던 19장의 정책 노드와 다른 점입니다. 검색어를 만든 것은 모델이고, 검색을 실행한 것은 우리 함수입니다.

보고에는 왕복 6,000원과 (근거: 반품교환환불정책 제4조)가 붙었습니다. 교환 배송비를 정한 조항이 제4조입니다.

시험 3 — 그 자리에서 처리했다

요청처리 에이전트는 되묻지 않고 change_shipping_address를 불렀고, 보고에 처리번호 A00001 을 적었습니다. memory_store/actions.json을 열어 보면 그 번호의 기록이 있습니다.

한 일 누가
요청 문장에서 주문번호와 새 주소를 뽑아 도구 인자로 넣었다 모델
본인 주문인지, 출고 전인지 판정했다 코드
기록을 쓰고 처리번호 A00001을 만들었다 코드
처리 결과를 문장으로 보고했다 모델

시험 4 — 같은 도구를 불렀고, 코드가 막았다

에이전트가 부른 도구는 시험 3과 똑같습니다. 에이전트는 이 주문이 배송중인지 미리 따져 보지 않았습니다. 프롬프트대로 "즉시 처리"하려고 도구를 불렀고, 도구 안의 코드가 ok=False와 이유를 돌려줬습니다. 에이전트는 그 이유("이미 '배송중' 상태라 배송지를 바꿀 수 없습니다.")를 그대로 보고했고, 처리했다고 말하지 않았습니다.

앞 절에서 LLM 없이 도구를 직접 불렀을 때와 같은 결과입니다. 에이전트를 붙여도 판정하는 쪽은 바뀌지 않습니다.

시험 5 — 환불하지 않고, 승인 요청을 올렸다

보고의 낱말을 봅니다. "환불했습니다"가 아니라 "환불 승인 요청이 접수되었습니다", 상태는 승인대기입니다. request_refund가 한 일은 기록 한 줄을 남긴 것이 전부입니다.

실행이 끝난 뒤의 memory_store/actions.json입니다(created_at은 줄였습니다).

[
  {
    "action_id": "A00001",
    "type": "배송지변경",
    "customer_id": "C003",
    "order_id": "HR20260726004",
    "detail": "콜드브루 원액 500ml 2병 배송지를 '서울 중구 세종대로 110'(으)로 변경",
    "status": "완료"
  },
  {
    "action_id": "A00002",
    "type": "환불요청",
    "customer_id": "C003",
    "order_id": "HR20260721001",
    "detail": "쿠션 운동화 화이트/250 · 결제 49800원 · 사유: 단순 변심 · 분류: 단순변심 · 배송 완료 후 2일, 기간(7일) 이내",
    "status": "승인대기"
  }
]

기록은 두 건입니다. 시험 4는 남지 않았습니다. 두 번째 기록의 detail에 "배송 완료 후 2일, 기간(7일) 이내"가 적혀 있습니다. 7월 25일에 받은 운동화를 실습 기준일인 7월 27일에 세어 2일입니다. 이 날짜 계산과 판정도 코드가 했습니다. 승인하는 사람은 이 한 줄을 보고 판단합니다.

시험 6 — 물러섰다

■ 6) 경계 테스트 — 주문조회 에이전트에게 정책 질문
  질문: 반품 배송비 얼마예요?
  보고: 반품 배송비 정책에 대해서는 정책 담당 확인이 필요합니다.

[도구] 줄이 없습니다. 에이전트는 어떤 도구도 부르지 않았고, 금액도 말하지 않았고, 정책 담당 확인이 필요하다고만 보고했습니다. 세 번의 실행에서 문장은 조금씩 달랐지만, 세 번 모두 도구를 부르지 않았고 금액을 말하지 않았습니다.

여섯을 나란히 놓으면

시험 부른 도구 기록 파일 누가 정했나
1 get_my_orders 변화 없음 도구 선택은 모델
2 search_policy 변화 없음 검색어는 모델
3 change_shipping_address A00001 완료 도구 선택은 모델, 처리 가능 판정은 코드
4 change_shipping_address 변화 없음 도구 선택은 모델, 거절은 코드
5 request_refund A00002 승인대기 도구 선택은 모델, 승인대기로 둔 것은 코드
6 없음 변화 없음 모델이 부르지 않기로 함 (경계 규칙)

에이전트마다 고를 수 있는 도구가 처음부터 다릅니다. 그 안에서 무엇을 부를지만 모델이 정하고, 불린 도구가 실제로 무엇을 할지는 코드가 정합니다.

남은 것

여섯 번의 시험은 우리가 알맞은 에이전트를 골라 주문번호까지 적어 건넸기 때문에 깔끔했습니다. 실제 고객은 누구에게 물어야 하는지 모르고, 주문번호도 말하지 않습니다. "지난주에 산 운동화 교환해 주세요"라는 한 문장을 처리하려면 누군가 주문조회 에이전트에게 어느 주문인지 묻고, 그 주문번호를 담아 요청처리 에이전트에게 넘겨야 합니다. 전문가 셋은 준비되었고, 이들에게 일을 나눠 주는 자리가 아직 비어 있습니다(22장).


6. 지금 폴더의 모습

haru-market/
├── app/
│   └── frontend/
│       ├── admin.html
│       └── index.html
├── chroma_db/                       (13장이 만든 정책 문서 검색용 폴더)
├── data/
├── memory_store/
│   ├── actions.json                 (처리 기록이 여기에 쌓인다)
│   └── tickets.json                 (8장부터 쌓인 티켓)
├── config.py
├── haru_tools.py                    (8장)
├── haru_actions.py                  ← 이번 장 (재사용 모듈)
├── haru_agents.py                   ← 이번 장 (재사용 모듈)
├── …                                (2~19장의 파일)
├── lesson20_planner_worker.py
├── check21_actions.py               ← 이번 장
└── lesson21_specialist_agents.py    ← 이번 장

핵심 정리

  • haru_agents.py는 재사용 모듈입니다. build_agents(session)은 {"order": …, "policy": …, "action": …} 딕셔너리를 돌려줍니다.
  • 세 에이전트는 run_agent(에이전트, 요청) 한 가지 방법으로 부릅니다.
  • 주문조회 에이전트는 되묻지 않고 조회했고, 정책안내 에이전트는 검색어를 스스로 만들어 찾고 조항을 근거로 붙였습니다.
  • 요청처리 에이전트는 출고 전 주문의 배송지를 그 자리에서 바꿨고, 배송중인 주문은 코드가 거절한 이유를 그대로 보고했고, 환불은 승인 요청까지만 올렸습니다.
  • 처리할 수 있는지는 에이전트가 아니라 도구 안의 코드가 판정했습니다. 기록 파일에 남은 것은 판정을 통과한 두 건뿐입니다.
  • 주문조회 에이전트는 정책 질문에 도구도 부르지 않고 금액도 말하지 않은 채 물러섰습니다.
  • 처리 기록이 남아 있으면 다시 실행할 때 번호가 이어집니다. 기록 파일을 지우면 처음 상태로 돌아갑니다.
← 이전 절따라하기 1 — 처리 도구 만들기다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com