Published on

LangChain 멀티에이전트 무한루프 막는 종료조건·가드레일

Authors

멀티에이전트는 잘 돌아갈 때는 사람처럼 협업하지만, 한 번 꼬이면 "조금만 더"를 외치며 끝없이 회전합니다. 특히 LangChain 기반에서 Supervisor가 작업을 분배하고 Worker가 툴을 호출하는 구조는, 종료조건이 명시적으로 설계되지 않으면 대화가 곧 실행 그래프가 되어 무한 루프가 되기 쉽습니다.

이 글은 멀티에이전트 무한 루프를 원인별로 분류하고, 이를 막기 위한 종료조건(Stop conditions)가드레일(Guardrails) 을 코드 레벨에서 어떻게 넣을지 정리합니다. 또한 “멈추는 것”만이 아니라 “멈추기 전에 더 나쁜 상태로 망가지지 않게” 만드는 패턴까지 다룹니다.

참고로 프롬프트 안전장치 관점은 아래 글도 함께 보면 설계가 빨라집니다.

멀티에이전트가 무한 루프에 빠지는 전형적인 6가지 패턴

1) 목표가 done으로 수렴하지 않는 문제 정의

에이전트에게 “최적화해줘”, “더 개선해줘”처럼 상태가 닫히지 않는 목표를 주면, 모델은 계속 개선 여지를 만들어냅니다. 이 경우 종료조건이 없으면 "개선"은 무한합니다.

2) Supervisor의 라우팅이 자기강화 루프가 되는 경우

Supervisor가 "정보 부족"을 이유로 Worker를 재호출하고, Worker는 "추가 확인 필요"로 다시 Supervisor에게 되돌립니다. 둘 다 논리적으로는 맞아 보이지만, 결정 규칙이 없으면 영원히 핑퐁합니다.

3) Tool 호출 실패가 재시도 루프로 변하는 경우

네트워크 오류, 429 레이트리밋, 스키마 불일치 같은 실패가 발생했을 때, 에이전트가 “다시 시도”를 계속 선택하면 무한 재시도 루프가 됩니다. 중요한 포인트는 모델이 실패 원인을 정확히 이해하지 못하면, 재시도 조건이 영원히 참이 됩니다.

4) 메모리/컨텍스트 오염으로 인한 반복

에이전트가 이전 메시지에서 잘못된 가정(예: 잘못된 ID, 잘못된 API 베이스 URL)을 메모리에 저장하면, 이후 단계에서 계속 같은 실수를 반복합니다. 이때는 “종료”가 아니라 “정정”이 필요합니다.

5) 평가자(critic) 에이전트가 무한히 까는 경우

Critic이 "근거 부족", "더 엄밀히"만 반복하면, 생성자(agent)는 계속 보강하고 critic은 계속 불만족합니다. 평가 기준이 정량화되지 않으면 루프가 닫히지 않습니다.

6) 상태 전이가 없는 자유형 대화 그래프

LangGraph 등으로 그래프를 구성하더라도, 상태가 "계획"에서 "실행", "검증", "종료"로 전이하는 규칙이 없으면 그래프는 사실상 무한 while 루프입니다.

종료조건 설계: 언제 끝났다고 선언할 것인가

종료조건은 “최대 N번 돌면 끝” 같은 단순 제한만으로는 부족합니다. 실무에서는 의미 기반 종료 + 예산 기반 종료 + 실패 기반 종료를 함께 둬야 합니다.

1) 의미 기반 종료: done을 구조화된 스키마로 강제

가장 강력한 패턴은 Supervisor가 매 턴 "다음 행동"을 자연어로 말하지 못하게 하고, 구조화된 결정만 내리게 하는 것입니다.

예: Supervisor의 출력은 반드시 {"action": "delegate" | "finish" | "clarify", ...} 중 하나.

from typing import Literal, Optional
from pydantic import BaseModel, Field

class SupervisorDecision(BaseModel):
    action: Literal["delegate", "finish", "clarify"]
    target_agent: Optional[str] = None
    reason: str = Field(..., min_length=1)
    final_answer: Optional[str] = None

# LLM 출력은 반드시 이 스키마로 파싱되도록 강제

핵심은 finish가 선택될 수 있는 명확한 완료 정의를 함께 제공하는 것입니다.

  • 산출물 포맷(예: JSON, 표, 코드 diff)
  • 필수 항목 체크리스트
  • 성공 조건(예: 테스트 통과, 특정 필드 채움)

2) 예산 기반 종료: step, token, cost, wall-clock

멀티에이전트는 단일 에이전트보다 비용 폭주가 빠릅니다. 예산은 중복으로 거는 게 안전합니다.

  • 최대 스텝 수: max_steps
  • 최대 토큰: max_tokens_total
  • 최대 비용: max_cost_usd
  • 최대 시간: timeout_seconds

이 중 하나라도 초과하면 finish가 아니라 강제 중단 + 요약 반환이 실무적으로 유용합니다.

import time

class Budget:
    def __init__(self, max_steps: int, timeout_seconds: int):
        self.max_steps = max_steps
        self.timeout_seconds = timeout_seconds
        self.started_at = time.time()
        self.steps = 0

    def tick(self):
        self.steps += 1
        if self.steps > self.max_steps:
            raise RuntimeError("budget_exceeded: max_steps")
        if time.time() - self.started_at > self.timeout_seconds:
            raise RuntimeError("budget_exceeded: timeout")

3) 실패 기반 종료: 재시도는 정책으로 제한

툴 실패는 “에이전트가 알아서”가 아니라 플랫폼 정책으로 제한해야 합니다.

  • 동일 오류 코드 연속 k회면 중단
  • 지수 백오프 + 최대 재시도 횟수
  • 특정 오류는 즉시 중단(예: 인증 실패)
import random
import time

RETRYABLE = {"rate_limited", "timeout", "temporary_unavailable"}

def call_with_retry(fn, *, max_retries: int = 3):
    last_err = None
    for attempt in range(max_retries + 1):
        try:
            return fn()
        except Exception as e:
            last_err = e
            code = getattr(e, "code", "unknown")
            if code not in RETRYABLE or attempt == max_retries:
                raise
            sleep = (2 ** attempt) + random.random()
            time.sleep(sleep)
    raise last_err

여기서 중요한 점은 “재시도 여부 판단”을 LLM에게 맡기지 않는 것입니다. LLM은 실패의 원인을 정확히 분류하지 못할 수 있고, 그 순간부터 무한 재시도 루프가 시작됩니다.

가드레일 설계: 끝나기 전에 망가지지 않게

종료조건이 “언제 멈출지”라면, 가드레일은 “어떻게 안전하게 돌릴지”입니다.

1) 상태머신으로 전이를 제한

멀티에이전트는 "대화"가 아니라 "워크플로"로 다뤄야 합니다. 최소한 다음 상태를 두는 것을 권장합니다.

  • PLAN
  • ACT
  • VERIFY
  • FINISH
  • FAIL_SAFE

각 상태에서 가능한 다음 상태를 제한하면, PLAN으로 계속 되돌아가는 루프 같은 것을 구조적으로 차단할 수 있습니다.

from enum import Enum

class Phase(str, Enum):
    PLAN = "plan"
    ACT = "act"
    VERIFY = "verify"
    FINISH = "finish"
    FAIL_SAFE = "fail_safe"

ALLOWED_TRANSITIONS = {
    Phase.PLAN: {Phase.ACT, Phase.FAIL_SAFE},
    Phase.ACT: {Phase.VERIFY, Phase.FAIL_SAFE},
    Phase.VERIFY: {Phase.ACT, Phase.FINISH, Phase.FAIL_SAFE},
    Phase.FINISH: set(),
    Phase.FAIL_SAFE: set(),
}

def transition(cur: Phase, nxt: Phase) -> Phase:
    if nxt not in ALLOWED_TRANSITIONS[cur]:
        raise RuntimeError(f"invalid_transition: {cur} to {nxt}")
    return nxt

이 패턴은 LangGraph를 쓰든, 직접 루프를 돌리든 동일하게 적용됩니다.

2) 반복 감지 가드: 동일 행동/동일 툴 호출 시그니처 차단

무한 루프의 대부분은 “사실상 같은 행동”을 반복합니다. 따라서 매 스텝의 핵심을 해시로 남기고, 같은 시그니처가 연속되면 중단하거나 전략을 바꿔야 합니다.

  • 에이전트 이름
  • 선택한 툴 이름
  • 툴 입력 파라미터(정규화)
  • Supervisor의 결정 액션
import hashlib
import json

class LoopDetector:
    def __init__(self, max_same: int = 3):
        self.max_same = max_same
        self.last = None
        self.same_count = 0

    def check(self, signature_obj: dict):
        raw = json.dumps(signature_obj, sort_keys=True, ensure_ascii=False)
        sig = hashlib.sha256(raw.encode("utf-8")).hexdigest()

        if sig == self.last:
            self.same_count += 1
        else:
            self.last = sig
            self.same_count = 1

        if self.same_count >= self.max_same:
            raise RuntimeError("loop_detected: repeated_signature")

실무에서는 max_same을 2~4 정도로 두고, 감지 시 FAIL_SAFE로 전이해 “현재까지 결과 요약 + 사용자에게 질문”으로 마무리하는 편이 좋습니다.

3) 검증 레이어: LLM 출력은 항상 파서검증기를 통과

무한 루프는 종종 “출력 형식 불일치”에서 시작됩니다. Supervisor가 JSON을 내야 하는데 자연어로 내거나, Worker가 툴 인자를 누락하면 Supervisor가 "다시"를 시키고 루프가 됩니다.

따라서 LLM 응답은

  • 스키마 파싱 실패 시 즉시 재시도 1회만
  • 그래도 실패하면 강제 종료

같은 정책을 둬야 합니다.

from pydantic import ValidationError

def parse_or_fail(model_cls, text: str, *, allow_retry: bool = True):
    try:
        return model_cls.model_validate_json(text)
    except ValidationError:
        if not allow_retry:
            raise
        # 재시도는 1회만 허용하는 식으로 제한
        raise RuntimeError("schema_parse_failed")

4) “더 조사해”를 막는 정보요청 한도

Supervisor가 계속 "추가 정보"를 요구하면 루프가 됩니다. 이때는 clarify 액션을 별도로 만들고, clarify는 최대 1~2회만 허용하세요.

  • clarify_count가 임계치를 넘으면 FAIL_SAFE
  • clarify는 반드시 구체 질문 1~3개만 허용

이 패턴은 MSA에서 보상 트랜잭션을 무한 재실행하지 않도록 멱등성과 재시도 정책을 두는 것과 유사합니다.

LangChain 멀티에이전트 루프에 가드레일을 거는 예시

아래는 “Supervisor가 결정을 내리고 Worker가 실행”하는 단순 루프를 예시로, 종료조건과 가드레일을 한 곳에 묶은 형태입니다. LangChain의 구체 클래스는 버전에 따라 다를 수 있으니, 핵심은 결정 스키마 + 예산 + 반복 감지 + 상태 전이 조합입니다.

from dataclasses import dataclass
from typing import Optional

@dataclass
class RunState:
    phase: str
    clarify_count: int = 0
    last_result: Optional[str] = None

budget = Budget(max_steps=20, timeout_seconds=60)
loop_detector = LoopDetector(max_same=3)
state = RunState(phase="plan")

while True:
    budget.tick()

    # 1) Supervisor 결정 받기 (스키마 강제)
    decision = get_supervisor_decision(state)  # SupervisorDecision

    # 2) 반복 감지 시그니처
    loop_detector.check({
        "phase": state.phase,
        "action": decision.action,
        "target": decision.target_agent,
    })

    # 3) 상태 전이 및 실행
    if decision.action == "finish":
        state.phase = "finish"
        return decision.final_answer or state.last_result or ""

    if decision.action == "clarify":
        state.clarify_count += 1
        if state.clarify_count > 2:
            state.phase = "fail_safe"
            return "추가 정보 요청이 반복되어 중단합니다. 목표/제약을 구체화해 주세요."
        question = decision.reason
        return f"추가 확인이 필요합니다: {question}"

    if decision.action == "delegate":
        state.phase = "act"
        result = run_worker(decision.target_agent, state)
        state.last_result = result
        state.phase = "verify"

        # 4) 검증 단계에서 실패하면 제한적으로 재실행
        ok = verify_result(result)
        if ok:
            state.phase = "finish"
            return result
        else:
            # verify 실패가 반복되면 fail_safe로
            continue

이 예시의 포인트는 다음과 같습니다.

  • Supervisor에게 finish를 선택할 수 있는 구조를 줌
  • clarify를 별도 액션으로 만들고 횟수를 제한
  • 동일한 결정 반복을 해시로 탐지
  • plan로 무한 회귀하지 않게 상태를 제한

Critic-Generator 루프를 닫는 실전 규칙

Critic이 있는 구조에서 무한 루프를 막으려면 “Critic이 무엇을 만족하면 끝인가”를 수치화해야 합니다.

권장 패턴은 아래 중 하나입니다.

1) Critic 점수 기반 종료

  • Critic이 score0부터 1로 평가
  • score0.8 이상이면 종료
  • 최대 2~3회까지만 개선

2) 체크리스트 기반 종료

  • 필수 항목 N
  • 누락 항목이 0이면 종료
  • 누락이 남아도 개선 반복 횟수 초과 시 종료

이때도 Critic 출력은 반드시 스키마로 고정하세요.

class CriticVerdict(BaseModel):
    score: float = Field(..., ge=0.0, le=1.0)
    missing: list[str] = Field(default_factory=list)
    must_fix: bool = False

missing이 줄지 않는다면, 더 돌려도 결과가 좋아지지 않는다는 신호입니다. 이 경우에는 개선 루프를 멈추고 “현재 최선 + 남은 리스크”를 사용자에게 투명하게 반환하는 편이 좋습니다.

운영 관점 체크리스트: 무한 루프를 “사고”로 만들지 않기

멀티에이전트의 무한 루프는 개발 단계에서는 버그지만, 운영에서는 비용 사고입니다. 다음 항목을 로그/메트릭으로 남기면 조기 탐지가 가능합니다.

  • 대화/실행 step
  • 에이전트별 호출 횟수
  • 툴별 실패율과 재시도 횟수
  • loop_detected 발생 빈도
  • budget_exceeded 발생 빈도
  • 사용자 요청 유형별 평균 비용

그리고 실패 시에는 “아무것도 못함”이 아니라 아래를 반환하도록 설계하세요.

  • 현재까지의 중간 결과 요약
  • 실패 원인(예: 예산 초과, 반복 감지, 스키마 파싱 실패)
  • 사용자가 다음에 제공해야 할 정보
  • 재실행 시 바꿔야 할 옵션(예: 예산 상향, 다른 툴 사용)

이런 fail_safe 응답은 시스템 신뢰도를 크게 올립니다.

결론: 종료조건은 기능이 아니라 제품 안전장치

LangChain 멀티에이전트에서 무한 루프를 막는 핵심은 “모델이 알아서 멈추겠지”를 버리고, 다음을 시스템 레벨 정책으로 넣는 것입니다.

  • Supervisor 결정을 스키마로 강제하고 finish를 명시적으로 제공
  • step, 시간, 비용 예산을 중복으로 제한
  • 툴 실패 재시도는 LLM이 아니라 코드 정책으로 제한
  • 상태머신으로 전이를 닫고, 반복 시그니처를 감지
  • 실패 시 fail_safe로 요약/질문을 반환

이 조합을 적용하면, 멀티에이전트는 “똑똑하지만 불안정한 데모”에서 “운영 가능한 워크플로 엔진”으로 한 단계 올라갑니다.