📖 먼저 이야기로 볼까요?
로봇이 할 일을 정했어요.
로봇이 그 자리에 딱 멈췄어요. 그리고 지금까지 한 일을 세이브 파일에 저장했어요.
사람이 쪽지에 대답을 적어서 보내요. 지금 바로 해도 되고, 내일 해도 돼요.
로봇이 세이브를 불러와서 멈췄던 곳부터 이어서 일을 끝내요.
이 프로그램은 큰일을 하기 전에 "해도 돼요?" 하고 물어보고, 대답이 올 때까지 얌전히 기다리는 로봇이에요. 기다리는 동안 로봇은 게임처럼 세이브를 해 둬요. 이 설명서는 그 세이브 파일 속까지 전부 열어 봐요.
로봇이 할 일을 정했어요.
로봇이 그 자리에 딱 멈췄어요. 그리고 지금까지 한 일을 세이브 파일에 저장했어요.
사람이 쪽지에 대답을 적어서 보내요. 지금 바로 해도 되고, 내일 해도 돼요.
로봇이 세이브를 불러와서 멈췄던 곳부터 이어서 일을 끝내요.
로봇은 방 세 개를 차례로 지나가요. 그런데 2번 방에는 "멈춤" 표지판이 있어요.
interrupt()예요. 사실 로봇은 멈출 때만이 아니라 방 하나를 지날 때마다 저장해요. 4번에서 자세히 봐요.방 세 개를 차례로 지나가며 일하는 친구예요. 등에 가방(State)을 메고 다니는데, 가방에는 대화(messages), 할 일(pending_action), 대답(decision)이 들어 있어요.
로봇이 이걸 만나면 질문 쪽지를 내밀고 그 자리에 딱 멈춰요.
게임 저장 파일이에요. 안에 사진첩(checkpoints)과 메모장(writes), 공책 두 권이 들어 있어요.
"슬롯 1", "슬롯 2" 고르듯이 어떤 저장을 쓸지 정하는 이름표예요. 우리가 직접 지어요.
사진 한 장 한 장에 붙는 번호예요. 이건 로봇이 알아서 만들어요.
사람의 대답을 들고 가서 로봇을 다시 움직이게 해요.
놀라운 사실! 이 코드에는 "저장해", "불러와" 하는 코드가 한 줄도 없어요. 전부 로봇이 알아서 해요. 우리가 하는 건 딱 두 가지예요.
builder.compile(checkpointer=checkpointer)graph.invoke(..., config={"configurable": {"thread_id": "hitl_demo"}})멈출 때만 저장하는 게 아니에요. 방(노드) 하나가 끝날 때마다 자동으로 저장해요. 이번 실행에서는 사진이 모두 5장 찍혔어요.
| 사진 | 언제 찍었나 | 가방 속 |
|---|---|---|
| step -1 | 출발 직전 (입력만 받은 상태) | 텅 빔 |
| step 0 | 출발 직후, "작업 시작" 메시지를 넣은 뒤 | messages 1개 |
| step 1 | 1번 방(propose)을 끝낸 뒤 | + pending_action |
| ⏸ 2번 방에서 멈춤. 2번 방은 끝나지 않았으니 사진은 안 찍고, 메모장에 질문 쪽지만 붙여 둬요. 1차 invoke는 여기서 끝! | ||
| step 2 | (이어하기 후) 2번 방(approval)을 끝낸 뒤 | + decision = yes |
| step 3 | 3번 방(execute)을 끝낸 뒤 = 도착 | + AIMessage "실행됨" |
우리가 지어요. 이 코드에서는 "hitl_demo"예요.
"이 사람 대화", "이 결재 건"처럼 한 묶음을 가리켜요. 사진 5장이 모두 같은 슬롯 이름을 달고 있어요.
실제 서비스에서는 사용자 ID나 결재 번호를 넣어요. 이름이 다르면 서로 다른 세이브라서 섞이지 않아요.
로봇이 사진을 찍을 때마다 자동으로 만들어요. 우리는 손댈 필요가 없어요.
사진 한 장마다 하나씩, 전부 달라요. 사진은 "내 바로 앞 사진(부모)" 번호도 같이 적어 둬서, 줄줄이 이어진 기차처럼 순서를 알 수 있어요.
실행 화면에 찍힌 진짜 번호 1f1d98ac-b4cb-69cc-8001-8671bd0dbe23 안에는 비밀이 숨어 있어요.
8000은 step 0, 8001은 step 1, 8002는 step 2… 이렇게 늘어나요. 출발 직전 사진(step -1)은 특별하게 bfff로 찍혀요.번호에 시각이 들어 있어서 좋은 점: 로봇이 "가장 최근 사진"을 찾을 때 번호를 큰 순서로 줄 세워서 맨 앞 한 장만 꺼내면 끝이에요. 진짜로 그렇게 찾아요(10번 어른용 메모 참고).
세이브 파일(checkpoints.sqlite)을 열면 표(테이블)가 두 개 있어요. 사진첩과 메모장이에요.
방을 지날 때마다 찍은 가방 속 전체 사진이에요. 한 줄 = 사진 한 장.
| 칸 이름 | 뜻 |
|---|---|
thread_id | 슬롯 이름 |
checkpoint_id | 사진 번호표 |
parent_checkpoint_id | 바로 앞 사진 번호 |
checkpoint | 가방 속 전체 (압축됨) |
metadata | 몇 단계인지(step), 왜 찍었는지(source) |
사진과 사진 사이에 각 방에서 무엇을 했는지 적은 메모예요. 한 줄 = 메모 한 개. 각 메모는 "어느 사진 다음에 한 일인지" 사진 번호를 달고 있어요.
| 메모 종류 (channel) | 뜻 |
|---|---|
messages, pending_action, decision | 방이 가방에 넣은 것 |
branch:to:approval | "다음엔 approval 방으로!" 화살표 |
__interrupt__ | ⏸ 질문 쪽지 |
__resume__ | ▶ 사람의 대답 쪽지 |
왜 메모장이 따로 있어요? 2번 방에서 멈추면 그 방은 아직 끝나지 않았잖아요. 끝나지 않았으니 사진은 못 찍어요. 대신 "여기서 이런 질문을 하고 멈췄어요"를 메모장에 붙여 둬요. 나중에 대답 쪽지도 같은 자리에 붙어요. 그래서 이어할 때 로봇이 메모를 보고 "아, 여기서 멈췄었지!" 하고 알 수 있어요.
사진첩과 메모장 속 내용은 컴퓨터용 압축 글씨(msgpack)로 저장돼 있어서, DB 프로그램으로 그냥 열면 깨져 보여요. 그래서 코드에 읽기 좋게 풀어서 보여 주는 함수(print_db)를 넣었어요.
python interrupt_basic.py를 실행하면 이제 세이브 파일 속을 두 번 보여 줘요. 멈췄을 때 한 번, 다 끝났을 때 한 번이요. 아래는 진짜 실행 화면이에요(번호표는 길어서 끝부분만 남겼어요).
[1차 invoke]
멈춘 노드: ('approval',)
지금 체크포인트: {'thread_id': 'hitl_demo', 'checkpoint_id': '1f1d98ac-b4cb-69cc-8001-8671bd0dbe23'}
↑ 우리가 지은 슬롯 이름 ↑ 로봇이 만든 사진 번호 (step 1 사진)
┌ DB 내용: 1차 invoke 후 (checkpoints 3행 / writes 5행)
│ [checkpoints] step -1 (input) checkpoint_id=…-bfff-… 부모=없음
│ State: {} ← 텅 빈 가방
│ [writes] messages = ["HumanMessage('작업 시작')"] ← 입력을 넣음
│ [writes] branch:to:propose = None ← 다음은 1번 방
│ [checkpoints] step 0 (loop) checkpoint_id=…-8000-… 부모=…bfff…
│ State: {'messages': ["HumanMessage('작업 시작')"]}
│ [writes] pending_action = 출장 신청서 제출: 부산 2일, 예산 800,000원 ← 1번 방이 한 일
│ [writes] branch:to:approval = None ← 다음은 2번 방
│ [checkpoints] step 1 (loop) checkpoint_id=…-8001-… 부모=…8000…
│ State: {'messages': [...], 'pending_action': '출장 신청서 제출: ...'}
│ [writes] __interrupt__ = ["Interrupt('이 작업을 실행할까요? (yes/no)')"] ← ⏸ 질문 쪽지!
└
__interrupt__)가 붙어 있어요. 2번 방은 아직 안 끝나서 사진(step 2)이 없어요. "1번 방까지 끝났고, 2번 방에서 질문하고 멈췄다"는 게 이걸로 다 기록돼요.…8001…)인 것도 보세요. 이게 "가장 최근 사진"이에요.┌ DB 내용: 2차 invoke 후 (checkpoints 5행 / writes 10행) │ [checkpoints] step -1 ... (위와 똑같음) │ [checkpoints] step 0 ... (위와 똑같음) │ [checkpoints] step 1 checkpoint_id=…-8001-… │ [writes] task=00000000 __resume__ = yes ← ▶ 대답 쪽지 (새로 붙음) │ [writes] task=2ee25a1c __resume__ = ['yes'] ← ▶ 2번 방 전용 대답 쪽지 │ [writes] task=2ee25a1c __interrupt__ = [Interrupt('이 작업을 실행할까요?')] ← 아까 그 질문 쪽지 │ [writes] task=2ee25a1c decision = yes ← 2번 방이 한 일 (새로) │ [writes] task=2ee25a1c branch:to:execute = None ← 다음은 3번 방 (새로) │ [checkpoints] step 2 (loop) checkpoint_id=…-8002-… 부모=…8001… ← 새 사진 │ State: {..., 'decision': 'yes'} │ [writes] messages = ["AIMessage('실행됨: 출장 신청서 제출: ...')"] ← 3번 방이 한 일 │ [checkpoints] step 3 (loop) checkpoint_id=…-8003-… 부모=…8002… ← 새 사진 (도착) │ State: {'messages': [작업 시작, 실행됨: ...], 'pending_action': ..., 'decision': 'yes'} └
__resume__ = yes)와 2번 방이 한 일(decision = yes)이 새로 붙었어요. 대답 쪽지가 두 개인 건 "전체에 온 대답"과 "2번 방에 전달된 대답"을 따로 적어 두기 때문이에요.8002, 8003으로 늘었죠?task=2ee25a1c는 "이번 2번 방 일감"에 붙은 이름표예요(task_id, 이것도 로봇이 자동으로 만들어요). 질문 쪽지와 대답 쪽지의 task가 같아서, 이 대답이 그 질문의 대답이라는 걸 알 수 있어요.graph.invoke(Command(resume="yes"), config) 한 줄을 실행하면 로봇은 이 순서로 생각해요.
"hitl_demo"를 읽어요.…8001…)"yes"를 메모장에 붙여요(__resume__).interrupt()가 멈추지 않고 대답 쪽지 내용 "yes"를 건네줘요. 그래서 decision = "yes"가 돼요.1차 invoke 때 2번 방에서 멈추긴 해요. 하지만 사진도 메모도 안 남아요. 이어하기를 누르면 2번(가장 최근 사진 꺼내기)에서 꺼낼 게 없어서
RuntimeError: Cannot use Command(resume=...) without checkpointer 오류가 나요. 직접 해 봤어요!
슬롯 이름이 늘 "hitl_demo"라서, 그냥 두면 실행할 때마다 사진이 계속 쌓여요. 그래서 코드 맨 앞에서 checkpointer.delete_thread("hitl_demo")로 이 슬롯의 사진과 메모를 싹 지우고 시작해요. 다른 슬롯(checkpointer_basic.py가 만든 user_A, user_B)은 건드리지 않아요.
[1차 invoke] ← 출발! 사진 -1, 0, 1 찍고 2번 방에서 멈춤 멈춘 노드: ('approval',) 요청 내용: {..., 'question': '이 작업을 실행할까요? (yes/no)'} 지금 체크포인트: {...} ← 가장 최근 사진 번호 ┌ DB 내용: 1차 invoke 후 ... ← 세이브 파일 속 (사진 3, 메모 5) 승인하시겠습니까? (yes/no): yes ← 내가 대답 [2차 invoke] ← 이어하기! 사진 2, 3 찍고 도착 최종: 실행됨: 출장 신청서 제출: 부산 2일, 예산 800,000원 ┌ DB 내용: 2차 invoke 후 ... ← 세이브 파일 속 (사진 5, 메모 10)
no라고 대답하면 최종이 실행 거부됨이 되고, step 1 메모의 대답 쪽지와 decision이 no로 바뀌어요.
| 쉬운 말 | 코드 |
|---|---|
| 🎒 로봇의 가방 | class State(TypedDict): messages, pending_action, decision |
| 📝 1번 방: 할 일 정하기 | propose_action |
| ✋ 2번 방: 허락받기 | human_approval 안의 interrupt(...) |
| 🚀 3번 방: 실행하기 | execute |
| 💾 세이브 파일 열기 | SqliteSaver.from_conn_string("checkpoints.sqlite") |
| 🔌 자동 저장 켜기 | builder.compile(checkpointer=checkpointer) |
| 🏷️ 슬롯 이름 | {"configurable": {"thread_id": "hitl_demo"}} |
| 🧹 슬롯 비우기 | checkpointer.delete_thread("hitl_demo") |
| 🔍 가장 최근 사진 보기 | graph.get_state(config) |
| 📒 사진첩 전부 넘겨 보기 | checkpointer.list(config) (최신 사진부터 나와요) |
| 🖨️ 세이브 속을 읽기 좋게 출력 | print_db(checkpointer, config, 제목) (이번에 추가한 함수) |
| ▶️ 대답 들고 이어하기 | graph.invoke(Command(resume="yes"), config) |
2번 방(허락받기)이에요. 거기에 멈춤 표지판 interrupt()가 있거든요.
아니요! 방 하나가 끝날 때마다 찍어요. 이번 실행에서는 모두 5장이었어요.
thread_id예요. checkpoint_id는 로봇이 사진을 찍을 때마다 자동으로 만들어요.
사진 번호표 앞부분에 찍은 시각이 들어 있어서, 번호를 큰 순서로 세우면 맨 앞이 가장 최근 사진이에요. 그 한 장만 꺼내요.
마지막 사진(step 1) 밑 메모장에 질문 쪽지(__interrupt__)를 붙여 뒀기 때문이에요.
아니요! 로봇은 정확히 소문자 yes일 때만 실행해요. "Yes", "y", "네"는 전부 "하지 마"로 알아들어요.
id = str(uuid6(clock_seq=step))을 써요. uuid6는 앞부분이 타임스탬프라 시간순 정렬이 되고, clock_seq 자리에 step이 들어가서 8000 + step처럼 보여요(step -1은 bfff). (langgraph/pregel/_checkpoint.py)SqliteSaver.put()이 INSERT OR REPLACE INTO checkpoints ...를 실행해요.put_writes()가 INSERT ... INTO writes ...로 저장해요.get_tuple()이 ... WHERE thread_id = ? ORDER BY checkpoint_id DESC LIMIT 1로 꺼내요. 번호가 시간순이라 이게 곧 최신이에요.checkpoint_id를 넣으면 그 사진부터 다시 실행할 수도 있어요(타임 트래블).checkpointer.list()나 graph.get_state_history()로 풀어서 봐요.