🤖✋💾

허락받는 로봇

이 프로그램은 큰일을 하기 전에 "해도 돼요?" 하고 물어보고, 대답이 올 때까지 얌전히 기다리는 로봇이에요. 기다리는 동안 로봇은 게임처럼 세이브를 해 둬요. 이 설명서는 그 세이브 파일 속까지 전부 열어 봐요.

1

📖 먼저 이야기로 볼까요?

1컷 🤖📝 "부산 출장 신청서를 낼 거예요. 돈은 80만 원이에요."

로봇이 할 일을 정했어요.

2컷 🤖⏸️💾 "잠깐! 큰일이니까 사람한테 먼저 물어볼게요."

로봇이 그 자리에 딱 멈췄어요. 그리고 지금까지 한 일을 세이브 파일에 저장했어요.

3컷 🙋💬 "응, 해도 돼!" 또는 "아니, 하지 마!"

사람이 쪽지에 대답을 적어서 보내요. 지금 바로 해도 되고, 내일 해도 돼요.

4컷 🤖💾🚀 "세이브 불러왔어요! 허락받았으니 신청서 냈어요!"

로봇이 세이브를 불러와서 멈췄던 곳부터 이어서 일을 끝내요.

2

🗺️ 로봇이 지나가는 길

로봇은 방 세 개를 차례로 지나가요. 그런데 2번 방에는 "멈춤" 표지판이 있어요.

출발 ▶ 끝! 🎉 📝 1번 방 할 일 정하기 ✋ 2번 방 허락받기 멈춤 🚀 3번 방 실행하기 💾 세이브 파일 checkpoints.sqlite 멈출 때 저장 이어할 때 불러오기 🙋 사람 yes 또는 no 대답 쪽지 대답이 오면 2번 방부터 이어서!
빨간 "멈춤" 표지판이 바로 interrupt()예요. 사실 로봇은 멈출 때만이 아니라 방 하나를 지날 때마다 저장해요. 4번에서 자세히 봐요.
3

🎭 등장인물 소개

🤖
로봇
graph (그래프)

방 세 개를 차례로 지나가며 일하는 친구예요. 등에 가방(State)을 메고 다니는데, 가방에는 대화(messages), 할 일(pending_action), 대답(decision)이 들어 있어요.

✋
멈춤 표지판
interrupt()

로봇이 이걸 만나면 질문 쪽지를 내밀고 그 자리에 딱 멈춰요.

💾
세이브 파일
SqliteSaver → checkpoints.sqlite

게임 저장 파일이에요. 안에 사진첩(checkpoints)과 메모장(writes), 공책 두 권이 들어 있어요.

🏷️
세이브 슬롯 이름
thread_id = "hitl_demo"

"슬롯 1", "슬롯 2" 고르듯이 어떤 저장을 쓸지 정하는 이름표예요. 우리가 직접 지어요.

🔖
사진 번호표
checkpoint_id

사진 한 장 한 장에 붙는 번호예요. 이건 로봇이 알아서 만들어요.

▶️
이어하기 버튼
Command(resume="yes")

사람의 대답을 들고 가서 로봇을 다시 움직이게 해요.

4

💾 세이브는 누가, 언제 해요?

놀라운 사실! 이 코드에는 "저장해", "불러와" 하는 코드가 한 줄도 없어요. 전부 로봇이 알아서 해요. 우리가 하는 건 딱 두 가지예요.

🙋 우리가 하는 일
  • 자동 저장 켜기: 로봇을 만들 때 세이브 파일을 쥐여 줘요.
    builder.compile(checkpointer=checkpointer)
  • 슬롯 이름 알려 주기: 실행할 때마다 이름표를 같이 줘요.
    graph.invoke(..., config={"configurable": {"thread_id": "hitl_demo"}})
🤖 로봇이 알아서 하는 일
  • 방 하나를 지날 때마다 가방 속 사진을 찍어 저장
  • 사진마다 번호표(checkpoint_id)를 만들어 붙이기
  • 각 방에서 한 일을 메모장에 적기
  • 멈출 때 질문 쪽지를 메모장에 붙여 두기
  • 이어할 때 그 슬롯의 가장 최근 사진을 찾아 불러오기

📷 언제 저장해요?

멈출 때만 저장하는 게 아니에요. 방(노드) 하나가 끝날 때마다 자동으로 저장해요. 이번 실행에서는 사진이 모두 5장 찍혔어요.

사진언제 찍었나가방 속
step -1출발 직전 (입력만 받은 상태)텅 빔
step 0출발 직후, "작업 시작" 메시지를 넣은 뒤messages 1개
step 11번 방(propose)을 끝낸 뒤+ pending_action
⏸ 2번 방에서 멈춤. 2번 방은 끝나지 않았으니 사진은 안 찍고, 메모장에 질문 쪽지만 붙여 둬요. 1차 invoke는 여기서 끝!
step 2(이어하기 후) 2번 방(approval)을 끝낸 뒤+ decision = yes
step 33번 방(execute)을 끝낸 뒤 = 도착+ AIMessage "실행됨"
5

🏷️ 이름표가 두 개예요

🏷️ thread_id: 슬롯 이름

우리가 지어요. 이 코드에서는 "hitl_demo"예요.

"이 사람 대화", "이 결재 건"처럼 한 묶음을 가리켜요. 사진 5장이 모두 같은 슬롯 이름을 달고 있어요.

실제 서비스에서는 사용자 ID나 결재 번호를 넣어요. 이름이 다르면 서로 다른 세이브라서 섞이지 않아요.

🔖 checkpoint_id: 사진 번호표

로봇이 사진을 찍을 때마다 자동으로 만들어요. 우리는 손댈 필요가 없어요.

사진 한 장마다 하나씩, 전부 달라요. 사진은 "내 바로 앞 사진(부모)" 번호도 같이 적어 둬서, 줄줄이 이어진 기차처럼 순서를 알 수 있어요.

🔍 사진 번호표를 뜯어보면

실행 화면에 찍힌 진짜 번호 1f1d98ac-b4cb-69cc-8001-8671bd0dbe23 안에는 비밀이 숨어 있어요.

1f1d98ac찍은 시각- b4cb찍은 시각- 69cc찍은 시각- 8001몇 번째 단계- 8671bd0dbe23랜덤 (겹치지 않게)

번호에 시각이 들어 있어서 좋은 점: 로봇이 "가장 최근 사진"을 찾을 때 번호를 큰 순서로 줄 세워서 맨 앞 한 장만 꺼내면 끝이에요. 진짜로 그렇게 찾아요(10번 어른용 메모 참고).

6

📒 세이브 파일 속 공책 두 권

세이브 파일(checkpoints.sqlite)을 열면 표(테이블)가 두 개 있어요. 사진첩과 메모장이에요.

📷 checkpoints = 사진첩

방을 지날 때마다 찍은 가방 속 전체 사진이에요. 한 줄 = 사진 한 장.

칸 이름뜻
thread_id슬롯 이름
checkpoint_id사진 번호표
parent_checkpoint_id바로 앞 사진 번호
checkpoint가방 속 전체 (압축됨)
metadata몇 단계인지(step), 왜 찍었는지(source)
📝 writes = 메모장

사진과 사진 사이에 각 방에서 무엇을 했는지 적은 메모예요. 한 줄 = 메모 한 개. 각 메모는 "어느 사진 다음에 한 일인지" 사진 번호를 달고 있어요.

메모 종류 (channel)뜻
messages, pending_action, decision방이 가방에 넣은 것
branch:to:approval"다음엔 approval 방으로!" 화살표
__interrupt__⏸ 질문 쪽지
__resume__▶ 사람의 대답 쪽지

왜 메모장이 따로 있어요? 2번 방에서 멈추면 그 방은 아직 끝나지 않았잖아요. 끝나지 않았으니 사진은 못 찍어요. 대신 "여기서 이런 질문을 하고 멈췄어요"를 메모장에 붙여 둬요. 나중에 대답 쪽지도 같은 자리에 붙어요. 그래서 이어할 때 로봇이 메모를 보고 "아, 여기서 멈췄었지!" 하고 알 수 있어요.

사진첩과 메모장 속 내용은 컴퓨터용 압축 글씨(msgpack)로 저장돼 있어서, DB 프로그램으로 그냥 열면 깨져 보여요. 그래서 코드에 읽기 좋게 풀어서 보여 주는 함수(print_db)를 넣었어요.

7

🔓 진짜 세이브 파일 열어 보기

python interrupt_basic.py를 실행하면 이제 세이브 파일 속을 두 번 보여 줘요. 멈췄을 때 한 번, 다 끝났을 때 한 번이요. 아래는 진짜 실행 화면이에요(번호표는 길어서 끝부분만 남겼어요).

① 멈췄을 때 (1차 invoke 후)

[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)')"]  ← ⏸ 질문 쪽지!
  └

② 다 끝났을 때 (2차 invoke 후)

  ┌ 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'}
  └
세이브 파일 속 사진첩 📷 과 메모장 📝 (2차 invoke 후) ⏸ 1차 invoke는 step 1 사진에서 멈춤 📷 step -1 가방: 텅 빔 출발 직전 …-bfff-… 📷 step 0 messages: 작업 시작 …-8000-… 📷 step 1 ⏸ messages 1개 pending_action: 출장 신청서 …-8001-… 📷 step 2 messages 1개 pending_action ✓ decision: yes …-8002-… 📷 step 3 🎉 messages 2개 pending_action ✓ decision: yes …-8003-… ✏️ 입력: 작업 시작 ➡️ 다음: propose ✏️ pending_action ← 출장 신청서 ➡️ 다음: approval ⏸ 질문 쪽지 ▶ 대답 쪽지: yes ✏️ decision ← yes ➡️ 다음: execute ✏️ messages ← 실행됨 메모 없음 (도착했으니까) 📷 사진 = checkpoints 표 한 줄: 그 순간 가방 속 전체. 파란 화살표는 "부모 → 다음 사진" 순서예요. 📝 메모 = writes 표 줄들: 그 사진 다음 방에서 한 일. 사진 5장, 메모 10개가 실행 화면의 "5행 / 10행"이에요.
step 1 사진 메모에 질문 쪽지와 대답 쪽지가 같이 붙어 있는 게 핵심이에요.
8

🔄 이어하기 버튼을 누르면 로봇 머릿속에선

graph.invoke(Command(resume="yes"), config) 한 줄을 실행하면 로봇은 이 순서로 생각해요.

  1. 슬롯 열기: config에서 슬롯 이름 "hitl_demo"를 읽어요.
  2. 가장 최근 사진 꺼내기: 그 슬롯의 사진들을 번호가 큰 순서로 세우고 맨 앞 한 장을 꺼내요. → step 1 사진(…8001…)
  3. 메모 확인: step 1 사진 밑에 질문 쪽지가 붙어 있네? "아, 1번 방까지 끝내고 2번 방에서 질문하고 멈췄었구나!" 가방도 사진 속 모습 그대로 다시 메요.
  4. 대답 쪽지 붙이기: 받은 대답 "yes"를 메모장에 붙여요(__resume__).
  5. 2번 방 다시 들어가기: 2번 방 입구부터 다시 들어가요. 그런데 이번엔 멈춤 표지판 interrupt()가 멈추지 않고 대답 쪽지 내용 "yes"를 건네줘요. 그래서 decision = "yes"가 돼요.
  6. 계속 진행: 2번 방 끝 → 사진 step 2, 3번 방 끝 → 사진 step 3. 도착!
😮 만약 세이브 파일 없이 로봇을 만들었다면?

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로 바뀌어요.

9

🧩 쉬운 말 ↔ 코드 짝꿍

쉬운 말코드
🎒 로봇의 가방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)
10

✏️ 퀴즈! (눌러서 정답 보기)

Q1. 로봇은 몇 번 방에서 멈출까요?

2번 방(허락받기)이에요. 거기에 멈춤 표지판 interrupt()가 있거든요.

Q2. 사진(checkpoint)은 멈출 때만 찍을까요?

아니요! 방 하나가 끝날 때마다 찍어요. 이번 실행에서는 모두 5장이었어요.

Q3. thread_id와 checkpoint_id 중 우리가 직접 짓는 건?

thread_id예요. checkpoint_id는 로봇이 사진을 찍을 때마다 자동으로 만들어요.

Q4. 로봇은 "가장 최근 사진"을 어떻게 찾을까요?

사진 번호표 앞부분에 찍은 시각이 들어 있어서, 번호를 큰 순서로 세우면 맨 앞이 가장 최근 사진이에요. 그 한 장만 꺼내요.

Q5. 멈췄을 때 2번 방 사진이 없는데, 로봇은 어떻게 2번 방에서 멈춘 걸 기억해요?

마지막 사진(step 1) 밑 메모장에 질문 쪽지(__interrupt__)를 붙여 뒀기 때문이에요.

Q6. 대답을 "Yes"(대문자)라고 적으면 실행될까요?

아니요! 로봇은 정확히 소문자 yes일 때만 실행해요. "Yes", "y", "네"는 전부 "하지 마"로 알아들어요.

🧑‍💻 어른용 메모 (실제 동작 근거)