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

21장. 전문 에이전트 구현

처리를 위험도로 나눈다 — 직접 처리, 승인 요청, 상담원에게

한 줄 요약

고객의 요청을 전부 에이전트가 처리하게 둘 수도, 전부 사람에게 넘길 수도 없습니다. 기준은 되돌릴 수 있는가입니다. 되돌릴 수 있는 일은 에이전트가 직접 처리하고, 돈이 나가는 되돌릴 수 없는 일은 승인 요청까지만 올리고, 처리할 수 없는 일은 상담원에게 넘깁니다. 그리고 처리할 수 있는지 없는지는 모델이 아니라 코드가 판정합니다.


1. 세 갈래

갈래 해당하는 일 에이전트가 하는 일 왜 이렇게 나눴나
직접 처리 배송지 변경, 교환 접수 그 자리에서 처리하고 처리번호를 보고한다 잘못돼도 되돌릴 수 있다. 주소는 다시 바꾸면 되고, 교환 접수는 취소하면 된다
승인 요청까지만 환불, 주문 취소 요청을 승인대기 상태로 등록한다. 실행은 사람이 승인한 뒤 돈이 나간다. 한번 나간 돈은 되돌리기 어렵다
상담원에게 계정·결제 오류, 보상 요구, 사람과 통화하고 싶다는 요청 티켓으로 접수한다 우리 도구로 처리할 수 없다

세 갈래는 도구 이름으로도 드러납니다.

도구 갈래 남기는 기록
change_shipping_address 직접 처리 처리 기록, 상태 완료
request_exchange 직접 처리 처리 기록, 상태 완료
request_refund 승인 요청까지만 처리 기록, 상태 승인대기
create_ticket (8장) 상담원에게 티켓
get_my_requests (조회) 남기지 않는다

환불 도구의 이름이 refund가 아니라 request_refund 인 것을 봐 두세요. 이 도구는 환불하지 않습니다. 환불해 달라는 요청을 등록합니다. 8장에서 "되돌릴 수 없는 것은 도구로 만들지 않는다"고 한 원칙은 그대로입니다. 에이전트의 도구 목록 어디에도 환불을 실행하는 도구는 없습니다.

에이전트에게 맡길 수 있는가를 정하는 질문은 "모델이 똑똑한가"가 아니라 "잘못됐을 때 되돌릴 수 있는가" 입니다.


2. 가능 여부는 코드가 판정한다

"배송지를 바꿔 주세요"라는 요청을 처리해도 되는지는 주문이 아직 출고 전인가에 달려 있습니다. 이 판단을 누가 해야 할까요.

모델에게 맡길 수도 있습니다. 프롬프트에 "출고된 주문은 바꾸지 마라"고 적으면 대부분 지킵니다. 그러나 대부분입니다. 그리고 지켰는지 확인하려면 매번 답변을 읽어 봐야 합니다.

그래서 이 판단을 도구 안의 코드에 넣었습니다.

BEFORE_SHIPPING = ("결제완료", "배송준비")   # 아직 출고되지 않은 상태

def change_shipping_address(order_id: str, new_address: str) -> dict:
    order = _my_order(session, order_id)
    if order is None:
        return not_found
    if order["order_status"] not in BEFORE_SHIPPING:
        return {"ok": False, "order_id": order_id, "status": order["order_status"],
                "reason": f"이미 '{order['order_status']}' 상태라 배송지를 바꿀 수 없습니다."}
    action = _record(session, "배송지변경", order_id,
                     f"{order['product_name']} 배송지를 '{new_address}'(으)로 변경", "완료")
    return {"ok": True, "action_id": action["action_id"], ...}
줄 하는 일
_my_order(session, order_id) 본인 주문인지 확인한다. 아니면 "찾을 수 없습니다" (8장의 본인 확인)
if ... not in BEFORE_SHIPPING 출고 여부를 판정한다. 주문 상태가 결제완료나 배송준비가 아니면 처리하지 않는다
"ok": False, "reason": ... 처리하지 않았다는 것과 그 이유를 값으로 돌려준다
_record(...) 판정을 통과했을 때만 기록을 남기고 처리번호를 만든다

모델이 무엇을 요청하든, 배송중인 주문의 배송지는 바뀌지 않습니다. 조건문은 어길 수 없습니다.

도구마다 코드가 판정하는 것을 모았습니다.

도구 코드가 판정하는 것 통과하지 못하면
change_shipping_address 본인 주문인가, 출고 전인가 ok=False, "이미 '배송중' 상태라 배송지를 바꿀 수 없습니다."
request_exchange 본인 주문인가, 배송 완료됐는가, 기간 안인가, 재고가 있는가 ok=False, "배송 완료 후 N일이 지나 교환 가능 기간(7일)을 넘었습니다."
request_refund 본인 주문인가, 출고 전(주문 취소)인가 배송 완료(환불)인가, 기간 안인가 ok=False, "배송 완료 후 N일이 지나 반품 가능 기간(7일)을 넘었습니다."

기간은 파일 맨 위에 상수로 적혀 있습니다.

RETURN_DAYS = 7                  # 단순 변심 반품·교환: 배송 완료 후 7일 이내
DEFECT_DAYS = 30                 # 불량·오배송: 배송 완료 후 30일 이내 (정책 제2조)

에이전트가 문장 전체의 의미를 해석해 reason_category를 단순변심, 상품하자, 오배송, 확인필요 중 하나로 분류합니다. 코드는 분류값을 검증하고 단순 변심에는 7일, 상품 하자·오배송에는 30일을 적용합니다. 특정 단어의 포함 여부로 사유를 판정하지 않습니다. 예를 들어 “불량은 아니고 색이 마음에 안 들어요”는 단순 변심입니다.

사유가 없거나 불명확하면 확인필요로 전달합니다. 배송 완료 주문은 기록을 만들지 않고 needs_clarification=True를 반환하며, 에이전트가 고객에게 사유를 묻습니다. 출고 전 주문 취소에는 반품 기간을 적용하지 않으므로 사유 분류가 불명확해도 취소 승인 요청을 등록할 수 있습니다.


3. 실습 기준일 TODAY

"배송 완료 후 며칠이 지났는가"를 세려면 오늘이 언제인지 알아야 합니다. 그런데 실습 데이터의 주문은 2026년 7월까지만 있습니다. 실제 오늘 날짜로 세면 모든 주문이 기간을 한참 넘긴 것으로 나옵니다.

그래서 이 과정에서는 오늘을 고정해 둡니다.

TODAY = date(2026, 7, 27)        # 실습 기준일 (주문 데이터가 2026년 7월까지 있다)

def _days_since_delivery(order) -> int | None:
    delivered = order["delivered_at"]
    if not isinstance(delivered, str) or not delivered:
        return None
    return (TODAY - date.fromisoformat(delivered)).days

이 장의 실습 고객(C003)의 주문 두 건으로 세어 봅니다.

주문 배송 완료일 기준일까지 단순 변심 반품(7일)
쿠션 운동화 (HR20260721001) 2026-07-25 2일 가능
스테인리스 텀블러 2개 (HR20260628003) 2026-07-01 26일 기간 지남

날짜 계산도 모델에게 맡기지 않습니다. 모델에게 "7월 1일에서 7월 27일까지 며칠인가"를 물으면 대개 맞히지만, 뺄셈은 코드가 하면 항상 맞습니다.


4. 승인 요청은 어떻게 생겼나

request_refund가 판정을 통과하면 하는 일은 기록 한 줄을 남기는 것입니다.

action = _record(session, kind, order_id,
                 f"{order['product_name']} {order['option']} · 결제 {order['order_amount']}원"
                 f" · 사유: {reason} · {note}", "승인대기")
return {"ok": True, "action_id": action["action_id"], "order_id": order_id,
        "product_name": order["product_name"], "order_amount": order["order_amount"],
        "status": "승인대기",
        "message": "승인 요청을 등록했습니다. 담당자가 승인하면 처리됩니다."}

상태가 완료가 아니라 승인대기 입니다. 이 상태를 바꾸는 함수는 따로 있습니다.

def decide(action_id: str, approve: bool, by: str = "상담원") -> dict:
    """승인 대기 요청을 승인하거나 반려한다. 승인하면 그때 실행(환불·취소)된 것으로 기록한다."""

decide는 make_action_tools가 돌려주는 도구 묶음에 들어 있지 않습니다. 에이전트는 이 함수를 부를 수 없습니다. 부르는 것은 사람입니다. 승인 화면에서 사람이 버튼을 누르면 그때 이 함수가 실행됩니다(23장, 24장).

누가 할 수 있는 일
요청처리 에이전트 승인 요청을 등록한다 (request_refund)
사람 (상담원) 요청을 승인하거나 반려한다 (decide)

같은 주문의 승인 요청이 이미 대기 중이면 request_refund는 새 요청을 만들지 않고 "이미 승인을 기다리는 요청이 있습니다" 와 함께 앞의 처리번호를 돌려줍니다. 같은 환불 요청이 두 줄로 쌓여 두 번 승인되는 일을 막습니다.


5. 기록은 한 파일에 쌓인다

처리 도구가 남기는 기록은 모두 memory_store/actions.json에 쌓입니다.

action = {
    "action_id": f"A{len(actions) + 1:05d}",
    "type": kind,                          # 배송지변경 / 교환접수 / 환불요청 / 주문취소요청
    "customer_id": session.customer_id,
    "order_id": order_id,
    "detail": detail,
    "status": status,                      # 완료 / 승인대기 / 승인완료 / 반려
    "created_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
값 누가 넣나
action_id (처리번호, A00001부터) 코드. 지금까지 쌓인 기록 수에 1을 더한다
customer_id 세션. 모델이 넣지 않는다
type, status 코드. 어느 도구가 불렸고 판정이 어떻게 났는지로 정해진다
detail 안의 새 주소·새 옵션·사유 모델이 도구 인자로 넘긴 값

원본 데이터(data/orders.csv)는 고치지 않습니다. 실습에서는 "처리했다"는 사실을 이 기록 파일에 남기는 것으로 처리를 대신합니다. 그래서 실습을 몇 번 되풀이해도 원본은 그대로이고, 기록 파일만 지우면 처음 상태로 돌아갑니다.

티켓은 8장에서 만든 대로 memory_store/tickets.json에 따로 쌓입니다. 처리번호는 A로, 티켓번호는 T로 시작합니다.


핵심 정리

  • 처리는 되돌릴 수 있는가로 나눕니다.
  • 직접 처리: 배송지 변경, 교환 접수. 에이전트가 그 자리에서 처리합니다.
  • 승인 요청까지만: 환불, 주문 취소. request_refund는 승인대기 기록만 만들고, 실행은 사람이 decide로 합니다.
  • 상담원에게: 도구로 처리할 수 없는 일은 create_ticket으로 접수합니다.
  • 출고 여부, 기간, 재고는 모델이 아니라 도구 안의 코드가 판정합니다. 통과하지 못하면 ok=False와 이유를 돌려줍니다.
  • 날짜는 실습 기준일 TODAY(2026년 7월 27일) 로 셉니다.
  • 처리 기록은 memory_store/actions.json 에 쌓이고, 처리번호와 고객 ID는 코드가 넣습니다.
← 이전 절읽는 에이전트와 처리하는 에이전트 — 위험한 도구를 쥔 쪽을 하나로 좁힌다다음 절 →검색을 도구로 만든다 — 언제, 몇 번 찾을지를 에이전트가 정한다
오명운 · macro@prag-ai.com