사람이 개입(Human-in-the-Loop)하는 에이전트

우리는 이메일을 분류하는 라우터를 사용한 다음, 응답 생성을 위해 에이전트로 전달하는 이메일 어시스턴트를 만들었습니다. 평가도 마쳤고요.

그런데 받은편지함을 자율적으로 관리하도록 완전히 믿을 수 있을까요? 이렇게 민감한 작업에는 사람이 개입하는 방식(HITL)이 중요합니다!

여기서는 이메일 어시스턴트에 사람의 개입 단계를 추가하여 특정 도구 호출을 검토할 수 있도록 하는 방법을 보여드리겠습니다.

overview-img

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

overview-img

Resources

환경 변수

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)
  1. write_email(to, subject, content) - 지정된 수신자에게 이메일 전송
  2. schedule_meeting(attendees, subject, duration_minutes, preferred_day, start_time) - 일정 회의 예약 (preferred_day는 datetime 객체)
  3. check_calendar_availability(day) - 특정 날짜의 가능한 시간대 확인
  4. Question(content) - 사용자에게 후속 질문하기
  5. 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(알림)을 보내기로 결정되면 그래프를 중단합니다!

overview-img

이를 위해 새로운 노드 triage_interrupt_handler를 추가합니다. 이 노드는 다음과 같은 역할을 수행합니다:

  1. 분류 결과가 notify인 경우 사용자에게 표시: 분류 결과를 담은 dict를 인터럽트에 전달합니다.
  2. 사용자의 결정에 대한 응답 허용: 에이전트 인박스에서 반환되는 내용을 처리할 코드를 설계합니다.

여기에서 볼 수 있듯이, 에이전트 인박스에서 확인할 수 있도록 인터럽트를 특정 필드로 포맷합니다:

  • 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이 실행하려는 도구 호출을 검토하고, 실행 전에 인간 검토가 필요한 항목을 판단하는 것입니다.

작동 방식은 다음과 같습니다:

  1. 도구 선택: 핸들러는 인간 승인이 필요한 “HITL 도구” 목록을 유지합니다:

    • write_email: 이메일 발송은 상당한 외부 영향력을 가짐
    • schedule_meeting: 회의 일정은 캘린더에 영향을 미침
    • Question: 사용자에게 질문하는 것은 직접적인 상호작용이 필요함
  2. 직접 실행: HITL 목록에 포함되지 않은 도구(예: check_calendar_availability)는 중단 없이 즉시 실행됩니다. 이를 통해 저위험 작업은 자동으로 진행됩니다.

  3. 컨텍스트 준비: 검토가 필요한 도구의 경우 핸들러는:

    • 컨텍스트 확보를 위해 원본 이메일을 검색합니다
    • 명확한 표시를 위해 도구 호출 세부 정보를 포맷팅합니다
    • 각 도구 유형별로 허용되는 상호작용 유형을 구성합니다
  4. 중단 생성: 핸들러는 다음을 포함한 구조화된 중단 요청을 생성합니다:

    • 작업명과 인수
    • 허용된 상호작용 유형에 대한 구성
    • 원본 이메일과 제안된 작업을 모두 포함하는 설명
  5. 응답 처리: 인터럽트 후 핸들러는 사람의 응답을 처리합니다:

    • 수락: 원본 인자로 도구를 실행
    • 수정: 수정된 인자로 도구 호출을 업데이트한 후 실행
    • 무시: 도구 실행 취소
    • 응답: 실행 없이 피드백 기록

이 핸들러는 일상적인 작업은 자동으로 진행시키면서 중요한 모든 작업에 대해 사용자의 감독을 보장합니다.

도구 인수(이메일 내용이나 회의 세부사항 등)를 수정할 수 있는 기능은 사용자가 어시스턴트의 동작을 정밀하게 제어할 수 있게 합니다.

전체 흐름을 시각화하면 다음과 같습니다:

overview-img

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()
================================ Human Message =================================

이메일에 답장하세요: 

**Subject**: 세금 시즌 통화 일정 잡기
**From**: Project Manager <[email protected]>
**To**: Lance Martin <[email protected]>

Lance,

세금 시즌이 또 돌아왔습니다. 올해 세금 계획 전략에 대해 논의하기 위한 통화 일정을 잡고 싶습니다. 비용 절감에 도움이 될 수 있는 몇 가지 제안이 있습니다.

다음 주에 시간이 되시나요? 화요일이나 목요일 오후가 저에게는 가장 좋으며, 약 45분 정도 소요될 것 같습니다.

감사합니다,
Project Manager

---

================================== Ai Message ==================================
Tool Calls:
  check_calendar_availability (call_y8omb69gUghPEohVN0eXL4c0)
 Call ID: call_y8omb69gUghPEohVN0eXL4c0
  Args:
    day: 2025-11-04
================================= Tool Message =================================

2025-11-04의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
================================== Ai Message ==================================
Tool Calls:
  check_calendar_availability (call_QkvsNnRam29dA0Kij5xIptNZ)
 Call ID: call_QkvsNnRam29dA0Kij5xIptNZ
  Args:
    day: 2025-11-06
================================= Tool Message =================================

2025-11-06의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
================================== Ai Message ==================================
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
================================= Tool Message =================================

2명의 참석자와 함께 Tuesday, November 04, 2025 14에45 동안 진행될 예정인 '세금 시즌 계획 전략 논의' 회의
================================== Ai Message ==================================
Tool Calls:
  write_email (call_NZVkh5SaFyEGcez2ouxeIGne)
 Call ID: call_NZVkh5SaFyEGcez2ouxeIGne
  Args:
    to: [email protected]
    subject: Re: 세금 시즌 통화 일정 잡기
    content: 안녕하세요,

다음 주 화요일 오후 2시에 45분간 세금 시즌 계획 전략 논의를 위한 통화를 예약했습니다. 비용 절감 제안에 대해 자세히 듣고 논의할 수 있기를 기대합니다.

감사합니다.
Lance Martin
================================= Tool Message =================================

[email protected]에게 'Re: 세금 시즌 통화 일정 잡기'라는 제목과 안녕하세요,

다음 주 화요일 오후 2시에 45분간 세금 시즌 계획 전략 논의를 위한 통화를 예약했습니다. 비용 절감 제안에 대해 자세히 듣고 논의할 수 있기를 기대합니다.

감사합니다.
Lance Martin라는 내용으로 이메일을 발송했습니다.
================================== Ai Message ==================================
Tool Calls:
  Done (call_R4Q3RGNTPJFuE5k0MtVmUihR)
 Call ID: call_R4Q3RGNTPJFuE5k0MtVmUihR
  Args:
    done: True

중단을 통해 도구 호출을 수정할 수 있습니다

이 테스트는 HITL 흐름에서 사람의 수정이 어떻게 작동하는지 보여줍니다:

  1. 이전과 동일한 세금 계획 이메일로 시작합니다
  2. 에이전트가 동일한 매개변수로 미팅을 제안합니다
  3. 이번에는 사용자가 미팅 제안을 수정하여 다음을 변경합니다:
    • 시간을 45분에서 30분으로 변경
    • 미팅 제목을 더 간결하게 만듦
  4. 에이전트가 이메일 초안을 작성할 때 이러한 변경 사항에 적응합니다
  5. 사용자가 이메일을 더 짧고 덜 격식 있게 추가로 수정합니다
  6. 워크플로우가 두 수정 사항이 모두 반영되어 완료됩니다

이 시나리오는 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 응답 타입이 어떻게 작동하는지 보여줍니다:

  1. 사용자는 이전 테스트와 동일한 미팅 제안을 받습니다
  2. 승인하는 대신, 매개변수를 수정합니다:
    • 시간을 45분에서 30분으로 단축
    • 동일한 요일과 시간 유지
  3. edit 응답에는 수정된 인자의 전체 세트가 포함됩니다
  4. 중단 핸들러가 원래 도구 인자를 이러한 수정된 인자로 교체합니다
  5. 도구가 사용자의 수정 사항으로 실행됩니다

이것은 수정 기능이 사용자에게 에이전트 액션에 대한 정확한 제어권을 제공하면서도 에이전트가 실행 세부 사항을 처리하도록 하는 방법을 보여줍니다.

# 이제 사용자가 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분 시간을 반영한 이메일 초안을 작성합니다. 이제 이메일 콘텐츠 수정이 어떻게 작동하는지 보여줍니다:

  1. 에이전트가 더 짧은 30분 시간을 언급하도록 이메일을 조정했습니다
  2. 사용자가 이메일에 더 큰 변경을 원하는 것을 시뮬레이션합니다:
    • 콘텐츠를 완전히 재작성하여 더 짧고 덜 격식 있게 만듦
    • 이메일에 언급된 미팅 날짜 변경 (사용자가 에이전트의 실수를 수정할 수 있음을 보여줌)
    • 미팅을 확정된 것으로 명시하는 대신 확인 요청
  3. edit 응답에는 완전히 새로운 이메일 콘텐츠가 포함됩니다
  4. 도구 인자가 이 수정된 콘텐츠로 업데이트됩니다
  5. 이메일이 사용자가 선호하는 문구로 전송됩니다

이 예제는 복잡한 커뮤니케이션 작업을 위한 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 도구 호출을 수정하는 것을 시뮬레이션합니다...
================================== Ai Message ==================================
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()
================================ Human Message =================================

이메일에 답장하세요: 

**Subject**: 세금 시즌 통화 일정 잡기
**From**: Project Manager <[email protected]>
**To**: Lance Martin <[email protected]>

Lance,

세금 시즌이 또 돌아왔습니다. 올해 세금 계획 전략에 대해 논의하기 위한 통화 일정을 잡고 싶습니다. 비용 절감에 도움이 될 수 있는 몇 가지 제안이 있습니다.

다음 주에 시간이 되시나요? 화요일이나 목요일 오후가 저에게는 가장 좋으며, 약 45분 정도 소요될 것 같습니다.

감사합니다,
Project Manager

---

================================== Ai Message ==================================
Tool Calls:
  check_calendar_availability (call_iVmJnOdOQenp25SA5PKm2WFL)
 Call ID: call_iVmJnOdOQenp25SA5PKm2WFL
  Args:
    day: 2025-11-04
================================= Tool Message =================================

2025-11-04의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
================================== Ai Message ==================================
Tool Calls:
  check_calendar_availability (call_6CuTDQtoEfL6khBRoNdzoI1j)
 Call ID: call_6CuTDQtoEfL6khBRoNdzoI1j
  Args:
    day: 2025-11-06
================================= Tool Message =================================

2025-11-06의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
================================== Ai Message ==================================
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
================================= Tool Message =================================

2명의 참석자와 함께 Tuesday, May 06, 2025 14에30 동안 진행될 예정인 '세금 계획 논의' 회의
================================== Ai Message ==================================
Tool Calls:
  write_email (call_Fb0XuNmovV9NridNraGWmBW1)
 Call ID: call_Fb0XuNmovV9NridNraGWmBW1
  Args:
    to: [email protected]
    subject: Re: 세금 시즌 통화 일정 잡기
    content: 안녕하세요 Project Manager님,

세금 계획에 대해 연락 주셔서 감사합니다. 다음 주 목요일 오후 3시에 30분 통화 일정을 잡았습니다. 괜찮으실까요?

감사합니다,
Lance Martin
================================= Tool Message =================================

[email protected]에게 'Re: 세금 시즌 통화 일정 잡기'라는 제목과 안녕하세요 Project Manager님,

세금 계획에 대해 연락 주셔서 감사합니다. 다음 주 목요일 오후 3시에 30분 통화 일정을 잡았습니다. 괜찮으실까요?

감사합니다,
Lance Martin라는 내용으로 이메일을 발송했습니다.
================================== Ai Message ==================================
Tool Calls:
  Done (call_GWCo7to13ukoOC21LtVezXPt)
 Call ID: call_GWCo7to13ukoOC21LtVezXPt
  Args:
    done: True

중단을 통해 도구 호출에 대한 피드백을 제공할 수 있습니다

이 테스트 세트는 “response” 기능을 보여줍니다 - 수정이나 승인 없이 피드백 제공:

  1. 먼저, 미팅 일정 잡기에 대한 피드백을 테스트합니다:

    • 사용자가 구체적인 선호사항을 제공합니다 (45분 대신 30분, 그리고 오후 미팅)
    • 에이전트가 이 피드백을 수정된 제안에 반영합니다
    • 그런 다음 사용자가 수정된 미팅 일정을 승인합니다
  2. 두 번째로, 이메일 작성에 대한 피드백을 테스트합니다:

    • 사용자가 특정 마무리 문구가 있는 더 짧고 덜 격식 있는 이메일을 요청합니다
    • 에이전트가 이 지침에 따라 이메일을 완전히 재작성합니다
    • 사용자가 새 초안을 승인합니다
  3. 마지막으로, 질문에 대한 피드백을 테스트합니다:

    • 브런치 초대에 대해 사용자가 추가 맥락과 함께 질문에 답변합니다
    • 에이전트가 이 정보를 사용하여 적절한 이메일 응답 초안을 작성합니다
    • 워크플로우가 사용자의 입력이 통합되어 진행됩니다

“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 도구 호출에 대한 피드백 제공

이제 미팅 일정 잡기에 대한 피드백 기능을 살펴봅니다:

  1. 에이전트가 화요일 오후 2시에 표준 45분 미팅을 제안합니다
  2. 승인이나 수정 대신, 자연어로 피드백을 제공합니다
  3. 우리의 피드백은 두 가지 선호사항을 명시합니다:
    • 더 짧은 미팅 (45분 대신 30분)
    • 오후 미팅 선호 (오후 2시 이후)
  4. 에이전트가 response 타입을 통해 이 피드백을 받습니다
  5. 중단 핸들러가 이 피드백을 상태에 메시지로 추가합니다
  6. 에이전트가 이 피드백을 처리하고 이러한 선호사항을 반영한 새로운 도구 호출을 생성합니다

전체 매개변수 세트를 지정해야 하는 직접 수정과 달리, 피드백은 사용자가 대화식으로 선호사항을 표현할 수 있게 합니다. 그런 다음 에이전트가 이 피드백을 해석하고 적절히 적용하여 수정된 제안을 만들어야 합니다.

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 도구 호출에 대한 피드백을 제공합니다

수정된 회의 일정을 수락한 후, 에이전트가 이메일 초안을 작성합니다. 이제 이메일 내용에 대한 피드백을 테스트합니다:

  1. 에이전트의 이메일은 비교적 격식 있고 상세합니다
  2. 우리는 다음을 요청하는 스타일 피드백을 제공합니다:
    • 더 짧고 간결한 이메일
    • 덜 격식 있는 어조
    • 회의를 기대한다는 구체적인 맺음말
  3. 에이전트는 이 피드백을 처리하여 이메일을 완전히 다시 작성합니다
  4. 새 초안은 훨씬 더 짧고, 더 캐주얼하며, 요청된 맺음말을 포함합니다

이는 콘텐츠 생성을 위한 자연어 피드백의 강력함을 보여줍니다:

  • 사용자가 전체 이메일을 직접 다시 작성할 필요가 없습니다
  • 스타일, 어조, 내용에 대한 상위 수준의 지침을 제공할 수 있습니다
  • 에이전트가 이 지침을 바탕으로 실제 작성을 처리합니다
  • 결과물은 필수 정보를 보존하면서 사용자 선호도에 더 잘 맞습니다

메시지 히스토리는 원본 이메일과 수정된 이메일을 모두 보여주며, 피드백이 어떻게 반영되었는지 명확하게 보여줍니다.

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()
================================ Human Message =================================

이메일에 답장하세요: 

**Subject**: 세금 시즌 통화 일정 잡기
**From**: Project Manager <[email protected]>
**To**: Lance Martin <[email protected]>

Lance,

세금 시즌이 또 돌아왔습니다. 올해 세금 계획 전략에 대해 논의하기 위한 통화 일정을 잡고 싶습니다. 비용 절감에 도움이 될 수 있는 몇 가지 제안이 있습니다.

다음 주에 시간이 되시나요? 화요일이나 목요일 오후가 저에게는 가장 좋으며, 약 45분 정도 소요될 것 같습니다.

감사합니다,
Project Manager

---

================================== Ai Message ==================================
Tool Calls:
  check_calendar_availability (call_kI7kfMPPCauazExpAXckHYyt)
 Call ID: call_kI7kfMPPCauazExpAXckHYyt
  Args:
    day: 2025-11-04
================================= Tool Message =================================

2025-11-04의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
================================== Ai Message ==================================
Tool Calls:
  check_calendar_availability (call_cxkS3B6FZHJUYKCwPGeSqHIM)
 Call ID: call_cxkS3B6FZHJUYKCwPGeSqHIM
  Args:
    day: 2025-11-06
================================= Tool Message =================================

2025-11-06의 가능한 시간: 오전 9시, 오후 2시, 오후 4시
================================== Ai Message ==================================
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
================================= Tool Message =================================

사용자가 피드백을 제공했으며, 이를 회의 요청에 반영할 수 있습니다. 피드백: 45분 대신 30분으로 일정을 잡아주세요. 그리고 저는 오후 2시 이후의 오후 미팅을 선호합니다.
================================== Ai Message ==================================
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
================================= Tool Message =================================

2명의 참석자와 함께 Tuesday, November 04, 2025 16에30 동안 진행될 예정인 '세금 시즌 계획 전략 논의' 회의
================================== Ai Message ==================================
Tool Calls:
  write_email (call_2ZqRRAdpXBn0HW6eJ7Fx6rbL)
 Call ID: call_2ZqRRAdpXBn0HW6eJ7Fx6rbL
  Args:
    to: [email protected]
    subject: Re: 세금 시즌 통화 일정 잡기
    content: 안녕하세요,

다음 주 화요일(11월 4일) 오후 4시에 30분간 세금 시즌 계획 전략 논의를 위한 통화를 예약했습니다. 이 시간에 뵙기를 기대합니다.

감사합니다.
Lance Martin
================================= Tool Message =================================

사용자가 피드백을 제공했으며, 이를 이메일에 반영할 수 있습니다. 피드백: 더 짧고 격식 없게. 회의를 기대한다는 맺음말을 포함하세요!
================================== Ai Message ==================================
Tool Calls:
  write_email (call_sPiImsqwnTE7xj3iYXFvrbVb)
 Call ID: call_sPiImsqwnTE7xj3iYXFvrbVb
  Args:
    to: [email protected]
    subject: Re: 세금 시즌 통화 일정 잡기
    content: 안녕하세요,

다음 주 화요일(11월 4일) 오후 4시에 30분간 통화를 예약했습니다. 회의에서 뵙기를 기대합니다!

감사합니다,
Lance
================================= Tool Message =================================

[email protected]에게 'Re: 세금 시즌 통화 일정 잡기'라는 제목과 안녕하세요,

다음 주 화요일(11월 4일) 오후 4시에 30분간 통화를 예약했습니다. 회의에서 뵙기를 기대합니다!

감사합니다,
Lance라는 내용으로 이메일을 발송했습니다.
================================== Ai Message ==================================
Tool Calls:
  Done (call_WCyEzJ6Mi28XknFeLMeeFHf6)
 Call ID: call_WCyEzJ6Mi28XknFeLMeeFHf6
  Args:
    done: True

인터럽트를 통한 새로운 도구 활용

이제 Question 도구를 호출하여 피드백을 제공하는 이메일을 시도해봅시다

마지막으로, Question 도구와 함께 피드백이 어떻게 작동하는지 테스트합니다:

  1. 브런치 초대 이메일에서, 에이전트가 선호하는 요일과 시간을 묻습니다
  2. 무시하는 대신, 추가 컨텍스트와 함께 실질적인 응답을 제공합니다:
    • 언급된 사람들을 초대하고 싶다는 확인
    • 어느 주말이 가장 적합한지 확인이 필요하다는 언급
    • 예약이 필요하다는 정보 추가
  3. 에이전트는 이 정보를 사용하여:
    • 모든 피드백을 통합한 포괄적인 이메일 초안 작성
    • 구체적인 날짜/시간을 제공하지 않았음을 인지하고 캘린더 확인을 제안
    • 예약에 대한 세부사항 포함
  4. 완성된 이메일은 원래 요청과 우리의 추가 가이드 모두를 반영합니다

이는 질문 응답이 전체 워크플로우를 어떻게 형성하는지 보여줍니다:

  • 질문을 통해 에이전트가 누락된 정보를 수집할 수 있습니다
  • 사용자 응답에는 직접적인 답변과 추가 컨텍스트가 모두 포함될 수 있습니다
  • 에이전트는 이 모든 정보를 다음 작업에 통합합니다
  • 최종 결과물은 인간과 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 도구 호출을 수락하는 중 시뮬레이션...
================================== Ai Message ==================================
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()
================================ Human Message =================================

이메일에 답장하세요: 

**Subject**: 저녁?
**From**: Partner <[email protected]>
**To**: Lance Martin <[email protected]>

저녁에 이탈리안 먹을래 아니면 인도 음식 먹을래?

---

================================== Ai Message ==================================
Tool Calls:
  write_email (call_ngWaqahYeaneb75ucPzzBeE7)
 Call ID: call_ngWaqahYeaneb75ucPzzBeE7
  Args:
    to: [email protected]
    subject: Re: 저녁?
    content: 안녕하세요,

저녁 식사 제안 감사합니다. 이탈리안과 인도 음식 중 어느 쪽이 더 편한지 알려주시면 좋겠습니다. 저는 두 음식 모두 좋아합니다.

감사합니다.
Lance
================================= Tool Message =================================

사용자가 피드백을 제공했으며, 이를 이메일에 반영할 수 있습니다. 피드백: 인도 음식으로 하자.
================================== Ai Message ==================================
Tool Calls:
  write_email (call_lLWjXBYm0ae8zOneqWpIdQNz)
 Call ID: call_lLWjXBYm0ae8zOneqWpIdQNz
  Args:
    to: [email protected]
    subject: Re: 저녁?
    content: 안녕하세요,

인도 음식으로 하자는 의견 잘 알겠습니다. 저녁에 뵙겠습니다.

감사합니다.
Lance
================================= Tool Message =================================

[email protected]에게 'Re: 저녁?'라는 제목과 안녕하세요,

인도 음식으로 하자는 의견 잘 알겠습니다. 저녁에 뵙겠습니다.

감사합니다.
Lance라는 내용으로 이메일을 발송했습니다.
================================== Ai Message ==================================
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에서 인터럽트를 확인할 수 있습니다:

studio-img

이러한 중단된 스레드를 확인하기 위해 커스텀 인터페이스인 Agent Inbox를 사용할 것입니다.

이 인터페이스는 LangGraph 에이전트가 수행한 특정 작업을 편집, 승인, 무시 또는 피드백을 제공하는 편리한 방법입니다.

dev.agentinbox.ai로 이동하면 그래프에 쉽게 연결할 수 있습니다:

  • Graph name: langgraph.json 파일의 이름 (email_assistant_hitl)
  • Graph URL: http://127.0.0.1:2024/

그러면 실행된 모든 중단된 스레드가 표시됩니다:

agent-inbox-img

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