LangGraph 공식문서를 번역한 내용입니다. 필요한 경우 부연 설명을 추가하였고 이해하기 쉽게 예제를 일부 변경하였습니다. 문제가 되면 삭제하겠습니다.
https://langchain-ai.github.io/langgraph/how-tos/react-agent-structured-output/
에이전트의 출력을 구조화된 형식으로 반환해야 할 때가 있을 수 있다. 예를 들어, 에이전트의 출력이 다른 다운스트림 소프트웨어에서 사용되는 경우, 에이전트가 호출될 때마다 동일한 구조화된 형식으로 출력을 제공하도록 하면 일관성을 유지할 수 있다.
여기서는 툴 호출 에이전트의 출력을 구조화된 형식으로 강제하는 두 가지 방법을 알아보자. 기본 ReAct 에이전트(모델 노드와 툴 호출 노드)를 사용하며, 마지막에는 사용자에게 응답을 포맷팅하는 세 번째 노드가 추가된다. 두 가지 방법 모두 아래 다이어그램에 표시된 동일한 그래프 구조를 사용하지만, 내부 메커니즘은 다르다.
첫 번째 방법은 에이전트 노드에서 사용할 추가 도구로 원하는 출력을 바인딩하여 툴 호출 에이전트가 구조화된 출력을 강제하도록 하는 것이다. 기본 ReAct 에이전트와는 달리, 이 경우 에이전트 노드는 툴과 END 사이를 선택하는 것이 아니라 호출할 특정 툴을 선택한다. 예상 흐름은 에이전트 노드의 LLM이 먼저 액션 툴을 선택하고, 액션 툴 출력값을 받은 후 응답 툴을 호출한다. 이후 응답 툴은 에이전트 노드의 툴 호출 인수를 구조화하는 응답 노드로 라우팅된다.
장점과 단점
장점
이 방식의 장점은 LLM을 하나만 사용하므로 비용과 대기 시간이 절감된다는 점이다.
단점
그러나 단점은 단일 LLM이 항상 원하는 툴을 호출한다고 보장할 수 없다는 점이다. bind_tools
를 사용할 때 tool_choice
를 any
로 설정하면 LLM이 매번 최소한 하나의 툴을 선택하도록 강제할 수 있지만, 이것이 완벽한 해결책은 아니다. 또 다른 단점은 에이전트가 여러 툴을 호출할 수 있다는 점이다. 따라서 라우팅 함수에서 이를 명시적으로 확인해야 한다. OpenAI를 사용하는 경우, parallell_tool_calling=False
로 설정하여 한 번에 하나의 툴만 호출되도록 설정할 수 있다.
두 번째 방법은 사용자에게 응답하기 위해 두 번째 LLM(이 예에서는 model_with_structured_output
)을 사용하는 것이다.
이 경우 기본 ReAct 에이전트를 정상적으로 정의하지만, 에이전트 노드는 툴 노드와 대화를 종료하는 선택지 사이에서 선택하는 대신 툴 노드와 응답 노드 사이에서 선택하게 된다. 응답 노드는 구조화된 출력을 사용하는 두 번째 LLM을 포함하며, 호출되면 직접 사용자에게 반환된다. 이 방법은 사용자에게 응답하기 전에 하나의 추가 단계를 추가한 기본 ReAct 방식으로 생각할 수 있다.
장점과 단점
장점
이 방법의 주요 장점은 .with_structured_output
이 LLM에서 예상대로 작동하는 한 구조화된 출력을 보장한다는 점이다.
단점
단점은 사용자에게 응답하기 전에 추가적인 LLM 호출이 필요하다는 점으로, 이로 인해 비용과 대기 시간이 증가할 수 있다. 또한, 에이전트 노드 LLM에 원하는 출력 스키마에 대한 정보를 제공하지 않으면, 에이전트 LLM이 올바른 출력 스키마로 답변하기 위해 필요한 툴을 호출하지 못할 위험이 있다.
두 옵션 모두 동일한 그래프 구조를 따른다(위의 다이어그램 참조). 둘 다 기본 ReAct 아키텍처의 정확한 복제본이며, 대화 종료 전에 응답 노드가 추가된 형태이다.
준비
우선, 필요한 패키지를 설치하자.
pip install langgraph langchain_openai
모델, 도구, 그래프 상태 정의
이제 출력 구조를 정의하고, 그래프 상태를 설정하며, 사용할 툴과 모델을 정의해 보자.
구조화된 출력을 사용하려면 LangChain의 with_structured_output
메서드를 사용할 것이다. 이 메서드에 대한 자세한 내용은 여기에서 확인할 수 있다.
이 예제에서는 날씨를 찾는 단일 툴을 사용하고, 사용자에게 구조화된 날씨 응답을 반환할 것이다.
from typing import Literal
from dotenv import load_dotenv
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import MessagesState
from pydantic import BaseModel, Field
load_dotenv()
class WeatherResponse(BaseModel):
"""Respond to the user with this"""
temperature: float = Field(description="The temperature in fahrenheit")
wind_directon: str = Field(
description="The direction of the wind in abbreviated form"
)
wind_speed: float = Field(description="The speed of the wind in km/h")
class AgentState(MessagesState):
final_response: WeatherResponse
@tool
def get_weather(city: Literal["서울", "부산"]):
"""Use this to get weather information."""
if city == "서울":
return "서울은 구름이 많고 북동풍이 불고 있고 기온은 30도이다."
elif city == "부산":
return "부산은 남동풍이 불고 있고 32도이고 맑다."
else:
raise AssertionError("Unknown city")
tools = [get_weather]
model = ChatOpenAI(model="gpt-4o-mini")
model_with_tools = model.bind_tools(tools)
model_with_structured_output = model.with_structured_output(WeatherResponse)
옵션 1: 출력을 도구로 바인딩
단일 LLM 옵션을 사용하는 방법을 알아보자.
그래프 정의
그래프 정의는 위의 예와 매우 유사하며, 유일한 차이점은 응답 노드에서 더 이상 LLM을 호출하지 않는 대신, 이미 get_weather
툴을 포함한 LLM에 WeatherResponse
툴을 바인딩한다는 점이다.
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
tools = [get_weather, WeatherResponse]
model_with_response_tool = model.bind_tools(tools, tool_choice="any")
def call_model(state: AgentState):
response = model_with_response_tool.invoke(state["messages"])
return {"messages": [response]}
def respond(state: AgentState):
response = WeatherResponse(**state["messages"][-1].tool_calls[0]["args"])
return {"final_response": response}
def should_continue(state: AgentState):
messages = state["messages"]
last_message = messages[-1]
if (
len(last_message.tool_calls) == 1
and last_message.tool_calls[0]["name"] == "WeatherResponse"
):
return "respond"
else:
return "continue"
# Define a new graph
workflow = StateGraph(AgentState)
workflow.add_node("agent", call_model)
workflow.add_node("respond", respond)
workflow.add_node("tools", ToolNode(tools))
workflow.set_entry_point("agent")
workflow.add_conditional_edges(
"agent",
should_continue,
{
"continue": "tools",
"respond": "respond",
},
)
workflow.add_edge("tools", "agent")
workflow.add_edge("respond", END)
graph = workflow.compile()
사용법
이제 의도한 대로 동작하는지 확인해보자.
answer = graph.invoke(input={"messages": [("human", "서울 날씨 어때?")]})[
"final_response"
]
print(answer)
temperature=30.0 wind_directon='NE' wind_speed=8.0
의도한 대로 WeatherResponse
를 리턴했다.
옵션 2: 2 LLM
두 번째 LLM이 구조화된 출력을 강제하도록 사용하는 방법을 알아보자.
그래프 정의
from typing import Literal
from dotenv import load_dotenv
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import MessagesState
from pydantic import BaseModel, Field
load_dotenv()
class WeatherResponse(BaseModel):
"""Respond to the user with this"""
temperature: float = Field(description="The temperature in celsius")
wind_directon: str = Field(
description="The direction of the wind in abbreviated form"
)
wind_speed: float = Field(description="The speed of the wind in km/h")
class AgentState(MessagesState):
final_response: WeatherResponse
@tool
def get_weather(city: Literal["서울", "부산"]):
"""Use this to get weather information."""
if city == "서울":
return "서울은 구름이 많고 5 mph 북동풍이 불고 있고 기온은 30도이다."
elif city == "부산":
return "부산은 5 mph 남동풍이 불고 있고 32도이고 맑다."
else:
raise AssertionError("Unknown city")
tools = [get_weather]
model = ChatOpenAI(model="gpt-4o-mini")
model_with_tools = model.bind_tools(tools)
model_with_structured_output = model.with_structured_output(WeatherResponse)
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
from langchain_core.messages import HumanMessage
def call_model(state: AgentState):
response = model_with_tools.invoke(state["messages"])
return {"messages": [response]}
def respond(state: AgentState):
response = model_with_structured_output.invoke(
[HumanMessage(content=state["messages"][-2].content)]
)
return {"final_response": response}
def should_continue(state: AgentState):
messages = state["messages"]
last_message = messages[-1]
if not last_message.tool_calls:
return "respond"
else:
return "continue"
workflow = StateGraph(AgentState)
workflow.add_node("agent", call_model)
workflow.add_node("respond", respond)
workflow.add_node("tools", ToolNode(tools))
workflow.set_entry_point("agent")
workflow.add_conditional_edges(
"agent",
should_continue,
{
"continue": "tools",
"respond": "respond",
},
)
workflow.add_edge("tools", "agent")
workflow.add_edge("respond", END)
graph = workflow.compile()
사용법
기대한 대로 구조화된 출력으로 나타나는지 확인할 수 있다.
answer = graph.invoke(input={"messages": [("human", "서울 날씨 어때?")]})[
"final_response"
]
print(answer)
temperature=30.0 wind_directon='NE' wind_speed=8.0
에이전트는 예상대로 WeatherResponse
객체를 반환했다. 이제 이 에이전트를 더 복잡한 소프트웨어 스택에서 사용하더라도, 에이전트의 출력이 다음 단계에서 기대하는 형식과 일치하지 않을까 걱정할 필요가 없다.
LangGraph 참고 자료
- Controllability
- Persistence
- Memory
- Human-in-the-loop
- Streaming
- Tool calling
- Subgraphs
- State Management
- Other
- Prebuilt ReAct Agent