logo
홈블로그소개
4,256

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

·개인정보처리방침
AIPython

LangGraph 워크플로우 테스팅: 결정적 단정문에서 LLM-as-Judge까지

Toma
2026년 8월 5일
약 18분
목차
🧪 LangGraph 워크플로우 테스팅: 결정적 단정문에서 LLM-as-Judge까지
📌 소개
🗂️ 예제 그래프 — Email Agent
🧰 Pytest 기초
🔍 Testing Nodes — 부분적으로 테스트하기
🎯 개별 노드 테스트
⏸️ 중간 구간 테스트 — update_state + interrupt
🤖 AI Nodes / Testing AI Nodes
💥 그 순간, 테스트가 전부 깨진다
📊 1단계 → 2단계: 정확한 값에서 범위로
🚫 2단계의 한계 — 문자열 매칭의 죽음
⚖️ Testing AI Response — LLM as Judge
📐 평가 기준 정의
🧑‍⚖️ Judge 함수
✅ 최종 테스트
🎁 정리
이전 포스트AI Agent Workflow Architectures — LangGraph로 구현하는 4가지 설계
다음 포스트Multi-Agent Architectures — LangGraph로 구현하는 멀티 에이전트 설계

목차

🧪 LangGraph 워크플로우 테스팅: 결정적 단정문에서 LLM-as-Judge까지
📌 소개
🗂️ 예제 그래프 — Email Agent
🧰 Pytest 기초
🔍 Testing Nodes — 부분적으로 테스트하기
🎯 개별 노드 테스트
⏸️ 중간 구간 테스트 — update_state + interrupt
🤖 AI Nodes / Testing AI Nodes
💥 그 순간, 테스트가 전부 깨진다
📊 1단계 → 2단계: 정확한 값에서 범위로
🚫 2단계의 한계 — 문자열 매칭의 죽음
⚖️ Testing AI Response — LLM as Judge
📐 평가 기준 정의
🧑‍⚖️ Judge 함수
✅ 최종 테스트
🎁 정리

🧪 LangGraph 워크플로우 테스팅: 결정적 단정문에서 LLM-as-Judge까지

📌 소개

내가 만든 workflow, agent, graph들을 pytest를 사용해서 테스팅하는 방법을 정리한다.

그래프가 커지면서 여러 개의 노드와 edge가 있을 때 각각을 테스트하고 싶은 경우가 있다. 또는 input/output의 품질이 개선되고 있는지 테스트하고 싶을 때도 유용하다.

🎯 이 글의 핵심 흐름
하드코딩된 그래프를 테스트하는 것은 쉽다. 하지만 노드가 LLM 호출로 바뀌는 순간, 기존 테스트는 전부 깨진다. 이 글은 그 지점에서 단정문(assertion)이 어떻게 진화해야 하는지를 따라간다.

🗂️ 예제 그래프 — Email Agent

이메일을 input으로 받아 다음을 수행하는 그래프를 테스트 대상으로 삼는다.

  1. 이메일을 카테고리별로 구분 — 중요한 이메일인지, 스팸인지 구분
  2. 이메일에 우선순위 할당 — 중요할수록 높은 숫자
  3. 그래프가 이메일에 대한 response 반환
mermaid
flowchart LR
    START([START]) --> A["categorize_email"]
    A --> B["assign_priority"]
    B --> C["draft_response"]
    C --> END([END])

처음에는 AI 모델 없이 하드코딩으로 시작한다. 예측 가능한 상태에서 테스트 기법을 먼저 익히기 위해서다.

python
class EmailState(TypedDict):
    email: str
    category: Literal["spam", "normal", "urgent"]
    priority_score: int
    response: str


def categorize_email(state: EmailState):
    email = state["email"].lower()

    if "urgent" in email or "asap" in email:
        category = "urgent"
    elif "offer" in email or "discount" in email:
        category = "spam"
    else:
        category = "normal"

    return {"category": category}


def assign_priority(state: EmailState):
    scores = {"urgent": 10, "normal": 5, "spam": 1}
    return {"priority_score": scores[state["category"]]}


def draft_response(state: EmailState) -> EmailState:
    responses = {
        "urgent": "I will answer you as fast as i can",
        "normal": "I'll get back to you soon",
        "spam": "Go away!",
    }
    return {"response": responses[state["category"]]}

🧰 Pytest 기초

uv run pytest tests.py -vv 로 pytest를 실행한다. -vv 는 verbose(상세)에 대한 옵션이다.

⚠️ **main.py**에서 그래프를 invoke하면 안 된다. 테스트 파일이 from main import graph로 import하는 순간 main.py의 top-level 코드가 전부 실행되기 때문이다. main.py는 compile까지만 수행하도록 해야 한다.

pytest로 테스트하려면 test_*로 시작하는 함수들을 만들면 된다. @pytest.mark.parametrize 데코레이터는 파라미터들을 테스트 함수에 전달해주므로, 테스트 케이스를 일일이 하드코딩하지 않아도 된다.

python
import pytest

from main import graph


@pytest.mark.parametrize(
    "email, expected_category, expected_score",
    [
        # email, expected_category, expected_score 순
        ("this is urgent!", "urgent", 10),
        ("i wanna talk to you", "normal", 5),
        ("i have an offer for you", "spam", 1),
    ],
)
def test_full_graph(email, expected_category, expected_score):

    result = graph.invoke({"email": email})

    assert result["category"] == expected_category
    assert result["priority_score"] == expected_score

💡 assert****는 python 키워드로, 조건을 쓸 수 있으며 조건이 참이 아니면 일종의 에러를 만든다. 이를 pytest와 결합하면 pytest가 test_ 함수를 실행하고 어떤 assert가 실패했는지 알려준다.


🔍 Testing Nodes — 부분적으로 테스트하기

위에선 그래프 전체를 테스트해봤으니, 이번엔 그래프를 부분적으로 테스트하는 방법이다.

🎯 개별 노드 테스트

graph.nodes로 특정 노드만 참조한 뒤 invoke하면 된다.

python
def test_individual_nodes():

    # categorize_email node 테스트
    result = graph.nodes["categorize_email"].invoke({"email": "check out this offer"})

    assert result["category"] == "spam"

    # assign_priority node 테스트
    result = graph.nodes["assign_priority"].invoke({"category": "spam"})

    assert result["priority_score"] == 1

    # draft_response node 테스트
    result = graph.nodes["draft_response"].invoke({"category": "spam"})

    assert "Go away" in result["response"]

🔖 위 코드의 assign_priority는 원본 커밋에서 assing_priority로 되어 있다(오타). 노드 등록 키와 참조가 일관되게 같은 철자를 쓰고 있어 동작에는 문제가 없다. 이 글에서는 가독성을 위해 올바른 철자로 표기했다.

⏸️ 중간 구간 테스트 — update_state + interrupt

하나의 노드를 테스트하는 건 위에서 해봤으니, 이번엔 그래프 실행 중간의 특정 구간을 테스트하는 방법이다. (ex. 20개 노드 중 5개만)

⚠️ 그래프에 체크포인터를 설정해주어야 한다. 부분 실행을 테스트하려면 그래프를 interrupt(중단)해야 하고, 그래프를 중단했다가 다시 실행시키려면 그래프의 state를 저장할 체크포인터가 필요하기 때문이다.

python
checkpointer = MemorySaver()  
graph = graph_builder.compile(checkpointer=checkpointer)
python
def test_partial_execution():

    # categorize_email 노드가 이미 실행되고 있다고 가정.
    graph.update_state(
        config={
            "configurable": {
                "thread_id": "1",
            },
        },
        values={
            "email": "please check out this offer",
            "category": "spam",
        },
        as_node="categorize_email",
    )

    # 이미 categorize_email이 실행된 것처럼 가정하고 있으니
    # 그래프 호출 시 categorize_email 노드부터 실행됨 (START가 아니라.)
    result = graph.invoke(
        None,
        config={
            "configurable": {
                "thread_id": "1",
            },
        },
        interrupt_after="draft_response",
    )

    assert result["priority_score"] == 1

두 가지 핵심 옵션이 있다.

💡 as_node — 마치 categorize_email node처럼 그래프의 state를 업데이트하고 싶다는 의미다. 즉, node인 척하는 것.

💡 interrupt_after — draft_response node 다음에 interrupt할 수 있게 해준다.
interrupt command를 사용하면 tool을 만들고 에이전트가 그 tool을 호출하게 해야 하지만, interrupt_after나 interrupt_before를 사용하면 내가 원하는 지점에서 그래프를 멈출 수 있다.

✅ 검증 노트 — interrupt_after는 compile()에만 넘기는 것으로 오해하기 쉽지만, langgraph 0.6.6의 invoke() 시그니처에도 정식 파라미터로 존재한다. 위 코드처럼 invoke() 호출 시점에 넘기는 것이 유효하다.

python
['self', 'input', 'config', 'context', 'stream_mode', 'print_mode',  
 'output_keys', 'interrupt_before', 'interrupt_after', 'durability', 'kwargs']

🤖 AI Nodes / Testing AI Nodes

지금까진 그래프를 입력과 출력이 있고 예측 가능한 프로그램과 같이 테스트를 해봤는데, 실제 AI Agent는 이와 같이 고정적인 입/출력이 존재하지 않으며 예측 불가능하다.

이런 경우를 테스트하는 방법을 알기 위해서 먼저 현재 하드코딩되어 있는 로직을 모두 LLM을 호출하는 call로 변경한다.

💥 그 순간, 테스트가 전부 깨진다

노드가 LLM 호출로 바뀌면 기존 단정문은 더 이상 성립하지 않는다. 단정문은 다음 3단계로 진화해야 한다.

단계단정문왜 바뀌는가
1. 하드코딩assert score == 10결정적(deterministic). 입력이 같으면 출력도 같다
2. LLM 도입assert 8 <= score <= 10확률적. 정확한 값은 매번 다르지만 범위는 지킨다
3. 자유 텍스트assert judge(...) >= 70범위조차 불가능. 문자열 매칭이 통하지 않는다

📊 1단계 → 2단계: 정확한 값에서 범위로

구조화된 출력(숫자, 카테고리)은 범위 단정문으로 완화하면 된다. 프롬프트에 준 가이드라인(Urgent: 8-10, Normal: 4-7, Spam: 1-3)이 곧 테스트의 범위가 된다.

python
@pytest.mark.parametrize(
    "email, expected_category, min_score, max_score",
    [
        ("this is urgent!", "urgent", 8, 10),
        ("i wanna talk to you", "normal", 4, 7),
        ("i have an offer for you", "spam", 1, 3),
    ],
)
def test_full_graph(email, expected_category, min_score, max_score):

    result = graph.invoke({"email": email}, config={"configurable": {"thread_id": "1"}})

    assert result["category"] == expected_category
    assert min_score <= result["priority_score"] <= max_score

💡 카테고리는 여전히 **==**로 단정할 수 있다. with_structured_output에 Literal["spam", "normal", "urgent"]을 주면 출력이 세 값 중 하나로 강제되기 때문이다. 즉, 출력 공간을 좁히면 결정적 단정문을 유지할 수 있다.

🚫 2단계의 한계 — 문자열 매칭의 죽음

draft_response는 자유 텍스트를 반환하므로 범위 단정문조차 쓸 수 없다. 실제로 이 시점의 커밋에서 해당 단정문은 주석 처리된다.

python
# draft_response node 테스트
# result = graph.nodes["draft_response"].invoke({"category": "spam"})
# assert "Go away" in result["response"]

⚠️ "Go away"는 하드코딩 시절의 응답 문자열이었다. LLM은 같은 의미를 무한히 다른 문장으로 표현하므로 문자열 매칭은 의미가 없어진다. 여기서 다음 단계, LLM as judge가 필요해진다.


⚖️ Testing AI Response — LLM as Judge

LLM의 Response를 테스트할 수 있게 하려면 또다른 LLM을 불러와서 이 response를 평가하게 하면 된다. 이를 LLM as judge라고 부른다.

📐 평가 기준 정의

python
class SimilarityScoreOutput(BaseModel):
    similarity_score: int = Field(
        description="How similar is the response to the examples?",
        gt=0,
        lt=100,
    )

✅ 검증 노트 — gt=0, lt=100은 "값이 0보다 크고 100보단 작아야 함"이라는 설명과 정확히 일치한다. pydantic으로 직접 실행해 확인한 결과: 0 → 거부, 1 → 통과, 99 → 통과, 100 → 거부. (경계값을 포함하려면 ge/le를 쓴다.)

카테고리별로 정답에 가까운 예시들을 준비한다. 이것이 judge의 채점 기준이 된다.

python
RESPONSE_EXAMPLES = {
    "urgent": [
        "Thank you for your urgent message. We are addressing this immediately and will respond as soon as possible.",
        "We've received your urgent request and are prioritizing it. Our team is on it right away.",
        "This urgent matter has our immediate attention. We'll respond promptly.",
    ],
    "normal": [
        "Thank you for your email. We'll review it and get back to you within 24-48 hours.",
        "We've received your message and will respond soon. Thank you for reaching out.",
        "Thank you for contacting us. We'll process your request and respond shortly.",
        "Thank you for the update. I will review the information and follow up as needed.",
        "Thank you for the update on the project status. I will review and follow up by the end of the week.",
        "Thanks for sharing this update. We'll review and respond accordingly.",
    ],
    "spam": [
        "This message has been flagged as spam and filtered.",
        "This email has been identified as promotional content.",
        "This message has been marked as spam.",
    ],
}

🧑‍⚖️ Judge 함수

AI모델이 준 response와 이메일의 카테고리를 받고, 다른 AI모델에게 몇 가지 예시를 전달하여 그래프에서 받은 response가 그 예시와 얼마나 비슷한지 1~100 사이 숫자로 평가하게 한다.

python
def judge_response(response: str, category: str):

    s_llm = llm.with_structured_output(SimilarityScoreOutput)

    examples = RESPONSE_EXAMPLES[category]
    result = s_llm.invoke(
        f"""
        Score how similar this response is to the examples.

        Category: {category}

        Examples:
        {"\n".join(examples)}

        Response to evaluate:
        {response}

        Scoring criteria:
        - 90-100: Very similar in tone, content, and intent
        - 70-89: Similar with minor differences
        - 50-69: Moderately similar, captures main idea
        - 30-49: Some similarity but missing key elements
        - 0-29: Very different or inappropriate

    """
    )

    return result.similarity_score

💡 채점 기준을 프롬프트에 명시하는 것이 핵심이다. "비슷한지 점수 매겨줘"만 주면 judge마다 제멋대로 채점한다. 구간별 의미(90-100은 무엇, 70-89는 무엇)를 정의해야 점수가 재현 가능한 신호가 된다.

✅ 최종 테스트

python
def test_individual_nodes():
    ...

    # draft_response
    result = graph.nodes["draft_response"].invoke(
        {
            "category": "spam",
            "email": "Get rich quick!!! I have a pyramid scheme for you!",
            "priority_score": 1,
        }
    )

    similarity_score = judge_response(result["response"], "spam")
    assert similarity_score >= 70

💡 **70**이라는 임계값이 곧 품질 기준선이다. 이 숫자를 올리면 테스트가 엄격해지고, 내리면 느슨해진다. 프롬프트를 개선했을 때 이 점수가 오르는지 보는 것이 곧 품질이 개선되고 있는지에 대한 테스트다.

⚠️ 개별 노드 테스트에도 이제 state를 더 채워줘야 한다. 하드코딩 시절엔 {"category": "spam"}만으로 충분했지만, LLM 노드는 프롬프트에 email과 priority_score를 사용하므로 이 값들이 없으면 KeyError가 난다.


🎁 정리

🧭 테스트 대상의 예측 가능성이 낮아질수록, 단정문은 느슨해지고 판정자는 똑똑해져야 한다.

기법쓰는 곳
@pytest.mark.parametrize테스트 케이스를 하드코딩하지 않고 표로 관리
graph.nodes["..."].invoke()그래프 전체가 아닌 개별 노드만 테스트
update_state(as_node=...)앞선 노드가 실행된 척하고 중간부터 시작
invoke(interrupt_after=...)원하는 지점에서 그래프를 멈춰 구간만 테스트
범위 단정문LLM이 내는 숫자 출력 검증
Literal • with_structured_output출력 공간을 좁혀 == 단정문 유지
LLM as judgeLLM이 내는 자유 텍스트 품질 검증

⚠️ LLM as judge를 쓰는 테스트는 비결정적이고 비용이 든다. 매 실행마다 API를 호출하므로 느리고, 드물게 흔들릴 수 있다. 빠른 결정적 테스트(카테고리 분류 등)와 분리해서 관리하는 것이 좋다.