ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 생성과 검증의 분리 — generator 노드와 validator 노드 설계
    IT 2026. 6. 28. 21:00
    생성과 검증의 분리 — generator 노드와 validator 노드 설계

    LangGraph로 자기수정 파이프라인을 구현할 때 가장 흔히 저지르는 실수는 "하나의 노드가 초안을 쓰고 스스로 검토하게" 만드는 것이다. 이 글은 왜 그게 문제인지, 그리고 generator와 validator를 별도 노드로 분리했을 때 무엇이 달라지는지를 코드와 함께 설명한다.

    왜 생성과 검증을 분리해야 하는가

    LLM에게 "글을 쓰고 스스로 검토해"라고 요청하면 자기 평가에 관대해진다. 사람도 자기가 쓴 글을 교정할 때 오탈자를 잘 못 잡는 것과 같은 이치다. 동일한 컨텍스트 안에서 생성 직후에 평가가 이어지면, LLM은 이미 작성한 내용을 합리화하는 방향으로 판단을 내린다.

    diagram

    위 구조의 핵심 문제는 단 한 번의 LLM 호출이 생성과 평가를 모두 담당한다는 점이다. 초안 작성 단계에서 만들어진 내용이 자기 평가 단계의 기준을 오염시킨다. LLM은 자신이 생성한 텍스트를 이미 컨텍스트로 갖고 있어서, "이것이 좋은 글인가"를 판단할 때 무의식적으로 자기 합리화 방향으로 판단한다. 놓치기 쉬운 함정은 이 구조가 겉으로는 잘 작동하는 것처럼 보인다는 것이다 — 검증 결과가 나오기 때문이다. 하지만 그 검증이 진짜로 엄격한지는 별개의 문제다.

    diagram

    분리된 구조에서는 생성 전용 LLM 호출과 검증 전용 LLM 호출이 각각 독립적이다. 생성 노드는 창의적 서술에 집중하는 system prompt를, 검증 노드는 엄격한 편집자 기준을 담은 system prompt를 갖는다. 초안 작성 단계에서 완성된 내용이 State를 통해 검증 LLM 호출 단계에 전달될 때, 검증자는 그 초안을 "외부 입력"으로 받아 평가한다 — 자신이 만들지 않은 텍스트이기 때문에 합리화 유인이 없다. 이 패턴이 유효한 이유는 system prompt가 컨텍스트를 결정하기 때문이다. 검증자가 독립된 LLM 호출이라는 것은 곧 독립된 컨텍스트를 가진다는 의미다.

    generator 노드: 두 가지 모드

    generator 노드는 State에서 feedback 여부를 확인해 두 가지 경로로 동작한다.

    diagram

    이 흐름에서 핵심은 feedback 유무를 확인하는 분기다. 첫 생성 시에는 주제만 담은 단순한 프롬프트를 구성한다. 검증자에게 한 번이라도 REJECTED를 받았다면 피드백을 프롬프트에 포함해 개선을 유도한다. draft와 retry_count만 반환하는 것이 중요하다 — messages나 feedback 같은 다른 State 필드는 건드리지 않는다. 놓치기 쉬운 함정은 피드백을 "대화 히스토리"로 messages에 넣고 싶어진다는 것이다. 그렇게 하면 LLM이 이전 초안을 컨텍스트로 갖게 되어 합리화 문제가 다시 생긴다. 피드백은 명시적으로 새 HumanMessage의 본문에 포함해야 한다.

    def generator_node(state):
        feedback = state.get("feedback", "")
        if feedback:
            prompt = f"주제: {state['topic']}\n피드백: {feedback}\n위 피드백을 반영해 개선하세요."
        else:
            prompt = f"주제: {state['topic']}\n구체적인 예시를 포함해 작성하세요."
        response = llm.invoke([HumanMessage(content=prompt)])
        return {"draft": response.content, "retry_count": state.get("retry_count", 0) + 1}
        # feedback, topic, is_approved는 반환하지 않음 — LangGraph가 이전 값 유지
    

    이 코드에서 주목할 부분은 반환값이다. draft와 retry_count만 반환하고 나머지 State 필드는 변경하지 않는다. LangGraph는 노드가 반환한 딕셔너리를 State에 merge하므로, 반환하지 않은 필드는 이전 값이 그대로 유지된다. retry_count를 직접 증가시켜 반환하는 이유는 재시도 횟수를 State에서 추적해 무한 루프를 방지하는 라우팅 조건에서 이 값을 사용하기 때문이다.

    validator 노드: APPROVED/REJECTED 파싱 패턴

    validator 노드는 검증자 역할을 LLM에 명확하게 부여하는 것에서 시작한다.

    diagram

    이 흐름의 핵심은 SystemMessage 구성 단계와 응답 첫 줄 파싱 단계다. SystemMessage로 평가 기준과 응답 형식을 동시에 지정한다 — "무엇을 평가할지"와 "어떤 형식으로 답할지"를 함께 지시하는 것이다. 응답의 첫 줄만 보고 승인 여부를 파싱한다. 전체 응답을 파싱하려 하면 LLM이 설명을 먼저 쓰고 판정을 나중에 쓰는 경우 파싱이 깨진다. "첫 줄에 APPROVED 또는 REJECTED만"이라는 형식 제약이 파싱 안정성을 보장한다. state의 draft를 그대로 HumanMessage에 넣는 것도 중요하다 — validator가 자신이 생성하지 않은 텍스트를 평가하는 구조가 이 지점에서 완성된다.

    def validator_node(state):
        response = llm.invoke([
            SystemMessage(content="엄격한 편집자. 통과하면 첫 줄에 'APPROVED', 아니면 'REJECTED' 후 사유."),
            HumanMessage(content=f"평가 대상:\n\n{state['draft']}")
        ])
        approved = response.content.strip().upper().startswith("APPROVED")
        return {"feedback": response.content, "is_approved": approved}
        # draft, topic, retry_count는 반환하지 않음
    

    SystemMessage의 마지막 두 문장이 파싱 패턴의 핵심이다. APPROVED는 "첫 줄에 단독으로"라고 지시했기 때문에 startswith로 충분히 파싱된다. REJECTED 뒤에 개선점이 오는 구조도 명시했으므로 feedback 값에는 validator가 왜 거절했는지의 이유가 담겨 있다. 이 값이 다음 generator 호출에서 재생성 프롬프트의 재료가 된다. 놓치기 쉬운 함정은 LLM이 종종 "APPROVED, 이유는..."처럼 쉼표를 붙이거나 "APPROVED입니다"처럼 조사를 붙인다는 것이다. startswith가 아닌 ==로 비교하면 이 경우에 파싱이 실패한다.

    두 노드의 역할 분리: State 필드 관계도

    diagram

    이 관계도에서 draft와 feedback이 두 노드를 연결하는 채널임을 볼 수 있다. generator는 draft에 쓰고, validator는 draft에서 읽는다. 반대 방향으로 validator가 쓴 feedback를 generator가 읽는다. 두 노드가 공유하는 State 필드는 이 둘뿐이며, 나머지 필드는 각자의 전용 영역이다. 이것이 "역할 분리"의 구체적 의미다 — 코드 레벨에서는 각 노드가 반환하는 딕셔너리의 키가 겹치지 않는 것으로 표현된다. retry_count는 generator만 읽고 쓴다. 라우팅 노드에서 이 값을 읽어 최대 재시도 횟수를 초과하면 루프를 강제 종료하는 조건에 사용한다. 놓치기 쉬운 함정은 validator가 feedback뿐만 아니라 "개선된 draft도 써주면 어떨까" 하는 유혹이다. 그렇게 하면 generator는 validator가 고쳐준 draft를 받아서 단순히 포장만 하게 되어 자기수정의 의미가 사라진다.

    APPROVED/REJECTED 패턴의 범용성

    이 패턴은 도메인에 관계없이 "LLM이 구조화된 판정을 내려야 하는" 모든 상황에 적용된다.

    diagram

    이 공통 구조에서 B의 역할과 기준만 교체하면 다른 도메인에 그대로 적용된다. 코드 리뷰 맥락에서는 "PASS/FAIL"로, 영화 추천 적합도 평가에서는 "ACCEPTABLE/REVISE"로, 팩트 체크에서는 "VERIFIED/UNVERIFIED"로 바꿀 수 있다. 공통점은 항상 첫 줄이 구조화된 판정이고 나머지가 사람이 읽을 설명이라는 것이다. '첫 줄 파싱'과 '나머지 피드백'이 하나의 LLM 응답에서 분리된다는 것도 이 패턴의 강점이다 — '첫 줄 파싱'은 코드가 파싱하고, '나머지 피드백'은 다음 generator가 프롬프트에 포함한다. 두 소비자가 같은 응답을 서로 다른 방식으로 사용하는 구조다. 놓치기 쉬운 함정은 판정 키워드를 너무 많이 정의하는 것이다. APPROVED/NEEDS_WORK/PARTIAL/UNCLEAR처럼 4가지 이상으로 늘리면 startswith 파싱이 복잡해지고 LLM이 일관성 없는 키워드를 반환하기 시작한다. 이진 판정이 파싱 안정성과 라우팅 단순성 모두에서 우월하다.

    정리

    generator와 validator를 분리하는 핵심 이유는 각 노드에 독립된 system prompt와 독립된 LLM 컨텍스트를 부여하기 위해서다. 생성자는 창의적 서술에 집중하고, 검증자는 자신이 만들지 않은 텍스트를 외부 기준으로 평가한다. 두 노드는 State에서 서로 다른 필드를 읽고 쓰며, 공유하는 채널은 draft(생성 → 검증)와 feedback(검증 → 생성) 두 가지뿐이다. APPROVED/REJECTED 패턴은 이 구조를 라우팅과 연결하는 인터페이스다 — 첫 줄의 구조화된 판정이 코드가 읽는 신호이고, 나머지 피드백이 다음 생성 사이클의 재료가 된다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.