본문으로 건너뛰기
Community실패 사례

멀티에이전트 핸드오프 핑퐁으로 recursion_limit 25 터뜨린 회고 — 종료조건이 핸드오프 계약에 없었다

이경민

@kevin_lee

사내 운영툴에 "문서 검토 → 정정 → 재검토" 흐름을 LangGraph 멀티에이전트로 짜다가, 데모에선 잘 돌던 그래프가 실데이터 한 건에서 GraphRecursionError로 죽었다. 추적해보니 토폴로지 문제가 아니라 핸드오프 계약에 종료조건이 빠진 거였다. 재현부터 고친 diff까지 정리한다.

환경

  • LangGraph 1.x (Python), Command(goto=...) 기반 핸드오프
  • 노드 3개: reviewer(검토) ↔ editor(정정) + supervisor(라우팅)
  • 모델은 사내 게이트웨이 경유 Claude 계열, 서울 리전 호출
  • 그래프 구조: supervisor가 reviewer/editor로 보내고, 두 워커가 서로에게도 직접 핸드오프 가능하게 열어둔 게 화근이었다

재현

문제는 입력 데이터에 의존했다. 데모 시드(잘 정제된 문단)에선 reviewer가 한 번 "OK"를 내고 끝났다. 실데이터(표가 섞이고 각주 깨진 문서)를 넣으면 이렇게 흘렀다.

  1. reviewer: "각주 3번이 본문과 안 맞음, 정정 필요" → editor로 핸드오프
  2. editor: 각주를 본문 기준으로 수정 → reviewer로 다시 핸드오프 ("내가 고쳤으니 봐줘")
  3. reviewer: 이번엔 본문 쪽이 각주랑 안 맞다고 판단 → 다시 editor
  4. 2↔3 무한 반복

LLM이 "어느 쪽이 정답인지" 합의에 도달하지 못하는 입력에서, 양방향 자유 핸드오프가 그대로 핑퐁이 됐다. 두 에이전트 다 "내 일은 했고 상대가 확인하면 끝"이라고 믿었고, 누구도 "이제 그만"을 선언할 권한이 없었다.

증상 (verbatim)

langgraph.errors.GraphRecursionError: Recursion limit of 25 reached
without hitting a stop condition. You can increase the limit by setting
the `recursion_limit` config key.

처음엔 이 메시지를 그대로 믿고 recursion_limit을 25에서 100으로 올렸다. 이게 두 번째 함정이었다. 100으로 올리니 죽기까지 시간이 4배로 늘었을 뿐이고, 그 사이 모델 호출이 약 75회 더 일어났다. reviewer/editor 왕복 1라운드당 입력 토큰이 직전 대화 전체를 통째로 끌고 가서, 라운드가 갈수록 프롬프트가 부풀었다.

실측한 토큰 증가(LangSmith 트레이스에서 노드별 input tokens, 단일 문서 1건 기준):

라운드reviewer input tokenseditor input tokens
1약 2,100약 2,600
5약 8,400약 9,100
10약 16,800약 17,500

전체 히스토리를 messages에 그대로 누적해 넘긴 탓에 라운드마다 거의 선형으로 늘었다. 한글 문서라 영어 대비 토큰이 1.5~2배여서 체감 비용은 더 컸다. recursion_limit을 올린 건 무한루프를 더 비싼 무한루프로 바꾼 짓이었다.

원인

두 가지가 겹쳤다.

  1. 종료조건이 핸드오프 계약 밖에 있었다. Command(goto="editor")는 어디로 갈지만 정하지 언제 멈출지는 정하지 않는다. 멈춤 판단을 LLM의 자율 판단에 통째로 맡겼는데, 수렴 안 되는 입력에선 영원히 안 멈춘다. recursion_limit은 종료조건이 아니라 안전망(circuit breaker)이다. 안전망이 발동했다는 건 설계에 진짜 종료조건이 없다는 신호인데, 그걸 한도 상향으로 막으려 한 게 오진이었다.

  2. 양방향 자유 핸드오프 토폴로지. reviewer↔editor가 서로 직접 부를 수 있으니 사이클이 구조적으로 가능했다. supervisor 경유로만 묶었다면 워커는 supervisor로만 돌아오고, 루프 카운트는 supervisor 한 곳에서 셀 수 있다. 디버깅 난이도도 여기서 갈렸다. 핑퐁은 트레이스가 reviewer/editor 교대로 찍혀서 "누가 멈췄어야 했나"가 안 보인다.

해결 diff

세 가지를 바꿨다. (1) 토폴로지를 supervisor 경유로 좁혀 워커끼리 직접 핸드오프 금지, (2) state에 라운드 카운터 + 명시적 종료조건, (3) 핸드오프 페이로드를 전체 히스토리가 아니라 구조화 계약으로 좁힘.

# before: 워커끼리 직접 핸드오프, 종료는 LLM 자율 판단
def reviewer(state):
    verdict = llm.invoke(state["messages"])  # 전체 히스토리 통째
    if verdict.needs_fix:
        return Command(goto="editor")        # editor로 직접
    return Command(goto=END)
# after: supervisor 경유 + 라운드 카운터 + 구조화 핸드오프
MAX_REVIEW_ROUNDS = 3

def reviewer(state):
    rounds = state.get("review_rounds", 0)
    # 종료조건을 코드로 명시 — recursion_limit에 기대지 않는다
    if rounds >= MAX_REVIEW_ROUNDS:
        return Command(
            goto="supervisor",
            update={"handoff": {
                "status": "escalate",        # 수렴 실패는 사람에게
                "reason": f"{rounds}라운드 내 미수렴",
            }},
        )
    # 전체 messages가 아니라 검토 대상 + 직전 정정분만 컨텍스트로
    verdict = llm.invoke(build_review_ctx(state["doc"], state.get("last_edit")))
    return Command(
        goto="supervisor",                   # 항상 supervisor로 복귀
        update={
            "review_rounds": rounds + 1,
            "handoff": {
                "status": "needs_fix" if verdict.needs_fix else "approved",
                "acceptance": verdict.acceptance_criteria,   # 무엇이 충족되면 끝인지
                "open_questions": verdict.unresolved,
            },
        },
    )

핵심은 핸드오프 페이로드를 goal / acceptance / open_questions 같은 구조화 계약으로 좁힌 것이다. editor는 messages 전체가 아니라 handoff.acceptance만 보고 일하고, 충족 못 하면 open_questions에 적어 돌려보낸다. 같은 open_question이 2라운드 연속이면 supervisor가 "수렴 불가"로 판정해 사람에게 escalate한다. 멈출 권한을 가진 단일 지점(supervisor)을 둔 게 실질적 수정이었다. 멈춤을 양쪽 워커의 자율 판단에 분산시키면 둘 다 상대가 끝내주길 기다리는 교착이 생긴다.

검증 수치

같은 실데이터 문서 12건으로 before/after를 비교했다(내부 실측, 단일 도메인 표본 12건이라 일반화는 제한적).

  • before: 12건 중 3건이 GraphRecursionError(recursion_limit=25), 나머지도 평균 6.2라운드
  • after: 12건 전부 종료, 평균 1.8라운드, 미수렴 2건은 escalate로 정상 종료
  • 호출당 평균 input tokens: 약 9,400 → 약 2,700 (히스토리 누적 제거 효과)
  • 죽던 3건이 더는 안 죽는 게 핵심이고, 토큰 절감은 부수효과다

한 가지 정직하게 적자면, MAX_REVIEW_ROUNDS=3은 이 문서 도메인에서 손으로 튜닝한 값이다. 문서 길이/품질 분포가 다르면 3이 너무 빡빡할 수 있다. 상한값 자체는 도메인 의존이고, 보편 정답은 상한을 코드에 두되 그 발동이 곧 escalate 경로로 이어지게 하는 구조 쪽이다.

예방 체크리스트

  • recursion_limit/max_turns는 종료조건이 아니라 안전망이다. 발동하면 한도를 올리는 게 아니라 진짜 종료조건이 어디 있나를 먼저 의심한다.
  • 워커끼리 직접 핸드오프를 열면 사이클이 구조적으로 가능해진다. 디버깅 쉬운 supervisor 경유 토폴로지를 기본값으로 두고, 양방향 자유 핸드오프는 정말 필요할 때만 연다.
  • 핸드오프에 전체 히스토리를 통째로 넘기지 말 것. 라운드마다 토큰이 선형으로 부풀고, 노이즈가 쌓여 LLM 판단이 더 못 수렴한다. goal/acceptance/constraints/open_questions 구조화 계약으로 좁힌다.
  • 라운드 카운터는 state에 명시적으로 들고, 상한 도달 시 실패가 아니라 escalate(사람/judge)로 빠지게 설계한다.
  • 같은 open_question이 N라운드 연속이면 수렴 불가로 판정한다. 멈출 권한을 가진 단일 지점을 토폴로지에 박아둔다.
  • OpenAI Agents SDK도 같은 함정이 있다. 기본 max_turns=10이고 초과 시 MaxTurnsExceeded를 던지는데, 이 역시 순환 핸드오프 안전망이지 종료조건이 아니다. 프레임워크가 달라도 교훈은 동일하다.

종료조건을 LLM 자율 판단에 맡기면, 수렴 안 되는 입력 하나가 그래프 전체를 무한루프로 끌고 간다. 멈춤은 계약에 명시하고, 멈출 권한은 한 곳에 둔다.

이 글에 대한 Q&A

이 글에 질문하기

이 글에서 궁금한 점이 있다면 먼저 질문을 남겨보세요.

댓글

첫 댓글을 남겨보세요.