29차시. Conditional Edge 실습
7. 실습 ⑥ — 경로별 동작 확인
출처: 원본 PDF 29차시_ConditionalEdge실습.pdf 15~16쪽 · 쪽 그림 15쪽 16쪽
📝 src/test_routing.py
from graph import app
from state import init_state
import time
CASES = [
("안녕하세요", "greeting"),
("고맙습니다", "greeting"),
("125 + 340 * 2", "calc"),
("오늘 날씨 어때요?", "scope"),
("환불은 며칠 이내인가요?", "document"),
("휴가 신청은 어떻게 하나요?", "document"),
]
for q, expected in CASES:
t0 = time.time()
nodes = []
for step in app.stream(init_state(q), {"recursion_limit": 25}):
nodes.extend(step.keys())
sec = time.time() - t0
final = app.invoke(init_state(q), {"recursion_limit": 25})
mark = "✓" if final.get("intent") == expected else "✗"
print(f"\n{mark} Q: {q}")
print(f" 유형: {final.get('intent')} (기대: {expected})")
print(f" 경로: {' -> '.join(nodes)}")
print(f" 시간: {sec:.2f}초")
print(f" A: {final['answer'][:60]}")
실행 결과
─────────────────────────────────────────────
─────────
✓ Q: 안녕하세요
유형: greeting (기대: greeting)
경로: classify -> greeting
시간: 0.01초 ← LLM 호출 0회
A: 안녕하세요! 사내 문서에 관한 질문을 도와드립니다...
✓ Q: 125 + 340 * 2
유형: calc (기대: calc)
경로: classify -> calc
시간: 0.01초
A: 125 + 340 * 2 = 805
✓ Q: 오늘 날씨 어때요?
유형: scope (기대: scope)
경로: classify -> scope
시간: 0.01초
A: 죄송합니다. 저는 사내 문서에 관한 질문에만...
✓ Q: 환불은 며칠 이내인가요?
유형: document (기대: document)
경로: classify -> retrieve -> generate -> verify
시간: 4.12초 ← 정상 RAG 경로
A: 환불은 상품 수령 후 7일 이내에 신청하실 수 있습니다[1]...🎯 확인할 것
단축 경로 세 개는 0.01초에 끝났습니다.
LLM을 한 번도 부르지 않았습니다.
27차시 시스템이었다면
세 질문 모두 검색 → fallback으로 4초씩 걸렸을 겁니다.
실습 코드 · 29/test_routing.py
실습 코드: 실습/src/29/test_routing.py · 위의 원본 PDF 29차시 15~16쪽 「7️⃣ 실습 ⑥ — 경로별 동작 확인」과 이어집니다.
# ==============================================================
# 29차시. PPT 29차시_ConditionalEdge실습. 코드 폴더는 src/29
# PPT: 29차시 질문 분류
# 역할: graph29에 질문을 넣어 실제 intent가 예상 intent와 같은지 표시합니다.
# 읽는 키: question
# 쓰는 키: 화면 출력. State는 바꾸지 않고 결과만 읽습니다.
# 그래프:
# classify --> greeting | calc | scope | retrieve --> ...
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.
# return에 적지 않은 키는 이전 값이 남습니다.
# log와 tried_queries는 리스트라서, 새로 return한 항목이 뒤에 이어 붙습니다.
# Retriever: 검색 담당. 질문과 비슷한 문서 조각만 고릅니다. 답 문장은 만들지 않습니다.
# Generator: 고른 문서를 근거로 답 문장을 만듭니다. 문서가 없으면 안내 문장만 돌려줍니다.
# Verifier: 답이 근거와 맞는지 검사하고 grade를 적습니다.
# grade=pass 답을 확정하고 그래프를 끝냅니다.
# grade=retry 같은 문서로 답 문장만 다시 씁니다. retries가 1 늘어납니다.
# grade=research 문서가 부족합니다. 검색어를 바꾼 뒤 다시 찾습니다. rewrites가 늘어납니다.
# grade=giveup 더 진행하지 않고 fallback으로 안내 문장만 돌려줍니다.
# fallback: 실패했을 때 답을 지어내지 않고 안내 문장을 반환하는 노드입니다.
# intent: 질문 종류. greeting 인사, calc 계산, scope 문서 밖, document 문서 검색.
# rewrite: 사용자가 친 question은 유지하고, 검색에 쓰는 query만 바꿉니다.
# safe_node: 노드를 try로 감쌉니다. 오류가 나도 그래프는 멈추지 않고 node_error에 적습니다.
# 기대값과 같으면 체크 표시, 다르면 엑스 표시를 출력합니다.
# 답은 앞에서 60자만 출력합니다.
# ==============================================================
# ============================================================
# Classifier Test
# ============================================================
# 여러 질문을 그래프에 전달하여 분류 결과를 확인합니다.
# 실제 분류 결과와 예상 분류 결과를 비교하여
# 분류 노드가 정상적으로 작동하는지 테스트합니다.
# ============================================================
import os
import sys
# ============================================================
# 모듈 경로 설정
# ============================================================
# 현재 테스트 파일이 있는 폴더입니다.
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
# 현재 폴더의 상위 src 폴더를 찾습니다.
SRC_DIR = os.path.abspath(
os.path.join(CURRENT_DIR, "..")
)
# src 폴더에서 graph29와 State 모듈을 찾을 수 있도록 추가합니다.
sys.path.insert(0, SRC_DIR)
# ============================================================
# 그래프와 초기 State 가져오기
# ============================================================
# 분류 그래프를 가져옵니다.
from graph29 import app
# 질문을 초기 State로 만들어 주는 함수를 가져옵니다.
from graph_state2 import make_initial_state
# ============================================================
# 테스트 질문과 예상 결과
# ============================================================
# 각 질문과 기대하는 분류 결과를 저장합니다.
TEST_CASES = [
("안녕하세요", "greeting"),
("고맙습니다", "greeting"),
("125 + 340 * 2", "calc"),
("오늘 날씨 어때요?", "scope"),
("환불은 며칠 이내인가요?", "document"),
("휴가 신청은 어떻게 하나요?", "document"),
]
# ============================================================
# 테스트 실행 함수
# ============================================================
def run_tests():
"""
테스트 질문을 그래프에 전달하고 결과를 출력합니다.
"""
# 테스트 질문을 하나씩 확인합니다.
for question, expected_intent in TEST_CASES:
# 질문을 초기 State로 변환합니다.
initial_state = make_initial_state(question)
# 그래프를 실행합니다.
result = app.invoke(
initial_state,
{
"recursion_limit": 25,
},
)
# 실제 분류 결과를 가져옵니다.
actual_intent = result.get("intent", "")
# 실제 결과와 예상 결과를 비교합니다.
if actual_intent == expected_intent:
mark = "✓"
else:
mark = "✗"
# 답변이 없어도 오류가 발생하지 않도록 기본값을 사용합니다.
answer = result.get("answer", "")
# 질문별 테스트 결과만 출력합니다.
print()
print(f"{mark} 질문: {question}")
print(f" 실제 분류: {actual_intent}")
print(f" 예상 분류: {expected_intent}")
print(f" 답변: {answer[:60]}")
if __name__ == "__main__":
run_tests()실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
경로: classify → greeting | calc | scope | retrieve → END
예시 출력 (API 키 필요, 실행하지 않음)
테스트 표의 기대 intent는 코드에 적혀 있습니다.
안녕하세요 -> greeting
고맙습니다 -> greeting
125 + 340 * 2 -> calc
오늘 날씨 어때요? -> scope
환불은 며칠 이내인가요? -> document
휴가 신청은 어떻게 하나요? -> document
실행 통과 여부와 답 앞부분은 기록에 없습니다.
기록된 같은 종류:
안녕하세요 intent=greeting grade=pass elapsed=0.01
10 + 20 intent=calc grade=pass elapsed=0.0
오늘 날씨 intent=scope grade=pass
환불은 며칠 이내에 신청해야 하나요? intent=document grade=pass retries=0 rewrites=0 elapsed=6.69코드 해설
graph29에 질문을 넣어 실제 intent가 예상 intent와 같은지 표시합니다.
코드 앞에서 고정해 두는 말입니다.
- 읽는 키: question
- 쓰는 키: 화면 출력. State는 바꾸지 않고 결과만 읽습니다.
- 그래프:
- classify --> greeting | calc | scope | retrieve --> ...
- 파이썬만 아는 분을 위한 말
- State는 노드들이 함께 쓰는 딕셔너리입니다. 함수는 state를 읽고, 바꿀 키만 return 합니다.
- return에 적지 않은 키는 이전 값이 남습니다.
- log와 tried_queries는 리스트라서, 새로 return한 항목이 뒤에 이어 붙습니다.
함수와 클래스로 나누어 읽습니다.
run_tests: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.