LLM 개발 입문 (7) - 3

루나·2026년 2월 16일

LLM STUDY

목록 보기
16/31
post-thumbnail

본 포스팅은 "Do it! LLM을 활용한 AI 에이전트 개발 입문"을 독학하며 쓴 글입니다.
내돈내산 포스팅임을 참고해주시면 감사하겠습니다.
2026년 2월 16일 기준으로 작성되었습니다.


Chapter 7

랭체인을 활용한 에이전트 개발

본 포스팅에서는 랭체인을 도구로 에이전트를 만들어보겠습니다!_!

1. @tool 데코레이터로 랭체인에 함수 연결하기

오픈 AI의 API로 펑션 콜링을 이용해 현재 시간을 알려주는 챗봇을 만들었다면
이번엔 랭체인으로 개발해보겠습니다!!

@tool 데코렝터를 사용하면 함수를 도구로 변경할 수 있다
이 데코레이터는 함수를 랭체인에서 외부 도구로 등록하여 언어 모델이 함수를 호출하고 사용할 수 있게 해준다
즉, @tool 데코레이터는 랭체인이 함수의 기능을 언어 모델이 활용할 수 있게 만드는 역할을 한다!!

  • 데코레이터(Decorator)란?
    파이썬에서 함수의 동작을 수정하거나 확장하는데 사용하는 도구
    원래의 함수를 변경하지 않고 추가 기능을 덧붙일 수 있게 해준다

일단 langchain_tool.ipynb를 생성하고 아래와 같이 코드를 작성해보자

# langchain_tool.ipynb
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

llm = ChatOpenAI(model = "gpt-4o-mini")

llm.invoke([HumanMessage("잘 지냈어?")])

이 정도는 처음에 랭체인을 배우고 챗봇을 만들 때 사용한 코드라 이제 익숙해진듯!!!

여기에서 이전에 사용한 get_current_time 함수를 가져와서 랭체인의 도구로 활용해보자

get_current_time 함수를 랭체인으로 언어 모델에 연결한다!
함수에 """ """ 으로 표시된 설명은 단순 주석이 아니라 랭체인에게 이 함수의 기능과 입력값, 사용 방법을 알려주는 문서화 문자열(docstring)이다

오픈AI의 API를 이용해 펑션 콜링을 할 때에는 딕셔너리 형태로 정보를 작성했지만 이제는 문서화 문자열로 설정한다
또한 기존의 timezone 매개변수 외에도 location 매개변수를 추가하여 어느 지역을 의미하는지를 문자열로 지정한다

예를 들어 '부산은 지금 몇시야?'와 같은 질문에 timezone은 Asia/Seoul로 지정한다
Asia/Busan이라는 timezone은 존재하지 않기 때문이다
location은 GPT가 대답할 때 '부산은 현재 00시 입니다' 라고 답변을 생성하기 편하도록 하는 용도이다

from langchain_core.tools import tool
from datetime import datetime
import pytz

@tool # @tool 데코레이터를 사용해서 함수를 도구로 등록
def get_current_time(timezone : str, location : str) -> str:
  """현재 시간을 반환하는 함수

  Args: 
    timezone(str) : 타임존 (예 : Asia/Seoul). 실제 존재해야함
    location(str) : 지역명. 타임존은 모든 지명에 대응되지 ㅇ낳으므로 이후 llm 답변 생성에 사용됨
  """
  tz = pytz.timezone(timezone)
  now = datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S")

  location_and_local_time = f'{now} {timezone}'
  print(location_and_local_time)
  return location_and_local_time

이어서 사용할 수 있는 도구를 리스트와 딕셔너리 형태로 만든다
이제 .bind_tool() 매서드를 이용해 기존에 선언한 언어 모델에 도구로 등록할 수 있다!!

# 도구를 tools 리스트에 추가하고, tool_dict에도 추가한다
tools = [get_current_time, ]
tool_dict = {"get_current_time" : get_current_time,}

# 도구를 모델에 바인딩: 모델에 도구를 바인딩하면, 도구를 사용하여 답변을 생성할 수 있음
llm_with_tools = llm.bind_tools(tools)

이제 모든 준비가 완료되었다!!
messages라는 리스트 변수를 만들고 SystemMessage와 HumanMessage를 담습니다
그리고 메세지가 담긴 리스트에 새로만든 llm_wioth_tools.invoke()를 사용해 답변을 생성한다!!.

from langchain_core.messages import SystemMessage

# 사용자의 질문과 도구를 사용해 언어 모델 답변 생성
messages = [
  SystemMessage('너는 사용자의 질문에 답변을 하기 위해 tools를 사용할 수 있다'),
  HumanMessage('부산은 지금 몇시야?')
]

# llm_with_tools를 사용해 사용자의 질문에 언어 모델 답변 생성
response = llm_with_tools.invoke(messages)
messages.append(response)

# 생성된 언어 모델 답변 출력
print(messages)
[SystemMessage(content='너는 사용자의 질문에 답변을 하기 위해 tools를 사용할 수 있다', additional_kwargs={}, response_metadata={}), 
HumanMessage(content='부산은 지금 몇시야?', additional_kwargs={}, response_metadata={}), 
AIMessage(content='', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 23, 'prompt_tokens': 130, 'total_tokens': 153, '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_provider': 'openai', 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_af17f56e76', 
'id': 'chatcmpl-D9ocLshiC09r9YuQXAO1d0SeUpgm6', 'service_tier': 'default', 'finish_reason': 'tool_calls', 'logprobs': None}, 
id='lc_run--019c65a6-476d-79a1-a2cc-8cbb4959b748-0', 
tool_calls=[{'name': 'get_current_time', 'args': {'timezone': 'Asia/Seoul', 'location': 'Busan'}, 
'id': 'call_VGxfwlc8RjGRluJrptcOCi5m', 'type': 'tool_call'}], invalid_tool_calls=[], 
usage_metadata={'input_tokens': 130, 'output_tokens': 23, 'total_tokens': 153, '
input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]

함수를 실행해야 한다고 판단했으므로 content에는 값이 없고 그 대신 tool_calls에는 도구 호출에 필요한 정보가 딕셔너리 형태로 포함되어 있다
GPT는 get_current_time 함수를 실행해야 한다고 판단했으며 이 함수에 필요한 arguments에는 timezone은 'Asia/Seoul'로
location은 'Busan'으로 입력해야 한다고 결정했다

위의 경우에서는 GPT가 함수 하나만 실행해도 된다고 판단했으므로 호출하는 함수는 response.tool_calls 리스트 안에 1개만 들어가있다
하지만 만약 사용자가 '부산과 뉴욕의 시간을 알려줘'라고 요청한다면 함수 2개를 실행해햐 한다고 판단할 수 있다
이 경우 tool_calls 리스트에 함수 호출 정보가 들어가며 각각 별도로 실행되도록 for문을 사용할 수 있다

for문을 이용해서 response.tool_calls에서 함수 호출 정보를 하나씩 가져와 반복한다
현재는 get_current_time 함수만 도구로 등록해놓았으므로 selected_tool 변수는 항상 이 함수로만 설정된다

for tool_call in response.tool_calls:
  selected_tool = tool_dict[tool_call["name"]]    # tool_dict를 사용해 도구 함수 선택
  print(tool_call["args"])  # 도구 호출 시 전달된 인자 출력
  tool_msg = selected_tool.invoke(tool_call)  # 도구 함수를 호출해 결과 반환
  messages.append(tool_msg)


messages

이 코드를 실행해보면 마지막에 ToolMessage 안에 우리가 원하는 답변이 들어있는 것을 볼 수 있다

{'timezone': 'Asia/Seoul', 'location': 'Busan'}
2026-02-16 17:58:24 Asia/Seoul

[SystemMessage(content='너는 사용자의 질문에 답변을 하기 위해 tools를 사용할 수 있다', additional_kwargs={}, response_metadata={}),
 HumanMessage(content='부산은 지금 몇시야?', additional_kwargs={}, response_metadata={}),
 AIMessage(content='', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 23, 'prompt_tokens': 130, 'total_tokens': 153, '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_provider': 'openai', 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_af17f56e76', 'id': 'chatcmpl-D9ocLshiC09r9YuQXAO1d0SeUpgm6', 'service_tier': 'default', 'finish_reason': 'tool_calls', 'logprobs': None}, id='lc_run--019c65a6-476d-79a1-a2cc-8cbb4959b748-0', tool_calls=[{'name': 'get_current_time', 'args': {'timezone': 'Asia/Seoul', 'location': 'Busan'}, 'id': 'call_VGxfwlc8RjGRluJrptcOCi5m', 'type': 'tool_call'}], invalid_tool_calls=[], usage_metadata={'input_tokens': 130, 'output_tokens': 23, 'total_tokens': 153, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}),
 ToolMessage(content='2026-02-16 17:58:24 Asia/Seoul', name='get_current_time', tool_call_id='call_VGxfwlc8RjGRluJrptcOCi5m')]

이제 이 대화 내용이 담긴 messages를 llm_with_tools.invoke에 넘기면 현재 시각 정보를 문장으로 답변해준다

llm_with_tools.invoke(messages)
AIMessage(content='부산은 지금 2026년 2월 16일 17시 58분입니다.', additional_kwargs={'refusal': None}, 
response_metadata={'token_usage': {'completion_tokens': 23, 'prompt_tokens': 178, 'total_tokens': 201, '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_provider': 'openai', 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_af17f56e76', 'id': 'chatcmpl-D9okMG0wIikNVBw62y43Lycq65wjV', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='lc_run--019c65ad-de20-71e2-a095-d32d3e2d0786-0', tool_calls=[], invalid_tool_calls=[], usage_metadata={'input_tokens': 178, 'output_tokens': 23, 'total_tokens': 201, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})

2. 파이단틱 이용하기

파이단틱(Pydantic)은 입력된 데이터의 유효성과 형식을 검증하고 특정 데이터 형식으로 명확하게 표현할 때 사용하는 라이브러리이다!!

예를들어 지난 챕터에서 만든 주가 정보를 가져오는 get_yf_stock_history 함수는 종목을 의미하는 티커(ticker)와 기간(period)을 매개변수로 받는다
이 매개변수 형식을 파이단틱의 베이스모델(BaseModel)과 필드(Field)를 이용해 더욱 명확하게 정의할 수 있다
(오 신기해)

이번에는 파이단틱을 이용해 앞에서 만든 주가 정보 get_yf_stock_history 함수의 입력값 형식을 명확하게 정의하고,
이를 랭체인의 도구로 변환하여 답변을 생성해보려고 한다!!
기존 langchain_tool.ipynb 파일에서 이어서 작업해보자

우선 StockHistoryInput 클래스를 만들고 클래스안에 사용할 데이터를 Field를 이용해 정의한다

from pydantic import BaseModel, Field

class StockHistoryInput(BaseModel):
  ticker : str = Field(..., title = "주식 코드", description = "주식 코드 (예 : AAPL)")
  period : str = Field(..., title = "기간", description = "주식 데이터 조회 기간 (예 : 1d, 1mo, 1y)")

그리고 get_yf_stock_history 함수에서 이 클래스 형식으로 매개변수를 받도록 수정한다
tools와 tool_dict는 새로 만든 get_yf_stock_history 함수를 추가해서 다시 선언한다!

import yfinance as yf

@tool
def get_yf_stock_history(stock_history_input : StockHistoryInput) -> str:
  """주식 종목의 가격 데이터를 조회하는 함수"""
    
  stock = yf.Ticker(stock_history_input.ticker)
  history = stock.history(period = stock_history_input.period)
  history_md = history.to_markdown()  #데이터프레임을 마크다운 형식으로 변환

  return history_md

tools = [get_current_time, get_yf_stock_history]
tool_dict = {
  "get_current_time" : get_current_time,
  "get_yf_stock_history" : get_yf_stock_history,
}

llm_with_tools = llm.bind_tools(tools)

그리고 messages에 HumanMessage로 테슬라의 주가 변화에 관한 질문을 추가하고 llm_with_tools.invoke()를 사용하여 답변을 요청한다
그리고 그 결과를 출력하고 messages에 추가한다

messages.append(HumanMessage("테슬라는 한 달 전에 비해 주가가 올랐나 내렸나?"))

response = llm_with_tools.invoke(messages)
print(response)
messages.append(response)

그런데 책이랑 다르게 결과에서 tool_calls에서 function의 arguments에 stock_history_input이 비어있다 ㅠㅜㅠㅜ
(이거 진짜 왜 이럼??)
'args': {'stock_history_input': {'ticker': 'TSLA', 'period': '1mo'}} 아래에 이런 부분이 있는데 실행이 안된다...

content='' additional_kwargs={'refusal': None} response_metadata={'token_usage': {'completion_tokens': 27, 'prompt_tokens': 269, 
'total_tokens': 296, '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_provider': 'openai', 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_373a14eb6f', 'id': 'chatcmpl-D9qywvFAI3Z60EmDoK2gKHybNxbB1', 'service_tier': 'default', 
'finish_reason': 'tool_calls', 'logprobs': None} id='lc_run--019c6630-f9a0-7bd3-a9fc-a4ff31c750f0-0' tool_calls=[{'name': 'get_yf_stock_history', 
'args': {'stock_history_input': {'ticker': 'TSLA', 'period': '1mo'}}, 'id': 'call_2b9zjoH2oShGuaN9MDllCgRC', 'type': 'tool_call'}] invalid_tool_calls=[] usage_metadata={'input_tokens': 269, 'output_tokens': 27, 'total_tokens': 296, '
input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}

일단 계속 진행해보자!
위에서 했던것처럼 여러 함수를 실행해야 한다고 판단할 수 있으니 for문을 이용해서 함수를 차례대로 이용할 수 있도록 해보자

for tool_call in response.tool_calls:
    selected_tool = tool_dict[tool_call["name"]]
    print(tool_call["args"])
    tool_msg = selected_tool.invoke(tool_call)
    messages.append(tool_msg)
    print(tool_msg)

이 셀을 실행하니까 의도한 대로 get_yf_stock_history 함수를 사용한 결과를 잘 가져왔다
(근데 왜 터미널 내에서는 \n 개행이 적용되지 않는 것이지...?)

{'stock_history_input': {'ticker': 'TSLA', 'period': '1mo'}}
content='| Date                      |   Open |   High |    Low |   Close |      Volume |   Dividends |   Stock Splits |\n
|:--------------------------|-------:|-------:|-------:|--------:|------------:|------------:|---------------:|\n
| 2026-01-14 00:00:00-05:00 | 442.81 | 443.91 | 434.22 |  439.2  | 5.72595e+07 |           0 |              0 |\n
| 2026-01-15 00:00:00-05:00 | 441.13 | 445.36 | 437.65 |  438.57 | 4.94658e+07 |           0 |              0 |\n
|2026-01-16 00:00:00-05:00 | 439.5  | 447.25 | 435.26 |  437.5  | 6.02206e+07 |           0 |              0 |\n
| 2026-01-20 00:00:00-05:00 | 429.36 | 430.73 | 417.44 |  419.25 | 6.31873e+07 |           0 |              0 |\n
| 2026-01-21 00:00:00-05:00 | 421.66 | 438.2  | 419.62 |  431.44 | 6.8124e+07  |           0 |              0 |\n
| 2026-01-22 00:00:00-05:00 | 435.16 | 449.5  | 432.63 |  449.36 | 7.15467e+07 |           0 |              0 |\n
| 2026-01-23 00:00:00-05:00 | 447.43 | 452.43 | 444.04 |  449.06 | 5.67714e+07 |           0 |              0 |\n
| 2026-01-26 00:00:00-05:00 | 445    | 445.04 | 434.28 |  435.2  | 4.93974e+07 |           0 |              0 |\n
| 2026-01-27 00:00:00-05:00 | 437.41 | 437.52 | 430.69 |  430.9  | 3.77331e+07 |           0 |              0 |\n
| 2026-01-28 00:00:00-05:00 | 431.91 | 438.26 | 430.1  |  431.46 | 5.48574e+07 |           0 |              0 |\n
| 2026-01-29 00:00:00-05:00 | 437.8  | 440.23 | 414.62 |  416.56 | 8.16861e+07 |           0 |              0 |\n
| 2026-01-30 00:00:00-05:00 | 425.35 | 439.88 | 422.7  |  430.41 | 8.26261e+07 |           0 |              0 |\n
| 2026-02-02 00:00:00-05:00 | 421.29 | 427.15 | 414.5  |  421.81 | 5.87395e+07 |           0 |              0 |\n
| 2026-02-03 00:00:00-05:00 | 424.27 | 428.56 | 413.69 |  421.96 | 5.68865e+07 |           0 |              0 |\n
| 2026-02-04 00:00:00-05:00 | 420.46 | 423.9  | 399.18 |  406.01 | 7.46069e+07 |           0 |              0 |\n
| 2026-02-05 00:00:00-05:00 | 397.02 | 402.1  | 387.53 |  397.21 | 7.28198e+07 |           0 |              0 |\n
| 2026-02-06 00:00:00-05:00 | 400.87 | 414.55 | 397.75 |  411.11 | 6.26771e+07 |           0 |              0 |\n
| 2026-02-09 00:00:00-05:00 | 409.91 | 421.25 | 407.29 |  417.32 | 5.44843e+07 |           0 |              0 |\n
| 2026-02-10 00:00:00-05:00 | 418.08 | 427.25 | 417    |  425.21 | 6.44502e+07 |           0 |              0 |\n
| 2026-02-11 00:00:00-05:00 | 427.96 | 436.35 | 420.03 |  428.27 | 5.7362e+07  |           0 |              0 |\n
| 2026-02-12 00:00:00-05:00 | 430.3  | 436.23 | 414    |  417.07 | 6.19334e+07 |           0 |              0 |\n
| 2026-02-13 00:00:00-05:00 | 414.31 | 424.06 | 410.88 |  417.44 | 5.13512e+07 |           0 |              0 |
' name='get_yf_stock_history' tool_call_id='call_2b9zjoH2oShGuaN9MDllCgRC'

이제 마지막으로 get_yf_stock_history 함수의 실행 결과가 포함된 messages를 llm_with_tools.invoke로 다시 처리하면 자연어 형식으로 최종 답변이 생성된다!!

llm_with_tools.invoke(messages)
AIMessage(content='한 달 전인 2026년 1월 14일에 테슬라(주식 코드: TSLA)의 종가는 439.20달러였고, 현재의 종가는 417.44달러입니다. \
n\n따라서, 테슬라의 주가는 한 달 전보다 내렸습니다.', additional_kwargs={'refusal': None}, 
response_metadata={'token_usage': {'completion_tokens': 69, 'prompt_tokens': 1629, 'total_tokens': 1698, 
'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': 1536}}, 'model_provider': 'openai', 'model_name': 'gpt-4o-mini-2024-07-18', 
'system_fingerprint': 'fp_373a14eb6f', 'id': 'chatcmpl-D9rGYSo8uWGA7QRdxVAbY4ANTvAHx', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, 
id='lc_run--019c6641-a089-7f80-ae87-b96776aa3c8c-0', tool_calls=[], invalid_tool_calls=[], usage_metadata={'input_tokens': 1629, 'output_tokens': 69, 'total_tokens': 1698, '
input_token_details': {'audio': 0, 'cache_read': 1536}, 'output_token_details': {'audio': 0, 'reasoning': 0}})

3. 마무리

이렇게 펑션 콜링을 랭체인에서 활용하는 방법을 알아보았다!!
개인적으로 문법을 공부할 때 @tool 같은 데코레이션이 들어오면 갑자기 너무 괜히 고오급 코드처럼 느껴지고 어려워보이는데..
더 공부를 해봐야겠지....?

다음 포스팅에서는 스트림 방식으로 출력해보는것을 해보려고한다
랭체인에서 하면 스트림 방식 출력도 훨씬 쉽지 않을까...나?

profile
Per ardua ad astra

0개의 댓글