23장. Human-in-the-Loop
interrupt — 그래프를 멈추고 이어 가기
한 줄 요약
LangGraph의 interrupt 는 그래프를 그 자리에서 멈추고 바깥에 질문을 내보냅니다. 멈춘 상태는 체크포인터(checkpointer) 가 저장하고, 같은 thread_id 로 Command(resume=값)을 보내면 멈춘 곳부터 이어서 실행됩니다.
1. 세 가지 부품
| 부품 | 하는 일 | 코드 |
|---|---|---|
interrupt(내보낼 값) |
노드 안에서 부르면 그래프가 멈춘다. 넘긴 값은 그래프 밖으로 나간다 | decision = interrupt({...}) |
| 체크포인터 | 멈춘 순간의 상태를 저장한다. 없으면 멈출 수 없다 | builder.compile(checkpointer=InMemorySaver()) |
Command(resume=값) |
저장된 상태를 불러와 이어서 실행한다 | graph.invoke(Command(resume="approve"), config=config) |
여기에 실행을 구분하는 이름표 하나가 더 필요합니다.
config = {"configurable": {"thread_id": "refund-C003-001"}}
thread_id 는 "어느 실행을 이어 갈 것인가"를 가리키는 이름입니다. 환불 요청이 동시에 여러 건 멈춰 있어도, 이름이 다르면 서로 섞이지 않습니다. 멈출 때와 이어 갈 때 같은 thread_id를 써야 합니다.
2. 멈추는 쪽 — 노드 안
def approval_gate(state: RefundState) -> dict:
decision = interrupt({
"type": "refund_approval",
"order_id": state["order_id"],
"action_id": state["action_id"],
"review": state["review"],
"ask": "이 환불을 실행할까요? (approve / reject)",
})
return {"approved": decision == "approve"}
이 함수는 두 번에 걸쳐 실행됩니다.
| 때 | 일어나는 일 |
|---|---|
| 처음 실행 | interrupt(...)에서 멈춘다. 괄호 안의 값이 밖으로 나간다. return까지 가지 못한다 |
| 이어 갈 때 | interrupt(...)가 이번에는 멈추지 않고 resume으로 받은 값을 돌려준다. 그 값이 decision에 담기고 return까지 간다 |
밖에서 사람이 넣어 준 결정이, 노드 안에서는 interrupt()가 돌려준 값으로 나타나는 것입니다.
내보내는 값에 action_id 가 들어 있는 것을 봐 두세요. 검토 노드에서 request_refund가 등록한 승인 요청의 처리번호(A로 시작)입니다. 상담원이 보는 요청서와 처리 기록의 한 줄이 이 번호로 이어집니다.
3. 이어 가는 쪽 — 그래프 밖
# 첫 실행: 승인 게이트에서 멈춘다
result = graph.invoke({...처음 상태...}, config=config)
if "__interrupt__" in result: # 멈췄다는 표시
payload = result["__interrupt__"][0].value # interrupt 에 넘겼던 값
# ... 상담원 화면에 payload 를 보여 준다 ...
# 상담원의 결정을 넣어 이어서 실행한다
result = graph.invoke(Command(resume="approve"), config=config)
| 줄 | 뜻 |
|---|---|
"__interrupt__" in result |
그래프가 끝까지 가지 못하고 멈췄다는 표시 |
result["__interrupt__"][0].value |
interrupt(...)에 넘겼던 값. 상담원에게 보여 줄 내용 |
Command(resume="approve") |
처음 상태 대신 "이어서 실행하라"는 명령을 넣는다 |
config=config |
같은 thread_id 여야 한다 |
수업에서는 한 파일 안에서 곧바로 이어 가지만, 두 invoke 사이에 얼마든지 시간이 흘러도 됩니다. 그 사이를 체크포인터가 메웁니다.
4. 알아 둘 규칙 네 가지
LangGraph 공식 문서(https://docs.langchain.com/oss/python/langgraph/interrupts)가 밝히는 규칙입니다.
| 규칙 | 내용 | 우리 코드에서 |
|---|---|---|
| 체크포인터가 있어야 한다 | 상태를 저장할 곳이 없으면 멈췄다 이어 갈 수 없다 | compile(checkpointer=InMemorySaver()) |
같은 thread_id로 이어 간다 |
다른 이름을 주면 새 실행으로 본다 | config를 그대로 다시 쓴다 |
| 이어 갈 때 노드는 처음부터 다시 실행된다 | interrupt가 있던 줄에서 이어지는 것이 아니다. 그 노드의 첫 줄부터 다시 돈다 |
approval_gate에는 interrupt 앞에 다른 일이 없다 |
| 내보내는 값은 단순한 자료여야 한다 | 문자열, 숫자, 딕셔너리, 리스트처럼 JSON으로 바꿀 수 있는 값 | 딕셔너리 하나를 넘긴다 |
세 번째 규칙이 설계에 영향을 줍니다. 만약 검토와 interrupt를 한 노드에 넣었다면, 상담원이 승인하는 순간 검토가 한 번 더 실행됩니다. 승인 요청을 등록하는 코드가 다시 돌고, LLM을 다시 부르니 비용이 들고, 상담원이 본 것과 다른 요청서가 만들어집니다.
그래서 우리 그래프는 검토 노드와 승인 노드를 나눴습니다. 검토 결과(요청서와 action_id)는 상태에 저장되어 있으므로, 이어 갈 때는 승인 노드만 다시 돕니다.
5. 체크포인터 — 저장은 어디에 되는가
수업에서 쓰는 InMemorySaver 는 이름 그대로 상태를 메모리에 저장합니다.
| 저장소 | 저장 위치 | 프로그램을 끄면 |
|---|---|---|
InMemorySaver |
실행 중인 프로그램의 메모리 | 사라진다 |
| 데이터베이스 기반 체크포인터 | 디스크의 데이터베이스 | 남는다 |
따라서 지금 만드는 것은 "프로그램이 실행되는 동안"의 멈춤과 이어 가기입니다. 상담원이 다음 날 승인해도 이어지게 하려면 체크포인터를 데이터베이스에 저장하는 방식으로 바꿔야 합니다. 그래프의 나머지 코드는 그대로 둡니다.
체크포인터와 처리 기록은 서로 다른 것을 저장합니다. 헷갈리지 않게 나눠 둡니다.
| 무엇을 저장하나 | 어디에 | 프로그램을 끄면 | |
|---|---|---|---|
체크포인터 (InMemorySaver) |
그래프의 실행 상태 (어느 노드에서 멈췄나, 상태 값) | 메모리 | 사라진다 |
처리 기록 (memory_store/actions.json) |
승인 요청과 그 결과 (승인대기 → 승인완료 / 반려) |
파일 | 남는다 |
16장과 나란히 놓으면 이렇습니다.
| 무엇을 저장하나 | 그래서 가능해지는 것 | |
|---|---|---|
| 16장 | 대화 이력 | 대화를 이어 간다 |
| 23장 | 그래프의 실행 상태 | 실행을 멈췄다 이어 간다 |
6. 그래프 전체 모양
시작 → [검토] ──(요건 충족)──→ [승인 게이트] → [결과 기록] → 끝
│ 여기서 멈춤
└──(요건 미달)──→ [이유 안내] → 끝
| 노드 | 누구의 일 | 배운 곳 |
|---|---|---|
검토 review |
코드(기간 판정, 승인 요청 등록) + AI(사유 분류·요청서) | 18장 (노드), 21장 (request_refund) |
갈림길 route_after_review |
코드 | 19장 (조건 분기) |
승인 게이트 approval_gate |
사람 | 이번 장 (interrupt) |
결과 기록 execute |
코드 | 21장 (decide) |
이유 안내 reject |
코드 |
새로 배우는 것은 interrupt 하나입니다. 나머지는 18장과 19장의 부품, 그리고 21장에서 만든 처리 도구입니다.
핵심 정리
interrupt(값)은 그래프를 멈추고 값을 밖으로 내보냅니다.- 체크포인터가 멈춘 상태를 저장합니다. 없으면
interrupt를 쓸 수 없습니다. - 같은
thread_id로Command(resume=값)을 보내면 이어서 실행되고, 그 값이interrupt()의 반환값이 됩니다. - 이어 갈 때 노드는 처음부터 다시 실행됩니다. 그래서 검토와 승인을 다른 노드로 나눕니다.
InMemorySaver는 프로그램을 끄면 사라집니다. 오래 기다리려면 데이터베이스 기반으로 바꿉니다.