LangGraph 공식문서를 번역한 내용입니다. 필요한 경우 부연 설명을 추가하였고 이해하기 쉽게 예제를 일부 변경하였습니다. 문제가 되면 삭제하겠습니다.
https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/review-tool-calls/
사람이 개입하는(Human-in-the-loop, HIL) 상호작용은 에이전트 시스템에서 매우 중요하다. 일반적인 패턴은 특정 도구 호출 후에 사람이 개입하는 단계를 추가하는 것이다. 이러한 도구 호출은 종종 함수 호출이나 정보 저장으로 이어진다. 예를 들면 다음과 같다.
- SQL을 실행하는 도구 호출, 이 후 도구에 의해 실행됨
- 요약을 생성하는 도구 호출, 이 후 그래프의 상태에 저장됨
도구 호출을 사용하는 것은 실제 도구를 호출하든 아니든 일반적 방법이다.
여기서 구현하고 싶은 상호작용은 다음과 같다.
- 도구 호출을 승인하고 계속 진행
- 도구 호출을 수동으로 수정하고 계속 진행
- 자연어 피드백을 주고, 이를 에이전트로 전달한 후 계속 진행하지 않음
LangGraph에서 이를 구현할 수 있는데, 여기서는 중단점을 사용한다. 중단점은 특정 단계 전에 그래프 실행을 중단하도록 하여, 해당 지점에서 그래프 상태를 수동으로 업데이트하고 위의 세 가지 옵션 중 하나를 선택할 수 있게 해준다.
준비
우선, 필요한 패키지를 설치하자.
pip install langgraph langchain_openai
기본적인 사용법
구현하기 위해 간단한 그래프를 설정해 보자. 먼저, 어떤 행동을 취할지 결정하는 LLM 호출이 있다. 그 다음에는 Human 노드로 이동한다. 이 노드는 실제로 아무 작업도 수행하지 않지만, 이 노드 전에 그래프를 중단하고 상태를 업데이트하는 것이 목적이다. 그 후 상태를 확인하고, LLM으로 다시 라우팅하거나 올바른 도구로 라우팅한다.
이제 실행해보자.
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from typing_extensions import TypedDict, Literal
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.tools import tool
from langchain_core.messages import AIMessage
from IPython.display import Image, display
load_dotenv()
@tool
def weather_search(city: str):
"""Search for the weather"""
print("----")
print(f"{city}을 검색하고 있어요!")
print("----")
return "Sunny!"
model = ChatOpenAI(model_name="gpt-4o-mini").bind_tools([weather_search])
class State(MessagesState):
"""Simple state."""
def call_llm(state):
return {"messages": [model.invoke(state["messages"])]}
def human_review_node(state):
pass
def run_tool(state):
new_messages = []
tools = {"weather_search": weather_search}
tool_calls = state["messages"][-1].tool_calls
for tool_call in tool_calls:
tool = tools[tool_call["name"]]
result = tool.invoke(tool_call["args"])
new_messages.append(
{
"role": "tool",
"name": tool_call["name"],
"content": result,
"tool_call_id": tool_call["id"],
}
)
return {"messages": new_messages}
def route_after_llm(state) -> Literal[END, "human_review_node"]:
if len(state["messages"][-1].tool_calls) == 0:
return END
else:
return "human_review_node"
def route_after_human(state) -> Literal["run_tool", "call_llm"]:
if isinstance(state["messages"][-1], AIMessage):
return "run_tool"
else:
return "call_llm"
builder = StateGraph(State)
builder.add_node(call_llm)
builder.add_node(run_tool)
builder.add_node(human_review_node)
builder.add_edge(START, "call_llm")
builder.add_conditional_edges("call_llm", route_after_llm)
builder.add_conditional_edges("human_review_node", route_after_human)
builder.add_edge("run_tool", "call_llm")
memory = MemorySaver()
graph = builder.compile(checkpointer=memory, interrupt_before=["human_review_node"])
display(
Image(
graph.get_graph().draw_mermaid_png(
output_file_path="how-to-review-tool-calls.png"
)
)
)
리뷰 없는 예시
도구 호출이 없어서 리뷰가 필요 없는 예시를 살펴보자.
# Input
initial_input = {"messages": [{"role": "user", "content": "안녕!"}]}
# Thread
thread = {"configurable": {"thread_id": "1"}}
for event in graph.stream(initial_input, thread, stream_mode="values"):
print(event)
{'messages': [HumanMessage(content='안녕!', additional_kwargs={}, response_metadata={}, id='9eb97603-d399-44dc-bc37-b32a8611d676')]}
{'messages': [HumanMessage(content='안녕!', additional_kwargs={}, response_metadata={}, id='9eb97603-d399-44dc-bc37-b32a8611d676'), AIMessage(content='안녕하세요! 어떻게 도와드릴까요?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 11, 'prompt_tokens': 45, 'total_tokens': 56, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_3de1288069', 'finish_reason': 'stop', 'logprobs': None}, id='run-ca7cd2ab-df36-436f-91d8-0e0471c986c3-0', usage_metadata={'input_tokens': 45, 'output_tokens': 11, 'total_tokens': 56, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}
상태를 확인하면, 작업이 완료된 것을 알 수 있다.
print("Pending Executions!")
print(graph.get_state(thread).next)
Pending Executions!
()
승인 도구를 가진 예시
이제 도구 호출을 승인하는 모습이 어떻게 보이는지 살펴보자.
initial_input = {"messages": [{"role": "user", "content": "서울 날씨 어때?"}]}
thread = {"configurable": {"thread_id": "2"}}
for event in graph.stream(initial_input, thread, stream_mode="values"):
print(event)
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='ed4d4a97-2aca-4c34-b0c2-ae8233038ec4')]}
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='ed4d4a97-2aca-4c34-b0c2-ae8233038ec4'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_awwt2ptdLFl293xpQKUZwXIf', 'function': {'arguments': '{"city":"서울"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 48, 'total_tokens': 62, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-58b6e6b0-5df4-4579-b84c-fcffb61673ff-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울'}, 'id': 'call_awwt2ptdLFl293xpQKUZwXIf', 'type': 'tool_call'}], usage_metadata={'input_tokens': 48, 'output_tokens': 14, 'total_tokens': 62, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}
지금 확인해보면, 그것이 사람의 검토를 기다리고 있다는 것을 알 수 있다.
print("Pending Executions!")
print(graph.get_state(thread).next)
Pending Executions!
('human_review_node',)
툴 호출을 승인하려면 수정 없이 스레드를 계속 진행하면 된다. 이를 위해 입력 없이 새 실행을 생성하면 된다.
for event in graph.stream(None, thread, stream_mode="values"):
print(event)
----
서울을 검색하고 있어요!
----
{
'messages': [
HumanMessage(content=
'서울 날씨 어때?',
...),
AIMessage(content=
'',
...),
tool_calls=
[
{
'name': 'weather_search',
'args': {
'city': '서울'
},
'id': 'call_awwt2ptdLFl293xpQKUZwXIf',
'type': 'tool_call'
}
],
...,
ToolMessage(content=
'Sunny!',
name=
'weather_search',
id=
'215074da-5613-4f9a-a65a-dbf98332498f',
tool_call_id=
'call_awwt2ptdLFl293xpQKUZwXIf'
)
]
}
{
'messages': [
HumanMessage(content=
'서울 날씨 어때?',
...,
AIMessage(content=
'',
additional_kwargs=
{
'tool_calls': [
{
'id': 'call_awwt2ptdLFl293xpQKUZwXIf',
'function': {
'arguments': '{"city":"서울"}',
'name': 'weather_search'
},
'type': 'function'
}
],
'refusal': None
},
...,
tool_calls=
[
{
'name': 'weather_search',
'args': {
'city': '서울'
},
'id': 'call_awwt2ptdLFl293xpQKUZwXIf',
'type': 'tool_call'
}
],
...,
ToolMessage(content=
'Sunny!',
name=
'weather_search',
id=
'215074da-5613-4f9a-a65a-dbf98332498f',
tool_call_id=
'call_awwt2ptdLFl293xpQKUZwXIf'
),
AIMessage(content=
'서울의 날씨는 맑습니다!',
additional_kwargs=
{
'refusal': None
},
...,
]
}
도구 호출 수정
이제 툴 호출을 수정해보자. 예를 들어, 일부 매개변수를 변경하거나(심지어 호출된 툴을 변경할 수도 있다) 그런 다음 해당 툴을 실행하는 경우이다.
initial_input = {"messages": [{"role": "user", "content": "서울 날씨 어때?"}]}
thread = {"configurable": {"thread_id": "5"}}
for event in graph.stream(initial_input, thread, stream_mode="values"):
print(event)
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='31db76fd-47f9-43ef-9165-98c0ce864926')]}
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='31db76fd-47f9-43ef-9165-98c0ce864926'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_F6IiIWx1hKTANI5wli7GH2eW', 'function': {'arguments': '{"city":"서울"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 48, 'total_tokens': 62, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-e76eccc9-80a7-4dbb-9fd0-90a70644bbc9-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울'}, 'id': 'call_F6IiIWx1hKTANI5wli7GH2eW', 'type': 'tool_call'}], usage_metadata={'input_tokens': 48, 'output_tokens': 14, 'total_tokens': 62, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}
print("Pending Executions!")
print(graph.get_state(thread).next)
Pending Executions!
('human_review_node',)
이를 위해 먼저 상태를 업데이트해야 한다. 업데이트하려는 메시지의 ID와 동일한 ID를 가진 메시지를 전달하면 해당 메시지가 덮어쓰기 된다. 이는 동일한 ID를 가진 메시지를 교체하는 리듀서를 사용하기 때문에 가능한데, 이에 대한 자세한 내용은 여기에서 확인할 수 있다.
# 변경하려는 메시지의 ID를 가져오려면 현재 상태를 가져와야 한다.
state = graph.get_state(thread)
print("Current State:")
print(state.values)
print("\nCurrent Tool Call ID:")
current_content = state.values["messages"][-1].content
current_id = state.values["messages"][-1].id
tool_call_id = state.values["messages"][-1].tool_calls[0]["id"]
print(tool_call_id)
# 이제 대체 도구 호출을 구성해야 한다.
# 인수를 `서울, 대한민국`으로 변경할 것이다.
# 어떤 수의 인수나 도구 이름을 변경할 수 있다는 점에 유의하자.
new_message = {
"role": "assistant",
"content": current_content,
"tool_calls": [
{
"id": tool_call_id,
"name": "weather_search",
"args": {"city": "서울, 대한민국"},
}
],
# 이건 중요하다 - 이것은 대체하는 메시지와 동일해야 한다!
# 그렇지 않으면 별도의 메시지로 표시된다.
"id": current_id,
}
graph.update_state(
thread,
{"messages": [new_message]},
as_node="human_review_node",
)
for event in graph.stream(None, thread, stream_mode="values"):
print(event)
Current State:
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='31db76fd-47f9-43ef-9165-98c0ce864926'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_F6IiIWx1hKTANI5wli7GH2eW', 'function': {'arguments': '{"city":"서울"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 48, 'total_tokens': 62, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-e76eccc9-80a7-4dbb-9fd0-90a70644bbc9-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울'}, 'id': 'call_F6IiIWx1hKTANI5wli7GH2eW', 'type': 'tool_call'}], usage_metadata={'input_tokens': 48, 'output_tokens': 14, 'total_tokens': 62, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}
Current Tool Call ID:
call_F6IiIWx1hKTANI5wli7GH2eW
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='31db76fd-47f9-43ef-9165-98c0ce864926'), AIMessage(content='', additional_kwargs={}, response_metadata={}, id='run-e76eccc9-80a7-4dbb-9fd0-90a70644bbc9-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울, 대한민국'}, 'id': 'call_F6IiIWx1hKTANI5wli7GH2eW', 'type': 'tool_call'}])]}
----
서울, 대한민국을 검색하고 있어요!
----
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='31db76fd-47f9-43ef-9165-98c0ce864926'), AIMessage(content='', additional_kwargs={}, response_metadata={}, id='run-e76eccc9-80a7-4dbb-9fd0-90a70644bbc9-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울, 대한민국'}, 'id': 'call_F6IiIWx1hKTANI5wli7GH2eW', 'type': 'tool_call'}]), ToolMessage(content='Sunny!', name='weather_search', id='22e0952b-7249-4a85-acbb-9a8753b04e22', tool_call_id='call_F6IiIWx1hKTANI5wli7GH2eW')]}
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='31db76fd-47f9-43ef-9165-98c0ce864926'), AIMessage(content='', additional_kwargs={}, response_metadata={}, id='run-e76eccc9-80a7-4dbb-9fd0-90a70644bbc9-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울, 대한민국'}, 'id': 'call_F6IiIWx1hKTANI5wli7GH2eW', 'type': 'tool_call'}]), ToolMessage(content='Sunny!', name='weather_search', id='22e0952b-7249-4a85-acbb-9a8753b04e22', tool_call_id='call_F6IiIWx1hKTANI5wli7GH2eW'), AIMessage(content='서울의 날씨는 맑습니다!', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 74, 'total_tokens': 84, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'stop', 'logprobs': None}, id='run-4feb0ad1-39ce-4f25-819e-75350768970f-0', usage_metadata={'input_tokens': 74, 'output_tokens': 10, 'total_tokens': 84, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}
도구 호출에 피드백 하기
때로는 도구 호출을 실행하지 않거나 사용자가 도구 호출을 수동으로 수정하는 것을 원하지 않을 수 있다. 이 경우 사용자의 자연어 피드백을 받는 것이 더 나을 수 있다. 그런 다음 이 피드백을 도구 호출의 가짜 결과로 삽입할 수 있다.
이를 수행하는 방법에는 여러 가지가 있다.
- 상태에 새로운 메시지를 추가하여 "도구 호출 결과"를 나타낼 수 있다.
- 두 개의 새로운 메시지를 상태에 추가할 수 있다. 하나는 도구 호출에서 발생한 "오류"를 나타내고, 다른 하나는 피드백을 나타내는
HumanMessage
이다.
두 방법은 모두 상태에 메시지를 추가하는 방식에서 유사하지만, human_node
이후의 로직과 다양한 유형의 메시지를 처리하는 방식에서 차이가 있다.
이번 예제에서는 피드백을 나타내는 단일 도구 호출 메시지를 추가할 것이다. 이를 실습해보자.
initial_input = {"messages": [{"role": "user", "content": "서울 날씨 어때?"}]}
thread = {"configurable": {"thread_id": "6"}}
for event in graph.stream(initial_input, thread, stream_mode="values"):
print(event)
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='c486b0d8-b280-4e83-ab91-286a008e5e47')]}
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='c486b0d8-b280-4e83-ab91-286a008e5e47'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_ieMC3Fqu30mxhPfQ8MtlZ8aD', 'function': {'arguments': '{"city":"서울"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 48, 'total_tokens': 62, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-c1d0959f-e3aa-45f1-b514-7cdaeab015cb-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울'}, 'id': 'call_ieMC3Fqu30mxhPfQ8MtlZ8aD', 'type': 'tool_call'}], usage_metadata={'input_tokens': 48, 'output_tokens': 14, 'total_tokens': 62, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}
print("Pending Executions!")
print(graph.get_state(thread).next)
Pending Executions!
('human_review_node',)
이를 수행하려면 먼저 상태를 업데이트해야 한다. 이를 위해 응답하려는 도구 호출의 동일한 도구 호출 ID를 가진 메시지를 전달할 수 있다. 위의 ID와는 다른 ID임을 유의하자.
state = graph.get_state(thread)
print("Current State:")
print(state.values)
print("\nCurrent Tool Call ID:")
tool_call_id = state.values["messages"][-1].tool_calls[0]["id"]
print(tool_call_id)
new_message = {
"role": "tool",
# 자연어 피드백이다.
"content": "사용자 요청 변경: 국가도 함께 전달",
"name": "weather_search",
"tool_call_id": tool_call_id,
}
graph.update_state(
thread,
{"messages": [new_message]},
as_node="human_review_node",
)
for event in graph.stream(None, thread, stream_mode="values"):
print(event)
Current State:
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='90437134-46fb-414f-926c-cdedaa630fa0'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'function': {'arguments': '{"city":"서울"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 48, 'total_tokens': 62, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-d7107924-7fa4-47dd-83f9-914382969cae-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울'}, 'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'type': 'tool_call'}], usage_metadata={'input_tokens': 48, 'output_tokens': 14, 'total_tokens': 62, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}
Current Tool Call ID:
call_b0JTEG1k8XeziTOZhrltMF0N
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='90437134-46fb-414f-926c-cdedaa630fa0'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'function': {'arguments': '{"city":"서울"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 48, 'total_tokens': 62, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-d7107924-7fa4-47dd-83f9-914382969cae-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울'}, 'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'type': 'tool_call'}], usage_metadata={'input_tokens': 48, 'output_tokens': 14, 'total_tokens': 62, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), ToolMessage(content='사용자 요청 변경: 국가도 함께 전달', name='weather_search', id='91392f4c-e017-4b02-8162-4fe680675869', tool_call_id='call_b0JTEG1k8XeziTOZhrltMF0N')]}
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='90437134-46fb-414f-926c-cdedaa630fa0'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'function': {'arguments': '{"city":"서울"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 48, 'total_tokens': 62, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-d7107924-7fa4-47dd-83f9-914382969cae-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울'}, 'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'type': 'tool_call'}], usage_metadata={'input_tokens': 48, 'output_tokens': 14, 'total_tokens': 62, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), ToolMessage(content='사용자 요청 변경: 국가도 함께 전달', name='weather_search', id='91392f4c-e017-4b02-8162-4fe680675869', tool_call_id='call_b0JTEG1k8XeziTOZhrltMF0N'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_hABBP0o1UwHLYyPeysyFsQsa', 'function': {'arguments': '{"city":"서울, 대한민국"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 16, 'prompt_tokens': 79, 'total_tokens': 95, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-b113e051-8963-4f85-98fc-3ebbf7879a2d-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울, 대한민국'}, 'id': 'call_hABBP0o1UwHLYyPeysyFsQsa', 'type': 'tool_call'}], usage_metadata={'input_tokens': 79, 'output_tokens': 16, 'total_tokens': 95, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}
이제 다른 중단점에 도달한 것을 볼 수 있다. 왜냐하면 모델로 돌아가서 호출할 내용을 전혀 새로운 예측으로 받았기 때문이다. 이제 이것을 승인하고 계속 진행한다.
print("Pending Executions!")
print(graph.get_state(thread).next)
for event in graph.stream(None, thread, stream_mode="values"):
print(event)
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='90437134-46fb-414f-926c-cdedaa630fa0'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'function': {'arguments': '{"city":"서울"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 48, 'total_tokens': 62, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-d7107924-7fa4-47dd-83f9-914382969cae-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울'}, 'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'type': 'tool_call'}], usage_metadata={'input_tokens': 48, 'output_tokens': 14, 'total_tokens': 62, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), ToolMessage(content='사용자 요청 변경: 국가도 함께 전달', name='weather_search', id='91392f4c-e017-4b02-8162-4fe680675869', tool_call_id='call_b0JTEG1k8XeziTOZhrltMF0N'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_hABBP0o1UwHLYyPeysyFsQsa', 'function': {'arguments': '{"city":"서울, 대한민국"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 16, 'prompt_tokens': 79, 'total_tokens': 95, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-b113e051-8963-4f85-98fc-3ebbf7879a2d-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울, 대한민국'}, 'id': 'call_hABBP0o1UwHLYyPeysyFsQsa', 'type': 'tool_call'}], usage_metadata={'input_tokens': 79, 'output_tokens': 16, 'total_tokens': 95, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}
----
서울, 대한민국을 검색하고 있어요!
----
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='90437134-46fb-414f-926c-cdedaa630fa0'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'function': {'arguments': '{"city":"서울"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 48, 'total_tokens': 62, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-d7107924-7fa4-47dd-83f9-914382969cae-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울'}, 'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'type': 'tool_call'}], usage_metadata={'input_tokens': 48, 'output_tokens': 14, 'total_tokens': 62, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), ToolMessage(content='사용자 요청 변경: 국가도 함께 전달', name='weather_search', id='91392f4c-e017-4b02-8162-4fe680675869', tool_call_id='call_b0JTEG1k8XeziTOZhrltMF0N'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_hABBP0o1UwHLYyPeysyFsQsa', 'function': {'arguments': '{"city":"서울, 대한민국"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 16, 'prompt_tokens': 79, 'total_tokens': 95, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-b113e051-8963-4f85-98fc-3ebbf7879a2d-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울, 대한민국'}, 'id': 'call_hABBP0o1UwHLYyPeysyFsQsa', 'type': 'tool_call'}], usage_metadata={'input_tokens': 79, 'output_tokens': 16, 'total_tokens': 95, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), ToolMessage(content='Sunny!', name='weather_search', id='87fd3de0-aeb8-4577-93a6-d1959fd14b91', tool_call_id='call_hABBP0o1UwHLYyPeysyFsQsa')]}
{'messages': [HumanMessage(content='서울 날씨 어때?', additional_kwargs={}, response_metadata={}, id='90437134-46fb-414f-926c-cdedaa630fa0'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'function': {'arguments': '{"city":"서울"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 14, 'prompt_tokens': 48, 'total_tokens': 62, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-d7107924-7fa4-47dd-83f9-914382969cae-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울'}, 'id': 'call_b0JTEG1k8XeziTOZhrltMF0N', 'type': 'tool_call'}], usage_metadata={'input_tokens': 48, 'output_tokens': 14, 'total_tokens': 62, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), ToolMessage(content='사용자 요청 변경: 국가도 함께 전달', name='weather_search', id='91392f4c-e017-4b02-8162-4fe680675869', tool_call_id='call_b0JTEG1k8XeziTOZhrltMF0N'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_hABBP0o1UwHLYyPeysyFsQsa', 'function': {'arguments': '{"city":"서울, 대한민국"}', 'name': 'weather_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 16, 'prompt_tokens': 79, 'total_tokens': 95, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-b113e051-8963-4f85-98fc-3ebbf7879a2d-0', tool_calls=[{'name': 'weather_search', 'args': {'city': '서울, 대한민국'}, 'id': 'call_hABBP0o1UwHLYyPeysyFsQsa', 'type': 'tool_call'}], usage_metadata={'input_tokens': 79, 'output_tokens': 16, 'total_tokens': 95, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), ToolMessage(content='Sunny!', name='weather_search', id='87fd3de0-aeb8-4577-93a6-d1959fd14b91', tool_call_id='call_hABBP0o1UwHLYyPeysyFsQsa'), AIMessage(content='서울의 날씨는 맑습니다!', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 105, 'total_tokens': 115, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0705bf87c0', 'finish_reason': 'stop', 'logprobs': None}, id='run-04f817ca-fea6-40e0-aea4-2541c4a60f09-0', usage_metadata={'input_tokens': 105, 'output_tokens': 10, 'total_tokens': 115, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}