금속 가격을 가져오는 ReAct 에이전트 구축과 평가

금속 가격을 가져오는 ReAct 에이전트 구축과 평가

AI 에이전트는 금융, 전자상거래, 고객 지원 같은 분야에서 점점 더 가치 있게 쓰이고 있어요. 이 에이전트들은 API와 자율적으로 상호작용하고, 실시간 데이터를 검색하며, 사용자 목표에 부합하는 작업을 수행할 수 있어요. 이들 에이전트가 효과적이고 정확하며 다양한 입력에 잘 반응하는지 확인하려면 평가가 필수적이에요.

출처: 문서

본문

이 튜토리얼에서는 다음을 할 거예요.

  • 금속 가격을 가져오는 ReAct 에이전트를 구축해요.
  • 주요 성능 메트릭을 추적하는 평가 파이프라인을 설정해요.
  • 다양한 쿼리로 에이전트의 효과성을 실행·평가해요.

링크를 클릭해 Google Colab에서 노트북을 열어 보세요.

사전 요구사항

  • Python 3.8+
  • LangGraph, LangChain, LLM에 대한 기본 이해

Ragas 및 기타 의존성 설치

pip로 Ragas와 LangGraph를 설치해요.

%pip install langgraph==0.2.44
%pip install ragas
%pip install nltk

ReAct 에이전트 구축

외부 컴포넌트 초기화

시작하려면 외부 컴포넌트를 설정하는 두 가지 옵션이 있어요.

  • 실시간 API 키 사용 : metals.dev 에 계정을 만들어 API 키를 받으세요.
  • API 응답 시뮬레이션 : 미리 정의된 JSON 객체로 API 응답을 시뮬레이션할 수도 있어요. 이렇게 하면 실시간 API 키 없이도 더 빨리 시작할 수 있어요.

자신의 필요에 맞는 방법을 선택해 설정을 진행하세요.

API 응답을 시뮬레이션하는 미리 정의된 JSON 객체

계정을 만들지 않고 빨리 시작하고 싶다면, 아래 주어진 API 응답을 시뮬레이션하는 미리 정의된 JSON 객체를 사용해 설정 과정을 건너뛸 수 있어요.

metal_price = {
    "gold": 88.1553,
    "silver": 1.0523,
    "platinum": 32.169,
    "palladium": 35.8252,
    "lbma_gold_am": 88.3294,
    "lbma_gold_pm": 88.2313,
    "lbma_silver": 1.0545,
    "lbma_platinum_am": 31.99,
    "lbma_platinum_pm": 32.2793,
    "lbma_palladium_am": 36.0088,
    "lbma_palladium_pm": 36.2017,
    "mcx_gold": 93.2689,
    "mcx_gold_am": 94.281,
    "mcx_gold_pm": 94.1764,
    "mcx_silver": 1.125,
    "mcx_silver_am": 1.1501,
    "mcx_silver_pm": 1.1483,
    "ibja_gold": 93.2713,
    "copper": 0.0098,
    "aluminum": 0.0026,
    "lead": 0.0021,
    "nickel": 0.0159,
    "zinc": 0.0031,
    "lme_copper": 0.0096,
    "lme_aluminum": 0.0026,
    "lme_lead": 0.002,
    "lme_nickel": 0.0158,
    "lme_zinc": 0.0031,
}

get_metal_price 도구 정의

get_metal_price 도구는 에이전트가 특정 금속의 가격을 가져오는 데 사용돼요. LangChain의 @tool 데코레이터를 사용해 이 도구를 만들 거예요.

metals.dev API의 실시간 데이터를 사용하려면 함수를 수정해 API에 실시간 요청을 하도록 바꿀 수 있어요.

from langchain_core.tools import tool


# Define the tools for the agent to use
@tool
def get_metal_price(metal_name: str) -> float:
    """Fetches the current per gram price of the specified metal.

    Args:
        metal_name : The name of the metal (e.g., 'gold', 'silver', 'platinum').

    Returns:
        float: The current price of the metal in dollars per gram.

    Raises:
        KeyError: If the specified metal is not found in the data source.
    """
    try:
        metal_name = metal_name.lower().strip()
        if metal_name not in metal_price:
            raise KeyError(
                f"Metal '{metal_name}' not found. Available metals: {', '.join(metal_price['metals'].keys())}"
            )
        return metal_price[metal_name]
    except Exception as e:
        raise Exception(f"Error fetching metal price: {str(e)}")

LLM에 도구 바인딩

get_metal_price 도구가 정의됐으니 다음 단계는 이 도구를 ChatOpenAI 모델에 바인딩하는 거예요. 이렇게 하면 에이전트가 사용자 요청에 따라 실행 중 도구를 호출할 수 있게 되어, 외부 데이터와 상호작용하고 본래 능력을 넘어선 작업을 수행할 수 있어요.

from langchain_openai import ChatOpenAI

tools = [get_metal_price]
llm = ChatOpenAI(model="gpt-4o-mini")
llm_with_tools = llm.bind_tools(tools)

LangGraph에서 state는 그래프가 실행되면서 정보를 추적·업데이트하는 데 중요한 역할을 해요. 그래프의 다른 부분이 실행되면서 state는 변경사항을 반영해 진화하고, 노드 사이에 전달되는 정보를 담아요.

예를 들어 이런 대화형 시스템에서 state는 교환된 메시지를 추적하는 데 사용돼요. 새 메시지가 생성될 때마다 state에 추가되고, 업데이트된 state가 노드를 통해 전달되어 대화가 논리적으로 진행되도록 보장해요.

State 정의

이것을 LangGraph로 구현하려면 메시지 목록을 유지하는 state 클래스를 정의해요. 새 메시지가 생성될 때마다 이 목록에 추가되어 대화 기록이 계속 업데이트돼요.

from langgraph.graph import END
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
from typing import Annotated
from typing_extensions import TypedDict


class GraphState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

should_continue 함수 정의

should_continue 함수는 대화가 추가 도구 상호작용으로 진행할지 끝낼지 결정해요. 구체적으로 마지막 메시지에 도구 호출(예: 금속 가격 요청)이 있는지 확인해요.

  • 마지막 메시지에 도구 호출이 있으면 에이전트가 외부 도구를 호출했다는 뜻이므로 대화가 계속되고 "tools" 노드로 이동해요.
  • 도구 호출이 없으면 END state로 나타나는 대화가 종료돼요.
# Define the function that determines whether to continue or not
def should_continue(state: GraphState):
    messages = state["messages"]
    last_message = messages[-1]
    if last_message.tool_calls:
        return "tools"
    return END

모델 호출

call_model 함수는 현재 대화 state를 기반으로 LLM과 상호작용해 응답을 생성해요. 업데이트된 state를 입력으로 받아 처리하고 모델이 생성한 응답을 반환해요.

# Define the function that calls the model
def call_model(state: GraphState):
    messages = state["messages"]
    response = llm_with_tools.invoke(messages)
    return {"messages": [response]}

Assistant 노드 생성

assistant 노드는 현재 대화 state를 처리하고 LLM을 사용해 관련 응답을 생성하는 핵심 컴포넌트예요. state를 평가하고 적절한 행동 방침을 결정한 뒤 진행 중인 대화에 맞는 응답을 만들기 위해 LLM을 호출해요.

# Node
def assistant(state: GraphState):
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}

Tool 노드 생성

tool_node 는 금속 가격 가져오기나 LLM의 본래 능력을 넘어선 다른 작업을 수행하는 외부 도구와의 상호작용을 관리해요. 도구 자체는 코드 앞부분에서 정의되며, tool_node 는 현재 state와 대화의 필요에 따라 이 도구들을 호출해요.

from langgraph.prebuilt import ToolNode

# Node
tools = [get_metal_price]
tool_node = ToolNode(tools)

그래프 구축

그래프 구조는 상호 연결된 노드와 엣지로 구성된 에이전트 워크플로우의 백본이에요. 이 그래프를 구성하려면 다양한 노드를 정의·연결할 수 있는 StateGraph 빌더를 사용해요. 각 노드는 프로세스의 한 단계(예: assistant 노드, tool 노드)를 나타내고, 엣지는 이 단계 사이의 실행 흐름을 결정해요.

from langgraph.graph import START, StateGraph
from IPython.display import Image, display

# Define a new graph for the agent
builder = StateGraph(GraphState)

# Define the two nodes we will cycle between
builder.add_node("assistant", assistant)
builder.add_node("tools", tool_node)

# Set the entrypoint as `agent`
builder.add_edge(START, "assistant")

# Making a conditional edge
# should_continue will determine which node is called next.
builder.add_conditional_edges("assistant", should_continue, ["tools", END])

# Making a normal edge from `tools` to `agent`.
# The `agent` node will be called after the `tool`.
builder.add_edge("tools", "assistant")

# Compile and display the graph for a visual overview
react_graph = builder.compile()
display(Image(react_graph.get_graph(xray=True).draw_mermaid_png()))

설정을 테스트하기 위해 쿼리로 에이전트를 실행해 볼게요. 에이전트는 metals.dev API를 사용해 구리 가격을 가져올 거예요.

from langchain_core.messages import HumanMessage

messages = [HumanMessage(content="What is the price of copper?")]
result = react_graph.invoke({"messages": messages})

result["messages"]
[HumanMessage(content='What is the price of copper?', id='4122f5d4-e298-49e8-a0e0-c98adda78c6c'),
 AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_DkVQBK4UMgiXrpguUS2qC4mA', 'function': {'arguments': '{"metal_name":"copper"}', 'name': 'get_metal_price'}, 'type': 'function'}]}, response_metadata={...}, id='run-0f77b156-e43e-4c1e-bd3a-307333eefb68-0', tool_calls=[{'name': 'get_metal_price', 'args': {'metal_name': 'copper'}, 'id': 'call_DkVQBK4UMgiXrpguUS2qC4mA', 'type': 'tool_call'}], usage_metadata={...}),
 ToolMessage(content='0.0098', name='get_metal_price', id='422c089a-6b76-4e48-952f-8925c3700ae3', tool_call_id='call_DkVQBK4UMgiXrpguUS2qC4mA'),
 AIMessage(content='The price of copper is $0.0098 per gram.', response_metadata={...}, id='run-67cbf98b-4fa6-431e-9ce4-58697a76c36e-0', usage_metadata={...})]

메시지를 Ragas 평가 형식으로 변환

현재 구현에서 GraphState 는 인간 사용자, AI(LLM의 응답), 그리고 LLM이 사용하는 외부 도구 사이에 교환된 메시지를 목록으로 저장해요. 각 메시지는 LangChain 형식의 객체예요.

# Implementation of Graph State
class GraphState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

에이전트 실행 중 메시지가 교환될 때마다 GraphState 의 메시지 목록에 추가돼요. 하지만 Ragas는 상호작용을 평가하기 위해 특정 메시지 형식을 요구해요.

Ragas는 자체 형식으로 에이전트 상호작용을 평가해요. 그래서 LangGraph를 사용한다면 LangChain 메시지 객체를 Ragas 메시지 객체로 변환해야 해요. 이렇게 하면 Ragas의 내장 평가 도구로 AI 에이전트를 평가할 수 있어요.

목표: LangChain 메시지 목록(예: HumanMessage, AIMessage, ToolMessage)을 Ragas가 기대하는 형식으로 변환해서, 평가 프레임워크가 이를 이해하고 제대로 처리할 수 있게 하는 거예요.

LangChain 메시지 목록을 Ragas 평가에 적합한 형식으로 변환하려면, Ragas는 convert_to_ragas_messages 함수를 제공해요. 이를 사용해 LangChain 메시지를 Ragas가 기대하는 형식으로 변환할 수 있어요.

사용 방법은 다음과 같아요.

from ragas.integrations.langgraph import convert_to_ragas_messages

# Assuming 'result["messages"]' contains the list of LangChain messages
ragas_trace = convert_to_ragas_messages(result["messages"])

ragas_trace  # List of Ragas messages
[HumanMessage(content='What is the price of copper?', metadata=None, type='human'),
 AIMessage(content='', metadata=None, type='ai', tool_calls=[ToolCall(name='get_metal_price', args={'metal_name': 'copper'})]),
 ToolMessage(content='0.0098', metadata=None, type='tool'),
 AIMessage(content='The price of copper is $0.0098 per gram.', metadata=None, type='ai', tool_calls=None)]

에이전트 성능 평가

이 튜토리얼에서는 다음 메트릭으로 에이전트를 평가해 보겠습니다.

  • 도구 호출 정확도 : ToolCallAccuracy 는 주어진 작업을 완료하는 데 필요한 도구를 LLM이 식별·호출하는 성능을 평가하는 메트릭이에요.
  • 에이전트 목표 정확도 : Agent goal accuracy는 LLM이 사용자의 목표를 식별·달성하는 성능을 평가하는 메트릭이에요. 이진(binary) 메트릭으로, 1은 AI가 목표를 달성했음을, 0은 달성하지 못했음을 나타내요.

먼저 에이전트를 몇 가지 쿼리로 실행하고, 이 쿼리들에 대한 ground truth 라벨이 있는지 확인해 보겠습니다.

도구 호출 정확도

from ragas.metrics import ToolCallAccuracy
from ragas.dataset_schema import MultiTurnSample
from ragas.integrations.langgraph import convert_to_ragas_messages
import ragas.messages as r


ragas_trace = convert_to_ragas_messages(
    messages=result["messages"]
)  # List of Ragas messages converted using the Ragas function

sample = MultiTurnSample(
    user_input=ragas_trace,
    reference_tool_calls=[
        r.ToolCall(name="get_metal_price", args={"metal_name": "copper"})
    ],
)

tool_accuracy_scorer = ToolCallAccuracy()
await tool_accuracy_scorer.multi_turn_ascore(sample)
1.0

Tool Call Accuracy가 1인 이유: LLM이 필요한 도구(get_metal_price)를 올바른 파라미터(금속 이름 "copper")로 정확히 식별하고 사용했기 때문이에요.

에이전트 목표 정확도

messages = [HumanMessage(content="What is the price of 10 grams of silver?")]

result = react_graph.invoke({"messages": messages})

result["messages"]  # List of LangChain messages
[HumanMessage(content='What is the price of 10 grams of silver?', id='51a469de-5b7c-4d01-ab71-f8db64c8da49'),
 AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_rdplOo95CRwo3mZcPu4dmNxG', 'function': {'arguments': '{"metal_name":"silver"}', 'name': 'get_metal_price'}, 'type': 'function'}]}, response_metadata={...}, id='run-3bb60e27-1275-41f1-a46e-03f77984c9d8-0', tool_calls=[{'name': 'get_metal_price', 'args': {'metal_name': 'silver'}, 'id': 'call_rdplOo95CRwo3mZcPu4dmNxG', 'type': 'tool_call'}], usage_metadata={...}),
 ToolMessage(content='1.0523', name='get_metal_price', id='0b5f9260-df26-4164-b042-6df2e869adfb', tool_call_id='call_rdplOo95CRwo3mZcPu4dmNxG'),
 AIMessage(content='The current price of silver is approximately $1.0523 per gram. Therefore, the price of 10 grams of silver would be about $10.52.', response_metadata={...}, id='run-93e38f71-cc9d-41d6-812a-bfad9f9231b2-0', usage_metadata={...})]
from ragas.integrations.langgraph import convert_to_ragas_messages

ragas_trace = convert_to_ragas_messages(
    result["messages"]
)  # List of Ragas messages converted using the Ragas function
ragas_trace
[HumanMessage(content='What is the price of 10 grams of silver?', metadata=None, type='human'),
 AIMessage(content='', metadata=None, type='ai', tool_calls=[ToolCall(name='get_metal_price', args={'metal_name': 'silver'})]),
 ToolMessage(content='1.0523', metadata=None, type='tool'),
 AIMessage(content='The current price of silver is approximately $1.0523 per gram. Therefore, the price of 10 grams of silver would be about $10.52.', metadata=None, type='ai', tool_calls=None)]
from ragas.dataset_schema import MultiTurnSample
from ragas.metrics import AgentGoalAccuracyWithReference
from ragas.llms import LangchainLLMWrapper


sample = MultiTurnSample(
    user_input=ragas_trace,
    reference="Price of 10 grams of silver",
)

scorer = AgentGoalAccuracyWithReference()

evaluator_llm = LangchainLLMWrapper(ChatOpenAI(model="gpt-4o-mini"))
scorer.llm = evaluator_llm
await scorer.multi_turn_ascore(sample)

Agent Goal Accuracy가 1인 이유: LLM이 은 10그램 가격을 검색한다는 사용자 목표를 올바르게 달성했기 때문이에요.

다음 단계

🎉 축하해요! Ragas 평가 프레임워크를 사용해 에이전트를 평가하는 방법을 배웠어요.

더 알아보기 (Learn more)