LangGraph에서 에이전트가 멈추는 이유 – Human in the Loop 설계 패턴 정리

에이전트가 멈춰야 할 때 멈추지 않거나, 멈추지 말아야 할 때 멈추는 문제를 LangGraph로 멀티에이전트 시스템을 운영하면서 꽤 오래 고민했다. 이론적으로 Human-in-the-Loop는 단순하다. 에이전트가 스스로 판단하기 어려운 지점에서 사람에게 확인을 요청하고, 사람의 입력을 받아서 다시 진행하는 것이다. 그런데 실제로 구현해보면 “어느 노드에 interrupt를 거냐”와 “체크포인터를 어떻게 설계하냐”가 에이전트 전체 흐름을 좌우한다는 걸 알게 된다.

이 글은 내가 직접 겪은 실패 패턴과 그걸 고친 설계 기준을 정리한 것이다. LangGraph 공식 문서에 Human-in-the-Loop 개념 설명은 있는데, 실제 프로덕션에서 어느 지점에 어떻게 넣어야 하는지는 직접 부딪혀보기 전에는 잘 모른다.

에이전트가 루프에 빠진 날

처음 LangGraph Supervisor 패턴으로 멀티에이전트를 운영하기 시작했을 때, 에이전트가 같은 도구를 반복 호출하면서 루프에 빠지는 문제를 겪었다. Researcher 서브에이전트가 검색 결과를 가져오고, Supervisor가 그 결과를 보고 “더 조사가 필요하다”고 판단해서 다시 Researcher에게 보내고, Researcher가 같은 검색을 반복하는 패턴이었다.

중단 조건이 없었다. 상태 그래프에 종료 조건을 걸어두긴 했는데, Supervisor의 판단 로직이 “정보가 충분한가”를 너무 느슨하게 평가하고 있어서 루프를 탈출하지 못했다. 결과적으로 토큰과 API 비용이 무한히 쌓이다가 외부 타임아웃에 걸려서 강제 종료됐다.

이 경험에서 얻은 첫 번째 교훈은 Human-in-the-Loop를 안전망으로 써야 한다는 거다. 에이전트가 특정 횟수 이상 같은 도구를 호출했거나, 상태가 일정 시간 이상 변하지 않았을 때 자동으로 사람에게 넘기는 조건이 필요하다. 완전 자율보다 사람의 확인을 받는 포인트를 설계 초기에 명확히 넣는 게 낫다.

어느 노드에 interrupt를 걸어야 하는가

LangGraph에서 interrupt_before 또는 interrupt_after로 특정 노드 앞뒤에 실행을 일시 정지시킬 수 있다. 문제는 어디에 거느냐다.

내가 찾은 기준은 “돌이키기 어려운 작업 직전”이다. 읽기 전용 작업(조회, 검색)은 실행해도 부작용이 없다. 그런데 쓰기 작업(DB 수정, 외부 API 호출, 이메일 발송, 결제 처리)은 한번 실행되면 되돌리기 어렵다. 이 경계에 interrupt를 거는 게 가장 자연스럽다.

graph = builder.compile(
    checkpointer=checkpointer,
    interrupt_before=["execute_payment", "send_email", "update_database"]
)

이렇게 하면 에이전트가 결제를 처리하기 직전, 이메일을 보내기 직전에 실행이 멈추고 사람의 확인을 기다린다. 사람이 승인하면 None 입력으로 재개하고, 수정이 필요하면 상태를 업데이트한 다음 재개할 수 있다.

처음에는 “판단이 필요한 모든 지점”에 interrupt를 걸려고 했다. 그랬더니 사람이 너무 자주 개입해야 해서 에이전트의 의미가 없어졌다. interrupt는 꼭 필요한 지점에만, 최소한으로 거는 게 맞다.

체크포인터 설계 – 상태를 어디에 어떻게 저장할 것인가

Human-in-the-Loop가 제대로 동작하려면 체크포인터가 필수다. 에이전트 실행을 일시 정지하면 그 시점의 상태를 어딘가에 저장해야 하고, 재개할 때 그 상태에서 정확히 다시 시작해야 한다.

LangGraph는 MemorySaverPostgresSaver를 기본으로 제공한다. MemorySaver는 인메모리라서 프로세스가 재시작되면 상태가 날아간다. 로컬 테스트에서는 괜찮은데, 프로덕션에서는 PostgresSaver나 Redis 기반 체크포인터를 써야 한다.

내가 운영하는 시스템에서는 PostgreSQL을 체크포인터 백엔드로 쓴다. RAG 파이프라인의 메타데이터가 이미 PostgreSQL에 있어서 인프라를 추가하지 않아도 되는 이유도 있고, 트랜잭션 보장이 되는 점도 중요했다. 에이전트 상태를 저장하는 도중에 장애가 나도, 불완전한 상태가 저장되지 않는다.

체크포인터 설계에서 하나 더 신경 쓴 부분이 thread_id다. LangGraph에서 각 대화 세션은 고유한 thread_id로 구분된다. 같은 thread_id로 재개하면 이전 상태에서 이어서 실행하고, 다른 thread_id를 쓰면 새 대화가 시작된다. 사용자별로 thread_id를 어떻게 관리할지, 오래된 thread를 언제 정리할지를 미리 설계해두지 않으면 체크포인터 DB가 빠르게 커진다.

상태 업데이트로 에이전트 방향 바꾸기

Human-in-the-Loop의 진짜 유용함은 단순한 승인/거절이 아니라 실행 중에 상태를 수정할 수 있다는 데 있다.

에이전트가 멈춘 시점에 현재 상태를 사람이 보고, 수정이 필요하면 상태를 업데이트해서 재개할 수 있다. 예를 들어 에이전트가 잘못된 검색어로 조회하려고 한다면, 사람이 검색어를 수정해서 상태에 넣고 다시 실행하면 된다.

# 현재 상태 확인
current_state = graph.get_state(config)

# 상태 수정 후 재개
graph.update_state(
    config,
    {"search_query": "수정된 검색어"},
    as_node="researcher"
)

# 재개
for event in graph.stream(None, config):
    print(event)

이 패턴을 쓰면서 가장 많이 활용한 사례가 Supervisor가 태스크를 잘못 라우팅했을 때다. 에이전트가 Researcher에게 보내야 할 태스크를 Writer에게 보내려는 상황에서, 사람이 라우팅 결정을 수정해서 올바른 서브에이전트로 보낼 수 있다. 에이전트 전체를 다시 돌리지 않아도 된다.

타임아웃과 자동 처리 – 사람이 응답 안 하면 어떻게 하나

Human-in-the-Loop에서 실제 운영에서 꼭 처리해야 하는 문제가 있다. 사람이 응답하지 않으면 어떻게 되냐는 거다.

인터럽트 후 사람이 수 시간 내에 응답하지 않으면 전체 워크플로가 멈춘 상태로 남아 있다. 이게 여러 개 쌓이면 운영 복잡도가 올라간다.

내가 쓰는 방법은 타임아웃 조건을 넣는 거다. 일정 시간 안에 사람의 응답이 없으면 “기본값으로 진행” 또는 “태스크 취소”를 자동으로 선택하도록 한다. 어떤 쪽을 선택할지는 해당 노드의 위험도에 따라 다르다. 읽기 전용이거나 낮은 위험도의 작업은 타임아웃 시 기본값으로 진행하고, 결제나 외부 발송처럼 위험도가 높은 작업은 타임아웃 시 취소로 처리한다.

알림 채널도 중요하다. 인터럽트가 발생했을 때 슬랙이나 이메일로 담당자에게 알려야 실제로 응답이 온다. 인터럽트 노드에서 알림을 보내는 로직을 같이 넣어두는 게 운영에서 필수다.

설계 원칙으로 정리하면

LangGraph Human-in-the-Loop를 운영하면서 정리된 원칙이 몇 가지 있다.

interrupt는 돌이키기 어려운 작업 직전에만 건다. 너무 자주 걸면 사람의 개입 부담이 커져서 에이전트를 쓰는 의미가 없어진다. 체크포인터는 반드시 영속 저장소를 쓴다. 인메모리 체크포인터는 테스트 전용이다. thread_id 관리 정책을 처음부터 설계한다. 나중에 바꾸기 어렵다. 타임아웃 조건을 반드시 넣는다. 응답 없는 인터럽트가 쌓이면 운영이 복잡해진다. 알림 채널과 연결한다. 인터럽트가 발생했는데 담당자가 모르면 의미가 없다.

이 다섯 가지를 처음 설계 단계에서 반영하면 나중에 운영하면서 겪는 문제가 눈에 띄게 줄어든다. 에이전트를 만드는 것보다 에이전트를 안전하게 운영하는 게 더 어렵다는 걸, Human-in-the-Loop를 설계하면서 다시 한번 확인하게 됐다.

Leave a Comment