목차
1. 정리 배경
2. AI Agent의 두가지 특성
3. 도구 만들기
4. llm에 도구 부여하기 from scratch
5. AgentExecuter 만들기
6. 결론
정리 배경은 간단하다. Agent 개발에 대해서 기초부터 정리하고 싶었기 때문이다.
해당 게시물은 테디 노트님의 강의를 내가 이해하기 편하도록 정리하는 것이 목적이다. 자세하고 수준 높은 설명을 원한다면 유료 결제하고 들으시면 된다.
작년 부터 에이전트 개념이 핫하고, 또 앞으로 유망할 것으로 보이는데, Agent에 대한 정의는 논문을 읽다보면 다소 상이한 부분이 있다. 나는 다수의 논문을 읽고 나서, AI Agent에 대해 두가지 특성을 중심으로 정의 한다.
따라서 해당 글에서는 LLM이 도구를 사용하는 방법을 기초 동작부터 정리한다. 다만 내가 이해하기 편한게 목적이기 때문에, 수준은 나한테 맞춘다.
import os
from langchain.agents import tool
from langchain_openai import ChatOpenAI
# 도구를 정의합니다.
@tool
def get_word_length(word: str) -> int:
"""Returns the length of a word."""
return len(word)
@tool
def add_function(a: float, b: float) -> float:
"""Adds two numbers together."""
return a + b
tools = [get_word_length, add_function]
도구 만드는 부분에서 가장 핵심은 tool불러오는 것과, decorator, docstring이다.
도구(tool)은 사실 남들이 만들어 놓은 것을 사용할 수 도 있지만, 각자의 개발과정과 개발 산출물은 다를 수 있고, 보통 다르다. 각자가 편한 구현이 있을테니. 또 서비스 구현 과정에서 도구는 그 회사나 개인의 강점 및 역량을 보여주는 포인트가 될 수 있다. 따라서 커스터마이징을 한다는 것을 전제로 한다.
# 모델 생성
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# 도구 바인딩
llm_with_tools = llm.bind_tools(tools)
tool_call_results = llm_with_tools.invoke("'바르셀로나' 라는 단어의 길이는 몇자입니까?").tool_calls
print(tool_call_results)
print(tool_call_results, end="\n\n==========\n\n")
# 첫 번째 도구 호출 결과
single_result = tool_call_results[0]
# 도구 이름
print(single_result["type"])
# 도구 인자
print(single_result["args"])
# tool은 리스트로 묶이니 이렇게 표현할 수 도 있다.
print(tools[0].name)
====아래는 결과 ===
[{'name': 'get_word_length', 'args': {'word': '바르셀로나'}, 'id': 'call_tGYFw97eK7DuNEg9pJggpoIK', 'type': 'tool_call'}]
[{'name': 'get_word_length', 'args': {'word': '바르셀로나'}, 'id': 'call_tGYFw97eK7DuNEg9pJggpoIK', 'type': 'tool_call'}]
==========
tool_call
{'word': '바르셀로나'}
get_word_length
여기서는 일단 llm을 호출하고, 메소드를 통해 기존에 만들어 놓은 도구들을 묶는 것이 첫번쨰 포인트이다.
두번째 포인트는 모델에 따라 도구 바인딩이 지원되지 않는 모델이 있을 수 있다. 따라서 어떤 모델이 지원되는지 확인해 볼 필요가 있다. Claude나 Ollama를 통해 사용하는 모델 중에도 지원되는 모델이 꽤 많이 있는 것으로 알고 있지만 내 입장에서 가장 쉽고 저렴하게 동작시킬 수 있는 모델은 'gpt-4o-mini'라고 생각했다.
세번째 포인트는 invoke로 프롬프트를 입력하여 호출하면 도구를 사용하지 않고 결과가 나오기 때문에 .tool_calls를 뒤에 붙여 사용하는 것이다. 다만 이렇게만 사용하면 프롬프트의 결과가 나오지 않고, 어떤 도구를 사용할지만 알려준다. 따라서 execute하는 함수를 만들어야 한다. 테디노트님 깃헙을 참조해서 현시점에 동작하도록 한 코드는 다음과 같다.
def execute_tool_calls(tool_call_results):
"""
도구 호출 결과를 실행하는 함수
:param tool_call_results: 도구 호출 결과 리스트
:param tools: 사용 가능한 도구 리스트
"""
# 도구 호출 결과 리스트를 순회합니다.
for tool_call_result in tool_call_results:
# 도구의 이름과 인자를 추출합니다.
tool_name = tool_call_result["name"] # 도구의 이름(함수명)
tool_args = tool_call_result["args"] # 도구에 전달되는 인자
# 도구 이름과 일치하는 도구를 찾아 실행합니다.
# next() 함수를 사용하여 일치하는 첫 번째 도구를 찾습니다.
matching_tool = next((tool for tool in tools if tool.name == tool_name), None)
if matching_tool:
# 일치하는 도구를 찾았다면 해당 도구를 실행합니다.
result = matching_tool.invoke(tool_args)
# 실행 결과를 출력합니다.
print(f"[실행도구] {tool_name} [Argument] {tool_args}\n[실행결과] {result}")
else:
# 일치하는 도구를 찾지 못했다면 경고 메시지를 출력합니다.
print(f"경고: {tool_name}에 해당하는 도구를 찾을 수 없습니다.")
# 도구 호출 실행
# 이전에 얻은 tool_call_results를 인자로 전달하여 함수를 실행합니다.
execute_tool_calls(tool_call_results)
====== 아래는 결과 =======
[실행도구] get_word_length [Argument] {'word': '바르셀로나'}
[실행결과] 5
for loop을 통해 정의된 도구 중 LLM이 생각했을때 사용할 수 있는 도구를 찾는 것이다.
이 함수를 포함하여 결과를 더 간단히 호출할 수 있는 방법은 다음과 같다.
from langchain_core.output_parsers.openai_tools import JsonOutputToolsParser
# bind_tools + Parser + Execution
chain = llm_with_tools | JsonOutputToolsParser(tools=tools) | execute_tool_calls
# 실행 결과
print(chain.invoke("What is the length of the word '바르셀로나'?"))
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_openai import ChatOpenAI
from langchain.agents import create_tool_calling_agent
from langchain.agents import AgentExecutor
# Agent 프롬프트 생성
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"You are very powerful assistant, but don't know current events",
),
("user", "{input}"),
MessagesPlaceholder(variable_name="agent_scratchpad"),
]
)
# 모델 생성
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [get_word_length, add_function]
agent = create_tool_calling_agent(llm, tools, prompt)
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True,
handle_parsing_errors=True,
)
# Agent 실행
result = agent_executor.invoke(
{"input": "114.5 + 121.2 + 34.2 + 110.1 의 계산 결과는?"}
)
====== 실행 결과 =======
> Entering new AgentExecutor chain...
Invoking: `add_function` with `{'a': 114.5, 'b': 121.2}`
235.7
Invoking: `add_function` with `{'a': 34.2, 'b': 110.1}`
144.3
Invoking: `add_function` with `{'a': 235.7, 'b': 144.3}`
380.0114.5 + 121.2 + 34.2 + 110.1의 계산 결과는 380.0입니다.
> Finished chain.
114.5 + 121.2 + 34.2 + 110.1의 계산 결과는 380.0입니다.
다음 코드에서 이해할 수 있는 것은 Agent와 AgentExecutor의 존재다.
이전까지 개발했던 것처럼 Agent는 llm에 tools를 제공하는 방식으로 구현될 수 있다. 하지만 Agent를 그 자체만으로 사용하는 것은 계속된 루프에 빠진다거나, 동작시간이 오래 걸릴 수 있다. 따라서 Executor에 담아 사용하는 것이 필요하다.
앞과 다른 것은 에이전트를 위한 프롬프트 형식을 작성해야 한다는 것인데, 다른 것은 채팅형의 LLM을 이용하는 것과 다른 것이 없으니 추가적인 설명을 하자면 MessagePlaceholder의 내용이 아닐까 싶다.
MessagePlaceholder의 variable_name에 "agent_scratchpad"라는 이름이 붙어 있는데, Agent가 복수의 턴의 대화를 받았을 때, 앞의 내용을 기억하기 위한 목적으로 사용되는 것으로 보인다.
이와 같은 방식으로 복수의 덧셈을 수행하는 프롬프트를 입력하면, 위의 결과와 같이 add_function이라는 함수를 선택해 복수의 계산을 수행하는 것을 확인할 수 있다.
상기 문서를 통해 Agent를 만들고 도구를 부여하는 방법을 확인할 수 있었다. 테디노트님 강의의 모든 것을 다 담은 글이 아니고, 글자 세는 부분에서는 바르셀로나로 예시를 바꿔 수행했다.
또 해당 부분을 langchain을 통해 간단히 수행하는 방법도 정리했다.
해당 부분을 정리하면서 생각한 지점은 다음과 같다.
docstring을 영어가 아닌 다른 언어로 바꿔서 표현하면 얼마나 Agent가 기능할까? 영어로 작성된 Agent와 성능 차이가 있을까?
얼마나 다양한 유즈케이스에 해당 Agent를 적용할 수 있을까? 또 적용하는데 얼마나 다양한 요소들이 고려되어야 하는가?
다음 글을 쓴다면 Agent를 사용하고 서비스에, 고려 할 만한 기능이다.