Skip to main content
이 튜토리얼에서는 LangSmith와 인기 있는 테스트 도구(Pytest, Vitest, Jest)의 통합을 사용하여 LLM 애플리케이션을 평가하는 방법을 보여드립니다. 상장 주식에 대한 질문에 답변하는 ReAct 에이전트를 만들고 이에 대한 포괄적인 테스트 스위트를 작성하겠습니다.

설정

이 튜토리얼은 에이전트 오케스트레이션을 위해 LangGraph를, OpenAI의 GPT-4o를, 검색을 위해 Tavily를, E2B의 코드 인터프리터를, 주식 데이터를 가져오기 위해 Polygon을 사용하지만 약간의 수정을 통해 다른 프레임워크, 모델, 도구에도 적용할 수 있습니다. Tavily, E2B, Polygon은 무료로 가입할 수 있습니다.

설치

먼저 에이전트를 만드는 데 필요한 패키지를 설치합니다:
pip install -U langgraph langchain[openai] langchain-community e2b-code-interpreter
다음으로 테스트 프레임워크를 설치합니다:
# langsmith>=0.3.1 버전이 설치되어 있는지 확인하세요
pip install -U "langsmith[pytest]"

환경 변수

다음 환경 변수를 설정합니다:
export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY=<YOUR_LANGSMITH_API_KEY>
export OPENAI_API_KEY=<YOUR_OPENAI_API_KEY>
export TAVILY_API_KEY=<YOUR_TAVILY_API_KEY>
export E2B_API_KEY=<YOUR_E2B_API_KEY>
export POLYGON_API_KEY=<YOUR_POLYGON_API_KEY>

애플리케이션 만들기

ReAct 에이전트를 정의하기 위해 오케스트레이션에는 LangGraph/LangGraph.js를, LLM과 도구에는 LangChain을 사용하겠습니다.

도구 정의

먼저 에이전트에서 사용할 도구를 정의하겠습니다. 3가지 도구가 사용됩니다:
  • Tavily를 사용하는 검색 도구
  • E2B를 사용하는 코드 인터프리터 도구
  • Polygon을 사용하는 주식 정보 도구
from langchain_community.tools import TavilySearchResults
from e2b_code_interpreter import Sandbox
from langchain_community.tools.polygon.aggregates import PolygonAggregates
from langchain_community.utilities.polygon import PolygonAPIWrapper
from typing_extensions import Annotated, TypedDict, Optional, Literal

# 검색 도구 정의
search_tool = TavilySearchResults(
  max_results=5,
  include_raw_content=True,
)

# 코드 도구 정의
def code_tool(code: str) -> str:
  """파이썬 코드를 실행하고 결과를 반환합니다."""
  sbx = Sandbox()
  execution = sbx.run_code(code)

  if execution.error:
      return f"Error: {execution.error}"
  return f"Results: {execution.results}, Logs: {execution.logs}"

# 주식 티커 도구의 입력 스키마 정의
class TickerToolInput(TypedDict):
  """티커 도구의 입력 형식입니다.
    이 도구는 from_date부터 to_date까지 집계 블록(timespan_multiplier * timespan)으로 데이터를 가져옵니다.
  """
  ticker: Annotated[str, ..., "주식의 티커 심볼"]
  timespan: Annotated[Literal["minute", "hour", "day", "week", "month", "quarter", "year"], ..., "시간 윈도우의 크기"]
  timespan_multiplier: Annotated[int, ..., "시간 윈도우의 배수"]
  from_date: Annotated[str, ..., "데이터를 가져올 시작 날짜, YYYY-MM-DD 형식 - 년, 월, 일만 포함"]
  to_date: Annotated[str, ..., "데이터를 가져올 종료 날짜, YYYY-MM-DD 형식 - 년, 월, 일만 포함"]

api_wrapper = PolygonAPIWrapper()
polygon_aggregate = PolygonAggregates(api_wrapper=api_wrapper)

# 주식 티커 도구 정의
def ticker_tool(query: TickerToolInput) -> str:
  """티커 데이터를 가져옵니다."""
  return polygon_aggregate.invoke(query)

에이전트 정의

이제 모든 도구를 정의했으므로 create_agent를 사용하여 에이전트를 만들 수 있습니다.
from typing_extensions import Annotated, TypedDict
from langchain.agents import create_agent


class AgentOutputFormat(TypedDict):
    numeric_answer: Annotated[float | None, ..., "사용자가 숫자 답변을 요청한 경우 숫자 답변"]
    text_answer: Annotated[str | None, ..., "사용자가 텍스트 답변을 요청한 경우 텍스트 답변"]
    reasoning: Annotated[str, ..., "답변의 근거"]

agent = create_agent(
    model="openai:gpt-4o-mini",
    tools=[code_tool, search_tool, polygon_aggregates],
    response_format=AgentOutputFormat,
    system_prompt="You are a financial expert. Respond to the users query accurately",
)

테스트 작성

이제 에이전트를 정의했으므로 기본 기능을 확인하기 위한 몇 가지 테스트를 작성해 보겠습니다. 이 튜토리얼에서는 에이전트의 도구 호출 기능이 작동하는지, 에이전트가 관련 없는 질문을 무시하는지, 모든 도구를 사용해야 하는 복잡한 질문에 답변할 수 있는지를 테스트하겠습니다. 먼저 테스트 파일을 설정하고 파일 상단에 필요한 import를 추가해야 합니다.
`tests/test_agent.py` 파일을 생성합니다.

from app import agent, polygon_aggregates, search_tool # 에이전트가 정의된 곳에서 import합니다
import pytest
from langsmith import testing as t

테스트 1: 주제에서 벗어난 질문 처리

첫 번째 테스트는 에이전트가 관련 없는 쿼리에 대해 도구를 사용하지 않는지 확인하는 간단한 검사입니다.
@pytest.mark.langsmith
@pytest.mark.parametrize(
  # <-- 모든 일반 pytest 마커를 계속 사용할 수 있습니다
  "query",
  ["Hello!", "How are you doing?"],
)
def test_no_tools_on_offtopic_query(query: str) -> None:
  """에이전트가 주제에서 벗어난 쿼리에 대해 도구를 사용하지 않는지 테스트합니다."""
  # 테스트 예제를 기록합니다
  t.log_inputs({"query": query})
  expected = []
  t.log_reference_outputs({"tool_calls": expected})
  # ReACT 루프를 실행하는 대신 에이전트의 모델 노드를 직접 호출합니다.
  result = agent.nodes["agent"].invoke(
      {"messages": [{"role": "user", "content": query}]}
  )
  actual = result["messages"][0].tool_calls
  t.log_outputs({"tool_calls": actual})
  # 도구 호출이 없었는지 확인합니다.
  assert actual == expected

테스트 2: 간단한 도구 호출

도구 호출의 경우 에이전트가 올바른 도구를 올바른 파라미터로 호출하는지 확인하겠습니다.
@pytest.mark.langsmith
def test_searches_for_correct_ticker() -> None:
  """모델이 간단한 쿼리에 대해 올바른 티커를 조회하는지 테스트합니다."""
  # 테스트 예제를 기록합니다
  query = "What is the price of Apple?"
  t.log_inputs({"query": query})
  expected = "AAPL"
  t.log_reference_outputs({"ticker": expected})
  # 전체 ReACT 루프를 실행하는 대신 에이전트의 모델 노드를 직접 호출합니다.
  result = agent.nodes["agent"].invoke(
      {"messages": [{"role": "user", "content": query}]}
  )
  tool_calls = result["messages"][0].tool_calls
  if tool_calls[0]["name"] == polygon_aggregates.name:
      actual = tool_calls[0]["args"]["ticker"]
  else:
      actual = None
  t.log_outputs({"ticker": actual})
  # 올바른 티커가 쿼리되었는지 확인합니다
  assert actual == expected

테스트 3: 복잡한 도구 호출

일부 도구 호출은 다른 것보다 테스트하기 쉽습니다. 티커 조회의 경우 올바른 티커가 검색되었는지 확인할 수 있습니다. 코딩 도구의 경우 도구의 입력과 출력이 훨씬 덜 제약적이며 올바른 답변에 도달하는 방법이 많습니다. 이 경우 전체 에이전트를 실행하여 코딩 도구를 호출하고 올바른 답변에 도달하는지 확인하여 도구가 올바르게 사용되는지 테스트하는 것이 더 간단합니다.
@pytest.mark.langsmith
def test_executes_code_when_needed() -> None:
  query = (
      "In the past year Facebook stock went up by 66.76%, "
      "Apple by 25.24%, Google by 37.11%, Amazon by 47.52%, "
      "Netflix by 78.31%. Whats the avg return in the past "
      "year of the FAANG stocks, expressed as a percentage?"
  )
  t.log_inputs({"query": query})
  expected = 50.988
  t.log_reference_outputs({"response": expected})
  # 필요할 때 에이전트가 코드를 실행하는지 테스트합니다
  result = agent.invoke({"messages": [{"role": "user", "content": query}]})
  t.log_outputs({"result": result["structured_response"].get("numeric_answer")})
  # LLM이 수행한 모든 도구 호출을 가져옵니다
  tool_calls = [
      tc["name"]
      for msg in result["messages"]
      for tc in getattr(msg, "tool_calls", [])
  ]
  # 에이전트가 수행한 단계 수를 기록합니다. 이는 에이전트가 답변에 도달하는
  # 효율성을 판단하는 데 유용합니다.
  t.log_feedback(key="num_steps", score=len(result["messages"]) - 1)
  # 코드 도구가 사용되었는지 확인합니다
  assert "code_tool" in tool_calls
  # 숫자 답변이 제공되었는지 확인합니다:
  assert result["structured_response"].get("numeric_answer") is not None
  # 답변이 정확한지 확인합니다
  assert abs(result["structured_response"]["numeric_answer"] - expected) <= 0.01

테스트 4: LLM-as-a-judge

LLM-as-a-judge 평가를 실행하여 에이전트의 답변이 검색 결과에 근거하고 있는지 확인하겠습니다. LLM as a judge 호출을 에이전트와 별도로 추적하기 위해 Python에서는 LangSmith에서 제공하는 trace_feedback 컨텍스트 매니저를, JS/TS에서는 wrapEvaluator 함수를 사용합니다.
from typing_extensions import Annotated, TypedDict
from langchain.chat_models import init_chat_model

class Grade(TypedDict):
  """소스 문서에서 답변의 근거를 평가합니다."""
  score: Annotated[
      bool,
      ...,
      "답변이 소스 문서에 완전히 근거한 경우 True를, 그렇지 않으면 False를 반환합니다.",
  ]

judge_llm = init_chat_model("gpt-4o").with_structured_output(Grade)

@pytest.mark.langsmith
def test_grounded_in_source_info() -> None:
  """응답이 도구 출력에 근거하고 있는지 테스트합니다."""
  query = "How did Nvidia stock do in 2024 according to analysts?"
  t.log_inputs({"query": query})
  result = agent.invoke({"messages": [{"role": "user", "content": query}]})
  # LLM이 수행한 모든 검색 호출을 가져옵니다
  search_results = "\n\n".join(
      msg.content
      for msg in result["messages"]
      if msg.type == "tool" and msg.name == search_tool.name
  )
  t.log_outputs(
      {
          "response": result["structured_response"].get("text_answer"),
          "search_results": search_results,
      }
  )
  # 피드백 LLM 실행을 에이전트 실행과 별도로 추적합니다.
  with t.trace_feedback():
      # LLM judge를 위한 지침
      instructions = (
          "Grade the following ANSWER. "
          "The ANSWER should be fully grounded in (i.e. supported by) the source DOCUMENTS. "
          "Return True if the ANSWER is fully grounded in the DOCUMENTS. "
          "Return False if the ANSWER is not grounded in the DOCUMENTS."
      )
      answer_and_docs = (
          f"ANSWER: {result['structured_response'].get('text_answer', '')}\n"
          f"DOCUMENTS:\n{search_results}"
      )
      # judge LLM을 실행합니다
      grade = judge_llm.invoke(
          [
              {"role": "system", "content": instructions},
              {"role": "user", "content": answer_and_docs},
          ]
      )
      t.log_feedback(key="groundedness", score=grade["score"])
  assert grade['score']

테스트 실행

구성 파일을 설정한 후(Vitest 또는 Jest를 사용하는 경우), 다음 명령을 사용하여 테스트를 실행할 수 있습니다:
`ls.vitest.config.ts` 파일을 생성합니다:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["**/*.eval.?(c|m)[jt]s"],
    reporters: ["langsmith/vitest/reporter"],
    setupFiles: ["dotenv/config"],
  },
});
pytest --langsmith-output tests

참고 코드

프로젝트에 VitestJest용 구성 파일도 추가하는 것을 잊지 마세요.

에이전트

from e2b_code_interpreter import Sandbox
from langchain_community.tools import PolygonAggregates, TavilySearchResults
from langchain_community.utilities.polygon import PolygonAPIWrapper
from langchain.agents import create_agent
from typing_extensions import Annotated, TypedDict


search_tool = TavilySearchResults(
    max_results=5,
    include_raw_content=True,
)

def code_tool(code: str) -> str:
    """Execute python code and return the result."""
    sbx = Sandbox()
    execution = sbx.run_code(code)

    if execution.error:
        return f"Error: {execution.error}"
    return f"Results: {execution.results}, Logs: {execution.logs}"

polygon_aggregates = PolygonAggregates(api_wrapper=PolygonAPIWrapper())

class AgentOutputFormat(TypedDict):
    numeric_answer: Annotated[
        float | None, ..., "The numeric answer, if the user asked for one"
    ]
    text_answer: Annotated[
        str | None, ..., "The text answer, if the user asked for one"
    ]
    reasoning: Annotated[str, ..., "The reasoning behind the answer"]

agent = create_agent(
    model="openai:gpt-4o-mini",
    tools=[code_tool, search_tool, polygon_aggregates],
    response_format=AgentOutputFormat,
    system_prompt="You are a financial expert. Respond to the users query accurately",
)

테스트

# from app import agent, polygon_aggregates, search_tool # 에이전트가 정의된 곳에서 import합니다
import pytest
from langchain.chat_models import init_chat_model
from langsmith import testing as t
from typing_extensions import Annotated, TypedDict

@pytest.mark.langsmith
@pytest.mark.parametrize(
  # <-- 모든 일반 pytest 마커를 계속 사용할 수 있습니다
  "query",
  ["Hello!", "How are you doing?"],
)
def test_no_tools_on_offtopic_query(query: str) -> None:
  """에이전트가 주제에서 벗어난 쿼리에 대해 도구를 사용하지 않는지 테스트합니다."""
  # 테스트 예제를 기록합니다
  t.log_inputs({"query": query})
  expected = []
  t.log_reference_outputs({"tool_calls": expected})
  # ReACT 루프를 실행하는 대신 에이전트의 모델 노드를 직접 호출합니다.
  result = agent.nodes["agent"].invoke(
      {"messages": [{"role": "user", "content": query}]}
  )
  actual = result["messages"][0].tool_calls
  t.log_outputs({"tool_calls": actual})
  # 도구 호출이 없었는지 확인합니다.
  assert actual == expected

@pytest.mark.langsmith
def test_searches_for_correct_ticker() -> None:
  """모델이 간단한 쿼리에 대해 올바른 티커를 조회하는지 테스트합니다."""
  # 테스트 예제를 기록합니다
  query = "What is the price of Apple?"
  t.log_inputs({"query": query})
  expected = "AAPL"
  t.log_reference_outputs({"ticker": expected})
  # 전체 ReACT 루프를 실행하는 대신 에이전트의 모델 노드를 직접 호출합니다.
  result = agent.nodes["agent"].invoke(
      {"messages": [{"role": "user", "content": query}]}
  )
  tool_calls = result["messages"][0].tool_calls
  if tool_calls[0]["name"] == polygon_aggregates.name:
      actual = tool_calls[0]["args"]["ticker"]
  else:
      actual = None
  t.log_outputs({"ticker": actual})
  # 올바른 티커가 쿼리되었는지 확인합니다
  assert actual == expected

@pytest.mark.langsmith
def test_executes_code_when_needed() -> None:
  query = (
      "In the past year Facebook stock went up by 66.76%, "
      "Apple by 25.24%, Google by 37.11%, Amazon by 47.52%, "
      "Netflix by 78.31%. Whats the avg return in the past "
      "year of the FAANG stocks, expressed as a percentage?"
  )
  t.log_inputs({"query": query})
  expected = 50.988
  t.log_reference_outputs({"response": expected})
  # 필요할 때 에이전트가 코드를 실행하는지 테스트합니다
  result = agent.invoke({"messages": [{"role": "user", "content": query}]})
  t.log_outputs({"result": result["structured_response"].get("numeric_answer")})
  # LLM이 수행한 모든 도구 호출을 가져옵니다
  tool_calls = [
      tc["name"]
      for msg in result["messages"]
      for tc in getattr(msg, "tool_calls", [])
  ]
  # 에이전트가 수행한 단계 수를 기록합니다. 이는 에이전트가 답변에 도달하는
  # 효율성을 판단하는 데 유용합니다.
  t.log_feedback(key="num_steps", score=len(result["messages"]) - 1)
  # 코드 도구가 사용되었는지 확인합니다
  assert "code_tool" in tool_calls
  # 숫자 답변이 제공되었는지 확인합니다:
  assert result["structured_response"].get("numeric_answer") is not None
  # 답변이 정확한지 확인합니다
  assert abs(result["structured_response"]["numeric_answer"] - expected) <= 0.01

class Grade(TypedDict):
  """소스 문서에서 답변의 근거를 평가합니다."""
  score: Annotated[
      bool,
      ...,
      "답변이 소스 문서에 완전히 근거한 경우 True를, 그렇지 않으면 False를 반환합니다.",
  ]

judge_llm = init_chat_model("gpt-4o").with_structured_output(Grade)

@pytest.mark.langsmith
def test_grounded_in_source_info() -> None:
  """응답이 도구 출력에 근거하고 있는지 테스트합니다."""
  query = "How did Nvidia stock do in 2024 according to analysts?"
  t.log_inputs({"query": query})
  result = agent.invoke({"messages": [{"role": "user", "content": query}]})
  # LLM이 수행한 모든 검색 호출을 가져옵니다
  search_results = "\n\n".join(
      msg.content
      for msg in result["messages"]
      if msg.type == "tool" and msg.name == search_tool.name
  )
  t.log_outputs(
      {
          "response": result["structured_response"].get("text_answer"),
          "search_results": search_results,
      }
  )
  # 피드백 LLM 실행을 에이전트 실행과 별도로 추적합니다.
  with t.trace_feedback():
      # LLM judge를 위한 지침
      instructions = (
          "Grade the following ANSWER. "
          "The ANSWER should be fully grounded in (i.e. supported by) the source DOCUMENTS. "
          "Return True if the ANSWER is fully grounded in the DOCUMENTS. "
          "Return False if the ANSWER is not grounded in the DOCUMENTS."
      )
      answer_and_docs = (
          f"ANSWER: {result['structured_response'].get('text_answer', '')}\n"
          f"DOCUMENTS:\n{search_results}"
      )
      # judge LLM을 실행합니다
      grade = judge_llm.invoke(
          [
              {"role": "system", "content": instructions},
              {"role": "user", "content": answer_and_docs},
          ]
      )
      t.log_feedback(key="groundedness", score=grade["score"])
  assert grade["score"]

Connect these docs programmatically to Claude, VSCode, and more via MCP for real-time answers.
I