사람이 개입(Human-in-the-Loop)하는 에이전트
우리는 이메일을 분류하는 라우터를 사용한 다음, 응답 생성을 위해 에이전트로 전달하는 이메일 어시스턴트를 만들었습니다. 평가도 마쳤고요.
그런데 받은편지함을 자율적으로 관리하도록 완전히 믿을 수 있을까요? 이렇게 민감한 작업에는 사람이 개입하는 방식(HITL)이 중요합니다!
여기서는 이메일 어시스턴트에 사람의 개입 단계를 추가하여 특정 도구 호출을 검토할 수 있도록 하는 방법을 보여드리겠습니다.

특정 지점에서 그래프를 일시 정지시키고 사용자 입력을 기다리는 방법을 알아봅니다.

Resources
- Notebook Reference: hitl.ipynb
- For LangSmith Studio: src/email_assistant
- Agent Inbox: https://github.com/langchain-ai/agent-inbox
환경 변수
from dotenv import load_dotenv
load_dotenv("../../.env", override=True)True
이메일 어시스턴트에 HITL 추가하기
이메일 어시스턴트에 HITL을 추가해 봅시다.
어시스턴트가 사용자에게 질문을 할 수 있도록 하는 새로운 도구 ‘질문’을 추가하겠습니다.
from langchain_core.tools import tool
@tool
def write_email(to: str, subject: str, content: str) -> str:
"""이메일을 작성하고 전송하세요."""
return (
f"{to}에게 '{subject}'라는 제목과 {content}라는 내용으로 이메일을 발송했습니다."
)from datetime import datetime
@tool
def schedule_meeting(
attendees: list[str],
subject: str,
duration_minutes: int,
preferred_day: datetime,
start_time: int,
) -> str:
"""캘린더 회의를 예약하세요."""
date_str = preferred_day.strftime("%A, %B %d, %Y")
return (
f"{len(attendees)}명의 참석자와 함께 {date_str} {start_time}에"
f"{duration_minutes} 동안 진행될 예정인 '{subject}' 회의"
)@tool
def check_calendar_availability(day: str) -> str:
"""지정된 날짜의 일정 가용성을 확인합니다."""
return f"{day}의 가능한 시간: 오전 9시, 오후 2시, 오후 4시"from pydantic import BaseModel
@tool
class Question(BaseModel):
"""사용자에게 물어볼 질문."""
content: str
@tool
class Done(BaseModel):
"""이메일이 발송되었습니다."""
done: bool# 에이전트가 사용할 수 있는 모든 도구
tools = [
write_email,
schedule_meeting,
check_calendar_availability,
Question,
Done,
]tools_by_name = {tool.name: tool for tool in tools}
tools_by_name{'write_email': StructuredTool(name='write_email', description='이메일을 작성하고 전송하세요.', args_schema=<class 'langchain_core.utils.pydantic.write_email'>, func=<function write_email at 0x10fce2fc0>),
'schedule_meeting': StructuredTool(name='schedule_meeting', description='캘린더 회의를 예약하세요.', args_schema=<class 'langchain_core.utils.pydantic.schedule_meeting'>, func=<function schedule_meeting at 0x10fe75440>),
'check_calendar_availability': StructuredTool(name='check_calendar_availability', description='지정된 날짜의 일정 가용성을 확인합니다.', args_schema=<class 'langchain_core.utils.pydantic.check_calendar_availability'>, func=<function check_calendar_availability at 0x10fe753a0>),
'Question': StructuredTool(name='Question', description='사용자에게 물어볼 질문.', args_schema=<class 'langchain_core.utils.pydantic.Question'>, func=<class '__main__.Question'>),
'Done': StructuredTool(name='Done', description='이메일이 발송되었습니다.', args_schema=<class 'langchain_core.utils.pydantic.Done'>, func=<class '__main__.Done'>)}
from langchain.chat_models import init_chat_model
llm_with_tools = init_chat_model(
"openai:gpt-4.1-mini",
temperature=0.0,
).bind_tools(tools)from email_assistant.schemas import RouterSchema
llm_router = init_chat_model(
"openai:gpt-4.1-mini",
temperature=0.0,
).with_structured_output(RouterSchema)from email_assistant.prompt_templates import HITL_TOOLS_PROMPT
from IPython.display import Markdown
Markdown(HITL_TOOLS_PROMPT)- write_email(to, subject, content) - 지정된 수신자에게 이메일 전송
- schedule_meeting(attendees, subject, duration_minutes, preferred_day, start_time) - 일정 회의 예약 (preferred_day는 datetime 객체)
- check_calendar_availability(day) - 특정 날짜의 가능한 시간대 확인
- Question(content) - 사용자에게 후속 질문하기
- Done - 이메일 전송 완료
분류(Triage) 노드
이메일 분류하는 라우팅 노드를 함수로 정의합니다.
여기서 분류 결과가 notify인 경우, 사용자가 이메일을 검토할 수 있도록 그래프를 중단시킵니다!
그래서 새로운 노드인 triage_interrupt_handler로 이동합니다.
from typing import Literal
from email_assistant.prompts import (
agent_system_prompt_hitl,
default_background,
default_cal_preferences,
default_response_preferences,
default_triage_instructions,
triage_system_prompt,
triage_user_prompt,
)
from email_assistant.schemas import State
from email_assistant.utils import format_email_markdown, format_for_display, parse_email
from langgraph.types import Command
def triage_router(
state: State,
) -> Command[Literal["triage_interrupt_handler", "response_agent", "__end__"]]:
"""이메일 내용을 분석하여 응답, 알림 또는 무시 여부를 결정합니다."""
# 이메일 입력을 파싱합니다.
author, to, subject, email_thread = parse_email(state["email_input"])
# 알림 발생 시 에이전트 인박스를 위한 이메일 마크다운 생성
email_markdown = format_email_markdown(subject, author, to, email_thread)
# 분류 지침이 포함된 시스템 프롬프트
system_prompt = triage_system_prompt.format(
background=default_background,
triage_instructions=default_triage_instructions,
)
# 사용자 요청 프롬프트
user_prompt = triage_user_prompt.format(
author=author, to=to, subject=subject, email_thread=email_thread
)
# 라우터 LLM 실행
result = llm_router.invoke(
[
("system", system_prompt),
("user", user_prompt),
]
)
assert isinstance(result, RouterSchema)
# 분류
classification = result.classification
# 분류에 따라 분기 처리
if classification == "respond":
print("📧 분류: RESPOND - 이 이메일에는 답변이 필요합니다.")
return Command(
goto="response_agent",
update={
"classification_decision": classification,
"messages": [
{
"role": "user",
"content": f"이메일에 답장하세요: {email_markdown}",
}
],
},
)
if classification == "ignore":
print("🚫 분류: IGNORE - 이 이메일은 무시합니다.")
return Command(
goto="__end__",
update={
"classification_decision": classification,
},
)
if classification == "notify":
print("🔔 분류: NOTIFY - 이 이메일에는 중요한 정보가 포함되어 있습니다")
Command(
goto="triage_interrupt_handler",
update={
"classification_decision": classification,
},
)
raise ValueError(f"Invalid classification: {classification}")분류 인터럽트 핸들러
사용자에게 notify(알림)을 보내기로 결정되면 그래프를 중단합니다!

이를 위해 새로운 노드 triage_interrupt_handler를 추가합니다. 이 노드는 다음과 같은 역할을 수행합니다:
- 분류 결과가
notify인 경우 사용자에게 표시: 분류 결과를 담은dict를 인터럽트에 전달합니다. - 사용자의 결정에 대한 응답 허용: 에이전트 인박스에서 반환되는 내용을 처리할 코드를 설계합니다.
여기에서 볼 수 있듯이, 에이전트 인박스에서 확인할 수 있도록 인터럽트를 특정 필드로 포맷합니다:
action_request: 인터럽트의 동작 및 인수를 나타내며,action(동작 이름)과args(도구 호출 인수)로 구성됩니다. 에이전트 인박스에서 인터럽트 이벤트의 주요 헤더로 표시됩니다.config: 허용되는 상호작용 유형과 각 유형별 특정 UI 요소를 구성합니다.description: 상세해야 하며 마크다운 형식일 수 있습니다. 에이전트 인박스에서 설명으로 표시됩니다.
from langgraph.types import interrupt
def triage_interrupt_handler(
state: State,
) -> Command[Literal["response_agent", "__end__"]]:
"""분류 단계에서 발생하는 인터럽트를 처리합니다."""
# 이메일 입력을 파싱합니다
author, to, subject, email_thread = parse_email(state["email_input"])
# 알림 발생 시 에이전트 인박스를 위한 이메일 마크다운 생성
email_markdown = format_email_markdown(subject, author, to, email_thread)
# 에이전트 인박스는 단일 키 `type`을 가진 딕셔너리 목록으로 응답합니다.
# `type`은 `accept`, `edit`, `ignore` 또는 `response`일 수 있습니다.
response = interrupt(
{
"action_request": {
"action": f"이메일 어시스턴트: {state['classification_decision']}",
"args": {},
},
"config": {
"allow_ignore": True,
"allow_respond": True,
"allow_edit": False,
"allow_accept": False,
},
# Agent Inbox에 표시할 이메일
"description": email_markdown,
}
)
print("response", response)
messages = [("user", f"사용자에게 알리기 위한 이메일: {email_markdown}")]
if response[0]["type"] == "response":
user_input = response["args"]
return Command(
goto="response_agent",
update={
"messages": messages
+ [
(
"user",
f"사용자가 이메일에 답장하고자 합니다. 이 피드백을 활용하여 응답하십시오.: {user_input}",
)
],
},
)
if response[0]["type"] == "ignore":
return Command(
goto="__end__",
update={"messages": messages},
)
raise ValueError(f"Invalid response: {response}")LLM 노드
def llm_call(state: State):
"""LLM은 도구를 호출할지 여부를 결정합니다."""
system_prompt = agent_system_prompt_hitl.format(
tools_prompt=HITL_TOOLS_PROMPT,
background=default_background,
response_preferences=default_response_preferences,
cal_preferences=default_cal_preferences,
)
response = llm_with_tools.invoke([("system", system_prompt)] + state["messages"])
return {"messages": [response]}인터럽트 핸들러
interrupt_handler는 응답 에이전트의 핵심 HITL 구성 요소입니다.
이 핸들러의 역할은 LLM이 실행하려는 도구 호출을 검토하고, 실행 전에 인간 검토가 필요한 항목을 판단하는 것입니다.
작동 방식은 다음과 같습니다:
-
도구 선택: 핸들러는 인간 승인이 필요한 “HITL 도구” 목록을 유지합니다:
write_email: 이메일 발송은 상당한 외부 영향력을 가짐schedule_meeting: 회의 일정은 캘린더에 영향을 미침Question: 사용자에게 질문하는 것은 직접적인 상호작용이 필요함
-
직접 실행: HITL 목록에 포함되지 않은 도구(예:
check_calendar_availability)는 중단 없이 즉시 실행됩니다. 이를 통해 저위험 작업은 자동으로 진행됩니다. -
컨텍스트 준비: 검토가 필요한 도구의 경우 핸들러는:
- 컨텍스트 확보를 위해 원본 이메일을 검색합니다
- 명확한 표시를 위해 도구 호출 세부 정보를 포맷팅합니다
- 각 도구 유형별로 허용되는 상호작용 유형을 구성합니다
-
중단 생성: 핸들러는 다음을 포함한 구조화된 중단 요청을 생성합니다:
- 작업명과 인수
- 허용된 상호작용 유형에 대한 구성
- 원본 이메일과 제안된 작업을 모두 포함하는 설명
-
응답 처리: 인터럽트 후 핸들러는 사람의 응답을 처리합니다:
- 수락: 원본 인자로 도구를 실행
- 수정: 수정된 인자로 도구 호출을 업데이트한 후 실행
- 무시: 도구 실행 취소
- 응답: 실행 없이 피드백 기록
이 핸들러는 일상적인 작업은 자동으로 진행시키면서 중요한 모든 작업에 대해 사용자의 감독을 보장합니다.
도구 인수(이메일 내용이나 회의 세부사항 등)를 수정할 수 있는 기능은 사용자가 어시스턴트의 동작을 정밀하게 제어할 수 있게 합니다.
전체 흐름을 시각화하면 다음과 같습니다:

from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
# HITL 허용 도구
hitl_tools = ["write_email", "schedule_meeting", "Question"]
def interrupt_handler(state: State) -> Command[Literal["llm_call", "__end__"]]:
"""도구 호출에 대한 인적 검토를 위한 인터럽트를 생성합니다"""
# 메시지 저장
messages: list[BaseMessage] = []
# 다음 LLM 호출 노드로 이동하십시오
goto = "llm_call"
# 원본 이메일
email_input = state["email_input"]
author, to, subject, email_thread = parse_email(email_input)
original_email_markdown = format_email_markdown(subject, author, to, email_thread)
last_message = state["messages"][-1]
assert isinstance(last_message, AIMessage)
# 마지막 메시지의 도구 호출을 반복 처리합니다
for tool_call in last_message.tool_calls:
# 해당 도구가 HITL 목록에 포함되지 않은 경우, 인터럽트 없이 바로 실행!
if tool_call["name"] not in hitl_tools:
tool = tools_by_name[tool_call["name"]]
observation = tool.invoke(tool_call["args"])
messages.append(
ToolMessage(content=observation, tool_call_id=tool_call["id"])
)
continue
# 표시를 위한 형식 도구 호출 및 원본 이메일 앞에 추가
tool_display = format_for_display(tool_call)
description = original_email_markdown + tool_display
# 에이전트 수신함에서 허용되는 작업을 구성합니다
if tool_call["name"] in {"write_email", "schedule_meeting"}:
config = {
"allow_ignore": True,
"allow_respond": True,
"allow_edit": True,
"allow_accept": True,
}
elif tool_call["name"] == "Question":
config = {
"allow_ignore": True,
"allow_respond": True,
"allow_edit": False,
"allow_accept": False,
}
else:
raise ValueError(f"Invalid tool call: {tool_call['name']}")
# 에이전트 수신함으로 보내고 응답을 기다립니다.
response = interrupt(
[
{
"action_request": {
"action": tool_call["name"],
"args": tool_call["args"],
},
"config": config,
"description": description,
}
]
)[0]
# 응답 처리
if response["type"] == "accept":
tool = tools_by_name[tool_call["name"]]
observation = tool.invoke(tool_call["args"])
messages.append(
ToolMessage(content=observation, tool_call_id=tool_call["id"])
)
# AI 메시지의 도구 호출을 수정된 내용으로 업데이트합니다.
elif response["type"] == "edit":
# Agent Inbox로 부터 편집 내용 가져오기
edited_args = response["args"]["args"]
# 편집 중인 호출을 필터링하여 제거하고 업데이트된 버전을 추가하여 새로운 도구 호출 목록 생성
# 이는 원본 목록을 직접 수정하지 않도록 합니다
updated_tool_calls = [
tc for tc in last_message.tool_calls if tc["id"] != tool_call["id"]
] + [
{
"type": "tool_call",
"name": tool_call["name"],
"args": edited_args,
"id": tool_call["id"],
}
]
# 원본을 수정하는 대신 업데이트된 도구 호출을 포함한 메시지의 새 복사본 생성
# 이는 상태 불변성을 보장하고 코드의 다른 부분에서 발생하는 부작용을 방지합니다
# 메시지 상태 키("messages": result)를 업데이트할 때 add_messages 리듀서는
# ID별로 기존 메시지를 덮어씁니다. 여기서 이 특성을 활용하여 도구 호출을 업데이트합니다.
messages.append(
last_message.model_copy(update={"tool_calls": updated_tool_calls})
)
# Agent Inbox에서 편집된 내용으로 도구 호출을 업데이트합니다.
if tool_call["name"] in {"write_email", "schedule_meeting"}:
tool = tools_by_name[tool_call["name"]]
observation = tool.invoke(edited_args)
messages.append(
ToolMessage(content=observation, tool_call_id=tool_call["id"])
)
# Catch all other tool calls
else:
raise ValueError(f"Invalid tool call: {tool_call['name']}")
elif response["type"] == "ignore":
goto = "__end__"
# 도구를 실행하지않고, 에이전트에게 관련하여 알려줍니다
if tool_call["name"] == "write_email":
messages.append(
ToolMessage(
content="사용자가 이 이메일 초안을 무시하도록 요청했습니다. 이 이메일을 무시하고 워크플로를 종료하십시오.",
tool_call_id=tool_call["id"],
)
)
elif tool_call["name"] == "schedule_meeting":
messages.append(
ToolMessage(
content="사용자가 이 캘린더 회의 초안을 무시하도록 요청했습니다. 이 이메일을 무시하고 워크플로를 종료하십시오.",
tool_call_id=tool_call["id"],
)
)
elif tool_call["name"] == "Question":
messages.append(
ToolMessage(
content="사용자가 이 질문을 무시하도록 요청했습니다. 이 이메일을 무시하고 워크플로를 종료하십시오.",
tool_call_id=tool_call["id"],
)
)
else:
raise ValueError(f"Invalid tool call: {tool_call['name']}")
elif response["type"] == "response":
# 사용자가 제공한 피드백
user_feedback = response["args"]
if tool_call["name"] == "write_email":
# 도구를 실행하지 않고, 이메일 내용에 포함시킬 사용자 피드백 메시지를 추가합니다.
messages.append(
ToolMessage(
content=f"사용자가 피드백을 제공했으며, 이를 이메일에 반영할 수 있습니다. 피드백: {user_feedback}",
tool_call_id=tool_call["id"],
)
)
elif tool_call["name"] == "schedule_meeting":
# 도구를 실행하지 않고, 이메일 내용에 포함시킬 사용자 피드백 메시지를 추가합니다.
messages.append(
ToolMessage(
content=f"사용자가 피드백을 제공했으며, 이를 회의 요청에 반영할 수 있습니다. 피드백: {user_feedback}",
tool_call_id=tool_call["id"],
)
)
elif tool_call["name"] == "Question":
# 도구를 실행하지 않고, 이메일 내용에 포함시킬 사용자 피드백 메시지를 추가합니다.
messages.append(
ToolMessage(
content=f"사용자가 질문에 답변했습니다. 이 답변은 향후 후속 조치에 활용할 수 있습니다. 피드백: {user_feedback}",
tool_call_id=tool_call["id"],
)
)
else:
raise ValueError(f"Invalid tool call: {tool_call['name']}")
# Catch all other responses
else:
raise ValueError(f"Invalid response: {response}")
return Command(goto=goto, update={"messages": messages})def should_continue(state: State) -> Literal["interrupt_handler", "__end__"]:
"""도구 핸들러로 이동하거나, Done 도구 호출 시 종료"""
last_message = state["messages"][-1]
if isinstance(last_message, AIMessage) and last_message.tool_calls:
for tool_call in last_message.tool_calls:
if tool_call["name"] == "Done":
return "__end__"
return "interrupt_handler"from email_assistant.schemas import StateInput
from langgraph.graph import END, START, StateGraph
# Build workflow
response_agent = (
StateGraph(State)
.add_node("llm_call", llm_call)
.add_node("interrupt_handler", interrupt_handler)
.add_conditional_edges(
"llm_call",
should_continue,
{
"interrupt_handler": "interrupt_handler",
END: END,
},
)
.add_edge(START, "llm_call")
).compile()# Build overall workflow
overall_workflow = (
StateGraph(State, input_schema=StateInput)
.add_node(triage_router)
.add_node(triage_interrupt_handler)
.add_node("response_agent", response_agent)
.add_edge(START, "triage_router")
)HITL 패턴 검토
분류 중단 이메일이 “notify”로 분류되면, 시스템이 중단되어 사용자에게 이메일을 표시합니다
- 사용자 결정: 사용자는 알림을 무시하거나 이메일에 응답하기 위한 피드백을 제공할 수 있습니다
- 흐름 제어: 무시하면 워크플로우 종료, 사용자가 피드백을 제공하면 응답 에이전트로 진행됩니다
이메일 작성: 시스템이 제안된 이메일 초안을 사용자 검토를 위해 표시합니다
- 사용자 결정 및 흐름 제어: 무시(워크플로우 종료), 피드백으로 응답, 초안을 그대로 승인, 또는 초안 수정
미팅 일정 잡기: 시스템이 제안된 미팅 세부사항을 사용자 검토를 위해 표시합니다
- 사용자 결정 및 흐름 제어: 무시(워크플로우 종료), 피드백으로 응답, 미팅 세부사항을 그대로 승인, 또는 세부사항 수정
질문: 시스템이 정보를 명확히 하기 위해 사용자에게 질문합니다
- 사용자 결정 및 흐름 제어: 무시(워크플로우 종료) 또는 답변으로 응답
중단을 통해 도구 호출을 검토하고 승인할 수 있습니다
import uuid
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import InMemorySaver
# 응답할 이메일
email_input_respond = {
"to": "Lance Martin <[email protected]>",
"author": "Project Manager <[email protected]>",
"subject": "세금 시즌 통화 일정 잡기",
"email_thread": "Lance,\n\n세금 시즌이 또 돌아왔습니다. 올해 세금 계획 전략에 대해 논의하기 위한 통화 일정을 잡고 싶습니다. 비용 절감에 도움이 될 수 있는 몇 가지 제안이 있습니다.\n\n다음 주에 시간이 되시나요? 화요일이나 목요일 오후가 저에게는 가장 좋으며, 약 45분 정도 소요될 것 같습니다.\n\n감사합니다,\nProject Manager",
}
# 체크포인터와 함께 그래프 컴파일
checkpointer = InMemorySaver()
graph = overall_workflow.compile(checkpointer=checkpointer)
thread_id_1 = uuid.uuid4()
thread_config_1 = RunnableConfig(configurable={"thread_id": thread_id_1})
# 중단하기로 선택한 도구 호출까지 그래프 실행
print("첫 번째 중단 지점까지 그래프를 실행합니다...")
for chunk in graph.stream({"email_input": email_input_respond}, config=thread_config_1):
# 중단 객체가 있는 경우 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")첫 번째 중단 지점까지 그래프를 실행합니다...
📧 분류: RESPOND - 이 이메일에는 답변이 필요합니다.
INTERRUPT OBJECT:
액션 요청: {'action': 'schedule_meeting', 'args': {'attendees': ['[email protected]', '[email protected]'], 'subject': '세금 시즌 계획 전략 논의', 'duration_minutes': 45, 'preferred_day': '2025-11-04', 'start_time': 14}}
무슨 일이 일어났나요?
중단(interrupt)에 도달하여 도구 호출 시점에서 실행이 일시 중지되었습니다. 사용자에게 표시된 중단된 action(도구 호출 이름)과 args(도구 호출 인자)를 확인할 수 있습니다.
이제 중단을 어떻게 처리해야하나요?
Command를 사용하여 그래프 상태를 업데이트하고 그래프를 재개합니다. Command는 다음과 같은 기능을 가지고 있습니다.
goto: 다음에 이동할 노드를 지정합니다update: 실행을 계속하기 전에 상태를 수정합니다resume: 중단 호출에서 반환할 값을 제공합니다
그래프가 처리하도록 설계된 어떤 값이든 반환할 수 있습니다. 이 프로젝트에서 그래프는 accept, edit, ignore, 또는 response가 될 수 있는 단일 키 type을 가진 딕셔너리 리스트를 처리하도록 설계되어 있습니다. 따라서 도구 호출을 승인한다는 것을 그래프에 알리기 위해 resume 인자에 {"type": "accept"}를 간단히 전달할 수 있습니다.
from langgraph.types import Command
print(
f"\n사용자가 {Interrupt_Object.value[0]['action_request']} 도구 호출을 승인하는 것을 시뮬레이션합니다..."
)
for chunk in graph.stream(Command(resume=[{"type": "accept"}]), config=thread_config_1):
# 중단 객체가 있는 경우 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")사용자가 {'action': 'schedule_meeting', 'args': {'attendees': ['[email protected]', '[email protected]'], 'subject': '세금 시즌 계획 전략 논의', 'duration_minutes': 45, 'preferred_day': '2025-11-04', 'start_time': 14}} 도구 호출을 승인하는 것을 시뮬레이션합니다...
INTERRUPT OBJECT:
액션 요청: {'action': 'write_email', 'args': {'to': '[email protected]', 'subject': 'Re: 세금 시즌 통화 일정 잡기', 'content': '안녕하세요,\n\n다음 주 화요일 오후 2시에 45분간 세금 시즌 계획 전략 논의를 위한 통화를 예약했습니다. 비용 절감 제안에 대해 자세히 듣고 논의할 수 있기를 기대합니다.\n\n감사합니다.\nLance Martin'}}
print(
f"\n사용자가 {Interrupt_Object.value[0]['action_request']} 도구 호출을 승인하는 것을 시뮬레이션합니다..."
)
for chunk in graph.stream(Command(resume=[{"type": "accept"}]), config=thread_config_1):
# 중단 객체가 있는 경우 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")사용자가 {'action': 'write_email', 'args': {'to': '[email protected]', 'subject': 'Re: 세금 시즌 통화 일정 잡기', 'content': '안녕하세요,\n\n다음 주 화요일 오후 2시에 45분간 세금 시즌 계획 전략 논의를 위한 통화를 예약했습니다. 비용 절감 제안에 대해 자세히 듣고 논의할 수 있기를 기대합니다.\n\n감사합니다.\nLance Martin'}} 도구 호출을 승인하는 것을 시뮬레이션합니다...
state = graph.get_state(thread_config_1)
for m in state.values["messages"]:
m.pretty_print()================================[1m Human Message [0m=================================
이메일에 답장하세요:
**Subject**: 세금 시즌 통화 일정 잡기
**From**: Project Manager <[email protected]>
**To**: Lance Martin <[email protected]>
Lance,
세금 시즌이 또 돌아왔습니다. 올해 세금 계획 전략에 대해 논의하기 위한 통화 일정을 잡고 싶습니다. 비용 절감에 도움이 될 수 있는 몇 가지 제안이 있습니다.
다음 주에 시간이 되시나요? 화요일이나 목요일 오후가 저에게는 가장 좋으며, 약 45분 정도 소요될 것 같습니다.
감사합니다,
Project Manager
---
==================================[1m Ai Message [0m==================================
Tool Calls:
check_calendar_availability (call_y8omb69gUghPEohVN0eXL4c0)
Call ID: call_y8omb69gUghPEohVN0eXL4c0
Args:
day: 2025-11-04
=================================[1m Tool Message [0m=================================
2025-11-04의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
==================================[1m Ai Message [0m==================================
Tool Calls:
check_calendar_availability (call_QkvsNnRam29dA0Kij5xIptNZ)
Call ID: call_QkvsNnRam29dA0Kij5xIptNZ
Args:
day: 2025-11-06
=================================[1m Tool Message [0m=================================
2025-11-06의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
==================================[1m Ai Message [0m==================================
Tool Calls:
schedule_meeting (call_TsRJN34yJgP2m0UFRVZsfkME)
Call ID: call_TsRJN34yJgP2m0UFRVZsfkME
Args:
attendees: ['[email protected]', '[email protected]']
subject: 세금 시즌 계획 전략 논의
duration_minutes: 45
preferred_day: 2025-11-04
start_time: 14
=================================[1m Tool Message [0m=================================
2명의 참석자와 함께 Tuesday, November 04, 2025 14에45 동안 진행될 예정인 '세금 시즌 계획 전략 논의' 회의
==================================[1m Ai Message [0m==================================
Tool Calls:
write_email (call_NZVkh5SaFyEGcez2ouxeIGne)
Call ID: call_NZVkh5SaFyEGcez2ouxeIGne
Args:
to: [email protected]
subject: Re: 세금 시즌 통화 일정 잡기
content: 안녕하세요,
다음 주 화요일 오후 2시에 45분간 세금 시즌 계획 전략 논의를 위한 통화를 예약했습니다. 비용 절감 제안에 대해 자세히 듣고 논의할 수 있기를 기대합니다.
감사합니다.
Lance Martin
=================================[1m Tool Message [0m=================================
[email protected]에게 'Re: 세금 시즌 통화 일정 잡기'라는 제목과 안녕하세요,
다음 주 화요일 오후 2시에 45분간 세금 시즌 계획 전략 논의를 위한 통화를 예약했습니다. 비용 절감 제안에 대해 자세히 듣고 논의할 수 있기를 기대합니다.
감사합니다.
Lance Martin라는 내용으로 이메일을 발송했습니다.
==================================[1m Ai Message [0m==================================
Tool Calls:
Done (call_R4Q3RGNTPJFuE5k0MtVmUihR)
Call ID: call_R4Q3RGNTPJFuE5k0MtVmUihR
Args:
done: True
중단을 통해 도구 호출을 수정할 수 있습니다
이 테스트는 HITL 흐름에서 사람의 수정이 어떻게 작동하는지 보여줍니다:
- 이전과 동일한 세금 계획 이메일로 시작합니다
- 에이전트가 동일한 매개변수로 미팅을 제안합니다
- 이번에는 사용자가 미팅 제안을 수정하여 다음을 변경합니다:
- 시간을 45분에서 30분으로 변경
- 미팅 제목을 더 간결하게 만듦
- 에이전트가 이메일 초안을 작성할 때 이러한 변경 사항에 적응합니다
- 사용자가 이메일을 더 짧고 덜 격식 있게 추가로 수정합니다
- 워크플로우가 두 수정 사항이 모두 반영되어 완료됩니다
이 시나리오는 HITL의 가장 강력한 측면 중 하나를 보여줍니다:
- 사용자는 에이전트 액션이 실행되기 전에 정확한 수정을 할 수 있으며, 모든 세부 사항을 직접 처리하지 않고도 최종 결과가 자신의 선호도와 일치하도록 보장할 수 있습니다.
# 이전과 동일한 이메일
email_input_respond = {
"to": "Lance Martin <[email protected]>",
"author": "Project Manager <[email protected]>",
"subject": "세금 시즌 통화 일정 잡기",
"email_thread": "Lance,\n\n세금 시즌이 또 돌아왔습니다. 올해 세금 계획 전략에 대해 논의하기 위한 통화 일정을 잡고 싶습니다. 비용 절감에 도움이 될 수 있는 몇 가지 제안이 있습니다.\n\n다음 주에 시간이 되시나요? 화요일이나 목요일 오후가 저에게는 가장 좋으며, 약 45분 정도 소요될 것 같습니다.\n\n감사합니다,\nProject Manager",
}
# 새 스레드로 그래프 컴파일
checkpointer = InMemorySaver()
graph = overall_workflow.compile(checkpointer=checkpointer)
thread_id_2 = uuid.uuid4()
thread_config_2 = RunnableConfig(configurable={"thread_id": thread_id_2})
# 첫 번째 중단까지 그래프 실행 - "respond"로 분류되고 에이전트가 write_email 도구 호출을 생성합니다
print("첫 번째 중단 지점까지 그래프를 실행합니다...")
for chunk in graph.stream({"email_input": email_input_respond}, config=thread_config_2):
# 중단 객체가 있는 경우 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")첫 번째 중단 지점까지 그래프를 실행합니다...
📧 분류: RESPOND - 이 이메일에는 답변이 필요합니다.
INTERRUPT OBJECT:
액션 요청: {'action': 'schedule_meeting', 'args': {'attendees': ['[email protected]', '[email protected]'], 'subject': '세금 시즌 계획 전략 통화', 'duration_minutes': 45, 'preferred_day': '2025-11-04T00:00:00', 'start_time': 14}}
schedule_meeting 도구 호출 수정
에이전트가 초기 미팅 일정을 제안할 때, 이제 수정 기능을 통해 사용자가 변경하는 것을 시뮬레이션합니다. 이것은 edit 응답 타입이 어떻게 작동하는지 보여줍니다:
- 사용자는 이전 테스트와 동일한 미팅 제안을 받습니다
- 승인하는 대신, 매개변수를 수정합니다:
- 시간을 45분에서 30분으로 단축
- 동일한 요일과 시간 유지
edit응답에는 수정된 인자의 전체 세트가 포함됩니다- 중단 핸들러가 원래 도구 인자를 이러한 수정된 인자로 교체합니다
- 도구가 사용자의 수정 사항으로 실행됩니다
이것은 수정 기능이 사용자에게 에이전트 액션에 대한 정확한 제어권을 제공하면서도 에이전트가 실행 세부 사항을 처리하도록 하는 방법을 보여줍니다.
# 이제 사용자가 schedule_meeting 도구 호출을 수정하는 것을 시뮬레이션합니다
print("\n사용자가 schedule_meeting 도구 호출을 수정하는 것을 시뮬레이션합니다...")
edited_schedule_args = {
"attendees": ["[email protected]", "[email protected]"],
"subject": "세금 계획 논의",
"duration_minutes": 30, # 45분에서 30분으로 변경
"preferred_day": "2025-05-06",
"start_time": 14,
}
for chunk in graph.stream(
Command(resume=[{"type": "edit", "args": {"args": edited_schedule_args}}]),
config=thread_config_2,
):
# response_agent의 가장 최근 메시지 검사
if "response_agent" in chunk:
chunk["response_agent"]["messages"][-1].pretty_print()
# 중단 객체가 있는 경우 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")사용자가 schedule_meeting 도구 호출을 수정하는 것을 시뮬레이션합니다...
INTERRUPT OBJECT:
액션 요청: {'action': 'write_email', 'args': {'to': '[email protected]', 'subject': 'Re: 세금 시즌 통화 일정 잡기', 'content': '안녕하세요,\n\n다음 주 화요일 오후 2시에 30분간 세금 계획 전략 논의를 위한 통화를 예약했습니다. 45분 요청해 주셨으나, 30분 일정으로 조정하였으니 참고 부탁드립니다.\n\n회의에서 비용 절감 제안에 대해 자세히 듣고 논의하길 기대합니다.\n\n감사합니다.\nLance Martin'}}
write_email 도구 호출 수정
수정된 미팅 일정을 승인한 후, 에이전트는 30분 시간을 반영한 이메일 초안을 작성합니다. 이제 이메일 콘텐츠 수정이 어떻게 작동하는지 보여줍니다:
- 에이전트가 더 짧은 30분 시간을 언급하도록 이메일을 조정했습니다
- 사용자가 이메일에 더 큰 변경을 원하는 것을 시뮬레이션합니다:
- 콘텐츠를 완전히 재작성하여 더 짧고 덜 격식 있게 만듦
- 이메일에 언급된 미팅 날짜 변경 (사용자가 에이전트의 실수를 수정할 수 있음을 보여줌)
- 미팅을 확정된 것으로 명시하는 대신 확인 요청
edit응답에는 완전히 새로운 이메일 콘텐츠가 포함됩니다- 도구 인자가 이 수정된 콘텐츠로 업데이트됩니다
- 이메일이 사용자가 선호하는 문구로 전송됩니다
이 예제는 복잡한 커뮤니케이션 작업을 위한 HITL의 강력함을 보여줍니다 - 에이전트가 구조와 초기 콘텐츠를 처리하는 동안, 사람은 어조, 스타일, 내용을 세밀하게 조정할 수 있습니다.
# 이제 사용자가 write_email 도구 호출을 수정하는 것을 시뮬레이션합니다
print("\n사용자가 write_email 도구 호출을 수정하는 것을 시뮬레이션합니다...")
edited_email_args = {
"to": "[email protected]",
"subject": "Re: 세금 시즌 통화 일정 잡기",
"content": "안녕하세요 Project Manager님,\n\n세금 계획에 대해 연락 주셔서 감사합니다. 다음 주 목요일 오후 3시에 30분 통화 일정을 잡았습니다. 괜찮으실까요?\n\n감사합니다,\nLance Martin",
}
for chunk in graph.stream(
Command(resume=[{"type": "edit", "args": {"args": edited_email_args}}]),
config=thread_config_2,
):
# response_agent의 가장 최근 메시지 검사
if "response_agent" in chunk:
chunk["response_agent"]["messages"][-1].pretty_print()
# 중단 객체가 있는 경우 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")사용자가 write_email 도구 호출을 수정하는 것을 시뮬레이션합니다...
==================================[1m Ai Message [0m==================================
Tool Calls:
Done (call_GWCo7to13ukoOC21LtVezXPt)
Call ID: call_GWCo7to13ukoOC21LtVezXPt
Args:
done: True
전체 메시지 히스토리를 확인하고, 수정된 도구 호출을 보려면 트레이스를 확인하세요:
https://smith.langchain.com/public/21769510-d57a-41e4-b5c7-0ddb23c237d8/r
state = graph.get_state(thread_config_2)
for m in state.values["messages"]:
m.pretty_print()================================[1m Human Message [0m=================================
이메일에 답장하세요:
**Subject**: 세금 시즌 통화 일정 잡기
**From**: Project Manager <[email protected]>
**To**: Lance Martin <[email protected]>
Lance,
세금 시즌이 또 돌아왔습니다. 올해 세금 계획 전략에 대해 논의하기 위한 통화 일정을 잡고 싶습니다. 비용 절감에 도움이 될 수 있는 몇 가지 제안이 있습니다.
다음 주에 시간이 되시나요? 화요일이나 목요일 오후가 저에게는 가장 좋으며, 약 45분 정도 소요될 것 같습니다.
감사합니다,
Project Manager
---
==================================[1m Ai Message [0m==================================
Tool Calls:
check_calendar_availability (call_iVmJnOdOQenp25SA5PKm2WFL)
Call ID: call_iVmJnOdOQenp25SA5PKm2WFL
Args:
day: 2025-11-04
=================================[1m Tool Message [0m=================================
2025-11-04의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
==================================[1m Ai Message [0m==================================
Tool Calls:
check_calendar_availability (call_6CuTDQtoEfL6khBRoNdzoI1j)
Call ID: call_6CuTDQtoEfL6khBRoNdzoI1j
Args:
day: 2025-11-06
=================================[1m Tool Message [0m=================================
2025-11-06의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
==================================[1m Ai Message [0m==================================
Tool Calls:
schedule_meeting (call_mlObXggj9pod9WjpZCklUQse)
Call ID: call_mlObXggj9pod9WjpZCklUQse
Args:
attendees: ['[email protected]', '[email protected]']
subject: 세금 계획 논의
duration_minutes: 30
preferred_day: 2025-05-06
start_time: 14
=================================[1m Tool Message [0m=================================
2명의 참석자와 함께 Tuesday, May 06, 2025 14에30 동안 진행될 예정인 '세금 계획 논의' 회의
==================================[1m Ai Message [0m==================================
Tool Calls:
write_email (call_Fb0XuNmovV9NridNraGWmBW1)
Call ID: call_Fb0XuNmovV9NridNraGWmBW1
Args:
to: [email protected]
subject: Re: 세금 시즌 통화 일정 잡기
content: 안녕하세요 Project Manager님,
세금 계획에 대해 연락 주셔서 감사합니다. 다음 주 목요일 오후 3시에 30분 통화 일정을 잡았습니다. 괜찮으실까요?
감사합니다,
Lance Martin
=================================[1m Tool Message [0m=================================
[email protected]에게 'Re: 세금 시즌 통화 일정 잡기'라는 제목과 안녕하세요 Project Manager님,
세금 계획에 대해 연락 주셔서 감사합니다. 다음 주 목요일 오후 3시에 30분 통화 일정을 잡았습니다. 괜찮으실까요?
감사합니다,
Lance Martin라는 내용으로 이메일을 발송했습니다.
==================================[1m Ai Message [0m==================================
Tool Calls:
Done (call_GWCo7to13ukoOC21LtVezXPt)
Call ID: call_GWCo7to13ukoOC21LtVezXPt
Args:
done: True
중단을 통해 도구 호출에 대한 피드백을 제공할 수 있습니다
이 테스트 세트는 “response” 기능을 보여줍니다 - 수정이나 승인 없이 피드백 제공:
-
먼저, 미팅 일정 잡기에 대한 피드백을 테스트합니다:
- 사용자가 구체적인 선호사항을 제공합니다 (45분 대신 30분, 그리고 오후 미팅)
- 에이전트가 이 피드백을 수정된 제안에 반영합니다
- 그런 다음 사용자가 수정된 미팅 일정을 승인합니다
-
두 번째로, 이메일 작성에 대한 피드백을 테스트합니다:
- 사용자가 특정 마무리 문구가 있는 더 짧고 덜 격식 있는 이메일을 요청합니다
- 에이전트가 이 지침에 따라 이메일을 완전히 재작성합니다
- 사용자가 새 초안을 승인합니다
-
마지막으로, 질문에 대한 피드백을 테스트합니다:
- 브런치 초대에 대해 사용자가 추가 맥락과 함께 질문에 답변합니다
- 에이전트가 이 정보를 사용하여 적절한 이메일 응답 초안을 작성합니다
- 워크플로우가 사용자의 입력이 통합되어 진행됩니다
“response” 기능은 승인과 수정 사이의 격차를 메웁니다 - 사용자는 전체 콘텐츠를 직접 작성하지 않고도 에이전트를 안내할 수 있습니다. 이것은 특히 다음과 같은 경우에 강력합니다:
- 어조와 스타일 조정
- 에이전트가 놓친 맥락 추가
- 에이전트의 접근 방식 재지정
- 다음 단계를 형성하는 방식으로 질문에 답변
# 응답 - 미팅 요청 이메일
email_input_respond = {
"to": "Lance Martin <[email protected]>",
"author": "Project Manager <[email protected]>",
"subject": "세금 시즌 통화 일정 잡기",
"email_thread": "Lance,\n\n세금 시즌이 또 돌아왔습니다. 올해 세금 계획 전략에 대해 논의하기 위한 통화 일정을 잡고 싶습니다. 비용 절감에 도움이 될 수 있는 몇 가지 제안이 있습니다.\n\n다음 주에 시간이 되시나요? 화요일이나 목요일 오후가 저에게는 가장 좋으며, 약 45분 정도 소요될 것 같습니다.\n\n감사합니다,\nProject Manager",
}
# 그래프 컴파일
checkpointer = InMemorySaver()
graph = overall_workflow.compile(checkpointer=checkpointer)
thread_id_5 = uuid.uuid4()
thread_config_5 = {"configurable": {"thread_id": thread_id_5}}
# 첫 번째 중단까지 그래프 실행
# 이메일은 "respond"로 분류됩니다
# 에이전트가 schedule_meeting과 write_email 도구 호출을 생성합니다
print("첫 번째 중단 지점까지 그래프를 실행합니다...")
for chunk in graph.stream({"email_input": email_input_respond}, config=thread_config_5):
# 중단 객체가 있는 경우 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")첫 번째 중단 지점까지 그래프를 실행합니다...
📧 분류: RESPOND - 이 이메일에는 답변이 필요합니다.
INTERRUPT OBJECT:
액션 요청: {'action': 'schedule_meeting', 'args': {'attendees': ['[email protected]', '[email protected]'], 'subject': '세금 시즌 계획 전략 논의', 'duration_minutes': 45, 'preferred_day': '2025-11-04T00:00:00', 'start_time': 14}}
schedule_meeting 도구 호출에 대한 피드백 제공
이제 미팅 일정 잡기에 대한 피드백 기능을 살펴봅니다:
- 에이전트가 화요일 오후 2시에 표준 45분 미팅을 제안합니다
- 승인이나 수정 대신, 자연어로 피드백을 제공합니다
- 우리의 피드백은 두 가지 선호사항을 명시합니다:
- 더 짧은 미팅 (45분 대신 30분)
- 오후 미팅 선호 (오후 2시 이후)
- 에이전트가
response타입을 통해 이 피드백을 받습니다 - 중단 핸들러가 이 피드백을 상태에 메시지로 추가합니다
- 에이전트가 이 피드백을 처리하고 이러한 선호사항을 반영한 새로운 도구 호출을 생성합니다
전체 매개변수 세트를 지정해야 하는 직접 수정과 달리, 피드백은 사용자가 대화식으로 선호사항을 표현할 수 있게 합니다. 그런 다음 에이전트가 이 피드백을 해석하고 적절히 적용하여 수정된 제안을 만들어야 합니다.
print(
f"\n사용자가 {Interrupt_Object.value[0]['action_request']['action']} 도구 호출에 대한 피드백을 제공하는 것을 시뮬레이션합니다..."
)
for chunk in graph.stream(
Command(
resume=[
{
"type": "response",
"args": "45분 대신 30분으로 일정을 잡아주세요. 그리고 저는 오후 2시 이후의 오후 미팅을 선호합니다.",
}
]
),
config=thread_config_5,
):
# 중단 객체가 있는 경우 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")사용자가 schedule_meeting 도구 호출에 대한 피드백을 제공하는 것을 시뮬레이션합니다...
INTERRUPT OBJECT:
액션 요청: {'action': 'schedule_meeting', 'args': {'attendees': ['[email protected]', '[email protected]'], 'subject': '세금 시즌 계획 전략 논의', 'duration_minutes': 30, 'preferred_day': '2025-11-04T00:00:00', 'start_time': 16}}
피드백 제공 후 schedule_meeting 도구 호출 승인
print(f"\n사용자가 {Interrupt_Object.value[0]['action_request']} 도구 호출을 승인하는 것을 시뮬레이션합니다...")
for chunk in graph.stream(Command(resume=[{"type": "accept"}]), config=thread_config_5):
# 중단 객체가 있는 경우 검사
if '__interrupt__' in chunk:
Interrupt_Object = chunk['__interrupt__'][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")사용자가 {'action': 'schedule_meeting', 'args': {'attendees': ['[email protected]', '[email protected]'], 'subject': '세금 시즌 계획 전략 논의', 'duration_minutes': 30, 'preferred_day': '2025-11-04T00:00:00', 'start_time': 16}} 도구 호출을 승인하는 것을 시뮬레이션합니다...
INTERRUPT OBJECT:
액션 요청: {'action': 'write_email', 'args': {'to': '[email protected]', 'subject': 'Re: 세금 시즌 통화 일정 잡기', 'content': '안녕하세요,\n\n다음 주 화요일(11월 4일) 오후 4시에 30분간 세금 시즌 계획 전략 논의를 위한 통화를 예약했습니다. 이 시간에 뵙기를 기대합니다.\n\n감사합니다.\nLance Martin'}}
이제 write_email 도구 호출에 대한 피드백을 제공합니다
수정된 회의 일정을 수락한 후, 에이전트가 이메일 초안을 작성합니다. 이제 이메일 내용에 대한 피드백을 테스트합니다:
- 에이전트의 이메일은 비교적 격식 있고 상세합니다
- 우리는 다음을 요청하는 스타일 피드백을 제공합니다:
- 더 짧고 간결한 이메일
- 덜 격식 있는 어조
- 회의를 기대한다는 구체적인 맺음말
- 에이전트는 이 피드백을 처리하여 이메일을 완전히 다시 작성합니다
- 새 초안은 훨씬 더 짧고, 더 캐주얼하며, 요청된 맺음말을 포함합니다
이는 콘텐츠 생성을 위한 자연어 피드백의 강력함을 보여줍니다:
- 사용자가 전체 이메일을 직접 다시 작성할 필요가 없습니다
- 스타일, 어조, 내용에 대한 상위 수준의 지침을 제공할 수 있습니다
- 에이전트가 이 지침을 바탕으로 실제 작성을 처리합니다
- 결과물은 필수 정보를 보존하면서 사용자 선호도에 더 잘 맞습니다
메시지 히스토리는 원본 이메일과 수정된 이메일을 모두 보여주며, 피드백이 어떻게 반영되었는지 명확하게 보여줍니다.
print(
f"\n{Interrupt_Object.value[0]['action_request']['action']} 도구 호출에 대한 사용자 피드백 제공 시뮬레이션 중..."
)
for chunk in graph.stream(
Command(
resume=[
{
"type": "response",
"args": "더 짧고 격식 없게. 회의를 기대한다는 맺음말을 포함하세요!",
}
]
),
config=thread_config_5,
):
# response_agent의 가장 최근 메시지 검사
if "response_agent" in chunk:
chunk["response_agent"]["messages"][-1].pretty_print()
# 인터럽트 객체가 있으면 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")write_email 도구 호출에 대한 사용자 피드백 제공 시뮬레이션 중...
INTERRUPT OBJECT:
액션 요청: {'action': 'write_email', 'args': {'to': '[email protected]', 'subject': 'Re: 세금 시즌 통화 일정 잡기', 'content': '안녕하세요,\n\n다음 주 화요일(11월 4일) 오후 4시에 30분간 통화를 예약했습니다. 회의에서 뵙기를 기대합니다!\n\n감사합니다,\nLance'}}
피드백 제공 후 write_email 도구 호출 수락하기
print(
f"\n사용자가 {Interrupt_Object.value[0]['action_request']} 도구 호출을 수락하는 중 시뮬레이션..."
)
for chunk in graph.stream(Command(resume=[{"type": "accept"}]), config=thread_config_5):
# 인터럽트 객체가 있으면 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")사용자가 {'action': 'write_email', 'args': {'to': '[email protected]', 'subject': 'Re: 세금 시즌 통화 일정 잡기', 'content': '안녕하세요,\n\n다음 주 화요일(11월 4일) 오후 4시에 30분간 통화를 예약했습니다. 회의에서 뵙기를 기대합니다!\n\n감사합니다,\nLance'}} 도구 호출을 수락하는 중 시뮬레이션...
전체 메시지 히스토리를 확인하고 트레이스를 살펴보세요:
https://smith.langchain.com/public/57006770-6bb3-4e40-b990-143c373ebe60/r
사용자 피드백이 도구 호출에 통합된 것을 확인할 수 있습니다.
state = graph.get_state(thread_config_5)
for m in state.values["messages"]:
m.pretty_print()================================[1m Human Message [0m=================================
이메일에 답장하세요:
**Subject**: 세금 시즌 통화 일정 잡기
**From**: Project Manager <[email protected]>
**To**: Lance Martin <[email protected]>
Lance,
세금 시즌이 또 돌아왔습니다. 올해 세금 계획 전략에 대해 논의하기 위한 통화 일정을 잡고 싶습니다. 비용 절감에 도움이 될 수 있는 몇 가지 제안이 있습니다.
다음 주에 시간이 되시나요? 화요일이나 목요일 오후가 저에게는 가장 좋으며, 약 45분 정도 소요될 것 같습니다.
감사합니다,
Project Manager
---
==================================[1m Ai Message [0m==================================
Tool Calls:
check_calendar_availability (call_kI7kfMPPCauazExpAXckHYyt)
Call ID: call_kI7kfMPPCauazExpAXckHYyt
Args:
day: 2025-11-04
=================================[1m Tool Message [0m=================================
2025-11-04의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
==================================[1m Ai Message [0m==================================
Tool Calls:
check_calendar_availability (call_cxkS3B6FZHJUYKCwPGeSqHIM)
Call ID: call_cxkS3B6FZHJUYKCwPGeSqHIM
Args:
day: 2025-11-06
=================================[1m Tool Message [0m=================================
2025-11-06의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
==================================[1m Ai Message [0m==================================
Tool Calls:
schedule_meeting (call_3jznIPDrkTh2DelY6974KiHt)
Call ID: call_3jznIPDrkTh2DelY6974KiHt
Args:
attendees: ['[email protected]', '[email protected]']
subject: 세금 시즌 계획 전략 논의
duration_minutes: 45
preferred_day: 2025-11-04T00:00:00
start_time: 14
=================================[1m Tool Message [0m=================================
사용자가 피드백을 제공했으며, 이를 회의 요청에 반영할 수 있습니다. 피드백: 45분 대신 30분으로 일정을 잡아주세요. 그리고 저는 오후 2시 이후의 오후 미팅을 선호합니다.
==================================[1m Ai Message [0m==================================
Tool Calls:
schedule_meeting (call_zlY8eff8c6kQq1wr70so9X3m)
Call ID: call_zlY8eff8c6kQq1wr70so9X3m
Args:
attendees: ['[email protected]', '[email protected]']
subject: 세금 시즌 계획 전략 논의
duration_minutes: 30
preferred_day: 2025-11-04T00:00:00
start_time: 16
=================================[1m Tool Message [0m=================================
2명의 참석자와 함께 Tuesday, November 04, 2025 16에30 동안 진행될 예정인 '세금 시즌 계획 전략 논의' 회의
==================================[1m Ai Message [0m==================================
Tool Calls:
write_email (call_2ZqRRAdpXBn0HW6eJ7Fx6rbL)
Call ID: call_2ZqRRAdpXBn0HW6eJ7Fx6rbL
Args:
to: [email protected]
subject: Re: 세금 시즌 통화 일정 잡기
content: 안녕하세요,
다음 주 화요일(11월 4일) 오후 4시에 30분간 세금 시즌 계획 전략 논의를 위한 통화를 예약했습니다. 이 시간에 뵙기를 기대합니다.
감사합니다.
Lance Martin
=================================[1m Tool Message [0m=================================
사용자가 피드백을 제공했으며, 이를 이메일에 반영할 수 있습니다. 피드백: 더 짧고 격식 없게. 회의를 기대한다는 맺음말을 포함하세요!
==================================[1m Ai Message [0m==================================
Tool Calls:
write_email (call_sPiImsqwnTE7xj3iYXFvrbVb)
Call ID: call_sPiImsqwnTE7xj3iYXFvrbVb
Args:
to: [email protected]
subject: Re: 세금 시즌 통화 일정 잡기
content: 안녕하세요,
다음 주 화요일(11월 4일) 오후 4시에 30분간 통화를 예약했습니다. 회의에서 뵙기를 기대합니다!
감사합니다,
Lance
=================================[1m Tool Message [0m=================================
[email protected]에게 'Re: 세금 시즌 통화 일정 잡기'라는 제목과 안녕하세요,
다음 주 화요일(11월 4일) 오후 4시에 30분간 통화를 예약했습니다. 회의에서 뵙기를 기대합니다!
감사합니다,
Lance라는 내용으로 이메일을 발송했습니다.
==================================[1m Ai Message [0m==================================
Tool Calls:
Done (call_WCyEzJ6Mi28XknFeLMeeFHf6)
Call ID: call_WCyEzJ6Mi28XknFeLMeeFHf6
Args:
done: True
인터럽트를 통한 새로운 도구 활용
이제 Question 도구를 호출하여 피드백을 제공하는 이메일을 시도해봅시다
마지막으로, Question 도구와 함께 피드백이 어떻게 작동하는지 테스트합니다:
- 브런치 초대 이메일에서, 에이전트가 선호하는 요일과 시간을 묻습니다
- 무시하는 대신, 추가 컨텍스트와 함께 실질적인 응답을 제공합니다:
- 언급된 사람들을 초대하고 싶다는 확인
- 어느 주말이 가장 적합한지 확인이 필요하다는 언급
- 예약이 필요하다는 정보 추가
- 에이전트는 이 정보를 사용하여:
- 모든 피드백을 통합한 포괄적인 이메일 초안 작성
- 구체적인 날짜/시간을 제공하지 않았음을 인지하고 캘린더 확인을 제안
- 예약에 대한 세부사항 포함
- 완성된 이메일은 원래 요청과 우리의 추가 가이드 모두를 반영합니다
이는 질문 응답이 전체 워크플로우를 어떻게 형성하는지 보여줍니다:
- 질문을 통해 에이전트가 누락된 정보를 수집할 수 있습니다
- 사용자 응답에는 직접적인 답변과 추가 컨텍스트가 모두 포함될 수 있습니다
- 에이전트는 이 모든 정보를 다음 작업에 통합합니다
- 최종 결과물은 인간과 AI의 협업 지능을 반영합니다
# 응답하기
email_input_respond = {
"to": "Lance Martin <[email protected]>",
"author": "Partner <[email protected]>",
"subject": "저녁?",
"email_thread": "저녁에 이탈리안 먹을래 아니면 인도 음식 먹을래?",
}
# 그래프 컴파일
checkpointer = InMemorySaver()
graph = overall_workflow.compile(checkpointer=checkpointer)
thread_id_6 = uuid.uuid4()
thread_config_6 = {"configurable": {"thread_id": thread_id_6}}
# 첫 번째 인터럽트까지 그래프 실행
print("첫 번째 인터럽트까지 그래프 실행 중...")
for chunk in graph.stream({"email_input": email_input_respond}, config=thread_config_6):
# 인터럽트 객체가 있으면 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")첫 번째 인터럽트까지 그래프 실행 중...
📧 분류: RESPOND - 이 이메일에는 답변이 필요합니다.
INTERRUPT OBJECT:
액션 요청: {'action': 'write_email', 'args': {'to': '[email protected]', 'subject': 'Re: 저녁?', 'content': '안녕하세요,\n\n저녁 식사 제안 감사합니다. 이탈리안과 인도 음식 중 어느 쪽이 더 편한지 알려주시면 좋겠습니다. 저는 두 음식 모두 좋아합니다.\n\n감사합니다.\nLance'}}
Question 도구 호출에 대한 피드백 제공하기
print(
f"\n{Interrupt_Object.value[0]['action_request']['action']} 도구 호출에 대한 사용자 피드백 제공 시뮬레이션 중..."
)
for chunk in graph.stream(
Command(resume=[{"type": "response", "args": "인도 음식으로 하자."}]),
config=thread_config_6,
):
# 인터럽트 객체가 있으면 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")write_email 도구 호출에 대한 사용자 피드백 제공 시뮬레이션 중...
INTERRUPT OBJECT:
액션 요청: {'action': 'write_email', 'args': {'to': '[email protected]', 'subject': 'Re: 저녁?', 'content': '안녕하세요,\n\n인도 음식으로 하자는 의견 잘 알겠습니다. 저녁에 뵙겠습니다.\n\n감사합니다.\nLance'}}
write_email 도구 호출 수락하기
print(
f"\n사용자가 {Interrupt_Object.value[0]['action_request']['action']} 도구 호출을 수락하는 중 시뮬레이션..."
)
for chunk in graph.stream(Command(resume=[{"type": "accept"}]), config=thread_config_6):
# response_agent의 가장 최근 메시지 검사
if "response_agent" in chunk:
chunk["response_agent"]["messages"][-1].pretty_print()
# 인터럽트 객체가 있으면 검사
if "__interrupt__" in chunk:
Interrupt_Object = chunk["__interrupt__"][0]
print("\nINTERRUPT OBJECT:")
print(f"액션 요청: {Interrupt_Object.value[0]['action_request']}")사용자가 write_email 도구 호출을 수락하는 중 시뮬레이션...
==================================[1m Ai Message [0m==================================
Tool Calls:
Done (call_gRfys3gAHIJ63FhEVsvKS9CD)
Call ID: call_gRfys3gAHIJ63FhEVsvKS9CD
Args:
done: True
전체 메시지 히스토리를 확인하고 트레이스를 살펴보세요:
https://smith.langchain.com/public/f4c727c3-b1d9-47a5-b3d0-3451619db8a2/r
사용자 피드백이 이메일 응답에 통합된 것을 확인할 수 있습니다.
state = graph.get_state(thread_config_6)
for m in state.values["messages"]:
m.pretty_print()================================[1m Human Message [0m=================================
이메일에 답장하세요:
**Subject**: 저녁?
**From**: Partner <[email protected]>
**To**: Lance Martin <[email protected]>
저녁에 이탈리안 먹을래 아니면 인도 음식 먹을래?
---
==================================[1m Ai Message [0m==================================
Tool Calls:
write_email (call_ngWaqahYeaneb75ucPzzBeE7)
Call ID: call_ngWaqahYeaneb75ucPzzBeE7
Args:
to: [email protected]
subject: Re: 저녁?
content: 안녕하세요,
저녁 식사 제안 감사합니다. 이탈리안과 인도 음식 중 어느 쪽이 더 편한지 알려주시면 좋겠습니다. 저는 두 음식 모두 좋아합니다.
감사합니다.
Lance
=================================[1m Tool Message [0m=================================
사용자가 피드백을 제공했으며, 이를 이메일에 반영할 수 있습니다. 피드백: 인도 음식으로 하자.
==================================[1m Ai Message [0m==================================
Tool Calls:
write_email (call_lLWjXBYm0ae8zOneqWpIdQNz)
Call ID: call_lLWjXBYm0ae8zOneqWpIdQNz
Args:
to: [email protected]
subject: Re: 저녁?
content: 안녕하세요,
인도 음식으로 하자는 의견 잘 알겠습니다. 저녁에 뵙겠습니다.
감사합니다.
Lance
=================================[1m Tool Message [0m=================================
[email protected]에게 'Re: 저녁?'라는 제목과 안녕하세요,
인도 음식으로 하자는 의견 잘 알겠습니다. 저녁에 뵙겠습니다.
감사합니다.
Lance라는 내용으로 이메일을 발송했습니다.
==================================[1m Ai Message [0m==================================
Tool Calls:
Done (call_gRfys3gAHIJ63FhEVsvKS9CD)
Call ID: call_gRfys3gAHIJ63FhEVsvKS9CD
Args:
done: True
배포
email_assistant/email_assistant_hitl.py에서 HITL 기능을 포함한 이메일 어시스턴트의 로컬 배포를 생성해봅시다.
이전과 마찬가지로, langgraph dev를 실행하고, Studio에서 email_assistant_hitl을 선택한 다음, 이메일을 제출합니다:
{
"author": "Alice Smith <[email protected]>",
"to": "John Doe <[email protected]>",
"subject": "API 문서에 대한 간단한 질문",
"email_thread": "안녕하세요 John,\n새로운 인증 서비스의 API 문서를 검토하다가 몇 가지 엔드포인트가 스펙에서 누락된 것 같아서 연락드립니다. 이것이 의도적인 것인지, 아니면 문서를 업데이트해야 하는지 확인해주실 수 있나요?\n구체적으로 다음 항목들을 찾고 있습니다:\n- /auth/refresh\n- /auth/validate\n감사합니다!\nAlice",
}{'author': 'Alice Smith <[email protected]>',
'to': 'John Doe <[email protected]>',
'subject': 'API 문서에 대한 간단한 질문',
'email_thread': '안녕하세요 John,\n새로운 인증 서비스의 API 문서를 검토하다가 몇 가지 엔드포인트가 스펙에서 누락된 것 같아서 연락드립니다. 이것이 의도적인 것인지, 아니면 문서를 업데이트해야 하는지 확인해주실 수 있나요?\n구체적으로 다음 항목들을 찾고 있습니다:\n- /auth/refresh\n- /auth/validate\n감사합니다!\nAlice'}
우리 서버는 상태를 저장하지 않습니다(stateless). 로컬 배포에서 스레드는 단순히 로컬 파일시스템(프로젝트 폴더의 .langgraph_api)에 저장됩니다.
호스팅된 배포에서는 스레드가 Postgres에 저장됩니다.
중단된(interrupted) 스레드는 상태가 ‘interrupted’인 스레드이며, Studio에서 인터럽트를 확인할 수 있습니다:

이러한 중단된 스레드를 확인하기 위해 커스텀 인터페이스인 Agent Inbox를 사용할 것입니다.
이 인터페이스는 LangGraph 에이전트가 수행한 특정 작업을 편집, 승인, 무시 또는 피드백을 제공하는 편리한 방법입니다.
dev.agentinbox.ai로 이동하면 그래프에 쉽게 연결할 수 있습니다:
- Graph name:
langgraph.json파일의 이름 (email_assistant_hitl) - Graph URL:
http://127.0.0.1:2024/
그러면 실행된 모든 중단된 스레드가 표시됩니다:

Agent Inbox는 위의 SDK로 보여진 것처럼 resume과 함께 Command를 사용하여 그래프를 재개합니다.