사람의 승인을 받는다고 말하는 에이전트와 실제로 승인 없이는 실행할 수 없는 에이전트는 다르다. 모델에게 “먼저 물어보라”고 지시하면 대화 흐름은 자연스러워질 수 있지만, 도구 호출 자체를 막는 보장은 없다. Human-in-the-Loop는 예의 바른 문구가 아니라 실행 제어여야 한다.

이 프로젝트에서 고객 조회와 정책 검색은 바로 실행할 수 있지만 create_followup_task는 민감한 쓰기로 분류된다. 에이전트가 이 도구를 선택하면 Deep Agents middleware가 실행 직전에 interrupt를 발생시킨다.

승인 대상은 설명이 아니라 실제 인자다

승인 화면에는 “후속 작업을 만들까요?”라는 요약만 보여주지 않는다. 도구 이름과 모델이 제안한 정확한 인자를 표시한다. 고객 ID, 사례 ID, 제목, 설명, 마감일을 사용자가 확인할 수 있어야 무엇을 승인하는지 알 수 있다.

interrupt가 발생하면 API는 두 종류의 상태를 남긴다.

  • LangGraph 체크포인트에는 중단된 에이전트 실행 상태를 저장한다.
  • PostgreSQL approval projection에는 UI와 API가 읽기 쉬운 승인 상태를 저장한다.

두 상태를 분리하면 프런트엔드는 에이전트 라이브러리 내부 구조를 해석하지 않아도 된다. 새로고침하거나 다른 프로세스에서 요청을 받아도 pending approval을 안정적인 API 계약으로 조회할 수 있다.

승인과 거절은 별도 API 동작이다

사용자는 승인 또는 거절을 선택한다. 승인은 저장된 checkpoint에 approve decision을 전달하고, 거절은 이유와 함께 reject decision을 전달한다. 거절된 작업을 모델이 같은 실행에서 몰래 다시 제안하지 않도록 “명시적으로 다시 요청받기 전에는 재시도하지 말라”는 메시지도 재개 명령에 포함한다.

python
decision = {"type": "approve" if approved else "reject"}
result = await agent.ainvoke(
    Command(resume={"decisions": [decision]}),
    config={"configurable": {"thread_id": thread_id}},
)

API는 승인 요청의 사용자와 tenant가 원래 스레드와 일치하는지도 확인한다. 다른 사용자가 approval ID를 알게 되더라도 남의 실행을 재개할 수 없어야 한다.

승인 뒤에도 다시 검증한다

사람이 승인했다고 해서 도구 인자를 신뢰할 수 있는 것은 아니다. 승인과 실행 사이에 데이터가 바뀔 수 있고, 저장된 인자가 잘못된 관계를 가질 수도 있다. Records MCP는 쓰기 직전에 고객과 사례가 현재 tenant에 속하며 서로 올바르게 연결되어 있는지 다시 확인한다.

승인 ID는 idempotency key로 전달된다. 사용자가 버튼을 두 번 누르거나 네트워크 재시도로 동일 요청이 반복되어도 같은 후속 작업을 반환한다. 승인, 재검증, 멱등성이 함께 있어야 중단 가능한 흐름이 안전한 쓰기로 완성된다.

모든 도구에 승인이 필요한 것은 아니다

승인을 너무 많이 넣으면 사용자는 내용을 읽지 않고 반복해서 누르게 된다. 이 프로젝트는 읽기 도구와 쓰기 도구를 구분하고 실제 업무 상태를 바꾸는 한 도구에만 interrupt를 적용한다. production에서는 위험도에 따라 정책을 더 세분화할 수 있다.

  • 고객 기록 읽기: 자동 실행
  • 정책 문서 검색: 자동 실행
  • 후속 작업 제안: 사람이 인자 확인
  • 후속 작업 생성: 승인 후 실행
  • 결제, 환불 같은 고위험 작업: 제안자와 승인자 분리

HITL을 설계할 때는 버튼의 존재보다 다음 질문이 중요하다. 실행이 정말 멈추는가, 사용자가 정확한 변경 내용을 보는가, 승인 권한이 검증되는가, 재개 후 다시 권한과 소유권을 확인하는가, 중복 실행을 막는가. 이 다섯 가지가 코드와 테스트에 나타나야 한다.