멀티에이전트 핸드오프 핑퐁으로 recursion_limit 25 터뜨린 회고 — 종료조건이 핸드오프 계약에 없었다
이경민
@kevin_lee
사내 운영툴에 "문서 검토 → 정정 → 재검토" 흐름을 LangGraph 멀티에이전트로 짜다가, 데모에선 잘 돌던 그래프가 실데이터 한 건에서 GraphRecursionError로 죽었다. 추적해보니 토폴로지 문제가 아니라 핸드오프 계약에 종료조건이 빠진 거였다. 재현부터 고친 diff까지 정리한다.
환경
- LangGraph 1.x (Python),
Command(goto=...)기반 핸드오프 - 노드 3개:
reviewer(검토) ↔editor(정정) +supervisor(라우팅) - 모델은 사내 게이트웨이 경유 Claude 계열, 서울 리전 호출
- 그래프 구조: supervisor가 reviewer/editor로 보내고, 두 워커가 서로에게도 직접 핸드오프 가능하게 열어둔 게 화근이었다
재현
문제는 입력 데이터에 의존했다. 데모 시드(잘 정제된 문단)에선 reviewer가 한 번 "OK"를 내고 끝났다. 실데이터(표가 섞이고 각주 깨진 문서)를 넣으면 이렇게 흘렀다.
reviewer: "각주 3번이 본문과 안 맞음, 정정 필요" →editor로 핸드오프editor: 각주를 본문 기준으로 수정 →reviewer로 다시 핸드오프 ("내가 고쳤으니 봐줘")reviewer: 이번엔 본문 쪽이 각주랑 안 맞다고 판단 → 다시editor로- 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 tokens | editor input tokens |
|---|---|---|
| 1 | 약 2,100 | 약 2,600 |
| 5 | 약 8,400 | 약 9,100 |
| 10 | 약 16,800 | 약 17,500 |
전체 히스토리를 messages에 그대로 누적해 넘긴 탓에 라운드마다 거의 선형으로 늘었다. 한글 문서라 영어 대비 토큰이 1.5~2배여서 체감 비용은 더 컸다. recursion_limit을 올린 건 무한루프를 더 비싼 무한루프로 바꾼 짓이었다.
원인
두 가지가 겹쳤다.
-
종료조건이 핸드오프 계약 밖에 있었다.
Command(goto="editor")는 어디로 갈지만 정하지 언제 멈출지는 정하지 않는다. 멈춤 판단을 LLM의 자율 판단에 통째로 맡겼는데, 수렴 안 되는 입력에선 영원히 안 멈춘다.recursion_limit은 종료조건이 아니라 안전망(circuit breaker)이다. 안전망이 발동했다는 건 설계에 진짜 종료조건이 없다는 신호인데, 그걸 한도 상향으로 막으려 한 게 오진이었다. -
양방향 자유 핸드오프 토폴로지. 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 자율 판단에 맡기면, 수렴 안 되는 입력 하나가 그래프 전체를 무한루프로 끌고 간다. 멈춤은 계약에 명시하고, 멈출 권한은 한 곳에 둔다.