본문으로 건너뛰기

챗봇 HITL 거부 결정적 취소

2026년 5월 26일, 챗봇은 거부된 쓰기 도구를 올바르게 건너뛰었지만 여전히 사용자에게 작업이 완료되었음을 알렸습니다. 해결 방법은 거부를 LLM 설명 결과 대신 그래프 소유 결과로 만드는 것이었습니다.

실패

깨진 흐름은 다음과 같습니다.

  1. 모델이 create_announcement 또는 send_sms와 같은 쓰기 도구를 요청했습니다.
  2. interrupt()는 그래프를 일시 중지하고 UI에 보류 중인 작업이 표시되었습니다.
  3. 사용자가 거부를 클릭한 경우
  4. 도구가 실행되지 않았습니다.
  5. 그래프는 여전히 모델을 통해 다시 라우팅되어 마치 작업이 성공한 것처럼 회전을 표현합니다.

이는 데이터 손실 버그가 아니라 안정성 버그였습니다. 백엔드 실행 경로를 이미 올바르게 건너뛰었습니다.

근본 원인

이전 거부 브랜치는 "사용자가 작업을 취소했습니다"와 같은 ToolMessage를 작성한 다음 항상 agent 노드로 다시 라우팅되었습니다.

따라서 사용자가 볼 수 있는 최종 문구는 LLM에 맡겨졌습니다. 표현을 보장할 만큼 강력한 취소 관련 규칙이 없으면 실제 모델은 마치 도구 호출이 완료된 것처럼 계속 응답할 수 있습니다.

코드에 수정 사항 유지

lumie-worker/services/chatbot/src/graph/agent.pysrc/graph/state.py는 이제 거부를 결정적 그래프 상태로 처리합니다.

  • ChatbotStatecancelled 플래그를 전달합니다.
  • tools_nodedecision.get("confirmed")가 거짓일 때 해당 플래그를 설정합니다.
  • route_after_tools는 모델에 대한 재시도 또는 재진입 전에 실행을 전용 cancel 노드로 보냅니다.
  • cancelled_node는 고정된 assistant 메시지를 내보냅니다.
  • 일치하는 ToolMessage가 계속 유지되므로 체크포인트된 도구 호출 기록이 프로토콜 유효 상태로 유지됩니다.

lumie-worker/services/chatbot/src/graph/agent.py의 대표적인 모양:

if state.get("cancelled"):
return "cancel"
if state.get("sql_retry_count", 0) >= cfg.sql_max_retries:
return "give_up"
return "agent"

회귀 가드

lumie-worker/services/chatbot/tests/test_graph.py는 이제 원하는 내레이션을 스크립팅하는 대신 구조적으로 동작을 보호합니다.

  • 가짜 모델은 초기 도구 호출 응답만 제공합니다.
  • 거부 경로는 execute_tool가 호출된 적이 없음을 검증합니다.
  • 최종 assistant 메시지는 _CANCELLED와 같아야 합니다.
  • model._index == 1는 모델이 거부된 후 다시 호출되지 않았음을 증명합니다.

마지막 주장이 중요한 변화입니다. 스크립트 모델이 잘못된 경로를 숨길 수 있더라도 잘못된 경로를 포착합니다.

레슨

  • 명시적인 사용자 거부는 프롬프트 문구가 아닌 그래프 로직으로 표현되어야 합니다.
  • 모델이 해당 차례의 분기 결과를 볼 수 없는 경우에도 프로토콜 정확성을 위해 ToolMessage가 필요할 수 있습니다.
  • 스크립트 모델 테스트에는 경로 모양이 최종 텍스트만큼 중요한 경우 호출 횟수 어설션이 필요합니다.

이 수정 사항에 대한 더 광범위한 컷오버 컨텍스트는 LangGraph Chatbot Production Cutover에서 다룹니다.