실무 Multi-Agent 오케스트레이션 22장 · 오케스트레이션 8 / 8 ← 이전목차다음 → TechLead Cro

22장. 오케스트레이션

실습문제와 해답

Supervisor를 직접 불러 보며 22장 내용을 손에 익힙니다. 표준 질문 12개 가운데 세 개를 골랐습니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다. 세 문제 모두 haru_supervisor.py를 고치지 않고 가져다 씁니다.


문제 1 — 한 문의에 세 에이전트 (표준 질문 Q6)

고객이 묻고 나서 요청하는 대신, 한 문장으로 요청하면 어떻게 될까요. 새 파일 ex22_three_agents.py를 만들어 아래 문의를 한 번 보내고, 세 가지를 출력하세요.

"지난주에 산 운동화가 불량이에요. 검정 270으로 교환 접수해 주세요."

  1. 위임 경로 — 어떤 전문 에이전트가 어떤 순서로 불렸는가
  2. 코드가 붙인 줄 — 요청처리 에이전트의 보고 끝에 붙은 [시스템 확인] 줄
  3. 처리 기록 — memory_store/actions.json에 남은 것
  • 힌트: run_supervisor가 돌려주는 이력에서 tool_calls가 있는 메시지를 찾으면 위임 기록이 나옵니다. 각 항목의 ["name"]이 위임 도구의 이름입니다.
  • 힌트: 위임 도구가 돌려준 보고는 이력 안에 ToolMessage로 들어 있습니다. msg.__class__.__name__ == "ToolMessage"로 고르고 msg.content에서 [시스템 확인]을 찾습니다.
  • 따라하기에서 이 운동화 주문의 교환을 이미 접수했습니다. 첫 줄에서 처리 기록을 지우고 시작합니다.
해답 보기
from haru_actions import ACTIONS_PATH, load_actions
from haru_supervisor import build_supervisor, run_supervisor
from haru_tools import CustomerSession

ACTIONS_PATH.unlink(missing_ok=True)       # 처리 기록을 비우고 시작한다 (A00001 부터)
supervisor = build_supervisor(CustomerSession("C003"))

QUESTION = "지난주에 산 운동화가 불량이에요. 검정 270으로 교환 접수해 주세요."
SHORT = {"ask_order_agent": "주문조회", "ask_policy_agent": "정책안내",
         "ask_action_agent": "요청처리"}

print(f"고객: {QUESTION}")
answer, messages = run_supervisor(supervisor, QUESTION)
print(f"하루: {answer}")
print("-" * 60)

# 이력에서 위임 기록(도구 호출)을 순서대로 모은다
path = [SHORT.get(tc["name"], tc["name"])
        for msg in messages
        for tc in (getattr(msg, "tool_calls", None) or [])]
print(f"위임 경로: {' → '.join(path)}")

# 요청처리 에이전트의 보고 끝에 코드가 붙인 줄을 찾는다
for msg in messages:
    if msg.__class__.__name__ == "ToolMessage" and "[시스템 확인]" in msg.content:
        print("코드가 붙인 줄:", msg.content[msg.content.index("[시스템 확인]"):])

for a in load_actions():
    print(f"처리 기록: {a['action_id']} · {a['type']} · {a['status']} · {a['detail']}")
$ python ex22_three_agents.py

실행 결과 (문장은 실행마다 다릅니다)

고객: 지난주에 산 운동화가 불량이에요. 검정 270으로 교환 접수해 주세요.
  [위임 → 주문조회] 고객의 최근 주문 중 운동화 주문과 고객 멤버십 등급 확인
  [위임 → 정책안내] VIP 등급의 상품 불량 교환 시 왕복 배송비 정책
  [위임 → 요청처리] 주문 HR20260721001의 쿠션 운동화(화이트/250)를 상품 불량 사유로 검정 270 사이즈로 교환 접수
하루: 주문하신 쿠션 운동화(화이트/250)에 대해 상품 불량 사유로 검정 270 사이즈 교환 접수가 완료되었습니다.
상품 불량에 의한 교환이므로 배송비는 무료이며(근거: 반품교환환불정책 제4조), 처리번호는 A00001입니다.
회수 기사가 방문하여 기존 상품을 수거한 후 새 상품을 발송해 드리겠습니다.
------------------------------------------------------------
위임 경로: 주문조회 → 정책안내 → 요청처리
코드가 붙인 줄: [시스템 확인] 처리번호 A00001 · 교환접수 · 상태: 완료
처리 기록: A00001 · 교환접수 · 완료 · 쿠션 운동화 화이트/250 → 검정/270 (상품 불량)

해설 — 한 문의에 세 에이전트가 차례로 움직였습니다.

순서 위임 Supervisor가 요청문에 담은 것 어디서 왔나
1 주문조회 "운동화 주문과 고객 멤버십 등급" 고객의 말 + 운영 원칙 9번(등급을 먼저 확인)
2 정책안내 "VIP 등급의 상품 불량 교환 시 왕복 배송비 정책" 1번 보고의 등급
3 요청처리 "주문 HR20260721001의 쿠션 운동화(화이트/250)를 상품 불량 사유로 검정 270 사이즈로 교환 접수" 1번 보고의 주문번호와 옵션

2번과 3번 요청문의 굵은 부분은 고객이 말한 적이 없습니다. 앞의 에이전트가 알아낸 것을 Supervisor가 뒤의 요청문에 옮겨 적었습니다. 21장의 실습문제에서 우리가 f"[확인된 주문] {order_report}…"로 직접 이어 준 일을 Supervisor가 했습니다.

답변 한 덩어리에 세 보고가 들어 있습니다. 상품과 옵션(주문조회), 배송비와 근거 조항(정책안내), 처리번호(요청처리)입니다.

처리번호를 따라가 봅니다. 답변의 A00001, 코드가 붙인 줄의 A00001, 기록 파일의 A00001이 같습니다. 기록의 사유가 "상품 불량"이므로 처리 도구는 불량에 해당하는 기간으로 판정했습니다. 요청문에 사유를 적은 것은 Supervisor이고, 그 사유로 기간을 고르고 판정한 것은 코드입니다.

위임 경로는 실행마다 달라질 수 있습니다. 이 교재를 준비하며 이 문의를 열한 번 보냈습니다. 아홉 번은 위와 같이 세 에이전트가 모두 움직였고, 두 번은 정책안내 없이 주문조회 → 요청처리로 끝났습니다. 고객이 배송비를 묻지 않았으므로 규정 확인을 건너뛴 것입니다. 달라지지 않은 것은 처리입니다. 열한 번 모두 A00001 · 교환접수 · 완료 한 건이 기록됐습니다.


문제 2 — 말하지 않은 등급을 확인하는가 (표준 질문 Q5)

운영 원칙 9번은 "등급에 따라 달라지는 내용은 고객이 등급을 말하지 않았더라도 등급을 먼저 확인한다"입니다. 아래 문의를 세 번 보내고, 매번 위임 경로와 답변에 들어 있어야 할 것을 한 줄씩 출력하는 파일 ex22_grade.py를 만드세요.

"단순 변심으로 반품하면 배송비 내야 해요?"

  • build_supervisor(session, verbose=False)로 만들면 [위임 →] 줄이 출력되지 않습니다.
  • 답변에 "VIP", "면제", "근거"가 들어 있는지, 그리고 요청처리 에이전트를 불렀는지 확인합니다.
  • 실행하기 전에 짐작해 보세요. 고객은 등급도 주문도 말하지 않았습니다. Supervisor는 누구를 먼저 부를까요? 묻기만 한 이 문의에 처리 기록이 생길까요?
해답 보기
from haru_supervisor import build_supervisor, run_supervisor
from haru_tools import CustomerSession

supervisor = build_supervisor(CustomerSession("C003"), verbose=False)

QUESTION = "단순 변심으로 반품하면 배송비 내야 해요?"
SHORT = {"ask_order_agent": "주문조회", "ask_policy_agent": "정책안내",
         "ask_action_agent": "요청처리"}

for i in range(1, 4):
    answer, messages = run_supervisor(supervisor, QUESTION)
    path = [SHORT.get(tc["name"], tc["name"])
            for msg in messages
            for tc in (getattr(msg, "tool_calls", None) or [])]
    print(f"{i}회: 위임 {len(path)}번  {' → '.join(path)}")
    print(f"     등급(VIP): {'있음' if 'VIP' in answer else '없음'}"
          f" / 월 1회 면제: {'있음' if '면제' in answer else '없음'}"
          f" / 근거 표기: {'있음' if '근거' in answer else '없음'}"
          f" / 처리: {'했음' if '요청처리' in path else '하지 않음'}")
    if i == 1:
        print(f"     하루: {answer}")
$ python ex22_grade.py

실행 결과 (문장은 실행마다 다릅니다)

1회: 위임 2번  주문조회 → 정책안내
     등급(VIP): 있음 / 월 1회 면제: 있음 / 근거 표기: 있음 / 처리: 하지 않음
     하루: VIP 등급이신 고객님의 단순 변심 반품 배송비는 월 1회에 한하여 면제되며, 월 2회째부터는 편도 3,000원이 부과됩니다(근거: 하루클럽 멤버십 정책 제2조). 

반품을 원하시면 주문하신 상품명을 알려주세요.
2회: 위임 2번  주문조회 → 정책안내
     등급(VIP): 있음 / 월 1회 면제: 있음 / 근거 표기: 있음 / 처리: 하지 않음
3회: 위임 2번  주문조회 → 정책안내
     등급(VIP): 있음 / 월 1회 면제: 있음 / 근거 표기: 있음 / 처리: 하지 않음

해설 — 세 번 모두 주문조회 → 정책안내 순서였습니다.

고객은 등급을 말하지 않았습니다. 그런데 답변은 "VIP 등급이신 고객님"으로 시작하고, 기본 규정(편도 3,000원)이 아니라 VIP의 규정(월 1회 면제) 을 먼저 말합니다. Supervisor가 주문조회 에이전트로 등급을 확인하고, 그 등급을 넣어 정책안내 에이전트에게 물었기 때문입니다. 정책안내 에이전트는 고객이 누구인지 모르므로, 요청문에 "VIP"가 없었다면 기본 규정만 돌아왔을 것입니다.

둘째 줄이 이 파일의 요점입니다. 경로가 아니라 지켜야 할 것을 하나씩 확인합니다.

확인한 것 어느 원칙인가 세 번의 결과
확인한 등급을 답에 밝혔다 원칙 9 (등급을 먼저 확인) 세 번 모두 있음
그 등급의 규정(월 1회 면제)으로 답했다 원칙 9 세 번 모두 있음
근거 표기가 답에 있다 원칙 9 (근거 표기 유지) 세 번 모두 있음
처리하지 않았다 원칙 6 (묻기만 한 문의) 세 번 모두 하지 않음

멀티에이전트의 결과는 "경로가 예상과 같은가"가 아니라 "지켜야 할 것이 지켜졌는가" 로 확인합니다. 경로는 실행마다 달라져도 되지만, 등급과 금액과 근거와 처리 여부는 달라지면 안 됩니다. 이 파일의 둘째 줄이 그 확인을 코드로 옮긴 것이고, 24장의 최종 검수도 같은 방식으로 합니다.

이 교재를 준비하며 이 파일을 두 번 실행했고(문의로는 여섯 번), 여섯 번 모두 결과가 이 표와 같았습니다.


문제 3 — 조건을 채우지 못한 요청, 그리고 상담원 (표준 질문 Q8)

요청이라고 해서 전부 처리되는 것은 아닙니다. 조건을 채우지 못한 요청을 Supervisor가 어떻게 다루는지 두 턴 대화로 확인하는 파일 ex22_handover.py를 만드세요.

  1. "6월 말에 주문한 텀블러 2개 반품하고 싶어요. 접수해 주세요."
  2. "그럼 상담원이랑 통화하고 싶어요."
  • 턴마다 처리 기록과 티켓이 몇 건 늘었는지 출력합니다. 티켓 파일의 위치는 haru_tools의 TICKETS_PATH에 있습니다.
  • 턴마다 요청처리 에이전트의 보고 끝에 코드가 붙인 [시스템 확인] 줄도 출력합니다. 이번 턴에 새로 쌓인 메시지만 보려면, 턴을 시작하기 전의 이력 길이를 기억해 둡니다.
  • 실행하기 전에 짐작해 보세요. 이 텀블러는 7월 1일에 배송 완료됐고 실습 기준일은 7월 27일입니다. 첫 턴에 처리 기록이 생길까요? 티켓은요?
해답 보기
import json

from haru_actions import load_actions
from haru_supervisor import build_supervisor, run_supervisor
from haru_tools import TICKETS_PATH, CustomerSession


def load_tickets() -> list[dict]:
    if TICKETS_PATH.exists():
        return json.loads(TICKETS_PATH.read_text(encoding="utf-8"))
    return []


supervisor = build_supervisor(CustomerSession("C003"))

history = []
for q in ["6월 말에 주문한 텀블러 2개 반품하고 싶어요. 접수해 주세요.",
          "그럼 상담원이랑 통화하고 싶어요."]:
    print("=" * 60)
    print(f"고객: {q}")
    seen = len(history)                     # 이번 턴에 새로 쌓인 메시지만 보려고 기억해 둔다
    n_actions, n_tickets = len(load_actions()), len(load_tickets())
    answer, history = run_supervisor(supervisor, q, history)
    print(f"하루: {answer}")
    for msg in history[seen:]:              # 요청처리 에이전트의 보고 끝에 코드가 붙인 줄
        if msg.__class__.__name__ == "ToolMessage" and "[시스템 확인]" in msg.content:
            print("  코드가 붙인 줄:", msg.content[msg.content.index("[시스템 확인]"):])
    print(f"  (이번 턴에 생긴 처리 기록: {len(load_actions()) - n_actions}건"
          f" / 티켓: {len(load_tickets()) - n_tickets}건)")

last = load_tickets()[-1]
print("=" * 60)
print(f"파일의 마지막 티켓: {last['ticket_id']} / {last['category']} / {last['summary']}")
$ python ex22_handover.py

실행 결과 (문장은 실행마다 다릅니다. 티켓번호는 지금까지 쌓인 티켓 수에 따라 달라집니다)

============================================================
고객: 6월 말에 주문한 텀블러 2개 반품하고 싶어요. 접수해 주세요.
  [위임 → 주문조회] 고객의 최근 주문 중 6월 말에 주문한 텀블러 2개 주문 정보와 고객의 멤버십 등급
  [위임 → 정책안내] VIP 등급의 단순 변심 반품 배송비 및 반품 정책
  [위임 → 요청처리] 주문 HR20260628003 (스테인리스 텀블러 500ml 2개) 단순 변심 반품 접수 승인 요청
하루: 고객님은 VIP 등급이시나, 해당 상품(스테인리스 텀블러 500ml 2개)은 배송 완료 후 반품 가능 기간인 7일을 경과하여 반품 접수가 불가합니다. (근거: 반품교환환불정책 제2조) 다른 문의가 있으시면 언제든 말씀해 주세요.
  코드가 붙인 줄: [시스템 확인] 새로 처리되거나 접수된 것은 없음
  (이번 턴에 생긴 처리 기록: 0건 / 티켓: 0건)
============================================================
고객: 그럼 상담원이랑 통화하고 싶어요.
  [위임 → 요청처리] 주문 HR20260628003 텀블러 반품 불가 건 관련 상담원 연결(티켓 접수)
하루: 주문하신 스테인리스 텀블러 500ml 2개 반품 건과 관련하여 상담원 연결을 위한 티켓(번호: T00007)이 정상적으로 접수되었습니다. 담당 상담원이 확인 후 빠른 시일 내에 연락드리겠습니다.
  코드가 붙인 줄: [시스템 확인] 티켓번호 T00007 · 상담원 접수
  (이번 턴에 생긴 처리 기록: 0건 / 티켓: 1건)
============================================================
파일의 마지막 티켓: T00007 / 상담원연결 / 주문 HR20260628003 텀블러 반품 불가 건에 대한 상담원 연결 요청

해설 — 세 가지를 봅니다.

첫째, 첫 턴에는 아무것도 생기지 않았습니다. 고객은 "접수해 주세요"라고 요청했고, Supervisor는 원칙 6번대로 주문을 확인한 뒤 요청처리 에이전트에게 맡겼습니다. 그러나 이 주문은 배송 완료 후 26일이 지났고, 처리 도구 안의 코드는 기간(7일)을 넘긴 반품을 받아 주지 않습니다(21장 실습문제 2에서 같은 주문으로 확인한 판정입니다). 처리 기록 0건, 티켓 0건입니다. Supervisor는 "접수했습니다"라고 하지 않고 기간이 지났다는 이유를 전했습니다.

둘째, 코드가 붙인 줄이 두 턴에서 다릅니다.

턴 코드가 붙인 줄 뜻
1 [시스템 확인] 새로 처리되거나 접수된 것은 없음 요청처리 에이전트를 불렀지만 기록 파일은 그대로다
2 [시스템 확인] 티켓번호 T00007 · 상담원 접수 티켓 파일에 한 건이 새로 생겼다

첫 턴의 줄이 중요합니다. 요청처리 에이전트의 보고가 어떤 문장이었든, Supervisor는 "새로 처리된 것이 없다"는 사실을 코드에게서 받았습니다. 처리하지 못한 요청이 처리된 것으로 전해질 틈이 없습니다.

셋째, 둘째 턴에는 곧바로 상담원에게 넘겼습니다. "상담원이랑 통화하고 싶어요"는 처리 도구로 할 수 없는 일입니다. 주문조회도 정책안내도 다시 부르지 않고 요청처리 에이전트만 불렀고, 요청문에는 고객이 이번 턴에 말하지 않은 주문번호 HR20260628003과 "반품 불가 건" 이 들어 있습니다. 이력을 가진 Supervisor가 앞 턴의 상황을 풀어서 넘겼고(운영 원칙 4번), 그 내용이 티켓의 summary에 남았습니다. 상담원은 이 티켓만 읽고도 무슨 일인지 알 수 있습니다.

답변의 티켓번호, 코드가 붙인 줄의 티켓번호, 파일의 마지막 티켓번호가 같은 것도 확인하세요.

기계가 처리할 수 있는 일(문제 1)은 그 자리에서 끝났고, 조건을 채우지 못한 일은 이유와 함께 멈췄고, 사람이 필요한 일은 사람에게 넘어갔습니다. 이 교재를 준비하며 이 대화를 여덟 번 실행했고, 여덟 번 모두 첫 턴은 처리 기록 0건·티켓 0건, 둘째 턴은 티켓 1건이었습니다.


핵심 정리

  • 한 문장의 요청에 주문조회 → 정책안내 → 요청처리가 차례로 움직입니다. 앞의 보고에서 나온 주문번호와 등급이 뒤의 요청문에 담깁니다.
  • 고객이 말하지 않은 등급도 먼저 확인해 그 등급의 규정으로 답합니다.
  • 결과는 경로가 아니라 지켜야 할 것(등급, 금액, 근거, 처리 여부, 기록)이 지켜졌는가로 확인합니다.
  • 조건을 채우지 못한 요청은 처리하지 않고 이유를 전합니다. 코드가 붙인 줄이 "새로 처리된 것 없음"을 알려 줍니다.
  • 사람이 필요한 일은 상담원에게 넘기고, 티켓에는 Supervisor가 이력에서 찾은 상황이 담깁니다.
  • 처리번호와 티켓번호는 코드가 파일에서 읽어 붙인 값이라 파일의 번호와 같습니다.
← 이전 절정리와 체크리스트23장 →왜 사람이 끼어들어야 하는가 — 되돌릴 수 없는 일 앞에서
오명운 · macro@prag-ai.com