23장. Human-in-the-Loop
실습문제와 해답
승인 게이트를 직접 움직여 보며 23장 내용을 손에 익힙니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다. 두 문제 모두 lesson23_hitl.py를 고치지 않고 가져다 씁니다.
아래 실행 결과는 따라하기를 한 번 실행한 뒤(actions.json에 A00001 한 건이 있는 상태)에 문제 1, 문제 2를 차례로 돌린 것입니다. 처리번호는 그때까지 쌓인 기록에 이어서 매겨지므로 여러분의 번호는 다를 수 있습니다.
문제 1 — 멈춘 상태를 들여다보고, 반려하기
새 파일 ex23_reject.py를 만들어 다음을 확인하세요.
- 따라하기와 같은 주문(
HR20260721001)으로 그래프를 실행해 승인 게이트에서 멈추게 한다.thread_id는"refund-C003-010"으로 한다. - 멈춘 상태에서 다음에 실행할 노드와 등록된 승인 요청 번호, 그 요청의 기록상 상태를 출력한다.
- 상담원이 반려한 것으로 이어 가고, 최종 결과와 처리 기록에 남은 상태를 출력한다.
- 힌트:
from lesson23_hitl import first_state, graph로 그래프와 처음 상태를 만드는 함수를 가져옵니다. 시연 코드는if __name__ == "__main__":안에 있어서, 가져올 때는 실행되지 않습니다. - 힌트:
graph.get_state(config)가 저장된 상태를 돌려줍니다..next에 다음 노드 이름이,.values에 상태 값이 들어 있습니다. - 힌트: 처리 기록은
from haru_actions import load_actions로 읽습니다. - 실행하기 전에 짐작해 보세요. 반려하면
execute노드는 실행될까요, 건너뛸까요? 기록에는 무엇이 남을까요?
해답 보기
from langgraph.types import Command
from haru_actions import load_actions
from lesson23_hitl import first_state, graph
config = {"configurable": {"thread_id": "refund-C003-010"}}
result = graph.invoke(
first_state("마음에 안 들어서 환불하고 싶어요.", "HR20260721001"),
config=config,
)
print("멈췄는가 :", "__interrupt__" in result)
# 저장된 상태를 thread_id 로 꺼내 본다
snapshot = graph.get_state(config)
action_id = snapshot.values["action_id"]
print("다음에 실행할 노드:", snapshot.next)
print("등록된 승인 요청 :", action_id)
print("기록의 상태(멈춤) :", [a["status"] for a in load_actions() if a["action_id"] == action_id])
# 상담원이 반려한다
result = graph.invoke(Command(resume="reject"), config=config)
print("재개 후 approved :", result["approved"])
print("최종 결과 :", result["result"])
print("다음에 실행할 노드:", graph.get_state(config).next)
# 처리 기록 파일에 무엇이 남았나
for a in load_actions():
if a["action_id"] == action_id:
print("기록의 상태(재개) :", a["status"], "/ 결정:", a["decided_by"], a["decided_at"][:10])
$ python ex23_reject.py
실행 결과 (날짜는 실행한 날로 나옵니다)
멈췄는가 : True
다음에 실행할 노드: ('approval_gate',)
등록된 승인 요청 : A00002
기록의 상태(멈춤) : ['승인대기']
재개 후 approved : False
최종 결과 : 상담원 검토 결과 환불이 반려되었습니다. (처리번호 A00002 · 상태: 반려)
다음에 실행할 노드: ()
기록의 상태(재개) : 반려 / 결정: 상담원 2026-10-08
해설 — 네 가지를 봅니다.
첫째, next가 ('approval_gate',) 입니다. 체크포인터에는 "다음에 approval_gate를 실행할 차례"라고 저장되어 있습니다. 이어 갈 때 이 노드가 처음부터 다시 실행되고, 이번에는 interrupt()가 멈추는 대신 "reject"를 돌려줍니다.
둘째, 검토 결과(action_id)가 상태에 남아 있습니다. 이어 갈 때 검토 노드는 다시 실행되지 않습니다. 승인 요청이 한 번 더 등록되지 않았고, LLM을 다시 부르지 않았으니 비용도 들지 않았고, 상담원이 본 요청서가 바뀔 일도 없습니다.
셋째, 반려해도 execute 노드는 실행됩니다. 그래프에서 승인 게이트 다음은 언제나 execute입니다. 그 안에서 decide(..., approve=False)가 불려 기록이 반려 로 바뀌었습니다. "결과 기록 노드에 가지 않는다"가 아니라 "가더라도 approved가 참이 아니면 승인완료가 되지 않는다" 가 정확한 설명입니다.
넷째, 기록에 누가 언제 결정했는지가 남았습니다(decided_by, decided_at). 승인이든 반려든 사람의 결정은 기록으로 남습니다. 끝난 뒤의 next는 빈 값 ()이고, 더 실행할 것이 없다는 뜻입니다.
import 한 줄로 그래프를 가져왔는데 체크포인터까지 함께 왔다는 것도 봐 두세요. lesson23_hitl.py에서 compile(checkpointer=InMemorySaver())로 만든 graph 그대로입니다. 이 프로그램이 끝나면 체크포인터에 저장된 실행 상태는 사라지고, actions.json의 기록은 남습니다.
문제 2 — 주문 취소 요청을 같은 그래프에 태우기
환불만이 아니라 주문 취소도 돈이 나가는 일입니다. C003 고객의 LED 무드등 주문(HR20260720022)은 아직 출고 전(결제완료)입니다. 새 파일 ex23_cancel.py를 만들어 이 주문의 취소 요청을 같은 그래프에 넣어 보세요.
- 고객의 말은 "필요 없어져서 주문을 취소하고 싶어요."로 하고,
thread_id는"cancel-C003-001"로 한다. - 멈췄는지 확인하고, 상담원이 받는 요청서를 출력한다.
haru_actions의pending_approvals()로 승인 대기 목록을 출력한다.- 승인으로 이어 간 뒤, 최종 결과와 승인 대기 목록, 그 요청의 기록을 출력한다.
- 힌트:
from haru_actions import load_actions, pending_approvals - 실행하기 전에 짐작해 보세요. 배송 완료일이 없는 주문입니다. 승인 게이트까지 갈까요? 기록의
type은 무엇으로 남을까요?
해답 보기
from langgraph.types import Command
from haru_actions import load_actions, pending_approvals
from lesson23_hitl import first_state, graph
config = {"configurable": {"thread_id": "cancel-C003-001"}}
# 출고 전 주문(LED 무드등, 결제완료)의 취소 요청을 같은 그래프에 넣는다
result = graph.invoke(
first_state("필요 없어져서 주문을 취소하고 싶어요.", "HR20260720022"),
config=config,
)
print("멈췄는가:", "__interrupt__" in result)
payload = result["__interrupt__"][0].value
print("\n■ 상담원이 받는 요청서")
print(payload["review"])
print("\n■ 승인 대기 목록")
for a in pending_approvals():
print(f" {a['action_id']} · {a['type']} · 주문 {a['order_id']} · {a['status']}")
print(f" {a['detail']}")
result = graph.invoke(Command(resume="approve"), config=config)
print("\n■ 승인 후")
print(" 최종 결과:", result["result"])
print(" 승인 대기 목록:", pending_approvals())
done = [a for a in load_actions() if a["action_id"] == payload["action_id"]][0]
print(f" 기록: {done['action_id']} · {done['type']} · {done['status']}")
$ python ex23_cancel.py
실행 결과 (요청서의 문장은 실행할 때마다 달라집니다)
멈췄는가: True
■ 상담원이 받는 요청서
- 주문번호: HR20260720022
- 상품: LED 무드등 (우드/단일, 1개)
- 금액: 19,900원
- 배송 완료일: 없음 (현재 결제완료 상태)
[시스템 확인] 승인 요청 A00003 등록 · 결제 19900원 · 상태: 승인대기
■ 승인 대기 목록
A00003 · 주문취소요청 · 주문 HR20260720022 · 승인대기
LED 무드등 우드/단일 · 결제 19900원 · 사유: 필요 없어져서 주문을 취소하고 싶어요. · 출고 전 주문 취소
■ 승인 후
최종 결과: 주문 HR20260720022 환불이 승인되어 처리되었습니다. (처리번호 A00003 · 상태: 승인완료)
승인 대기 목록: []
기록: A00003 · 주문취소요청 · 승인완료
해설 — 그래프는 한 글자도 고치지 않았습니다. 그런데도 주문 취소가 같은 길을 지나갔습니다.
| 볼 곳 | 결과 | 누가 정했나 |
|---|---|---|
| 승인 게이트까지 갔는가 | 갔다 (멈췄는가: True) |
코드. request_refund가 주문 상태 결제완료를 보고 "출고 전 주문 취소"로 통과시켰다 |
기록의 type |
주문취소요청 |
코드. 배송 완료 주문이면 환불요청, 출고 전이면 주문취소요청으로 나눈다 (21장) |
| 요청서의 "배송 완료일: 없음" | 주문 정보에 없는 값을 지어내지 않았다 | AI. "주문 정보에 없는 내용은 쓰지 마라"는 지시를 따랐다 |
| 승인 대기 목록 | 승인 전 1건 → 승인 후 0건 | 코드. pending_approvals()는 actions.json에서 승인대기만 골라 준다 |
환불인지 취소인지를 가르는 판단이 그래프가 아니라 처리 도구 안에 있기 때문에, 그래프는 "요건을 채웠으면 사람에게 묻고, 결정을 기록한다"는 한 가지 흐름만 알면 됩니다. 21장에서 판정을 코드에 넣어 둔 덕을 여기서 봅니다.
pending_approvals()가 돌려준 목록을 봐 두세요. 처리번호, 종류, 주문번호, 요약(detail)이 한 줄씩 들어 있습니다. 이 목록이 24장에서 상담원 화면의 "승인 대기" 구역이 되고, 화면의 승인 버튼이 이번 장과 같은 decide 를 부릅니다.
이 교재를 준비하며 두 문제를 세 번씩 실행했습니다. 요청서의 문장은 매번 달랐고, 멈춤 여부·처리번호·기록의 종류와 상태는 세 번 모두 위와 같았습니다.
핵심 정리
- 멈춘 그래프의 상태는
graph.get_state(config)로 꺼내 볼 수 있습니다..next가 다음 노드입니다. - 이어 갈 때 검토 노드는 다시 실행되지 않습니다. 결과가 상태에 저장되어 있기 때문입니다.
- 반려해도 결과 기록 노드는 지나갑니다. 그때 기록은
반려가 되고, 누가 언제 결정했는지 남습니다. - 주문 취소도 같은 그래프를 지나갑니다. 환불인지 취소인지는 처리 도구(코드)가 가릅니다.
pending_approvals()가 승인 대기 목록입니다. 24장에서 상담원 화면이 됩니다.