실무 Multi-Agent 오케스트레이션 19장 · 조건 분기와 라우팅 5 / 7 ← 이전목차다음 → TechLead Cro

19장. 조건 분기와 라우팅

따라하기 — 네 갈래 상담 그래프

목표

문의 유형에 따라 주문·정책·상담원·일반 네 갈래로 갈라지는 그래프를 만들고, 네 가지 문의를 넣어 각각 어느 길로 갔는지 로그로 확인합니다.


0. 실습 준비

이 장의 실습 고객 — C010 장시우 고객(일반 등급)으로 로그인한 상태라고 정해 두고 실습합니다.

VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.

$ conda activate myenv

이번 장은 새로 설치할 것이 없습니다. 대신 앞 장에서 만든 것 네 가지가 폴더에 있어야 합니다.

있어야 하는 것 만든 장 이번 장에서 쓰는 곳
lesson04_intent_classifier.py 4장 from lesson04_intent_classifier import classify
haru_tools.py 8장 주문·상품·티켓 도구
chroma_db/ 폴더 13장 정책 문서 검색
lesson14_rag_retriever.py 14장 검색·근거 선택·메타데이터 출처 표기

실행했을 때 아래처럼 나오면 이렇게 합니다.

이렇게 나오면 조치
ModuleNotFoundError: No module named 'lesson04_intent_classifier' (또는 'haru_tools', 'lesson14_rag_retriever') 해당 장으로 돌아가 파일을 만듭니다
[준비 필요] chroma_db/ 폴더가 없습니다. 13장 lesson13_rag_embedding.py 를 먼저 실행하세요. python lesson13_rag_embedding.py를 실행합니다

1. 파일 만들기

haru-market 폴더 맨 위에 새 파일을 만듭니다.

lesson19_router.py

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

# -*- coding: utf-8 -*-
"""[19장] 조건 분기와 라우터 노드 — 문의 유형별로 길을 나눈다

18장의 그래프는 길이 하나였다. 실제 상담은 유형마다 가야 할 길이 다르다.
  주문배송조회 → 주문 도구를 쓰는 노드
  환불교환     → 정책 RAG 노드
  상담원연결   → 티켓 노드
  나머지       → 일반 안내 노드

핵심 개념: add_conditional_edges(분기 기준 함수가 '다음 노드 이름'을 반환)

실행:  python lesson19_router.py  (13장 chroma_db 선행 필요)

temperature 는 넣지 않는다. Gemini 3 계열은 기본값을 그대로 쓰라는 것이 공식 권장이다.
"""
from typing import TypedDict

from google.genai import types
from langgraph.graph import END, START, StateGraph

from config import MODEL, get_client
from haru_tools import CustomerSession, make_tools
from lesson04_intent_classifier import classify
from lesson14_rag_retriever import answer_policy_question

client = get_client()
session = CustomerSession("C010")
tools = make_tools(session)

class CSState(TypedDict):
    question: str
    intent: str
    answer: str
    log: list[str]


# ── 라우터: 분류 노드 ─────────────────────────────────────────────────
def classify_node(state: CSState) -> dict:
    result = classify(state["question"])
    return {"intent": result.intent.value,
            "log": state["log"] + [f"분류 → {result.intent.value}"]}


def route(state: CSState) -> str:
    """분기 기준 함수 — 상태를 보고 '다음 노드 이름'을 돌려준다."""
    return {
        "주문배송조회": "order",
        "환불교환": "policy",
        "멤버십적립금": "policy",  # 멤버십 규정도 정책 문서(RAG)에 답이 있다
        "상담원연결": "human",
        "계정결제": "human",       # 계정·결제도 사람 처리로 (수업 단순화)
    }.get(state["intent"], "general")


# ── 각 갈래의 처리 노드 ───────────────────────────────────────────────
def order_node(state: CSState) -> dict:
    """주문 도구를 쓰는 갈래 (6~8장 도구 재사용)."""
    r = client.models.generate_content(
        model=MODEL, contents=state["question"],
        config=types.GenerateContentConfig(
            system_instruction="하루마켓 상담원. 도구로 조회한 데이터로만 답변. "
                               "주문번호·상품명·옵션·상태는 조회 결과의 값을 "
                               "그대로 옮긴다. 존댓말.",
            tools=[tools["get_my_orders"], tools["get_order_status"]],
            automatic_function_calling=types.AutomaticFunctionCallingConfig(
                maximum_remote_calls=4),
        ),
    )
    return {"answer": r.text, "log": state["log"] + ["주문 노드 처리"]}


def policy_node(state: CSState) -> dict:
    """14장의 검색·근거 선택·메타데이터 출처 표기를 그대로 재사용한다."""
    answer = answer_policy_question(state["question"])
    return {"answer": answer, "log": state["log"] + ["정책 RAG 노드 처리"]}

def human_node(state: CSState) -> dict:
    """상담원 연결 갈래 — 티켓 생성."""
    t = tools["create_ticket"](category=state["intent"],
                               summary=state["question"][:80], urgency="보통")
    return {"answer": f"상담원 연결이 필요한 문의로 접수했습니다. "
                      f"접수번호는 {t['ticket_id']} 입니다. 순차적으로 연락드릴게요.",
            "log": state["log"] + [f"티켓 발행 {t['ticket_id']}"]}


def general_node(state: CSState) -> dict:
    r = client.models.generate_content(
        model=MODEL, contents=state["question"],
        config=types.GenerateContentConfig(
            system_instruction="하루마켓 상담원. 도구로 조회한 상품 정보로만 답변. "
                               "상품 설명에 없는 성능·기능은 설명에 없다고만 답하고 "
                               "추측이나 권장을 덧붙이지 않는다. "
                               "하루마켓 쇼핑과 무관한 질문에는 답하지 않고 "
                               "쇼핑 문의를 도와드린다고 안내. 존댓말 3문장.",
            tools=[tools["search_products"], tools["check_stock"]]),
    )
    return {"answer": r.text, "log": state["log"] + ["일반 노드 처리"]}


# ── 그래프 조립 ──────────────────────────────────────────────────────
builder = StateGraph(CSState)
builder.add_node("classify", classify_node)
builder.add_node("order", order_node)
builder.add_node("policy", policy_node)
builder.add_node("human", human_node)
builder.add_node("general", general_node)

builder.add_edge(START, "classify")
# 조건부 엣지: route() 의 반환값이 다음 노드가 된다
builder.add_conditional_edges("classify", route,
                              ["order", "policy", "human", "general"])
for node in ("order", "policy", "human", "general"):
    builder.add_edge(node, END)

graph = builder.compile()

if __name__ == "__main__":
    for q in ["제 최근 주문 지금 어디예요?",
              "단순 변심 반품이면 배송비 얼마예요?",
              "사람이랑 통화하고 싶어요.",
              "텀블러 식기세척기 돌려도 되나요?"]:
        result = graph.invoke({"question": q, "intent": "", "answer": "", "log": []})
        print(f"\n고객: {q}")
        print(f"  경로: {' → '.join(result['log'])}")
        print(f"하루: {result['answer']}")

    print("""
──────────────────────────────────────────────────────────
분기 설계 포인트
  - 분기 '기준'은 LLM(분류기), 분기 '실행'은 코드(route 함수) — 역할 분리
  - 각 갈래는 자기 일에 필요한 도구만 가진다 (토큰 절약 + 오호출 방지)
    → 이 아이디어를 밀고 나가면 21장 '전문 에이전트 분리'가 된다
──────────────────────────────────────────────────────────""")

2. 코드에서 볼 곳

파일은 위에서 아래로 네 덩어리입니다.

덩어리 내용 볼 것
준비 도구, 정책 검색, 상태 CSState 4·8·14장의 부품과 13장의 저장소를 불러온다
라우터 classify_node, route 판단은 모델, 길 안내는 코드
갈래 노드 order_node 등 네 개 노드마다 도구가 다르다
조립 add_conditional_edges 갈림길

새로 나온 문법은 조립 부분의 한 줄입니다.

builder.add_edge(START, "classify")
# 조건부 엣지: route() 의 반환값이 다음 노드가 된다
builder.add_conditional_edges("classify", route,
                              ["order", "policy", "human", "general"])
for node in ("order", "policy", "human", "general"):
    builder.add_edge(node, END)

두 가지를 더 봅니다.

  • 로그인한 고객은 C010입니다. 파일 위쪽의 session = CustomerSession("C010")이 "지금 누구의 주문을 볼 수 있는가"를 정합니다(8장).
  • temperature를 넣지 않았습니다. Gemini 3 계열 모델은 temperature를 기본값(1.0) 그대로 두라는 것이 공식 문서의 권장입니다(2026년 10월 기준, https://ai.google.dev/gemini-api/docs/gemini-3). 그래서 이 장에서 새로 쓰는 호출에는 넣지 않습니다. 재사용하는 14장 함수의 설정은 그대로 유지됩니다.

3. 실행하기

실행하기 전에 짐작해 보세요. 앞 절의 라우팅 표를 보고, 아래 네 문의가 각각 어느 노드로 갈지 적어 둡니다.

  1. "제 최근 주문 지금 어디예요?"
  2. "단순 변심 반품이면 배송비 얼마예요?"
  3. "사람이랑 통화하고 싶어요."
  4. "텀블러 식기세척기 돌려도 되나요?"
$ python lesson19_router.py

분류·도구 선택·답변 생성을 위해 LLM을 여러 번 호출합니다. 실행 시간은 모델과 API 응답 상황에 따라 달라집니다.

실행 결과 (문장은 실행할 때마다 달라집니다. 출처를 끝까지 확인하도록 답변 전체를 출력합니다)

고객: 제 최근 주문 지금 어디예요?
  경로: 분류 → 주문배송조회 → 주문 노드 처리
하루: 고객님의 가장 최근 주문은 2026-07-19 13:29에 주문하신 [주문번호: HR20260719039] '경량 후드 집업' (옵션: 그레이/L)이며, 현재 **취소** 상태입니다.

배송 중인 주문을 확인하시려면 2026-07-08에 주문하신 [주문번호: HR20260708076] '호텔식 순면 이불커버 Q' (옵션: 그레이/Q)를 참고해 주시기 바랍니다. 이 주문은 현재 **배송중**입니다.
  [검색] 4개 청크:
    - [제5조 (환불 처리 기간), 제6조 (환불 금액 산정), 제7조 (반품·교환 불가 사유)] 계좌이체·무통장 계좌 환급 검수 후 3일 이내 하루포인트·쿠폰 즉시 복원 검수 후 1일 이내 간편결...
    - [제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ)] 서 제외된다. 제9조 (분쟁 처리) 반품·교환·환불에 관하여 회사와 고객 간 분쟁이 발생한 경우, ...
    - [제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간)] 행된다. 제4조 (교환) ① 동일 상품의 다른 색상·사이즈 교환은 수령일로부터 7일 이내 신청 가능...
    - [제1조 (용어의 정의), 제2조 (반품 사유별 기준), 제3조 (반품 신청 절차), 제4조 (교환)] "환불 신청"은 법률상 청약철회에 해당한다. 제2조 (반품 사유별 기준) [표 1] 사유별 신청 기...

고객: 단순 변심 반품이면 배송비 얼마예요?
  경로: 분류 → 환불교환 → 정책 RAG 노드 처리
하루: 단순 변심으로 인한 반품 시 편도 배송비 3,000원을 고객이 부담합니다.

(근거: 반품교환환불정책 · 제5조 (환불 처리 기간), 제6조 (환불 금액 산정), 제7조 (반품·교환 불가 사유))
(근거: 반품교환환불정책 · 제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ))
(근거: 반품교환환불정책 · 제1조 (용어의 정의), 제2조 (반품 사유별 기준), 제3조 (반품 신청 절차), 제4조 (교환))

고객: 사람이랑 통화하고 싶어요.
  경로: 분류 → 상담원연결 → 티켓 발행 T00006
하루: 상담원 연결이 필요한 문의로 접수했습니다. 접수번호는 T00006 입니다. 순차적으로 연락드릴게요.

고객: 텀블러 식기세척기 돌려도 되나요?
  경로: 분류 → 제품문의 → 일반 노드 처리
하루: 상품 설명에는 12시간 보온·보냉 기능과 세척 편한 와이드 입구라고만 기재되어 있습니다.
식기세척기 사용 가능 여부에 대한 내용은 상품 설명에 나와 있지 않습니다.
다른 쇼핑 관련 문의가 있으시면 언제든 말씀해 주세요.
(이하 생략)

접수번호는 지금까지 만든 티켓 수에 따라 다르게 나옵니다.


4. 무엇을 관찰했나

그래프 그림 — 점선이 네 개

19장의 네 갈래 그래프

classify에서 나가는 화살표 넷이 모두 점선입니다. 18장의 그래프에는 실선만 있었습니다. 점선은 "넷 중 하나로 간다"는 뜻입니다.

네 문의의 경로 — 짐작과 맞았나

문의 분류 간 노드 라우팅 표와 일치
최근 주문 어디예요 주문배송조회 order 일치
단순 변심 반품 배송비 환불교환 policy 일치
사람이랑 통화 상담원연결 human 일치
텀블러 식기세척기 제품문의 general 일치 (표에 없는 유형 → 기본 경로)

이 교재를 준비하며 여러 번 실행했을 때 네 경로는 매번 같았습니다. 분류 결과가 같으면 길은 코드가 정하므로 흔들릴 수 없습니다. 문장이 달라지는 것은 그다음, 도착한 노드 안에서입니다.

정책 갈래 — 같은 질문에 근거가 붙었다

"단순 변심 반품이면 배송비 얼마예요?"에 편도 3,000원이라고 답했습니다. 출처는 모델이 선택한 발췌의 doc_title · article을 코드가 그대로 붙였습니다. 여러 발췌를 선택하면 출처도 여러 줄이 됩니다.

정책 노드는 수정된 14장의 answer_policy_question()을 직접 재사용합니다. 검색(k=4), 근거 번호 검증, 출처 표기, 근거가 없을 때의 안내가 모두 같은 함수를 거칩니다. article에는 청크의 조항들이 모두 들어 있으므로 실제 사용한 조항보다 넓게 표시될 수 있습니다.

[검색] 로그는 그래프 실행 도중 출력되므로, 그래프가 끝난 뒤 출력하는 해당 문의와 경로보다 먼저 나옵니다.

18장의 그래프도 같은 질문을 받았습니다. 그때는 코드에 직접 적어 둔 발췌 두 줄이 근거였습니다. 이번에는 정책 문서를 검색해 찾은 발췌가 근거이고, 답에 조항이 붙었습니다. 흐름을 뜯어고친 것이 아니라 이 유형의 문의가 도착하는 노드를 바꿨습니다. 그래프로 만들어 두면 고칠 곳이 이렇게 좁아집니다.

상담원 갈래 — LLM 없이 끝났다

로그가 분류 → 상담원연결 → 티켓 발행 T00006입니다. 답변 문장은 몇 번을 실행해도 접수번호만 빼고 글자까지 같습니다. 모델이 쓴 문장이 아니라 코드에 적어 둔 문장이기 때문입니다.

주문 갈래 — 조회한 값이 그대로 답에 실렸다

모델은 get_my_orders로 C010의 주문 목록을 받아 "가장 최근 주문은 취소되었고, 그 앞 주문은 배송 중"이라고 답했습니다. 답에 나온 값을 주문 데이터와 견주어 봅니다.

답에 나온 값 data/orders.csv의 값
주문번호 HR20260719039 HR20260719039
경량 후드 집업, 옵션 그레이/L 경량 후드 집업, 그레이/L
상태 취소 취소
그 전 주문 HR20260708076, 호텔식 순면 이불커버 Q HR20260708076, 호텔식 순면 이불커버 Q (배송중)

주문번호, 상품명, 옵션, 상태가 모두 데이터와 같습니다. 주문 노드의 지시 두 줄, "도구로 조회한 데이터로만 답변"과 "조회 결과의 값을 그대로 옮긴다"가 한 일입니다. 고객은 주문번호를 말하지 않았습니다. 어느 주문인지 찾은 것은 도구이고, 그 도구를 부르기로 한 것은 모델입니다.

일반 갈래 — 상품 설명에 있는 만큼만 답했다

경로는 제품문의 → 일반 노드입니다. 표에 없는 유형이라 기본 경로로 왔습니다. 이 노드는 search_products로 텀블러를 찾았습니다. 상품 데이터에 적힌 설명은 이것이 전부입니다.

12시간 보온·보냉. 세척 편한 와이드 입구.

식기세척기 이야기는 설명에 없습니다. 그래서 답도 "상품 설명에는 식기세척기 사용 가능 여부에 대한 내용이 기재되어 있지 않습니다" 였습니다. 된다고도 안 된다고도 하지 않았습니다. 시스템 프롬프트의 "상품 설명에 없는 성능·기능은 설명에 없다고만 답하고"가 그대로 지켜졌습니다. 이 교재를 준비하며 세 번 실행했을 때 세 번 모두 같은 내용이었습니다.

라우터는 문의를 알맞은 노드에 데려다 놓고, 도착한 노드는 자기에게 주어진 근거 안에서 답합니다. 길과 근거를 따로 정해 두었기 때문에, 답을 볼 때도 둘을 따로 확인할 수 있습니다.


5. 지금 폴더의 모습

haru-market/
├── config.py
├── haru_tools.py                    (8장)
├── …                                (2~17장의 파일)
├── lesson18_langgraph_basics.py
├── lesson14_rag_retriever.py       (정책 답변과 출처 표기 재사용)
├── lesson19_router.py               ← 이번 장
├── chroma_db/                       (13장이 만든 정책 문서 검색용 폴더)
└── memory_store/
    └── tickets.json                 (티켓이 쌓이는 파일)

핵심 정리

  • add_conditional_edges 한 줄로 직선 그래프가 네 갈래가 되었습니다.
  • 네 문의는 라우팅 표대로 갈라졌고, 경로는 실행마다 같았습니다.
  • 정책 문의는 근거 있는 노드로 가서 조항이 붙은 답을 받았습니다.
  • 상담원 갈래는 LLM 없이 티켓을 만들고 끝났습니다.
  • 주문 노드의 답에 실린 주문번호·옵션·상태는 주문 데이터와 같았습니다.
  • 일반 노드는 상품 설명에 없는 내용을 "설명에 없다" 고 답했습니다. 노드마다 답의 근거를 하나로 정해 둔 결과입니다.
← 이전 절갈래마다 필요한 도구만 — 네 개의 전담 노드다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com