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(에이전트, 요청)한 가지 방법으로 부릅니다. - 주문조회 에이전트는 되묻지 않고 조회했고, 정책안내 에이전트는 검색어를 스스로 만들어 찾고 조항을 근거로 붙였습니다.
- 요청처리 에이전트는 출고 전 주문의 배송지를 그 자리에서 바꿨고, 배송중인 주문은 코드가 거절한 이유를 그대로 보고했고, 환불은 승인 요청까지만 올렸습니다.
- 처리할 수 있는지는 에이전트가 아니라 도구 안의 코드가 판정했습니다. 기록 파일에 남은 것은 판정을 통과한 두 건뿐입니다.
- 주문조회 에이전트는 정책 질문에 도구도 부르지 않고 금액도 말하지 않은 채 물러섰습니다.
- 처리 기록이 남아 있으면 다시 실행할 때 번호가 이어집니다. 기록 파일을 지우면 처음 상태로 돌아갑니다.