LLMOps 19
HITL Trigger와 Human Review 노드 구현 감각
HITL을 UI 버튼 하나나 if문 하나로만 생각하면 설계가 흐려진다.
근거 · 교안 p55-p58
1장: 정책 기반 조건 함수: HITL Trigger
솔라는 모니터 한구석에 떠 있는 코드 스니펫을 노려보듯 응시하고 있었다. 에이전트 개발 관련 기술 문서를 읽다가 마주친, 밑줄까지 그어진 한 문장이 머릿속을 맴돌았다. ‘HITL Trigger는 정책 기반 조건 함수로 캡슐화된다.’
“이게 무슨 말이지?”
혼잣말처럼 튀어나온 목소리에, 옆에서 조용히 책을 읽던 언니 루나가 고개를 들었다.
“뭐가?”
“아니, 그냥… Human-in-the-Loop 말이야. 사람이 개입하는 시점을 정하는 거잖아. 난 그냥 사용자가 ‘도움이 필요해요’ 버튼을 누르거나, 아니면 시스템이 특정 에러 코드를 뱉으면 사람을 부르는, 그런 단순한 if문 같은 거라고 생각했거든.”
솔라는 답답하다는 듯이 손가락으로 허공에 if-then 구문을 그려 보였다.
“그런데 여긴 ‘정책 기반 조건 함수’라고 너무 거창하게 설명하잖아. 그냥 조건문 하나면 될 걸 왜 굳이 함수로 감싸고 ‘정책’이라는 말까지 붙이는지 모르겠어. 설계가 흐려지는 기분이야.”
루나는 솔라의 말을 잠자코 듣더니, 읽던 책을 덮고 자신의 노트북을 화면을 솔라 쪽으로 돌렸다. 복잡한 다이어그램 대신, 몇 줄 안 되는 깔끔한 코드 블록이 나타났다.
“이게 방금 말한 HITL 트리거 함수의 아주 간단한 버전이라고 생각해 봐. should_trigger_hitl이라는 이름의 함수야.”
def should_trigger_hitl(state: AgentState) -> bool:
# 조건 1: 생성된 메시지에 민감 용어가 포함되어 있는가?
if contains_sensitive_term(state.latest_output):
return True
# 조건 2: Critic 에이전트가 결과를 'REJECTED'로 판단했는가?
if state.critique_decision == "REJECTED":
return True
# 조건 3: 에이전트가 최대 반복 횟수를 초과했는가?
if state.loop_count > MAX_LOOPS:
return True
return False
“어…?”
솔라는 자기도 모르게 화면으로 몸을 기울였다. 자신이 상상했던 단순한 if문이 아니었다. 여러 개의 if문이 연달아 나타나 있었다.
“함수 이름 옆에 state: AgentState라고 쓰여있는 거 보이지?”
루나가 코드의 첫 줄을 가리켰다.
“응. AgentState라는 타입의 state라는 걸 입력으로 받는다는 뜻이네.”
“맞아. 저 AgentState를 에이전트의 모든 활동과 상태를 기록하는 한 권의 ‘항해 일지’라고 상상해 봐. 에이전트가 방금 무슨 말을 생성했는지(latest_output), 비평가(Critic)가 그 결과물을 어떻게 평가했는지(critique_decision), 그리고 지금까지 몇 번이나 같은 작업을 반복했는지(loop_count) 같은 정보가 전부 저 일지에 기록되는 거야.”
루나의 설명과 함께 코드를 다시 훑어보자, 비로소 각 조건문이 무엇을 하는지 명확하게 보이기 시작했다. 첫 번째 if문은 항해 일지에 적힌 ‘최신 발언’을 검사해서 민감한 단어가 있는지 확인한다. 두 번째 if문은 일지에 기록된 ‘비평가의 평가’가 ‘거절’인지 확인한다. 세 번째 if문은 ‘반복 횟수’가 정해진 한도를 넘었는지 본다.
“아…!”
솔라의 입에서 짧은 탄성이 터져 나왔다.
“그러니까 이건 단 하나의 사건에 반응하는 게 아니구나. 에이전트의 현재 상태, 그러니까 이 ‘항해 일지’에 기록된 여러 정보를 종합적으로 보고 판단하는 거네. 민감 단어 때문일 수도 있고, 결과가 나빠서일 수도 있고, 아니면 그냥 너무 오래 헤매고 있어서일 수도 있고.”
“바로 그거야.”
루나가 짧게 답했다.
“하나의 조건이 아니라, ‘사람의 검토가 필요한 상황’에 대한 여러 규칙들의 묶음인 셈이지. 회사의 운영 방침이나 서비스의 안전 기준 같은 것들. 그래서 그냥 ‘조건문’이 아니라 ‘정책(Policy)’이라고 부르는 거야. 그 정책을 담은 함수고.”
솔라는 고개를 끄덕였다. 안개가 걷히는 기분이었다. HITL 트리거는 사용자가 누르는 비상 버튼 같은 게 아니었다. 시스템 내부를 흐르는 데이터, 즉 AgentState를 끊임없이 감시하며 사전에 정의된 여러 ‘정책’을 위반하는 순간을 포착하는 자동화된 감시탑에 더 가까웠다. 여러 조건을 조합해서 판단해야 하니, 당연히 하나의 함수로 캡슐화하는 것이 훨씬 깔끔하고 관리하기도 쉬웠다. ‘정책 기반 조건 함수’라는 말이 더 이상 거창하게 들리지 않았다. 오히려 그 역할을 정확하게 설명하는 이름으로 보였다.
“이제 알겠어. 트리거가 왜 그냥 if문 한 줄이 아닌지. 에이전트의 상태 전체를 보고 여러 정책을 한꺼번에 확인하는 거였구나.”
솔라는 명확해진 개념을 머릿속으로 정리했다. 이제 ‘정책 기반 트리거’라는 도구를 손에 쥔 기분이었다. 사람의 개입이 필요한 시점을 정의할 땐, 단순히 하나의 사건이 아니라 에이전트의 누적된 상태를 기반으로 여러 규칙을 조합해야 한다는 감각이었다.
한 가지 의문이 풀리자, 곧바로 다음 질문이 고개를 들었다.
“좋아, 그럼 이 함수가 True를 반환해서 ‘사람 나와라!’ 하고 신호를 보냈다고 치자. 그럼 그 다음은? 사람한테는 뭐가 보이는데? 그냥 ‘계속 진행할까요? [예/아니오]’ 같은 단순한 확인 창이 뜨는 건가? 그것만으로는 좀 부족할 것 같은데… 만약 결과물을 수정해야 한다면 어떻게 하지?”
2장: 외부 개입 노드: Human Review
솔라의 질문이 채 끝나기도 전에, 루나는 키보드를 몇 번 두드렸다. 화면에 떠 있던 should_trigger_hitl 함수 아래로 새로운 코드 블록이 나타났다. 이전 함수와는 사뭇 다른 모양새였다. 솔라의 질문에 대한 대답은 말이 아니라, 눈앞에 나타난 새로운 코드 조각이었다.
루나는 아무 말 없이 노트북 화면을 솔라 쪽으로 조금 더 밀어주었다. 화면의 불빛이 솔라의 얼굴에 비쳤다. human_review라는 이름이 붙은 함수였다.
def human_review(state: AgentState) -> dict:
print("--- HUMAN REVIEW REQUIRED ---")
print(f"Latest Output: {state.latest_output}")
print(f"Critic Decision: {state.critique_decision}")
print(f"Loop Count: {state.loop_count}")
while True:
choice = input("Enter your decision (APPROVE, REVISE, ABORT): ").upper()
if choice in ["APPROVE", "REVISE", "ABORT"]:
# 사람의 결정이 상태에 추가된다.
return {"human_decision": choice}
“이게… 사람한테 보이는 화면이야?”
솔라는 코드를 찬찬히 뜯어보았다. 자신이 상상했던 단순한 [예/아니오] 팝업창과는 거리가 멀었다. 함수는 먼저 에이전트의 현재 상태, 즉 ‘항해 일지’(AgentState)의 중요한 부분들을 요약해서 보여주고 있었다. 에이전트가 마지막으로 뭘 만들었는지, 비평가(Critic)는 그걸 어떻게 판단했는지, 그리고 몇 번이나 시도했는지까지.
“단순히 계속할지 말지만 묻는 게 아니네.”
“사람이 결정을 내리려면 맥락이 필요하니까.”
루나가 처음으로 입을 열었다.
“에이전트가 왜 멈췄는지, 지금까지 어떤 일이 있었는지 모른 채로 ‘계속’이나 ‘중단’을 누르는 건 의미가 없지. 그래서 이 human_review 노드는 먼저 AgentState의 현재 상황을 요약해서 보여주는 역할을 해.”
솔라는 고개를 끄덕였다. 그제야 print문들이 눈에 들어왔다. 이건 맹목적인 확인 버튼이 아니라, 상황 보고서에 가까웠다. 그리고 더 중요한 부분이 있었다. input 함수로 사용자 입력을 받는 부분이었다.
“APPROVE, REVISE, ABORT… 선택지가 정해져 있구나.”
솔라의 목소리에 깨달음이 묻어났다. 그냥 ‘네’라고 대답하거나, ‘결과가 마음에 안 드니 고쳐줘’ 같은 자유로운 문장을 입력하는 게 아니었다. 시스템이 이해할 수 있는 세 가지의 명확한 명령 중 하나를 선택해야 했다.
APPROVE: 좋아, 이대로 진행해.REVISE: 마음에 안 들어. 다시 만들어 봐.ABORT: 이 작업은 완전히 중단해.
“맞아. 여기서 ‘비결정적’이라는 말이 중요해져.”
루나가 human_review 함수를 가리켰다.
“에이전트 입장에서는 human_review라는 노드를 호출했지만, 그 안에서 사람이 APPROVE, REVISE, ABORT 중 무엇을 선택할지는 예측할 수 없어. 마치 외부 API를 호출했는데, 그 결과값이 세 가지 중 하나로 오는 것과 비슷하지. 그래서 ‘External Tool처럼 동작한다’고 표현하는 거야. 에이전트의 통제 밖에서 결정이 이루어지고, 그 구조화된 결과값만 돌려받는 거지.”
“아!”
솔라는 무릎을 쳤다. 모든 조각이 맞춰지는 느낌이었다. human_review 노드는 단순한 UI 확인 버튼이 아니었다. 에이전트의 흐름을 잠시 멈추고, 외부 세계(사람)에 판단을 요청하는 하나의 독립된 ‘노드’였다. 이 노드는 사람에게 충분한 정보를 제공하고, 시스템이 알아들을 수 있는 ‘구조화된 선택지’를 받아오는 역할을 한다. 그 결과는 에이전트의 항해 일지, 즉 AgentState에 human_decision이라는 새로운 항목으로 기록된다.
이제 솔라는 사람의 개입이 어떻게 시스템에 통합되는지 명확하게 그릴 수 있었다. 그것은 애매한 ‘확인’ 절차가 아니라, 데이터를 보여주고 구조화된 결정을 받아 상태를 업데이트하는, 명확하게 정의된 인간 결정 통합 과정이었다.
“알겠어. should_trigger_hitl이 ‘사람이 필요한 시점’을 알려주는 감시탑이라면, human_review는 사람이 상황 보고를 받고 구체적인 명령을 내리는 관제실 같은 거구나. 그리고 그 명령은 AgentState에 딱 기록되는 거고.”
솔라는 자신이 이해한 바를 정리하며 확신에 찬 표정을 지었다. 하지만 이내 새로운 의문이 떠올랐다. 그녀의 시선이 다시 화면으로 향했다.
“그런데 이상한 점이 있어. 이전 단계에서 Critic이 REJECTED라고 판단해서 human_review가 호출됐다고 해보자. 그런데 사람이 APPROVE를 선택했어. 그럼 AgentState에는 critique_decision: 'REJECTED'와 human_decision: 'APPROVE'가 동시에 기록되는 거잖아? 기계는 거절했는데 사람은 승인한 상황… 대체 누구 말을 들어야 하는 거야? 시스템은 이제 뭘 해야 하지?”
3장: 흐름 조정: Supervisor 패턴
솔라의 질문은 허공에 맴돌지 않았다. 그 질문 자체가 눈앞의 모니터 화면에 구체적인 형태로 나타나 있었다. 솔라는 방금 전 자기가 끄적였던 메모 위에 시선을 고정했다. 에이전트의 현재 상태, 즉 AgentState를 간략하게 표현한 것이었다.
# AgentState Snapshot
{
...
"critique_decision": "REJECTED",
"human_decision": "APPROVE"
}
하나의 상태 객체 안에 공존하는 두 개의 상반된 결정. 기계의 ‘거절’과 인간의 ‘승인’. 이 모순된 신호를 받은 에이전트는 대체 어디로 가야 할까? 앞으로? 뒤로? 아니면 멈춰야 할까? 솔라는 마치 갈림길에 선 자동차처럼 에이전트의 다음 행보가 막막하게 느껴졌다. 단순한 if/else 분기 로직으로는 이 충돌을 해결할 수 없어 보였다.
그때, 루나가 마지막 코드 조각을 화면에 띄웠다. supervisor라는 이름의 함수였다.
def supervisor(state: AgentState) -> dict:
# AgentState에서 human과 critic의 결정을 가져온다.
human_decision = state.get("human_decision")
critique_decision = state.get("critique_decision")
# 1. 사람의 결정이 있다면, 최우선으로 따른다.
if human_decision:
if human_decision == "APPROVE":
# 사람이 승인하면, Critic의 REJECTED를 무시하고 종료.
return {"next_step": "FINISH"}
elif human_decision == "REVISE":
# 사람이 수정을 원하면, 다시 생성 노드로.
return {"next_step": "GENERATE", "human_decision": None}
else: # ABORT
# 사람이 중단을 원하면, 즉시 종료.
return {"next_step": "ABORT"}
# 2. 사람의 개입이 없었다면, Critic의 결정을 따른다.
if critique_decision == "REJECTED":
# Critic이 거절했다면, 다시 생성 노드로.
return {"next_step": "GENERATE"}
else: # APPROVED
# Critic이 승인했다면, 종료.
return {"next_step": "FINISH"}
“이게… 최종 결정권자구나.”
솔라는 코드를 보자마자 중얼거렸다. 자신이 상상했던 단순한 조건 분기문이 아니었다. 이것은 여러 결정들을 종합하여 우선순위에 따라 다음 행동을 결정하는 ‘조정’ 로직이었다.
“한번 따라가 볼까?”
루나가 솔라가 적어둔 메모, 즉 critique_decision: "REJECTED", human_decision: "APPROVE" 상태를 가리켰다. 솔라는 고개를 끄덕이고는 손가락으로 화면의 코드를 한 줄씩 짚어 내려가기 시작했다.
“자, supervisor 함수가 이 상태를 받았어. 먼저 human_decision을 확인하네. 우리 상태에선 APPROVE니까, if human_decision: 조건문이 참이 돼서 안으로 들어가.”
솔라의 손가락이 첫 번째 큰 if 블록 안으로 이동했다.
“그다음, human_decision이 APPROVE인지 확인해. 맞아. 그럼 return {"next_step": "FINISH"}. 아…! Critic의 REJECTED는 아예 쳐다보지도 않고 그냥 끝나버리네.”
깨달음의 순간이었다. Supervisor는 단순히 두 가지 입력을 보고 분기하는 게 아니었다. 명확한 우선순위가 있었다. 사람의 결정이 존재한다면, 그 결정을 기계의 결정보다 앞세우는 것이다.
“그럼 만약에, 사람이 개입하지 않고 Critic만 REJECTED를 보냈다면?”
솔라는 스스로 다음 시나리오를 가정했다. 이번에는 메모에서 human_decision을 지우고 다시 코드를 따라갔다.
“human_decision이 없으니 첫 번째 if 블록은 건너뛰고… 두 번째 if 블록으로 가네. critique_decision이 REJECTED인지 확인하고, 맞으니까 return {"next_step": "GENERATE"}. 에이전트에게 다시 생성하라고 지시하는구나.”
이제 모든 것이 명확해졌다. Supervisor는 에이전트라는 오케스트라의 지휘자였다. Critic이라는 자동화된 평가 파트와 Human이라는 외부 연주자의 소리를 모두 듣고, AgentState라는 악보를 최종적으로 해석해서 다음 악장을 뭘 연주할지(next_step) 결정하는 역할이었다. 이 ‘결정 통합 로직’ 덕분에 시스템은 충돌하는 신호 속에서도 혼란 없이 다음 행동을 결정할 수 있었다.
“이제 알겠어. Supervisor가 왜 중요한지. AgentState에 쌓인 여러 목소리들 사이에서 최종적인 방향을 정해주는 교통경찰 같은 거였어.”
솔라는 자신이 얻은 세 가지 도구, ‘정책 기반 트리거’, ‘인간 결정 통합’, 그리고 ‘결정 통합 로직’이 어떻게 맞물려 돌아가는지 머릿속으로 그릴 수 있었다.
그녀는 책상 위에 놓인 빈 종이를 자신 앞으로 끌어당겼다. 그리고 펜을 들어 세 개의 상자를 그리기 시작했다.
첫 번째 상자에는 HITL Trigger라고 적었다. 두 번째 상자에는 Human Review, 세 번째 상자에는 Supervisor라고 적었다. 하지만 그녀는 상자들을 직접 화살표로 연결하지 않았다. 대신, 종이의 한가운데에 커다란 원을 그리고 AgentState라고 썼다.
그리고 그녀는 모든 흐름을 그 원을 통해 그리기 시작했다.
- 에이전트의 작업 결과가
AgentState원에 기록된다. HITL Trigger상자가 화살표로AgentState를 ‘읽고’ 있다. (READ)- 트리거가 발동하면, 흐름은
Human Review상자로 간다. Human Review상자는AgentState를 ‘읽고’, 사람의 결정을 받아 다시AgentState에 ‘쓴다’. (READ→WRITE)- 마지막으로,
Supervisor상자가AgentState를 ‘읽는다’. (READ) 여기에는 Critic의 결정과 사람의 결정이 모두 들어있다. Supervisor는 이 정보를 바탕으로 다음 노드를 결정하고, 흐름은 다시 에이전트의 다른 노드(‘GENERATE’ 또는 ‘FINISH’)로 이어진다.
솔라는 자신이 완성한 다이어그램을 만족스럽게 바라보았다. 각 노드들이 직접 서로를 호출하는 파이프라인이 아니었다. 모든 정보와 결정이 AgentState라는 중앙 허브를 통해 공유되고 통합되는, 훨씬 유연하고 확장 가능한 구조였다. HITL을 설계한다는 것은, 바로 이 상태 기반의 정보 흐름을 설계하는 것이라는 감각이 선명하게 다가왔다.
“이거였구나.”
솔라가 완성된 흐름도를 루나에게 보여주었다. 루나는 조용히 미소 지으며 고개를 끄덕였다. 더 이상의 설명은 필요 없었다.