LabHub
学习 学习路径 课程

AI 智能体 — 是图不是模型

漏掉审批的图不会报错

在 LabHub 中继续学习

한국어 원문으로 표시합니다.

한 줄 요약

되돌릴 수 없는 일 앞에서 멈추려면 중단점만으로는 안 된다 — 체크포인터가 없으면 그래프는 조용히 멈추고, 그 건은 그냥 없었던 일이 된다.

概念图: 중단점만으로는 안 된다 · 승인 대기 목록에 뜨지도 않고, 지급도 안 됐다. · 예외가 났다면 그날 잡혔을 것이다. · 단계마다 상태를 남긴다.

왜 이게 필요했나

환불 에이전트를 띄우면서 "십만 원이 넘으면 사람이 보고 나가게" 를 넣었다. interrupt_before=["settle"] 한 줄이었다. 시연도 잘 됐다.

일주일 뒤 고객센터에서 연락이 왔다. 고액 환불 건이 승인 대기 목록에 뜨지도 않고, 지급도 안 됐다. 로그에는 오류가 없다. 에이전트는 성공으로 끝나 있었다.

원인은 한 줄이었다. compile(interrupt_before=["settle"]) 만 주고 checkpointer 를 안 붙였다. 이 실습 이미지의 langgraph 0.2.60 에서 직접 재어 보면 이렇게 된다.

체크포인터 없이 interrupt_before → 예외 없음. 결과는 {'amount': 100, 'trace': ['assess']}
                                   settle 을 지나지 않았고, 이어서 돌릴 방법도 없다

예외가 났다면 그날 잡혔을 것이다. 조용히 절반짜리 상태를 돌려주었기 때문에 일주일이 걸렸다.

어떻게 동작하나

멈춤은 저장 위에 세워져 있다. Persistence 문서가 말하는 대로, 체크포인터는 단계마다 상태를 남긴다. 멈춤이란 "다음 단계를 지금 하지 않고, 남겨 둔 상태에서 나중에 이어서 한다" 는 뜻이다. 남겨 둘 곳이 없으면 이어서 할 수도 없다.

그래서 세 가지가 함께 있어야 한다.

멈춘 뒤에는 get_state(config) 로 상태를 본다. .next 가 "다음에 돌 노드" 를 담고 있고, 이것이 비어 있지 않다는 것이 곧 "아직 안 끝났다" 는 뜻이다.

state = app.get_state(config)
state.next            # ('settle',)  — 여기서 기다리는 중
state.values          # 그 시점의 상태 전체

이어서 돌릴 때는 입력 자리에 None 을 준다. app.invoke(None, config) 는 "새 입력은 없다. 저장된 데서 이어서 하라" 는 뜻이다.

사람이 고친 값은 리듀서를 거친다

승인자가 금액을 깎거나 거절하는 일이 실제로 가장 흔하다. 그때 쓰는 것이 update_state 다.

app.update_state(config, {"amount": 50000, "decision": "reject"})
app.invoke(None, config)

여기서 걸리기 쉬운 곳이 둘 있다.

첫째, update_state 가 넣는 값도 그 열쇠의 리듀서를 거친다. 덮어쓰는 열쇠는 덮어쓰지만, 이어 붙이는 열쇠(trace 같은 것)에 쓰면 쌓인다. 사람이 고친 흔적을 남기려고 trace 에 한 줄 넣는 것은 그래서 자연스럽고, 반대로 "목록을 통째로 바꾸려고" 넣으면 의도와 다르게 늘어난다.

둘째, 고친 값이 갈래를 다시 태울 수 있다. update_state 는 "마지막으로 돈 노드가 그 값을 썼다" 는 기록으로 남는다. 그래서 그 노드에 조건부 엣지가 달려 있으면 그 조건이 다시 평가된다. 이 실습에서 직접 재어 보면 이렇게 나타난다 — 승인 대기 중인 50만 원짜리 건의 금액을 승인자가 5만 원으로 깎으면, 그 건은 승인 경로를 벗어나 자동 처리 경로로 빠진다. needs_approval 이 다시 불려서 이제 "승인 필요 없음" 이 되기 때문이다.

이것이 버그인지 기능인지는 업무가 정한다. 깎아서 소액이 되었으니 그대로 나가도 된다면 기능이고, "한 번 승인 대기에 올라온 건은 사람이 끝을 봐야 한다" 면 버그다. 후자라면 판단에 쓰는 값과 집행에 쓰는 값을 분리해야 한다 — 원래 요청 금액을 따로 들고 갈래는 그것으로 정하고, 집행만 고친 금액으로 한다.

노드 밖에서 멈추기와 노드 안에서 멈추기

interrupt() 는 노드 안에서 멈춘다. 멈출 때 사람에게 보여 줄 값을 함께 넘길 수 있고, 재개할 때 사람이 준 답이 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)에 적고, 그 기준을 바꾸는 것이 곧 정책을 바꾸는 것이 되게 한다.

셋째, 거절을 표현할 방법이 없다. 승인만 있고 거절이 없으면, 승인자는 "그냥 안 누르기" 로 거절한다. 그러면 그 건은 영원히 대기열에 남는다. 거절도 결과여야 한다.

넷째, 승인 기록이 없다. 누가 승인했는지보다 먼저, 요청된 값과 실제로 나간 값이 다른가 를 남겨야 한다. 승인자가 금액을 고쳤다면 그 사실이 기록에 있어야 나중에 설명할 수 있다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

/root/work/aghitl/approve.py 를 한 단계씩 키운다. 먼저 승인이 필요한 일과 아닌 일을 가르는 기준과 갈래를 만들고, 체크포인터를 일부러 빠뜨린 판을 돌려 조용히 멈추는 것을 자기 눈으로 본다. 그다음 체크포인터와 thread_id 를 붙여 진짜로 멈추고, 이어서 돌리고, 승인자가 금액을 고치거나 거절한 뒤 이어서 돌린다. 마지막으로 노드 안에서 멈추는 방식을 만들어 그 노드에 몇 번 들어오는지 직접 세고, 요청값과 집행값을 함께 남기는 기록을 만든다.