29차시. Conditional Edge 실습
3. 실습 ② — 분류 노드 (2차 LLM)
출처: 원본 PDF 29차시_ConditionalEdge실습.pdf 7~8쪽 · 쪽 그림 7쪽 8쪽
📝 prompts.py에 추가
CLASSIFY_PROMPT = ChatPromptTemplate.from_template(
"사용자 질문의 유형을 하나만 골라 그 단어만 출력하십시오.\n"
"다른 설명은 절대 하지 마십시오.\n\n"
"[유형]\n"
"greeting : 인사, 감사, 잡담 (정보 요청이 아님)\n"
"calc : 순수한 수식 계산\n"
"scope : 우리 문서와 무관한 주제 "
"(날씨, 뉴스, 일반 상식 등)\n"
"document : 사내 문서에서 찾아야 할 질문\n\n"
"[판단 원칙]\n"
"애매하면 document를 선택하십시오.\n\n"
"[질문]\n{question}\n\n"
"[유형]"
)📝 classifier.py 완성
import config
from prompts import CLASSIFY_PROMPT
from nodes.generator import _llm
from langchain_core.output_parsers import StrOutputParser
_classify_chain = CLASSIFY_PROMPT | _llm | StrOutputParser()
VALID = {"greeting", "calc", "scope", "document"}
def classifier_node(state) -> dict:
q = state.get("question", "")
# ── 1차: 규칙 (비용 0) ──
intent = _rule_classify(q)
if intent:
return {"intent": intent,
"log": [f"분류(규칙): {intent}"]}
# ── 2차: LLM ──
if not config.USE_LLM_CLASSIFY:
return {"intent": "document",
"log": ["분류: 기본값 document"]}
try:
raw = _classify_chain.invoke({"question": q}).strip().lower()
intent = raw if raw in VALID else "document"
except Exception as e:
intent = "document" # 실패하면 안전한 기본값
return {"intent": intent,
"log": [f"분류 오류({type(e).__name__}) -> document"]}
return {"intent": intent, "log": [f"분류(LLM): {intent}"]}⚠️ 실패 시 항상 document로
28차시에서 배운 원칙입니다.
실수했을 때 덜 나쁜 쪽을 기본값으로.
문서 질문을 인사로 잘못 보내면 최악이지만,
인사를 문서 질문으로 보내면 견딜 만합니다.
실습 코드 · 29/classifier.py
실습 코드: 실습/src/29/classifier.py · 위의 원본 PDF 29차시 7~8쪽 「3️⃣ 실습 ② — 분류 노드 (2차 LLM)」과 이어집니다.
# ==============================================================
# 29차시. PPT 29차시_ConditionalEdge실습. 코드 폴더는 src/29
# PPT: 29차시 질문 분류
# 역할: Classifier Node. 규칙을 먼저 쓰고, 규칙이 못 가르면 LLM으로 intent를 정합니다.
# 읽는 키: question
# 쓰는 키: intent, log
# 그래프:
# classifier_node
# |-- 인사 단어, 20자 이하 --> greeting
# |-- 숫자와 연산자만 ------> calc
# |-- 날씨·주가 등 ---------> scope
# |-- 규칙 실패 + LLM 끔 --> document
# +-- 규칙 실패 + LLM 켬 --> greeting|calc|scope|document
# --------------------------------------------------------------
# 파이썬만 아는 분을 위한 말
# 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에 적습니다.
# intent는 다음 칸을 고르는 표식입니다. 이 노드는 답을 쓰지 않습니다.
# 인사 목록에 '안녕'이 있고, 20자 이하만 인사로 봅니다. 긴 문장 속 단어는 인사로 치지 않습니다.
# CALC_PATTERN은 숫자와 +-*/(). 만 허용합니다. 연산자가 실제로 있어야 calc입니다.
# OUT_OF_SCOPE_WORDS에 '날씨'가 있어 날씨 질문은 scope입니다.
# LLM 결과가 네 단어 중 하나가 아니면 document로 바꿉니다. 호출 오류도 document입니다.
# config.USE_LLM_CLASSIFY가 True이면 규칙이 None일 때 LLM을 부릅니다.
# ==============================================================
# ============================================================
# Classifier Node
# ============================================================
# 사용자의 질문을 인사, 계산, 범위 밖 질문, 문서 질문으로 분류합니다.
# 먼저 규칙으로 판단하고, 판단하기 어려운 질문만 LLM에게 전달합니다.
# ============================================================
import os
import sys
import re
# ============================================================
# 다른 차시의 파일을 import하기 위한 경로 설정
# ============================================================
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
# 공통 src 폴더를 추가합니다.
sys.path.insert(0, SRC_DIR)
# 25차시 Generator 모듈 경로를 추가합니다.
sys.path.insert(0, os.path.join(SRC_DIR, "25"))
# 13차시 프롬프트 모듈 경로를 추가합니다.
sys.path.insert(0, os.path.join(SRC_DIR, "13"))
# 24차시 Retriever 모듈 경로를 추가합니다.
sys.path.insert(0, os.path.join(SRC_DIR, "24"))
# 다른 모듈을 가져옵니다.
import config
from generator import _llm
from prompts import CLASSIFY_PROMPT
from langchain_core.output_parsers import StrOutputParser
# ============================================================
# 분류 기준
# ============================================================
# 인사말로 판단할 단어 목록입니다.
GREETING_WORDS = [
"안녕",
"반가",
"하이",
"헬로",
"고마",
"감사",
"수고",
"잘 부탁",
]
# 문서와 관련이 없다고 판단할 단어 목록입니다.
OUT_OF_SCOPE_WORDS = [
"날씨",
"주가",
"환율",
"로또",
"뉴스",
"맛집",
"영화",
"축구",
]
# 숫자와 계산 기호로만 이루어진 질문인지 확인합니다.
CALC_PATTERN = re.compile(
r"^[\d\s+\-*/().,]+[=?]?$"
)
# ============================================================
# 1차 분류: 규칙 기반 분류
# ============================================================
def rule_classify(question):
"""
규칙만 사용하여 질문을 빠르게 분류합니다.
반환값:
greeting : 인사말
calc : 계산식
scope : 문서 범위 밖 질문
None : 규칙으로 판단하지 못함
"""
# 질문 앞뒤의 공백을 제거합니다.
question = question.strip()
# 빈 질문은 문서와 관련 없는 질문으로 처리합니다.
if question == "":
return "scope"
# --------------------------------------------------------
# 1. 인사말인지 확인합니다.
# --------------------------------------------------------
# 긴 문장 전체를 인사말로 잘못 판단하지 않도록 제한합니다.
if len(question) <= 20:
# 인사말 단어를 하나씩 확인합니다.
for word in GREETING_WORDS:
# 질문에 인사말 단어가 포함되어 있으면 인사로 분류합니다.
if word in question:
return "greeting"
# --------------------------------------------------------
# 2. 계산식인지 확인합니다.
# --------------------------------------------------------
# 계산 연산자가 있는지 저장하는 변수입니다.
has_operator = False
# 사칙연산 기호를 하나씩 확인합니다.
for operator in "+-*/":
# 질문에 연산자가 있으면 계산식 후보로 표시합니다.
if operator in question:
has_operator = True
break
# 계산식 모양이고 실제 연산자도 있으면 계산으로 분류합니다.
if CALC_PATTERN.match(question) and has_operator:
return "calc"
# --------------------------------------------------------
# 3. 문서 범위 밖 질문인지 확인합니다.
# --------------------------------------------------------
# 범위 밖 단어를 하나씩 확인합니다.
for word in OUT_OF_SCOPE_WORDS:
# 질문에 범위 밖 단어가 포함되어 있으면 scope로 분류합니다.
if word in question:
return "scope"
# 규칙으로 판단하지 못한 질문입니다.
return None
# ============================================================
# 2차 분류: LLM 기반 분류
# ============================================================
# 프롬프트, LLM, 문자열 변환기를 연결합니다.
classify_chain = (
CLASSIFY_PROMPT
| _llm
| StrOutputParser()
)
# LLM이 반환할 수 있는 정상적인 분류 결과입니다.
VALID_INTENTS = [
"greeting",
"calc",
"scope",
"document",
]
# ============================================================
# LangGraph 분류 노드
# ============================================================
def classifier_node(state):
"""
질문을 분류하고 intent 값을 State에 저장합니다.
처리 순서:
1. 규칙으로 먼저 판단합니다.
2. 판단하지 못하면 LLM 사용 여부를 확인합니다.
3. LLM을 사용하지 않으면 document로 처리합니다.
4. LLM 오류가 발생해도 document로 처리합니다.
"""
# State에서 질문을 가져옵니다.
question = state.get("question", "")
# --------------------------------------------------------
# 1단계: 규칙 기반 분류
# --------------------------------------------------------
# 비용이 들지 않는 규칙 분류를 먼저 실행합니다.
intent = rule_classify(question)
# 규칙으로 판단했다면 즉시 결과를 반환합니다.
if intent is not None:
return {
"intent": intent,
"log": [f"분류(규칙): {intent}"],
}
# --------------------------------------------------------
# 2단계: LLM 분류 사용 여부 확인
# --------------------------------------------------------
# 설정값이 없으면 기본적으로 LLM을 사용하지 않습니다.
use_llm = getattr(config, "USE_LLM_CLASSIFY", False)
# LLM 분류를 사용하지 않으면 문서 질문으로 처리합니다.
if use_llm == False:
return {
"intent": "document",
"log": ["분류: 기본값 document"],
}
# --------------------------------------------------------
# 3단계: LLM으로 분류
# --------------------------------------------------------
try:
# LLM에게 질문을 전달합니다.
result = classify_chain.invoke({
"question": question,
})
# LLM의 결과를 소문자로 바꾸고 공백을 제거합니다.
intent = result.strip().lower()
# 허용되지 않은 결과는 document로 바꿉니다.
if intent not in VALID_INTENTS:
intent = "document"
# LLM 분류 결과를 기록합니다.
log_message = f"분류(LLM): {intent}"
except Exception as error:
# LLM 오류가 발생하면 안전하게 document로 처리합니다.
intent = "document"
# 오류 종류만 로그에 기록합니다.
error_name = type(error).__name__
log_message = f"분류 오류({error_name}) -> document"
# 최종 분류 결과를 State에 저장합니다.
return {
"intent": intent,
"log": [log_message],
}
# ============================================================
# 단독 실행 테스트
# ============================================================
if __name__ == "__main__":
# 테스트할 질문 목록입니다.
test_questions = [
"안녕하세요",
"125 + 340 * 2",
"오늘 날씨 어때요?",
"환불은 며칠 이내인가요?",
]
# 질문을 하나씩 테스트합니다.
for question in test_questions:
# 테스트용 State를 직접 만듭니다.
state = {
"question": question
}
# 분류 노드를 실행합니다.
result = classifier_node(state)
# 결과를 출력합니다.
print(f"\n질문: {question}")
print(f"분류: {result['intent']}")
print(f"로그: {result['log']}")실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
예시 출력 (API 키 필요, 실행하지 않음)
규칙만으로 갈리는 기록:
안녕하세요 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
대표이사가 누구인가요? intent=document grade=giveup rewrites=2 elapsed=3.41 fail_reason=low_score top_score=-0.219
규칙이 None인 질문의 LLM 분류 문장은 따로 적지 않습니다.코드 해설
Classifier Node. 규칙을 먼저 쓰고, 규칙이 못 가르면 LLM으로 intent를 정합니다.
코드 앞에서 고정해 두는 말입니다.
- 읽는 키: question
- 쓰는 키: intent, log
- 그래프:
- classifier_node
- |-- 인사 단어, 20자 이하 --> greeting
- |-- 숫자와 연산자만 ------> calc
- |-- 날씨·주가 등 ---------> scope
- |-- 규칙 실패 + LLM 끔 --> document
함수와 클래스로 나누어 읽습니다.
rule_classify: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.classifier_node: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.