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는 코드가 넣습니다.