-
LLM을 분류기로 쓰기 — SystemMessage와 HumanMessage로 Classifier Node 만들기IT 2026. 6. 27. 21:00
LLM을 쓸 때 우리는 보통 "질문을 던지면 자유롭게 답해주는 것"으로 생각한다. 그런데 LLM은 전혀 다른 방식으로도 동작할 수 있다. "이 입력이 영화 추천 요청인가, 영화 정보 질문인가, 일반 대화인가"를 판단하는 분류기(Classifier)로 쓰는 것이다. LangGraph에서 라우팅을 구현할 때 이 패턴이 결정적인 역할을 한다.
왜 LLM을 분류기로 쓰는가
규칙 기반 분류를 먼저 떠올리기 쉽다. "추천"이라는 단어가 있으면 recommend, "정보"가 있으면 info. 하지만 사용자 입력은 예측 불가능하다. "요즘 볼만한 거 없을까?" 같은 표현은 어떤 규칙으로 잡을 것인가. LLM은 자연어를 이해하기 때문에 이런 모호한 표현도 정확히 분류할 수 있다.
분류기 노드의 목적은 단 하나다. 입력을 받아 카테고리를 판단하고 state에 저장하는 것. 응답을 생성하는 것이 아니다. 이 역할 분리가 LangGraph 라우팅 설계의 핵심이다.
위 다이어그램은 분류기 노드가 그래프에서 어떤 위치를 차지하는지 보여준다. 사용자 입력이 들어오면 Classifier Node가 먼저 받는다. 이 노드는 LLM을 호출해 카테고리를 판단하고
category값을 state에 저장한다. 이후 조건부 엣지가category를 읽어 적절한 처리 노드로 라우팅한다. Classifier Node 자체는 사용자에게 돌아갈 응답을 전혀 만들지 않는다는 점이 핵심이다. 역할을 섞으면 나중에 디버깅과 교체가 어려워진다.SystemMessage와 HumanMessage — 두 메시지의 역할
LLM에 메시지를 전달할 때 단순히 문자열 하나를 넘기는 것이 아니다. LangChain은 메시지 타입을 구분한다. 분류기 구현에서 핵심이 되는 두 타입이
SystemMessage와HumanMessage다.두 메시지 타입의 역할을 구분해서 보면 설계 의도가 명확해진다.
SystemMessage는 LLM에게 "어떤 역할을 해야 하는지" 지시하는 메타 레벨 메시지다. 사용자는 이 내용을 볼 수 없다. "너는 입력을 분류하는 역할을 해야 하고, 반드시 카테고리 이름만 출력해야 한다"는 식의 지시가 여기에 담긴다.HumanMessage는 실제 사용자가 입력한 내용이다. LLM은 SystemMessage의 지시를 컨텍스트로 삼아 HumanMessage를 처리한다. 둘을 리스트로 묶어llm.invoke([...])에전달하면 LLM이 두 메시지를 조합해 판단한다.분류기 노드 구현
실제 코드로 보면 구조가 더 명확해진다.
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) # 분류기는 temperature=0 def classifier_node(state: AgentState) -> dict: user_input = state["messages"][-1].content system_msg = SystemMessage(content="카테고리(recommend/info/general)만 출력하세요.") response = llm.invoke([system_msg, HumanMessage(content=user_input)]) category = response.content.strip().lower() if category not in ("recommend", "info", "general"): category = "general" # 예상 외 응답은 안전한 기본값으로 폴백 return {"category": category} # messages가 아닌 category만 반환코드를 세 구역으로 나눠 보면 설계 의도가 보인다. 첫 번째 구역에서
temperature=0으로 LLM을 초기화한다. 이것은 단순한 옵션이 아니라 분류기의 신뢰성을 보장하는 핵심 설정이다. 두 번째 구역에서 SystemMessage와 HumanMessage를 구성하고 LLM을 호출한다. SystemMessage에 "반드시 카테고리 이름만"을 명시하지 않으면 LLM이 "recommend입니다"처럼 문장으로 응답해 파싱이 깨진다. 세 번째 구역에서 응답을 정규화하고 방어적 검증을 거친 뒤category만 반환한다. messages는 반환하지 않는다 — 분류기 노드는 응답을 생성하는 노드가 아니기 때문이다.temperature=0이 왜 분류기에 필수인가
temperature는 LLM 응답의 무작위성을 제어하는 파라미터다. 값이 높을수록 같은 입력에도 다른 응답이 나온다. 분류기에서 이것이 문제가 되는 이유를 비교해서 보자.
temperature=1에서는 같은 입력에도 응답이 매번 달라진다. "info"로 왔을 때는 라우팅이 성공하지만, "정보입니다"가 오면 허용 값 검증에서 걸려 general로 폴백된다. 이 폴백은 사용자 입력이 잘못된 게 아니라 LLM의 무작위성 때문에 발생한 것이다. 랜덤한 분류는 디버깅도, 테스트도 불가능하게 만든다.
temperature=0에서는 같은 입력에 항상 같은 응답이 나온다. 이것이 분류기가 요구하는 결정론적(deterministic) 동작이다. 흐름을 따라가면 응답이 정규화를 거쳐 허용 값 검증을 통과하고 state에 안전하게 저장된다. 분류기, 라우터, 검색 요약처럼 "판단"을 담당하는 노드는 모두 temperature=0이 원칙이다. 창의성이 필요 없는 역할이기 때문이다.
방어적 코딩 — LLM은 항상 지시를 완벽하게 따르지 않는다
SystemMessage에 아무리 명확하게 지시해도 LLM이 예상 밖의 응답을 반환하는 경우는 반드시 생긴다. 이것은 버그가 아니라 확률적 시스템의 본질이다.
category = response.content.strip().lower() # LLM이 예상 외의 값을 반환할 때를 대비한 방어 코드 if category not in ("recommend", "info", "general"): category = "general" # 안전한 기본값으로 폴백이 두 줄이 없으면 어떤 일이 벌어지는지 생각해보자. LLM이 "recommendation"을 반환했다고 가정한다. 이 값이 그대로 state에 들어가면 조건부 엣지의 라우팅 로직이 매핑을 찾지 못해 런타임 오류가 발생하거나 예상치 못한 노드로 실행이 흘러간다.
strip().lower()로 공백과 대소문자를 정규화한 뒤, 허용 집합에 없는 값은 모두 general로 폴백한다. "general이 틀릴 수 있지 않냐"는 질문이 나올 수 있다. 그렇다. 그러나 라우팅 오류로 시스템이 멈추는 것보다 안전한 경로로 처리되는 것이 낫다. 폴백이 자주 발생한다면 SystemMessage를 다듬어야 한다는 신호다.분류기 노드 vs 응답기 노드 — 역할 분리
분류기 노드가 무엇을 하고 무엇을 하지 않는지 명확히 구분해두면 설계가 흔들리지 않는다.
Classifier Node는 판단만 한다. LLM을 호출하되 그 결과로
category값 하나만 state에 저장하고, messages 리스트에는 아무것도 추가하지 않는다. 입력을 받아 카테고리를 정하는 것까지가 이 노드의 책임 전부다. 응답을 만드는 일은 의도적으로 배제되어 있다.Responder Node는 생성한다. 도구를 호출하거나 직접 텍스트를 만들어 사용자에게 돌아갈 실제 응답 메시지를 구성하고, 이를 messages에 추가한다. 분류 결과를 어떻게 받았는지는 신경 쓰지 않고 오직 응답을 만드는 데만 집중한다.
두 노드 타입의 책임이 완전히 다르다. 이 두 역할이 한 노드에 섞이면 무슨 문제가 생기는가. 분류 로직을 교체할 때 응답 로직도 같이 건드려야 하고, 테스트를 작성할 때 두 동작을 동시에 검증해야 하며, 디버깅할 때 어느 쪽이 문제인지 추적하기 어려워진다. 단일 책임 원칙은 LLM 노드 설계에서도 그대로 적용된다.
전체 흐름 — 입력부터 category 저장까지
분류기 노드 하나의 내부 실행 흐름을 단계별로 따라가면 설계 결정이 보인다. state에서 마지막 messages를 읽어 사용자 입력을 추출한다. SystemMessage로 역할을 지시하고 HumanMessage에 입력을 담아 LLM을 호출한다. 응답이 오면 정규화를 거쳐 허용 값 집합과 대조한다. 통과하면 그대로, 실패하면 general로 폴백해 state에 저장한다. 이 흐름 어디에도 "응답 메시지 생성"은 없다. 판단 → 저장, 이것이 전부다. 이 단순함이 분류기 노드를 교체 가능하고 테스트 가능하게 만든다. LLM 모델을 바꾸거나 카테고리 목록을 늘릴 때 이 노드만 수정하면 되고, 나머지 그래프는 그대로 유지된다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
검색 결과를 에이전트 도구로 — build_context와 @tool 패턴 (0) 2026.06.28 RAG의 배경과 make_retriever — LLM이 모르는 문서를 검색하는 방법 (0) 2026.06.28 생성과 검증의 분리 — generator 노드와 validator 노드 설계 (0) 2026.06.28 LangGraph 자기 수정 패턴의 State 설계 — 루프를 위한 5가지 필드 (0) 2026.06.27 LangGraph 조건부 라우팅 — 상태 값으로 다음 노드를 결정하는 방법 (0) 2026.06.27 LangGraph 상태에 메시지 외 필드 추가하기 — RouterState 설계 (0) 2026.06.26 graph.invoke vs graph.stream — 에이전트 실행의 두 가지 방식 (1) 2026.06.26 bind_tools — LLM이 도구를 인식하는 방법 (0) 2026.06.26 LangGraph StateGraph 완전 분해 — 노드·엣지·조건부 라우팅 (1) 2026.06.25 LangGraph의 상태(State) 설계 — Annotated와 add_messages가 하는 일 (0) 2026.06.25