AI 에이전트 — 모델이 아니라 그래프 · 중단점과 사람의 개입 · 이론
승인을 빠뜨린 그래프는 오류를 내지 않는다
한 줄 요약
되돌릴 수 없는 일 앞에서 멈추려면 중단점만으로는 안 된다 — 체크포인터가 없으면 그래프는 조용히 멈추고, 그 건은 그냥 없었던 일이 된다.
왜 이게 필요했나
환불 에이전트를 띄우면서 "십만 원이 넘으면 사람이 보고 나가게" 를 넣었다. interrupt_before=["settle"] 한 줄이었다. 시연도 잘 됐다.
일주일 뒤 고객센터에서 연락이 왔다. 고액 환불 건이 승인 대기 목록에 뜨지도 않고, 지급도 안 됐다. 로그에는 오류가 없다. 에이전트는 성공으로 끝나 있었다.
원인은 한 줄이었다. compile(interrupt_before=["settle"]) 만 주고 checkpointer 를 안 붙였다. 이 실습 이미지의 langgraph 0.2.60 에서 직접 재어 보면 이렇게 된다.
체크포인터 없이 interrupt_before → 예외 없음. 결과는 {'amount': 100, 'trace': ['assess']} settle 을 지나지 않았고, 이어서 돌릴 방법도 없다예외가 났다면 그날 잡혔을 것이다. 조용히 절반짜리 상태를 돌려주었기 때문에 일주일이 걸렸다.
어떻게 동작하나
멈춤은 저장 위에 세워져 있다. [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence) 문서가 말하는 대로, 체크포인터는 단계마다 상태를 남긴다. 멈춤이란 "다음 단계를 지금 하지 않고, 남겨 둔 상태에서 나중에 이어서 한다" 는 뜻이다. 남겨 둘 곳이 없으면 이어서 할 수도 없다.
그래서 세 가지가 함께 있어야 한다.
- 체크포인터 —
compile(checkpointer=MemorySaver()) - thread_id —
{"configurable": {"thread_id": "건-1042"}}. 어느 건을 이어서 할지 가리킨다 - 중단점 —
interrupt_before=["settle"]또는 노드 안의interrupt(...)
멈춘 뒤에는 get_state(config) 로 상태를 본다. .next 가 "다음에 돌 노드" 를 담고 있고, 이것이 비어 있지 않다는 것이 곧 "아직 안 끝났다" 는 뜻이다.
state = app.get_state(config)state.next # ('settle',) — 여기서 기다리는 중state.values # 그 시점의 상태 전체이어서 돌릴 때는 입력 자리에 None 을 준다. app.invoke(None, config) 는 "새 입력은 없다. 저장된 데서 이어서 하라" 는 뜻이다.
사람이 고친 값은 리듀서를 거친다
승인자가 금액을 깎거나 거절하는 일이 실제로 가장 흔하다. 그때 쓰는 것이 [update_state](https://docs.langchain.com/oss/python/langgraph/persistence) 다.
app.update_state(config, {"amount": 50000, "decision": "reject"})app.invoke(None, config)여기서 걸리기 쉬운 곳이 둘 있다.
첫째, update_state 가 넣는 값도 그 열쇠의 리듀서를 거친다. 덮어쓰는 열쇠는 덮어쓰지만, 이어 붙이는 열쇠(trace 같은 것)에 쓰면 쌓인다. 사람이 고친 흔적을 남기려고 trace 에 한 줄 넣는 것은 그래서 자연스럽고, 반대로 "목록을 통째로 바꾸려고" 넣으면 의도와 다르게 늘어난다.
둘째, 고친 값이 갈래를 다시 태울 수 있다. update_state 는 "마지막으로 돈 노드가 그 값을 썼다" 는 기록으로 남는다. 그래서 그 노드에 조건부 엣지가 달려 있으면 그 조건이 다시 평가된다. 이 실습에서 직접 재어 보면 이렇게 나타난다 — 승인 대기 중인 50만 원짜리 건의 금액을 승인자가 5만 원으로 깎으면, 그 건은 승인 경로를 벗어나 자동 처리 경로로 빠진다. needs_approval 이 다시 불려서 이제 "승인 필요 없음" 이 되기 때문이다.
이것이 버그인지 기능인지는 업무가 정한다. 깎아서 소액이 되었으니 그대로 나가도 된다면 기능이고, "한 번 승인 대기에 올라온 건은 사람이 끝을 봐야 한다" 면 버그다. 후자라면 판단에 쓰는 값과 집행에 쓰는 값을 분리해야 한다 — 원래 요청 금액을 따로 들고 갈래는 그것으로 정하고, 집행만 고친 금액으로 한다.
노드 밖에서 멈추기와 노드 안에서 멈추기
[interrupt()](https://docs.langchain.com/oss/python/langgraph/interrupts) 는 노드 안에서 멈춘다. 멈출 때 사람에게 보여 줄 값을 함께 넘길 수 있고, 재개할 때 사람이 준 답이 interrupt() 의 반환값으로 들어온다.
def confirm(state): answer = interrupt({"question": "이 환불을 승인합니까", "amount": state["amount"]}) return {"decision": "approve" if answer == "yes" else "reject"}app.invoke(Command(resume="yes"), config)편리하지만 반드시 알아야 할 성질이 있다. 재개하면 그 노드는 처음부터 다시 돈다. 직접 세어 보면 이렇다.
첫 실행 후 노드에 들어온 횟수 1재개 후 노드에 들어온 횟수 2그래서 interrupt() 앞에 둔 부수 효과(메일 보내기, 외부 호출, 카운터 증가)는 두 번 일어난다. 이것을 모르고 interrupt() 앞에서 결제를 걸면 두 번 결제된다. 규칙은 단순하다 — interrupt() 앞에는 다시 해도 되는 일만 둔다. 되돌릴 수 없는 일은 interrupt() 뒤에, 또는 아예 다음 노드에 둔다.
반대로 interrupt_before 는 노드에 들어가기 전에 멈춘다. 그래서 그 노드는 한 번만 돈다. 대신 사람에게 보여 줄 값을 따로 고르는 자리가 없고, 상태를 통째로 보여 주게 된다.
| | interrupt_before | 노드 안의 interrupt() |
| --- | --- | --- |
| 멈추는 자리 | 노드 앞 | 노드 안, 부른 지점 |
| 그 노드 실행 횟수 | 1 | 2(재개하면 처음부터 다시) |
| 사람에게 넘길 값 | 상태 전체 | interrupt(값) 으로 고른 것 |
| 재개 | invoke(None, config) | invoke(Command(resume=답), config) |
현장에서 만나는 모습
첫째, 체크포인터를 빠뜨린다. 위의 사고다. 오류가 안 나서 오래 산다. 승인 경로를 만들었으면 "정말 멈추는가" 를 get_state().next 로 확인하는 시험을 반드시 둔다.
둘째, 모든 것을 승인받게 한다. 소액 단순 환불까지 사람이 보면 대기열이 밀리고, 밀린 대기열은 결국 아무도 안 본다. 기준을 코드 한 곳(needs_approval)에 적고, 그 기준을 바꾸는 것이 곧 정책을 바꾸는 것이 되게 한다.
셋째, 거절을 표현할 방법이 없다. 승인만 있고 거절이 없으면, 승인자는 "그냥 안 누르기" 로 거절한다. 그러면 그 건은 영원히 대기열에 남는다. 거절도 결과여야 한다.
넷째, 승인 기록이 없다. 누가 승인했는지보다 먼저, 요청된 값과 실제로 나간 값이 다른가 를 남겨야 한다. 승인자가 금액을 고쳤다면 그 사실이 기록에 있어야 나중에 설명할 수 있다.
실무에서 진짜 중요한 것
- 멈춤은 저장 위에 있다. 체크포인터와 thread_id 없이 중단점만 주면 조용히 사라진다.
interrupt()앞에는 다시 해도 되는 일만 둔다. 재개하면 그 노드는 처음부터 다시 돈다.- 거절도 결과다. 승인·거절·수정 세 가지가 모두 끝까지 흘러가야 한다.
- 요청값과 집행값을 함께 남긴다. 승인 기록의 핵심은 사람 이름이 아니라 바뀐 값이다.
다음 실습에서 할 것
/root/work/aghitl/approve.py 를 한 단계씩 키운다. 먼저 승인이 필요한 일과 아닌 일을 가르는 기준과 갈래를 만들고, 체크포인터를 일부러 빠뜨린 판을 돌려 조용히 멈추는 것을 자기 눈으로 본다. 그다음 체크포인터와 thread_id 를 붙여 진짜로 멈추고, 이어서 돌리고, 승인자가 금액을 고치거나 거절한 뒤 이어서 돌린다. 마지막으로 노드 안에서 멈추는 방식을 만들어 그 노드에 몇 번 들어오는지 직접 세고, 요청값과 집행값을 함께 남기는 기록을 만든다.