LLM이 고른 도구가 위험 도구면 실행 직전에 멈추고, 사람이 승인 / 수정 / 거절 중 하나를 고르면 그대로 이어서 실행하는 에이전트입니다. 아래 플레이어에서 ▶를 누르면 그래프, 소스 코드, State가 한 단계씩 같이 움직입니다. 메시지와 출력은 모두 실제 실행 결과입니다.
interrupt_basic.py는 항상 멈췄습니다. 여기서는 LLM이 어떤 도구를 부르려 하느냐에 따라 route()가 길을 나눕니다. 위험 도구(RISKY_TOOLS)면 gate, 조회 도구면 tools로 갑니다.
LLM의 tool_calls는 "실행하고 싶다"는 요청일 뿐입니다. 실제 실행은 gate 안의 TOOL_MAP[...].invoke()에서 일어나므로, 그 앞에 interrupt()를 두면 사람이 보기 전에는 아무것도 실행되지 않습니다.
resume 값이 단순 yes/no가 아니라 dict입니다. {"decision": "approve" | "modify" | "reject", ...}. 수정은 인자를 바꿔 실행하고, 거절은 실행하지 않습니다.
세 경우 모두 gate가 ToolMessage를 돌려주고 agent로 돌아갑니다. LLM이 그 결과를 읽고 사용자에게 최종 답을 씁니다.
| 갈래 | resume 값 | 도구 실행 | gate가 돌려준 ToolMessage | LLM 최종 답 (요약) |
|---|---|---|---|---|
| 승인 | {"decision": "approve"} |
원래 인자 그대로budget=800000 |
출장 신청서 제출 완료: 부산 2일, 예산 800,000원 | 신청 완료, 예산 80만 원 |
| 수정 | {"decision": "modify", "args": {"budget": 500000}} |
인자 병합 후 실행budget=500000 |
제출 완료: 예산 500,000원 + "(사용자가 인자를 수정했습니다. 최종 실행값은 … 이 값으로 안내하세요.)" | 신청 완료, 예산 500,000원 |
| 거절 | {"decision": "reject", "reason": "분기 예산이 이미 소진되었음"} |
실행 안 함 | 사용자가 실행을 거절했습니다. 사유: 분기 예산이 이미 소진되었음. 실행하지 말고 대안을 안내하세요. | 거절 안내 + 대안 3가지 (다음 분기, 예산 재검토, 규정 확인) |
LLM이 tool_calls를 냈으면, 다음 LLM 호출 전에 그 호출 id마다 짝이 되는 ToolMessage(tool_call_id=call["id"])가 있어야 합니다. 짝이 없으면 Bedrock(Anthropic) API가 "tool_use에 대한 tool_result가 없다"며 요청을 거부합니다. 그래서 실행하지 않아도 "거절됐다"는 결과를 도구 결과 자리에 넣습니다.
LLM은 자기가 처음 낸 인자(80만 원)를 기억합니다. 결과에 "예산 500,000원"만 있으면 모델이 원래 요청값을 다시 말할 수 있습니다. 그래서 "최종 실행값은 이것이니 이 값으로 안내하라"는 문장을 결과에 붙입니다(코드 87~90줄 주석). 실제 실행에서도 최종 답이 500,000원으로 나왔습니다.
2차 invoke에서 gate는 63줄부터 다시 실행됩니다. 이번에는 interrupt()가 멈추지 않고 resume 값을 리턴합니다. 그래서 interrupt() 앞에는 부작용 있는 코드를 두면 안 되고, 실제 도구 실행(92줄)이 interrupt() 뒤에 있습니다.
세 시나리오 모두 위험 도구라 tools 노드를 지나지 않습니다. 플레이어의 "보너스: 규정 조회" 탭은 같은 그래프에 조회 질문을 넣어 본 실제 결과입니다. 승인 없이 tools에서 바로 실행되고, invoke 한 번으로 끝납니다.
call = ...tool_calls[0]입니다. LLM이 한 번에 [search_trip_policy, request_business_trip]처럼 여러 개를 내면 두 가지 문제가 생깁니다. 첫째, 위험 도구가 아닌 첫 번째 호출로 승인을 묻게 됩니다. 둘째, 나머지 호출에는 ToolMessage 짝이 없어 다음 LLM 호출이 실패할 수 있습니다. 코드 주석의 "단순화: 위험 도구는 한 번에 하나"가 이 가정입니다.trip_approve 등). hitl_flow.py를 다시 실행하면 같은 thread에 새 요청이 이어 붙어, 두 번째 실행부터는 LLM이 이전 대화까지 보고 답합니다.answer.get("decision", "reject")라서 키가 없으면 거절입니다. 다만 "Approve"처럼 오타가 나면 거절도 수정도 아니라서 원래 인자로 실행됩니다(75줄, 84줄 조건에 둘 다 안 걸림). 실서비스라면 허용값 검증이 필요합니다.