본문으로 건너뛰기
AIPida
검증됨중급Orchestration

LangGraph supervisor 멀티에이전트가 GraphRecursionError(25)로 죽습니다 — recursion_limit를 올리는 게 맞나요?

LangGraph로 supervisor + 워커 3개(researcher / writer / reviewer) 구조를 짰습니다. 한국어 리서치 태스크를 돌리면 정상 종료가 안 되고 약 25스텝쯤에서 이렇게 죽습니다.

langgraph.errors.GraphRecursionError: Recursion limit of 25 reached without hitting a stop condition.

LangSmith 트레이스를 보면 supervisor → reviewer → supervisor → researcher → supervisor → reviewer ... 식으로 supervisor와 워커 사이를 계속 왕복하다가 한도에 걸립니다. 결과물 자체는 3~4스텝이면 충분한데도요.

graph.invoke(..., {"recursion_limit": 100})으로 한도를 올리면 에러는 안 나지만 LLM 호출이 그만큼 늘어서 토큰/비용만 폭증하고, 가끔은 100까지 돌고도 답을 못 냅니다. recursion_limit를 올리는 게 정답인가요, 아니면 구조 문제인가요?

답변 1

  • 채택된 답변에디터 검증

    결론: recursion_limit를 올리는 건 증상 가리기다. GraphRecursionError: Recursion limit of 25(LangGraph 기본값 25 supersteps)는 거의 항상 supervisor가 "끝났다"를 판단하지 못해 워커와 무한 핑퐁하는 구조 문제다. 한도를 100으로 올리면 같은 루프를 4배 더 비싸게 돌 뿐이다. 고칠 곳은 세 군데다: supervisor에 명시적 종료 분기, 워커→supervisor 단방향 강제, state에 라운드 카운터.

    왜 핑퐁이 생기나

    supervisor 패턴에서 워커가 끝나면 보통 supervisor로 엣지가 돌아온다. supervisor의 라우팅 프롬프트가 "다음에 누구에게 보낼까"만 묻고 "이제 끝낼까"를 동등한 선택지로 주지 않으면, LLM은 항상 누군가를 더 호출하는 쪽으로 기운다. reviewer가 "문제없음"이라고 답해도 supervisor는 그걸 종료 신호가 아니라 또 다른 워커 호출 트리거로 읽는다. 여기에 양방향 자유 핸드오프(워커끼리도 서로 부를 수 있게)까지 열어두면 사이클이 폭발한다.

    고치는 법

    1) 종료를 1급 라우팅 옵션으로. supervisor가 고를 수 있는 목적지에 FINISH/END를 반드시 넣고, reviewer가 승인하면 그 경로로만 가게 한다.

    from typing import Literal, TypedDict
    from pydantic import BaseModel
    from langgraph.graph import StateGraph, START, END
    from langgraph.types import Command
    
    class State(TypedDict):
        messages: list
        round: int          # 의미 있는 작업 반복 카운터
        approved: bool      # reviewer 승인 플래그
    
    MEMBERS = ["researcher", "writer", "reviewer"]
    
    class Router(BaseModel):
        next: Literal["researcher", "writer", "reviewer", "FINISH"]
    
    def supervisor(state: State) -> Command[Literal["researcher", "writer", "reviewer", "__end__"]]:
        # 하드 가드: 승인됐거나 라운드 초과면 LLM에 묻지도 말고 종료
        if state.get("approved") or state["round"] >= 6:
            return Command(goto=END)
        decision = llm.with_structured_output(Router).invoke(
            [SYS_PROMPT] + state["messages"]   # SYS_PROMPT에 "reviewer가 approve하면 반드시 FINISH" 명시
        )
        nxt = decision.next
        return Command(
            goto=END if nxt == "FINISH" else nxt,
            update={"round": state["round"] + 1},
        )
    

    2) 워커는 supervisor로만 복귀(단방향). 워커끼리 직접 핸드오프하는 엣지를 만들지 마라. researcher → supervisor, writer → supervisor처럼 워커의 out-edge를 supervisor 하나로 고정하면 사이클 토폴로지 자체가 줄어든다. reviewer는 평가 결과를 state의 approved 불리언으로 써서 supervisor의 하드 가드가 읽게 한다(reviewer가 직접 다음 워커를 호출하는 self-approval 루프는 금지).

    3) 라운드 카운터로 fail-safe. 위 코드의 state["round"] >= 6처럼 state 안에 카운터를 두고 임계치에서 END로 보낸다. 이건 recursion_limit(전역 supersteps)와 다르다. recursion_limit는 그래프의 모든 스텝을 세는 안전망이고, 라운드 카운터는 의미 있는 작업 반복만 센다. 둘 다 있어야 한다. recursion_limit는 5~6라운드에 맞춰 넉넉히(예: 30~40) 잡고, 실제 종료는 라운드 카운터가 책임진다.

    한국어 작업에서 더 위험한 이유 두 가지

    • 한글 토큰 비용. 대부분의 BPE 토크나이저에서 한글은 영어보다 토큰이 더 잘게 쪼개진다. supervisor 라우팅 프롬프트가 전체 한국어 대화 히스토리를 매 스텝 통째로 본다면, 25스텝 핑퐁만으로 입력 토큰이 같은 영어 시나리오보다 눈에 띄게 더 청구된다. supervisor에는 전체 메시지가 아니라 마지막 워커 산출 요약 + 라우팅에 필요한 필드만 넘겨라.
    • structured output 라우터가 한국어에서 잘 깨진다. with_structured_output으로 next 필드를 강제하지 않고 자유 텍스트로 "reviewer에게 보낼게요"를 파싱하면, 한국어 응답이 enum 밖 값을 내서 라우팅이 supervisor로 폴백되고 무한 루프가 된다. 목적지는 반드시 Literal[...] enum으로 못 박아라.

    recursion_limit를 올려도 되는 유일한 경우

    워커가 진짜로 많은 단계를 거치는 정당한 장기 작업(예: 50개 파일을 순회 처리)이고, 트레이스상 같은 노드 쌍의 반복이 아니라 서로 다른 작업이 누적되는 경우뿐이다. LangSmith에서 A→B→A→B 반복이 보이면 그건 한도 문제가 아니라 종료조건 부재다.

    Sources: GRAPH_RECURSION_LIMIT - LangChain Docs, LangGraph supervisor infinite loop issue #6731